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:
@@ -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