Skip to content
File

Blob: src/client/components/auth/invite-page.tsx

typescript169 lines
1import { useEffect, useRef, useState } from "react";
2import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
3import { useMutation } from "@tanstack/react-query";
4import { UserPlus, AlertCircle, CheckCircle, LogIn } from "lucide-react";
5import { Button } from "@/client/components/ui/button";
6import {
7 BUTTON_BASE_CLASSES,
8 BUTTON_SIZE_CLASSES,
9 BUTTON_VARIANT_CLASSES,
10} from "@/client/components/ui/button-classes";
11import { Skeleton } from "@/client/components/ui/skeleton";
12import { api, toApiError } from "@/client/lib/api";
13import { useAuthStore, selectIsAuthenticated } from "@/client/stores/auth-store";
14import { ensureWorkspaceLocalOwner } from "@/client/stores/bootstrap";
15import { directoryCommands } from "@/client/stores/db/workspace-directory";
16import { useDocumentTitle } from "@/client/hooks/use-document-title";
17import type { InvitePreview } from "@/shared/types";
18 
19function buildOidcStartUrl(token: string): string {
20 const params = new URLSearchParams({ return_to: `/invite/${token}?accept=1` });
21 return `/api/v1/oidc/start?${params.toString()}`;
22}
23 
24export function InvitePage() {
25 const { token } = useParams({ strict: false }) as { token: string };
26 const { accept: acceptMarker } = useSearch({ from: "/invite/$token" });
27 const navigate = useNavigate();
28 useDocumentTitle("Accept Invite");
29 const isAuthenticated = useAuthStore(selectIsAuthenticated);
30 
31 const [invite, setInvite] = useState<InvitePreview | null>(null);
32 const [loadError, setLoadError] = useState<string | null>(null);
33 const [isLoadingInvite, setIsLoadingInvite] = useState(true);
34 
35 useEffect(() => {
36 let cancelled = false;
37 
38 async function loadInvite() {
39 try {
40 const data = await api.invites.get(token);
41 if (!cancelled) {
42 setInvite(data);
43 }
44 } catch (err) {
45 if (!cancelled) {
46 setLoadError(toApiError(err).message);
47 }
48 } finally {
49 if (!cancelled) setIsLoadingInvite(false);
50 }
51 }
52 
53 loadInvite();
54 return () => {
55 cancelled = true;
56 };
57 }, [token]);
58 
59 const acceptMutation = useMutation({
60 mutationFn: () => api.invites.accept(token),
61 onSuccess: async (result) => {
62 useAuthStore.getState().setAuth(result.accessToken, result.user);
63 await ensureWorkspaceLocalOwner(result.user.id, true);
64 const workspaceData = await api.workspaces.list();
65 await directoryCommands.replaceAll(workspaceData);
66 const joined = workspaceData.find((w) => w.id === result.workspace_id);
67 if (joined) {
68 navigate({ to: "/$workspaceSlug", params: { workspaceSlug: joined.slug } });
69 } else {
70 navigate({ to: "/" });
71 }
72 },
73 });
74 
75 const autoAcceptedRef = useRef(false);
76 useEffect(() => {
77 if (autoAcceptedRef.current) return;
78 if (acceptMarker !== "1") return;
79 if (!isAuthenticated) return;
80 autoAcceptedRef.current = true;
81 acceptMutation.mutate();
82 }, [acceptMarker, isAuthenticated, acceptMutation]);
83 
84 const acceptError = acceptMutation.error ? toApiError(acceptMutation.error).message : null;
85 
86 if (isLoadingInvite) {
87 return (
88 <div className="flex min-h-[calc(100vh-8rem)] items-center justify-center px-4" aria-busy="true">
89 <div className="w-full max-w-sm">
90 <div className="mb-8 flex flex-col items-center">
91 <Skeleton className="mb-3 h-10 w-10 rounded-full" />
92 <Skeleton className="h-7 w-40" />
93 <Skeleton className="mt-2 h-4 w-52" />
94 </div>
95 <div className="space-y-4">
96 <Skeleton className="h-10 w-full rounded-lg" />
97 </div>
98 </div>
99 </div>
100 );
101 }
102 
103 if (loadError) {
104 return (
105 <div className="flex min-h-[calc(100vh-8rem)] items-center justify-center px-4">
106 <div className="animate-slide-up text-center">
107 <AlertCircle className="mx-auto mb-4 h-10 w-10 text-red-400" />
108 <h1 className="text-lg font-semibold text-zinc-100">Invalid Invite</h1>
109 <p className="mt-2 text-sm text-zinc-400">{loadError}</p>
110 </div>
111 </div>
112 );
113 }
114 
115 const oidcStartUrl = buildOidcStartUrl(token);
116 
117 return (
118 <div className="flex min-h-[calc(100vh-8rem)] items-center justify-center px-4">
119 <div className="animate-slide-up w-full max-w-sm">
120 <div className="mb-8 text-center">
121 <CheckCircle className="mx-auto mb-3 h-10 w-10 text-accent-500" />
122 <h1 className="font-display text-2xl font-bold tracking-tight text-zinc-100">You&apos;re invited</h1>
123 <p className="mt-2 text-sm text-zinc-400">
124 Join <span className="font-medium text-zinc-200">{invite?.workspace_name}</span> as a {invite?.role}
125 </p>
126 </div>
127
128 {acceptError && (
129 <div
130 id="invite-error"
131 role="alert"
132 className="mb-4 flex items-start gap-2 rounded-lg border border-red-500/20 bg-red-500/10 px-3 py-2.5 text-sm text-red-400"
133 >
134 <AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
135 <span>{acceptError}</span>
136 </div>
137 )}
138
139 {!isAuthenticated ? (
140 <a
141 href={oidcStartUrl}
142 className={`${BUTTON_BASE_CLASSES} ${BUTTON_SIZE_CLASSES.md} ${BUTTON_VARIANT_CLASSES.primary} w-full`}
143 >
144 <LogIn className="h-4 w-4" />
145 Sign in with tessera to accept
146 </a>
147 ) : (
148 <Button
149 variant="primary"
150 type="button"
151 disabled={acceptMutation.isPending}
152 onClick={() => acceptMutation.mutate()}
153 className="w-full"
154 icon={
155 acceptMutation.isPending ? (
156 <span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
157 ) : (
158 <UserPlus className="h-4 w-4" />
159 )
160 }
161 >
162 {acceptMutation.isPending ? "Joining..." : "Accept invite"}
163 </Button>
164 )}
165 </div>
166 </div>
167 );
168}