feat: implement user account management features including enable/disable functionality and real-time presence tracking
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
95
server/dashboard/src/lib/stores/presence.svelte.ts
Normal file
95
server/dashboard/src/lib/stores/presence.svelte.ts
Normal 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();
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
49
server/dashboard/src/routes/api/presence/+server.ts
Normal file
49
server/dashboard/src/routes/api/presence/+server.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
20
server/dashboard/src/routes/api/presence/state.ts
Normal file
20
server/dashboard/src/routes/api/presence/state.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 ?? ''
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user