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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user