ubiquitous-invention/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-workspace-invites-and-roles.md
Randall Stillwell 778fe1d321 plans: scaffold daily-driver-finish, saas-hardening, agent-coordination
Three new plan trees that fill in the gaps surfaced during repo review.
Together they map out what remains between the current scaffold-with-stubs
state and a daily-usable, multitenant, agent-coordinated app.

* Plan-daily-driver-finish (P0): turn stubs into real data. Five tasks
  covering the lint/shared-types breakage, hardcoded dashboard mocks,
  AI-page setTimeout placeholder, post-signin landing decision, and a
  cross-browser collab smoke test against the deployed Hocuspocus
  instance.

* Plan-multitenant-saas-hardening (P1): everything multitenant needs
  beyond what Plan-multitenant-cursor-sync already covers. Invites and
  role management, soft-delete + append-only audit log, rate limits on
  the auth + mutation hot paths, and a Vitest + GitHub Actions test
  foundation so PRs can't ship red.

* Plan-agent-coordination (P2): the layer that makes a Task-*.md
  runnable, not just readable. Adds workflow_prompt with task -> epic
  -> plan inheritance, an agent_runs table for auditable sessions, and
  two new MCP tools (claim_task / complete_task) that replace the
  freeform update_object composition agents do today. Includes an
  intentionally-deferred Epic-optional-orchestrator that captures the
  Symphony-shaped runner as a decision point rather than an immediate
  build.

Each task is bead-scale (one focused Cursor session) with explicit
in-scope, out-of-scope, and anti-goal sections so a future agent can
pick up a single Task-*.md and start without scrollback context.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 23:52:22 -05:00

4.1 KiB

kind slug title plan_slug epic_slug status priority tenant_id owner cursor_todo_id updated_at
task workspace-invites-and-roles Workspace invites, accept flow, and role management multitenant-saas-hardening tenant-lifecycle ready P1 global unassigned null 2026-06-01

Task summary

Owners can invite an email to a workspace, the recipient accepts via a link (or via a "pending invites" UI on first sign-in), and lands in the workspace as a member. Owners and admins can change roles and remove members.

Description

workspace_members already exists. This task adds the invite layer on top.

Schema additions

New table workspace_invites:

  • id uuid pk
  • workspace_id uuid not null, references workspaces.id on delete cascade, indexed
  • email varchar not null (store lowercase — match the case-insensitive convention in migration 0004)
  • role varchar not null (owner | admin | member)
  • invited_by_user_id uuid not null references users.id
  • token varchar not null unique (random 32+ bytes, base64url)
  • expires_at timestamptz not null (default now() + interval '14 days')
  • accepted_at timestamptz null
  • revoked_at timestamptz null
  • created_at timestamptz default now
  • Unique partial index on (workspace_id, lower(email)) where accepted_at is null and revoked_at is null — prevents two open invites for the same email.

tRPC procedures

In a new router apps/web/server/routers/invites.ts:

  • invites.create({ workspaceSlug, email, role }) — admin/owner only. Generates token, sends an invite email (later — for now just return the accept URL so an operator can paste it). Idempotent: if there's an open invite for that email/workspace, return it.
  • invites.list({ workspaceSlug }) — admin/owner only. Lists pending invites.
  • invites.revoke({ inviteId }) — admin/owner only. Sets revoked_at.
  • invites.accept({ token })public procedure (no workspace scope). Validates token, requires authenticated session, inserts workspace_members row, sets accepted_at.

Membership procedures

Extend the existing workspaces router (apps/web/server/routers/workspaces.ts):

  • workspaces.listMembers({ workspaceSlug }) — already exists per the teams page; verify.
  • workspaces.updateMemberRole({ workspaceSlug, userId, role }) — admin/owner only.
  • workspaces.removeMember({ workspaceSlug, userId }) — admin/owner only. Can't remove the last owner; raise BAD_REQUEST if attempted.

UI

Extend apps/web/app/(app)/[workspaceSlug]/teams/page.tsx:

  • Add "Invite teammate" button → dialog with email + role select.
  • Show pending invites in a separate section with "Copy invite link" and "Revoke".
  • Per-member kebab menu: change role, remove. Hide for the current user; hide remove for the last owner.

Add a new route apps/web/app/invite/[token]/page.tsx:

  • If not signed in, send to /sign-in?callbackUrl=/invite/<token>.
  • If signed in, call invites.accept and redirect to the workspace.

Email (optional first pass)

Don't block on actual email sending. Return the accept URL from invites.create and let the operator paste it. Add a follow-up task ("send invite emails via Resend/Postmark") once a provider is chosen.

Subtasks

  • Add workspace_invites schema in packages/database/src/schema/workspaces.ts (or a new file).
  • Generate and commit the migration via pnpm db:generate.
  • Add apps/web/server/routers/invites.ts and wire into root.ts.
  • Add updateMemberRole and removeMember procedures.
  • Add invite dialog and pending-invites section to teams page.
  • Add /invite/[token] accept route.
  • Verify end-to-end: owner A invites email B, B signs up with that email, lands in the workspace as member.

Owner or assignee

Unassigned

Status

ready

Estimation

L

Acceptance criteria

  • Invite flow works end-to-end without email (copy-paste URL).
  • Cannot remove the last owner.
  • Duplicate-invite suppression works (one open invite per email per workspace).
  • Accept route 404s for revoked / expired tokens.
  • Epic: ./Epic-tenant-lifecycle.md
  • Plan: ../Plan-multitenant-saas-hardening.md