Team Console apps/team

Org / seat management, invites, domain verification, cohort licensing, exam proctoring, and zero-knowledge org encryption keys — for ExcelRewind managers and personal-account holders.
Contents 1. Overview 2. Architecture 3. Data flow 4. Sequences 5. Interconnections 6. Key files & functions 7. Invariants & gotchas

1. Overview

apps/team (package @excelrewind/team) is a standalone React + TypeScript single-page app, built with Vite, that serves two audiences under one shell: every signed-in user's personal Account screen (plan, devices, referrals, coupon redemption), and — for users who belong to an organization — the Team console (dashboard, members, invites, domain verification, cohort bulk-licensing, exam/kiosk proctoring, and org-level encryption-key management). It has no backend of its own: every read and write goes straight to Supabase (Postgres RPCs, RLS-gated direct table reads, and two purpose-built Edge Functions) from the browser.

The app is deployed as a static bundle to Cloudflare Pages, split across two custom domains that route to the same build: team.excelrewind.com (org managers) and account.excelrewind.com (personal accounts) — routing between the two "modes" is entirely client-side (the /enterprise path switches the sign-in screen into company-email-only mode and skips straight to the Team flow). Build/deploy: npm run build -w @excelrewind/team then wrangler pages deploy apps/team/dist --project-name excelrewind-team (see root package.jsondeploy:team is not part of the composite npm run deploy that ships addin/viewer/website, so it is deployed independently). apps/team/public/_headers sets a strict per-origin CSP (connect-src limited to the project's own Supabase origin), X-Robots-Tag: noindex, and Cache-Control: no-store on index.html so a redeploy is picked up immediately.

Local dev runs at :5174 (vite.config.ts, envDir: '../..' — it shares the monorepo's root .env with the viewer/add-in). Appending ?demo=1 to any URL short-circuits every Supabase call to fixture data from src/demo.ts so the whole console can be explored/screenshotted without a backend; the same fallback also fires automatically, screen-by-screen, whenever an RPC 404s (not-yet-deployed function), so the console degrades to "honest demo" rather than a blank error page.

2. Architecture diagram

flowchart TB
  subgraph entry["Entry / bootstrap"]
    main["main.tsx"] --> App["App.tsx (top-level orchestration)"]
  end

  subgraph state["Auth + data hooks"]
    useAuth["useAuth.ts (session state, OTP send/verify, signOut)"]
    supabaseClient["supabase.ts (supabase-js client + api.* RPC wrappers + types)"]
  end

  subgraph routing["Routing (no router dep)"]
    App -- "path === '/accept'" --> Accept["screens/Accept.tsx"]
    App -- "path === '/enterprise'" --> SignInEnt["screens/SignIn.tsx (enterprise=true)"]
    App -- "signed out" --> SignIn["screens/SignIn.tsx"]
    App -- "no org, wants team" --> CreateOrg["screens/CreateOrg.tsx"]
  end

  subgraph shell["Shell + nav"]
    Shell["Shell.tsx (sidebar nav + topbar)"]
    Modal["Modal.tsx (shared overlay)"]
    ui["ui.tsx (Icons, Avatar, Toast, badges)"]
  end

  subgraph screens["Team / Account screens"]
    Account["screens/Account.tsx"]
    Dashboard["screens/Dashboard.tsx"]
    Members["screens/Members.tsx"]
    Invites["screens/Invites.tsx"]
    InviteBanner["screens/InviteBanner.tsx"]
    Domain["screens/Domain.tsx"]
    Cohort["screens/Cohort.tsx"]
    Exams["screens/Exams.tsx"]
    Encryption["screens/Encryption.tsx"]
    Skeleton["screens/Skeleton.tsx"]
  end

  subgraph shared["Shared logic"]
    util["util.ts (fmtDate, titleCase, hueFromString, initials)"]
    freeEmail["freeEmail.ts (isFreeEmail — client-side mirror of SQL guard)"]
    demo["demo.ts (fixture data, ?demo=1 + RPC-404 fallback)"]
  end

  subgraph fmt["@excelrewind/format (shared package)"]
    keyring["keyring.ts (createKeyRow, unlockKeyRow, rewrapPass, newRecovery,\nwrapUnderMaster, unwrapUnderMaster, checkPassphrase)"]
  end

  App --> useAuth
  App --> supabaseClient
  App --> Shell
  App --> InviteBanner
  Shell --> screens
  Account --> freeEmail
  SignIn --> freeEmail
  screens --> supabaseClient
  screens --> Modal
  screens --> ui
  screens --> util
  screens --> demo
  Encryption --> keyring
  supabaseClient -.->|"CONFIG_OK / isDemo flags"| demo

3. Data-flow diagram(s)

Two distinct data paths converge on the same UI shell: read/write via Postgres RPC (the majority of actions) and direct RLS-gated table access (personal profile, devices, and the org encryption-key rows — chosen because these are simple owner-scoped reads/writes that don't need server-side business logic).

flowchart LR
  U["Org manager / member\n(browser)"]

  U -->|"email OTP: supabase.auth.signInWithOtp / verifyOtp"| SBAuth["Supabase Auth\n(GoTrue)"]
  SBAuth -->|"JWT session, persisted to localStorage"| U

  U -->|"supabase.rpc('org_dashboard' / 'org_invite' / 'org_accept_invite' / ...)"| RPC["Postgres RPCs\n(SECURITY DEFINER functions)"]
  RPC -->|"JSON: Dashboard{org, seats_used, members[], invites[], my_role}"| U

  U -->|"select/insert/update org_keys, org_keys_retired\n(ciphertext + salts + pubkeys only)"| PG["Postgres tables\n(RLS: owner/admin write, member read)"]
  U -->|"select profiles/devices (RLS: read-own)"| PG
  PG -->|"rows"| U

  U -->|"POST /functions/v1/send-invite\n{email, org_name, role, accept_url, inviter}"| SendInvite["Edge Fn: send-invite"]
  SendInvite -->|"transactional email"| Invitee["Invitee's inbox"]

  U -->|"POST /functions/v1/verify-domain\n(Bearer JWT)"| VerifyDomain["Edge Fn: verify-domain"]
  VerifyDomain -->|"DNS TXT lookup"| DNS["Public DNS"]
  VerifyDomain -->|"{verified, expected, found, hint}"| U

  U -->|"POST /functions/v1/exam-storage\n{action:'review_url', submission_id}"| ExamStorage["Edge Fn: exam-storage"]
  ExamStorage -->|"short-lived signed GET URL"| U
  U -->|"GET signed URL"| Storage["Supabase Storage\n(.rewind object)"]
  Storage -->|".rewind file (download)"| U
  U -->|"window.open"| Viewer["viewer.excelrewind.com\n(replay, out of scope)"]

  subgraph crypto["Client-side only — never sent to server"]
    Secret["org passphrase / recovery code"]
    Master["raw org master key (OMK)"]
  end
  U -.->|"PBKDF2 unwrap (in-memory only)"| crypto
  crypto -.->|"AES-wrapped ciphertext + salts + RSA pubkey"| PG

4. Process / sequence diagrams

4.1 Sign-in (email OTP) → dashboard load

sequenceDiagram
  participant User
  participant SignIn as "SignIn.tsx"
  participant useAuth as "useAuth.ts"
  participant Supa as "Supabase Auth"
  participant App as "App.tsx"
  participant API as "supabase.ts (api.*)"

  User->>SignIn: enter email, submit
  SignIn->>SignIn: isFreeEmail(email)? (enterprise mode only)
  alt enterprise + free-provider email
    SignIn-->>User: inline error, block send
  else ok
    SignIn->>Supa: auth.signInWithOtp({email, shouldCreateUser:true})
    Supa-->>SignIn: code emailed
    User->>SignIn: enter 6-digit code
    SignIn->>Supa: auth.verifyOtp({email, token, type:'email'})
    Supa-->>useAuth: onAuthStateChange(session)
    useAuth-->>App: {loading:false, email, userId}
    App->>API: api.dashboard()  [rpc org_dashboard]
    alt has org
      API-->>App: Dashboard{org, members, invites, my_role}
      App->>App: setLoad({s:'ready', dash})
    else no_org error
      API-->>App: error "no_org"
      App->>App: setLoad({s:'no-org'})
    else RPC missing (404 / PGRST202)
      API-->>App: isMissingRpc(e) = true
      App->>App: setLoad({s:'ready', dash: demoDashboard()})
    end
    App->>API: api.me(userId) + api.devices()
    API-->>App: Profile, Device[]
    App-->>User: render Shell (Account tab, + Team tabs if hasOrg)
  end

4.2 Invite a teammate → accept invite

sequenceDiagram
  participant Manager
  participant Invites as "Invites.tsx"
  participant API as "supabase.ts (api.*)"
  participant PG as "Postgres (org_invite RPC)"
  participant EdgeFn as "Edge Fn: send-invite"
  participant Invitee
  participant Accept as "Accept.tsx"

  Manager->>Invites: enter email + role, submit
  Invites->>API: api.invite(email, role)  [rpc org_invite]
  API->>PG: org_invite(p_email, p_role)
  PG-->>API: invite token
  API-->>Invites: token
  Invites->>Invites: build acceptLink(token) = origin/accept?token=...
  Invites->>API: api.sendInviteEmail({email, org_name, role, accept_url, inviter})
  API->>EdgeFn: POST /functions/v1/send-invite (Bearer JWT)
  Note over API,EdgeFn: fail-soft — never throws,\ncopy-link UI is the fallback
  EdgeFn-->>Invitee: email with accept link
  Invites-->>Manager: show copyable accept link + toast "Invite sent"

  Invitee->>Accept: open /accept?token=...
  alt not signed in
    Accept->>Accept: render SignIn (intro: "Sign in to accept your team invite")
    Note over Accept: OTP flow (see 4.1), re-renders with signedIn=true
  end
  Accept->>API: api.acceptInvite(token)  [rpc org_accept_invite]
  API->>PG: org_accept_invite(p_token)
  alt token valid
    PG-->>API: ok
    Accept-->>Invitee: success animation, then onDone()
    Accept->>Accept: history.replaceState('/'), fetchDash()
  else expired/invalid
    PG-->>API: error
    API-->>Accept: toFriendly(e) → "invite link is invalid or has expired"
    Accept-->>Invitee: error state + "Continue to console"
  end

4.3 Domain verification (DNS TXT) and auto-join gate

sequenceDiagram
  participant Owner
  participant Domain as "Domain.tsx"
  participant API as "supabase.ts (api.*)"
  participant PG as "Postgres (org_request_domain_verification, org_set_auto_join)"
  participant EdgeFn as "Edge Fn: verify-domain"
  participant DNS as "Public DNS"

  Owner->>Domain: click "Show DNS record"
  Domain->>API: api.requestDomainVerification()  [rpc]
  API->>PG: org_request_domain_verification()
  PG-->>Domain: {domain, record_name: _excelrewind.[domain], record_type:TXT, record_value:excelrewind-verify=[token]}
  Domain-->>Owner: render TXT record to publish

  Owner->>Domain: click "Verify via DNS" (after publishing the record)
  Domain->>API: api.verifyDomainDns()
  API->>EdgeFn: POST /functions/v1/verify-domain (Bearer JWT, apikey)
  EdgeFn->>DNS: lookup _excelrewind.[domain] TXT record
  alt record found + matches
    EdgeFn-->>Domain: {verified:true}
    Domain->>Domain: reload() → dashboard refetch, verified badge flips
  else not found / mismatch
    EdgeFn-->>Domain: {verified:false, expected, found[], hint}
    Domain-->>Owner: inline "Not verified yet" + found records
  end

  opt Owner enables auto-join
    Owner->>Domain: toggle Automatic join
    Domain->>API: api.setAutoJoin(true)  [rpc org_set_auto_join]
    API->>PG: org_set_auto_join(p_enable)
    alt domain not verified
      PG-->>API: error domain_not_verified
      Domain-->>Owner: revert toggle, toast error
    else ok
      PG-->>API: ok
      Domain-->>Owner: toggle stays on, reload()
    end
  end

4.4 Org encryption — setup, then rotate (zero-knowledge)

sequenceDiagram
  participant Admin
  participant Enc as "Encryption.tsx"
  participant KR as "@excelrewind/format keyring.ts"
  participant API as "supabase.ts (api.orgKeys*)"
  participant PG as "Postgres org_keys / org_keys_retired\n(RLS: owner/admin write)"

  Note over Admin,Enc: SETUP (no row exists yet)
  Admin->>Enc: enter passphrase x2, submit
  Enc->>KR: checkPassphrase(p1) — min 12 chars, mixed
  Enc->>KR: createKeyRow(p1)
  KR->>KR: masterRaw = random 256-bit key, RSA-3072 keypair,\nwrap masterRaw under PBKDF2(passphrase) and under a fresh recovery code
  KR-->>Enc: {fields (ciphertext+salts+pub), recoveryCode, masterRaw}
  Enc->>API: api.orgKeysInsert({org_id, ...toCols(fields), enabled:true, seal_member_files:false})
  API->>PG: INSERT org_keys (ciphertext + salts + pubkey only — never the passphrase or masterRaw)
  Enc-->>Admin: RevealModal — recovery code shown ONCE, must ack "stored safely"

  Note over Admin,Enc: ROTATE (heavy op, owner/admin)
  Admin->>Enc: click "Rotate org key", enter current secret + new passphrase
  Enc->>KR: unlockKeyRow(oldFields, secret, kind) — PBKDF2 unwrap, throws on wrong secret
  KR-->>Enc: oldMaster (raw, in-memory only)
  Enc->>KR: createKeyRow(newPassphrase) → newFields, newMaster, newRecoveryCode
  Enc->>API: api.orgKeysRetiredInsert({org_id, fingerprint:old, wrapped_omk: wrapUnderMaster(oldMaster,newMaster), org_pub:old, wrapped_org_priv:old})
  API->>PG: INSERT org_keys_retired (a) — old generation now sealed under the new master
  Enc->>API: api.orgKeysRetiredList()
  API->>PG: SELECT org_keys_retired
  loop each previously-retired generation
    Enc->>KR: unwrapUnderMaster(r.wrapped_omk, oldMaster)
    Enc->>KR: wrapUnderMaster(raw, newMaster)
    Enc->>API: api.orgKeysRetiredUpdate(orgId, fingerprint, {wrapped_omk: re-wrapped})
    API->>PG: UPDATE org_keys_retired (b) — re-chain under new master
  end
  Enc->>API: api.orgKeysUpdate({org_id, ...toCols(newFields)})
  API->>PG: UPDATE org_keys (c) — swap in new generation, keep enabled/seal toggles
  Enc-->>Admin: RevealModal — new recovery code shown ONCE
  Note over Enc,PG: Order is (a)+(b) THEN (c) so a mid-flight failure\nnever strands an old generation unreachable.\nWrong secret at unlockKeyRow aborts before any writes.

4.5 Cohort bulk-grant (group-licence sponsor)

sequenceDiagram
  participant Sponsor
  participant Cohort as "Cohort.tsx"
  participant API as "supabase.ts (api.*)"
  participant PG as "Postgres cohort_invite_bulk RPC"

  Sponsor->>Cohort: paste emails OR upload students.csv
  Cohort->>Cohort: parseEmails(text) — split on comma/whitespace/newline,\nfirst @-token per line, de-dupe client-side
  Sponsor->>Cohort: click "Invite N"
  Cohort->>API: api.cohortInviteBulk(emails[])
  API->>PG: cohort_invite_bulk(p_emails)
  PG-->>API: {results:[{email,status:'ok'|'skip',reason?,token?}], seats, seats_used}
  API-->>Cohort: BulkInviteResponse
  Cohort-->>Sponsor: per-row table (Invited / No seat / Already in / Duplicate / Invalid)
  Cohort->>Cohort: reload() → dashboard refetch (seat meter updates)

  opt seats run out
    Sponsor->>Cohort: "Request more seats" (enter total)
    Cohort->>API: api.cohortRequestSeats(total)  [rpc]
    API->>PG: cohort_request_seats(p_n)
    PG-->>Cohort: pending_seat_request set on org row (superadmin approves out-of-band, apps/admin)
  end

5. Interconnections

flowchart LR
  team["apps/team"]
  team -->|"supabase-js: auth (OTP), rpc(), from().select/insert/update"| Supabase["Supabase project\n(Postgres + Auth + Storage)"]
  team -->|"HTTPS POST, Bearer JWT + apikey"| SendInvite["Edge Fn: send-invite"]
  team -->|"HTTPS POST, Bearer JWT + apikey"| VerifyDomain["Edge Fn: verify-domain"]
  team -->|"HTTPS POST, Bearer JWT + apikey"| ExamStorage["Edge Fn: exam-storage"]
  team -->|"window.open (manual handoff, no API call)"| Viewer["viewer.excelrewind.com"]
  team -->|"link only: /?ref=code"| Website["excelrewind website (WEBSITE const)"]
  team -->|"@excelrewind/format import (client-side crypto lib)"| Format["packages/format keyring.ts"]
  team -->|"@excelrewind/branding import (PRODUCT_NAME, WEBSITE)"| Branding["packages/branding"]
  team -.->|"Payment Links (env-gated anchor href, no API call)"| Stripe["Stripe (Payment Links + billing portal)"]
  team -->|"static hosting"| CFPages["Cloudflare Pages\n(team.excelrewind.com, account.excelrewind.com)"]
  Admin["apps/admin (superadmin)"] -.->|"approves cohort_request_seats,\nsets feature entitlements consumed via effective_features()"| Supabase
External surfaceProtocolPurposeWhere in code
Supabase Auth (GoTrue)supabase-js, email OTPSign-in/sign-up (both personal and enterprise entrance); session persisted to localStorage, auto-refresheduseAuth.ts, supabase.ts
Postgres RPCs (SECURITY DEFINER functions)supabase-js .rpc() over PostgRESTorg_dashboard, org_create, org_invite, org_accept_invite, org_decline_invite, org_revoke_invite, org_remove_member, org_set_role, org_request_domain_verification, org_verify_domain_manual, org_set_auto_join, cohort_invite_bulk, cohort_request_seats, org_features, org_feature_set, effective_features, exam_list/create/set/delete/submissions_list, my_pending_invites, my_referral(s), public_promo_config, public_tiers, redeem_couponsupabase.ts — the api object; rpc() helper
Postgres tables (direct, RLS-gated)supabase-js .from().select/insert/update()profiles, devices (read-own); org_keys, org_keys_retired (owner/admin write, member read) — ciphertext/salts/pubkeys only, never secretssupabase.tsapi.me, api.devices, api.orgKeys*
Edge Fn send-inviteHTTPS POST /functions/v1/send-invite, Bearer JWT + apikey, JSON bodyEmails the invite accept-link; fail-soft (never throws — copy-link stays the fallback UX)supabase.tsapi.sendInviteEmail; called from Invites.tsx
Edge Fn verify-domainHTTPS POST /functions/v1/verify-domain, Bearer JWT + apikeyReal DNS TXT lookup to confirm domain ownership; returns {verified, expected, found, hint}, never throws on "not yet verified"supabase.tsapi.verifyDomainDns; called from Domain.tsx
Edge Fn exam-storageHTTPS POST /functions/v1/exam-storage, Bearer JWT + apikey, {action:'review_url', submission_id}Mints a short-lived signed GET URL for a submitted .rewind so an admin can download and review itsupabase.tsapi.reviewUrl; called from Exams.tsx (AuditView.review)
viewer.excelrewind.comBrowser navigation (window.open), no API callWhere a reviewer drops the downloaded exam .rewind to replay it — the team console never embeds a viewer or exposes raw file content itselfExams.tsxVIEWER const
Stripe (Payment Links + billing portal)Plain <a href> to an env-configured URL, no SDK/API call from this appUpgrade CTA (Account tier cards) and cohort seat purchase; buttons render disabled ("soon") when the env var is unsetAccount.tsx (STRIPE_LINK, BILLING_PORTAL), Cohort.tsx (SEAT_PURCHASE_LINK)
@excelrewind/formatDirect ES import (bundled, not network)All org-key cryptography (PBKDF2 wrap/unwrap, AES, RSA keypair, passphrase strength check) — the only crypto used anywhere in this moduleEncryption.tsx imports from @excelrewind/format
@excelrewind/brandingDirect ES importProduct name / website URL strings — never hard-codedSignIn.tsx, Account.tsx
Cloudflare PagesStatic hosting, wrangler deployServes the built SPA on two domains; _headers sets CSP/robots/cache policyapps/team/public/_headers, root package.json deploy:team

6. Key functions & files

FileFunction / exportWhat it doesWhy it matters
src/App.tsxApp()Top-level orchestration: auth gate → /accept route → unified Account+Team shell; derives my_role client-side if the RPC omits it; resolves the encryption feature gate fail-openThe one place that decides which tabs a user can even reach (tabAvailable) — misreading my_role or the feature gate here silently over/under-exposes destructive controls
src/App.tsxtabAvailable(t)Gates each nav tab by org kind (cohort vs enterprise) and role; never strands a signed-in user on a tab their org doesn't haveClient-side only gate — the real authorization is RLS/RPC-side; this just prevents a confusing UI state, not a security boundary
src/useAuth.tssendCode, verifyCode, signOut, useAuth()Email OTP via supabase-js (signInWithOtp/verifyOtp); session state hook backed by onAuthStateChangeThe entire auth surface for this app — no password path, no OAuth
src/supabase.tsrpc<T>(fn, args)Thin wrapper around supabase.rpc() that throws the raw Postgres errorSingle choke point — every RPC call in the app goes through this
src/supabase.tsisMissingRpc(err)Distinguishes "function not deployed" (PGRST202 / 404 / "does not exist") from a real failurePowers the graceful demo-data fallback used across Dashboard/Exams/App — without it, an undeployed RPC would white-screen instead of demoing
src/supabase.tstoFriendly(err)Maps raw Postgres/Supabase errors to a RpcError with a stable code (seat_limit, duplicate, forbidden, bad_token, free_email_not_allowed)UI branches on .code, not the message string — keep this in sync with the SQL error text it pattern-matches
src/supabase.tsapi.orgKeysGet/Insert/Update, api.orgKeysRetiredList/Insert/UpdateDirect org_keys / org_keys_retired table access — no RPC layer; RLS alone enforces owner/admin writeOnly ciphertext/salts/pubkeys ever cross this boundary — confirms the zero-knowledge claim at the network layer
src/supabase.tsapi.reviewUrl, api.verifyDomainDns, api.sendInviteEmailThe three calls that go to Edge Functions instead of RPCs (need server-side secrets/DNS/storage-signing that can't live in Postgres)All three manually attach the session's access_token as Bearer + the anon key as apikey — study these if adding a new Edge Fn call
src/freeEmail.tsisFreeEmail(email), FREE_EMAIL_DOMAINSClient-side best-effort blocklist of free/public email providers, gating the enterprise sign-in and "Create a team" CTAExplicitly documented as not authoritative — the real gate is SQL is_free_email_domain() in the org_create migration; must be kept in sync by hand
src/screens/Encryption.tsxtoFields / toColsBidirectional mapping between the org_keys DB row shape and the format package's neutral KeyRowFields shapeAny schema rename in either place breaks encryption silently unless both are updated together
src/screens/Encryption.tsxRotateModal.submitFull org-key rotation: unlock old master → generate new master+keypair → retire old generation sealed under new master → re-wrap the entire retired chain → swap the live rowThe highest-stakes write path in the module; ordered so a mid-flight network failure can never strand a retired generation unreadable (re-wrap step is naturally idempotent on retry)
src/screens/Domain.tsxmanualOverrideOwner-only "Testing" toggle that flips domain_verified without a real DNS checkExplicitly labelled test-only in the UI; an auditor should confirm this RPC (org_verify_domain_manual) is gated/removed before GA — code comment flags it ("Gate before GA")
src/screens/Cohort.tsxparseEmails(text)Parses pasted text or an uploaded CSV into a de-duplicated candidate email list (grabs the first @-containing token per line, so a 3-column students.csv still works)Purely client-side convenience filter — the RPC (cohort_invite_bulk) re-validates/de-dupes server-side and is the actual source of truth for skip reasons
src/screens/Exams.tsxAuditView.reviewDownloads a signed .rewind via exam-storage and opens the viewer in a new tab so an instructor can replay itThe console never embeds or streams file content itself — download + external viewer is a deliberate "lightest honest path" per the code comment
src/screens/Account.tsxTierCard, STRIPE_LINKRenders live tiers from public_tiers() with an env-gated Payment Link per paid tierThe client never writes a user's tier directly — upgrade is entirely a redirect to Stripe; tier changes land via webhook elsewhere in the system
src/Shell.tsxShell(), TEAM_NAV / COHORT_NAVSidebar navigation; filters the Encryption tab by role AND the superadmin feature gate in one .filter()Note the code comment: the old self-serve Features screen was removed in v3.18.0 — feature exceptions are now superadmin-owned in apps/admin, not here

7. Invariants & gotchas