diff --git a/apps/web/app/(app)/[workspaceSlug]/teams/page.tsx b/apps/web/app/(app)/[workspaceSlug]/teams/page.tsx index 46268cb..38368ba 100644 --- a/apps/web/app/(app)/[workspaceSlug]/teams/page.tsx +++ b/apps/web/app/(app)/[workspaceSlug]/teams/page.tsx @@ -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 ( @@ -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(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

Missing workspace.

; + } + + const members = membersQuery.data ?? []; + const ownerCount = members.filter((m) => m.role === "owner").length; + return (

Teams

- + {canManage ? : null}
- {!workspaceSlug ? ( -

Missing workspace.

- ) : isLoading ? ( + {/* --- Pending invites (admin/owner only) --- */} + {canManage ? ( +
+
+ +

+ Pending invites +

+
+ {invitesQuery.isLoading ? ( +

Loading…

+ ) : !invitesQuery.data?.length ? ( +

+ No pending invites. Use the Invite button to add someone. +

+ ) : ( +
    + {invitesQuery.data.map((inv) => ( +
  • +
    +

    {inv.email}

    +

    + Invited as {formatRoleLabel(inv.role)} ·{" "} + + expires {relativeFromNow(inv.expiresAt)} + +

    +
    +
    + + +
    +
  • + ))} +
+ )} +
+ ) : null} + + {/* --- Members --- */} +
+ +

+ Members +

+
+ {membersQuery.isLoading ? (
{Array.from({ length: 6 }).map((_, i) => ( ))}
- ) : !members?.length ? ( -
+ ) : !members.length ? ( +
No members in this workspace yet.
) : (
{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 ( - {m.avatarUrl ? ( - - ) : null} - {memberInitials(m.name, m.email)} + {m.avatarUrl ? : null} + + {memberInitials(m.name, m.email)} +

{displayName}

@@ -95,12 +235,132 @@ export default function TeamsPage() { {formatRoleLabel(m.role)} + {showActions ? ( + + + + + + + Change role + + {/* Only an owner can promote anyone to owner. */} + {callerRole === "owner" ? ( + + updateRoleMut.mutate({ + workspace: workspaceSlug, + userId: m.id, + role: "owner", + }) + } + > + Owner + + ) : null} + + updateRoleMut.mutate({ + workspace: workspaceSlug, + userId: m.id, + role: "admin", + }) + } + > + Admin + + + updateRoleMut.mutate({ + workspace: workspaceSlug, + userId: m.id, + role: "member", + }) + } + > + Member + + + { + 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" + > + + Remove + + + + ) : null} ); })}
)} + + {/* 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) ? ( +

+ {updateRoleMut.error?.message ?? + removeMemberMut.error?.message ?? + revokeMut.error?.message} + {(updateRoleMut.error || + removeMemberMut.error || + revokeMut.error) && ( + + )} +

+ ) : null} + + {!canManage && callerRole === "member" ? ( +

+ You can see the team but only owners and admins can invite or change + roles. Ask one of them if you need a change. +

+ ) : null}
); } diff --git a/apps/web/app/invite/[token]/page.tsx b/apps/web/app/invite/[token]/page.tsx new file mode 100644 index 0000000..bfcbd77 --- /dev/null +++ b/apps/web/app/invite/[token]/page.tsx @@ -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 ; + } + + if (status === "loading") { + return ; + } + + if (status === "unauthenticated") { + return ( + +

+ You need to be signed in for us to know which account to add to the + workspace. +

+
+ +
+
+ ); + } + + if (acceptMut.isPending || (acceptMut.isIdle && status === "authenticated")) { + return ; + } + + if (acceptMut.isSuccess && acceptMut.data) { + return ( + +

+ Joined {acceptMut.data.workspace.name} as{" "} + {acceptMut.data.role}. Redirecting… +

+
+ ); + } + + 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 ( + +

+ The invite was for{" "} + {cause.invitedEmail}, but + you're signed in as{" "} + {session?.user?.email}. +

+

+ 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. +

+
+ + +
+
+ ); + } + + return ( + +

{acceptMut.error.message}

+
+ +
+
+ ); + } + + return ; +} + +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 ( +
+
+
+ +
+

{title}

+ {children} +
+
+ ); +} diff --git a/apps/web/components/teams/invite-dialog.tsx b/apps/web/components/teams/invite-dialog.tsx new file mode 100644 index 0000000..7db637d --- /dev/null +++ b/apps/web/components/teams/invite-dialog.tsx @@ -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 `` 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("member"); + const [acceptUrl, setAcceptUrl] = React.useState(null); + const [reused, setReused] = React.useState(false); + const [copied, setCopied] = React.useState(false); + const [error, setError] = React.useState(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 ( + { + setOpen(next); + if (!next) reset(); + }} + > + + {children ?? } + + + + Invite a teammate + + They'll get a private link to join this workspace. The link + expires in 14 days. + + + + {acceptUrl ? ( +
+
+ {reused + ? "An invite for this email already exists. Here's the link:" + : "Invite created. Share this link with them:"} +
+ +
+ +
+ e.currentTarget.select()} + className="font-mono text-xs" + /> + +
+

+ Email delivery isn't wired up yet. Paste this link to them + directly until it is. +

+
+ +
+ + +
+
+ ) : ( +
+
+ + setEmail(e.target.value)} + required + disabled={createMut.isPending} + /> +
+ +
+ + +

+ Ownership transfers happen in a separate flow, not via invite. +

+
+ + {error ? ( +

+ + {error} +

+ ) : null} + +
+ + +
+
+ )} +
+
+ ); +} diff --git a/apps/web/server/trpc.ts b/apps/web/server/trpc.ts index bc20ca0..0aa5ac4 100644 --- a/apps/web/server/trpc.ts +++ b/apps/web/server/trpc.ts @@ -27,6 +27,22 @@ export async function createContext(): Promise { const t = initTRPC.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) + : undefined, + }, + }), }); export const isAuthed = t.middleware(({ ctx, next }) => {