From 5c7bae61b56291e1bb589cd1e8f77f00566e91f5 Mon Sep 17 00:00:00 2001 From: AI Agent Date: Sun, 22 Feb 2026 19:36:11 +0000 Subject: [PATCH 1/7] admin) manage local login users --- src/lib/server/users.test.ts | 49 +++ src/lib/server/users.ts | 53 +++ .../(protected)/admin/api/users/+server.ts | 30 +- .../api/users/local-credentials/+server.ts | 22 ++ .../(protected)/admin/users/+page.svelte | 328 +++++++++++++++++- 5 files changed, 478 insertions(+), 4 deletions(-) create mode 100644 src/routes/(protected)/admin/api/users/local-credentials/+server.ts diff --git a/src/lib/server/users.test.ts b/src/lib/server/users.test.ts index 263792a..3be7e60 100644 --- a/src/lib/server/users.test.ts +++ b/src/lib/server/users.test.ts @@ -2,8 +2,10 @@ import { beforeEach, afterEach, describe, expect, it } from 'vitest'; import { setupTestDb } from '../../tests/helpers.js'; import type { Database } from './db/types.js'; import { + createLocalUser, deleteUser, listUsers, + setLocalPasswordForUser, updateUser, upsertUserFromAuth, upsertUserProfile @@ -100,3 +102,50 @@ describe('deleteUser', () => { expect(listUsers().find((item) => item.id === 'u4')).toBeUndefined(); }); }); + +describe('createLocalUser', () => { + it('creates a local user with credentials', async () => { + const created = await createLocalUser({ + username: 'local-user', + fullName: 'Local User', + email: 'local@example.com', + password: 'averysecurepassword' + }); + expect(created.username).toBe('local-user'); + expect(created.auth_source).toBe('Local'); + const row = database.get<{ user_id: string; password_hash: string }>( + 'SELECT user_id, password_hash FROM local_credentials WHERE user_id = ?', + [created.id] + ); + expect(row?.user_id).toBe(created.id); + expect(row?.password_hash).toBeTruthy(); + }); + + it('rejects duplicate usernames', async () => { + await createLocalUser({ + username: 'dup-user', + fullName: 'Dup User', + password: 'averysecurepassword' + }); + await expect( + createLocalUser({ + username: 'dup-user', + fullName: 'Dup User Two', + password: 'averysecurepassword' + }) + ).rejects.toThrow('Username already exists'); + }); +}); + +describe('setLocalPasswordForUser', () => { + it('sets local credentials for an existing user', async () => { + upsertUserFromAuth({ id: 'u5', username: 'sarah', fullName: 'Sarah Lee' }); + await setLocalPasswordForUser('u5', 'averysecurepassword'); + const row = database.get<{ user_id: string; password_hash: string }>( + 'SELECT user_id, password_hash FROM local_credentials WHERE user_id = ?', + ['u5'] + ); + expect(row?.user_id).toBe('u5'); + expect(row?.password_hash).toBeTruthy(); + }); +}); diff --git a/src/lib/server/users.ts b/src/lib/server/users.ts index d22508f..99ba306 100644 --- a/src/lib/server/users.ts +++ b/src/lib/server/users.ts @@ -1,4 +1,6 @@ +import { randomUUID } from 'crypto'; import { db } from './db/index.js'; +import { setLocalCredentialPassword } from './local-credentials.js'; export interface AppUser { id: string; @@ -11,6 +13,7 @@ export interface AppUser { } const DEFAULT_AUTH_SOURCE = 'OIDC - Synology'; +const LOCAL_AUTH_SOURCE = 'Local'; const normalizeOptional = (value?: string | null): string | undefined => { const trimmed = value?.trim(); @@ -112,6 +115,56 @@ export function deleteUser(id: string): void { db.run('DELETE FROM users WHERE id = ?', [id]); } +export async function createLocalUser(input: { + username: string; + fullName: string; + email?: string | null; + password: string; +}): Promise { + const username = input.username?.trim() ?? ''; + const fullName = input.fullName?.trim() ?? ''; + if (!username || !fullName) { + throw new Error('Username and full name are required'); + } + const email = normalizeEmail(input.email); + const existingUsername = db.get<{ id: string }>( + 'SELECT id FROM users WHERE lower(username) = lower(?)', + [username] + ); + if (existingUsername) { + throw new Error('Username already exists'); + } + if (email) { + const existingEmail = db.get<{ id: string }>( + 'SELECT id FROM users WHERE lower(email) = lower(?)', + [email] + ); + if (existingEmail) { + throw new Error('Email already exists'); + } + } + const id = randomUUID(); + db.run( + `INSERT INTO users (id, username, full_name, email, auth_source) + VALUES (?, ?, ?, ?, ?)`, + [id, username, fullName, email ?? null, LOCAL_AUTH_SOURCE] + ); + await setLocalCredentialPassword(id, input.password); + return db.get('SELECT * FROM users WHERE id = ?', [id])!; +} + +export async function setLocalPasswordForUser(userId: string, password: string): Promise { + const id = userId?.trim() ?? ''; + if (!id) { + throw new Error('User id required'); + } + const existing = db.get<{ id: string }>('SELECT id FROM users WHERE id = ?', [id]); + if (!existing) { + throw new Error('User not found'); + } + await setLocalCredentialPassword(id, password); +} + 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`, diff --git a/src/routes/(protected)/admin/api/users/+server.ts b/src/routes/(protected)/admin/api/users/+server.ts index 378fb6b..c977c96 100644 --- a/src/routes/(protected)/admin/api/users/+server.ts +++ b/src/routes/(protected)/admin/api/users/+server.ts @@ -1,7 +1,7 @@ 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'; +import { createLocalUser, deleteUser, listUsers, updateUser } from '$lib/server/users.js'; export const GET: RequestHandler = async (event) => { requireAdmin((await event.locals.auth())?.user); @@ -24,6 +24,34 @@ export const PATCH: RequestHandler = async (event) => { return json({ ok: true }); }; +export const POST: RequestHandler = async (event) => { + requireAdmin((await event.locals.auth())?.user); + const body = await event.request.json(); + const { username, full_name, email, password } = body as { + username?: string; + full_name?: string; + email?: string | null; + password?: string; + }; + if (!username?.trim() || !full_name?.trim() || !password?.trim()) { + return json({ error: 'username, full_name and password required' }, { status: 400 }); + } + try { + const user = await createLocalUser({ + username: username.trim(), + fullName: full_name.trim(), + email, + password + }); + return json(user); + } catch (error) { + return json( + { error: error instanceof Error ? error.message : 'Failed to create user' }, + { status: 400 } + ); + } +}; + export const DELETE: RequestHandler = async (event) => { requireAdmin((await event.locals.auth())?.user); const id = event.url.searchParams.get('id')?.trim(); diff --git a/src/routes/(protected)/admin/api/users/local-credentials/+server.ts b/src/routes/(protected)/admin/api/users/local-credentials/+server.ts new file mode 100644 index 0000000..cab2b16 --- /dev/null +++ b/src/routes/(protected)/admin/api/users/local-credentials/+server.ts @@ -0,0 +1,22 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAdmin } from '$lib/server/admin/auth.js'; +import { setLocalPasswordForUser } from '$lib/server/users.js'; + +export const POST: RequestHandler = async (event) => { + requireAdmin((await event.locals.auth())?.user); + const body = await event.request.json(); + const { id, password } = body as { id?: string; password?: string }; + if (!id?.trim() || !password?.trim()) { + return json({ error: 'id and password required' }, { status: 400 }); + } + try { + await setLocalPasswordForUser(id.trim(), password); + return json({ ok: true }); + } catch (error) { + return json( + { error: error instanceof Error ? error.message : 'Failed to set password' }, + { status: 400 } + ); + } +}; diff --git a/src/routes/(protected)/admin/users/+page.svelte b/src/routes/(protected)/admin/users/+page.svelte index fceb4c6..70004e9 100644 --- a/src/routes/(protected)/admin/users/+page.svelte +++ b/src/routes/(protected)/admin/users/+page.svelte @@ -17,6 +17,19 @@ let editUsername = $state(''); let editFullName = $state(''); let editEmail = $state(''); + let addDrawerOpen = $state(false); + let addUsername = $state(''); + let addFullName = $state(''); + let addEmail = $state(''); + let addPassword = $state(''); + let addPasswordConfirm = $state(''); + let addError = $state(''); + let addSubmitting = $state(false); + let passwordDrawerUser = $state(null); + let localPassword = $state(''); + let localPasswordConfirm = $state(''); + let localPasswordError = $state(''); + let localPasswordSubmitting = $state(false); async function loadUsers() { loading = true; @@ -50,6 +63,113 @@ error = ''; } + function openAddDrawer() { + addDrawerOpen = true; + addError = ''; + } + + function closeAddDrawer() { + addDrawerOpen = false; + addUsername = ''; + addFullName = ''; + addEmail = ''; + addPassword = ''; + addPasswordConfirm = ''; + addError = ''; + } + + async function submitAddUser() { + if (!addUsername.trim() || !addFullName.trim() || !addPassword.trim()) { + addError = 'Username, full name, and password are required'; + return; + } + if (addPassword.trim().length < 12) { + addError = 'Password must be at least 12 characters'; + return; + } + if (addPassword.trim() !== addPasswordConfirm.trim()) { + addError = 'Passwords do not match'; + return; + } + addSubmitting = true; + addError = ''; + try { + const res = await fetch(`${base}/admin/api/users`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: addUsername.trim(), + full_name: addFullName.trim(), + email: addEmail.trim() || null, + password: addPassword + }) + }); + if (!res.ok) { + const data = await res.json(); + addError = data.error ?? 'Failed to create user'; + return; + } + closeAddDrawer(); + loadUsers(); + } catch (e) { + addError = e instanceof Error ? e.message : 'Failed to create user'; + } finally { + addSubmitting = false; + } + } + + function openPasswordDrawer(user: User) { + passwordDrawerUser = user; + localPassword = ''; + localPasswordConfirm = ''; + localPasswordError = ''; + } + + function closePasswordDrawer() { + passwordDrawerUser = null; + localPassword = ''; + localPasswordConfirm = ''; + localPasswordError = ''; + } + + async function submitLocalPassword() { + if (!passwordDrawerUser) return; + if (!localPassword.trim()) { + localPasswordError = 'Password is required'; + return; + } + if (localPassword.trim().length < 12) { + localPasswordError = 'Password must be at least 12 characters'; + return; + } + if (localPassword.trim() !== localPasswordConfirm.trim()) { + localPasswordError = 'Passwords do not match'; + return; + } + localPasswordSubmitting = true; + localPasswordError = ''; + try { + const res = await fetch(`${base}/admin/api/users/local-credentials`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: passwordDrawerUser.id, + password: localPassword + }) + }); + if (!res.ok) { + const data = await res.json(); + localPasswordError = data.error ?? 'Failed to set password'; + return; + } + closePasswordDrawer(); + } catch (e) { + localPasswordError = e instanceof Error ? e.message : 'Failed to set password'; + } finally { + localPasswordSubmitting = false; + } + } + async function submitEdit() { if (!editing) return; if (!editUsername.trim() || !editFullName.trim()) { @@ -105,9 +225,17 @@
-
-

Users

-

Manage application user accounts and access.

+
+
+

Users

+

Manage application user accounts and access.

+
+
@@ -218,6 +346,24 @@
+
+ +{#if addDrawerOpen} +
e.key === 'Escape' && closeAddDrawer()} + >
+ +{/if} + +{#if passwordDrawerUser} +
e.key === 'Escape' && closePasswordDrawer()} + >
+ +{/if} -- 2.49.1 From 345da2ef7aab278c8562cdb30beb7938cda5d0c9 Mon Sep 17 00:00:00 2001 From: Shaun Campbell Date: Sun, 22 Feb 2026 14:46:51 -0500 Subject: [PATCH 2/7] fix(admin/users): convert Svelte 4 event syntax to Svelte 5 Replace `on:keydown` with `onkeydown` and `onsubmit|preventDefault` with inline handlers calling `e.preventDefault()` to fix compilation errors caused by mixing old and new Svelte event syntax. Co-Authored-By: Claude Sonnet 4.6 --- src/routes/(protected)/admin/users/+page.svelte | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/routes/(protected)/admin/users/+page.svelte b/src/routes/(protected)/admin/users/+page.svelte index 70004e9..86b4fd8 100644 --- a/src/routes/(protected)/admin/users/+page.svelte +++ b/src/routes/(protected)/admin/users/+page.svelte @@ -418,14 +418,14 @@ role="button" tabindex="-1" onclick={closeAddDrawer} - on:keydown={(e: KeyboardEvent) => e.key === 'Escape' && closeAddDrawer()} + onkeydown={(e: KeyboardEvent) => e.key === 'Escape' && closeAddDrawer()} >