diff --git "a/web/src/settings/SettingsModal.tsx" "b/web/src/settings/SettingsModal.tsx" --- "a/web/src/settings/SettingsModal.tsx" +++ "b/web/src/settings/SettingsModal.tsx" @@ -1,1316 +1,1316 @@ -// --------------------------------------------------------------------------- -// settings / SettingsModal.tsx — EXIT wave 2 (W2-8, contract Y4). -// -// Settings and Manage users as ONE centred modal with a left rail, matching the -// shipped Streamlit design (`app.py:settings_dialog`, item 8) and the reference -// the owner named: *"when I click 'Setting' this whole thing pops up? I want -// that exact thing for when we click Settings and Manage User, where the -// background is clearly blurred too."* -// -// ⚠ WAVE 19 R12 — THE SURFACE IS CALLED "SETTINGS" AGAIN, and this note replaces -// the wave-15 one that said the opposite ("the surface is called PROFILE now, in -// this shell only"). The owner has ruled the rename back: one account-menu row, -// called Settings, with a gear, shown to everyone, landing on Account. The file, -// the export and the `SettingsSection` type were never renamed in either -// direction — which is exactly why the wave-15 trade was the right one to make -// and the cheap one to undo. A LABEL moved twice; no import ever did. -// -// ⛔ `verify_ui.py`'s naming check enforced the wave-15 wording, so the correct -// edit would have turned it red. It is inverted in the same change (bare -// "Profile" must not survive as copy; bare "Settings" must be PRESENT) and -// booked as wave-19 amendment 1. A gate that pins a superseded ruling is not a -// gate, it is a second place the old decision lives. -// -// A MODAL, NOT A PAGE, and the shipped version's reasoning holds here too: -// settings are a detour, not a destination — you change a toggle and go back to -// what you were doing. The Streamlit app kept a settings PAGE alongside the -// modal because a deep link is a poor thing to land on a modal; the shell has -// no settings route at all, so there is nothing to keep. -// -// ⛔ EVERY ADMIN CONTROL HERE IS A COURTESY, NOT A CHECK. The server refuses a -// non-admin on every `/admin/*` route, fail-closed, and Y4's gate proves it by -// having a viewer try. Hiding the rail entry only spares a member a door that -// would not open. -// --------------------------------------------------------------------------- - -import { useCallback, useEffect, useRef, useState } from "react"; -import type { ReactNode } from "react"; -import type { SessionUser } from "../shell/session"; -import { isAdmin, parseUserEnvelope } from "../shell/session"; -import type { AdminUser, SettingsPayload } from "./settingsApi"; -// ⚠ `patchUser` and `setPassword` left this file with `EditUser` (R7) — they are -// imported by `PermsEditor` now, where the form that writes them lives, and it -// spells the patch shape `Parameters[1]` rather than importing -// `UserPatch` separately. `noUnusedLocals` is what walks a move like this down to -// the last orphaned import, which is the tsconfig earning its keep again. -import { clearAvatar, createUser, getSettings, listUsers, setAvatar } from "./settingsApi"; -import { - addKeychainEntry, - deleteKeychainEntry, - getConnectors, - listKeychain, - setKeychainScope, - pauseConnector, - testKeychainEntry, -} from "./settingsApi"; -import type { ConnectorRow, KeyEntry, UnsyncedInfo } from "./settingsApi"; -import { PermsEditor } from "./PermsEditor"; -// Wave 19 contract C2 — session D's self-contained plane; see the mount below. -import { AdminPane } from "./AdminPane"; -import { ManageAgentPane } from "../manage-agent/ManageAgentPane"; -import { StatementsPane } from "./StatementsPane"; -import { moduleSummary, parseEntry, reachableSection } from "./permsModel"; - -// The vocabulary lives beside the rule that gates it (permsModel), and is -// re-exported here so `Shell.tsx`'s import does not have to know that. -export type { SettingsSection } from "./permsModel"; -import type { SettingsSection } from "./permsModel"; - -// ⛔ `BU_LABELS`, `BU_IDS` and `scopeWords` ALL WENT WITH THE PICKER (R1). -// Nothing offers, writes or RENDERS a business unit in this shell any more — -// the last reader was the accounts list, and its column is now "Access". The -// concept survives only as a legacy value on the record, which S1's migration -// converts to a permanent filter once. `noUnusedLocals` is what walked the -// removal down to the last orphan, which is the tsconfig earning its keep. - -function moduleWords(mods: string[] | "all"): string { - // ⚠ `[]` READS as unrestricted — the existing semantic Y4 says not to "fix" - // this wave. Saying "All modules" here is therefore the TRUE statement, and - // the write side is what refuses to create the ambiguity (see settingsApi). - if (mods === "all" || !Array.isArray(mods) || mods.length === 0) return "All modules"; - return `${mods.length} module${mods.length === 1 ? "" : "s"}: ${mods.join(", ")}`; -} - -// ------------------------------------------------------------------ the users pane - -/** The trailing affordance on an account card. SVG, never a character glyph. */ -function ChevIcon() { - return ( - - ); -} - -// ── WAVE 19 R13: one mark per rail tab ─────────────────────────────────────── -// -// The rail was five words in a column, and at a glance five words in a column is -// a list you read rather than a place you navigate. A mark per row is what makes -// "Keychains" findable without reading "Connectors" first. -// -// ⚠ PURE FUNCTIONS OF NOTHING, and that is a requirement here rather than a -// style: `verify_ui.py` renders this module through `react-dom/server`, where -// `window` and `document` do not exist. An icon that measured anything, or read -// a theme off the DOM at module scope, would take the whole gate down with it — -// and the failure would look like a broken gate rather than a broken icon. -// SVG only, never emoji (the platform's own standing rule). -function RailIcon({ children }: { children: ReactNode }) { - return ( - - ); -} - -/** Account — the person whose settings these are. */ -const IconAccount = ( - - - - -); -/** Your access — a closed padlock: what this account may open, and what narrows it. */ -const IconAccess = ( - - - - -); -/** Manage users — more than one person. */ -const IconUsers = ( - - - - - -); -/** Manage agent — a channel bot. Deliberately NOT a second person glyph: the - * principal here is a CHANNEL, and a rail that draws it as a user invites the - * per-person reading the pane's first sentence exists to correct. A speech - * bubble with a machine mark inside it, in the same stroke weight as the rest. */ -const IconAgent = ( - - - - - -); -/** Keychains — a key. */ -const IconKey = ( - - - - -); -/** Connectors — two links joined: where the data comes from. */ -/** Statements — an envelope. The one room in here that sends something OUT of - * the building, so it wears the only outbound-shaped mark on the rail. */ -const IconMail = ( - - - - -); -const IconPlug = ( - - - - - -); -/** Loopable admin — stacked plates, i.e. MANY tenants. Deliberately not another - * lock or shield: "Your access" already wears a padlock, and two authority - * glyphs in one rail would read as two grades of the same thing rather than as - * a different scope entirely. */ -const IconTenants = ( - - - - -); - -function UsersPane() { - const [users, setUsers] = useState(null); - const [error, setError] = useState(""); - const [notice, setNotice] = useState(""); - /** - * ⚠ WAVE 17 R7 — ONE STATE, WHERE THERE WERE TWO. This pane held `editing` - * (the inline account-details card, at the BOTTOM of the list) and `permsFor` - * (the full-pane access page) as separate things, on the wave-15 reasoning - * that *"they are two different jobs: one changes who the account IS, the - * other changes what it may SEE"*. True about the data, wrong about the - * person: an admin opening an account does not know in advance which of the - * two they came for, and the product offered them two doors, one of which - * scrolled the page instead of changing it. The owner met that as *"Edit and - * clicking a user name should just be the EXACT same opening of a full page - * so its not confusing"*. There is one door now, and one thing behind it. - */ - const [open, setOpen] = useState(null); - - const reload = useCallback(() => { - void listUsers().then((r) => { - if (r.ok) { - setUsers(r.data.users ?? []); - setError(""); - } else { - setUsers([]); - setError(r.message); - } - }); - }, []); - - useEffect(reload, [reload]); - - // wave17 item 3 (R6). ⚠ `.pg-empty` was the WRONG BOX as well as the wrong - // words: it is the dashed empty-state frame, so a list that was merely still - // arriving was drawn in the chrome that means "there is nothing here". - if (users === null) - return ( -
- -
- ); - - // The FULL-PANE TAKEOVER: the users pane IS the modal body, so returning the - // page here replaces the whole body and nothing else has to know. The rail - // stays — it is chrome, not body, and an admin who wants out of this page - // entirely should not have to find a Back button first. - if (open) { - const who = users.find((u) => u.username === open); - // The record can genuinely vanish under an open page: `reload()` runs after - // every write, and another admin may have removed the account in between. - // Saying so beats rendering a page about nobody. - if (!who) { - return ( -
-
-

That account is no longer listed

-

It may have been removed since this list was loaded.

-
- -
-
-
- ); - } - return ( - u.name || u.username)} - copyTargets={users.filter((u) => u.username !== who.username)} - onBack={() => setOpen(null)} - // ⚠ IT DOES NOT CLOSE THE PAGE ANY MORE. Wave 15's version returned to - // the list on save, which was right when this was only an access - // editor. It is one page with several things to save now, and a page - // that ejects you after the first of them is a page you have to - // re-open to finish the job. The way out stays visible instead. - onSaved={(msg) => { - setNotice(msg); - setError(""); - reload(); - }} - /> - ); - } - - return ( -
- {/* ⚠ WAVE 19 (owner item 9) — THE HEADING MATCHES THE RAIL ENTRY THAT - OPENS IT. It said "Accounts" while the only way in was a tab reading - "Manage users", so the pane appeared to be somewhere other than the - place that was clicked — the cheapest kind of "am I in the right - screen?" there is. The rail is the name; the body agrees with it. */} -

Manage users

- {error ?

{error}

: null} - {notice ?

{notice}

: null} - - {users.length === 0 && !error ? ( -

No accounts are configured on this deployment yet.

- ) : ( - /* ⚠ WAVE 17 R7 — A LIST OF CARDS, NOT A TABLE, and the shape is the - feature. The owner asked to "click the whole user's Card, not just - name, so it has bigger hit box": in a table the only click target - that could exist was the name itself, because a `` is - invisible to a keyboard and wrapping every cell in a button is five - tab stops per row. One ` - - ))} - - )} - - { - setNotice(msg); - setError(""); - reload(); - }} - onError={setError} - /> -
- ); -} - -// ⛔ `BuPicker` WAS HERE, AND IT IS NOT COMING BACK (wave 15, R1). Business unit -// died as a USER-FACING CONCEPT: BU access is now one permanent filter among -// others under the per-module permissioning engine — "dba is any of [Fisch, -// Both]" says exactly what the two checkboxes used to say, in the same grammar -// as every other restriction, and an admin who wants a different cut is no -// longer limited to the two cuts somebody hard-coded. -// -// The picker's old help text is worth keeping in one form, because the property -// it named still holds and is still the point: a member scoped to one unit sees -// no other unit's data anywhere. What changed is WHERE that is enforced — the -// permanent filter and its server-side wall (C-PERM), not a `bus` list. -// -// ⚠ `bus` still exists on the RECORD and in `AdminUser` during the strangler -// period (legacy Streamlit pages read it; S1's migration converts it once). What -// no longer exists is a UI that WRITES it — see EditUser/AddUser below, which -// deliberately stopped sending the field they can no longer edit. - -// ⛔ `EditUser` WAS HERE. It is not deleted so much as RELOCATED — every control -// it held (display name, role, deactivate/reactivate, password reset with its -// signs-them-out-everywhere warning) now lives at the top of the user page in -// `PermsEditor`, which is the whole of R7: *"Edit and clicking a user name -// should just be the EXACT same opening of a full page"*. What genuinely died -// is the SHAPE — a card that appeared at the bottom of the accounts list, below -// the fold, describing a row somewhere above it. Two doors, two layouts and one -// of them scrolled instead of navigating. -// -// ⚠ The load-bearing note from this block MOVES WITH THE FORM and is restated -// there: the PATCH still carries no `bus` (R1). With the picker gone the form -// cannot edit that field, so sending it would be a client echoing back a value -// it only read — and once the migration converts `bus` into a permanent filter -// and clears the field, that echo would RESURRECT it. - -function AddUser({ - onCreated, - onError, -}: { - onCreated: (msg: string) => void; - onError: (msg: string) => void; -}) { - const [open, setOpen] = useState(false); - const [username, setUsername] = useState(""); - const [name, setName] = useState(""); - const [role, setRole] = useState("user"); - const [password, setPassword_] = useState(""); - const [busy, setBusy] = useState(false); - - if (!open) - return ( - - ); - - const valid = username.trim().length > 0 && password.length > 0; - return ( -
-

New account

-
- - setUsername(e.target.value)} - /> - {/* S1's Y4 amendment 1, surfaced as guidance rather than as a 409. */} -

- Must be new. To change an existing account, edit it above — re-creating - one would revive sessions that were revoked. -

-
-
- - setName(e.target.value)} /> -
-
- - -
-
- - setPassword_(e.target.value)} - /> - {/* Said before the click, because a new account is created OPEN and is - narrowed afterwards. That order is the server's (an absent `bus` - defaults to every unit) and this wave does not change it — what the - wave changes is that "afterwards" now leads somewhere: open the - account and set its per-module access, filters and hidden fields. */} -

- A new account starts with access to everything this deployment shows. - Open it from the list afterwards to set what they may see. -

-
-
- - -
-
- ); -} - -// ------------------------------------------------------------------ profile photo (wave 14) - -/** Downscale + centre-crop to a <=128px JPEG data URL. JPEG at this size lands ~5-10KB, - * comfortably under the server's 64KB decoded wall; the server re-validates regardless. */ -async function fileToAvatarDataUrl(file: File): Promise { - const url = URL.createObjectURL(file); - try { - const img = new Image(); - await new Promise((res, rej) => { - img.onload = () => res(); - img.onerror = () => rej(new Error("unreadable image")); - img.src = url; - }); - if (!img.width || !img.height) return null; - const side = Math.min(128, Math.max(img.width, img.height)); - const canvas = document.createElement("canvas"); - canvas.width = side; - canvas.height = side; - const ctx = canvas.getContext("2d"); - if (!ctx) return null; - const s = Math.min(img.width, img.height); - ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, side, side); - return canvas.toDataURL("image/jpeg", 0.85); - } catch { - return null; - } finally { - URL.revokeObjectURL(url); - } -} - -/** The first letter of a name, the way both shells compute it. - * ⚠ Unicode letter/number, not `[a-z0-9]` — the app's `_account_css` uses - * Python's `str.isalnum()`, so an ASCII-only class here would give a non-Latin - * name an initial in one door and a blank circle in the other. */ -function initialOf(name: string): string { - return (name.match(/[\p{L}\p{N}]/u)?.[0] ?? "?").toUpperCase(); -} - -/** - * ⚠ WAVE 17 (16b) — THE INLINE STYLES ARE GONE, AND SO IS THEIR JUSTIFICATION. - * This block used to carry twelve declarations in a `style` object under the - * note *"the `.set-*`/`.shell-*` stylesheet is another wave-14 session's file"*. - * That was true then and is not true now — SHELL owns the `.set-*` region this - * wave — and an inline `background: "var(--lp-blue-tint)"` is still a colour - * decision living outside the stylesheet, which is the thing the file-wide - * token rule exists to stop. Two sizes are needed (the identity header and an - * accounts card), so there are two classes rather than a size prop threading a - * number into CSS. - */ -function AvatarPreview({ user, large }: { user: SessionUser; large?: boolean }) { - const cls = large ? "set-avatar set-avatar--lg" : "set-avatar"; - if (user.avatar) { - return ; - } - return ( - - ); -} - -/** The accounts list's monogram. A separate component because `AdminUser` - * carries no `avatar` — only the SIGNED-IN user's photo is on the wire — so - * offering a photo slot here would be a hole nothing can fill. */ -function Monogram({ name }: { name: string }) { - return ( - - ); -} - -/** The role, as a chip rather than a table cell (16b). Two roles, one of which - * bypasses every rule in the product — that is a property of the account, and a - * property belongs beside the name, not in a row of key/value pairs. */ -function RoleChip({ admin }: { admin: boolean }) { - return ( - - {admin ? "Admin" : "Member"} - - ); -} - -function ProfilePhotoBlock({ - user, - admin, - onUser, -}: { - user: SessionUser; - admin: boolean; - onUser?: (u: SessionUser) => void; -}) { - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - const fileRef = useRef(null); - - const upload = useCallback( - async (file: File) => { - setBusy(true); - setError(""); - const dataUrl = await fileToAvatarDataUrl(file); - if (!dataUrl) { - setBusy(false); - setError("That file could not be read as an image."); - return; - } - const r = await setAvatar(dataUrl); - setBusy(false); - if (r.ok) { - const fresh = parseUserEnvelope(r.data); - if (fresh) onUser?.(fresh); - } else { - setError(r.message); - } - }, - [onUser] - ); - - const remove = useCallback(async () => { - setBusy(true); - setError(""); - const r = await clearAvatar(); - setBusy(false); - if (r.ok) { - const fresh = parseUserEnvelope(r.data); - if (fresh) onUser?.(fresh); - } else { - setError(r.message); - } - }, [onUser]); - - // ⛔ THE HELP SENTENCE IS DELETED (wave 17, item 16b, owner's instruction: - // "replace with layout, not copy"). It read "Shown on Assignee fields and your - // account chip. Square images look best; large ones are scaled down - // automatically." Every clause was work the interface should do rather than - // explain: WHERE the photo appears is answered by showing it at the size and - // shape it will be worn, beside the name it belongs to; SQUARE IS BEST and - // LARGE ONES ARE SCALED are apologies for a centre-crop-and-downscale that - // already happens without asking (`fileToAvatarDataUrl`) and that the user - // cannot influence. A sentence describing behaviour the reader has no control - // over is decoration. - return ( -
- -
-
- {user.name} - -
-
{user.username}
-
- { - const f = e.target.files?.[0]; - e.target.value = ""; - if (f) void upload(f); - }} - /> - - {user.avatar ? ( - - ) : null} -
- {error ?

{error}

: null} -
-
- ); -} - -// ------------------------------------------------------------------ the modal - -export function SettingsModal({ - user, - section, - onSection, - onClose, - onUser, - moduleLabels, -}: { - user: SessionUser; - section: SettingsSection; - onSection: (s: SettingsSection) => void; - onClose: () => void; - /** Wave 14 C-AVATAR — the shell's session setter, so a photo change updates the account - * chip live instead of on the next full reload. Optional: absent, the photo still saves. */ - onUser?: (u: SessionUser) => void; - /** Registry key → the label the nav already shows for it. Passed down rather - * than fetched or hardcoded: the shell holds a server-filtered nav with every - * label in it, and a second copy of that mapping in this file would be a - * place for "Customer" to become "Customers" in one door only. Absent ⇒ rows - * fall back to the key, which is honest if ugly. */ - moduleLabels?: Record; -}) { - const [settings, setSettings] = useState(null); - const admin = isAdmin(user); - // Wave 19 (C2) — `=== true`, not truthy: absent or malformed reads as NO. - const platformAdmin = settings?.platformAdmin === true; - - useEffect(() => { - void getSettings().then((r) => { - if (r.ok) setSettings(r.data); - }); - }, []); - - // Escape closes. A modal a keyboard cannot dismiss is a trap, and this one - // covers the whole application. - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onClose]); - - // WAVE 19 R13 — the tuple grew a third member: the tab's mark. ONE data change - // and one line in the render loop below, which is the whole of the ruling. - const rail: Array<[SettingsSection, string, ReactNode]> = [ - ["account", "Account", IconAccount], - ["scope", "Your access", IconAccess], - ...(admin - ? ([ - ["users", "Manage users", IconUsers], - // ⭐ WAVE 33, owner item 10 — placed by the owner, verbatim: *"under a - // module called 'Manage agent' which should be under 'Manage user'"*. - // Immediately after `users`, so "under" is literal in the rail rather - // than a tab hidden inside the other room. - ["agents", "Manage agent", IconAgent], - // Wave 18 (C7): the tenant's credential store + data-source status board. - ["keychains", "Keychains", IconKey], - ["connectors", "Connectors", IconPlug], - // EXIT-6: the statement sender, ported off app.py. Admin-only, and the - // server ALSO refuses it to any tenant but Royal (the send client is - // env-credentialed) — this entry is the courtesy, not the wall. - ["statements", "Statements", IconMail], - ] as Array<[SettingsSection, string, ReactNode]>) - : []), - // Wave 19 (R3 / C2): the Loopable admin plane. Gated on the SERVER's - // `platformAdmin`, never on `admin` — R3 is explicit that a tenant-scoped - // admin does not qualify, so this entry appears for exactly one account and - // is invisible to every tenant admin in the product. - ...(platformAdmin - ? ([["padmin", "Loopable admin", IconTenants]] as Array< - [SettingsSection, string, ReactNode] - >) - : []), - ]; - // A non-admin deep-linked to the users pane lands on Account rather than on - // an empty frame. The server would refuse the data anyway; this just avoids - // showing a room with nothing in it. The rule is a NAMED function so the gate - // can negative-control it — see `permsModel.reachableSection`. - // - // ⛔ `padmin` IS GATED HERE, NOT IN THAT FUNCTION, and the reason is that its - // input does not exist there. `reachableSection` takes `admin` — a boolean off - // the user record — while `platformAdmin` arrives asynchronously on the - // settings payload and (per R3) is NOT implied by tenant admin. Passing it - // through `reachableSection` would have meant either widening that function's - // signature and re-aiming a negative control that guards a different rule, or - // conflating the two admin concepts the ruling exists to keep apart. Composed - // instead: the section falls back to Account until the server has said yes. - const active = - section === "padmin" && !platformAdmin ? "account" : reachableSection(section, admin); - - return ( -
{ - if (e.target === e.currentTarget) onClose(); - }} - > - {/* ⚠ WAVE 19 R12 — the modal is "Settings", and the ACCESSIBLE name tracks - the visible one, which is the half that is easy to forget. A dialog a - screen reader announces as "Profile" while the rail reads "Settings" is - the same drift as a stale comment, only harder to notice: nobody - looking at the screen can see it. Manage users is still a SECTION - inside it (R12 is a rename, not a re-architecture). */} -
- - -
- - - {/* Wave 33 (owner item 10), wired the way contract C2's lesson demands - a new surface be wired: at the MOUNT SITE, not by an import that a - grep would mistake for a render. The pane is self-contained — it - fetches its own data and owns its loading/empty/error states — and - `reachableSection` has already bounced a non-admin to Account. */} - {active === "agents" ? : null} - {active === "keychains" ? : null} - {active === "connectors" ? : null} - {active === "statements" ? : null} - {/* Wave 19 contract C2 — the wiring record, verified at the MOUNT SITE - (the wave-14 lesson: an occurrence-grep proves an import, not a - render): `settings/AdminPane.tsx → SettingsModal.tsx pane switch → - prop user`. The pane is session D's, self-contained: it fetches its - own data and owns its loading/empty/error states, so mounting it - cannot fail on plumbing. Its prop is typed STRUCTURALLY on D's side - ({username; name; role}), so the shell's `SessionUser` satisfies it - by structural typing — no cast here, and no import from `shell/` - there. `active` cannot be "padmin" unless the server said so. */} - {active === "padmin" ? : null} - - {active === "account" ? ( -
-

Account

- {/* ⚠ WAVE 17 (16b) — THE THREE KEY/VALUE ROWS ARE NOT DELETED, - THEY ARE LAID OUT. "Signed in as", "Username" and "Role" were a - three-row table restating what a photo, a name and a chip say - at a glance — and the photo block sat UNDER them, so the pane - opened on a label reading "Signed in as" above the picture of - the person it was labelling. The identity header carries all - three now: the name at title weight, the username beneath it, - the role as a chip beside it. Nothing was dropped; the labels - were, because each one named the obvious. */} - -
- ) : null} - - {active === "scope" ? ( -
-

Your access

- {/* ⛔ THE "Business units" ROW IS GONE (R1) — the concept it - reported no longer exists on this side of the product. It is - NOT replaced by nothing: what shapes a restricted account's - rows is now a permanent filter per module, and the rows below - say so. A pane reporting "All modules" while a filter quietly - halves somebody's book is worse than the row it replaced — - that is the user who files a bug about the numbers. */} -

- What this account may open, and anything that narrows it. Only an - administrator can change this. -

- {/* 16b — one card, so the restrictions read as a group belonging to - the heading rather than as loose rows floating under it. */} -
-
- Modules - - {moduleWords(settings?.scope.modules ?? user.modules)} - -
- {/* An admin's "All modules" is true but does not say WHY, and the - why is the part that matters: it is not a grant somebody can - revoke module by module, it is a bypass. The accounts list - already says "Everything (admin)" — this is the same fact, on - the account's own page, so the two doors agree. */} - {admin ? ( -
- Restrictions - - None apply — administrators bypass every rule - -
- ) : null} - {/* One row per module the account is restricted in. `moduleSummary` - is the SAME sentence the admin editor shows, from the same - function — two dialects of "2 conditions, 3 fields hidden" - would eventually disagree about what a rule says. Admins are - skipped: they bypass perms entirely, so any stored rule is - inert and printing it here would be a lie about their access. */} - {!admin - ? Object.entries(settings?.scope.perms ?? {}).map(([key, raw]) => { - const entry = parseEntry(raw); - const words = moduleSummary(entry); - // "Full access" adds nothing beside the Modules row above; - // only a REAL narrowing earns a line. - if (words === "Full access") return null; - return ( -
- {moduleLabels?.[key] ?? key} - {words} -
- ); - }) - : null} -
-
- ) : null} - - {active === "users" ? : null} -
-
-
- ); -} - -// ── Wave 18 (C7): the Keychain pane ───────────────────────────────────────────────────────── -// Secrets go IN and never come back out: rows carry a masked preview, the add form clears on -// success, and there is no reveal affordance by design (the server has no route for one). -/** - * ⭐⭐ WAVE 32 · R4 / contract C1 — WHO A CONNECTION IS FOR, in the two words the ruling uses. - * - * ⛔ THE VOCABULARY IS THE SERVER'S. `routes_keychain.SCOPES` declares it and `verify_meta` - * asserts these two literals appear on both sides, so the label map is a PRESENTATION of a - * server word and never a client-side union — a `type Scope = …` here would turn "the server - * added a scope" into "the client drops the row" (the wave-9 law). - */ -const SCOPE_LABEL: Record = { - business: "Business-wide", - personal: "Personal", -}; -const scopeLabel = (s: string | undefined): string => - SCOPE_LABEL[String(s || "business")] || String(s); - -function KeychainPane() { - const [entries, setEntries] = useState([]); - const [locked, setLocked] = useState(false); - const [loaded, setLoaded] = useState(false); - const [err, setErr] = useState(""); - /** R4: only an administrator may make a connection business-wide. Served, not inferred from - * the account record — the server is the wall and the client renders what it will honour. */ - const [canBusiness, setCanBusiness] = useState(false); - const [tenantWide, setTenantWide] = useState([]); - const [form, setForm] = useState<{ - type: "odoo" | "generic"; - label: string; - scope: string; - fields: Record; - busy: boolean; - } | null>(null); - const [testResult, setTestResult] = useState>({}); - - const reload = useCallback(async () => { - const r = await listKeychain(); - if (r.ok) { - setEntries(r.data.entries); - setLocked(r.data.locked); - setCanBusiness(Boolean(r.data.canBusiness)); - setTenantWide(Array.isArray(r.data.tenantWideTypes) ? r.data.tenantWideTypes : []); - setErr(""); - } else { - setErr(r.message); - } - setLoaded(true); - }, []); - useEffect(() => { - void reload(); - }, [reload]); - - /** The scope a NEW key of this type may take. `odoo` and `meta_ads` are what the whole - * workspace's databases are read through, so the server refuses to make them personal — the - * picker says so here instead of letting the save fail. */ - const forcedBusiness = (t: string) => tenantWide.includes(t); - - const submit = async () => { - if (!form || form.busy) return; - setForm({ ...form, busy: true }); - const fields = Object.fromEntries( - Object.entries(form.fields).filter(([, v]) => v.trim() !== "") - ); - const r = await addKeychainEntry(form.label, form.type, fields, form.scope); - if (r.ok) { - setForm(null); - void reload(); - } else { - setErr(r.message); - setForm({ ...form, busy: false }); - } - }; - - const odooFields: Array<[string, string]> = [ - ["url", "Server URL (https://...)"], - ["db", "Database"], - ["user", "API user"], - ["api_key", "API key"], - ]; - - return ( -
-

Keychains

-

- Credentials for this workspace's data sources, encrypted at rest. A stored secret is - never shown again — only its last four characters identify it. -

- {locked ? ( -

- The keychain is locked: the platform's AIOS_KEYCHAIN_KEY is not configured on this - server. Entries can be listed but nothing can be added or decrypted. -

- ) : null} - {err ?

{err}

: null} - {!loaded ? ( -

Loading…

- ) : entries.length === 0 ? ( -

No keys stored yet.

- ) : ( -
- {entries.map((e) => ( -
-
- {e.label} - - {e.type} - {e.preview ? " · " + e.preview : ""} · {scopeLabel(e.scope)} - {e.scope === "personal" && e.owner ? " (" + e.owner + ")" : ""} · added by{" "} - {e.createdBy || "?"} - - {testResult[e.id] ? ( - {testResult[e.id]} - ) : null} -
-
- {/* ⭐ R4's scope door. Shown ONLY where it can be honoured: a member cannot make - anything business-wide, and `odoo`/`meta_ads` are business-wide by - construction — offering a control that the server would refuse is the - "button that leads nowhere" R7 banned one module over. */} - {canBusiness && !forcedBusiness(e.type) ? ( - - ) : null} - - -
-
- ))} -
- )} - {form ? ( -
-
- - -
- {/* ⭐⭐ WAVE 32 · R4 — WHO IS THIS CONNECTION FOR. Two options, no more: the ruling says - there is a business-wide kind and a personal kind, and business-wide is an - administrator's to give. A member sees the sentence instead of a disabled radio, - because a control you cannot use is worse than a line telling you why. */} -
- - {forcedBusiness(form.type) ? ( -

- Business-wide. This workspace's databases are read through an {form.type}{" "} - connection, so it applies to everyone here and cannot be personal. -

- ) : canBusiness ? ( - - ) : ( -

- Personal — only you. An administrator can add a connection for the whole - workspace. -

- )} -
-
- - setForm({ ...form, label: e.target.value })} - /> -
- {(form.type === "odoo" - ? odooFields - : ([ - ["name", "Name"], - ["value", "Secret value"], - ] as Array<[string, string]>) - ).map(([k, label]) => ( -
- - - setForm({ ...form, fields: { ...form.fields, [k]: e.target.value } }) - } - /> -
- ))} -
- - -
-
- ) : ( - - )} -
- ); -} - -// ── Wave 18 (C7): the Connectors pane — sources + R3's Unsynced-records guardrail ─────────── -function ConnectorsPane() { - const [rows, setRows] = useState([]); - const [note, setNote] = useState(""); - const [unsynced, setUnsynced] = useState(null); - const [showOrphans, setShowOrphans] = useState(false); - const [loaded, setLoaded] = useState(false); - const [err, setErr] = useState(""); - - const reload = useCallback(async () => { - const r = await getConnectors(); - if (r.ok) { - setRows(r.data.connectors); - setNote(r.data.pausedNote || ""); - setUnsynced(r.data.unsynced ?? null); - setErr(""); - } else { - setErr(r.message); - } - setLoaded(true); - }, []); - useEffect(() => { - void reload(); - }, [reload]); - - return ( -
-

Connectors

-

- Where this workspace's data comes from. Add credentials under Keychains; sources appear - here with their status. -

- {err ?

{err}

: null} - {!loaded ? ( -

Loading…

- ) : rows.length === 0 ? ( -

- No connectors yet. Store a key under Keychains to register a source — nothing syncs - until a later step turns it on. -

- ) : ( -
- {rows.map((r) => ( -
-
- {r.label} - - {r.type} · {r.source === "env" ? "environment credentials" : "keychain"} - {r.paused ? " · paused" : " · active"} - -
-
- -
-
- ))} -
- )} - {note ?

{note}

: null} - {unsynced ? ( -
- Unsynced records - {unsynced.known ? ( - unsynced.count === 0 ? ( -

- None — every record holding notes or custom-field data matches a row the - connector currently serves. -

- ) : ( - <> -

- {unsynced.count} record{unsynced.count === 1 ? "" : "s"} carry saved data - (notes, custom fields) but no longer match a synced row. Nothing is deleted — - they reattach by ID if the source serves them again. -

- - {showOrphans ? ( -
    - {unsynced.rows.map((o) => ( -
  • - #{o.pid} — {o.fields} saved field{o.fields === 1 ? "" : "s"} - {o.hint ? " · " + o.hint : ""} -
  • - ))} -
- ) : null} - - ) - ) : ( -

- Count unavailable right now{unsynced.note ? " (" + unsynced.note + ")" : ""} — the - pool could not be consulted; nothing was assumed. -

- )} -
- ) : null} -
- ); -} +// --------------------------------------------------------------------------- +// settings / SettingsModal.tsx — EXIT wave 2 (W2-8, contract Y4). +// +// Settings and Manage users as ONE centred modal with a left rail, matching the +// shipped Streamlit design (`app.py:settings_dialog`, item 8) and the reference +// the owner named: *"when I click 'Setting' this whole thing pops up? I want +// that exact thing for when we click Settings and Manage User, where the +// background is clearly blurred too."* +// +// ⚠ WAVE 19 R12 — THE SURFACE IS CALLED "SETTINGS" AGAIN, and this note replaces +// the wave-15 one that said the opposite ("the surface is called PROFILE now, in +// this shell only"). The owner has ruled the rename back: one account-menu row, +// called Settings, with a gear, shown to everyone, landing on Account. The file, +// the export and the `SettingsSection` type were never renamed in either +// direction — which is exactly why the wave-15 trade was the right one to make +// and the cheap one to undo. A LABEL moved twice; no import ever did. +// +// ⛔ `verify_ui.py`'s naming check enforced the wave-15 wording, so the correct +// edit would have turned it red. It is inverted in the same change (bare +// "Profile" must not survive as copy; bare "Settings" must be PRESENT) and +// booked as wave-19 amendment 1. A gate that pins a superseded ruling is not a +// gate, it is a second place the old decision lives. +// +// A MODAL, NOT A PAGE, and the shipped version's reasoning holds here too: +// settings are a detour, not a destination — you change a toggle and go back to +// what you were doing. The Streamlit app kept a settings PAGE alongside the +// modal because a deep link is a poor thing to land on a modal; the shell has +// no settings route at all, so there is nothing to keep. +// +// ⛔ EVERY ADMIN CONTROL HERE IS A COURTESY, NOT A CHECK. The server refuses a +// non-admin on every `/admin/*` route, fail-closed, and Y4's gate proves it by +// having a viewer try. Hiding the rail entry only spares a member a door that +// would not open. +// --------------------------------------------------------------------------- + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import type { SessionUser } from "../shell/session"; +import { isAdmin, parseUserEnvelope } from "../shell/session"; +import type { AdminUser, SettingsPayload } from "./settingsApi"; +// ⚠ `patchUser` and `setPassword` left this file with `EditUser` (R7) — they are +// imported by `PermsEditor` now, where the form that writes them lives, and it +// spells the patch shape `Parameters[1]` rather than importing +// `UserPatch` separately. `noUnusedLocals` is what walks a move like this down to +// the last orphaned import, which is the tsconfig earning its keep again. +import { clearAvatar, createUser, getSettings, listUsers, setAvatar } from "./settingsApi"; +import { + addKeychainEntry, + deleteKeychainEntry, + getConnectors, + listKeychain, + setKeychainScope, + pauseConnector, + testKeychainEntry, +} from "./settingsApi"; +import type { ConnectorRow, KeyEntry, UnsyncedInfo } from "./settingsApi"; +import { PermsEditor } from "./PermsEditor"; +// Wave 19 contract C2 — session D's self-contained plane; see the mount below. +import { AdminPane } from "./AdminPane"; +import { ManageAgentPane } from "../manage-agent/ManageAgentPane"; +/* ⛔ `StatementsPane` LEFT WITH ITS RAIL ROW (wave 35 · T38): the file is DELETED, not merely + unmounted. An orphaned component with no importer is the shape this repo books as + [[artifact-with-no-importer]] — it reads to the next person as a surface that still exists. */ +import { moduleSummary, parseEntry, reachableSection } from "./permsModel"; + +// The vocabulary lives beside the rule that gates it (permsModel), and is +// re-exported here so `Shell.tsx`'s import does not have to know that. +export type { SettingsSection } from "./permsModel"; +import type { SettingsSection } from "./permsModel"; + +// ⛔ `BU_LABELS`, `BU_IDS` and `scopeWords` ALL WENT WITH THE PICKER (R1). +// Nothing offers, writes or RENDERS a business unit in this shell any more — +// the last reader was the accounts list, and its column is now "Access". The +// concept survives only as a legacy value on the record, which S1's migration +// converts to a permanent filter once. `noUnusedLocals` is what walked the +// removal down to the last orphan, which is the tsconfig earning its keep. + +function moduleWords(mods: string[] | "all"): string { + // ⚠ `[]` READS as unrestricted — the existing semantic Y4 says not to "fix" + // this wave. Saying "All modules" here is therefore the TRUE statement, and + // the write side is what refuses to create the ambiguity (see settingsApi). + if (mods === "all" || !Array.isArray(mods) || mods.length === 0) return "All modules"; + return `${mods.length} module${mods.length === 1 ? "" : "s"}: ${mods.join(", ")}`; +} + +// ------------------------------------------------------------------ the users pane + +/** The trailing affordance on an account card. SVG, never a character glyph. */ +function ChevIcon() { + return ( + + ); +} + +// ── WAVE 19 R13: one mark per rail tab ─────────────────────────────────────── +// +// The rail was five words in a column, and at a glance five words in a column is +// a list you read rather than a place you navigate. A mark per row is what makes +// "Keychains" findable without reading "Connectors" first. +// +// ⚠ PURE FUNCTIONS OF NOTHING, and that is a requirement here rather than a +// style: `verify_ui.py` renders this module through `react-dom/server`, where +// `window` and `document` do not exist. An icon that measured anything, or read +// a theme off the DOM at module scope, would take the whole gate down with it — +// and the failure would look like a broken gate rather than a broken icon. +// SVG only, never emoji (the platform's own standing rule). +function RailIcon({ children }: { children: ReactNode }) { + return ( + + ); +} + +/** Account — the person whose settings these are. */ +const IconAccount = ( + + + + +); +/** Your access — a closed padlock: what this account may open, and what narrows it. */ +const IconAccess = ( + + + + +); +/** Manage users — more than one person. */ +const IconUsers = ( + + + + + +); +/** Manage agent — a channel bot. Deliberately NOT a second person glyph: the + * principal here is a CHANNEL, and a rail that draws it as a user invites the + * per-person reading the pane's first sentence exists to correct. A speech + * bubble with a machine mark inside it, in the same stroke weight as the rest. */ +const IconAgent = ( + + + + + +); +/** Keychains — a key. */ +const IconKey = ( + + + + +); +/* ⛔ `IconMail` LEFT WITH THE STATEMENTS ROW IT DREW (wave 35 · T38). `noUnusedLocals` is what + turns forgetting this half into a build error rather than into a glyph nobody renders, which is + the type system doing the sweeping. */ +/** Connectors — two links joined: where the data comes from. */ +const IconPlug = ( + + + + + +); +/** Loopable admin — stacked plates, i.e. MANY tenants. Deliberately not another + * lock or shield: "Your access" already wears a padlock, and two authority + * glyphs in one rail would read as two grades of the same thing rather than as + * a different scope entirely. */ +const IconTenants = ( + + + + +); + +function UsersPane() { + const [users, setUsers] = useState(null); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + /** + * ⚠ WAVE 17 R7 — ONE STATE, WHERE THERE WERE TWO. This pane held `editing` + * (the inline account-details card, at the BOTTOM of the list) and `permsFor` + * (the full-pane access page) as separate things, on the wave-15 reasoning + * that *"they are two different jobs: one changes who the account IS, the + * other changes what it may SEE"*. True about the data, wrong about the + * person: an admin opening an account does not know in advance which of the + * two they came for, and the product offered them two doors, one of which + * scrolled the page instead of changing it. The owner met that as *"Edit and + * clicking a user name should just be the EXACT same opening of a full page + * so its not confusing"*. There is one door now, and one thing behind it. + */ + const [open, setOpen] = useState(null); + + const reload = useCallback(() => { + void listUsers().then((r) => { + if (r.ok) { + setUsers(r.data.users ?? []); + setError(""); + } else { + setUsers([]); + setError(r.message); + } + }); + }, []); + + useEffect(reload, [reload]); + + // wave17 item 3 (R6). ⚠ `.pg-empty` was the WRONG BOX as well as the wrong + // words: it is the dashed empty-state frame, so a list that was merely still + // arriving was drawn in the chrome that means "there is nothing here". + if (users === null) + return ( +
+ +
+ ); + + // The FULL-PANE TAKEOVER: the users pane IS the modal body, so returning the + // page here replaces the whole body and nothing else has to know. The rail + // stays — it is chrome, not body, and an admin who wants out of this page + // entirely should not have to find a Back button first. + if (open) { + const who = users.find((u) => u.username === open); + // The record can genuinely vanish under an open page: `reload()` runs after + // every write, and another admin may have removed the account in between. + // Saying so beats rendering a page about nobody. + if (!who) { + return ( +
+
+

That account is no longer listed

+

It may have been removed since this list was loaded.

+
+ +
+
+
+ ); + } + return ( + u.name || u.username)} + copyTargets={users.filter((u) => u.username !== who.username)} + onBack={() => setOpen(null)} + // ⚠ IT DOES NOT CLOSE THE PAGE ANY MORE. Wave 15's version returned to + // the list on save, which was right when this was only an access + // editor. It is one page with several things to save now, and a page + // that ejects you after the first of them is a page you have to + // re-open to finish the job. The way out stays visible instead. + onSaved={(msg) => { + setNotice(msg); + setError(""); + reload(); + }} + /> + ); + } + + return ( +
+ {/* ⚠ WAVE 19 (owner item 9) — THE HEADING MATCHES THE RAIL ENTRY THAT + OPENS IT. It said "Accounts" while the only way in was a tab reading + "Manage users", so the pane appeared to be somewhere other than the + place that was clicked — the cheapest kind of "am I in the right + screen?" there is. The rail is the name; the body agrees with it. */} +

Manage users

+ {error ?

{error}

: null} + {notice ?

{notice}

: null} + + {users.length === 0 && !error ? ( +

No accounts are configured on this deployment yet.

+ ) : ( + /* ⚠ WAVE 17 R7 — A LIST OF CARDS, NOT A TABLE, and the shape is the + feature. The owner asked to "click the whole user's Card, not just + name, so it has bigger hit box": in a table the only click target + that could exist was the name itself, because a `` is + invisible to a keyboard and wrapping every cell in a button is five + tab stops per row. One ` + + ))} + + )} + + { + setNotice(msg); + setError(""); + reload(); + }} + onError={setError} + /> +
+ ); +} + +// ⛔ `BuPicker` WAS HERE, AND IT IS NOT COMING BACK (wave 15, R1). Business unit +// died as a USER-FACING CONCEPT: BU access is now one permanent filter among +// others under the per-module permissioning engine — "dba is any of [Fisch, +// Both]" says exactly what the two checkboxes used to say, in the same grammar +// as every other restriction, and an admin who wants a different cut is no +// longer limited to the two cuts somebody hard-coded. +// +// The picker's old help text is worth keeping in one form, because the property +// it named still holds and is still the point: a member scoped to one unit sees +// no other unit's data anywhere. What changed is WHERE that is enforced — the +// permanent filter and its server-side wall (C-PERM), not a `bus` list. +// +// ⚠ `bus` still exists on the RECORD and in `AdminUser` during the strangler +// period (legacy Streamlit pages read it; S1's migration converts it once). What +// no longer exists is a UI that WRITES it — see EditUser/AddUser below, which +// deliberately stopped sending the field they can no longer edit. + +// ⛔ `EditUser` WAS HERE. It is not deleted so much as RELOCATED — every control +// it held (display name, role, deactivate/reactivate, password reset with its +// signs-them-out-everywhere warning) now lives at the top of the user page in +// `PermsEditor`, which is the whole of R7: *"Edit and clicking a user name +// should just be the EXACT same opening of a full page"*. What genuinely died +// is the SHAPE — a card that appeared at the bottom of the accounts list, below +// the fold, describing a row somewhere above it. Two doors, two layouts and one +// of them scrolled instead of navigating. +// +// ⚠ The load-bearing note from this block MOVES WITH THE FORM and is restated +// there: the PATCH still carries no `bus` (R1). With the picker gone the form +// cannot edit that field, so sending it would be a client echoing back a value +// it only read — and once the migration converts `bus` into a permanent filter +// and clears the field, that echo would RESURRECT it. + +function AddUser({ + onCreated, + onError, +}: { + onCreated: (msg: string) => void; + onError: (msg: string) => void; +}) { + const [open, setOpen] = useState(false); + const [username, setUsername] = useState(""); + const [name, setName] = useState(""); + const [role, setRole] = useState("user"); + const [password, setPassword_] = useState(""); + const [busy, setBusy] = useState(false); + + if (!open) + return ( + + ); + + const valid = username.trim().length > 0 && password.length > 0; + return ( +
+

New account

+
+ + setUsername(e.target.value)} + /> + {/* S1's Y4 amendment 1, surfaced as guidance rather than as a 409. */} +

+ Must be new. To change an existing account, edit it above — re-creating + one would revive sessions that were revoked. +

+
+
+ + setName(e.target.value)} /> +
+
+ + +
+
+ + setPassword_(e.target.value)} + /> + {/* Said before the click, because a new account is created OPEN and is + narrowed afterwards. That order is the server's (an absent `bus` + defaults to every unit) and this wave does not change it — what the + wave changes is that "afterwards" now leads somewhere: open the + account and set its per-module access, filters and hidden fields. */} +

+ A new account starts with access to everything this deployment shows. + Open it from the list afterwards to set what they may see. +

+
+
+ + +
+
+ ); +} + +// ------------------------------------------------------------------ profile photo (wave 14) + +/** Downscale + centre-crop to a <=128px JPEG data URL. JPEG at this size lands ~5-10KB, + * comfortably under the server's 64KB decoded wall; the server re-validates regardless. */ +async function fileToAvatarDataUrl(file: File): Promise { + const url = URL.createObjectURL(file); + try { + const img = new Image(); + await new Promise((res, rej) => { + img.onload = () => res(); + img.onerror = () => rej(new Error("unreadable image")); + img.src = url; + }); + if (!img.width || !img.height) return null; + const side = Math.min(128, Math.max(img.width, img.height)); + const canvas = document.createElement("canvas"); + canvas.width = side; + canvas.height = side; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + const s = Math.min(img.width, img.height); + ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, side, side); + return canvas.toDataURL("image/jpeg", 0.85); + } catch { + return null; + } finally { + URL.revokeObjectURL(url); + } +} + +/** The first letter of a name, the way both shells compute it. + * ⚠ Unicode letter/number, not `[a-z0-9]` — the app's `_account_css` uses + * Python's `str.isalnum()`, so an ASCII-only class here would give a non-Latin + * name an initial in one door and a blank circle in the other. */ +function initialOf(name: string): string { + return (name.match(/[\p{L}\p{N}]/u)?.[0] ?? "?").toUpperCase(); +} + +/** + * ⚠ WAVE 17 (16b) — THE INLINE STYLES ARE GONE, AND SO IS THEIR JUSTIFICATION. + * This block used to carry twelve declarations in a `style` object under the + * note *"the `.set-*`/`.shell-*` stylesheet is another wave-14 session's file"*. + * That was true then and is not true now — SHELL owns the `.set-*` region this + * wave — and an inline `background: "var(--lp-blue-tint)"` is still a colour + * decision living outside the stylesheet, which is the thing the file-wide + * token rule exists to stop. Two sizes are needed (the identity header and an + * accounts card), so there are two classes rather than a size prop threading a + * number into CSS. + */ +function AvatarPreview({ user, large }: { user: SessionUser; large?: boolean }) { + const cls = large ? "set-avatar set-avatar--lg" : "set-avatar"; + if (user.avatar) { + return ; + } + return ( + + ); +} + +/** The accounts list's monogram. A separate component because `AdminUser` + * carries no `avatar` — only the SIGNED-IN user's photo is on the wire — so + * offering a photo slot here would be a hole nothing can fill. */ +function Monogram({ name }: { name: string }) { + return ( + + ); +} + +/** The role, as a chip rather than a table cell (16b). Two roles, one of which + * bypasses every rule in the product — that is a property of the account, and a + * property belongs beside the name, not in a row of key/value pairs. */ +function RoleChip({ admin }: { admin: boolean }) { + return ( + + {admin ? "Admin" : "Member"} + + ); +} + +function ProfilePhotoBlock({ + user, + admin, + onUser, +}: { + user: SessionUser; + admin: boolean; + onUser?: (u: SessionUser) => void; +}) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const fileRef = useRef(null); + + const upload = useCallback( + async (file: File) => { + setBusy(true); + setError(""); + const dataUrl = await fileToAvatarDataUrl(file); + if (!dataUrl) { + setBusy(false); + setError("That file could not be read as an image."); + return; + } + const r = await setAvatar(dataUrl); + setBusy(false); + if (r.ok) { + const fresh = parseUserEnvelope(r.data); + if (fresh) onUser?.(fresh); + } else { + setError(r.message); + } + }, + [onUser] + ); + + const remove = useCallback(async () => { + setBusy(true); + setError(""); + const r = await clearAvatar(); + setBusy(false); + if (r.ok) { + const fresh = parseUserEnvelope(r.data); + if (fresh) onUser?.(fresh); + } else { + setError(r.message); + } + }, [onUser]); + + // ⛔ THE HELP SENTENCE IS DELETED (wave 17, item 16b, owner's instruction: + // "replace with layout, not copy"). It read "Shown on Assignee fields and your + // account chip. Square images look best; large ones are scaled down + // automatically." Every clause was work the interface should do rather than + // explain: WHERE the photo appears is answered by showing it at the size and + // shape it will be worn, beside the name it belongs to; SQUARE IS BEST and + // LARGE ONES ARE SCALED are apologies for a centre-crop-and-downscale that + // already happens without asking (`fileToAvatarDataUrl`) and that the user + // cannot influence. A sentence describing behaviour the reader has no control + // over is decoration. + return ( +
+ +
+
+ {user.name} + +
+
{user.username}
+
+ { + const f = e.target.files?.[0]; + e.target.value = ""; + if (f) void upload(f); + }} + /> + + {user.avatar ? ( + + ) : null} +
+ {error ?

{error}

: null} +
+
+ ); +} + +// ------------------------------------------------------------------ the modal + +export function SettingsModal({ + user, + section, + onSection, + onClose, + onUser, + moduleLabels, +}: { + user: SessionUser; + section: SettingsSection; + onSection: (s: SettingsSection) => void; + onClose: () => void; + /** Wave 14 C-AVATAR — the shell's session setter, so a photo change updates the account + * chip live instead of on the next full reload. Optional: absent, the photo still saves. */ + onUser?: (u: SessionUser) => void; + /** Registry key → the label the nav already shows for it. Passed down rather + * than fetched or hardcoded: the shell holds a server-filtered nav with every + * label in it, and a second copy of that mapping in this file would be a + * place for "Customer" to become "Customers" in one door only. Absent ⇒ rows + * fall back to the key, which is honest if ugly. */ + moduleLabels?: Record; +}) { + const [settings, setSettings] = useState(null); + const admin = isAdmin(user); + // Wave 19 (C2) — `=== true`, not truthy: absent or malformed reads as NO. + const platformAdmin = settings?.platformAdmin === true; + + useEffect(() => { + void getSettings().then((r) => { + if (r.ok) setSettings(r.data); + }); + }, []); + + // Escape closes. A modal a keyboard cannot dismiss is a trap, and this one + // covers the whole application. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + // WAVE 19 R13 — the tuple grew a third member: the tab's mark. ONE data change + // and one line in the render loop below, which is the whole of the ruling. + const rail: Array<[SettingsSection, string, ReactNode]> = [ + ["account", "Account", IconAccount], + ["scope", "Your access", IconAccess], + ...(admin + ? ([ + ["users", "Manage users", IconUsers], + // ⭐ WAVE 33, owner item 10 — placed by the owner, verbatim: *"under a + // module called 'Manage agent' which should be under 'Manage user'"*. + // Immediately after `users`, so "under" is literal in the rail rather + // than a tab hidden inside the other room. + ["agents", "Manage agent", IconAgent], + // Wave 18 (C7): the tenant's credential store + data-source status board. + ["keychains", "Keychains", IconKey], + ["connectors", "Connectors", IconPlug], + /* ⛔⛔ THE `Statements` ROW IS GONE (wave 35 · T38, owner item 14 / ruling R10). + It is an AGENT now — `Agents -> Monthly statements` — where a scheduled run assembles + a batch and parks it, and a person releases it with a click. + ⚠ The send DOOR is unchanged and unweakened: `routes_statements`, `admin_gate` + + the Royal-Imports gate, `collections_send.queue_statement` with SAFE_MODE in the data + layer. One of its two front doors closed; the capability did not move. + ⚠ A stored preference naming the old section lands on Account, not on a blank pane — + `permsModel.RETIRED_SECTIONS`. */ + ] as Array<[SettingsSection, string, ReactNode]>) + : []), + // Wave 19 (R3 / C2): the Loopable admin plane. Gated on the SERVER's + // `platformAdmin`, never on `admin` — R3 is explicit that a tenant-scoped + // admin does not qualify, so this entry appears for exactly one account and + // is invisible to every tenant admin in the product. + ...(platformAdmin + ? ([["padmin", "Loopable admin", IconTenants]] as Array< + [SettingsSection, string, ReactNode] + >) + : []), + ]; + // A non-admin deep-linked to the users pane lands on Account rather than on + // an empty frame. The server would refuse the data anyway; this just avoids + // showing a room with nothing in it. The rule is a NAMED function so the gate + // can negative-control it — see `permsModel.reachableSection`. + // + // ⛔ `padmin` IS GATED HERE, NOT IN THAT FUNCTION, and the reason is that its + // input does not exist there. `reachableSection` takes `admin` — a boolean off + // the user record — while `platformAdmin` arrives asynchronously on the + // settings payload and (per R3) is NOT implied by tenant admin. Passing it + // through `reachableSection` would have meant either widening that function's + // signature and re-aiming a negative control that guards a different rule, or + // conflating the two admin concepts the ruling exists to keep apart. Composed + // instead: the section falls back to Account until the server has said yes. + const active = + section === "padmin" && !platformAdmin ? "account" : reachableSection(section, admin); + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > + {/* ⚠ WAVE 19 R12 — the modal is "Settings", and the ACCESSIBLE name tracks + the visible one, which is the half that is easy to forget. A dialog a + screen reader announces as "Profile" while the rail reads "Settings" is + the same drift as a stale comment, only harder to notice: nobody + looking at the screen can see it. Manage users is still a SECTION + inside it (R12 is a rename, not a re-architecture). */} +
+ + +
+ + + {/* Wave 33 (owner item 10), wired the way contract C2's lesson demands + a new surface be wired: at the MOUNT SITE, not by an import that a + grep would mistake for a render. The pane is self-contained — it + fetches its own data and owns its loading/empty/error states — and + `reachableSection` has already bounced a non-admin to Account. */} + {active === "agents" ? : null} + {active === "keychains" ? : null} + {active === "connectors" ? : null} + {/* Wave 19 contract C2 — the wiring record, verified at the MOUNT SITE + (the wave-14 lesson: an occurrence-grep proves an import, not a + render): `settings/AdminPane.tsx → SettingsModal.tsx pane switch → + prop user`. The pane is session D's, self-contained: it fetches its + own data and owns its loading/empty/error states, so mounting it + cannot fail on plumbing. Its prop is typed STRUCTURALLY on D's side + ({username; name; role}), so the shell's `SessionUser` satisfies it + by structural typing — no cast here, and no import from `shell/` + there. `active` cannot be "padmin" unless the server said so. */} + {active === "padmin" ? : null} + + {active === "account" ? ( +
+

Account

+ {/* ⚠ WAVE 17 (16b) — THE THREE KEY/VALUE ROWS ARE NOT DELETED, + THEY ARE LAID OUT. "Signed in as", "Username" and "Role" were a + three-row table restating what a photo, a name and a chip say + at a glance — and the photo block sat UNDER them, so the pane + opened on a label reading "Signed in as" above the picture of + the person it was labelling. The identity header carries all + three now: the name at title weight, the username beneath it, + the role as a chip beside it. Nothing was dropped; the labels + were, because each one named the obvious. */} + +
+ ) : null} + + {active === "scope" ? ( +
+

Your access

+ {/* ⛔ THE "Business units" ROW IS GONE (R1) — the concept it + reported no longer exists on this side of the product. It is + NOT replaced by nothing: what shapes a restricted account's + rows is now a permanent filter per module, and the rows below + say so. A pane reporting "All modules" while a filter quietly + halves somebody's book is worse than the row it replaced — + that is the user who files a bug about the numbers. */} +

+ What this account may open, and anything that narrows it. Only an + administrator can change this. +

+ {/* 16b — one card, so the restrictions read as a group belonging to + the heading rather than as loose rows floating under it. */} +
+
+ Modules + + {moduleWords(settings?.scope.modules ?? user.modules)} + +
+ {/* An admin's "All modules" is true but does not say WHY, and the + why is the part that matters: it is not a grant somebody can + revoke module by module, it is a bypass. The accounts list + already says "Everything (admin)" — this is the same fact, on + the account's own page, so the two doors agree. */} + {admin ? ( +
+ Restrictions + + None apply. Administrators bypass every rule + +
+ ) : null} + {/* One row per module the account is restricted in. `moduleSummary` + is the SAME sentence the admin editor shows, from the same + function — two dialects of "2 conditions, 3 fields hidden" + would eventually disagree about what a rule says. Admins are + skipped: they bypass perms entirely, so any stored rule is + inert and printing it here would be a lie about their access. */} + {!admin + ? Object.entries(settings?.scope.perms ?? {}).map(([key, raw]) => { + const entry = parseEntry(raw); + const words = moduleSummary(entry); + // "Full access" adds nothing beside the Modules row above; + // only a REAL narrowing earns a line. + if (words === "Full access") return null; + return ( +
+ {moduleLabels?.[key] ?? key} + {words} +
+ ); + }) + : null} +
+
+ ) : null} + + {active === "users" ? : null} +
+
+
+ ); +} + +// ── Wave 18 (C7): the Keychain pane ───────────────────────────────────────────────────────── +// Secrets go IN and never come back out: rows carry a masked preview, the add form clears on +// success, and there is no reveal affordance by design (the server has no route for one). +/** + * ⭐⭐ WAVE 32 · R4 / contract C1 — WHO A CONNECTION IS FOR, in the two words the ruling uses. + * + * ⛔ THE VOCABULARY IS THE SERVER'S. `routes_keychain.SCOPES` declares it and `verify_meta` + * asserts these two literals appear on both sides, so the label map is a PRESENTATION of a + * server word and never a client-side union — a `type Scope = …` here would turn "the server + * added a scope" into "the client drops the row" (the wave-9 law). + */ +const SCOPE_LABEL: Record = { + business: "Business-wide", + personal: "Personal", +}; +const scopeLabel = (s: string | undefined): string => + SCOPE_LABEL[String(s || "business")] || String(s); + +function KeychainPane() { + const [entries, setEntries] = useState([]); + const [locked, setLocked] = useState(false); + const [loaded, setLoaded] = useState(false); + const [err, setErr] = useState(""); + /** R4: only an administrator may make a connection business-wide. Served, not inferred from + * the account record — the server is the wall and the client renders what it will honour. */ + const [canBusiness, setCanBusiness] = useState(false); + const [tenantWide, setTenantWide] = useState([]); + const [form, setForm] = useState<{ + type: "odoo" | "generic"; + label: string; + scope: string; + fields: Record; + busy: boolean; + } | null>(null); + const [testResult, setTestResult] = useState>({}); + + const reload = useCallback(async () => { + const r = await listKeychain(); + if (r.ok) { + setEntries(r.data.entries); + setLocked(r.data.locked); + setCanBusiness(Boolean(r.data.canBusiness)); + setTenantWide(Array.isArray(r.data.tenantWideTypes) ? r.data.tenantWideTypes : []); + setErr(""); + } else { + setErr(r.message); + } + setLoaded(true); + }, []); + useEffect(() => { + void reload(); + }, [reload]); + + /** The scope a NEW key of this type may take. `odoo` and `meta_ads` are what the whole + * workspace's databases are read through, so the server refuses to make them personal — the + * picker says so here instead of letting the save fail. */ + const forcedBusiness = (t: string) => tenantWide.includes(t); + + const submit = async () => { + if (!form || form.busy) return; + setForm({ ...form, busy: true }); + const fields = Object.fromEntries( + Object.entries(form.fields).filter(([, v]) => v.trim() !== "") + ); + const r = await addKeychainEntry(form.label, form.type, fields, form.scope); + if (r.ok) { + setForm(null); + void reload(); + } else { + setErr(r.message); + setForm({ ...form, busy: false }); + } + }; + + const odooFields: Array<[string, string]> = [ + ["url", "Server URL (https://...)"], + ["db", "Database"], + ["user", "API user"], + ["api_key", "API key"], + ]; + + return ( +
+

Keychains

+

+ Credentials for this workspace's data sources, encrypted at rest. A stored secret is + never shown again, and only its last four characters identify it. +

+ {locked ? ( +

+ The keychain is locked: the platform's AIOS_KEYCHAIN_KEY is not configured on this + server. Entries can be listed but nothing can be added or decrypted. +

+ ) : null} + {err ?

{err}

: null} + {!loaded ? ( +

Loading…

+ ) : entries.length === 0 ? ( +

No keys stored yet.

+ ) : ( +
+ {entries.map((e) => ( +
+
+ {e.label} + + {e.type} + {e.preview ? " · " + e.preview : ""} · {scopeLabel(e.scope)} + {e.scope === "personal" && e.owner ? " (" + e.owner + ")" : ""} · added by{" "} + {e.createdBy || "?"} + + {testResult[e.id] ? ( + {testResult[e.id]} + ) : null} +
+
+ {/* ⭐ R4's scope door. Shown ONLY where it can be honoured: a member cannot make + anything business-wide, and `odoo`/`meta_ads` are business-wide by + construction — offering a control that the server would refuse is the + "button that leads nowhere" R7 banned one module over. */} + {canBusiness && !forcedBusiness(e.type) ? ( + + ) : null} + + +
+
+ ))} +
+ )} + {form ? ( +
+
+ + +
+ {/* ⭐⭐ WAVE 32 · R4 — WHO IS THIS CONNECTION FOR. Two options, no more: the ruling says + there is a business-wide kind and a personal kind, and business-wide is an + administrator's to give. A member sees the sentence instead of a disabled radio, + because a control you cannot use is worse than a line telling you why. */} +
+ + {forcedBusiness(form.type) ? ( +

+ Business-wide. This workspace's databases are read through an {form.type}{" "} + connection, so it applies to everyone here and cannot be personal. +

+ ) : canBusiness ? ( + + ) : ( +

+ Personal, so only you. An administrator can add a connection for the whole + workspace. +

+ )} +
+
+ + setForm({ ...form, label: e.target.value })} + /> +
+ {(form.type === "odoo" + ? odooFields + : ([ + ["name", "Name"], + ["value", "Secret value"], + ] as Array<[string, string]>) + ).map(([k, label]) => ( +
+ + + setForm({ ...form, fields: { ...form.fields, [k]: e.target.value } }) + } + /> +
+ ))} +
+ + +
+
+ ) : ( + + )} +
+ ); +} + +// ── Wave 18 (C7): the Connectors pane — sources + R3's Unsynced-records guardrail ─────────── +function ConnectorsPane() { + const [rows, setRows] = useState([]); + const [note, setNote] = useState(""); + const [unsynced, setUnsynced] = useState(null); + const [showOrphans, setShowOrphans] = useState(false); + const [loaded, setLoaded] = useState(false); + const [err, setErr] = useState(""); + + const reload = useCallback(async () => { + const r = await getConnectors(); + if (r.ok) { + setRows(r.data.connectors); + setNote(r.data.pausedNote || ""); + setUnsynced(r.data.unsynced ?? null); + setErr(""); + } else { + setErr(r.message); + } + setLoaded(true); + }, []); + useEffect(() => { + void reload(); + }, [reload]); + + return ( +
+

Connectors

+

+ Where this workspace's data comes from. Add credentials under Keychains; sources appear + here with their status. +

+ {err ?

{err}

: null} + {!loaded ? ( +

Loading…

+ ) : rows.length === 0 ? ( +

+ No connectors yet. Store a key under Keychains to register a source. Nothing syncs + until a later step turns it on. +

+ ) : ( +
+ {rows.map((r) => ( +
+
+ {r.label} + + {r.type} · {r.source === "env" ? "environment credentials" : "keychain"} + {r.paused ? " · paused" : " · active"} + +
+
+ +
+
+ ))} +
+ )} + {note ?

{note}

: null} + {unsynced ? ( +
+ Unsynced records + {unsynced.known ? ( + unsynced.count === 0 ? ( +

+ None. Every record holding notes or custom-field data matches a row the + connector currently serves. +

+ ) : ( + <> +

+ {unsynced.count} record{unsynced.count === 1 ? "" : "s"} carry saved data + (notes, custom fields) but no longer match a synced row. Nothing is deleted; + they reattach by ID if the source serves them again. +

+ + {showOrphans ? ( +
    + {unsynced.rows.map((o) => ( +
  • + #{o.pid}: {o.fields} saved field{o.fields === 1 ? "" : "s"} + {o.hint ? " · " + o.hint : ""} +
  • + ))} +
+ ) : null} + + ) + ) : ( +

+ Count unavailable right now{unsynced.note ? " (" + unsynced.note + ")" : ""}. The + pool could not be consulted; nothing was assumed. +

+ )} +
+ ) : null} +
+ ); +}