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..108bb59 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(); @@ -24,12 +27,16 @@ const normalizeEmail = (value?: string | null): string | null | undefined => { 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 listUsers(): (AppUser & { has_local_credentials: boolean })[] { + return db + .all( + `SELECT u.id, u.username, u.full_name, u.email, u.auth_source, u.created_at, u.updated_at, + CASE WHEN lc.user_id IS NOT NULL THEN 1 ELSE 0 END AS has_local_credentials + FROM users u + LEFT JOIN local_credentials lc ON lc.user_id = u.id + ORDER BY u.username COLLATE NOCASE` + ) + .map((row) => ({ ...row, has_local_credentials: row.has_local_credentials === 1 })); } export function upsertUserFromAuth(input: { @@ -112,6 +119,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..c23416b 100644 --- a/src/routes/(protected)/admin/users/+page.svelte +++ b/src/routes/(protected)/admin/users/+page.svelte @@ -8,6 +8,7 @@ full_name: string; email: string | null; auth_source: string; + has_local_credentials: boolean; } let users = $state([]); @@ -17,6 +18,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 +64,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 +226,17 @@
-
-

Users

-

Manage application user accounts and access.

+
+
+

Users

+

Manage application user accounts and access.

+
+
@@ -212,12 +341,37 @@ {user.email || '—'} - {user.auth_source} +
+ {user.auth_source} + {#if user.has_local_credentials && user.auth_source !== 'Local'} + Local + {/if} +
+
+ +{#if addDrawerOpen} +
e.key === 'Escape' && closeAddDrawer()} + >
+ +{/if} + +{#if passwordDrawerUser} +
e.key === 'Escape' && closePasswordDrawer()} + >
+ +{/if} diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index f846970..59cb130 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -10,10 +10,12 @@ const resolveErrorMessage = (value: string | null): string | null => { export const load: PageServerLoad = async ({ url }) => { const authUrl = process.env.AUTH_URL ?? env.AUTH_URL ?? ''; const localAuthEnabled = (process.env.LOCAL_AUTH_ENABLED ?? env.LOCAL_AUTH_ENABLED) === 'true'; + const appRoot = authUrl.replace(/\/auth$/, ''); return { signinUrl: `${authUrl}/signin/synology`, - localSigninUrl: `${authUrl}/signin/local`, + localSigninUrl: `${authUrl}/callback/local`, + callbackUrl: appRoot, localAuthEnabled, error: resolveErrorMessage(url.searchParams.get('error')) }; diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index e89c471..72e2fa7 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -45,6 +45,7 @@ {#if data.localAuthEnabled}
+