feat(api): admin-initiated user creation via POST /admin/users (0.43.0)
Some checks failed
deploy / test-backend (push) Failing after 8s
deploy / test-frontend (push) Failing after 38s
deploy / build-and-push (push) Has been skipped
deploy / deploy (push) Has been skipped

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:
MechaCat02
2026-05-31 14:00:31 +02:00
parent 2f47faa11c
commit 030b27754b
10 changed files with 505 additions and 6 deletions

View File

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