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
+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);
});
});