File
Blob: src/client/components/admin-login.tsx
| 1 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 2 | import { ExternalLink, Loader2, LogOut, Shield } from "lucide-react"; |
| 3 | import { useSearchParams } from "react-router-dom"; |
| 4 | import { DomainManager } from "@/client/components/admin/domain-manager"; |
| 5 | import { PermanentInboxList } from "@/client/components/admin/permanent-inbox-list"; |
| 6 | import { TempInboxList } from "@/client/components/admin/temp-inbox-list"; |
| 7 | import { toast } from "@/client/components/toast"; |
| 8 | import { Button, Card, ErrorBanner, buttonClasses } from "@/client/components/ui"; |
| 9 | import { |
| 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 | |
| 22 | const 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 | |
| 33 | function describeError(code: string | null) { |
| 34 | if (!code) { |
| 35 | return null; |
| 36 | } |
| 37 | return ERROR_COPY[code] ?? "Sign-in failed. Try again."; |
| 38 | } |
| 39 | |
| 40 | export 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 | } |