From 42fe36ca86c0f48462550d64c01d3d730d62aba6 Mon Sep 17 00:00:00 2001 From: AI Agent Date: Sun, 22 Feb 2026 04:26:16 +0000 Subject: [PATCH] admin) User management admin page (#35) Reviewed-on: https://cloud.campbellwireless.net/git/campbellwireless/trips/pulls/35 Reviewed-by: shaun Co-authored-by: AI Agent Co-committed-by: AI Agent --- src/auth.ts | 21 +- src/lib/components/AdminNavMenu.svelte | 9 + src/lib/server/db/migrations.ts | 12 + src/lib/server/travellers.ts | 3 + src/lib/server/users.test.ts | 102 +++++++ src/lib/server/users.ts | 138 +++++++++ .../(protected)/admin/api/users/+server.ts | 33 +++ .../(protected)/admin/users/+page.svelte | 267 ++++++++++++++++++ 8 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 src/lib/server/users.test.ts create mode 100644 src/lib/server/users.ts create mode 100644 src/routes/(protected)/admin/api/users/+server.ts create mode 100644 src/routes/(protected)/admin/users/+page.svelte diff --git a/src/auth.ts b/src/auth.ts index 0f66836..a73dbf9 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,5 +1,6 @@ import { SvelteKitAuth } from '@auth/sveltekit'; import { env } from '$env/dynamic/private'; +import { upsertUserFromAuth } from '$lib/server/users.js'; export const { handle, signIn, signOut } = SvelteKitAuth({ providers: [ @@ -23,7 +24,25 @@ export const { handle, signIn, signOut } = SvelteKitAuth({ trustHost: true, callbacks: { jwt({ token, profile }) { - if (profile?.sub) token.sub = profile.sub as string; + const details = profile as + | { + sub?: string; + name?: string; + email?: string; + username?: string; + preferred_username?: string; + } + | undefined; + if (details?.sub) { + token.sub = details.sub as string; + upsertUserFromAuth({ + id: details.sub, + username: details.username ?? details.preferred_username ?? details.name, + fullName: details.name ?? details.username ?? details.preferred_username, + email: details.email, + authSource: 'OIDC - Synology' + }); + } return token; }, session({ session, token }) { diff --git a/src/lib/components/AdminNavMenu.svelte b/src/lib/components/AdminNavMenu.svelte index 48b0100..58d54b6 100644 --- a/src/lib/components/AdminNavMenu.svelte +++ b/src/lib/components/AdminNavMenu.svelte @@ -42,6 +42,15 @@ General + + Users + +

Reference Data

diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index f573cdf..3e65b0f 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -53,6 +53,18 @@ export function runMigrations(db: Database): void { ) `); + db.run(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + full_name TEXT NOT NULL, + email TEXT, + auth_source TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + db.run(` CREATE TABLE IF NOT EXISTS cities ( id INTEGER PRIMARY KEY, diff --git a/src/lib/server/travellers.ts b/src/lib/server/travellers.ts index 3e8609a..9720d48 100644 --- a/src/lib/server/travellers.ts +++ b/src/lib/server/travellers.ts @@ -1,5 +1,6 @@ import { db } from './db/index.js'; import { randomUUID } from 'crypto'; +import { upsertUserProfile } from './users.js'; export interface Person { id: string; @@ -33,6 +34,7 @@ export function upsertSelfProfile( WHERE id = ?`, [firstName, lastName, email ?? null, existing.id] ); + upsertUserProfile(userId, `${firstName} ${lastName}`.trim(), email ?? null); return db.get('SELECT * FROM people WHERE id = ?', [existing.id])!; } const id = randomUUID(); @@ -41,6 +43,7 @@ export function upsertSelfProfile( VALUES (?, ?, ?, ?, ?, 1)`, [id, userId, firstName, lastName, email ?? null] ); + upsertUserProfile(userId, `${firstName} ${lastName}`.trim(), email ?? null); return db.get('SELECT * FROM people WHERE id = ?', [id])!; } diff --git a/src/lib/server/users.test.ts b/src/lib/server/users.test.ts new file mode 100644 index 0000000..263792a --- /dev/null +++ b/src/lib/server/users.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, afterEach, describe, expect, it } from 'vitest'; +import { setupTestDb } from '../../tests/helpers.js'; +import type { Database } from './db/types.js'; +import { + deleteUser, + listUsers, + updateUser, + upsertUserFromAuth, + upsertUserProfile +} from './users.js'; + +let database: Database; +beforeEach(() => { + database = setupTestDb(); +}); + +afterEach(() => { + database.close(); +}); + +describe('upsertUserFromAuth', () => { + it('creates a new user with profile data', () => { + const created = upsertUserFromAuth({ + id: 'u1', + username: 'jdoe', + fullName: 'Jane Doe', + email: 'jane@example.com', + authSource: 'OIDC - Synology' + }); + expect(created.id).toBe('u1'); + expect(created.username).toBe('jdoe'); + expect(created.full_name).toBe('Jane Doe'); + expect(created.email).toBe('jane@example.com'); + expect(created.auth_source).toBe('OIDC - Synology'); + expect(listUsers()).toHaveLength(1); + }); + + it('updates existing users with new auth data', () => { + upsertUserFromAuth({ + id: 'u1', + username: 'jdoe', + fullName: 'Jane Doe', + email: 'jane@example.com', + authSource: 'OIDC - Synology' + }); + const updated = upsertUserFromAuth({ + id: 'u1', + username: 'janed', + email: 'jane.doe@example.com' + }); + expect(updated.username).toBe('janed'); + expect(updated.email).toBe('jane.doe@example.com'); + }); +}); + +describe('upsertUserProfile', () => { + it('creates a user from profile details', () => { + const created = upsertUserProfile('u2', 'Sam Sample', 'sam@example.com'); + expect(created.id).toBe('u2'); + expect(created.full_name).toBe('Sam Sample'); + expect(created.email).toBe('sam@example.com'); + }); +}); + +describe('updateUser', () => { + it('updates user fields and syncs self profile when present', () => { + upsertUserFromAuth({ + id: 'u3', + username: 'sarah', + fullName: 'Sarah Lee', + email: 'sarah@example.com' + }); + database.run( + `INSERT INTO people (id, user_id, first_name, last_name, email, is_self) + VALUES (?, ?, ?, ?, ?, 1)`, + ['p1', 'u3', 'Sarah', 'Lee', 'sarah@example.com'] + ); + + updateUser({ id: 'u3', username: 'slee', fullName: 'Sarah Smith', email: 'ss@example.com' }); + + const user = listUsers().find((item) => item.id === 'u3'); + expect(user?.username).toBe('slee'); + expect(user?.full_name).toBe('Sarah Smith'); + expect(user?.email).toBe('ss@example.com'); + + const profile = database.get<{ first_name: string; last_name: string; email: string | null }>( + 'SELECT first_name, last_name, email FROM people WHERE user_id = ? AND is_self = 1', + ['u3'] + ); + expect(profile?.first_name).toBe('Sarah'); + expect(profile?.last_name).toBe('Smith'); + expect(profile?.email).toBe('ss@example.com'); + }); +}); + +describe('deleteUser', () => { + it('removes the user record', () => { + upsertUserFromAuth({ id: 'u4', username: 'delete-me', fullName: 'Delete Me' }); + deleteUser('u4'); + expect(listUsers().find((item) => item.id === 'u4')).toBeUndefined(); + }); +}); diff --git a/src/lib/server/users.ts b/src/lib/server/users.ts new file mode 100644 index 0000000..d22508f --- /dev/null +++ b/src/lib/server/users.ts @@ -0,0 +1,138 @@ +import { db } from './db/index.js'; + +export interface AppUser { + id: string; + username: string; + full_name: string; + email: string | null; + auth_source: string; + created_at: string; + updated_at: string; +} + +const DEFAULT_AUTH_SOURCE = 'OIDC - Synology'; + +const normalizeOptional = (value?: string | null): string | undefined => { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +}; + +const normalizeEmail = (value?: string | null): string | null | undefined => { + if (value === undefined) return undefined; + if (value === null) return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +}; + +export function listUsers(): AppUser[] { + return db.all( + `SELECT id, username, full_name, email, auth_source, created_at, updated_at + FROM users + ORDER BY username COLLATE NOCASE` + ); +} + +export function upsertUserFromAuth(input: { + id: string; + username?: string | null; + fullName?: string | null; + email?: string | null; + authSource?: string | null; +}): AppUser { + const existing = db.get('SELECT * FROM users WHERE id = ?', [input.id]); + const username = normalizeOptional(input.username) ?? existing?.username ?? input.id; + const fullName = normalizeOptional(input.fullName) ?? existing?.full_name ?? ''; + const email = normalizeOptional(input.email) ?? existing?.email ?? null; + const authSource = + normalizeOptional(input.authSource) ?? existing?.auth_source ?? DEFAULT_AUTH_SOURCE; + + if (existing) { + const setClauses: string[] = []; + const values: Array = []; + if (username !== existing.username) { + setClauses.push('username = ?'); + values.push(username); + } + if (fullName !== existing.full_name) { + setClauses.push('full_name = ?'); + values.push(fullName); + } + if (email !== existing.email) { + setClauses.push('email = ?'); + values.push(email); + } + if (authSource !== existing.auth_source) { + setClauses.push('auth_source = ?'); + values.push(authSource); + } + if (setClauses.length > 0) { + setClauses.push("updated_at = datetime('now')"); + values.push(input.id); + db.run(`UPDATE users SET ${setClauses.join(', ')} WHERE id = ?`, values); + } + } else { + db.run( + `INSERT INTO users (id, username, full_name, email, auth_source) + VALUES (?, ?, ?, ?, ?)`, + [input.id, username, fullName, email, authSource] + ); + } + return db.get('SELECT * FROM users WHERE id = ?', [input.id])!; +} + +export function upsertUserProfile( + userId: string, + fullName: string, + email?: string | null +): AppUser { + return upsertUserFromAuth({ id: userId, fullName, email, authSource: DEFAULT_AUTH_SOURCE }); +} + +export function updateUser(input: { + id: string; + username: string; + fullName: string; + email?: string | null; +}): void { + const existing = db.get<{ email: string | null }>('SELECT email FROM users WHERE id = ?', [ + input.id + ]); + const email = normalizeEmail(input.email); + const nextEmail = email === undefined ? existing?.email ?? null : email; + db.run( + `UPDATE users + SET username = ?, full_name = ?, email = ?, updated_at = datetime('now') + WHERE id = ?`, + [input.username.trim(), input.fullName.trim(), nextEmail, input.id] + ); + syncSelfProfile(input.id, input.fullName, nextEmail); +} + +export function deleteUser(id: string): void { + db.run('DELETE FROM users WHERE id = ?', [id]); +} + +function syncSelfProfile(userId: string, fullName: string, email?: string | null): void { + const profile = db.get<{ id: string; first_name: string; last_name: string; email: string | null }>( + `SELECT id, first_name, last_name, email FROM people WHERE user_id = ? AND is_self = 1 LIMIT 1`, + [userId] + ); + if (!profile) return; + + const trimmed = fullName.trim(); + let firstName = profile.first_name; + let lastName = profile.last_name; + if (trimmed) { + const parts = trimmed.split(/\s+/); + firstName = parts[0] ?? profile.first_name; + if (parts.length > 1) { + lastName = parts.slice(1).join(' '); + } + } + const nextEmail = email === undefined ? profile.email : email; + db.run( + `UPDATE people SET first_name = ?, last_name = ?, email = ?, updated_at = datetime('now') + WHERE id = ?`, + [firstName, lastName, nextEmail, profile.id] + ); +} diff --git a/src/routes/(protected)/admin/api/users/+server.ts b/src/routes/(protected)/admin/api/users/+server.ts new file mode 100644 index 0000000..f8257f1 --- /dev/null +++ b/src/routes/(protected)/admin/api/users/+server.ts @@ -0,0 +1,33 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAdmin } from '$lib/server/admin/auth.js'; +import { deleteUser, listUsers, updateUser } from '$lib/server/users.js'; + +export const GET: RequestHandler = async (event) => { + requireAdmin((await event.locals.auth())?.user?.id); + return json(listUsers()); +}; + +export const PATCH: RequestHandler = async (event) => { + requireAdmin((await event.locals.auth())?.user?.id); + const body = await event.request.json(); + const { id, username, full_name, email } = body as { + id?: string; + username?: string; + full_name?: string; + email?: string | null; + }; + if (!id?.trim() || !username?.trim() || !full_name?.trim()) { + return json({ error: 'id, username and full_name required' }, { status: 400 }); + } + updateUser({ id: id.trim(), username, fullName: full_name, email }); + return json({ ok: true }); +}; + +export const DELETE: RequestHandler = async (event) => { + requireAdmin((await event.locals.auth())?.user?.id); + const id = event.url.searchParams.get('id')?.trim(); + if (!id) return json({ error: 'id required' }, { status: 400 }); + deleteUser(id); + return json({ ok: true }); +}; diff --git a/src/routes/(protected)/admin/users/+page.svelte b/src/routes/(protected)/admin/users/+page.svelte new file mode 100644 index 0000000..fceb4c6 --- /dev/null +++ b/src/routes/(protected)/admin/users/+page.svelte @@ -0,0 +1,267 @@ + + + + Users — Admin — Trips + + +
+
+

Users

+

Manage application user accounts and access.

+
+ +
+ {#if error && !editing} +
+ {error} +
+ {/if} + {#if editing} +
+

Edit user

+ {#if error} +

{error}

+ {/if} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ {/if} + +
+ {#if loading} +

Loading...

+ {:else if users.length === 0} +

No users found.

+ {:else} + + + + + + + + + + + + {#each users as user (user.id)} + + + + + + + + {/each} + +
UsernameFull nameEmailAuth sourceActions
{user.username} + {user.full_name || '—'} + + {user.email || '—'} + + {user.auth_source} + +
+ + +
+
+ {/if} +
+
+