Sales Portal

Sales Portal #

Classification: Restricted (describes source code, infrastructure, and access control).

The Sales Portal is an internal-only admin app for the Sales and Customer Success teams. It proxies Clerk so staff can manage customer organizations without direct Clerk-dashboard access, and it records an audit trail of every action to Datadog.

  • Code: apps/sales-portal/
  • Stack: TanStack Start (React 19) + Vite + Nitro, shared @bonsai/ui.
  • Hosts: dev dev-sales-portal.internal.gotofu.com, prod sales-portal.internal.gotofu.com (internal ALB, VPN/office restricted).
  • Doppler: shares the bonsai project (like webapp/tofu-mcp); synced to the bonsai-secret k8s secret at runtime.

What it does #

Area Capability
Organizations List/search (by name, slug, or Clerk org id), create (auto-slug + invite owners inline), rename; whole row is clickable
Org deletion Needs org:organization:delete — “Danger zone” on the org detail → General tab, behind a type-the-org-name confirmation. Deletes the Clerk org; bonsapi’s organization_deleted webhook cleans up the DB row, billing cache, and Intercom
Members Two tabs — Members and Invited (invitee, status, date); invites are Owner-only
Users Needs org:member:manage — flat, searchable Clerk user directory (/users); each row opens a detail page (/users/:userId) with read-only Profile and Organizations tabs, plus a “Danger zone” delete behind a type-the-email confirmation. Internal gotofu staff are hidden from the list and cannot be viewed or deleted from here
Feature flags Toggle any flag, including canary (replaces hand-editing in the Clerk dashboard)
Trials Constrained: extend expiry by 1 week, max entities fixed at 2, max extractions/month +10 (10s cooldown, cap 100)
Impersonation Eye icon in the member list (needs sales_portal:impersonate): mints a Clerk actor token and opens a recorded webapp session
Recordings Needs sales_portal:watch_recording — replay impersonation sessions (contain customer PII)
Billing plans Needs sales_portal:plans — org detail → Billing Plans tab. Hide a publicly listed Stripe plan from one organization’s billing page. Only products marked public in Stripe are listed, so a sales-only plan cannot be granted from here
Audit trail Everyone with access — global (/audit) and per-org (org detail → Audit tab), read back from Datadog

Feature flags and trials are stored in Clerk organization metadata (publicMetadata.features, publicMetadata.trial_*, privateMetadata.data_retention_period_in_days). bonsapi’s organization.updated webhook mirrors these into the database, so writes made here stay in sync.

Plan visibility lives alongside them in publicMetadata.billing_plans, keyed by Stripe product id (not plan name, so a rename in Stripe cannot orphan a toggle). It is a sparse map of overrides, not an allowlist: a product absent from it falls back to its own Stripe visibility metadata, which is what keeps the first toggle from implicitly hiding every other plan. It deliberately does not go through the config/features.yaml codegen — it is per-org sales data keyed on Stripe ids, not a product feature flag.

Access control (Clerk RBAC) #

“Feature or permission?” — both. A Clerk custom permission key is composed of a Feature namespace plus an action: org:<feature>:<permission>. The portal uses:

  • Feature (namespace): sales_portal
  • Permissions (the source of truth for gating): org:sales_portal:access (see the portal at all), org:sales_portal:impersonate (the member-list eye icon), org:sales_portal:watch_recording (the Recordings screen), org:sales_portal:plans (the Billing Plans tab), org:organization:delete (the General tab’s “Danger zone”), org:member:manage (the Users page’s delete action).
  • Attach these permissions to whatever roles you like in the Clerk dashboard (e.g. a sales role with just access, a csm role with access + impersonate, an owner role with all three). The portal checks the permissions on the caller’s internal-org membership — not role names. The audit trail is visible to anyone with access. Users without access get a “No access” screen with a sign-out button.

⚠️ org:organization:delete and org:member:manage do not sit in the sales_portal namespace. org:organization:delete needs its own organization Feature in the Clerk dashboard; org:member:manage needs its own member Feature (matching bonsapi’s own permission convention in libs/rust/bonsai-model/src/org_permission.rs, so the portal and bonsapi agree on what the permission means). Until a permission’s Feature exists and is attached to a role, the corresponding capability (canDeleteOrg / canManageUsers) is always false and its UI simply never renders. Create both before expecting org deletion or user deletion to work in a given Clerk instance.

Access is decided server-side by looking up the caller’s membership in the internal gotofu Clerk org (SALES_PORTAL_INTERNAL_CLERK_ORG_ID) and reading that membership’s permission list — not the JWT has() helper, because a staff user usually has a customer org active and custom permissions only appear in the token for the active org. See apps/sales-portal/src/shared/server/auth.ts.

Do not confuse the three “feature” concepts in this repo. BonsAI feature flags are config/features.yaml → org publicMetadata.features (what the Feature Flags tab toggles). Clerk RBAC Features are permission namespaces like sales_portal. Clerk Billing features are unused (Tofu bills via Stripe) — if Clerk Billing is ever enabled, permission checks only pass when the permission’s Feature is in the org’s active plan.

Granting a staff member access #

  1. In the Clerk dashboard, open the internal gotofu organization.
  2. Add the user as a member with role org:sales or org:csm.
  3. No code change or redeploy is needed — the portal reads the role live (60s cache).

Audit trail #

Every mutation writes a Datadog log tagged is_audit_log: true (matching the backend audit_info! convention in libs/rust/bonsai-utils). Find them with:

service:sales-portal is_audit_log:true

Each event carries audit_type (OrgCreate, OrgNameUpdate, OrgDelete, OrgInvitationCreate, FeatureFlagUpdate, TrialUpdate, ImpersonationStart, UserDelete), the actor’s user id + email, the target org/user, and before/after snapshots for flag/trial changes. Source: apps/sales-portal/src/shared/server/audit.ts.

OrgDelete carries a full before snapshot (name, slug, member count, features, trial) — the Clerk org is gone by the time the event is read, so this log is the only remaining record of what was deleted. UserDelete carries the same idea for a user (email, name) — the Clerk user is gone by the time the event is read.

Impersonation & recording #

CSM users can impersonate a customer user from an org’s Impersonate tab. The portal mints a short-lived Clerk actor token and opens https://<WEBAPP_HOST>/sign-in?__clerk_ticket=<token> in a new tab; Clerk’s <SignIn> redeems the ticket.

The impersonated webapp session is screen-recorded with rrweb: the ImpersonationRecorder (apps/webapp/src/shared/components/impersonation-recorder.tsx) dynamically loads rrweb only for impersonated sessions and streams event chunks to POST /api/impersonation-recording, which uploads them to a Google Drive shared drive. CSMs replay sessions in the portal under Recordings.

Runbooks #

Doppler keys (bonsai project) #

The portal shares the bonsai Doppler project. CLERK_SECRET_KEY, DATADOG_API_KEY and WEBAPP_HOST already exist there; add the rest:

Key Status Notes
VITE_CLERK_PUBLISHABLE_KEY add Clerk publishable key (client). Inlined at build. Same value as NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY.
CLERK_SECRET_KEY exists
SALES_PORTAL_INTERNAL_CLERK_ORG_ID add Clerk org id (org_…) of the internal gotofu org.
WEBAPP_HOST exists Bare webapp host, e.g. app.gotofu.com; the portal prepends the scheme.
DATADOG_API_KEY exists
GOOGLE_SALES_PORTAL_SERVICE_ACCOUNT_JSON_B64 add Base64 of the Drive service-account JSON (also used by the webapp recorder).
GDRIVE_SALES_PORTAL_RECORDINGS_FOLDER_ID add Root folder id in the recordings shared drive (also used by the webapp recorder).

The existing bonsai-doppler-external-secret already syncs every bonsai key into bonsai-secret (dataFrom regexp .*), so no new ExternalSecret is needed. CI build uses the shared DOPPLER_TOKEN / DOPPLER_CONFIG secrets (same as webapp).

Clerk dashboard setup (dev AND prod instances) #

  1. Roles & Permissions → Custom permissions → create Feature sales_portal, then permissions sales_portal:access and sales_portal:impersonate.
  2. Create roles org:sales (access only) and org:csm (access + impersonate).
  3. Assign staff in the internal gotofu org; note that org’s id → SALES_PORTAL_INTERNAL_CLERK_ORG_ID.
  4. Confirm the Enhanced B2B Authentication add-on covers the extra custom roles, and that Clerk Billing is not enabled.

Google Drive service account (one-time) #

  1. In Google Cloud Console, create/reuse a project (e.g. tofu-sales-portal).
  2. Enable the Google Drive API.
  3. Create a service account sales-portal-recorder; add a JSON key; base64-encode it into GOOGLE_SALES_PORTAL_SERVICE_ACCOUNT_JSON_B64.
  4. Create a Shared Drive “Impersonation Recordings”. It must be a Shared Drive — service accounts have no personal Drive quota, so uploads to a normal folder fail with storageQuotaExceeded.
  5. Add the service-account email as Content manager on the Shared Drive; keep human membership restricted to the sales/CSM group (recordings contain customer data).
  6. Put the target folder id in GDRIVE_SALES_PORTAL_RECORDINGS_FOLDER_ID.
  7. All Drive API calls pass supportsAllDrives: true (+ includeItemsFromAllDrives: true on lists), or Shared Drive items are invisible.

Retention: recordings are not auto-deleted yet. Decide and implement a retention policy (manual cleanup or a scheduled job) before this sees heavy use.

Local development #

mise run sales-portal-dev   # Doppler sales-portal/dev + vite dev on :3040