Closes Task-workspace-invites-and-roles end-to-end. Builds on the
schema + procedures from 7a55d6d (Task 2, part 1/2).
apps/web/components/teams/invite-dialog.tsx (new):
* Owner/admin-only sheet that wraps invites.create. Email input + role
select (member/admin; owner deliberately excluded — single-owner
model means ownership transfer is a separate flow, not a fresh
invite). On success surfaces the accept URL with a copy-to-clipboard
affordance and a "your email isn't wired up yet, paste this directly"
hint. Plain text input in this commit; the smart recipient
autocomplete combobox from Task 3 will swap it in via a follow-up
edit to this same file (subagent is working that in parallel).
apps/web/app/(app)/[workspaceSlug]/teams/page.tsx (rewrite):
* Replaced the placeholder "Invite coming soon" button with the new
InviteDialog. Adds:
- Pending invites section (admin/owner only) listing each open
invite with email, role, expiry-relative time, and Copy link /
Revoke actions.
- Per-member kebab menu with role-change actions and Remove. Only
owners can promote anyone to owner; admins can move people
between admin/member only. The "demote to member" item disables
on the last-owner row (the server enforces this anyway with a
clear error; UI just avoids surfacing a click that'd 400).
- "You're a member, not a manager" footer hint for non-owners/admins.
* Caller's role is derived from the members query (no extra
round-trip) — the membership row IS the source of truth for who
can manage what.
* Mutation errors surface inline at the page level with a Dismiss
action — kebab/copy actions that hit the last-owner guard, expired-
token error, etc. don't fail silently.
apps/web/app/invite/[token]/page.tsx (new):
* Public-by-token redeem page. Four phases handled cleanly:
1. No session yet -> "Sign in to continue" with callbackUrl set so
the user lands back here after auth.
2. Authenticated, accepting -> spinner.
3. Success -> redirect to the workspace's slug-rooted URL.
4. FORBIDDEN with cause.reason='email_not_owned' -> dedicated
explainer page showing both the invited email AND the user's
current sign-in email, with deep links to link the invited email
via OAuth and try again. (This is the Task 1 invariant
surfacing through the UI: we never silently accept an invite
under a mismatched identity.)
* All other accept errors (not found / revoked / expired) render the
message verbatim with a "Go home" button.
apps/web/server/trpc.ts:
* Added a small errorFormatter that exposes `error.cause` to the
client when it's a plain object. Required for the invite-accept
explainer page to read `cause.invitedEmail` off the TRPCError. The
cause-payload contract is "small, pure data, no secrets" — anything
the server throws as a cause is also visible client-side.
End-to-end behavior verified statically: type-check clean across all
6 packages. Smoke test path:
1. As admin@tasks.dev, open /<workspace>/teams.
2. Click Invite -> dialog opens -> enter an email, pick member, send.
3. See the success state with the accept URL. Copy it.
4. Open the URL in a different browser (or incognito). With no session
-> sign-in prompt. After auth -> invite accepts and you land in
the workspace. With a session whose email doesn't match -> the
email-mismatch explainer renders.
Note: the test runner shows three new tests in packages/shared
(invite-suggestions.test.ts) from the in-progress Task-3 subagent.
Those land with their own commit when the subagent finishes — they're
visible here only because they share the working tree.
Co-authored-by: Cursor <cursoragent@cursor.com>
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>