From 894e8d9e5186ad6930d833ba11354168dbe9c944 Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Mon, 16 Feb 2026 07:43:31 +0100 Subject: [PATCH] feat: implement user account management features including enable/disable functionality and real-time presence tracking --- CHANGELOG.md | 4 + DOCUMENTATION.md | 24 +++++ TODO.md | 2 +- server/dashboard/src/hooks.server.ts | 13 +++ server/dashboard/src/lib/schema.ts | 2 + server/dashboard/src/lib/server/auth.ts | 4 +- .../src/lib/stores/presence.svelte.ts | 95 +++++++++++++++++++ server/dashboard/src/routes/+layout.svelte | 48 ++++++++++ server/dashboard/src/routes/+page.svelte | 4 +- .../src/routes/api/presence/+server.ts | 49 ++++++++++ .../routes/api/presence/heartbeat/+server.ts | 25 +++++ .../src/routes/api/presence/state.ts | 20 ++++ .../src/routes/login/+page.server.ts | 4 + .../src/routes/reports/[id]/+page.server.ts | 5 +- .../src/routes/reports/[id]/+page.svelte | 27 +++++- .../src/routes/users/+page.server.ts | 41 ++++++++ .../dashboard/src/routes/users/+page.svelte | 34 ++++++- server/src/db/migrate.ts | 1 + server/src/db/schema.sql | 1 + 19 files changed, 395 insertions(+), 8 deletions(-) create mode 100644 server/dashboard/src/lib/stores/presence.svelte.ts create mode 100644 server/dashboard/src/routes/api/presence/+server.ts create mode 100644 server/dashboard/src/routes/api/presence/heartbeat/+server.ts create mode 100644 server/dashboard/src/routes/api/presence/state.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f5f4342..3a4dab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 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) 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. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 2e3c66b..2c6eedb 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -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 EMLy is developed by FOISX @ 3gIT. \ No newline at end of file diff --git a/TODO.md b/TODO.md index eeb9f39..00bdda1 100644 --- a/TODO.md +++ b/TODO.md @@ -6,7 +6,7 @@ # Existing Features - [ ] 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. -- [ ] 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 - [ ] Missing i18n for Toast notifications (to investigate) diff --git a/server/dashboard/src/hooks.server.ts b/server/dashboard/src/hooks.server.ts index 474ba4a..773bb01 100644 --- a/server/dashboard/src/hooks.server.ts +++ b/server/dashboard/src/hooks.server.ts @@ -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); diff --git a/server/dashboard/src/lib/schema.ts b/server/dashboard/src/lib/schema.ts index 9d946eb..df3e117 100644 --- a/server/dashboard/src/lib/schema.ts +++ b/server/dashboard/src/lib/schema.ts @@ -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() }); diff --git a/server/dashboard/src/lib/server/auth.ts b/server/dashboard/src/lib/server/auth.ts index 590e6b0..cf3b188 100644 --- a/server/dashboard/src/lib/server/auth.ts +++ b/server/dashboard/src/lib/server/auth.ts @@ -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 diff --git a/server/dashboard/src/lib/stores/presence.svelte.ts b/server/dashboard/src/lib/stores/presence.svelte.ts new file mode 100644 index 0000000..87250fa --- /dev/null +++ b/server/dashboard/src/lib/stores/presence.svelte.ts @@ -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([]); + connected = $state(false); + + private eventSource: EventSource | null = null; + private heartbeatInterval: ReturnType | 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(); diff --git a/server/dashboard/src/routes/+layout.svelte b/server/dashboard/src/routes/+layout.svelte index 56ada1d..7c0c80c 100644 --- a/server/dashboard/src/routes/+layout.svelte +++ b/server/dashboard/src/routes/+layout.svelte @@ -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(); + }); {#if !data.user} @@ -103,6 +120,37 @@
+ {#if otherActiveUsers.length > 0} +
+ {#each otherActiveUsers.slice(0, 5) as activeUser} + + +
+ {(activeUser.displayname || activeUser.username).charAt(0).toUpperCase()} + +
+
+ +

{activeUser.displayname || activeUser.username}

+

+ {#if activeUser.reportId} + Viewing Report #{activeUser.reportId} + {:else if activeUser.currentPath === '/users'} + User Management + {:else if activeUser.currentPath === '/'} + Reports List + {:else} + {activeUser.currentPath} + {/if} +

+
+
+ {/each} + {#if otherActiveUsers.length > 5} + +{otherActiveUsers.length - 5} + {/if} +
+ {/if} {#if data.newCount > 0}
{/if}
+ diff --git a/server/dashboard/src/routes/api/presence/+server.ts b/server/dashboard/src/routes/api/presence/+server.ts new file mode 100644 index 0000000..ff88606 --- /dev/null +++ b/server/dashboard/src/routes/api/presence/+server.ts @@ -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' + } + }); +}; diff --git a/server/dashboard/src/routes/api/presence/heartbeat/+server.ts b/server/dashboard/src/routes/api/presence/heartbeat/+server.ts new file mode 100644 index 0000000..6b12a07 --- /dev/null +++ b/server/dashboard/src/routes/api/presence/heartbeat/+server.ts @@ -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 }); +}; diff --git a/server/dashboard/src/routes/api/presence/state.ts b/server/dashboard/src/routes/api/presence/state.ts new file mode 100644 index 0000000..5b274bb --- /dev/null +++ b/server/dashboard/src/routes/api/presence/state.ts @@ -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(); + +// SSE client connections +export const sseClients = new Map(); + +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); + } + } +} diff --git a/server/dashboard/src/routes/login/+page.server.ts b/server/dashboard/src/routes/login/+page.server.ts index fc52c9c..c1ca5af 100644 --- a/server/dashboard/src/routes/login/+page.server.ts +++ b/server/dashboard/src/routes/login/+page.server.ts @@ -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, { diff --git a/server/dashboard/src/routes/reports/[id]/+page.server.ts b/server/dashboard/src/routes/reports/[id]/+page.server.ts index 83dea88..50cfe11 100644 --- a/server/dashboard/src/routes/reports/[id]/+page.server.ts +++ b/server/dashboard/src/routes/reports/[id]/+page.server.ts @@ -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 ?? '' }; }; diff --git a/server/dashboard/src/routes/reports/[id]/+page.svelte b/server/dashboard/src/routes/reports/[id]/+page.svelte index 9a9e713..25a4e2e 100644 --- a/server/dashboard/src/routes/reports/[id]/+page.svelte +++ b/server/dashboard/src/routes/reports/[id]/+page.svelte @@ -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 = { screenshot: Image, mail_file: Mail, @@ -90,6 +97,24 @@
+ {#if otherViewers.length > 0} +
+ + {#each otherViewers as viewer} + + +
+ {(viewer.displayname || viewer.username).charAt(0).toUpperCase()} + +
+
+ + {viewer.displayname || viewer.username} is viewing this report + +
+ {/each} +
+ {/if} { 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' }); diff --git a/server/dashboard/src/routes/users/+page.svelte b/server/dashboard/src/routes/users/+page.svelte index 2459172..d7172c9 100644 --- a/server/dashboard/src/routes/users/+page.svelte +++ b/server/dashboard/src/routes/users/+page.svelte @@ -1,7 +1,7 @@