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