feat(api): admin-initiated user creation via POST /admin/users (0.43.0)
Pairs with the ALLOW_SELF_REGISTER toggle from 0.42.0: admins can mint
accounts regardless of the toggle state, so a closed-membership
deployment still has a working enrollment path. The endpoint accepts
{ username, password, is_admin? } so admins can mint co-admins in one
call (avoiding a separate promote + extra audit row for the common
"invite a co-admin" flow).
Implementation:
- POST /api/v1/admin/users guarded by RequireAdmin
- Reuses validate_username / validate_password from api::auth (made
pub(crate)) so the admin path can never produce an account self-
register would reject and vice versa
- repo::user::admin_create_user wraps INSERT + admin_audit insert in
a single tx — same "audit reflects what committed" semantics as the
existing admin_safe_* fns
- Audit row: action="create_user", payload={username, is_admin}
Frontend:
- createAdminUser() in lib/api/admin.ts
- /admin/users grows a collapsible "Create user" form above the table
(username, password, "Make admin" checkbox). Errors surface inline;
the list reloads on success.
Backend tests: 7 new, including the headline
`create_user_works_even_when_self_register_disabled` that pins the
admin-create path is NOT gated by the public toggle.
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
listAdminUsers,
|
||||
deleteAdminUser,
|
||||
setUserAdmin,
|
||||
createAdminUser,
|
||||
listAdminMangas,
|
||||
listAdminChapters,
|
||||
getSystemStats
|
||||
@@ -126,6 +127,49 @@ describe('admin api client', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('createAdminUser POSTs to /v1/admin/users with body and returns the created user', async () => {
|
||||
const created = { ...userFixture, username: 'invited01' };
|
||||
fetchSpy.mockResolvedValueOnce(ok(created, 201));
|
||||
const got = await createAdminUser({
|
||||
username: 'invited01',
|
||||
password: 'freshpass1234'
|
||||
});
|
||||
expect(got).toEqual(created);
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/admin\/users$/);
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
username: 'invited01',
|
||||
password: 'freshpass1234'
|
||||
});
|
||||
});
|
||||
|
||||
it('createAdminUser forwards is_admin when provided', async () => {
|
||||
const created = { ...userFixture, username: 'coadmin', is_admin: true };
|
||||
fetchSpy.mockResolvedValueOnce(ok(created, 201));
|
||||
await createAdminUser({
|
||||
username: 'coadmin',
|
||||
password: 'freshpass1234',
|
||||
is_admin: true
|
||||
});
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
username: 'coadmin',
|
||||
password: 'freshpass1234',
|
||||
is_admin: true
|
||||
});
|
||||
});
|
||||
|
||||
it('createAdminUser surfaces 409 conflict on duplicate username', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
envelope(409, 'conflict', 'username is already taken')
|
||||
);
|
||||
await expect(
|
||||
createAdminUser({ username: 'taken', password: 'freshpass1234' })
|
||||
).rejects.toMatchObject({ status: 409, code: 'conflict' });
|
||||
});
|
||||
|
||||
it('setUserAdmin PATCHes is_admin and returns the updated user', async () => {
|
||||
const updated = { ...userFixture, is_admin: true };
|
||||
fetchSpy.mockResolvedValueOnce(ok(updated));
|
||||
|
||||
@@ -44,6 +44,23 @@ export async function setUserAdmin(id: string, isAdmin: boolean): Promise<User>
|
||||
});
|
||||
}
|
||||
|
||||
export type CreateAdminUserInput = {
|
||||
username: string;
|
||||
password: string;
|
||||
is_admin?: boolean;
|
||||
};
|
||||
|
||||
/** POST /v1/admin/users — admin-initiated account creation. Works
|
||||
* regardless of the ALLOW_SELF_REGISTER toggle, since the entire
|
||||
* point is for an admin to enroll someone when self-register is off. */
|
||||
export async function createAdminUser(input: CreateAdminUserInput): Promise<User> {
|
||||
return request<User>('/v1/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
// ---- mangas / chapters with sync state -------------------------------------
|
||||
|
||||
export type MangaSyncState = 'in_progress' | 'dropped' | 'synced';
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
listAdminUsers,
|
||||
deleteAdminUser,
|
||||
setUserAdmin,
|
||||
createAdminUser,
|
||||
type AdminUsersPage
|
||||
} from '$lib/api/admin';
|
||||
import { ApiError } from '$lib/api/client';
|
||||
@@ -14,6 +15,14 @@
|
||||
let error: string | null = $state(null);
|
||||
let busyId: string | null = $state(null);
|
||||
|
||||
// Create-user form (collapsed by default).
|
||||
let showCreate = $state(false);
|
||||
let newUsername = $state('');
|
||||
let newPassword = $state('');
|
||||
let newIsAdmin = $state(false);
|
||||
let createError: string | null = $state(null);
|
||||
let creating = $state(false);
|
||||
|
||||
async function load() {
|
||||
error = null;
|
||||
try {
|
||||
@@ -52,10 +61,92 @@
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onCreate(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
createError = null;
|
||||
creating = true;
|
||||
try {
|
||||
await createAdminUser({
|
||||
username: newUsername.trim(),
|
||||
password: newPassword,
|
||||
is_admin: newIsAdmin
|
||||
});
|
||||
// Reset form + reload list so the new row is visible.
|
||||
newUsername = '';
|
||||
newPassword = '';
|
||||
newIsAdmin = false;
|
||||
showCreate = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
createError = e instanceof ApiError ? e.message : 'create failed';
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Users</h1>
|
||||
|
||||
<div class="toolbar">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showCreate = !showCreate)}
|
||||
data-testid="admin-users-toggle-create"
|
||||
>
|
||||
{showCreate ? 'Cancel' : 'Create user'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showCreate}
|
||||
<form class="create-form" onsubmit={onCreate} data-testid="admin-users-create-form">
|
||||
<label class="field">
|
||||
<span>Username</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newUsername}
|
||||
minlength="3"
|
||||
maxlength="32"
|
||||
required
|
||||
autocomplete="off"
|
||||
data-testid="admin-users-create-username"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
minlength="8"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
data-testid="admin-users-create-password"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={newIsAdmin}
|
||||
data-testid="admin-users-create-is-admin"
|
||||
/>
|
||||
<span>Make admin</span>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
class="primary"
|
||||
disabled={creating}
|
||||
data-testid="admin-users-create-submit"
|
||||
>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
{#if createError}
|
||||
<p class="error" role="alert" data-testid="admin-users-create-error">
|
||||
{createError}
|
||||
</p>
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -181,4 +272,57 @@
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.create-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto auto;
|
||||
gap: var(--space-3);
|
||||
align-items: end;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-elevated);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.create-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.create-form .field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.create-form .field input {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.create-form .field-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.create-form .primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-contrast);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.create-form .primary:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
.create-form .error {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user