| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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"; |
| |
| |
| |
| |
| |
| 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"; |
| |
| import { AdminPane } from "./AdminPane"; |
| import { StatementsPane } from "./StatementsPane"; |
| import { moduleSummary, parseEntry, reachableSection } from "./permsModel"; |
|
|
| |
| |
| export type { SettingsSection } from "./permsModel"; |
| import type { SettingsSection } from "./permsModel"; |
|
|
| |
| |
| |
| |
| |
| |
|
|
| function moduleWords(mods: string[] | "all"): string { |
| |
| |
| |
| if (mods === "all" || !Array.isArray(mods) || mods.length === 0) return "All modules"; |
| return `${mods.length} module${mods.length === 1 ? "" : "s"}: ${mods.join(", ")}`; |
| } |
|
|
| |
|
|
| |
| function ChevIcon() { |
| return ( |
| <svg className="set-acct-chev" viewBox="0 0 12 12" aria-hidden="true"> |
| <path d="M4.4 2.4 8 6l-3.6 3.6" /> |
| </svg> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function RailIcon({ children }: { children: ReactNode }) { |
| return ( |
| <svg className="set-rail-icon" viewBox="0 0 16 16" aria-hidden="true"> |
| {children} |
| </svg> |
| ); |
| } |
|
|
| |
| const IconAccount = ( |
| <RailIcon> |
| <circle cx="8" cy="5.6" r="2.7" /> |
| <path d="M2.9 13.4c0-2.5 2.3-4.1 5.1-4.1s5.1 1.6 5.1 4.1" /> |
| </RailIcon> |
| ); |
| |
| const IconAccess = ( |
| <RailIcon> |
| <rect x="3.3" y="7.1" width="9.4" height="6.3" rx="1.3" /> |
| <path d="M5.6 7.1V5.3a2.4 2.4 0 0 1 4.8 0v1.8" /> |
| </RailIcon> |
| ); |
| |
| const IconUsers = ( |
| <RailIcon> |
| <circle cx="6.2" cy="5.9" r="2.3" /> |
| <path d="M1.9 13.2c0-2.2 1.9-3.5 4.3-3.5s4.3 1.3 4.3 3.5" /> |
| <path d="M10.7 4.1a2.3 2.3 0 0 1 0 4.3M11.6 9.9c1.5.4 2.5 1.5 2.5 3.3" /> |
| </RailIcon> |
| ); |
| |
| const IconKey = ( |
| <RailIcon> |
| <circle cx="5.4" cy="10.6" r="2.6" /> |
| <path d="M7.3 8.8 13.1 3M11 5.1l1.5 1.5M9.4 6.7l1.4 1.4" /> |
| </RailIcon> |
| ); |
| |
| |
| |
| const IconMail = ( |
| <RailIcon> |
| <path d="M2 4.5h12v7H2z" /> |
| <path d="M2.4 5 8 9l5.6-4" /> |
| </RailIcon> |
| ); |
| const IconPlug = ( |
| <RailIcon> |
| <path d="M6.6 9.4 4.5 11.5a2.6 2.6 0 0 1-3.7-3.7l2.1-2.1" /> |
| <path d="M9.4 6.6l2.1-2.1a2.6 2.6 0 0 1 3.7 3.7l-2.1 2.1" /> |
| <path d="M6.2 9.8l3.6-3.6" /> |
| </RailIcon> |
| ); |
| |
| |
| |
| |
| const IconTenants = ( |
| <RailIcon> |
| <path d="M8 1.9 14.3 5 8 8.1 1.7 5z" /> |
| <path d="M2.4 8.1 8 10.9l5.6-2.8M2.4 11.2 8 14l5.6-2.8" /> |
| </RailIcon> |
| ); |
|
|
| function UsersPane() { |
| const [users, setUsers] = useState<AdminUser[] | null>(null); |
| const [error, setError] = useState(""); |
| const [notice, setNotice] = useState(""); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const [open, setOpen] = useState<string | null>(null); |
|
|
| const reload = useCallback(() => { |
| void listUsers().then((r) => { |
| if (r.ok) { |
| setUsers(r.data.users ?? []); |
| setError(""); |
| } else { |
| setUsers([]); |
| setError(r.message); |
| } |
| }); |
| }, []); |
|
|
| useEffect(reload, [reload]); |
|
|
| |
| |
| |
| if (users === null) |
| return ( |
| <div className="set-loading"> |
| <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" /> |
| </div> |
| ); |
|
|
| |
| |
| |
| |
| if (open) { |
| const who = users.find((u) => u.username === open); |
| |
| |
| |
| if (!who) { |
| return ( |
| <div className="set-pane"> |
| <section className="set-card"> |
| <h4 className="set-h4">That account is no longer listed</h4> |
| <p className="set-help">It may have been removed since this list was loaded.</p> |
| <div className="set-actions"> |
| <button type="button" className="set-secondary" onClick={() => setOpen(null)}> |
| Back to accounts |
| </button> |
| </div> |
| </section> |
| </div> |
| ); |
| } |
| return ( |
| <PermsEditor |
| // Remount per account: the page holds a DRAFT of somebody's permissions |
| // and an unsaved display name, and carrying either across a switch of |
| // subject is how one account's edits get saved onto another. |
| key={who.username} |
| user={who} |
| // ⚠ TWO LISTS, TWO JOBS, AND MIXING THEM MAKES A WRONG URL. These are |
| // DISPLAY NAMES, for `user`-typed filter conditions — the cells hold |
| // names, so the builder must offer names. `copyTargets` below carries |
| // the RECORDS, because a copy addresses accounts by USERNAME. |
| userOptions={users.map((u) => 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 ( |
| <div className="set-pane"> |
| {/* ⚠ 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. */} |
| <h3 className="set-h">Manage users</h3> |
| {error ? <p className="set-error">{error}</p> : null} |
| {notice ? <p className="set-notice">{notice}</p> : null} |
|
|
| {users.length === 0 && !error ? ( |
| <p className="pg-empty">No accounts are configured on this deployment yet.</p> |
| ) : ( |
| /* ⚠ 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 `<tr onClick>` is |
| invisible to a keyboard and wrapping every cell in a button is five |
| tab stops per row. One `<button>` per account is ONE control with the |
| whole card as its hit box, focusable and Enter-able for free. |
| ⛔ "Business units" IS STILL GONE (R1) and did not come back as a |
| card line. What replaced it is the access sentence, which is a page |
| now rather than a cell because a permanent filter does not fit in |
| one. */ |
| <ul className="set-acctlist"> |
| {users.map((u) => ( |
| <li key={u.username}> |
| <button |
| type="button" |
| className={"set-acct" + (u.active ? "" : " is-inactive")} |
| onClick={() => setOpen(u.username)} |
| > |
| <Monogram name={u.name || u.username} /> |
| <span className="set-acct-body"> |
| <span className="set-acct-top"> |
| <span className="set-acct-name">{u.name || u.username}</span> |
| <RoleChip admin={u.role === "admin"} /> |
| {u.active ? null : <span className="set-chip set-chip--off">Deactivated</span>} |
| </span> |
| <span className="set-acct-sub">{u.username}</span> |
| {/* ⚠ An admin BYPASSES perms entirely (C-PERM amendment 4) — |
| the clause that also keeps break-glass alive during a |
| store outage. Saying "Everything (admin)" rather than |
| rendering a rule prevents the one dangerous misreading: |
| that the restrictions on an admin's access page are in |
| force. */} |
| <span className="set-acct-access"> |
| {u.role === "admin" |
| ? "Everything (admin)" |
| : (u.accessSummary ?? moduleWords(u.modules))} |
| </span> |
| </span> |
| {/* ⚠ A SPAN, NOT A BUTTON, AND THAT IS THE POINT OF R7. It is |
| inside the card's own button, so clicking the word "Edit" |
| opens exactly the page clicking anywhere else on the card |
| opens — which is the ruling, literally. A real nested button |
| is invalid markup, and the stretched-link alternative would |
| put two tab stops on one destination: the confusion the |
| ruling names, rebuilt in the accessibility tree. */} |
| <span className="set-acct-go"> |
| Edit |
| <ChevIcon /> |
| </span> |
| </button> |
| </li> |
| ))} |
| </ul> |
| )} |
|
|
| <AddUser |
| onCreated={(msg) => { |
| setNotice(msg); |
| setError(""); |
| reload(); |
| }} |
| onError={setError} |
| /> |
| </div> |
| ); |
| } |
|
|
| // ⛔ `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 ( |
| <button type="button" className="set-primary set-add" onClick={() => setOpen(true)}> |
| Add an account |
| </button> |
| ); |
|
|
| const valid = username.trim().length > 0 && password.length > 0; |
| return ( |
| <section className="set-card"> |
| <h4 className="set-h4">New account</h4> |
| <div className="set-field"> |
| <label className="set-label" htmlFor="set-new-user">Username</label> |
| <input |
| id="set-new-user" |
| className="set-input" |
| value={username} |
| autoComplete="off" |
| onChange={(e) => setUsername(e.target.value)} |
| /> |
| {/* S1's Y4 amendment 1, surfaced as guidance rather than as a 409. */} |
| <p className="set-help"> |
| Must be new. To change an existing account, edit it above — re-creating |
| one would revive sessions that were revoked. |
| </p> |
| </div> |
| <div className="set-field"> |
| <label className="set-label" htmlFor="set-new-name">Display name</label> |
| <input id="set-new-name" className="set-input" value={name} onChange={(e) => setName(e.target.value)} /> |
| </div> |
| <div className="set-field"> |
| <label className="set-label" htmlFor="set-new-role">Role</label> |
| <select id="set-new-role" className="pg-select" value={role} onChange={(e) => setRole(e.target.value)}> |
| <option value="user">Member</option> |
| <option value="admin">Admin</option> |
| </select> |
| </div> |
| <div className="set-field"> |
| <label className="set-label" htmlFor="set-new-pw">Password</label> |
| <input |
| id="set-new-pw" |
| className="set-input" |
| type="password" |
| value={password} |
| autoComplete="new-password" |
| onChange={(e) => 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. */} |
| <p className="set-help"> |
| A new account starts with access to everything this deployment shows. |
| Open it from the list afterwards to set what they may see. |
| </p> |
| </div> |
| <div className="set-actions"> |
| <button |
| type="button" |
| className="set-primary" |
| disabled={busy || !valid} |
| onClick={() => { |
| setBusy(true); |
| void createUser({ |
| username: username.trim().toLowerCase(), |
| name: name || username.trim(), |
| role, |
| // ⚠ `bus` IS DELIBERATELY ABSENT (R1) — see the note where the |
| // picker used to be. The route defaults an absent `bus` to "all", |
| // so this is the same account it always created, minus a client |
| // asserting a value it cannot edit. |
| // ⚠ `"all"`, never `[]`. An empty list READS as unrestricted, so |
| // sending one to mean "nothing" would grant everything. |
| modules: "all", |
| password, |
| }).then((r) => { |
| setBusy(false); |
| if (r.ok) { |
| onCreated(`Created ${username.trim().toLowerCase()}.`); |
| setOpen(false); |
| setUsername(""); |
| setName(""); |
| setPassword_(""); |
| } else { |
| onError(r.message); |
| } |
| }); |
| }} |
| > |
| Create account |
| </button> |
| <button type="button" className="set-secondary" onClick={() => setOpen(false)}> |
| Cancel |
| </button> |
| </div> |
| </section> |
| ); |
| } |
|
|
| // ------------------------------------------------------------------ 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<string | null> { |
| const url = URL.createObjectURL(file); |
| try { |
| const img = new Image(); |
| await new Promise<void>((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 <img className={cls} src={user.avatar} alt="" aria-hidden="true" />; |
| } |
| return ( |
| <span className={cls} aria-hidden="true"> |
| {initialOf(user.name)} |
| </span> |
| ); |
| } |
|
|
| /** 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 ( |
| <span className="set-avatar" aria-hidden="true"> |
| {initialOf(name)} |
| </span> |
| ); |
| } |
|
|
| /** 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 ( |
| <span className={admin ? "set-chip set-chip--admin" : "set-chip"}> |
| {admin ? "Admin" : "Member"} |
| </span> |
| ); |
| } |
|
|
| 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<HTMLInputElement>(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 ( |
| <div className="set-identity"> |
| <AvatarPreview user={user} large /> |
| <div className="set-identity-body"> |
| <div className="set-identity-head"> |
| <span className="set-identity-name">{user.name}</span> |
| <RoleChip admin={admin} /> |
| </div> |
| <div className="set-identity-sub">{user.username}</div> |
| <div className="set-identity-acts"> |
| <input |
| ref={fileRef} |
| type="file" |
| accept="image/png,image/jpeg" |
| className="set-filepick" |
| onChange={(e) => { |
| const f = e.target.files?.[0]; |
| e.target.value = ""; |
| if (f) void upload(f); |
| }} |
| /> |
| <button |
| type="button" |
| className="set-secondary" |
| disabled={busy} |
| onClick={() => fileRef.current?.click()} |
| > |
| {user.avatar ? "Change photo" : "Upload photo"} |
| </button> |
| {user.avatar ? ( |
| <button type="button" className="set-secondary" disabled={busy} onClick={remove}> |
| Remove |
| </button> |
| ) : null} |
| </div> |
| {error ? <p className="set-error set-identity-err">{error}</p> : null} |
| </div> |
| </div> |
| ); |
| } |
|
|
| // ------------------------------------------------------------------ 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<string, string>; |
| }) { |
| const [settings, setSettings] = useState<SettingsPayload | null>(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 ( |
| <div |
| className="set-backdrop" |
| onMouseDown={(e) => { |
| 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). */} |
| <div className="set-modal" role="dialog" aria-modal="true" aria-label="Settings"> |
| <nav className="set-rail"> |
| <span className="set-rail-cap">Settings</span> |
| {rail.map(([key, label, icon]) => ( |
| <button |
| key={key} |
| type="button" |
| className={"set-rail-item" + (active === key ? " is-active" : "")} |
| onClick={() => onSection(key)} |
| > |
| {icon} |
| {label} |
| </button> |
| ))} |
| </nav> |
|
|
| <div className="set-body"> |
| <button type="button" className="set-close" onClick={onClose} aria-label="Close settings"> |
| × |
| </button> |
|
|
| {active === "keychains" ? <KeychainPane /> : null} |
| {active === "connectors" ? <ConnectorsPane /> : null} |
| {active === "statements" ? <StatementsPane /> : 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" ? <AdminPane user={user} /> : null} |
|
|
| {active === "account" ? ( |
| <div className="set-pane"> |
| <h3 className="set-h">Account</h3> |
| {/* ⚠ 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. */} |
| <ProfilePhotoBlock user={user} admin={admin} onUser={onUser} /> |
| </div> |
| ) : null} |
|
|
| {active === "scope" ? ( |
| <div className="set-pane"> |
| <h3 className="set-h">Your access</h3> |
| {/* ⛔ 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. */} |
| <p className="set-help set-pane-intro"> |
| What this account may open, and anything that narrows it. Only an |
| administrator can change this. |
| </p> |
| {/* 16b — one card, so the restrictions read as a group belonging to |
| the heading rather than as loose rows floating under it. */} |
| <section className="set-card set-card--rows"> |
| <div className="pg-drill-row"> |
| <span className="pg-drill-key">Modules</span> |
| <span className="pg-drill-val"> |
| {moduleWords(settings?.scope.modules ?? user.modules)} |
| </span> |
| </div> |
| {/* 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 ? ( |
| <div className="pg-drill-row"> |
| <span className="pg-drill-key">Restrictions</span> |
| <span className="pg-drill-val"> |
| None apply — administrators bypass every rule |
| </span> |
| </div> |
| ) : 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 ( |
| <div className="pg-drill-row" key={key}> |
| <span className="pg-drill-key">{moduleLabels?.[key] ?? key}</span> |
| <span className="pg-drill-val">{words}</span> |
| </div> |
| ); |
| }) |
| : null} |
| </section> |
| </div> |
| ) : null} |
|
|
| {active === "users" ? <UsersPane /> : null} |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| // ── 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<string, string> = { |
| business: "Business-wide", |
| personal: "Personal", |
| }; |
| const scopeLabel = (s: string | undefined): string => |
| SCOPE_LABEL[String(s || "business")] || String(s); |
|
|
| function KeychainPane() { |
| const [entries, setEntries] = useState<KeyEntry[]>([]); |
| 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<string[]>([]); |
| const [form, setForm] = useState<{ |
| type: "odoo" | "generic"; |
| label: string; |
| scope: string; |
| fields: Record<string, string>; |
| busy: boolean; |
| } | null>(null); |
| const [testResult, setTestResult] = useState<Record<string, string>>({}); |
|
|
| 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 ( |
| <div className="set-pane"> |
| <h3 className="set-h">Keychains</h3> |
| <p className="set-help set-pane-intro"> |
| Credentials for this workspace's data sources, encrypted at rest. A stored secret is |
| never shown again — only its last four characters identify it. |
| </p> |
| {locked ? ( |
| <p className="set-error"> |
| 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. |
| </p> |
| ) : null} |
| {err ? <p className="set-error">{err}</p> : null} |
| {!loaded ? ( |
| <p className="set-help">Loading…</p> |
| ) : entries.length === 0 ? ( |
| <p className="set-help">No keys stored yet.</p> |
| ) : ( |
| <div className="set-card"> |
| {entries.map((e) => ( |
| <div key={e.id} className="set-kv-row kc-row"> |
| <div className="kc-main"> |
| <strong>{e.label}</strong> |
| <span className="kc-meta"> |
| {e.type} |
| {e.preview ? " · " + e.preview : ""} · {scopeLabel(e.scope)} |
| {e.scope === "personal" && e.owner ? " (" + e.owner + ")" : ""} · added by{" "} |
| {e.createdBy || "?"} |
| </span> |
| {testResult[e.id] ? ( |
| <span className="kc-test">{testResult[e.id]}</span> |
| ) : null} |
| </div> |
| <div className="kc-actions"> |
| {/* ⭐ 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) ? ( |
| <button |
| type="button" |
| className="set-btn" |
| onClick={async () => { |
| const next = e.scope === "personal" ? "business" : "personal"; |
| const r = await setKeychainScope(e.id, next); |
| if (r.ok) void reload(); |
| else setErr(r.message); |
| }} |
| > |
| {e.scope === "personal" ? "Make business-wide" : "Make personal"} |
| </button> |
| ) : null} |
| <button |
| type="button" |
| className="set-btn" |
| onClick={async () => { |
| setTestResult((t) => ({ ...t, [e.id]: "Testing…" })); |
| const r = await testKeychainEntry(e.id); |
| setTestResult((t) => ({ |
| ...t, |
| [e.id]: r.ok |
| ? (r.data.ok ? "OK — " : "Failed — ") + r.data.message |
| : r.message, |
| })); |
| }} |
| > |
| Test |
| </button> |
| <button |
| type="button" |
| className="set-btn kc-danger" |
| onClick={async () => { |
| const sure = window.confirm( |
| 'Delete the key "' + e.label + '"? This cannot be undone.' |
| ); |
| if (!sure) return; |
| const r = await deleteKeychainEntry(e.id); |
| if (r.ok) void reload(); |
| else setErr(r.message); |
| }} |
| > |
| Delete |
| </button> |
| </div> |
| </div> |
| ))} |
| </div> |
| )} |
| {form ? ( |
| <div className="set-card kc-form"> |
| <div className="set-field"> |
| <label>Type</label> |
| <select |
| value={form.type} |
| onChange={(e) => { |
| const type = e.target.value as "odoo" | "generic"; |
| setForm({ |
| ...form, |
| type, |
| fields: {}, |
| // A tenant-wide type has exactly one legal scope; snapping the picker is the |
| // honest move, because the alternative is a save that 400s on a choice the |
| // form offered. |
| scope: forcedBusiness(type) ? "business" : form.scope, |
| }); |
| }} |
| > |
| <option value="odoo">Odoo</option> |
| <option value="generic">Generic secret</option> |
| </select> |
| </div> |
| {/* ⭐⭐ 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. */} |
| <div className="set-field"> |
| <label>Who can use it</label> |
| {forcedBusiness(form.type) ? ( |
| <p className="set-help"> |
| Business-wide. This workspace's databases are read through an {form.type}{" "} |
| connection, so it applies to everyone here and cannot be personal. |
| </p> |
| ) : canBusiness ? ( |
| <select |
| value={form.scope} |
| onChange={(e) => setForm({ ...form, scope: e.target.value })} |
| > |
| <option value="business">Business-wide — everyone in this workspace</option> |
| <option value="personal">Personal — only me</option> |
| </select> |
| ) : ( |
| <p className="set-help"> |
| Personal — only you. An administrator can add a connection for the whole |
| workspace. |
| </p> |
| )} |
| </div> |
| <div className="set-field"> |
| <label>Label</label> |
| <input |
| value={form.label} |
| maxLength={80} |
| placeholder={form.type === "odoo" ? "Production Odoo" : "e.g. Shipping API"} |
| onChange={(e) => setForm({ ...form, label: e.target.value })} |
| /> |
| </div> |
| {(form.type === "odoo" |
| ? odooFields |
| : ([ |
| ["name", "Name"], |
| ["value", "Secret value"], |
| ] as Array<[string, string]>) |
| ).map(([k, label]) => ( |
| <div className="set-field" key={k}> |
| <label>{label}</label> |
| <input |
| value={form.fields[k] ?? ""} |
| type={k === "api_key" || k === "value" ? "password" : "text"} |
| onChange={(e) => |
| setForm({ ...form, fields: { ...form.fields, [k]: e.target.value } }) |
| } |
| /> |
| </div> |
| ))} |
| <div className="set-actions"> |
| <button type="button" className="set-btn" onClick={() => setForm(null)}> |
| Cancel |
| </button> |
| <button |
| type="button" |
| className="set-btn set-btn-primary" |
| disabled={form.busy || !form.label.trim() || locked} |
| onClick={() => void submit()} |
| > |
| {form.busy ? "Saving…" : "Save key"} |
| </button> |
| </div> |
| </div> |
| ) : ( |
| <button |
| type="button" |
| className="set-btn set-btn-primary" |
| disabled={locked} |
| onClick={() => |
| setForm({ |
| type: "odoo", |
| label: "", |
| // ⚠ The default is the SAFE one for the account opening the form: a member can only |
| // create a personal connection, so starting on `business` would put a value in the |
| // body the server refuses. |
| scope: canBusiness ? "business" : "personal", |
| fields: {}, |
| busy: false, |
| }) |
| } |
| > |
| Add a key |
| </button> |
| )} |
| </div> |
| ); |
| } |
|
|
| // ── Wave 18 (C7): the Connectors pane — sources + R3's Unsynced-records guardrail ─────────── |
| function ConnectorsPane() { |
| const [rows, setRows] = useState<ConnectorRow[]>([]); |
| const [note, setNote] = useState(""); |
| const [unsynced, setUnsynced] = useState<UnsyncedInfo | null>(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 ( |
| <div className="set-pane"> |
| <h3 className="set-h">Connectors</h3> |
| <p className="set-help set-pane-intro"> |
| Where this workspace's data comes from. Add credentials under Keychains; sources appear |
| here with their status. |
| </p> |
| {err ? <p className="set-error">{err}</p> : null} |
| {!loaded ? ( |
| <p className="set-help">Loading…</p> |
| ) : rows.length === 0 ? ( |
| <p className="set-help"> |
| No connectors yet. Store a key under Keychains to register a source — nothing syncs |
| until a later step turns it on. |
| </p> |
| ) : ( |
| <div className="set-card"> |
| {rows.map((r) => ( |
| <div key={r.key} className="set-kv-row kc-row"> |
| <div className="kc-main"> |
| <strong>{r.label}</strong> |
| <span className="kc-meta"> |
| {r.type} · {r.source === "env" ? "environment credentials" : "keychain"} |
| {r.paused ? " · paused" : " · active"} |
| </span> |
| </div> |
| <div className="kc-actions"> |
| <button |
| type="button" |
| className="set-btn" |
| onClick={async () => { |
| const res = await pauseConnector(r.key, !r.paused); |
| if (res.ok) void reload(); |
| else setErr(res.message); |
| }} |
| > |
| {r.paused ? "Resume" : "Pause"} |
| </button> |
| </div> |
| </div> |
| ))} |
| </div> |
| )} |
| {note ? <p className="set-help kc-note">{note}</p> : null} |
| {unsynced ? ( |
| <div className="set-card kc-unsynced"> |
| <strong>Unsynced records</strong> |
| {unsynced.known ? ( |
| unsynced.count === 0 ? ( |
| <p className="set-help"> |
| None — every record holding notes or custom-field data matches a row the |
| connector currently serves. |
| </p> |
| ) : ( |
| <> |
| <p className="set-help"> |
| {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. |
| </p> |
| <button |
| type="button" |
| className="set-btn" |
| onClick={() => setShowOrphans((s) => !s)} |
| > |
| {showOrphans ? "Hide" : "Show " + String(unsynced.shown ?? unsynced.rows.length)} |
| </button> |
| {showOrphans ? ( |
| <ul className="kc-orphans"> |
| {unsynced.rows.map((o) => ( |
| <li key={String(o.pid)}> |
| #{o.pid} — {o.fields} saved field{o.fields === 1 ? "" : "s"} |
| {o.hint ? " · " + o.hint : ""} |
| </li> |
| ))} |
| </ul> |
| ) : null} |
| </> |
| ) |
| ) : ( |
| <p className="set-help"> |
| Count unavailable right now{unsynced.note ? " (" + unsynced.note + ")" : ""} — the |
| pool could not be consulted; nothing was assumed. |
| </p> |
| )} |
| </div> |
| ) : null} |
| </div> |
| ); |
| } |
|
|