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>