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}
+
+
+
+ | Username |
+ Full name |
+ Email |
+ Auth source |
+ Actions |
+
+
+
+ {#each users as user (user.id)}
+
+ | {user.username} |
+
+ {user.full_name || '—'}
+ |
+
+ {user.email || '—'}
+ |
+
+ {user.auth_source}
+ |
+
+
+
+
+
+ |
+
+ {/each}
+
+
+ {/if}
+
+
+