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>