Skip to content
File

Blob: src/client/components/admin-login.tsx

typescript224 lines
1import { useCallback, useEffect, useRef, useState } from "react";
2import { ExternalLink, Loader2, LogOut, Shield } from "lucide-react";
3import { useSearchParams } from "react-router-dom";
4import { DomainManager } from "@/client/components/admin/domain-manager";
5import { PermanentInboxList } from "@/client/components/admin/permanent-inbox-list";
6import { TempInboxList } from "@/client/components/admin/temp-inbox-list";
7import { toast } from "@/client/components/toast";
8import { Button, Card, ErrorBanner, buttonClasses } from "@/client/components/ui";
9import {
10 ApiError,
11 adminLogout,
12 adminSignInUrl,
13 clearAdminBookmark,
14 getErrorMessage,
15 isAdminAccessDisabledError,
16 listAdminDomains,
17 listAdminInboxes,
18 type AdminDomain,
19 type AdminInbox,
20} from "@/client/lib/api";
21 
22const ERROR_COPY: Record<string, string> = {
23 ADMIN_ACCESS_DISABLED: "Admin access is unavailable because tessera OIDC is not configured or cannot be discovered.",
24 invalid_request: "Sign-in request was missing required parameters.",
25 invalid_state: "Sign-in handshake state was invalid or expired. Try signing in again.",
26 missing_state: "Sign-in handshake cookie was missing. Try signing in again.",
27 token_exchange_failed: "tessera rejected the authorization code. Try signing in again.",
28 invalid_id_token: "tessera returned an unexpected ID token. Try signing in again.",
29 not_operator: "This tessera account is not authorized for admin access.",
30 session_create_failed: "Could not create the admin session. Try signing in again.",
31};
32 
33function describeError(code: string | null) {
34 if (!code) {
35 return null;
36 }
37 return ERROR_COPY[code] ?? "Sign-in failed. Try again.";
38}
39 
40export function AdminLogin() {
41 const [searchParams, setSearchParams] = useSearchParams();
42 const initialError = describeError(searchParams.get("error"));
43 const initialErrorRef = useRef(initialError);
44 const [authenticated, setAuthenticated] = useState(false);
45 const [bootLoading, setBootLoading] = useState(true);
46 const [refreshing, setRefreshing] = useState(false);
47 const [accessDisabled, setAccessDisabled] = useState(false);
48 const [error, setError] = useState<string | null>(initialError);
49 const [domains, setDomains] = useState<AdminDomain[]>([]);
50 const [inboxes, setInboxes] = useState<AdminInbox[]>([]);
51 
52 const reload = useCallback(async () => {
53 const [nextDomains, nextInboxes] = await Promise.all([listAdminDomains(), listAdminInboxes()]);
54 setDomains(nextDomains);
55 setInboxes(nextInboxes);
56 }, []);
57 
58 const reset = useCallback((message?: string) => {
59 clearAdminBookmark();
60 setAuthenticated(false);
61 setDomains([]);
62 setInboxes([]);
63 setError(message ?? null);
64 }, []);
65 
66 useEffect(() => {
67 let active = true;
68 
69 const boot = async () => {
70 try {
71 await reload();
72 if (!active) return;
73 setAuthenticated(true);
74 setError(null);
75 } catch (nextError) {
76 if (!active) return;
77 if (isAdminAccessDisabledError(nextError)) {
78 setAccessDisabled(true);
79 reset(getErrorMessage(nextError));
80 return;
81 }
82 if (nextError instanceof ApiError && (nextError.status === 401 || nextError.status === 403)) {
83 // Cookie missing or rejected; keep the existing error from
84 // ?error= if present, otherwise stay quiet.
85 reset(initialErrorRef.current ?? undefined);
86 return;
87 }
88 reset(getErrorMessage(nextError));
89 } finally {
90 if (active) {
91 setBootLoading(false);
92 }
93 }
94 };
95 
96 void boot();
97 return () => {
98 active = false;
99 };
100 }, [reload, reset]);
101 
102 // Drop ?error= from the URL once we have rendered it once so a refresh
103 // after a successful sign-in does not re-show the banner.
104 useEffect(() => {
105 if (!searchParams.get("error")) {
106 return;
107 }
108 const next = new URLSearchParams(searchParams);
109 next.delete("error");
110 setSearchParams(next, { replace: true });
111 }, [searchParams, setSearchParams]);
112 
113 const handleReload = useCallback(async () => {
114 setRefreshing(true);
115 try {
116 await reload();
117 setError(null);
118 setAccessDisabled(false);
119 } catch (nextError) {
120 if (isAdminAccessDisabledError(nextError)) {
121 setAccessDisabled(true);
122 reset(getErrorMessage(nextError));
123 throw nextError;
124 }
125 if (nextError instanceof ApiError && (nextError.status === 401 || nextError.status === 403)) {
126 reset();
127 throw nextError;
128 }
129 setError(getErrorMessage(nextError));
130 throw nextError;
131 } finally {
132 setRefreshing(false);
133 }
134 }, [reload, reset]);
135 
136 const handleLogout = useCallback(async () => {
137 try {
138 await adminLogout();
139 reset();
140 toast.info("Admin session cleared");
141 } catch (nextError) {
142 toast.error(getErrorMessage(nextError));
143 }
144 }, [reset]);
145 
146 if (bootLoading) {
147 return (
148 <main className="animate-slide-up space-y-6">
149 <Card className="flex items-center gap-2 text-sm text-zinc-400">
150 <Loader2 className="h-4 w-4 animate-spin" />
151 Loading admin console...
152 </Card>
153 </main>
154 );
155 }
156 
157 return (
158 <main className="animate-slide-up space-y-6">
159 {authenticated ? (
160 <Card className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
161 <div className="flex items-center gap-3">
162 <span className="inline-grid h-9 w-9 place-items-center rounded-lg bg-accent-500/10">
163 <Shield className="h-5 w-5 text-accent-400" />
164 </span>
165 <div>
166 <h1 className="text-base font-semibold text-zinc-100">Admin console</h1>
167 <span className="flex items-center gap-1.5 text-xs text-zinc-400">
168 <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
169 Authenticated via tessera
170 </span>
171 </div>
172 </div>
173 <Button icon={<LogOut className="h-3.5 w-3.5" />} onClick={() => void handleLogout()}>
174 Sign out
175 </Button>
176 </Card>
177 ) : (
178 <Card variant="accent">
179 <div className="mb-2 flex items-center gap-1.5">
180 <Shield className="h-3.5 w-3.5 text-accent-400" />
181 <span className="text-xs font-semibold uppercase tracking-wider text-accent-400">Reserved Access</span>
182 </div>
183 <h1 className="text-xl font-semibold tracking-tight text-zinc-100">Admin console</h1>
184 <p className="mt-2 text-sm leading-relaxed text-zinc-400">
185 Sign in with tessera to manage domains, inspect temporary inboxes, and browse permanent inboxes.
186 </p>
187 <p className="mt-2 text-xs leading-relaxed text-zinc-400">
188 Admin access is limited to operators allowlisted in tessera.
189 </p>
190
191 {accessDisabled ? (
192 <Button
193 variant="primary"
194 size="md"
195 disabled
196 icon={<ExternalLink className="h-3.5 w-3.5" />}
197 className="mt-5"
198 >
199 Sign in with tessera
200 </Button>
201 ) : (
202 <a href={adminSignInUrl()} className={buttonClasses({ variant: "primary", size: "md", className: "mt-5" })}>
203 <ExternalLink className="h-3.5 w-3.5" />
204 Sign in with tessera
205 </a>
206 )}
207
208 {error ? <ErrorBanner className="mt-4">{error}</ErrorBanner> : null}
209 </Card>
210 )}
211
212 {authenticated ? (
213 <section className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)]">
214 <div className="space-y-6">
215 <DomainManager domains={domains} loading={refreshing} onAdminSessionError={reset} onReload={handleReload} />
216 <TempInboxList onSessionError={reset} />
217 </div>
218 <PermanentInboxList inboxes={inboxes} loading={refreshing} />
219 </section>
220 ) : null}
221 </main>
222 );
223}