Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
363 changes: 226 additions & 137 deletions apps/console/src/app/Onboarding.tsx

Large diffs are not rendered by default.

23 changes: 18 additions & 5 deletions apps/console/src/app/RootGate.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/**
* RootGate - decides between SIWE sign-in and the workspace. Real mode trusts the
* cookie-backed /auth/me probe in the store; demo mode keeps the old local
* onboarding flag so the seeded showcase still boots straight into the shell.
* RootGate - decides between SIWE sign-in, no-org onboarding, and the workspace.
* Real mode trusts the cookie-backed /auth/me probe in the store; demo mode keeps
* the old local onboarding flag so the seeded showcase still boots into the shell.
*/
import { useState } from "react";
import { useEffect, useState } from "react";
import { Shell } from "./Shell";
import { Onboarding } from "./Onboarding";
import { DesktopOnly, useIsDesktop } from "./DesktopOnly";
Expand All @@ -16,11 +16,23 @@ export function RootGate() {
// Demo mode skips SIWE onboarding entirely and boots into the Shell (the
// desktop-only gate below still applies — this stays a desktop product).
const [onboarded, setOnboarded] = useState(() => DEMO_MODE || localStorage.getItem("benzo.console.onboarded") === "1");
const [orgOnboarding, setOrgOnboarding] = useState(false);
function finish() {
localStorage.setItem("benzo.console.onboarded", "1");
setOnboarded(true);
void refresh();
}
function finishOrgOnboarding() {
setOrgOnboarding(false);
void refresh();
}
useEffect(() => {
if (!session) {
setOrgOnboarding(false);
return;
}
if (!session.activeOrg) setOrgOnboarding(true);
}, [session]);
Comment on lines +29 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reset orgOnboarding when an active organization appears.

This effect only changes the flag to true. If a refresh later supplies session.activeOrg, the stale flag still routes the user to onboarding because Line 45 checks orgOnboarding first.

Proposed fix
 useEffect(() => {
-  if (!session) {
-    setOrgOnboarding(false);
-    return;
-  }
-  if (!session.activeOrg) setOrgOnboarding(true);
+  setOrgOnboarding(Boolean(session && !session.activeOrg));
 }, [session]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (!session) {
setOrgOnboarding(false);
return;
}
if (!session.activeOrg) setOrgOnboarding(true);
}, [session]);
useEffect(() => {
setOrgOnboarding(Boolean(session && !session.activeOrg));
}, [session]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/console/src/app/RootGate.tsx` around lines 29 - 35, Update the useEffect
in RootGate to explicitly set orgOnboarding to false whenever session.activeOrg
exists, while preserving the existing false reset when there is no session;
ensure the flag is assigned on both branches so a refreshed active organization
cannot leave stale onboarding state.

if (!isDesktop) return <DesktopOnly />;
if (DEMO_MODE) return onboarded ? <Shell /> : <Onboarding onDone={finish} />;
if (loading && !session) {
Expand All @@ -30,5 +42,6 @@ export function RootGate() {
</div>
);
}
return session ? <Shell /> : <Onboarding onDone={() => void refresh()} />;
if (session && (orgOnboarding || !session.activeOrg)) return <Onboarding onDone={finishOrgOnboarding} />;
return session?.activeOrg ? <Shell /> : <Onboarding onDone={() => void refresh()} />;
}
95 changes: 87 additions & 8 deletions apps/console/src/demo/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@
*/
import type {
Counterparty,
CreateOrgResponse,
Invoice,
OnboardingStatus,
OnboardingStatusResponse,
PaymentOrder,
PayrollBatch,
ProvisionTreasuryResponse,
StartOnboardingResponse,
ViewingGrant,
} from "@benzo/types";
import type {
Expand All @@ -30,6 +35,7 @@ import { createDemoDb, dashboardSummary, treasuryView, usd } from "./data";
// PURE-annotated so a normal build (DEMO_MODE folds to false → demoApi unused)
// tree-shakes the entire demo graph away, leaving the production bundle unchanged.
const db = /* @__PURE__ */ createDemoDb();
let demoMockKyc: { name?: string; country?: string } | undefined;

// ---- fake-chain helpers ---------------------------------------------------
function randHex(len: number): string {
Expand Down Expand Up @@ -65,13 +71,46 @@ const byId = <T extends { id: string }>(arr: T[], id: string) => arr.find((x) =>
const READ = 140;
const PROVE = 640;
const SETTLE = 720;
const ONBOARDING_STEP = 520;

function activeOrg() {
const org = db.session.activeOrg;
if (!org) throw new Error("No demo org");
return org;
}

const ONBOARDING_STATUSES = ["pending_kyc", "kyc_approved", "allowlisted", "gas_dripped", "awaiting_registration", "complete"] as const;

function setDemoOnboardingStatus(status: OnboardingStatus["status"], mockKycPayload?: { name?: string; country?: string }): OnboardingStatus {
const order = ONBOARDING_STATUSES.indexOf(status as (typeof ONBOARDING_STATUSES)[number]);
const timestamp = now();
const kycDone = order >= 1;
const allowlistDone = order >= 2;
const gasDone = order >= 3;
const registrationChecked = order >= 4;
const registrationDone = order >= 5;
db.onboardingStatus = {
...db.onboardingStatus,
status,
error: status === "failed" ? db.onboardingStatus.error ?? "Onboarding failed." : null,
updatedAt: timestamp,
mockKyc: kycDone
? {
approvedAt: timestamp,
payload: mockKycPayload ?? db.onboardingStatus.mockKyc?.payload ?? {},
provider: "demo",
}
: null,
steps: {
kyc: { completedAt: kycDone ? timestamp : null, provider: kycDone ? "demo" : null },
allowlist: { completedAt: allowlistDone ? timestamp : null, result: allowlistDone ? { ok: true } : null, txHash: allowlistDone ? fakeTx() : null },
gas: { completedAt: gasDone ? timestamp : null, result: gasDone ? { ok: true } : null, txHash: gasDone ? fakeTx() : null },
registration: { completedAt: registrationDone ? timestamp : null, lastCheckedAt: registrationChecked ? timestamp : null },
},
};
return clone(db.onboardingStatus);
}

export const demoApi = {
// ---- session / status ---------------------------------------------------
session: async () => (await delay(READ), clone(db.session)),
Expand Down Expand Up @@ -380,14 +419,54 @@ export const demoApi = {
return clone(p);
},

// ---- onboarding / auth (unused: demo boots straight into the Shell) -----
onboarding: async () => ({}),
saveOnboarding: async (patch: unknown) => patch as Record<string, unknown>,
submitKyb: async () => ({ status: "approved" as const, provider: "demo", inquiryRef: `kyb_${randHex(6)}`, checks: ["identity", "sanctions"], onChain: true, txHash: fakeTx() }),
kybStatus: async () => ({ status: "approved" as const, inquiryRef: `kyb_${randHex(6)}`, onChain: true }),
provisionTreasury: async () => ({ onChain: true, txHash: fakeTx(), mvkRoot: fakeRoot(), treasuryAddress: db.publicAddress }),
registerOwnerMvk: async () => ({ onChain: true, txHash: fakeTx(), mvkRoot: fakeRoot() }),
finishOnboarding: async () => clone(db.session),
// ---- onboarding / auth -------------------------------------------------
createOrg: async (body: { name: string; slug: string }): Promise<CreateOrgResponse> => {
await delay(READ);
const org = { id: `org_${randHex(6)}`, name: body.name, slug: body.slug, role: "owner" as const, createdAt: now() };
db.session.orgs = [org, ...db.session.orgs.filter((existing) => existing.id !== org.id)];
db.session.activeOrg = org;
db.session.role = "owner";
return { org: clone(org), role: "owner" };
},
startOnboarding: async (mockKyc?: { name?: string; country?: string }): Promise<StartOnboardingResponse> => {
await delay(READ);
demoMockKyc = mockKyc;
db.onboardingStatus = {
...db.onboardingStatus,
id: `onb_${randHex(6)}`,
status: "pending_kyc",
error: null,
createdAt: now(),
updatedAt: now(),
mockKyc: null,
steps: {
kyc: { completedAt: null, provider: null },
allowlist: { completedAt: null, result: null, txHash: null },
gas: { completedAt: null, result: null, txHash: null },
registration: { completedAt: null, lastCheckedAt: null },
},
};
return { jobId: `job_${randHex(8)}`, onboarding: clone(db.onboardingStatus) };
},
onboardingStatus: async (): Promise<OnboardingStatusResponse> => (await delay(READ), { onboarding: clone(db.onboardingStatus) }),
subscribeOnboardingStatus: (onStatus: (onboarding: OnboardingStatus) => void): { close: () => void } => {
let closed = false;
const timers = ONBOARDING_STATUSES.map((status, index) => window.setTimeout(() => {
if (closed) return;
onStatus(setDemoOnboardingStatus(status, demoMockKyc));
}, index * ONBOARDING_STEP));
return {
close: () => {
closed = true;
timers.forEach((timer) => window.clearTimeout(timer));
},
};
},
provisionTreasury: async (_orgId: string): Promise<ProvisionTreasuryResponse> => {
await delay(SETTLE);
db.treasuryProvision = { ...db.treasuryProvision, registered: true, consented: true, registrationTxHash: fakeTx() };
return clone(db.treasuryProvision);
},
orgs: async () => ({ orgs: clone(db.session.orgs) }),
siweNonce: async () => ({ nonce: randHex(16) }),
siweVerify: async () => ({ user: clone(db.session.user) }),
Expand Down
36 changes: 35 additions & 1 deletion apps/console/src/demo/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import type {
LedgerEntry,
LiveStatusResponse,
Member,
OnboardingStatus,
PaymentOrder,
PayrollBatch,
ProvisionTreasuryResponse,
TreasuryView,
ViewingGrant,
} from "@benzo/types";
Expand Down Expand Up @@ -46,6 +48,8 @@ export interface DemoDb {
integrations: Integration[];
invites: OrgInvite[];
ledger: LedgerEntry[];
onboardingStatus: OnboardingStatus;
treasuryProvision: ProvisionTreasuryResponse;
/** Private (shielded) pool total, minor units — grows when "Make private" runs. */
privateTotal: string;
/** Public liquid USDC balance, minor units. */
Expand All @@ -64,6 +68,7 @@ const shielded = (handle: string, spend: string) => ({
});

export function createDemoDb(): DemoDb {
const publicAddress = "0x9Fb2c7A11e4D3f6B8a0C15e2D9f4A7c3B6e1D8a2";
const owner: Member = {
id: "mem_owner",
orgId: ORG_ID,
Expand Down Expand Up @@ -338,6 +343,33 @@ export function createDemoDb(): DemoDb {
{ id: "vg_1", kind: "grant", title: "Grant Thornton LLP · Q2", status: "active", amountLabel: "—", at: ISO("2026-07-01T00:00:00Z") },
];

const onboardingStatus: OnboardingStatus = {
id: "onb_demo",
userId: owner.id,
address: DEMO_OWNER_ADDRESS,
chainEnv: "demo",
chainId: 43113,
status: "complete",
error: null,
createdAt: ISO("2026-02-03"),
updatedAt: ISO("2026-02-03T00:05:00Z"),
mockKyc: { approvedAt: ISO("2026-02-03T00:01:00Z"), payload: { name: org.name, country: org.country }, provider: "demo" },
steps: {
kyc: { completedAt: ISO("2026-02-03T00:01:00Z"), provider: "demo" },
allowlist: { completedAt: ISO("2026-02-03T00:02:00Z"), result: { ok: true }, txHash: fakeHash("a110") },
gas: { completedAt: ISO("2026-02-03T00:03:00Z"), result: { ok: true }, txHash: fakeHash("9a5") },
registration: { completedAt: ISO("2026-02-03T00:04:00Z"), lastCheckedAt: ISO("2026-02-03T00:04:00Z") },
},
};

const treasuryProvision: ProvisionTreasuryResponse = {
address: publicAddress,
custody: "managed",
registered: true,
consented: true,
registrationTxHash: fakeHash("eerc"),
};

return {
session,
live: { live: true, mode: "live", missing: [] },
Expand All @@ -352,9 +384,11 @@ export function createDemoDb(): DemoDb {
integrations,
invites,
ledger,
onboardingStatus,
treasuryProvision,
privateTotal: usd(842300),
publicUnits: usd(48250),
publicAddress: "0x9Fb2c7A11e4D3f6B8a0C15e2D9f4A7c3B6e1D8a2",
publicAddress,
usdcIssuer: "0x5425890298aed601595a70AB815c96711a31Bc65",
dashboardActivity,
};
Expand Down
97 changes: 92 additions & 5 deletions apps/console/src/lib/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CreatePaymentRequest } from "@benzo/types";
import type { CreatePaymentRequest, OnboardingStatus } from "@benzo/types";
import { ACTIVE_ORG_KEY, api, apiHref } from "./api";

function jsonResponse(body: unknown, status = 200): Response {
Expand All @@ -15,6 +15,27 @@ function callHeaders(call: unknown[]): Headers {
: new Headers();
}

function onboardingStatus(status: OnboardingStatus["status"]): OnboardingStatus {
return {
id: "onb_1",
userId: "usr_1",
address: "0x1234567890abcdef1234567890abcdef12345678",
chainEnv: "testnet",
chainId: 43113,
Comment on lines +23 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== relevant files =="
git ls-files | rg '^(apps/console/src/lib/api\.test\.ts|packages/.*/config|.*config.*)$' || true

echo
echo "== search for `@benzo/config` usage =="
rg -n "`@benzo/config`|chainEnv|chainId|testnet|43113" apps/console/src packages -g '!**/node_modules/**' || true

echo
echo "== map api.test.ts =="
if [ -f apps/console/src/lib/api.test.ts ]; then
  wc -l apps/console/src/lib/api.test.ts
  cat -n apps/console/src/lib/api.test.ts
fi

echo
echo "== map candidate config files =="
for f in $(git ls-files | rg '^.*config.*\.(ts|tsx|js|mjs|cjs|json)$' | head -n 40); do
  echo "--- $f"
  wc -l "$f"
done

Repository: Miny-Labs/benzo-console

Length of output: 25688


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file list =="
git ls-files | rg '^(apps/console/src/lib/api\.test\.ts|.*`@benzo/config`.*|.*config.*)$' || true

echo
echo "== api.test.ts =="
sed -n '1,200p' apps/console/src/lib/api.test.ts

echo
echo "== `@benzo/config` references =="
rg -n "`@benzo/config`|chainEnv|chainId|testnet|43113" apps/console/src packages -g '!**/node_modules/**' || true

Repository: Miny-Labs/benzo-console

Length of output: 15752


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== packages/config/src/index.ts (relevant ranges) =="
sed -n '1,120p' packages/config/src/index.ts
echo
sed -n '120,240p' packages/config/src/index.ts
echo
sed -n '240,320p' packages/config/src/index.ts

echo
echo "== packages/config/test/index.test.ts =="
sed -n '1,120p' packages/config/test/index.test.ts

echo
echo "== apps/console/src/lib/api.ts =="
sed -n '1,220p' apps/console/src/lib/api.ts

Repository: Miny-Labs/benzo-console

Length of output: 17830


Import the shared Fuji chain metadata here. chainEnv/chainId still duplicate the test chain setup; pull the chain ID from @benzo/config and derive the env label from the shared chain object so this fixture stays in sync.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/console/src/lib/api.test.ts` around lines 23 - 24, Update the test
fixture’s chain configuration to use the shared Fuji metadata from
`@benzo/config`: import the Fuji chain object and shared chain ID, replace the
hardcoded chainId with the shared value, and derive chainEnv from the imported
chain object instead of duplicating "testnet".

Source: Coding guidelines

status,
error: status === "failed" ? "failed" : null,
createdAt: "2026-07-10T00:00:00.000Z",
updatedAt: "2026-07-10T00:00:02.000Z",
mockKyc: null,
steps: {
kyc: { completedAt: status === "pending_kyc" ? null : "2026-07-10T00:00:01.000Z", provider: status === "pending_kyc" ? null : "mock" },
allowlist: { completedAt: ["allowlisted", "gas_dripped", "awaiting_registration", "complete"].includes(status) ? "2026-07-10T00:00:02.000Z" : null, result: null, txHash: null },
gas: { completedAt: ["gas_dripped", "awaiting_registration", "complete"].includes(status) ? "2026-07-10T00:00:03.000Z" : null, result: null, txHash: null },
registration: { completedAt: status === "complete" ? "2026-07-10T00:00:04.000Z" : null, lastCheckedAt: ["awaiting_registration", "complete"].includes(status) ? "2026-07-10T00:00:04.000Z" : null },
},
};
}

describe("console API idempotency", () => {
afterEach(() => {
localStorage.clear();
Expand Down Expand Up @@ -73,12 +94,11 @@ describe("console API idempotency", () => {
amount: { amount: "10000000", assetCode: "USDC" },
} satisfies CreatePaymentRequest;
const actions: Array<() => Promise<unknown>> = [
() => api.saveOnboarding({ name: "Acme" }),
() => api.createOrg({ name: "Acme", slug: "acme" }),
() => api.startOnboarding({ name: "Acme Inc.", country: "US" }),
() => api.siweVerify("m", "0xsig"),
() => api.logout(),
() => api.submitKyb({ legalName: "Acme Inc." }),
() => api.provisionTreasury(),
() => api.finishOnboarding(),
() => api.provisionTreasury("org_1"),
() => api.proveBalance("1"),
() => api.proveTotal(),
() => api.proveSolvency(),
Expand Down Expand Up @@ -129,6 +149,73 @@ describe("console API idempotency", () => {
expect(callHeaders(fetchMock.mock.calls[1]).get("idempotency-key")).toBe(firstKey);
});

it("uses the real org and eERC onboarding endpoints", async () => {
const complete = onboardingStatus("complete");
const fetchMock = vi.fn()
.mockResolvedValueOnce(jsonResponse({ org: { id: "org_1", name: "Acme", slug: "acme", role: "owner", createdAt: "2026-07-10T00:00:00.000Z" }, role: "owner" }))
.mockResolvedValueOnce(jsonResponse({ jobId: "job_1", onboarding: onboardingStatus("pending_kyc") }, 202))
.mockResolvedValueOnce(jsonResponse({ onboarding: complete }))
.mockResolvedValueOnce(jsonResponse({ address: "0xtreasury", custody: "managed", registered: true, consented: true, registrationTxHash: "0xreg" }, 201));
vi.stubGlobal("fetch", fetchMock);

await expect(api.createOrg({ name: "Acme", slug: "acme" })).resolves.toMatchObject({ role: "owner", org: { id: "org_1" } });
await expect(api.startOnboarding({ name: "Acme Inc.", country: "US" })).resolves.toMatchObject({ jobId: "job_1" });
await expect(api.onboardingStatus()).resolves.toMatchObject({ onboarding: { status: "complete" } });
await expect(api.provisionTreasury("org_1")).resolves.toMatchObject({ address: "0xtreasury", custody: "managed" });

expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
apiHref("/orgs"),
apiHref("/onboarding/start"),
apiHref("/onboarding/status"),
apiHref("/orgs/org_1/treasury"),
]);
expect(fetchMock.mock.calls[0][1]).toMatchObject({ method: "POST", body: JSON.stringify({ name: "Acme", slug: "acme" }), credentials: "include" });
expect(fetchMock.mock.calls[1][1]).toMatchObject({ method: "POST", body: JSON.stringify({ mockKyc: { name: "Acme Inc.", country: "US" } }), credentials: "include" });
expect(callHeaders(fetchMock.mock.calls[2]).get("idempotency-key")).toBeNull();
expect(fetchMock.mock.calls[3][1]).toMatchObject({ method: "POST", body: JSON.stringify({ consent: true }), credentials: "include" });
});

it("subscribes to onboarding status with credentialed EventSource", () => {
class FakeEventSource {
static instances: FakeEventSource[] = [];
url: string | URL;
init?: EventSourceInit;
private listeners = new Map<string, Array<(event: MessageEvent<string>) => void>>();

constructor(url: string | URL, init?: EventSourceInit) {
this.url = url;
this.init = init;
FakeEventSource.instances.push(this);
}

addEventListener(type: string, listener: EventListenerOrEventListenerObject | null) {
if (typeof listener !== "function") return;
const listeners = this.listeners.get(type) ?? [];
listeners.push(listener as (event: MessageEvent<string>) => void);
this.listeners.set(type, listeners);
}

close() {}

emit(type: string, data: unknown) {
const event = new MessageEvent(type, { data: JSON.stringify(data) });
for (const listener of this.listeners.get(type) ?? []) listener(event);
}
}
vi.stubGlobal("EventSource", FakeEventSource as unknown as typeof EventSource);
const seen: OnboardingStatus["status"][] = [];

const subscription = api.subscribeOnboardingStatus((status) => seen.push(status.status));
const source = FakeEventSource.instances[0];
source.emit("status", { onboarding: onboardingStatus("kyc_approved") });
source.emit("status", { onboarding: onboardingStatus("complete") });
subscription.close();

expect(source.url).toBe(apiHref("/onboarding/status/stream"));
expect(source.init).toMatchObject({ withCredentials: true });
expect(seen).toEqual(["kyc_approved", "complete"]);
});

it("loads proof receipts through the authenticated API without mutation idempotency", async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([{ id: "prf_1", action: "payroll.policy.cap", vkId: "SPENDCAP", verified: true, createdAt: "2026-06-26T00:00:00.000Z" }]));
vi.stubGlobal("fetch", fetchMock);
Expand Down
Loading
Loading