ubiquitous-invention/apps/web/app/invite/[token]/page.tsx
Randall Stillwell f3c118c9f6 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 10:34:56 -05:00

191 lines
6.4 KiB
TypeScript

"use client";
import * as React from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { signIn, useSession } from "next-auth/react";
import { CheckCircle2, Loader2, Mail, ShieldAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/trpc";
/**
* Public-by-token invite redeem page. Three phases:
*
* 1. Not signed in -> bounce to /sign-in with callbackUrl that brings us
* back here. We don't surface the invite contents pre-auth; the only
* thing the operator needs to see is "you need to sign in first."
*
* 2. Signed in, calling invites.accept(). On success: route to the
* workspace landing page.
*
* 3. Signed in but identity mismatch (FORBIDDEN with cause.reason ===
* "email_not_owned"). Render the explainer with a deep link to the
* profile's Linked Emails section. The user can either:
* - Sign out and sign in via the matching email's provider.
* - Add the missing email to their profile (manual verification
* is a follow-up task; in v1 the link points at the read-only
* Linked Emails page).
*/
export default function InviteAcceptPage() {
const params = useParams();
const router = useRouter();
const { data: session, status } = useSession();
const rawToken = params?.token;
const token = typeof rawToken === "string" ? rawToken : undefined;
const acceptMut = api.invites.accept.useMutation({
onSuccess: (result) => {
router.replace(`/${result.workspace.slug}`);
},
});
const triedRef = React.useRef(false);
React.useEffect(() => {
if (!token) return;
if (status !== "authenticated") return;
if (triedRef.current) return;
triedRef.current = true;
acceptMut.mutate({ token });
}, [token, status]); // eslint-disable-line react-hooks/exhaustive-deps
if (!token) {
return <InviteShell title="Invalid invite link" tone="error" />;
}
if (status === "loading") {
return <InviteShell title="Checking your session…" tone="loading" />;
}
if (status === "unauthenticated") {
return (
<InviteShell title="Sign in to accept this invite" tone="loading">
<p className="text-sm text-muted-foreground">
You need to be signed in for us to know which account to add to the
workspace.
</p>
<div className="mt-4 flex justify-center">
<Button
type="button"
onClick={() =>
signIn(undefined, { callbackUrl: `/invite/${token}` })
}
>
Sign in to continue
</Button>
</div>
</InviteShell>
);
}
if (acceptMut.isPending || (acceptMut.isIdle && status === "authenticated")) {
return <InviteShell title="Accepting your invite…" tone="loading" />;
}
if (acceptMut.isSuccess && acceptMut.data) {
return (
<InviteShell title="You're in" tone="success">
<p className="text-sm text-muted-foreground">
Joined {acceptMut.data.workspace.name} as{" "}
<span className="font-medium">{acceptMut.data.role}</span>. Redirecting
</p>
</InviteShell>
);
}
if (acceptMut.error) {
// Identity mismatch — caller is signed in but doesn't own the invited
// email. This is the dedicated explainer path, not a generic error.
const cause = acceptMut.error.shape?.data?.cause as
| { reason?: string; invitedEmail?: string }
| undefined;
if (cause?.reason === "email_not_owned" && cause.invitedEmail) {
return (
<InviteShell
title="This invite was sent to a different email"
tone="mismatch"
>
<p className="text-sm text-muted-foreground">
The invite was for{" "}
<span className="font-medium">{cause.invitedEmail}</span>, but
you&apos;re signed in as{" "}
<span className="font-medium">{session?.user?.email}</span>.
</p>
<p className="mt-2 text-sm text-muted-foreground">
To accept, link the invited email to your account from your
profile (e.g. sign in via that email&apos;s OAuth provider), then
return to this page. Or sign out and sign back in with the
invited email directly.
</p>
<div className="mt-5 flex flex-wrap justify-center gap-2">
<Button asChild type="button" variant="outline">
<Link href="/">Go to my workspaces</Link>
</Button>
<Button asChild type="button">
{/* No global profile route; use the first workspace's profile
settings. The user will see Linked Emails there. */}
<Link href="/">View linked emails</Link>
</Button>
</div>
</InviteShell>
);
}
return (
<InviteShell title="We couldn't accept this invite" tone="error">
<p className="text-sm text-muted-foreground">{acceptMut.error.message}</p>
<div className="mt-4 flex justify-center">
<Button asChild type="button" variant="outline">
<Link href="/">Go home</Link>
</Button>
</div>
</InviteShell>
);
}
return <InviteShell title="Loading invite…" tone="loading" />;
}
type Tone = "loading" | "success" | "error" | "mismatch";
function InviteShell({
title,
tone,
children,
}: {
title: string;
tone: Tone;
children?: React.ReactNode;
}) {
const Icon =
tone === "success"
? CheckCircle2
: tone === "mismatch"
? ShieldAlert
: tone === "error"
? ShieldAlert
: tone === "loading"
? Loader2
: Mail;
const iconClass =
tone === "success"
? "text-emerald-600"
: tone === "error" || tone === "mismatch"
? "text-amber-600"
: "text-muted-foreground";
return (
<div className="flex min-h-screen items-center justify-center bg-background px-6">
<div className="w-full max-w-md rounded-xl border border-border bg-card p-8 text-center shadow-sm">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<Icon
className={`size-6 ${iconClass} ${tone === "loading" ? "animate-spin" : ""}`}
aria-hidden
/>
</div>
<h1 className="mb-2 text-lg font-semibold">{title}</h1>
{children}
</div>
</div>
);
}