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>
191 lines
6.4 KiB
TypeScript
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'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'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>
|
|
);
|
|
}
|