diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 4bd2f2f9..d27f6e0b 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -385,6 +385,7 @@ export type AdminUser = { username: string; display_name: string | null; is_admin: boolean; + auto_approve_requests: boolean; created_at: string; }; @@ -420,6 +421,31 @@ export async function deleteInvite(token: string): Promise { await api.del(`/api/admin/invites/${token}`); } +// U2 user-management actions ----------------------------------------------- + +export type CreateUserInput = { + username: string; + password: string; + display_name?: string; + is_admin?: boolean; +}; + +export async function createUser(input: CreateUserInput): Promise { + return api.post('/api/admin/users', input); +} + +export async function deleteUser(id: string): Promise { + await api.del(`/api/admin/users/${id}`); +} + +export async function resetUserPassword(id: string, password: string): Promise { + await api.post(`/api/admin/users/${id}/reset-password`, { password }); +} + +export async function updateUserAutoApprove(id: string, autoApprove: boolean): Promise { + return api.put(`/api/admin/users/${id}/auto-approve`, { auto_approve: autoApprove }); +} + export function createAdminUsersQuery() { return createQuery({ queryKey: qk.adminUsers(), diff --git a/web/src/routes/admin/users/+page.svelte b/web/src/routes/admin/users/+page.svelte index ffd413d3..337a8b66 100644 --- a/web/src/routes/admin/users/+page.svelte +++ b/web/src/routes/admin/users/+page.svelte @@ -6,10 +6,15 @@ updateUserAdmin, createInvite, deleteInvite, + createUser, + deleteUser, + resetUserPassword, + updateUserAutoApprove, createAdminUsersQuery, createAdminInvitesQuery, type AdminUser, - type AdminInvite + type AdminInvite, + type CreateUserInput } from '$lib/api/admin'; const client = useQueryClient(); @@ -34,6 +39,22 @@ }, 5000); } + // Modal state + let showCreateModal = $state(false); + let createForm = $state({ + username: '', + password: '', + confirmPassword: '', + display_name: '', + is_admin: false, + }); + + let resetPasswordTarget = $state(null); + let resetPasswordValue = $state(''); + let resetPasswordConfirm = $state(''); + + let confirmDeleteTarget = $state(null); + async function onToggleAdmin(u: AdminUser) { saving = true; try { @@ -52,6 +73,102 @@ } } + async function onSubmitCreate(e: SubmitEvent) { + e.preventDefault(); + if (createForm.password !== createForm.confirmPassword) { + showToast('Passwords do not match.'); + return; + } + saving = true; + try { + await createUser({ + username: createForm.username, + password: createForm.password, + display_name: createForm.display_name || undefined, + is_admin: createForm.is_admin, + }); + await client.invalidateQueries({ queryKey: qk.adminUsers() }); + showCreateModal = false; + createForm = { username: '', password: '', confirmPassword: '', display_name: '', is_admin: false }; + showToast('User created.'); + } catch (e: unknown) { + const code = (e as { code?: string })?.code; + showToast(createUserErrorMessage(code)); + } finally { + saving = false; + } + } + + function createUserErrorMessage(code: string | undefined): string { + switch (code) { + case 'username_taken': return 'That username is already taken.'; + case 'username_invalid': return 'Username must be 3–32 characters: letters, numbers, underscores, hyphens.'; + case 'password_too_short': return 'Password must be at least 8 characters.'; + default: return `Create failed: ${code ?? 'unknown'}`; + } + } + + async function onConfirmDelete() { + if (!confirmDeleteTarget) return; + saving = true; + try { + await deleteUser(confirmDeleteTarget.id); + await client.invalidateQueries({ queryKey: qk.adminUsers() }); + showToast(`Deleted ${confirmDeleteTarget.username}.`); + confirmDeleteTarget = null; + } catch (e: unknown) { + const code = (e as { code?: string })?.code; + if (code === 'last_admin') { + showToast(`Can't delete the last admin — promote someone else first.`); + } else { + showToast(`Delete failed: ${code ?? 'unknown'}`); + } + } finally { + saving = false; + } + } + + async function onSubmitResetPassword(e: SubmitEvent) { + e.preventDefault(); + if (!resetPasswordTarget) return; + if (resetPasswordValue !== resetPasswordConfirm) { + showToast('Passwords do not match.'); + return; + } + saving = true; + try { + await resetUserPassword(resetPasswordTarget.id, resetPasswordValue); + showToast(`Password reset for ${resetPasswordTarget.username}.`); + resetPasswordTarget = null; + resetPasswordValue = ''; + resetPasswordConfirm = ''; + } catch (e: unknown) { + const code = (e as { code?: string })?.code; + if (code === 'password_too_short') { + showToast('Password must be at least 8 characters.'); + } else { + showToast(`Reset failed: ${code ?? 'unknown'}`); + } + } finally { + saving = false; + } + } + + async function onToggleAutoApprove(u: AdminUser) { + saving = true; + try { + await updateUserAutoApprove(u.id, !u.auto_approve_requests); + await client.invalidateQueries({ queryKey: qk.adminUsers() }); + showToast(u.auto_approve_requests + ? `Auto-approve disabled for ${u.username}.` + : `Auto-approve enabled for ${u.username}.`); + } catch (e: unknown) { + showToast(`Toggle failed: ${(e as { code?: string })?.code ?? 'unknown'}`); + } finally { + saving = false; + } + } + async function onGenerateInvite() { saving = true; try { @@ -104,7 +221,17 @@
-

Accounts

+
+

Accounts

+ +
{#if usersQuery.isPending}

Reading users…

{:else if usersQuery.isError} @@ -114,7 +241,7 @@ {:else}
    {#each users as u (u.id)} -
  • +
  • {u.username} @@ -124,12 +251,18 @@ data-testid="admin-badge" >admin {/if} + {#if u.auto_approve_requests} + auto-approve + {/if}
    {#if u.display_name}{u.display_name} · {/if}created {formatDate(u.created_at)}
    -
    +
    + + +
  • {/each} @@ -211,3 +371,205 @@ {toast} {/if} + +{#if showCreateModal} + + +
    { showCreateModal = false; }} + > + +
    +{/if} + +{#if resetPasswordTarget} + + +
    { resetPasswordTarget = null; resetPasswordValue = ''; resetPasswordConfirm = ''; }} + > + +
    +{/if} + +{#if confirmDeleteTarget} + + +
    { confirmDeleteTarget = null; }} + > + +
    +{/if} diff --git a/web/src/routes/admin/users/users.test.ts b/web/src/routes/admin/users/users.test.ts index 4f9b2fd7..8ad66c31 100644 --- a/web/src/routes/admin/users/users.test.ts +++ b/web/src/routes/admin/users/users.test.ts @@ -17,7 +17,11 @@ vi.mock('$lib/api/admin', () => ({ updateUserAdmin: vi.fn(), listInvites: vi.fn(), createInvite: vi.fn(), - deleteInvite: vi.fn() + deleteInvite: vi.fn(), + createUser: vi.fn(), + deleteUser: vi.fn(), + resetUserPassword: vi.fn(), + updateUserAutoApprove: vi.fn() })); import AdminUsersPage from './+page.svelte'; @@ -26,7 +30,11 @@ import { createAdminInvitesQuery, updateUserAdmin, createInvite, - deleteInvite + deleteInvite, + createUser, + deleteUser, + resetUserPassword, + updateUserAutoApprove } from '$lib/api/admin'; const alice: AdminUser = { @@ -34,6 +42,7 @@ const alice: AdminUser = { username: 'alice', display_name: null, is_admin: true, + auto_approve_requests: false, created_at: '2026-05-01T00:00:00Z' }; @@ -42,6 +51,7 @@ const bob: AdminUser = { username: 'bob', display_name: 'Bob B', is_admin: false, + auto_approve_requests: false, created_at: '2026-05-02T00:00:00Z' }; @@ -127,4 +137,79 @@ describe('/admin/users', () => { setup([alice], []); expect(screen.getByText('No active invites.')).toBeInTheDocument(); }); + + test('opens new-user modal and submits createUser on form submission', async () => { + (createUser as ReturnType).mockResolvedValue({}); + setup(); + await waitFor(() => screen.getByText('alice')); + + await fireEvent.click(screen.getByRole('button', { name: /New user/i })); + await waitFor(() => screen.getByRole('dialog')); + + await fireEvent.input(screen.getByLabelText('Username'), { target: { value: 'created1' } }); + const passwordInputs = screen.getAllByLabelText(/^Password$/i); + await fireEvent.input(passwordInputs[0], { target: { value: 'abcd1234' } }); + await fireEvent.input(screen.getByLabelText(/Confirm password/i), { target: { value: 'abcd1234' } }); + await fireEvent.click(screen.getByRole('button', { name: /^Create user$/i })); + + expect(createUser).toHaveBeenCalledWith(expect.objectContaining({ + username: 'created1', + password: 'abcd1234', + })); + }); + + test('opens delete confirm and calls deleteUser on confirm', async () => { + (deleteUser as ReturnType).mockResolvedValue(undefined); + setup(); + await waitFor(() => screen.getByText('alice')); + + const deleteButtons = screen.getAllByRole('button', { name: /^Delete$/i }); + await fireEvent.click(deleteButtons[0]); + await waitFor(() => screen.getByRole('dialog')); + + // The confirm Delete button is inside the dialog; it is the last "Delete" in the DOM. + const allDeleteButtons = screen.getAllByRole('button', { name: /^Delete$/i }); + await fireEvent.click(allDeleteButtons[allDeleteButtons.length - 1]); + + expect(deleteUser).toHaveBeenCalled(); + }); + + test('last_admin error from delete shows clear toast', async () => { + (deleteUser as ReturnType).mockRejectedValueOnce({ code: 'last_admin' }); + setup(); + await waitFor(() => screen.getByText('alice')); + + const deleteButtons = screen.getAllByRole('button', { name: /^Delete$/i }); + await fireEvent.click(deleteButtons[0]); + await waitFor(() => screen.getByRole('dialog')); + const allDeleteButtons = screen.getAllByRole('button', { name: /^Delete$/i }); + await fireEvent.click(allDeleteButtons[allDeleteButtons.length - 1]); + + await waitFor(() => screen.getByRole('status')); + expect(screen.getByRole('status').textContent).toMatch(/last admin/i); + }); + + test('reset-password modal calls resetUserPassword with the new password', async () => { + (resetUserPassword as ReturnType).mockResolvedValue(undefined); + setup(); + await waitFor(() => screen.getByText('alice')); + + await fireEvent.click(screen.getAllByRole('button', { name: /Reset password/i })[0]); + await waitFor(() => screen.getByRole('dialog')); + + await fireEvent.input(screen.getByLabelText(/New password/i), { target: { value: 'abcd1234' } }); + await fireEvent.input(screen.getByLabelText(/^Confirm$/i), { target: { value: 'abcd1234' } }); + await fireEvent.click(screen.getByRole('button', { name: /Set password/i })); + + expect(resetUserPassword).toHaveBeenCalledWith(expect.any(String), 'abcd1234'); + }); + + test('toggle-auto-approve calls updateUserAutoApprove', async () => { + (updateUserAutoApprove as ReturnType).mockResolvedValue({}); + setup(); + await waitFor(() => screen.getByText('alice')); + + await fireEvent.click(screen.getAllByRole('button', { name: /Enable auto-approve/i })[0]); + expect(updateUserAutoApprove).toHaveBeenCalledWith(expect.any(String), true); + }); });