feat(web/m7-user-mgmt): /admin/users CRUD actions (U2)

Extends the U1 /admin/users page with the four U2 admin endpoints.

- "New user" button opens a modal: username, optional display
  name, password + confirm, optional admin checkbox. Validates
  password match client-side; surface server-side errors
  (username_taken, username_invalid, password_too_short).

- Per-row "Delete" opens a confirm modal explaining the cascade
  (plays, likes, sessions). Last-admin guard surfaces as a clear
  toast if the server refuses.

- Per-row "Reset password" opens a small modal: new password +
  confirm. Toast confirms success.

- Per-row "Enable / Disable auto-approve" toggles the
  per-user flag (the #355 sub-feature surface). Inline button
  state reflects the current value.

AdminUser type extended with auto_approve_requests; the badge
appears next to the admin badge when enabled.

Tests cover create-user submit, delete confirm flow, last-admin
toast on delete, reset-password submit, auto-approve toggle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 12:21:23 -04:00
parent 46d38afe2d
commit 777e32ca24
3 changed files with 479 additions and 6 deletions
+26
View File
@@ -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<void> {
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<AdminUser> {
return api.post<AdminUser>('/api/admin/users', input);
}
export async function deleteUser(id: string): Promise<void> {
await api.del(`/api/admin/users/${id}`);
}
export async function resetUserPassword(id: string, password: string): Promise<void> {
await api.post(`/api/admin/users/${id}/reset-password`, { password });
}
export async function updateUserAutoApprove(id: string, autoApprove: boolean): Promise<AdminUser> {
return api.put<AdminUser>(`/api/admin/users/${id}/auto-approve`, { auto_approve: autoApprove });
}
export function createAdminUsersQuery() {
return createQuery({
queryKey: qk.adminUsers(),
+366 -4
View File
@@ -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<CreateUserInput & { confirmPassword: string }>({
username: '',
password: '',
confirmPassword: '',
display_name: '',
is_admin: false,
});
let resetPasswordTarget = $state<AdminUser | null>(null);
let resetPasswordValue = $state('');
let resetPasswordConfirm = $state('');
let confirmDeleteTarget = $state<AdminUser | null>(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 332 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 section -->
<section class="space-y-3">
<h3 class="font-display text-xl font-medium text-text-primary">Accounts</h3>
<div class="flex items-baseline justify-between">
<h3 class="font-display text-xl font-medium text-text-primary">Accounts</h3>
<button
type="button"
disabled={saving}
class="inline-flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => { showCreateModal = true; }}
>
New user
</button>
</div>
{#if usersQuery.isPending}
<p class="text-text-secondary">Reading users…</p>
{:else if usersQuery.isError}
@@ -114,7 +241,7 @@
{:else}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each users as u (u.id)}
<li class="flex items-center gap-4 p-3" data-testid="user-row" data-user-id={u.id}>
<li class="flex flex-wrap items-center gap-2 p-3" data-testid="user-row" data-user-id={u.id}>
<div class="min-w-0 flex-1 space-y-0.5">
<div class="flex flex-wrap items-center gap-2">
<span class="text-base font-medium text-text-primary">{u.username}</span>
@@ -124,12 +251,18 @@
data-testid="admin-badge"
>admin</span>
{/if}
{#if u.auto_approve_requests}
<span
class="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs text-text-secondary"
data-testid="auto-approve-badge"
>auto-approve</span>
{/if}
</div>
<div class="text-sm text-text-secondary">
{#if u.display_name}{u.display_name} · {/if}created {formatDate(u.created_at)}
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<div class="flex shrink-0 flex-wrap items-center gap-2">
<button
type="button"
disabled={saving}
@@ -139,6 +272,33 @@
>
{u.is_admin ? 'Remove admin' : 'Make admin'}
</button>
<button
type="button"
disabled={saving}
aria-label={u.auto_approve_requests ? `Disable auto-approve for ${u.username}` : `Enable auto-approve for ${u.username}`}
class="inline-flex items-center gap-1 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => onToggleAutoApprove(u)}
>
{u.auto_approve_requests ? 'Disable auto-approve' : 'Enable auto-approve'}
</button>
<button
type="button"
disabled={saving}
aria-label={`Reset password for ${u.username}`}
class="inline-flex items-center gap-1 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => { resetPasswordTarget = u; }}
>
Reset password
</button>
<button
type="button"
disabled={saving}
aria-label={`Delete ${u.username}`}
class="inline-flex items-center gap-1 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => { confirmDeleteTarget = u; }}
>
Delete
</button>
</div>
</li>
{/each}
@@ -211,3 +371,205 @@
{toast}
</div>
{/if}
{#if showCreateModal}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center"
style="background: rgba(0,0,0,0.5);"
onclick={() => { showCreateModal = false; }}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="create-user-title"
class="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-lg"
onclick={(e) => e.stopPropagation()}
tabindex="-1"
>
<h3 id="create-user-title" class="font-display text-lg font-medium text-text-primary">New user</h3>
<form onsubmit={onSubmitCreate} class="mt-4 space-y-3">
<div>
<label for="cu-username" class="block text-sm text-text-secondary">Username</label>
<input
id="cu-username"
type="text"
required
minlength="3"
maxlength="32"
bind:value={createForm.username}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label for="cu-display" class="block text-sm text-text-secondary">
Display name <span class="text-text-muted">(optional)</span>
</label>
<input
id="cu-display"
type="text"
maxlength="64"
bind:value={createForm.display_name}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label for="cu-password" class="block text-sm text-text-secondary">Password</label>
<input
id="cu-password"
type="password"
required
minlength="8"
bind:value={createForm.password}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label for="cu-confirm" class="block text-sm text-text-secondary">Confirm password</label>
<input
id="cu-confirm"
type="password"
required
minlength="8"
bind:value={createForm.confirmPassword}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<label class="flex items-center gap-2 text-sm text-text-secondary">
<input type="checkbox" bind:checked={createForm.is_admin} />
Make this user an admin
</label>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
disabled={saving}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
onclick={() => { showCreateModal = false; }}
>
Cancel
</button>
<button
type="submit"
disabled={saving}
class="inline-flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Creating…' : 'Create user'}
</button>
</div>
</form>
</div>
</div>
{/if}
{#if resetPasswordTarget}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center"
style="background: rgba(0,0,0,0.5);"
onclick={() => { resetPasswordTarget = null; resetPasswordValue = ''; resetPasswordConfirm = ''; }}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="reset-password-title"
class="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-lg"
onclick={(e) => e.stopPropagation()}
tabindex="-1"
>
<h3 id="reset-password-title" class="font-display text-lg font-medium text-text-primary">
Reset password for {resetPasswordTarget.username}
</h3>
<p class="mt-1 text-sm text-text-secondary">
The user will need to log in with the new password.
</p>
<form onsubmit={onSubmitResetPassword} class="mt-4 space-y-3">
<div>
<label for="rp-password" class="block text-sm text-text-secondary">New password</label>
<input
id="rp-password"
type="password"
required
minlength="8"
bind:value={resetPasswordValue}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label for="rp-confirm" class="block text-sm text-text-secondary">Confirm</label>
<input
id="rp-confirm"
type="password"
required
minlength="8"
bind:value={resetPasswordConfirm}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
disabled={saving}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
onclick={() => { resetPasswordTarget = null; resetPasswordValue = ''; resetPasswordConfirm = ''; }}
>
Cancel
</button>
<button
type="submit"
disabled={saving}
class="inline-flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Resetting…' : 'Set password'}
</button>
</div>
</form>
</div>
</div>
{/if}
{#if confirmDeleteTarget}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center"
style="background: rgba(0,0,0,0.5);"
onclick={() => { confirmDeleteTarget = null; }}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="delete-user-title"
class="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-lg"
onclick={(e) => e.stopPropagation()}
tabindex="-1"
>
<h3 id="delete-user-title" class="font-display text-lg font-medium text-text-primary">
Delete {confirmDeleteTarget.username}?
</h3>
<p class="mt-2 text-sm text-text-secondary">
This permanently deletes the account and cascades through plays, likes,
sessions, and quarantine. The action cannot be undone.
</p>
<div class="mt-5 flex justify-end gap-2">
<button
type="button"
disabled={saving}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
onclick={() => { confirmDeleteTarget = null; }}
>
Cancel
</button>
<button
type="button"
disabled={saving}
class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-action-fg disabled:cursor-not-allowed disabled:opacity-50"
onclick={onConfirmDelete}
>
{saving ? 'Deleting…' : 'Delete'}
</button>
</div>
</div>
</div>
{/if}
+87 -2
View File
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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);
});
});