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>
This commit is contained in:
parent
7a55d6d1c6
commit
f3c118c9f6
4 changed files with 734 additions and 18 deletions
|
|
@ -1,6 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
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";
|
||||
|
|
@ -8,6 +15,17 @@ 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";
|
||||
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();
|
||||
|
|
@ -25,6 +43,21 @@ function formatRoleLabel(role: string) {
|
|||
return labels[key] ?? role.charAt(0).toUpperCase() + role.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
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)}>
|
||||
|
|
@ -42,51 +75,158 @@ function MemberCardSkeleton({ className }: { className?: string }) {
|
|||
|
||||
export default function TeamsPage() {
|
||||
const params = useParams();
|
||||
const utils = api.useUtils();
|
||||
const rawSlug = params?.workspaceSlug;
|
||||
const workspaceSlug = typeof rawSlug === "string" ? rawSlug : undefined;
|
||||
|
||||
const { data: members, isLoading } = api.workspaces.listMembers.useQuery(
|
||||
const enabled = Boolean(workspaceSlug);
|
||||
const membersQuery = api.workspaces.listMembers.useQuery(
|
||||
{ workspace: workspaceSlug as string },
|
||||
{ enabled: Boolean(workspaceSlug) },
|
||||
{ enabled },
|
||||
);
|
||||
|
||||
// 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>
|
||||
<Button type="button" onClick={() => window.alert("Invite coming soon")}>
|
||||
Invite
|
||||
</Button>
|
||||
{canManage ? <InviteDialog workspaceSlug={workspaceSlug} /> : null}
|
||||
</div>
|
||||
|
||||
{!workspaceSlug ? (
|
||||
<p className="text-sm text-muted-foreground">Missing workspace.</p>
|
||||
) : isLoading ? (
|
||||
{/* --- 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>
|
||||
) : !members?.length ? (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-dashed border-border bg-muted/30 p-12 text-center text-sm text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
) : !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;
|
||||
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">
|
||||
{m.avatarUrl ? (
|
||||
<AvatarImage src={m.avatarUrl} alt="" />
|
||||
) : null}
|
||||
<AvatarFallback>{memberInitials(m.name, m.email)}</AvatarFallback>
|
||||
{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>
|
||||
|
|
@ -95,12 +235,132 @@ export default function TeamsPage() {
|
|||
<Badge variant="secondary" className="shrink-0 capitalize">
|
||||
{formatRoleLabel(m.role)}
|
||||
</Badge>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
191
apps/web/app/invite/[token]/page.tsx
Normal file
191
apps/web/app/invite/[token]/page.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
249
apps/web/components/teams/invite-dialog.tsx
Normal file
249
apps/web/components/teams/invite-dialog.tsx
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Check, Copy, Loader2, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type InviteRole = "admin" | "member";
|
||||
|
||||
interface InviteDialogProps {
|
||||
workspaceSlug: string;
|
||||
/** Children render as the trigger; default is a primary "Invite" button. */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner/admin-only modal for inviting a teammate by email. Returns the
|
||||
* accept URL on success so the inviter can copy/paste it into chat / DM
|
||||
* until the transactional email send is wired up (filed follow-up).
|
||||
*
|
||||
* The email input is a plain `<Input>` in this task; the smart recipient
|
||||
* autocomplete from `Task-invite-recipient-autocomplete` will swap it for
|
||||
* a combobox in a separate commit.
|
||||
*/
|
||||
export function InviteDialog({ workspaceSlug, children }: InviteDialogProps) {
|
||||
const utils = api.useUtils();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [role, setRole] = React.useState<InviteRole>("member");
|
||||
const [acceptUrl, setAcceptUrl] = React.useState<string | null>(null);
|
||||
const [reused, setReused] = React.useState(false);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const createMut = api.invites.create.useMutation({
|
||||
onSuccess: async (result) => {
|
||||
setError(null);
|
||||
setReused(result.reused);
|
||||
const fullUrl = result.acceptUrl.startsWith("/")
|
||||
? `${window.location.origin}${result.acceptUrl}`
|
||||
: result.acceptUrl;
|
||||
setAcceptUrl(fullUrl);
|
||||
setCopied(false);
|
||||
await utils.invites.list.invalidate({ workspace: workspaceSlug });
|
||||
},
|
||||
onError: (e) => {
|
||||
setError(e.message);
|
||||
setAcceptUrl(null);
|
||||
},
|
||||
});
|
||||
|
||||
const reset = () => {
|
||||
setEmail("");
|
||||
setRole("member");
|
||||
setAcceptUrl(null);
|
||||
setReused(false);
|
||||
setCopied(false);
|
||||
setError(null);
|
||||
createMut.reset();
|
||||
};
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email.trim()) {
|
||||
setError("Email is required");
|
||||
return;
|
||||
}
|
||||
createMut.mutate({
|
||||
workspace: workspaceSlug,
|
||||
email: email.trim().toLowerCase(),
|
||||
role,
|
||||
});
|
||||
};
|
||||
|
||||
const onCopy = async () => {
|
||||
if (!acceptUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(acceptUrl);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard API can fail in non-secure contexts; fall back to selecting
|
||||
// the input so the user can copy manually.
|
||||
const input = document.getElementById("invite-accept-url") as
|
||||
| HTMLInputElement
|
||||
| null;
|
||||
input?.select();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) reset();
|
||||
}}
|
||||
>
|
||||
<SheetTrigger asChild>
|
||||
{children ?? <Button type="button">Invite</Button>}
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Invite a teammate</SheetTitle>
|
||||
<SheetDescription>
|
||||
They'll get a private link to join this workspace. The link
|
||||
expires in 14 days.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{acceptUrl ? (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-300">
|
||||
{reused
|
||||
? "An invite for this email already exists. Here's the link:"
|
||||
: "Invite created. Share this link with them:"}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="invite-accept-url"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Accept link
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="invite-accept-url"
|
||||
value={acceptUrl}
|
||||
readOnly
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onCopy}
|
||||
aria-label={copied ? "Copied" : "Copy invite link"}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-emerald-600" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Email delivery isn't wired up yet. Paste this link to them
|
||||
directly until it is.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={reset}>
|
||||
Invite another
|
||||
</Button>
|
||||
<Button type="button" onClick={() => setOpen(false)}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={onSubmit} className="mt-6 space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="invite-email" className="text-xs font-medium">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="invite-email"
|
||||
type="email"
|
||||
autoComplete="off"
|
||||
placeholder="alex@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={createMut.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="invite-role" className="text-xs font-medium">
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
id="invite-role"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as InviteRole)}
|
||||
disabled={createMut.isPending}
|
||||
className={cn(
|
||||
"h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm",
|
||||
"focus:outline-none focus:ring-2 focus:ring-ring",
|
||||
)}
|
||||
>
|
||||
<option value="member">Member — can use the workspace</option>
|
||||
<option value="admin">Admin — can also manage people</option>
|
||||
</select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Ownership transfers happen in a separate flow, not via invite.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p
|
||||
className="flex items-start gap-2 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<X className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
<span>{error}</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={createMut.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMut.isPending || !email.trim()}>
|
||||
{createMut.isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden />
|
||||
Sending…
|
||||
</>
|
||||
) : (
|
||||
"Send invite"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
|
@ -27,6 +27,22 @@ export async function createContext(): Promise<Context> {
|
|||
|
||||
const t = initTRPC.context<Context>().create({
|
||||
transformer: superjson,
|
||||
// Expose `error.cause` to the client. Procedures that need to surface
|
||||
// structured failure modes (e.g. `invites.accept` returning the invited
|
||||
// email so the explainer page can render) pass a `{ reason, ... }` object
|
||||
// as the cause and the client reads it from `error.shape.data.cause`.
|
||||
// Anything thrown should still be safe to serialize (no class instances,
|
||||
// no DB rows, no secrets) — keep cause payloads small and pure data.
|
||||
errorFormatter: ({ shape, error }) => ({
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
cause:
|
||||
error.cause && typeof error.cause === "object" && !(error.cause instanceof Error)
|
||||
? (error.cause as Record<string, unknown>)
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export const isAuthed = t.middleware(({ ctx, next }) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue