From bb3a667660f1fc9be23bbcd70f34160b9a20921a Mon Sep 17 00:00:00 2001 From: simeontech Date: Tue, 20 Oct 2026 04:25:48 +0000 Subject: [PATCH] fix(frontend): validate admin key, authenticate admin pages, fix key rotation, format minified pages Closes #1757, #1758, #1759, #1760 --- .../app/account/bets/[betId]/claim/page.tsx | 56 +++- .../src/app/admin/__tests__/layout.test.tsx | 128 +++----- frontend/src/app/admin/api-keys/page.test.tsx | 55 ++++ frontend/src/app/admin/api-keys/page.tsx | 79 ++++- .../app/admin/audit/statistics/page.test.tsx | 18 + .../src/app/admin/audit/statistics/page.tsx | 49 ++- .../src/app/admin/email/queue/page.test.tsx | 32 ++ frontend/src/app/admin/email/queue/page.tsx | 76 ++++- frontend/src/app/admin/layout.tsx | 77 ++--- .../components/markets/OraclePriceWidget.tsx | 20 +- frontend/src/lib/api/admin-client.ts | 307 +++++++++++------- 11 files changed, 628 insertions(+), 269 deletions(-) create mode 100644 frontend/src/app/admin/api-keys/page.test.tsx create mode 100644 frontend/src/app/admin/audit/statistics/page.test.tsx create mode 100644 frontend/src/app/admin/email/queue/page.test.tsx diff --git a/frontend/src/app/account/bets/[betId]/claim/page.tsx b/frontend/src/app/account/bets/[betId]/claim/page.tsx index 29880893..c66ac2be 100644 --- a/frontend/src/app/account/bets/[betId]/claim/page.tsx +++ b/frontend/src/app/account/bets/[betId]/claim/page.tsx @@ -3,20 +3,64 @@ import { useEffect, useState } from 'react'; import { useParams } from 'next/navigation'; -type Bet = { market_id: string | number; outcome?: string; winning?: boolean; status?: string; claimed?: boolean; payout?: string | number }; +type Bet = { + market_id: string | number; + outcome?: string; + winning?: boolean; + status?: string; + claimed?: boolean; + payout?: string | number; +}; export default function ClaimPayoutPage() { const { betId } = useParams<{ betId: string }>(); const [bet, setBet] = useState(null); const [message, setMessage] = useState('Loading bet…'); const [claiming, setClaiming] = useState(false); - useEffect(() => { fetch(`/api/v1/blockchain/bets/${encodeURIComponent(betId)}`).then(r => r.ok ? r.json() : Promise.reject()).then(setBet).catch(() => setMessage('Unable to load this bet.')); }, [betId]); + useEffect(() => { + fetch(`/api/v1/blockchain/bets/${encodeURIComponent(betId)}`) + .then((r) => (r.ok ? r.json() : Promise.reject())) + .then(setBet) + .catch(() => setMessage('Unable to load this bet.')); + }, [betId]); - const reason = !bet ? 'Loading…' : bet.claimed || bet.status === 'claimed' ? 'This bet has already been claimed.' : bet.winning === false || bet.status === 'lost' ? 'This position did not win.' : ''; + const reason = !bet + ? 'Loading…' + : bet.claimed || bet.status === 'claimed' + ? 'This bet has already been claimed.' + : bet.winning === false || bet.status === 'lost' + ? 'This position did not win.' + : ''; async function claim() { if (!bet || reason) return; - setClaiming(true); setMessage(''); - try { const res = await fetch(`/api/v1/blockchain/markets/${bet.market_id}/claim`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bet_id: betId }) }); if (!res.ok) throw new Error(); const data = await res.json(); setBet({ ...bet, claimed: true }); setMessage(`Payout confirmed: ${data.amount ?? bet.payout ?? 'your winnings'}.`); } catch { setMessage('Claim failed. Please try again.'); } finally { setClaiming(false); } + setClaiming(true); + setMessage(''); + try { + const res = await fetch(`/api/v1/blockchain/markets/${bet.market_id}/claim`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bet_id: betId }), + }); + if (!res.ok) throw new Error(); + const data = await res.json(); + setBet({ ...bet, claimed: true }); + setMessage(`Payout confirmed: ${data.amount ?? bet.payout ?? 'your winnings'}.`); + } catch { + setMessage('Claim failed. Please try again.'); + } finally { + setClaiming(false); + } } - return

Claim payout

Bet: {betId}

{bet &&

Position: {bet.outcome ?? '—'}

}{reason &&

{reason}

}{message && !reason &&

{message}

}
; + return ( +
+

Claim payout

+

Bet: {betId}

+ {bet &&

Position: {bet.outcome ?? '—'}

} + {reason &&

{reason}

} + {message && !reason &&

{message}

} + +
+ ); } diff --git a/frontend/src/app/admin/__tests__/layout.test.tsx b/frontend/src/app/admin/__tests__/layout.test.tsx index 69264893..47121f2d 100644 --- a/frontend/src/app/admin/__tests__/layout.test.tsx +++ b/frontend/src/app/admin/__tests__/layout.test.tsx @@ -1,98 +1,58 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import AdminLayout from "../layout"; +jest.mock('next/navigation', () => ({ usePathname: () => '/admin' })); +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import AdminLayout from '../layout'; -const ADMIN_KEY_STORAGE = "admin_api_key"; +const KEY = 'predictiq-admin-key'; -function mockSessionResponse(ok: boolean) { +function mockFetch(ok: boolean) { return jest.fn().mockResolvedValue({ ok, status: ok ? 200 : 401, - json: async () => ({ ok }), + statusText: ok ? 'OK' : 'Unauthorized', + headers: new Headers(), + text: async () => (ok ? '{"keys":[]}' : '{"code":"UNAUTHORIZED"}'), + json: async () => ({ code: 'UNAUTHORIZED' }), }); } -describe("AdminAuthGate", () => { - beforeEach(() => { - sessionStorage.clear(); +const renderLayout = () => + render( + +
admin content
+
+ ); + +describe('AdminAuthGate', () => { + beforeEach(() => sessionStorage.clear()); + + it('shows the form and makes no request without a stored key', () => { + const f = mockFetch(true); + global.fetch = f as unknown as typeof fetch; + renderLayout(); + expect(screen.queryByText('admin content')).not.toBeInTheDocument(); + expect(f).not.toHaveBeenCalled(); }); - afterEach(() => { - jest.restoreAllMocks(); + it('validates a submitted key against the api-keys endpoint before unlocking', async () => { + const f = mockFetch(true); + global.fetch = f as unknown as typeof fetch; + renderLayout(); + await userEvent.type(document.querySelector('input')!, 'good-key'); + await userEvent.click(screen.getAllByRole('button')[0]); + await waitFor(() => expect(screen.getByText('admin content')).toBeInTheDocument()); + expect(f.mock.calls[0][0]).toContain('/api/v1/admin/api-keys'); + expect(f.mock.calls[0][1].headers['X-API-Key']).toBe('good-key'); + expect(sessionStorage.getItem(KEY)).toBe('good-key'); }); - it("renders the login form when no key is stored", () => { - const fetchMock = mockSessionResponse(false); - global.fetch = fetchMock as unknown as typeof fetch; - - render( - -
admin content
-
, - ); - - expect(screen.getByLabelText(/admin key/i)).toBeInTheDocument(); - expect(screen.queryByText("admin content")).not.toBeInTheDocument(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("renders children when a valid stored key is present", async () => { - sessionStorage.setItem(ADMIN_KEY_STORAGE, "valid-key"); - const fetchMock = mockSessionResponse(true); - global.fetch = fetchMock as unknown as typeof fetch; - - render( - -
admin content
-
, - ); - - await waitFor(() => { - expect(screen.getByText("admin content")).toBeInTheDocument(); - }); - - expect(fetchMock).toHaveBeenCalledWith( - "/api/v1/admin/session", - expect.objectContaining({ - headers: expect.objectContaining({ "x-admin-key": "valid-key" }), - }), - ); - }); - - it("keeps the login form when the stored key is invalid", async () => { - sessionStorage.setItem(ADMIN_KEY_STORAGE, "invalid-key"); - const fetchMock = mockSessionResponse(false); - global.fetch = fetchMock as unknown as typeof fetch; - - render( - -
admin content
-
, - ); - - await waitFor(() => { - expect(fetchMock).toHaveBeenCalled(); - }); - - expect(screen.getByLabelText(/admin key/i)).toBeInTheDocument(); - expect(screen.queryByText("admin content")).not.toBeInTheDocument(); - }); - - it("persists a submitted key to sessionStorage", async () => { - const fetchMock = mockSessionResponse(true); - global.fetch = fetchMock as unknown as typeof fetch; - - render( - -
admin content
-
, - ); - - await userEvent.type(screen.getByLabelText(/admin key/i), "new-key"); - await userEvent.click(screen.getByRole("button", { name: /sign in|submit|unlock/i })); - - await waitFor(() => { - expect(sessionStorage.getItem(ADMIN_KEY_STORAGE)).toBe("new-key"); - }); + it('rejects an invalid key without storing it', async () => { + global.fetch = mockFetch(false) as unknown as typeof fetch; + renderLayout(); + await userEvent.type(document.querySelector('input')!, 'bad-key'); + await userEvent.click(screen.getAllByRole('button')[0]); + await waitFor(() => expect(screen.getByText(/invalid api key/i)).toBeInTheDocument()); + expect(screen.queryByText('admin content')).not.toBeInTheDocument(); + expect(sessionStorage.getItem(KEY)).toBeNull(); }); }); diff --git a/frontend/src/app/admin/api-keys/page.test.tsx b/frontend/src/app/admin/api-keys/page.test.tsx new file mode 100644 index 00000000..72f325a4 --- /dev/null +++ b/frontend/src/app/admin/api-keys/page.test.tsx @@ -0,0 +1,55 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ApiKeys from './page'; + +const KEY = 'predictiq-admin-key'; +const res = (ok: boolean, body: unknown) => ({ + ok, + status: ok ? 200 : 422, + statusText: '', + headers: new Headers(), + text: async () => JSON.stringify(body), + json: async () => body, +}); + +describe('ApiKeys rotate', () => { + beforeEach(() => { + sessionStorage.clear(); + sessionStorage.setItem(KEY, 'old-key'); + }); + + it('sends the label, shows the new key and swaps the stored key on success', async () => { + const f = jest + .fn() + .mockImplementation((_u: string, init: RequestInit) => + Promise.resolve( + init.method === 'POST' + ? res(true, { new_key: 'new-secret', new_key_label: 'l' }) + : res(true, { keys: [] }) + ) + ); + global.fetch = f as unknown as typeof fetch; + render(); + await userEvent.type(screen.getByLabelText(/key label/i), 'ci-1'); + await userEvent.click(screen.getByRole('button', { name: /rotate key/i })); + await waitFor(() => expect(screen.getByTestId('new-key')).toHaveTextContent('new-secret')); + const post = f.mock.calls.find((c) => c[1].method === 'POST')!; + expect(JSON.parse(post[1].body)).toEqual({ key_label: 'ci-1' }); + expect(sessionStorage.getItem(KEY)).toBe('new-secret'); + }); + + it('keeps the stored key when rotation fails', async () => { + global.fetch = jest + .fn() + .mockImplementation((_u: string, init: RequestInit) => + Promise.resolve( + init.method === 'POST' ? res(false, { code: 'X' }) : res(true, { keys: [] }) + ) + ) as unknown as typeof fetch; + render(); + await userEvent.type(screen.getByLabelText(/key label/i), 'ci-1'); + await userEvent.click(screen.getByRole('button', { name: /rotate key/i })); + await waitFor(() => expect(screen.getByText(/rotation failed/i)).toBeInTheDocument()); + expect(sessionStorage.getItem(KEY)).toBe('old-key'); + }); +}); diff --git a/frontend/src/app/admin/api-keys/page.tsx b/frontend/src/app/admin/api-keys/page.tsx index 8651b206..c41760f5 100644 --- a/frontend/src/app/admin/api-keys/page.tsx +++ b/frontend/src/app/admin/api-keys/page.tsx @@ -1,4 +1,77 @@ 'use client'; -import { useEffect,useState } from 'react'; -type Key={id:string;name?:string;created_at?:string}; -export default function ApiKeys(){const [keys,setKeys]=useState([]),[message,setMessage]=useState('');const auth=()=>({'X-API-Key':sessionStorage.getItem('predictiq-admin-key')||''});const load=()=>fetch('/api/v1/admin/api-keys',{headers:auth()}).then(r=>r.json()).then(d=>setKeys(d.keys??d));useEffect(()=>{load()},[]);const rotate=async()=>{if(!confirm('Rotate this key? Your current session will be invalidated.'))return;const r=await fetch('/api/v1/admin/api-keys/rotate',{method:'POST',headers:{...auth(),'Content-Type':'application/json'}});sessionStorage.removeItem('predictiq-admin-key');setMessage(r.ok?'Key rotated. Please authenticate again.':'Rotation failed.');setKeys([])};return

API keys

{message&&

{message}

}
    {keys.map(k=>
  • {k.name??k.id} {k.created_at&&`(${k.created_at})`}
  • )}
} + +import { useCallback, useEffect, useState } from 'react'; +import { api, ADMIN_KEY_STORAGE, type AdminApiKey } from '@/lib/api/admin-client'; + +export default function ApiKeys() { + const [keys, setKeys] = useState([]); + const [message, setMessage] = useState(''); + const [label, setLabel] = useState(''); + const [newKey, setNewKey] = useState(''); + const [copied, setCopied] = useState(false); + + const load = useCallback(async () => { + try { + const d = await api.listApiKeys(); + setKeys(Array.isArray(d) ? d : (d.keys ?? [])); + } catch { + setMessage('Unable to load API keys.'); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const rotate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!label.trim()) return; + try { + const res = await api.rotateApiKey({ key_label: label.trim() }); + // Only swap the stored key once the server has confirmed the rotation. + sessionStorage.setItem(ADMIN_KEY_STORAGE, res.new_key); + setNewKey(res.new_key); + setLabel(''); + setMessage('Key rotated. Copy the new key now; it will not be shown again.'); + load(); + } catch { + setMessage('Rotation failed. Your current key is unchanged.'); + } + }; + + const copy = async () => { + try { + await navigator.clipboard.writeText(newKey); + setCopied(true); + } catch { + setCopied(false); + } + }; + + return ( +
+

API keys

+ {message &&

{message}

} + {newKey && ( +

+ {newKey}{' '} + +

+ )} +
+ + +
+
    + {keys.map((k) => ( +
  • + {k.name ?? k.label ?? k.id} {k.created_at && `(${k.created_at})`} +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/app/admin/audit/statistics/page.test.tsx b/frontend/src/app/admin/audit/statistics/page.test.tsx new file mode 100644 index 00000000..830b4369 --- /dev/null +++ b/frontend/src/app/admin/audit/statistics/page.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react'; +import AuditStatistics from './page'; + +it('fetches statistics with the admin key', async () => { + sessionStorage.setItem('predictiq-admin-key', 'k'); + const f = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: '', + headers: new Headers(), + text: async () => JSON.stringify({ actions_by_actor: { alice: 3 } }), + }); + global.fetch = f as unknown as typeof fetch; + render(); + expect(await screen.findByText('alice: 3')).toBeInTheDocument(); + expect(f.mock.calls[0][0]).toContain('/api/v1/audit/statistics'); + expect(f.mock.calls[0][1].headers['X-API-Key']).toBe('k'); +}); diff --git a/frontend/src/app/admin/audit/statistics/page.tsx b/frontend/src/app/admin/audit/statistics/page.tsx index 58e4619a..c37c3af5 100644 --- a/frontend/src/app/admin/audit/statistics/page.tsx +++ b/frontend/src/app/admin/audit/statistics/page.tsx @@ -1,3 +1,48 @@ 'use client'; -import { useEffect,useState } from 'react'; -export default function AuditStatistics(){const [data,setData]=useState(),[error,setError]=useState('');useEffect(()=>{fetch('/api/v1/audit/statistics').then(r=>r.ok?r.json():Promise.reject(Error('Unable to load audit statistics'))).then(setData).catch(e=>setError(e.message))},[]);if(error)return

{error}

;if(!data)return

Loading…

;const actors=data.actions_by_actor??data.by_actor??{},days=data.actions_by_day??data.by_day??{};if(!Object.keys(actors).length&&!Object.keys(days).length)return

No audit events recorded.

;return

Audit statistics

Actions by actor

{Object.entries(actors).map(([k,v])=>

{k}: {String(v)}

)}

Actions by day

{Object.entries(days).map(([k,v])=>

{k}: {String(v)}

)}
} + +import { useEffect, useState } from 'react'; +import { api } from '@/lib/api/admin-client'; + +type Stats = Record | undefined>; + +function Section({ title, rows }: { title: string; rows: Record }) { + return ( +
+

{title}

+ {Object.entries(rows).map(([k, v]) => ( +

+ {k}: {String(v)} +

+ ))} +
+ ); +} + +export default function AuditStatistics() { + const [data, setData] = useState(); + const [error, setError] = useState(''); + + useEffect(() => { + api + .getAuditStatistics() + .then((d) => setData(d as Stats)) + .catch(() => setError('Unable to load audit statistics')); + }, []); + + if (error) return

{error}

; + if (!data) return

Loading…

; + + const actors = data.actions_by_actor ?? data.by_actor ?? {}; + const days = data.actions_by_day ?? data.by_day ?? {}; + if (!Object.keys(actors).length && !Object.keys(days).length) { + return

No audit events recorded.

; + } + + return ( +
+

Audit statistics

+
+
+
+ ); +} diff --git a/frontend/src/app/admin/email/queue/page.test.tsx b/frontend/src/app/admin/email/queue/page.test.tsx new file mode 100644 index 00000000..a08320b3 --- /dev/null +++ b/frontend/src/app/admin/email/queue/page.test.tsx @@ -0,0 +1,32 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import EmailQueue from './page'; + +const res = (body: unknown) => ({ + ok: true, + status: 200, + statusText: '', + headers: new Headers(), + text: async () => JSON.stringify(body), +}); + +it('loads and requeues through the authenticated admin client', async () => { + sessionStorage.setItem('predictiq-admin-key', 'k'); + const f = jest + .fn() + .mockImplementation((_u: string, init: RequestInit) => + Promise.resolve( + res(init.method === 'POST' ? {} : { jobs: [{ id: 'j1', recipient: 'a@b.c' }] }) + ) + ); + global.fetch = f as unknown as typeof fetch; + render(); + await userEvent.click(await screen.findByRole('button', { name: /requeue/i })); + await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument()); + const [getUrl, getInit] = f.mock.calls[0]; + expect(getUrl).toContain('/api/v1/email/queue/dead-letter'); + expect(getInit.headers['X-API-Key']).toBe('k'); + const post = f.mock.calls[1][1]; + expect(post.headers['X-API-Key']).toBe('k'); + expect(post.headers['Idempotency-Key']).toBeTruthy(); +}); diff --git a/frontend/src/app/admin/email/queue/page.tsx b/frontend/src/app/admin/email/queue/page.tsx index c17ef520..d888c0e7 100644 --- a/frontend/src/app/admin/email/queue/page.tsx +++ b/frontend/src/app/admin/email/queue/page.tsx @@ -1,7 +1,73 @@ 'use client'; + import { useEffect, useState } from 'react'; -type Job={id:string;recipient?:string;template?:string;failure?:string;status?:string}; -export default function EmailQueue(){const [jobs,setJobs]=useState([]),[stats,setStats]=useState>({}),[error,setError]=useState(''); - const load=async()=>{try{const r=await fetch('/api/v1/email/queue/dead-letter');if(!r.ok)throw Error('Unable to load email queue');const d=await r.json();setJobs(d.jobs??d.items??[]);setStats(d.stats??{})}catch(e){setError((e as Error).message)}}; - useEffect(()=>{load()},[]); const requeue=async(id:string)=>{const r=await fetch(`/api/v1/email/queue/dead-letter/${id}/requeue`,{method:'POST'});if(r.ok)setJobs(x=>x.filter(j=>j.id!==id));else setError('Requeue failed')}; - return

Email queue

{error&&

{error}

}
{JSON.stringify(stats,null,2)}
{jobs.length?{jobs.map(j=>)}
RecipientTemplateFailure
{j.recipient}{j.template}{j.failure}
:

No dead-letter jobs.

}
} +import { api } from '@/lib/api/admin-client'; + +type Job = { id: string; recipient?: string; template?: string; failure?: string; status?: string }; + +export default function EmailQueue() { + const [jobs, setJobs] = useState([]); + const [stats, setStats] = useState>({}); + const [error, setError] = useState(''); + + const load = async () => { + try { + const d = (await api.getEmailDeadLetter()) as { + jobs?: Job[]; + items?: Job[]; + stats?: Record; + }; + setJobs(d.jobs ?? d.items ?? []); + setStats(d.stats ?? {}); + } catch { + setError('Unable to load email queue'); + } + }; + + useEffect(() => { + load(); + }, []); + + const requeue = async (id: string) => { + try { + await api.requeueEmailJob(id); + setJobs((x) => x.filter((j) => j.id !== id)); + } catch { + setError('Requeue failed'); + } + }; + + return ( +
+

Email queue

+ {error &&

{error}

} +
{JSON.stringify(stats, null, 2)}
+ {jobs.length ? ( + + + + + + + + + + {jobs.map((j) => ( + + + + + + + ))} + +
RecipientTemplateFailure +
{j.recipient}{j.template}{j.failure} + +
+ ) : ( +

No dead-letter jobs.

+ )} +
+ ); +} diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index 27ce8183..48aed69e 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { useI18n } from '../../lib/hooks/useI18n'; -import { useActiveNavItem } from '../../../hooks/useActiveNavItem'; +import { api, ApiError, ADMIN_KEY_STORAGE } from '../../lib/api/admin-client'; import '../../styles/admin.css'; function AdminAuthGate({ children }: { children: React.ReactNode }) { @@ -15,52 +15,41 @@ function AdminAuthGate({ children }: { children: React.ReactNode }) { const [isLoading, setIsLoading] = useState(false); useEffect(() => { - const k = sessionStorage.getItem('predictiq-admin-key'); - if (k) { - setKey(k); - fetch('/api/v1/admin/session', { headers: { 'X-API-Key': k } }) - .then((r) => { - if (r.ok) { - setOk(true); - } else { - sessionStorage.removeItem('predictiq-admin-key'); - setKey(''); - setError('Session expired. Please log in again.'); - } - }) - .catch(() => { - sessionStorage.removeItem('predictiq-admin-key'); - setKey(''); - setError('Failed to validate session.'); - }); - } + const k = sessionStorage.getItem(ADMIN_KEY_STORAGE); + if (!k) return; + setKey(k); + api + .listApiKeys(k) + .then(() => setOk(true)) + .catch(() => { + sessionStorage.removeItem(ADMIN_KEY_STORAGE); + setKey(''); + setError('Session expired. Please log in again.'); + }); }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); setIsLoading(true); - try { - const response = await fetch('/api/v1/admin/session', { - headers: { 'X-API-Key': key }, - }); - - if (response.ok) { - sessionStorage.setItem('predictiq-admin-key', key); - setOk(true); - } else { - setError('Invalid API key. Please try again.'); - } - } catch { - setError('Failed to validate API key. Please try again.'); + await api.listApiKeys(key); + sessionStorage.setItem(ADMIN_KEY_STORAGE, key); + setOk(true); + } catch (err) { + const unauthorized = err instanceof ApiError && (err.status === 401 || err.status === 403); + setError( + unauthorized + ? 'Invalid API key. Please try again.' + : 'Failed to validate API key. Please try again.' + ); } finally { setIsLoading(false); } }; const handleLogout = () => { - sessionStorage.removeItem('predictiq-admin-key'); + sessionStorage.removeItem(ADMIN_KEY_STORAGE); setKey(''); setOk(false); setError(''); @@ -79,7 +68,6 @@ function AdminAuthGate({ children }: { children: React.ReactNode }) { disabled={isLoading} /> - {error &&
{error}
}