the owner can create accounts

POST /api/users plus an Add-account form in Settings > User management. Until now
createUser had one call site — bootstrap, gated on an empty user table — so every
non-owner account anywhere had been inserted into Postgres by hand.

Created accounts are Active. The column defaults to Unverified and signin refuses
anything else with a bare UNAUTHORIZED, which is exactly what made the hand-INSERT
route look like a wrong password.

Also closes a hole found while reading the write path: a second Super Admin was
storable. The CHECK constraint pins user 1's role but cannot see other rows, and
getOwnerUser() was LIMIT 1 with no ORDER BY, so two holders would have made "who owns
this server" a question the query plan answered — and that answer feeds the agent
sidecar's identity, vault access and origin scoping. Both write paths now refuse the
role and getOwnerUser() orders by id.

USER_DIRS and provisionUserDirs move into data-path.ts so the create handler and
scripts/provision-user-dirs.ts cannot disagree about what an account's skeleton is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 15:15:09 +00:00
co-authored by Claude Opus 5
parent b7184283e0
commit 69a31051ac
10 changed files with 408 additions and 29 deletions
@@ -0,0 +1,214 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Loader2, UserPlus, Dices, Copy, X } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
// The owner creating an account. Until this existed the only way to add one was an INSERT in Postgres.
//
// The password is visible, not masked, and that is the point: the owner has to be able to read it back
// to the person they are creating it for. Masking a value nobody has yet only makes it easy to typo
// twice. When there is an invite flow this whole field goes away.
type CreateUserFormProps = {
/** Roles the server will actually accept. Excludes the owner role — see manage-users.ts. */
roles: string[];
/** Invalidated on success so the list below refreshes. */
usersKey: readonly unknown[];
};
// Mirrors validatePassword on the server: length, both cases, a digit and a symbol. Generated rather
// than demanded so the owner is not sitting there inventing one that passes.
function generatePassword(): string {
const lower = 'abcdefghijkmnopqrstuvwxyz';
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
const digits = '23456789';
const symbols = '!@#$%^&*-_=+';
const all = lower + upper + digits + symbols;
const pick = (set: string, count: number) =>
Array.from(crypto.getRandomValues(new Uint32Array(count)), (n) => set[n % set.length]!);
// One of each class first, so the result cannot fail the server's rules by chance, then filled out.
const chars = [...pick(lower, 4), ...pick(upper, 3), ...pick(digits, 3), ...pick(symbols, 2), ...pick(all, 8)];
// Shuffled so the classes are not in fixed positions. Fisher-Yates with crypto randomness.
const noise = crypto.getRandomValues(new Uint32Array(chars.length));
for (let i = chars.length - 1; i > 0; i--) {
const j = noise[i]! % (i + 1);
[chars[i], chars[j]] = [chars[j]!, chars[i]!];
}
return chars.join('');
}
const EMPTY = { email: '', name: '', username: '', password: '', role: 'Member' };
export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
const client = useClient();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState(EMPTY);
const set = (key: keyof typeof EMPTY) => (value: string) => setForm((prev) => ({ ...prev, [key]: value }));
const close = () => {
setOpen(false);
setForm(EMPTY);
};
const submit = async (ev: React.FormEvent) => {
ev.preventDefault();
setSaving(true);
try {
await client.post('/users', form);
await queryClient.invalidateQueries({ queryKey: usersKey });
// The password is named in the toast on purpose. It is the only moment it is recoverable — the
// server stores an argon2 hash and there is no reset flow yet, so an owner who closed the form
// without noting it would have to delete the account and make it again.
toast.success(`${form.email} created`, {
description: `Password: ${form.password}`,
duration: 30_000,
});
close();
} catch (ex) {
// The server's message is the useful one here — which field, and why.
toast.error(ex instanceof Error ? ex.message : 'Could not create the account');
} finally {
setSaving(false);
}
};
if (!open) {
return (
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<UserPlus className="mr-2 h-4 w-4" />
Add account
</Button>
);
}
return (
<form onSubmit={submit} className="space-y-4 rounded-lg border p-4">
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="text-sm font-medium">New account</h3>
<p className="text-xs text-muted-foreground">
Created active — they can sign in straight away. Tell them the password; it is not recoverable afterwards.
</p>
</div>
<Button type="button" variant="ghost" size="icon" onClick={close} aria-label="Cancel">
<X className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="new-user-email">Email</Label>
<Input
id="new-user-email"
type="email"
autoComplete="off"
value={form.email}
onChange={(ev) => set('email')(ev.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-user-name">Name</Label>
<Input
id="new-user-name"
autoComplete="off"
value={form.name}
onChange={(ev) => set('name')(ev.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-user-username">Username</Label>
<Input
id="new-user-username"
autoComplete="off"
value={form.username}
onChange={(ev) => set('username')(ev.target.value)}
required
/>
<p className="text-xs text-muted-foreground">Letters, numbers, dots, hyphens and underscores.</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-user-role">Role</Label>
<Select value={form.role} onValueChange={set('role')}>
<SelectTrigger id="new-user-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role} value={role}>
{role}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
What the role may reach is set under Permissions. A role with no grants can sign in and reach nothing but
its own profile.
</p>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="new-user-password">Password</Label>
<div className="flex gap-2">
<Input
id="new-user-password"
// Deliberately visible — see the note at the top of this file.
type="text"
autoComplete="off"
spellCheck={false}
className="font-mono"
value={form.password}
onChange={(ev) => set('password')(ev.target.value)}
required
/>
<Button type="button" variant="outline" size="icon" onClick={() => set('password')(generatePassword())}>
<Dices className="h-4 w-4" />
<span className="sr-only">Generate a password</span>
</Button>
<Button
type="button"
variant="outline"
size="icon"
disabled={!form.password}
onClick={() => {
void navigator.clipboard.writeText(form.password);
toast.success('Password copied');
}}
>
<Copy className="h-4 w-4" />
<span className="sr-only">Copy the password</span>
</Button>
</div>
<p className="text-xs text-muted-foreground">
At least 12 characters, with upper and lower case, a number and a symbol.
</p>
</div>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create account
</Button>
<Button type="button" variant="ghost" onClick={close} disabled={saving}>
Cancel
</Button>
</div>
</form>
);
};
@@ -15,6 +15,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { CreateUserForm } from './CreateUserForm';
type ManagedUser = {
id: number;
@@ -27,7 +28,14 @@ type ManagedUser = {
isOwner: boolean;
};
type UsersResponse = { users: ManagedUser[]; roles: string[]; ownerId: number };
type UsersResponse = {
users: ManagedUser[];
/** Every role, for displaying the owner's own value. */
roles: string[];
/** Roles the server will accept in a write. Excludes the owner role — both write paths refuse it. */
assignableRoles: string[];
ownerId: number;
};
const USERS_KEY = ['MANAGED_USERS'];
@@ -86,9 +94,11 @@ export const UsersSection = () => {
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Every account on this server. The owner is fixed — the database itself refuses to demote or remove it — so that
row cannot be changed from here.
row cannot be changed from here, and no other account can be promoted into it.
</p>
<CreateUserForm roles={data.assignableRoles} usersKey={USERS_KEY} />
<div className="rounded-lg border divide-y">
{data.users.map((user) => {
const busy = pendingId === user.id;
@@ -114,7 +124,9 @@ export const UsersSection = () => {
<SelectValue />
</SelectTrigger>
<SelectContent>
{data.roles.map((role) => (
{/* The owner's row needs its own value present to render at all, and it is disabled
anyway. Every other row offers only what the server will accept. */}
{(user.isOwner ? data.roles : data.assignableRoles).map((role) => (
<SelectItem key={role} value={role}>
{role}
</SelectItem>