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:
@@ -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(),
|
||||
|
||||
@@ -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 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 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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user