diff --git a/.gitignore b/.gitignore index 6b8d93bf..cad2da45 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ src/apps/officer-web/index.gen.html # scratch scripts — never commit these *.tmp.ts + +# Sidecar assets published at install time — copies of files that live in each sidecar's own tree. +public/plugins/ diff --git a/CLAUDE.md b/CLAUDE.md index ebc36834..564fe6dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,7 +126,7 @@ history was deleted because it had drifted from the real schema. Treat the schem files, as the source of truth. **Declare multi-column uniqueness as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`** — -drizzle-kit mis-diffs named composite unique *constraints* and re-creates them on every push, which used +drizzle-kit mis-diffs named composite unique _constraints_ and re-creates them on every push, which used to stop `db:push` on an unanswerable truncate prompt. Same for any foreign key whose generated name would exceed Postgres's 63-character identifier limit: name it explicitly. See `src/databases/CLAUDE.md` → "Composite keys" before adding either. @@ -170,7 +170,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha survive the next door; refusing to boot does. So **adding a router means adding one line to `CAPABILITIES`**. If the surface genuinely is not -user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` *with a reason* — an unexplained exemption +user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` _with a reason_ — an unexplained exemption is how the hole happened the first time. The frontend hook `useCapabilities` **fails open** on purpose: hiding a dock icon is a courtesy, the @@ -219,12 +219,11 @@ so it rewrites every uncommitted file — including work in progress that isn't up as unexplained whitespace churn in someone else's diff. Run `bunx prettier --write ` on the files you actually touched. `bun format` is only safe when the tree is otherwise clean. - ## Code Style - **Paradigm**: functional — pure functions, immutability, composition - **TypeScript**: strict, no `any`. Type-only imports are required (`verbatimModuleSyntax`). -- **Comments**: minimal, and about *why*. Don't narrate what the code already says. +- **Comments**: minimal, and about _why_. Don't narrate what the code already says. - **Async**: always async/await - **Exports**: named only, no defaults - **Files**: `PascalCase.tsx` for components, `kebab-case.ts` for everything else @@ -281,6 +280,13 @@ type parameter and let inference flow from it. - **Stay focused** — note unrelated problems, don't fix them uninvited - **Report honestly** — say what you verified and what you didn't +**Always commit and push when you finish implementing — including when it went wrong.** Don't wait to be +asked, and don't hold a branch back because it is unfinished, untested or turned out to be a dead end. The +history of the mistakes is worth having: a reverted commit and its message explain why an approach was +abandoned, which is exactly the thing that gets lost when a failed attempt is quietly discarded. Say what +state it is in — in the commit message and in `COMMS/` if another agent will pick it up — rather than +withholding the commit until it is good. + Commit messages: simple lowercase, no prefixes. ## Frontend route conventions (apply to EVERY new dashboard route) @@ -316,7 +322,7 @@ link-focusable). Half the app still does this; none of the new code should. `f35c145`); **react-router's ``** for nav chrome, so active state comes from the router. The hand-rolled `isActive` in `Dock`/`Header` is scheduled for replacement (audit Phase 4) — don't copy it. A disabled entry renders as a ``; a disabled `` is not a thing. A control that - *mutates* rather than navigates stays a ` diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/CreateUserForm.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/CreateUserForm.tsx new file mode 100644 index 00000000..46c887cc --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/CreateUserForm.tsx @@ -0,0 +1,332 @@ +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 { Textarea } from '@/components/ui/textarea'; +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', sshPublicKey: '' }; + +/** + * What the server did, shown after the fact. + * + * Kept on screen rather than announced in a toast because two of these values are only obtainable now: the + * password is stored as an argon2 hash, and the generated public key sits in a 700 home. A toast that + * carries something unrecoverable is a toast that gets dismissed by a stray click. + */ +type CreatedAccount = { + email: string; + password: string; + osUser: string | null; + osSshPublicKey: string | null; + osUserError: string | null; +}; + +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 [created, setCreated] = useState(null); + + const set = (key: keyof typeof EMPTY) => (value: string) => setForm((prev) => ({ ...prev, [key]: value })); + + const close = () => { + setOpen(false); + setForm(EMPTY); + setCreated(null); + }; + + const submit = async (ev: React.FormEvent) => { + ev.preventDefault(); + setSaving(true); + try { + const res = await client.post<{ + user: { osUser: string | null; osSshPublicKey: string | null }; + osUserError: string | null; + }>('/users', form); + await queryClient.invalidateQueries({ queryKey: usersKey }); + setCreated({ + email: form.email, + password: form.password, + osUser: res.user.osUser, + osSshPublicKey: res.user.osSshPublicKey, + osUserError: res.osUserError, + }); + } 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); + } + }; + + const copy = (value: string, what: string) => { + void navigator.clipboard.writeText(value); + toast.success(`${what} copied`); + }; + + // ── After creation ── + // + // Deliberately a wall you have to dismiss. Both values below are unrecoverable once this closes, and the + // public key has a job attached to it that nothing else will remind you to do. + if (created) { + return ( +
+
+

{created.email} created

+

+ Copy what you need before closing — none of it can be shown again. +

+
+ +
+ +
+ + +
+

+ Stored as a hash — this is the only time it exists in readable form. They can change it from their own + profile once signed in. +

+
+ + {created.osUser && ( +
+ + +
+ )} + + {created.osSshPublicKey && ( +
+ +
+