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:
@@ -14,10 +14,26 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
|||||||
|
|
||||||
## Multi-user
|
## Multi-user
|
||||||
|
|
||||||
- [ ] **No way to create a second account.** `createUser` has one call site, `auth/bootstrap.ts`, gated
|
- [x] **No way to create a second account.** Fixed 2026-08-11 on `sidecar-app-store`: `POST /api/users`
|
||||||
on an empty user table. There is no signup route, no invite flow and no admin create-user
|
(`api/users/create-user.ts`, owner-gated) plus an Add-account form in
|
||||||
handler, so every member on this instance was inserted into Postgres by hand. This is the
|
Settings → User management. Created accounts are `status: 'Active'` — the column defaults to
|
||||||
blocker for onboarding anyone who is not already in the database.
|
`'Unverified'` and `signin.ts` refuses anything else with a bare UNAUTHORIZED, which is the trap
|
||||||
|
the hand-INSERT route fell into. The owner sets the password and reads it out; `passwordChangedAt`
|
||||||
|
stays null. Directories come from the shared `provisionUserDirs`/`USER_DIRS` in `data-path.ts`,
|
||||||
|
which `scripts/provision-user-dirs.ts` now imports rather than restating.
|
||||||
|
|
||||||
|
- [ ] **Still no invite flow, and no password reset for a member.** The owner types the password and
|
||||||
|
tells the person, which means the owner knows it and the member cannot change it back if they
|
||||||
|
forget theirs — recovery today is delete-and-recreate. An invite (token, expiry, member sets
|
||||||
|
their own) needs a mail path. This is the next piece, not a nice-to-have.
|
||||||
|
|
||||||
|
- [x] **A second Super Admin was storable, and made the owner nondeterministic.** Fixed 2026-08-11.
|
||||||
|
`ck_users_owner_is_super_admin` pins user 1's role but a row-level CHECK cannot see other rows, and
|
||||||
|
`updateUserRoleHandler` happily promoted anyone — while `getOwnerUser()` was
|
||||||
|
`WHERE role='Super Admin' LIMIT 1` with no ORDER BY. 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, the list endpoint offers
|
||||||
|
`assignableRoles` without it, and `getOwnerUser()` orders by id.
|
||||||
|
|
||||||
- [ ] **`dashboards.id` is a global primary key, and ids are `slugify(name)`.** Two accounts cannot
|
- [ ] **`dashboards.id` is a global primary key, and ids are `slugify(name)`.** Two accounts cannot
|
||||||
both have a dashboard named "Home". Reachable today: six accounts exist. The recommendation on
|
both have a dashboard named "Home". Reachable today: six accounts exist. The recommendation on
|
||||||
|
|||||||
@@ -16,26 +16,10 @@
|
|||||||
|
|
||||||
import { mkdirSync, existsSync } from 'node:fs';
|
import { mkdirSync, existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
// The list and DATA_PATH itself come from the platform rather than being restated here. The owner's
|
||||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
// create-account handler provisions the same skeleton, and a script that drifted from it would produce
|
||||||
|
// accounts that differ by how they were made. Importing data-path.ts pulls in no database and no server.
|
||||||
// Mirrors the shape the owner's root grew organically. Most of these are also created on demand by
|
import { DATA_PATH, USER_DIRS } from '../src/servers/data-path';
|
||||||
// whichever feature owns them (attachments, dashboards, email_accounts…), so pre-creating them buys
|
|
||||||
// legibility more than function — the tree shows what a user has without having to use it first.
|
|
||||||
//
|
|
||||||
// `home` is the exception, and the reason this exists at all: nothing creates it today. getOwnerHomeDir
|
|
||||||
// returns process.env.HOME_DIR whenever it is set, which it always is on a real install, so the
|
|
||||||
// per-user home has never actually been reached. It is where a non-owner's sessions will run.
|
|
||||||
const USER_DIRS = [
|
|
||||||
'home',
|
|
||||||
'attachments',
|
|
||||||
'cache',
|
|
||||||
'dashboards',
|
|
||||||
'email_accounts',
|
|
||||||
'general_chat_sessions',
|
|
||||||
'logs',
|
|
||||||
'sidecar',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const DRY_RUN = process.env.DRY_RUN === '1';
|
const DRY_RUN = process.env.DRY_RUN === '1';
|
||||||
const emails = process.argv.slice(2).filter(Boolean);
|
const emails = process.argv.slice(2).filter(Boolean);
|
||||||
|
|||||||
@@ -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,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { CreateUserForm } from './CreateUserForm';
|
||||||
|
|
||||||
type ManagedUser = {
|
type ManagedUser = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -27,7 +28,14 @@ type ManagedUser = {
|
|||||||
isOwner: boolean;
|
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'];
|
const USERS_KEY = ['MANAGED_USERS'];
|
||||||
|
|
||||||
@@ -86,9 +94,11 @@ export const UsersSection = () => {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<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
|
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>
|
</p>
|
||||||
|
|
||||||
|
<CreateUserForm roles={data.assignableRoles} usersKey={USERS_KEY} />
|
||||||
|
|
||||||
<div className="rounded-lg border divide-y">
|
<div className="rounded-lg border divide-y">
|
||||||
{data.users.map((user) => {
|
{data.users.map((user) => {
|
||||||
const busy = pendingId === user.id;
|
const busy = pendingId === user.id;
|
||||||
@@ -114,7 +124,9 @@ export const UsersSection = () => {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<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}>
|
<SelectItem key={role} value={role}>
|
||||||
{role}
|
{role}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export {
|
|||||||
getUsers,
|
getUsers,
|
||||||
getUserById,
|
getUserById,
|
||||||
getUserByEmail,
|
getUserByEmail,
|
||||||
|
getUserByUsername,
|
||||||
getOwnerUser,
|
getOwnerUser,
|
||||||
getUserCount,
|
getUserCount,
|
||||||
createUser,
|
createUser,
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ export async function getUserByEmail(email: string): Promise<UserSelect | undefi
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `username` is unique in the schema, so this exists to turn a would-be constraint violation into a
|
||||||
|
// sentence. Creating an account is a form someone fills in, and "duplicate key value violates unique
|
||||||
|
// constraint users_username_unique" is not an answer to give them.
|
||||||
|
export async function getUserByUsername(username: string): Promise<UserSelect | undefined> {
|
||||||
|
const [user] = await db.select().from(users).where(eq(users.username, username));
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
// The owner: the one account with role 'Super Admin'. Sidecars that need "who is the owner" (e.g. the
|
// The owner: the one account with role 'Super Admin'. Sidecars that need "who is the owner" (e.g. the
|
||||||
// agent sidecar, which PM2 starts with no email in its env) resolve it here rather than being told by
|
// agent sidecar, which PM2 starts with no email in its env) resolve it here rather than being told by
|
||||||
// the main server.
|
// the main server.
|
||||||
@@ -27,8 +35,15 @@ export async function getUserByEmail(email: string): Promise<UserSelect | undefi
|
|||||||
// now makes, asserted a second way, and the two would part company the moment the owner was not user
|
// now makes, asserted a second way, and the two would part company the moment the owner was not user
|
||||||
// #1. Returns undefined rather than falling back to the lowest id when no row holds the role: the agent
|
// #1. Returns undefined rather than falling back to the lowest id when no row holds the role: the agent
|
||||||
// sidecar refusing to start beats it silently running as the wrong person.
|
// sidecar refusing to start beats it silently running as the wrong person.
|
||||||
|
//
|
||||||
|
// `order by id` is not cosmetic. Without it, two rows holding the role would make "who owns this
|
||||||
|
// server" whatever Postgres happened to return first — and that answer feeds the agent sidecar's
|
||||||
|
// identity, the vault and origin scoping. The write paths refuse to create a second Super Admin
|
||||||
|
// (create-user.ts and updateUserRoleHandler), so this should never have a choice to make; ordering is
|
||||||
|
// what makes the outcome deterministic if one ever gets in by another route, and id 1 is the bootstrap
|
||||||
|
// account the CHECK constraint already pins.
|
||||||
export async function getOwnerUser(): Promise<UserSelect | undefined> {
|
export async function getOwnerUser(): Promise<UserSelect | undefined> {
|
||||||
const [user] = await db.select().from(users).where(eq(users.role, 'Super Admin')).limit(1);
|
const [user] = await db.select().from(users).where(eq(users.role, 'Super Admin')).orderBy(users.id).limit(1);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { Handler } from 'hono';
|
||||||
|
import { createUser, getUserByEmail, getUserByUsername, USER_ROLES } from 'officerdb';
|
||||||
|
import type { UserRole } from 'officerdb';
|
||||||
|
import argon2 from 'argon2';
|
||||||
|
import * as errors from '@@/custom-errors';
|
||||||
|
import { provisionUserDirs } from '@@/data-path';
|
||||||
|
import { validatePassword } from '../auth/validate-password';
|
||||||
|
import { validateUsername } from '../auth/validate-username';
|
||||||
|
import { toPublicUser } from './manage-users';
|
||||||
|
|
||||||
|
// The owner creating a second account. Until this existed, `createUser` had exactly one call site —
|
||||||
|
// `auth/bootstrap.ts`, gated on an empty user table — so every non-owner account on any instance had
|
||||||
|
// been inserted into Postgres by hand.
|
||||||
|
//
|
||||||
|
// ── Why the owner sets the password ──
|
||||||
|
//
|
||||||
|
// The alternative is an invite: a token emailed to the person, who then sets their own. That is the
|
||||||
|
// better shape and it needs a mail path, a token table and an expiry policy. This is the honest
|
||||||
|
// intermediate: the owner types a password and tells the person, the same way they would hand over a
|
||||||
|
// wifi key. `passwordChangedAt` stays null, so nothing pretends the person chose it.
|
||||||
|
//
|
||||||
|
// ── Status is 'Active', deliberately ──
|
||||||
|
//
|
||||||
|
// The column defaults to 'Unverified' and `signin.ts` refuses anything that is not 'Active' with a bare
|
||||||
|
// UNAUTHORIZED. So an account created at the default would be indistinguishable from a wrong password,
|
||||||
|
// which is precisely the trap the hand-INSERT route fell into. An account the owner created in the admin
|
||||||
|
// UI is verified by definition — the owner is the verification.
|
||||||
|
|
||||||
|
/** Roles this route may assign. Never 'Super Admin' — see below. */
|
||||||
|
const ASSIGNABLE_ROLES = USER_ROLES.filter((r) => r !== 'Super Admin');
|
||||||
|
|
||||||
|
export const createUserHandler: Handler = async function (ctx) {
|
||||||
|
const body = (ctx.get('body') ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
|
||||||
|
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
||||||
|
const password = typeof body.password === 'string' ? body.password : '';
|
||||||
|
const role = typeof body.role === 'string' ? body.role : 'Member';
|
||||||
|
|
||||||
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw errors.BAD_REQUEST('Invalid email address');
|
||||||
|
if (!name) throw errors.BAD_REQUEST('Name is required');
|
||||||
|
|
||||||
|
// The same validators bootstrap uses. A member's password protects the same surface a member can
|
||||||
|
// reach, so there is no argument for a weaker rule here — and two different rules would mean the
|
||||||
|
// owner could create an account that could not then change its own password to something similar.
|
||||||
|
const username = validateUsername(typeof body.username === 'string' ? body.username : undefined);
|
||||||
|
validatePassword(password);
|
||||||
|
|
||||||
|
// Refused rather than filtered, so the owner is told instead of quietly getting a Member.
|
||||||
|
//
|
||||||
|
// There is exactly one owner. The database only pins user 1's role — a row-level CHECK cannot say
|
||||||
|
// "no OTHER row may hold this" — so a second Super Admin is storable, and `getOwnerUser()` would then
|
||||||
|
// return whichever the query reached first. That answer decides the identity the agent sidecar runs
|
||||||
|
// as, who reaches the vault and which origin is privileged, so it is not a thing to leave to a query
|
||||||
|
// plan. If the owner ever needs to hand the server over, that is a deliberate transfer, not a dropdown.
|
||||||
|
if (!(ASSIGNABLE_ROLES as readonly string[]).includes(role)) {
|
||||||
|
throw errors.BAD_REQUEST(
|
||||||
|
role === 'Super Admin'
|
||||||
|
? 'There is one server owner and it cannot be created here.'
|
||||||
|
: `Role must be one of: ${ASSIGNABLE_ROLES.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checked before the insert purely for the message — both columns are unique, so the database is the
|
||||||
|
// real guard and this is a race it can lose harmlessly (the insert then throws).
|
||||||
|
if (await getUserByEmail(email)) throw errors.CONFLICT('An account with that email already exists');
|
||||||
|
if (await getUserByUsername(username)) throw errors.CONFLICT('That username is taken');
|
||||||
|
|
||||||
|
const user = await createUser({
|
||||||
|
email,
|
||||||
|
password: await argon2.hash(password),
|
||||||
|
name,
|
||||||
|
username,
|
||||||
|
status: 'Active',
|
||||||
|
role: role as UserRole,
|
||||||
|
});
|
||||||
|
|
||||||
|
// After the row, and not fatal if it fails. A missing directory is repairable from a shell
|
||||||
|
// (`bun scripts/provision-user-dirs.ts <email>`); an account that half-exists because mkdir failed
|
||||||
|
// is not, and the owner would have to go into Postgres to clean it up — the exact thing this route
|
||||||
|
// is here to stop being necessary.
|
||||||
|
try {
|
||||||
|
provisionUserDirs(email);
|
||||||
|
} catch (ex) {
|
||||||
|
console.warn(`[users] created ${email} but could not provision its data directories`, ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.json({ user: toPublicUser(user) }, 201);
|
||||||
|
};
|
||||||
@@ -21,7 +21,8 @@ type PublicUser = {
|
|||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toPublicUser = (u: Awaited<ReturnType<typeof getUsers>>[number]): PublicUser => ({
|
/** Shared with create-user.ts, so a created account and a listed one are described the same way. */
|
||||||
|
export const toPublicUser = (u: Awaited<ReturnType<typeof getUsers>>[number]): PublicUser => ({
|
||||||
id: u.id,
|
id: u.id,
|
||||||
email: u.email,
|
email: u.email,
|
||||||
name: u.name,
|
name: u.name,
|
||||||
@@ -37,7 +38,10 @@ export const listUsersHandler: Handler = async function (ctx) {
|
|||||||
const users = await getUsers();
|
const users = await getUsers();
|
||||||
return ctx.json({
|
return ctx.json({
|
||||||
users: users.sort((a, b) => a.id - b.id).map(toPublicUser),
|
users: users.sort((a, b) => a.id - b.id).map(toPublicUser),
|
||||||
|
// Every role, so the owner's own row can display its value. The UI must not offer 'Super Admin' in a
|
||||||
|
// picker — both write paths refuse it — which is what `assignableRoles` is for.
|
||||||
roles: USER_ROLES,
|
roles: USER_ROLES,
|
||||||
|
assignableRoles: USER_ROLES.filter((r) => r !== 'Super Admin'),
|
||||||
ownerId: OWNER_USER_ID,
|
ownerId: OWNER_USER_ID,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -58,6 +62,15 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
|
|||||||
throw errors.FORBIDDEN('The server owner cannot be demoted.');
|
throw errors.FORBIDDEN('The server owner cannot be demoted.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// And nobody else can be promoted INTO it. The CHECK constraint pins user 1's role but cannot stop a
|
||||||
|
// second row holding it — a row-level check cannot see other rows — and `getOwnerUser()` resolves the
|
||||||
|
// owner by that role, so two holders make "who owns this server" a question the query plan answers.
|
||||||
|
// It decides the agent sidecar's identity, vault access and which origin is privileged. Handing the
|
||||||
|
// server over is a deliberate act, not a dropdown.
|
||||||
|
if (id !== OWNER_USER_ID && role === 'Super Admin') {
|
||||||
|
throw errors.FORBIDDEN('There is one server owner, and this is not how it changes.');
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await getUserById(id);
|
const existing = await getUserById(id);
|
||||||
if (!existing) throw errors.NOT_FOUND('User not found');
|
if (!existing) throw errors.NOT_FOUND('User not found');
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { isSuperAdmin } from '@@/super-admin';
|
|||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
import { updateUserHandler } from './update-user';
|
import { updateUserHandler } from './update-user';
|
||||||
import { listUsersHandler, updateUserRoleHandler, deleteUserHandler } from './manage-users';
|
import { listUsersHandler, updateUserRoleHandler, deleteUserHandler } from './manage-users';
|
||||||
|
import { createUserHandler } from './create-user';
|
||||||
import { capabilityAdminRouter } from './capabilities-routes';
|
import { capabilityAdminRouter } from './capabilities-routes';
|
||||||
|
|
||||||
export const usersRouter = createRouter();
|
export const usersRouter = createRouter();
|
||||||
@@ -24,6 +25,9 @@ const ownerGate: MiddlewareHandler = async (ctx, next) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
usersRouter.get('/', ownerGate, listUsersHandler);
|
usersRouter.get('/', ownerGate, listUsersHandler);
|
||||||
|
// POST, not PUT — and worth noting they sit one line apart. `PUT /` is the selfService exception every
|
||||||
|
// account may call on itself; `POST /` creates somebody else and is the owner's alone.
|
||||||
|
usersRouter.post('/', ownerGate, createUserHandler);
|
||||||
usersRouter.patch('/:id/role', ownerGate, updateUserRoleHandler);
|
usersRouter.patch('/:id/role', ownerGate, updateUserRoleHandler);
|
||||||
usersRouter.delete('/:id', ownerGate, deleteUserHandler);
|
usersRouter.delete('/:id', ownerGate, deleteUserHandler);
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,37 @@ export const getHomeDir = (email: string) => join(DATA_PATH, email, 'home');
|
|||||||
// terminals/chats/tasks share config and credentials with the shell they use outside Officer.
|
// terminals/chats/tasks share config and credentials with the shell they use outside Officer.
|
||||||
export const getOwnerHomeDir = (email: string): string => process.env.HOME_DIR ?? getHomeDir(email);
|
export const getOwnerHomeDir = (email: string): string => process.env.HOME_DIR ?? getHomeDir(email);
|
||||||
|
|
||||||
|
// The directory skeleton a new account gets under DATA_PATH.
|
||||||
|
//
|
||||||
|
// Most of these are also created on demand by whichever feature owns them, so pre-creating them buys
|
||||||
|
// legibility more than function — the tree shows what an account has without it having to be used first.
|
||||||
|
// `home` is the exception and the reason this exists: nothing else creates it, and it is where a
|
||||||
|
// non-owner's sessions would run.
|
||||||
|
//
|
||||||
|
// Single-sourced here rather than in the script that used to own the list, because there are now two
|
||||||
|
// callers — `scripts/provision-user-dirs.ts` and the owner's create-account handler — and a skeleton
|
||||||
|
// that differs depending on how the account was made is a bug nobody would think to look for.
|
||||||
|
export const USER_DIRS = [
|
||||||
|
'home',
|
||||||
|
'attachments',
|
||||||
|
'cache',
|
||||||
|
'dashboards',
|
||||||
|
'email_accounts',
|
||||||
|
'general_chat_sessions',
|
||||||
|
'logs',
|
||||||
|
'sidecar',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an account's root and its skeleton. Idempotent — an existing directory is left exactly as it is.
|
||||||
|
*
|
||||||
|
* Keyed on email because that is what the on-disk layout uses everywhere else (`DATA_PATH/<email>/…`).
|
||||||
|
* Renaming an account's email would orphan its directory; that is pre-existing and not this function's
|
||||||
|
* problem, but it is the reason nothing here derives a path from the id.
|
||||||
|
*/
|
||||||
|
export const provisionUserDirs = (email: string): void => {
|
||||||
|
for (const dir of USER_DIRS) mkdirSync(join(DATA_PATH, email, dir), { recursive: true });
|
||||||
|
};
|
||||||
|
|
||||||
export const getTmpAttachmentsDir = (email: string) => join(DATA_PATH, email, 'attachments', 'tmp');
|
export const getTmpAttachmentsDir = (email: string) => join(DATA_PATH, email, 'attachments', 'tmp');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user