diff --git a/apps/console/src/screens/Settings.test.tsx b/apps/console/src/screens/Settings.test.tsx index 5347602..5b465e6 100644 --- a/apps/console/src/screens/Settings.test.tsx +++ b/apps/console/src/screens/Settings.test.tsx @@ -102,6 +102,9 @@ describe("Settings consolidation", () => { render(); await waitFor(() => expect(apiMock.invites).toHaveBeenCalled()); + // The approval policy now lives on its own tab. + fireEvent.click(screen.getByText("Approval policy")); + // Save is disabled until the control is dirty. expect(screen.getByTestId("approval-policy-save")).toBeDisabled(); // Bump the required approvals from 1 to 2. diff --git a/apps/console/src/screens/Settings.tsx b/apps/console/src/screens/Settings.tsx index 8b22ea5..63c59ad 100644 --- a/apps/console/src/screens/Settings.tsx +++ b/apps/console/src/screens/Settings.tsx @@ -1,24 +1,50 @@ /** - * Settings & team - the workspace's control surface. Members + team invites, - * the approval-policy control (require N approvals over $X, the simplified - * successor to the standalone M-of-N builder), account recovery, roles at a - * glance (full matrix on demand), counterparties, and integrations. The heavy - * value-moving actions still live on their own screens. + * Settings & team — the workspace control surface, organized into tabs: Team (members + * + roles + invites), Approval policy (require N approvals over $X), Recovery, Payees, + * and Integrations. The heavy value-moving actions still live on their own screens. */ import { Fragment, useEffect, useState } from "react"; -import { Building2, Check, ChevronDown, KeyRound, Minus, Plug, Plus, Send, ShieldCheck, UserPlus, Users, X } from "lucide-react"; -import type { Integration } from "@benzo/types"; +import { Building2, Check, ChevronDown, KeyRound, Minus, Plug, Plus, RefreshCw, Send, ShieldCheck, UserPlus, Users, X } from "lucide-react"; +import type { ApprovalPolicy, Integration } from "@benzo/types"; import { api, type OrgInvite, type RecoveryStatus } from "../lib/api"; import { useConsole } from "../lib/store"; import { ROLES, roleHas, PERMISSION_GROUPS, ROLE_BLURB } from "../lib/permissions"; -import { friendlyError, fmtUsd, initials, minorToUsdc, usdcToMinor } from "../lib/format"; +import { friendlyError, fmtDateTime, fmtUsd, initials, minorToUsdc, usdcToMinor } from "../lib/format"; +import { copyTextToClipboard } from "../lib/clipboard"; import { Screen, Stagger, motion } from "../ui/motion"; -import { Button, Card, CopyButton, Input, Modal, Pill, Select, Skeleton, StatusPill, useToast } from "../ui/primitives"; +import { + Button, + Card, + Input, + MetaPill, + Modal, + PageHeader, + Pill, + Select, + Skeleton, + StatusPill, + Tabs, + Td, + Th, + Tr, + useToast, +} from "../ui/primitives"; const MEMBER_ROLES = ["owner", "admin", "treasurer", "approver", "auditor"] as const; +type TabId = "team" | "policy" | "recovery" | "payees" | "integrations"; + +const PROVIDER_LABEL: Record = { + quickbooks: "QuickBooks", + xero: "Xero", + merge: "Merge", + plaid: "Plaid", + slack: "Slack", + gusto: "Gusto", +}; export function SettingsScreen() { - const { counterparties, session, loading } = useConsole(); + const { session, loading } = useConsole(); + const [tab, setTab] = useState("team"); const [integrations, setIntegrations] = useState(null); const [recovery, setRecovery] = useState(null); useEffect(() => { @@ -26,121 +52,71 @@ export function SettingsScreen() { api.recoveryStatus().then((r) => setRecovery(r.recovery)).catch(() => setRecovery(null)); }, []); + const org = session?.org; + return ( -
-

Settings & team

-

{session?.org.legalName ?? session?.org.name} · {session?.org.country ?? "Country not set"} · KYB {session?.org.kybStatus}

-
+ - - - - - - - - - - - -
- Account recovery -
-
-
- {recovery?.bound ? "This workspace is bound to your current sign-in." : "This workspace is not bound yet."} -
-

- If your wallet sign-in changes, Benzo blocks access instead of attaching this workspace to a different key. Recovery requires an owner-approved migration. -

-
    - {(recovery?.nextSteps?.length ? recovery.nextSteps : ["Finish sign-in as an owner to bind this workspace key."]).map((step) => ( -
  • - - {step} -
  • - ))} -
-
-
-
- - - - + {/* Org identity header */} + +
+ + + +
+
{org?.legalName ?? org?.name ?? "Workspace"}
+
{org?.name}{org?.country ? ` · ${org.country}` : ""}
+
+
+
+ KYB + {org?.kybStatus ? : null} +
+
- - -
- Vendors & contractors -
-
- {loading && counterparties.length === 0 ? ( - [0, 1, 2].map((i) => ( -
-
- - -
- -
- )) - ) : counterparties.length === 0 ? ( -
No vendors or contractors yet.
- ) : ( - counterparties.map((c) => ( -
-
-
{c.name}
-
{c.type}
-
- -
- )) - )} -
-
-
+
+ +
- - -
- Integrations -
-
- {integrations === null ? ( - [0, 1].map((i) => ( -
- - -
- )) - ) : integrations.length === 0 ? ( -
No integrations connected.
- ) : ( - integrations.map((it) => ( -
-
{it.provider}
- -
- )) - )} -
-
-
-
+ {tab === "team" ? ( + + + + + + + + + ) : tab === "policy" ? ( + + ) : tab === "recovery" ? ( + + ) : tab === "payees" ? ( + + ) : ( + + )}
); } /** - * Team - members plus team invites (folded in from the retired standalone - * Invites screen). A team invite mints a console seat; share the link and the - * teammate finishes sign-in themselves. + * Team — members (table), roles, and team invites. A team invite mints a console + * seat; the raw claim URL is never shown — Copy link / Resend / Revoke instead. */ function TeamCard() { - const { members, loading } = useConsole(); + const { members, session, loading } = useConsole(); const toast = useToast(); const [invites, setInvites] = useState(null); const [open, setOpen] = useState(false); @@ -190,6 +166,11 @@ function TeamCard() { } } + async function copyLink(link: string, successMsg = "Invite link copied") { + const ok = await copyTextToClipboard(link); + toast({ title: ok ? successMsg : "Couldn't copy the link", tone: ok ? "success" : "danger" }); + } + async function revoke(id: string) { setRevoking(true); try { @@ -214,9 +195,10 @@ function TeamCard() { Invite teammate -
- {loading && members.length === 0 ? ( - [0, 1, 2].map((i) => ( + + {loading && members.length === 0 ? ( +
+ {[0, 1, 2].map((i) => (
@@ -225,37 +207,63 @@ function TeamCard() {
- )) - ) : members.length === 0 ? ( -
No team members yet. Invite one to enable maker-checker.
- ) : ( - members.map((m) => ( -
-
- {initials(m.name ?? m.email)} -
-
-
{m.name ?? m.email}
-
{m.email}
-
- {m.role} - -
- )) - )} -
+ ))} +
+ ) : members.length === 0 ? ( +
No team members yet. Invite one to enable maker-checker.
+ ) : ( +
+ + + + + + + + + + + + {members.map((m) => { + const you = !!session?.member && m.id === session.member.id; + return ( + + + + + + + + ); + })} + +
MemberEmailRoleStatusActions
+
+ + {initials(m.name ?? m.email)} + + {m.name ?? m.email} + {you ? You : null} +
+
{m.email}{m.role}
+
+ )} {pending.length ? (
-
Pending invites
+
Pending invites
{pending.map((inv) => ( -
- {inv.name ?? inv.email ?? "Invite"} - {inv.link} - {inv.role ? {inv.role} : null} - {inv.status} - +
+ {inv.name ?? inv.email ?? "Invite"} + {inv.role ? {inv.role} : null} + + +
@@ -318,48 +326,81 @@ function TeamCard() { } /** - * Approval policy - the simplified successor to the standalone M-of-N builder: - * one honest control, "require N approvals over $X". It edits the workspace's - * primary policy (amount routing condition + the on-chain-enforced release gate) - * without exposing the full chain editor, which was too heavy for the surface. + * Approval policy — "require N approvals over $X". When none is configured we show a + * coherent empty state with Create (no dashboard contradiction); the editor persists + * a real policy via updatePolicy (a freshly created default is session-local until a + * backend create exists). */ function ApprovalPolicyCard() { - const { policies, refresh } = useConsole(); + const { policies, session, refresh } = useConsole(); const toast = useToast(); - const policy = policies[0]; + const storePolicy = policies[0]; + const [localPolicy, setLocalPolicy] = useState(null); + const policy = storePolicy ?? localPolicy; + const savedAmountCond = policy?.conditions.find((c) => c.field === "amount"); - const savedAmount = savedAmountCond ? minorToUsdc(savedAmountCond.value) : ""; + const savedAmount = savedAmountCond && !Array.isArray(savedAmountCond.value) ? minorToUsdc(savedAmountCond.value) : ""; const savedN = policy?.releaseGate?.minApprovers ?? policy?.steps[0]?.minApprovers ?? 1; const [amount, setAmount] = useState(savedAmount); const [n, setN] = useState(savedN); const [busy, setBusy] = useState(false); - // Re-sync from the store only when the underlying policy actually changes - // (e.g. after a save + refresh), not on every background array-identity churn. - const sig = policy ? JSON.stringify([policy.conditions, policy.steps, policy.releaseGate]) : "none"; + const sig = policy ? JSON.stringify([policy.id, policy.conditions, policy.steps, policy.releaseGate]) : "none"; useEffect(() => { setAmount(savedAmount); setN(savedN); - // Keyed on `sig` so edits survive background refreshes; savedAmount/savedN track it. + // Keyed on `sig` so edits survive background refreshes. }, [sig]); const dirty = !!policy && (amount !== savedAmount || n !== savedN); - // A blank field would otherwise save as $0 — i.e. require approval on EVERY - // payment — silently. Require an explicit non-negative number to save. const amountValid = amount.trim() !== "" && Number.isFinite(Number(amount)) && Number(amount) >= 0; + function createDefault() { + setLocalPolicy({ + id: `pol_${Date.now()}`, + orgId: session?.org.id ?? "org", + name: "Default policy", + conditions: [{ field: "amount", operator: "gte", value: usdcToMinor("10000") }], + steps: [{ role: "approver", mode: "any", minApprovers: 2 }], + releaseGate: { role: "treasurer", mode: "all", minApprovers: 1 }, + reApprovalTriggers: [], + createdAt: new Date().toISOString(), + }); + toast({ title: "Default policy created — review and activate", tone: "success" }); + } + async function save() { if (!policy) return; + const minor = usdcToMinor(amount || "0"); + const hasAmount = policy.conditions.some((c) => c.field === "amount"); + const conditions = hasAmount + ? policy.conditions.map((c) => (c.field === "amount" ? { ...c, operator: "gte" as const, value: minor } : c)) + : [{ field: "amount" as const, operator: "gte" as const, value: minor }, ...policy.conditions]; + const releaseGate = policy.releaseGate ? { ...policy.releaseGate, minApprovers: n } : undefined; + const steps = releaseGate ? policy.steps : policy.steps.map((s, i) => (i === 0 ? { ...s, minApprovers: n } : s)); + + // A freshly created default isn't in the store yet. Try to persist it; if this + // build has no create/upsert path (updatePolicy rejects an unknown id), keep it + // for the session and say so honestly — never claim it's activated when it isn't. + if (!storePolicy && localPolicy) { + const next = { ...localPolicy, conditions, steps, releaseGate }; + setBusy(true); + try { + await api.updatePolicy(localPolicy.id, { conditions, steps, releaseGate }); + await refresh(); + setLocalPolicy(next); + toast({ title: "Approval policy activated", tone: "success" }); + } catch { + setLocalPolicy(next); + toast({ title: "Session-only default — not yet saved to your workspace.", tone: "warning" }); + } finally { + setBusy(false); + } + return; + } setBusy(true); try { - const minor = usdcToMinor(amount || "0"); - const hasAmount = policy.conditions.some((c) => c.field === "amount"); - const conditions = hasAmount - ? policy.conditions.map((c) => (c.field === "amount" ? { ...c, operator: "gte" as const, value: minor } : c)) - : [{ field: "amount" as const, operator: "gte" as const, value: minor }, ...policy.conditions]; - const releaseGate = policy.releaseGate ? { ...policy.releaseGate, minApprovers: n } : undefined; - const steps = releaseGate ? policy.steps : policy.steps.map((s, i) => (i === 0 ? { ...s, minApprovers: n } : s)); await api.updatePolicy(policy.id, { conditions, steps, releaseGate }); await refresh(); toast({ title: "Approval policy saved", tone: "success" }); @@ -376,9 +417,24 @@ function ApprovalPolicyCard() { Approval policy
{!policy ? ( -
No approval policy yet. One is created with your workspace.
+
+
No approval policy configured
+

+ Create a policy so payments over a threshold route to Approvals for dual control. Until then, no payment can require approval. +

+
+ +
+
) : ( <> + {!storePolicy ? ( +
+ Default policy created — review the threshold and approvals below, then activate. +
+ ) : null}
- Enforced on-chain. Org funds settle only with a valid in-circuit M-of-N proof - the verifier rejects a single-key spend, so release is gated by the contract, not just this server. A proposer can never approve their own payment. + Enforced on-chain. Org funds settle only with a valid in-circuit M-of-N proof — the verifier rejects a single-key spend, so release is gated by the contract, not just this server. A proposer can never approve their own payment.
-
@@ -440,10 +501,155 @@ function Stepper({ value, onDec, onInc, testid }: { value: number; onDec: () => ); } +/** Account recovery — the workspace binding + next steps. */ +function RecoveryCard({ recovery }: { recovery: RecoveryStatus["recovery"] | null }) { + return ( + +
+ Account recovery +
+
+
+ {recovery?.bound ? "This workspace is bound to your current sign-in." : "This workspace is not bound yet."} +
+

+ If your wallet sign-in changes, Benzo blocks access instead of attaching this workspace to a different key. Recovery requires an owner-approved migration. +

+
    + {(recovery?.nextSteps?.length ? recovery.nextSteps : ["Finish sign-in as an owner to bind this workspace key."]).map((step) => ( +
  • + + {step} +
  • + ))} +
+
+
+ ); +} + +/** Payees — vendors & contractors at a glance. */ +function PayeesCard({ loading }: { loading: boolean }) { + const { counterparties } = useConsole(); + return ( + +
+ Vendors & contractors +
+ {loading && counterparties.length === 0 ? ( +
+ {[0, 1, 2].map((i) => ( +
+
+ + +
+ +
+ ))} +
+ ) : counterparties.length === 0 ? ( +
No vendors or contractors yet.
+ ) : ( +
+ + + + + + + + + + + {counterparties.map((c) => ( + + + + + + + ))} + +
NameTypeTax formStatus
{c.name}{c.type}{c.taxFormType && c.taxFormType !== "none" ? {c.taxFormType} : }
+
+ )} +
+ ); +} + +/** Integrations — table with connected account, last sync, and actionable errors. */ +function IntegrationsCard({ integrations }: { integrations: Integration[] | null }) { + const toast = useToast(); + return ( + +
+ Integrations +
+ {integrations === null ? ( +
+ {[0, 1].map((i) => ( +
+ + +
+ ))} +
+ ) : integrations.length === 0 ? ( +
No integrations connected.
+ ) : ( +
+ + + + + + + + + + + + {integrations.map((it) => { + const account = it.externalRefs?.linkedAccount ?? it.externalRefs?.company ?? (it.status === "connected" ? "Connected" : "—"); + return ( + + + + + + + + ); + })} + +
IntegrationConnected accountLast syncStatusActions
{PROVIDER_LABEL[it.provider] ?? it.provider}{account}{it.lastSyncAt ? fmtDateTime(it.lastSyncAt) : "Never"} +
+ + {it.status === "error" && it.lastError ? {it.lastError} : null} +
+
+ {it.status === "error" ? ( + + ) : it.status === "disconnected" ? ( + + ) : ( + + )} +
+
+ )} +
+ ); +} + /** - * Roles - the per-role blurb list is the everyday read; the full - * roles x permissions matrix (driven by ROLE_PERMISSIONS) stays one click away - * behind "See full matrix" so the surface isn't a wall of checkmarks. + * Roles — the per-role blurb list is the everyday read; the full roles × permissions + * matrix stays one click away behind "See full matrix". */ function RolesCard() { const [showMatrix, setShowMatrix] = useState(false); @@ -504,7 +710,7 @@ function RolesCard() {
) : null}
- Auditor is a scoped viewing-key holder - read-only, never a signer. A privacy-native role. + Auditor is a scoped viewing-key holder — read-only, never a signer. A privacy-native role.
);