// --------------------------------------------------------------------------- // 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 { 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 = ( ); /** 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 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). */}
{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}
); }