ubiquitous-invention/apps/web/app/(app)/[workspaceSlug]/teams/page.tsx

367 lines
14 KiB
TypeScript
Raw Normal View History

"use client";
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2) 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>
2026-06-02 11:34:56 -04:00
import * as React from "react";
import { useParams } from "next/navigation";
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2) 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>
2026-06-02 11:34:56 -04:00
import { useSession } from "next-auth/react";
// Membership-derived role drives which surfaces render (Invite button,
// pending-invites section, kebab menus). The server enforces the same
// rules on every mutation regardless of what the UI shows.
import { Copy, Loader2, MailCheck, MoreHorizontal, Trash2, UserCog } from "lucide-react";
import { cn } from "@/lib/utils";
import { api } from "@/lib/trpc";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2) 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>
2026-06-02 11:34:56 -04:00
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { InviteDialog } from "@/components/teams/invite-dialog";
type WorkspaceRole = "owner" | "admin" | "member";
function memberInitials(name: string | null | undefined, email: string) {
const n = name?.trim();
if (n) return n.slice(0, 1).toUpperCase();
return email.trim().slice(0, 1).toUpperCase();
}
function formatRoleLabel(role: string) {
const key = role.toLowerCase();
const labels: Record<string, string> = {
owner: "Owner",
admin: "Admin",
member: "Member",
};
return labels[key] ?? role.charAt(0).toUpperCase() + role.slice(1).toLowerCase();
}
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2) 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>
2026-06-02 11:34:56 -04:00
function relativeFromNow(when: Date | string | null | undefined): string {
if (!when) return "—";
const target = when instanceof Date ? when.getTime() : new Date(when).getTime();
const diff = target - Date.now();
if (Number.isNaN(diff)) return "—";
const sign = diff < 0 ? "ago" : "from now";
const abs = Math.abs(diff);
const min = Math.floor(abs / 60_000);
if (min < 60) return `${min}m ${sign}`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ${sign}`;
const day = Math.floor(hr / 24);
return `${day}d ${sign}`;
}
function MemberCardSkeleton({ className }: { className?: string }) {
return (
<Card className={cn("overflow-hidden", className)}>
<CardContent className="flex items-center gap-4 p-6">
<div className="size-10 shrink-0 animate-pulse rounded-full bg-muted" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-4 w-32 animate-pulse rounded-md bg-muted" />
<div className="h-3 w-48 max-w-full animate-pulse rounded-md bg-muted" />
</div>
<div className="h-5 w-16 shrink-0 animate-pulse rounded-full bg-muted" />
</CardContent>
</Card>
);
}
export default function TeamsPage() {
const params = useParams();
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2) 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>
2026-06-02 11:34:56 -04:00
const utils = api.useUtils();
multi-tenancy: promote workspaces to top-level table 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>
2026-05-07 00:02:55 -04:00
const rawSlug = params?.workspaceSlug;
const workspaceSlug = typeof rawSlug === "string" ? rawSlug : undefined;
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2) 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>
2026-06-02 11:34:56 -04:00
const enabled = Boolean(workspaceSlug);
const membersQuery = api.workspaces.listMembers.useQuery(
multi-tenancy: promote workspaces to top-level table 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>
2026-05-07 00:02:55 -04:00
{ workspace: workspaceSlug as string },
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2) 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>
2026-06-02 11:34:56 -04:00
{ enabled },
);
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2) 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>
2026-06-02 11:34:56 -04:00
// Caller's role lives on the members query — that's the source of truth for
// who's in this workspace and at what level. resolve() returns the workspace
// itself but not the membership-side role, so we derive it locally instead
// of round-tripping a second procedure.
const currentUserId = useSession().data?.user?.id;
const callerRole = membersQuery.data?.find((m) => m.id === currentUserId)?.role as
| WorkspaceRole
| undefined;
const canManage = callerRole === "owner" || callerRole === "admin";
const invitesQuery = api.invites.list.useQuery(
{ workspace: workspaceSlug as string },
{ enabled: enabled && canManage },
);
const revokeMut = api.invites.revoke.useMutation({
onSuccess: () => utils.invites.list.invalidate({ workspace: workspaceSlug }),
});
const updateRoleMut = api.workspaces.updateMemberRole.useMutation({
onSuccess: () => utils.workspaces.listMembers.invalidate({ workspace: workspaceSlug }),
});
const removeMemberMut = api.workspaces.removeMember.useMutation({
onSuccess: () => utils.workspaces.listMembers.invalidate({ workspace: workspaceSlug }),
});
const [copyFlash, setCopyFlash] = React.useState<string | null>(null);
const copyInviteLink = async (token: string) => {
const url = `${window.location.origin}/invite/${token}`;
try {
await navigator.clipboard.writeText(url);
setCopyFlash(token);
window.setTimeout(() => setCopyFlash(null), 1500);
} catch {
window.prompt("Copy this invite link:", url);
}
};
if (!workspaceSlug) {
return <p className="p-10 text-sm text-muted-foreground">Missing workspace.</p>;
}
const members = membersQuery.data ?? [];
const ownerCount = members.filter((m) => m.role === "owner").length;
return (
<div className="mx-auto max-w-5xl px-8 py-10">
<div className="mb-8 flex flex-wrap items-center justify-between gap-4">
<h1 className="text-3xl font-bold tracking-tight">Teams</h1>
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2) 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>
2026-06-02 11:34:56 -04:00
{canManage ? <InviteDialog workspaceSlug={workspaceSlug} /> : null}
</div>
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2) 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>
2026-06-02 11:34:56 -04:00
{/* --- Pending invites (admin/owner only) --- */}
{canManage ? (
<section className="mb-10">
<div className="mb-3 flex items-center gap-2">
<MailCheck className="size-4 text-muted-foreground" aria-hidden />
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Pending invites
</h2>
</div>
{invitesQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : !invitesQuery.data?.length ? (
<p className="text-sm text-muted-foreground">
No pending invites. Use the Invite button to add someone.
</p>
) : (
<ul className="space-y-2">
{invitesQuery.data.map((inv) => (
<li
key={inv.id}
className="flex items-center justify-between gap-4 rounded-md border border-border bg-card px-4 py-3 shadow-sm"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{inv.email}</p>
<p className="text-xs text-muted-foreground">
Invited as {formatRoleLabel(inv.role)} ·{" "}
<span title={new Date(inv.expiresAt).toLocaleString()}>
expires {relativeFromNow(inv.expiresAt)}
</span>
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => copyInviteLink(inv.token)}
>
<Copy className="size-3.5" aria-hidden />
{copyFlash === inv.token ? "Copied" : "Copy link"}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => revokeMut.mutate({ inviteId: inv.id })}
disabled={revokeMut.isPending}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
Revoke
</Button>
</div>
</li>
))}
</ul>
)}
</section>
) : null}
{/* --- Members --- */}
<div className="mb-3 flex items-center gap-2">
<UserCog className="size-4 text-muted-foreground" aria-hidden />
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Members
</h2>
</div>
{membersQuery.isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<MemberCardSkeleton key={i} />
))}
</div>
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2) 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>
2026-06-02 11:34:56 -04:00
) : !members.length ? (
<div className="rounded-lg border border-dashed border-border bg-muted/30 p-12 text-center text-sm text-muted-foreground">
No members in this workspace yet.
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{members.map((m) => {
const displayName = m.name?.trim() || m.email;
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2) 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>
2026-06-02 11:34:56 -04:00
const isSelf = m.id === currentUserId;
const isLastOwner = m.role === "owner" && ownerCount <= 1;
const showActions = canManage && !isSelf;
return (
<Card key={m.id} className="overflow-hidden">
<CardContent className="flex items-center gap-4 p-6">
<Avatar className="size-10 shrink-0">
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2) 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>
2026-06-02 11:34:56 -04:00
{m.avatarUrl ? <AvatarImage src={m.avatarUrl} alt="" /> : null}
<AvatarFallback>
{memberInitials(m.name, m.email)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{displayName}</p>
<p className="truncate text-sm text-muted-foreground">{m.email}</p>
</div>
<Badge variant="secondary" className="shrink-0 capitalize">
{formatRoleLabel(m.role)}
</Badge>
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2) 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>
2026-06-02 11:34:56 -04:00
{showActions ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`Manage ${displayName}`}
className="size-8"
>
<MoreHorizontal className="size-4" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-44">
<DropdownMenuLabel className="text-xs text-muted-foreground">
Change role
</DropdownMenuLabel>
{/* Only an owner can promote anyone to owner. */}
{callerRole === "owner" ? (
<DropdownMenuItem
disabled={m.role === "owner" || updateRoleMut.isPending}
onSelect={() =>
updateRoleMut.mutate({
workspace: workspaceSlug,
userId: m.id,
role: "owner",
})
}
>
Owner
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={m.role === "admin" || updateRoleMut.isPending}
onSelect={() =>
updateRoleMut.mutate({
workspace: workspaceSlug,
userId: m.id,
role: "admin",
})
}
>
Admin
</DropdownMenuItem>
<DropdownMenuItem
disabled={
m.role === "member" ||
isLastOwner ||
updateRoleMut.isPending
}
onSelect={() =>
updateRoleMut.mutate({
workspace: workspaceSlug,
userId: m.id,
role: "member",
})
}
>
Member
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={isLastOwner || removeMemberMut.isPending}
onSelect={() => {
if (
window.confirm(
`Remove ${displayName} from this workspace?`,
)
) {
removeMemberMut.mutate({
workspace: workspaceSlug,
userId: m.id,
});
}
}}
className="text-destructive focus:bg-destructive/10 focus:text-destructive"
>
<Trash2 className="size-3.5" aria-hidden />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</CardContent>
</Card>
);
})}
</div>
)}
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2) 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>
2026-06-02 11:34:56 -04:00
{/* Surface any mutation errors at the page level so kebab/dialog actions
that hit the last-owner guard or other validation failures don't
fail silently. */}
{(updateRoleMut.error ||
removeMemberMut.error ||
revokeMut.error) ? (
<p className="mt-6 text-sm text-destructive" role="alert">
{updateRoleMut.error?.message ??
removeMemberMut.error?.message ??
revokeMut.error?.message}
{(updateRoleMut.error ||
removeMemberMut.error ||
revokeMut.error) && (
<Button
type="button"
variant="ghost"
size="sm"
className="ml-2"
onClick={() => {
updateRoleMut.reset();
removeMemberMut.reset();
revokeMut.reset();
}}
>
Dismiss
</Button>
)}
</p>
) : null}
{!canManage && callerRole === "member" ? (
<p className="mt-10 rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
You can see the team but only owners and admins can invite or change
roles. Ask one of them if you need a change.
</p>
) : null}
</div>
);
}