Skip to content

Archived Brief

This brief has been completed and is retained as a build record.

BRIEF B-PORT-01

Authenticated RTO Portal Shell + Member Model

Status: Ready to build Precondition: B-CAT-02 confirmed deployed ✅ Drops after: B-CAT-02 confirmed

SURFACE: rtopacks-site (rtopacks.com.au) DO NOT TOUCH: engine-db schema for users/tenants/user_tenant_roles (R-00 tables), Auth0 config, Stripe integration, catalogue API, Price It drawer, /dashboard/pricing, ops-v2 anything CF ACCOUNT: e5a9830215a8d88961dc6c80a8c7442a


Context

B-CAT-01 and B-CAT-02 built the catalogue and the price surface. B-PORT-01 builds the portal shell that everything else lives inside — the authenticated environment an RTO lands in after claiming their account.

The claim flow (R-01) already provisions a tenant and sets a ucca_verified_session cookie via Auth0 passwordless OTP. The dashboard shell already exists at /dashboard. This brief upgrades that shell into a proper portal: role-aware nav, member management, and the soft invite mechanic for Carol.

Note for Alex: Passkey/Face ID support is planned as a future Auth0 upgrade. Build nothing that would block that — no bespoke session handling, no custom auth state that bypasses Auth0. The cookie mechanic stays as-is.


→ ALEX

Deliverable 1 — Portal Shell Upgrade

The existing /dashboard page.js is a flat page. Upgrade it to a proper portal layout with:

Layout structure:

┌─────────────────────────────────────────────────────┐
│  [UCCA wordmark — small, top left]    [Carol ▾]      │
├──────────┬──────────────────────────────────────────┤
│          │                                          │
│  NAV     │   MAIN CONTENT AREA                      │
│          │                                          │
│          │                                          │
│          │                                          │
└──────────┴──────────────────────────────────────────┘
│  Price It ▲  $0.00                                  │  ← existing drawer, unchanged
└─────────────────────────────────────────────────────┘

Nav items — role-aware (see role matrix below):

Dashboard          (all roles)
My Scope           (all roles)
─────────────────
Composer           (Admin, Content Author) — STUB
Trainer Mapper     (Admin, Trainer) — STUB
─────────────────
Pricing            (Admin only)
Members            (Admin only)
─────────────────
Settings           (Admin only) — STUB

Role matrix:

Nav Item Admin Trainer Content Author Read Only
Dashboard
My Scope
Composer
Trainer Mapper
Pricing
Members
Settings

Role is read from user_tenant_roles in engine-db via /api/dashboard (already returns tenant context). Add role to the dashboard API response. Default to read_only if no role found.

Stub pages — for Composer, Trainer Mapper, Settings: render a simple placeholder. Dark background, centred. Name of the feature, one line of what it does, status tag: "Coming soon." No fake UI, no wireframes, just honest placeholders. Consistent styling.

Carol header dropdown ([Carol ▾]): - Shows first name from session - Dropdown: My Profile (stub), Sign Out - Sign out: clears ucca_verified_session cookie, redirects to /


Deliverable 2 — Members Page (/dashboard/members)

Visible to: Admin only. Nav item shows for Admin. If a non-Admin hits the URL directly, redirect to /dashboard.

Page layout:

Top: page title "Team" (not "Members" — warmer). Subtitle: "People who can access your RTOpacks workspace."

Member table:

Name          Email                    Role            Status     Actions
Carol Smith   carol@rtopacks.com.au    Admin           Active     —
[invited]     trainer@example.com      Trainer         Invited    Resend · Revoke

Columns: Name (or "Invited" if not yet accepted), Email, Role, Status (Active / Invited / Revoked), Actions.

Actions for active members: Change Role (dropdown, inline) · Revoke Actions for pending invites: Resend · Revoke No action row for the current user (can't revoke yourself).

Schema — new table in engine-db:

CREATE TABLE IF NOT EXISTS portal_invites (
  id TEXT PRIMARY KEY,              -- uuid v4
  tenant_id TEXT NOT NULL,
  invited_by TEXT NOT NULL,         -- user_id of sender
  email TEXT NOT NULL,
  role TEXT NOT NULL DEFAULT 'read_only',  -- admin|trainer|content_author|read_only
  token TEXT NOT NULL UNIQUE,       -- secure random, used in invite link
  status TEXT NOT NULL DEFAULT 'pending',  -- pending|accepted|revoked
  personalised_message TEXT,        -- Carol's edited message
  invited_at TEXT NOT NULL DEFAULT (datetime('now')),
  accepted_at TEXT,
  revoked_at TEXT
);

CREATE INDEX IF NOT EXISTS idx_invites_tenant ON portal_invites(tenant_id);
CREATE INDEX IF NOT EXISTS idx_invites_token ON portal_invites(token);
CREATE INDEX IF NOT EXISTS idx_invites_email ON portal_invites(email, tenant_id);

API routes (rtopacks-site):

GET  /api/members          → list members + pending invites for tenant
POST /api/members/invite   → create invite, send email
POST /api/members/resend   → resend invite email
POST /api/members/revoke   → revoke member or invite
PUT  /api/members/role     → change role for active member
GET  /api/invite/:token    → validate invite token (public, for accept flow)
POST /api/invite/:token    → accept invite (sets Auth0 account, writes user_tenant_roles)

FastAPI routes (ucca-engine):

POST /v1/portal/invites          → create invite record
GET  /v1/portal/invites          → list for tenant
POST /v1/portal/invites/revoke   → revoke
POST /v1/portal/invites/accept   → accept (provisions user + role grant)


Deliverable 3 — Invite Flow

The mechanic: Admin types an email. Sees a preview of the invite email before it sends. Can edit the message. Sends.

Invite modal/panel (triggered from Members page — "+ Add person" button, subtle, bottom of member table):

┌─────────────────────────────────────────────┐
│  Invite someone to your workspace           │
│                                             │
│  Their email                                │
│  [_______________________________]          │
│                                             │
│  Their role                                 │
│  [Trainer ▾]                                │
│                                             │
│  ┌─────────────────────────────────────┐    │
│  │ Preview                             │    │
│  │                                     │    │
│  │ Hi there,                           │    │
│  │                                     │    │
│  │ Carol Smith has invited you to join │    │
│  │ [RTO Name] on RTOpacks.             │    │
│  │                                     │    │
│  │ [editable message area]             │    │
│  │                                     │    │
│  │ Click here to accept →              │    │
│  └─────────────────────────────────────┘    │
│                                             │
│  [Cancel]              [Send invite]        │
└─────────────────────────────────────────────┘

The preview is live — updates as they type the message. The editable area has a default message ("I'd love for you to join our team on RTOpacks.") that they can overwrite. Sender name pulled from session. RTO name pulled from tenant record.

Invite email (sent via existing email infrastructure — same pattern as OTP emails): - Subject: [Carol Smith] invited you to join [RTO Name] on RTOpacks - Body: personalised message + accept link: https://rtopacks.com.au/invite/[token] - Accept link valid 7 days

Accept flow (/invite/:token): - Validates token (not expired, not revoked, not already accepted) - If valid: shows "You've been invited to join [RTO Name]" — asks for their name, asks them to verify their email (Auth0 OTP, same as claim flow) - On verify: provisions user in engine-db, writes user_tenant_roles with the invited role, sets session, redirects to /dashboard - If invalid/expired: shows clear message, offers to contact the sender


Deliverable 4 — Carol's Soft Invite Nudge

Trigger: User is Admin, has been in the portal for 5 continuous minutes, has zero other members (only themselves in the tenant), has not dismissed this overlay before.

Dismissed state stored in: localStorage key ucca_invite_nudge_dismissed — set to true on dismiss. Never shown again once dismissed.

The overlay:

Not a modal. A soft panel that slides up from the bottom-right corner. Does not cover nav or main content. Max width 320px. Sits above the Price It drawer tab.

┌────────────────────────────────────┐
│                               [×]  │
│                                    │
│  Hey Carol —                       │
│                                    │
│  If you want to bring your team    │
│  in, just drop their email here    │
│  and I'll send a personalised      │
│  invite from you.                  │
│                                    │
│  [_____________________________]   │
│  their email address               │
│                                    │
│  [Show me the invite →]            │
│                                    │
└────────────────────────────────────┘

"Carol" in the greeting is the user's first name from session.

"Show me the invite →" does not send immediately. It opens the full invite panel (Deliverable 3) with the email pre-filled. They see the preview, can edit, then decide to send.

Dismiss (×) sets the localStorage flag. Overlay never appears again for this user on this device.

Styling: Consistent with portal dark theme. Subtle border. No animation theatrics — slides in gently, that's it. This is a whisper, not a shout.


Ops Stub — required at deploy

ops.ucca.online → COMMERCIAL → Portal Invites

Table: tenant_id, invited_by, email, role, status, invited_at, accepted_at Summary: "X invites sent, Y accepted, Z pending" No management controls in ops — read only. Admin manages their own team.

Nav: Commercial → Portal Invites (badge: live)


Confirm deployed with:

  1. Screenshot of portal shell with role-aware nav (logged in as Admin)
  2. Screenshot of Members page showing member table
  3. Screenshot of invite panel with live preview
  4. Screenshot of Carol nudge overlay (can be triggered manually for testing — add a ?nudge=1 query param that bypasses the 5-min timer)
  5. Screenshot of ops console Portal Invites table
  6. Screenshot of /invite/:token accept page

→ TIM

  1. ASQA domain data — B-CAT-02's onboarding entry point has a domain verification step. Confirm the ASQA domain data is present in rtopacks-db or tell Alex it's not there yet so he can stub that step.

  2. Invite email sender address — what address should invite emails come from? Suggest noreply@rtopacks.com.au or hello@rtopacks.com.au. Confirm before Alex wires the email send.

  3. ?nudge=1 test — when Alex confirms deployed, hit https://rtopacks.com.au/dashboard?nudge=1 to verify the Carol overlay renders correctly.

  4. Write B-COMP-01 — Composer canvas brief. Write it while Alex is building B-PORT-01. That's the next drop.


UCCA Inc · Brief B-PORT-01 · 18 March 2026 "The shell that everything else plugs into."