feat: implement user account management features including enable/disable functionality and real-time presence tracking

This commit is contained in:
Flavio Fois
2026-02-16 07:43:31 +01:00
parent a89b18d434
commit 894e8d9e51
19 changed files with 395 additions and 8 deletions

View File

@@ -1,5 +1,9 @@
# Changelog EMLy # Changelog EMLy
## 1.5.5 (2026-02-14)
1) Aggiunto il supporto al caricamento dei bug report su un server esterno, per facilitare la raccolta e gestione dei report da parte degli sviluppatori. (Con fallback locale in caso di errore)
2) Aggiunto il supporto alle mail con formato TNEF/winmail.dat, per estrarre gli allegati correttamente.
## 1.5.4 (2026-02-10) ## 1.5.4 (2026-02-10)
1) Aggiunti i pulsanti "Download" al MailViewer, PDF e Image viewer, per scaricare il file invece di aprirlo direttamente. 1) Aggiunti i pulsanti "Download" al MailViewer, PDF e Image viewer, per scaricare il file invece di aprirlo direttamente.
2) Refactor del sistema di bug report. 2) Refactor del sistema di bug report.

View File

@@ -962,6 +962,30 @@ In dev mode (`wails dev`):
--- ---
## Dashboard Features
### ZIP File Upload
The dashboard supports uploading `.zip` files created by EMLy's `SubmitBugReport` feature when the API upload fails. Accessible via the "Upload ZIP" button on the reports list page, it parses `report.txt` (name, email, description), `system_info.txt` (hostname, OS, HWID, IP), and imports all attached files (screenshots, mail files, localStorage, config) into the database as a new bug report.
**API Endpoint**: `POST /api/reports/upload` - Accepts multipart form data with a `.zip` file.
### User Enable/Disable
Admins can temporarily disable user accounts without deleting them. Disabled users cannot log in and active sessions are invalidated. The `user` table has an `enabled` BOOLEAN column (default TRUE). Toggle is available in the Users management page. Restrictions: admins cannot disable themselves or other admin users.
### Active Users / Presence Tracking
Real-time presence tracking using Server-Sent Events (SSE). Connected users are tracked in-memory with heartbeat updates every 15 seconds. The layout header shows avatar indicators for other active users with tooltips showing what they're viewing. The report detail page shows who else is currently viewing the same report.
**Endpoints**:
- `GET /api/presence` - SSE stream for real-time presence updates
- `POST /api/presence/heartbeat` - Client heartbeat with current page/report info
**Client Store**: `$lib/stores/presence.svelte.ts` - Svelte 5 reactive store managing SSE connection and heartbeats.
---
## License & Credits ## License & Credits
EMLy is developed by FOISX @ 3gIT. EMLy is developed by FOISX @ 3gIT.

View File

@@ -6,7 +6,7 @@
# Existing Features # Existing Features
- [ ] Add seperated "Updater" binary, that will start on User login (via Scheduled Task), with a silent install mode. - [ ] Add seperated "Updater" binary, that will start on User login (via Scheduled Task), with a silent install mode.
- [x] Attach localStorage, config file to the "Bug Reporter" ZIP file, to investigate the issue with the user enviroment. - [x] Attach localStorage, config file to the "Bug Reporter" ZIP file, to investigate the issue with the user enviroment.
- [ ] Auto-send the "Bug Reporter" ZIP file to the support team, to investigate the issue with the user enviroment. - [x] Auto-send the "Bug Reporter" ZIP file to the support team, to investigate the issue with the user enviroment.
# Bugs # Bugs
- [ ] Missing i18n for Toast notifications (to investigate) - [ ] Missing i18n for Toast notifications (to investigate)

View File

@@ -28,6 +28,19 @@ export const handle: Handle = async ({ event, resolve }) => {
}); });
} }
// If user is disabled, invalidate their session and clear cookie
if (session && user && !user.enabled) {
await lucia.invalidateSession(session.id);
const sessionCookie = lucia.createBlankSessionCookie();
event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: '.',
...sessionCookie.attributes
});
event.locals.user = null;
event.locals.session = null;
return resolve(event);
}
event.locals.user = user; event.locals.user = user;
event.locals.session = session; event.locals.session = session;
return resolve(event); return resolve(event);

View File

@@ -7,6 +7,7 @@ import {
mysqlEnum, mysqlEnum,
timestamp, timestamp,
datetime, datetime,
boolean,
customType customType
} from 'drizzle-orm/mysql-core'; } from 'drizzle-orm/mysql-core';
@@ -56,6 +57,7 @@ export const userTable = mysqlTable('user', {
displayname: varchar('displayname', { length: 255 }).notNull().default(''), displayname: varchar('displayname', { length: 255 }).notNull().default(''),
passwordHash: varchar('password_hash', { length: 255 }).notNull(), passwordHash: varchar('password_hash', { length: 255 }).notNull(),
role: mysqlEnum('role', ['admin', 'user']).notNull().default('user'), role: mysqlEnum('role', ['admin', 'user']).notNull().default('user'),
enabled: boolean('enabled').notNull().default(true),
createdAt: timestamp('created_at').notNull().defaultNow() createdAt: timestamp('created_at').notNull().defaultNow()
}); });

View File

@@ -16,7 +16,8 @@ export const lucia = new Lucia(adapter, {
return { return {
username: attributes.username, username: attributes.username,
role: attributes.role, role: attributes.role,
displayname: attributes.displayname displayname: attributes.displayname,
enabled: attributes.enabled
}; };
} }
}); });
@@ -32,5 +33,6 @@ interface DatabaseUserAttributes {
username: string; username: string;
role: 'admin' | 'user'; role: 'admin' | 'user';
displayname: string; displayname: string;
enabled: boolean;
} }
// End of file // End of file

View File

@@ -0,0 +1,95 @@
import { browser } from '$app/environment';
import { page } from '$app/stores';
export interface ActiveUser {
userId: string;
username: string;
displayname: string;
currentPath: string;
reportId: number | null;
lastSeen: number;
}
class PresenceStore {
activeUsers = $state<ActiveUser[]>([]);
connected = $state(false);
private eventSource: EventSource | null = null;
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
private currentPath = '/';
private unsubscribePage: (() => void) | null = null;
connect() {
if (!browser || this.eventSource) return;
this.eventSource = new EventSource('/api/presence');
this.eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.activeUsers = data;
} catch {
// ignore parse errors
}
};
this.eventSource.onopen = () => {
this.connected = true;
};
this.eventSource.onerror = () => {
this.connected = false;
// EventSource auto-reconnects
};
// Track current page and send heartbeats
this.unsubscribePage = page.subscribe((p) => {
this.currentPath = p.url.pathname;
});
// Send heartbeat every 15 seconds
this.sendHeartbeat();
this.heartbeatInterval = setInterval(() => this.sendHeartbeat(), 15000);
}
disconnect() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
if (this.unsubscribePage) {
this.unsubscribePage();
this.unsubscribePage = null;
}
this.connected = false;
this.activeUsers = [];
}
private async sendHeartbeat() {
try {
const reportMatch = this.currentPath.match(/^\/reports\/(\d+)/);
const reportId = reportMatch ? Number(reportMatch[1]) : null;
await fetch('/api/presence/heartbeat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
currentPath: this.currentPath,
reportId
})
});
} catch {
// ignore heartbeat failures
}
}
getViewersForReport(reportId: number): ActiveUser[] {
return this.activeUsers.filter((u) => u.reportId === reportId);
}
}
export const presence = new PresenceStore();

View File

@@ -2,11 +2,28 @@
import '../app.css'; import '../app.css';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { onMount, onDestroy } from 'svelte';
import { Bug, LayoutDashboard, Users, LogOut } from 'lucide-svelte'; import { Bug, LayoutDashboard, Users, LogOut } from 'lucide-svelte';
import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Tooltip from '$lib/components/ui/tooltip';
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import { presence } from '$lib/stores/presence.svelte';
let { children, data } = $props(); let { children, data } = $props();
const otherActiveUsers = $derived(
presence.activeUsers.filter((u) => u.userId !== data.user?.id)
);
onMount(() => {
if (data.user) {
presence.connect();
}
});
onDestroy(() => {
presence.disconnect();
});
</script> </script>
{#if !data.user} {#if !data.user}
@@ -103,6 +120,37 @@
</h1> </h1>
</div> </div>
<div class="flex items-center gap-2 px-4"> <div class="flex items-center gap-2 px-4">
{#if otherActiveUsers.length > 0}
<div class="flex items-center gap-1.5">
{#each otherActiveUsers.slice(0, 5) as activeUser}
<Tooltip.Root>
<Tooltip.Trigger>
<div class="relative flex h-7 w-7 items-center justify-center rounded-full bg-green-500/20 text-xs font-medium text-green-400 border border-green-500/30">
{(activeUser.displayname || activeUser.username).charAt(0).toUpperCase()}
<span class="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-green-500 border-2 border-background"></span>
</div>
</Tooltip.Trigger>
<Tooltip.Content>
<p class="font-medium">{activeUser.displayname || activeUser.username}</p>
<p class="text-xs text-muted-foreground">
{#if activeUser.reportId}
Viewing Report #{activeUser.reportId}
{:else if activeUser.currentPath === '/users'}
User Management
{:else if activeUser.currentPath === '/'}
Reports List
{:else}
{activeUser.currentPath}
{/if}
</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
{#if otherActiveUsers.length > 5}
<span class="text-xs text-muted-foreground">+{otherActiveUsers.length - 5}</span>
{/if}
</div>
{/if}
{#if data.newCount > 0} {#if data.newCount > 0}
<div <div
class="ml-4 flex items-center gap-2 rounded-md bg-blue-500/10 px-3 py-1.5 text-sm text-blue-400" class="ml-4 flex items-center gap-2 rounded-md bg-blue-500/10 px-3 py-1.5 text-sm text-blue-400"

View File

@@ -2,12 +2,13 @@
import { goto, invalidateAll } from '$app/navigation'; import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { statusColors, statusLabels, formatDate } from '$lib/utils'; import { statusColors, statusLabels, formatDate } from '$lib/utils';
import { Search, ChevronLeft, ChevronRight, Filter, Paperclip, RefreshCcw, Inbox } from 'lucide-svelte'; import { Search, ChevronLeft, ChevronRight, Filter, Paperclip, RefreshCcw, Inbox, Upload } from 'lucide-svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import * as Select from '$lib/components/ui/select'; import * as Select from '$lib/components/ui/select';
import * as Empty from '$lib/components/ui/empty'; import * as Empty from '$lib/components/ui/empty';
import * as Dialog from '$lib/components/ui/dialog';
let { data } = $props(); let { data } = $props();
@@ -222,3 +223,4 @@
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -0,0 +1,49 @@
import type { RequestHandler } from './$types';
import { presenceMap, sseClients, broadcastPresence } from './state';
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user) {
return new Response('Unauthorized', { status: 401 });
}
const userId = locals.user.id;
const stream = new ReadableStream({
start(controller) {
const clientId = `${userId}-${Date.now()}`;
sseClients.set(clientId, controller);
// Send current state immediately
const users = Array.from(presenceMap.values()).filter(
(u) => Date.now() - u.lastSeen < 60000
);
controller.enqueue(`data: ${JSON.stringify(users)}\n\n`);
// Cleanup on close
const cleanup = () => {
sseClients.delete(clientId);
presenceMap.delete(userId);
broadcastPresence();
};
// Use a heartbeat to detect disconnection
const keepAlive = setInterval(() => {
try {
controller.enqueue(': keepalive\n\n');
} catch {
cleanup();
clearInterval(keepAlive);
}
}, 30000);
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
}
});
};

View File

@@ -0,0 +1,25 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { presenceMap, broadcastPresence } from '../state';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
error(401, 'Unauthorized');
}
const body = await request.json();
const { currentPath, reportId } = body;
presenceMap.set(locals.user.id, {
userId: locals.user.id,
username: locals.user.username,
displayname: locals.user.displayname,
currentPath: currentPath || '/',
reportId: reportId || null,
lastSeen: Date.now()
});
broadcastPresence();
return json({ ok: true });
};

View File

@@ -0,0 +1,20 @@
import type { ActiveUser } from '$lib/stores/presence.svelte';
// In-memory presence tracking - shared between SSE and heartbeat endpoints
export const presenceMap = new Map<string, ActiveUser>();
// SSE client connections
export const sseClients = new Map<string, ReadableStreamDefaultController>();
export function broadcastPresence() {
const users = Array.from(presenceMap.values()).filter((u) => Date.now() - u.lastSeen < 60000);
const data = `data: ${JSON.stringify(users)}\n\n`;
for (const [clientId, controller] of sseClients.entries()) {
try {
controller.enqueue(data);
} catch {
sseClients.delete(clientId);
}
}
}

View File

@@ -47,6 +47,10 @@ export const actions: Actions = {
return fail(400, { message: 'Invalid username or password' }); return fail(400, { message: 'Invalid username or password' });
} }
if (!user.enabled) {
return fail(403, { message: 'Account is disabled' });
}
const session = await lucia.createSession(user.id, {}); const session = await lucia.createSession(user.id, {});
const sessionCookie = lucia.createSessionCookie(session.id); const sessionCookie = lucia.createSessionCookie(session.id);
cookies.set(sessionCookie.name, sessionCookie.value, { cookies.set(sessionCookie.name, sessionCookie.value, {

View File

@@ -4,7 +4,7 @@ import { bugReports, bugReportFiles } from '$lib/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params }) => { export const load: PageServerLoad = async ({ params, locals }) => {
const id = Number(params.id); const id = Number(params.id);
if (isNaN(id)) throw error(400, 'Invalid report ID'); if (isNaN(id)) throw error(400, 'Invalid report ID');
@@ -39,6 +39,7 @@ export const load: PageServerLoad = async ({ params }) => {
files: files.map((f) => ({ files: files.map((f) => ({
...f, ...f,
created_at: f.created_at.toISOString() created_at: f.created_at.toISOString()
})) })),
currentUserId: locals.user?.id ?? ''
}; };
}; };

View File

@@ -10,20 +10,27 @@
Monitor, Monitor,
Settings, Settings,
Database, Database,
Mail Mail,
Eye
} from 'lucide-svelte'; } from 'lucide-svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog'; import * as AlertDialog from '$lib/components/ui/alert-dialog';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import * as Select from '$lib/components/ui/select'; import * as Select from '$lib/components/ui/select';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import * as Tooltip from '$lib/components/ui/tooltip';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Textarea } from '$lib/components/ui/textarea'; import { Textarea } from '$lib/components/ui/textarea';
import { presence } from '$lib/stores/presence.svelte';
let { data } = $props(); let { data } = $props();
let showDeleteDialog = $state(false); let showDeleteDialog = $state(false);
let statusUpdating = $state(false); let statusUpdating = $state(false);
let deleting = $state(false); let deleting = $state(false);
const otherViewers = $derived(
presence.getViewersForReport(data.report.id).filter((u) => u.userId !== data.currentUserId)
);
const roleIcons: Record<string, typeof FileText> = { const roleIcons: Record<string, typeof FileText> = {
screenshot: Image, screenshot: Image,
mail_file: Mail, mail_file: Mail,
@@ -90,6 +97,24 @@
</Card.Description> </Card.Description>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if otherViewers.length > 0}
<div class="flex items-center gap-1.5 mr-2">
<Eye class="h-4 w-4 text-muted-foreground" />
{#each otherViewers as viewer}
<Tooltip.Root>
<Tooltip.Trigger>
<div class="relative flex h-6 w-6 items-center justify-center rounded-full bg-green-500/20 text-xs font-medium text-green-400 border border-green-500/30">
{(viewer.displayname || viewer.username).charAt(0).toUpperCase()}
<span class="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full bg-green-500 border border-background"></span>
</div>
</Tooltip.Trigger>
<Tooltip.Content>
{viewer.displayname || viewer.username} is viewing this report
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
{/if}
<!-- Status selector --> <!-- Status selector -->
<Select.Root <Select.Root
type="single" type="single"

View File

@@ -48,6 +48,7 @@ export const load: PageServerLoad = async ({ locals }) => {
username: userTable.username, username: userTable.username,
displayname: userTable.displayname, displayname: userTable.displayname,
role: userTable.role, role: userTable.role,
enabled: userTable.enabled,
createdAt: userTable.createdAt createdAt: userTable.createdAt
}) })
.from(userTable) .from(userTable)
@@ -181,6 +182,46 @@ export const actions: Actions = {
return { success: true }; return { success: true };
}, },
toggleEnabled: async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
return fail(403, { message: 'Unauthorized' });
}
const formData = await request.formData();
const userId = formData.get('userId');
if (typeof userId !== 'string') {
return fail(400, { message: 'Invalid input' });
}
// Cannot disable yourself
if (userId === locals.user.id) {
return fail(400, { message: 'Cannot disable your own account' });
}
// Cannot disable other admins
const [targetUser] = await db
.select({ role: userTable.role, enabled: userTable.enabled })
.from(userTable)
.where(eq(userTable.id, userId))
.limit(1);
if (!targetUser) {
return fail(404, { message: 'User not found' });
}
if (targetUser.role === 'admin') {
return fail(400, { message: 'Cannot disable an admin user' });
}
await db
.update(userTable)
.set({ enabled: !targetUser.enabled })
.where(eq(userTable.id, userId));
return { success: true };
},
delete: async ({ request, locals }) => { delete: async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') { if (!locals.user || locals.user.role !== 'admin') {
return fail(403, { message: 'Unauthorized' }); return fail(403, { message: 'Unauthorized' });

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { formatDate } from '$lib/utils'; import { formatDate } from '$lib/utils';
import { Trash2, UserPlus, Pencil, KeyRound, Check, X } from 'lucide-svelte'; import { Trash2, UserPlus, Pencil, KeyRound, Check, X, ShieldOff, ShieldCheck } from 'lucide-svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
@@ -102,6 +102,7 @@
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Username</th> <th class="px-4 py-3 text-left font-medium text-muted-foreground">Username</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Display Name</th> <th class="px-4 py-3 text-left font-medium text-muted-foreground">Display Name</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Role</th> <th class="px-4 py-3 text-left font-medium text-muted-foreground">Role</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Created</th> <th class="px-4 py-3 text-left font-medium text-muted-foreground">Created</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Actions</th> <th class="px-4 py-3 text-left font-medium text-muted-foreground">Actions</th>
</tr> </tr>
@@ -121,6 +122,15 @@
{user.role} {user.role}
</span> </span>
</td> </td>
<td class="px-4 py-3">
<span
class="inline-flex rounded-full border px-2 py-0.5 text-xs font-medium {user.enabled
? 'bg-green-500/20 text-green-400 border-green-500/30'
: 'bg-red-500/20 text-red-400 border-red-500/30'}"
>
{user.enabled ? 'Enabled' : 'Disabled'}
</span>
</td>
<td class="px-4 py-3 text-muted-foreground"> <td class="px-4 py-3 text-muted-foreground">
{user.createdAt ? formatDate(user.createdAt) : '—'} {user.createdAt ? formatDate(user.createdAt) : '—'}
</td> </td>
@@ -129,6 +139,26 @@
<span class="text-xs text-muted-foreground">Current user</span> <span class="text-xs text-muted-foreground">Current user</span>
{:else} {:else}
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
{#if user.role !== 'admin'}
<form method="POST" action="?/toggleEnabled" use:enhance>
<input type="hidden" name="userId" value={user.id} />
<Button
type="submit"
variant="ghost"
size="icon"
class="h-8 w-8 {user.enabled
? 'text-orange-400 hover:text-orange-500 hover:bg-orange-500/10'
: 'text-green-400 hover:text-green-500 hover:bg-green-500/10'}"
title={user.enabled ? 'Disable User' : 'Enable User'}
>
{#if user.enabled}
<ShieldOff class="h-4 w-4" />
{:else}
<ShieldCheck class="h-4 w-4" />
{/if}
</Button>
</form>
{/if}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -164,7 +194,7 @@
</tr> </tr>
{:else} {:else}
<tr> <tr>
<td colspan="5" class="px-4 py-12 text-center text-muted-foreground"> <td colspan="6" class="px-4 py-12 text-center text-muted-foreground">
No users found. No users found.
</td> </td>
</tr> </tr>

View File

@@ -25,6 +25,7 @@ export async function runMigrations(): Promise<void> {
`ALTER TABLE bug_reports ADD COLUMN IF NOT EXISTS os_user VARCHAR(255) NOT NULL DEFAULT '' AFTER hostname`, `ALTER TABLE bug_reports ADD COLUMN IF NOT EXISTS os_user VARCHAR(255) NOT NULL DEFAULT '' AFTER hostname`,
`ALTER TABLE bug_reports ADD INDEX IF NOT EXISTS idx_hostname (hostname)`, `ALTER TABLE bug_reports ADD INDEX IF NOT EXISTS idx_hostname (hostname)`,
`ALTER TABLE bug_reports ADD INDEX IF NOT EXISTS idx_os_user (os_user)`, `ALTER TABLE bug_reports ADD INDEX IF NOT EXISTS idx_os_user (os_user)`,
`ALTER TABLE user ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT TRUE AFTER role`,
]; ];
for (const migration of alterMigrations) { for (const migration of alterMigrations) {

View File

@@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS `user` (
`username` VARCHAR(255) NOT NULL UNIQUE, `username` VARCHAR(255) NOT NULL UNIQUE,
`password_hash` VARCHAR(255) NOT NULL, `password_hash` VARCHAR(255) NOT NULL,
`role` ENUM('admin', 'user') NOT NULL DEFAULT 'user', `role` ENUM('admin', 'user') NOT NULL DEFAULT 'user',
`enabled` BOOLEAN NOT NULL DEFAULT TRUE,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`displayname` VARCHAR(255) NOT NULL DEFAULT '' `displayname` VARCHAR(255) NOT NULL DEFAULT ''
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;