feat: implement user account management features including enable/disable functionality and real-time presence tracking
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
2
TODO.md
2
TODO.md
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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 '../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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
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' });
|
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, {
|
||||||
|
|||||||
@@ -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 ?? ''
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user