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

@@ -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.session = session;
return resolve(event);

View File

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

View File

@@ -16,7 +16,8 @@ export const lucia = new Lucia(adapter, {
return {
username: attributes.username,
role: attributes.role,
displayname: attributes.displayname
displayname: attributes.displayname,
enabled: attributes.enabled
};
}
});
@@ -32,5 +33,6 @@ interface DatabaseUserAttributes {
username: string;
role: 'admin' | 'user';
displayname: string;
enabled: boolean;
}
// 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 { page } from '$app/stores';
import { enhance } from '$app/forms';
import { onMount, onDestroy } from 'svelte';
import { Bug, LayoutDashboard, Users, LogOut } from 'lucide-svelte';
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 { presence } from '$lib/stores/presence.svelte';
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>
{#if !data.user}
@@ -103,6 +120,37 @@
</h1>
</div>
<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}
<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"

View File

@@ -2,12 +2,13 @@
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
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 { Input } from '$lib/components/ui/input';
import * as Table from '$lib/components/ui/table';
import * as Select from '$lib/components/ui/select';
import * as Empty from '$lib/components/ui/empty';
import * as Dialog from '$lib/components/ui/dialog';
let { data } = $props();
@@ -222,3 +223,4 @@
</div>
{/if}
</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' });
}
if (!user.enabled) {
return fail(403, { message: 'Account is disabled' });
}
const session = await lucia.createSession(user.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies.set(sessionCookie.name, sessionCookie.value, {

View File

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

View File

@@ -10,20 +10,27 @@
Monitor,
Settings,
Database,
Mail
Mail,
Eye
} from 'lucide-svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import * as Card from '$lib/components/ui/card';
import * as Select from '$lib/components/ui/select';
import * as Table from '$lib/components/ui/table';
import * as Tooltip from '$lib/components/ui/tooltip';
import { Button } from '$lib/components/ui/button';
import { Textarea } from '$lib/components/ui/textarea';
import { presence } from '$lib/stores/presence.svelte';
let { data } = $props();
let showDeleteDialog = $state(false);
let statusUpdating = $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> = {
screenshot: Image,
mail_file: Mail,
@@ -90,6 +97,24 @@
</Card.Description>
</div>
<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 -->
<Select.Root
type="single"

View File

@@ -48,6 +48,7 @@ export const load: PageServerLoad = async ({ locals }) => {
username: userTable.username,
displayname: userTable.displayname,
role: userTable.role,
enabled: userTable.enabled,
createdAt: userTable.createdAt
})
.from(userTable)
@@ -181,6 +182,46 @@ export const actions: Actions = {
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 }) => {
if (!locals.user || locals.user.role !== 'admin') {
return fail(403, { message: 'Unauthorized' });

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
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 { Input } from '$lib/components/ui/input';
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">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">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">Actions</th>
</tr>
@@ -121,6 +122,15 @@
{user.role}
</span>
</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">
{user.createdAt ? formatDate(user.createdAt) : '—'}
</td>
@@ -129,6 +139,26 @@
<span class="text-xs text-muted-foreground">Current user</span>
{:else}
<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
variant="ghost"
size="icon"
@@ -164,7 +194,7 @@
</tr>
{:else}
<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.
</td>
</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 INDEX IF NOT EXISTS idx_hostname (hostname)`,
`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) {

View File

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