loopable / web /src /settings /SettingsModal.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
609fb78 verified
Raw
History Blame Contribute Delete
55.5 kB
// ---------------------------------------------------------------------------
// 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<typeof patchUser>[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 (
<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>
);
}
// ── 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 (
<svg className="set-rail-icon" viewBox="0 0 16 16" aria-hidden="true">
{children}
</svg>
);
}
/** Account — the person whose settings these are. */
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>
);
/** Your access — a closed padlock: what this account may open, and what narrows it. */
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>
);
/** Manage users — more than one person. */
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>
);
/** Keychains — a key. */
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>
);
/** 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 = (
<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>
);
/** 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 = (
<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("");
/**
* ⚠ 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<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]);
// 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 (
<div className="set-loading">
<span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
</div>
);
// 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 (
<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 conditionsthe 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>
);
}