add bearer token modal when auth is missing
All checks were successful
Build & Deploy / deploy (push) Successful in 1m41s

Modal automatically opens on mount if no token is stored, and reopens
on any 401 from the API via a global "auth-required" event. Token is
persisted in localStorage under "ordinarthur.bearer".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-16 17:29:55 +02:00
parent 8ef642fb0b
commit 810575221f
3 changed files with 126 additions and 0 deletions

View File

@ -41,6 +41,9 @@ export async function api<T = unknown>(
} }
const res = await fetch(`${BASE}${path}`, { ...rest, headers: finalHeaders }); const res = await fetch(`${BASE}${path}`, { ...rest, headers: finalHeaders });
if (!res.ok) { if (!res.ok) {
if (res.status === 401 && auth) {
window.dispatchEvent(new CustomEvent("auth-required"));
}
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
throw new ApiError(res.status, text || res.statusText); throw new ApiError(res.status, text || res.statusText);
} }
@ -66,6 +69,9 @@ export async function apiBinary<T = unknown>(
body, body,
}); });
if (!res.ok) { if (!res.ok) {
if (res.status === 401) {
window.dispatchEvent(new CustomEvent("auth-required"));
}
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
throw new ApiError(res.status, text || res.statusText); throw new ApiError(res.status, text || res.statusText);
} }

View File

@ -0,0 +1,118 @@
import { useEffect, useState } from "react";
import { getToken, setToken, clearToken } from "@/api/client";
import { Label } from "@/design";
/**
* Modal globale pour saisir / mettre à jour le bearer token.
* S'affiche automatiquement :
* - au mount si aucun token en localStorage
* - quand `client.ts` dispatche l'event "auth-required" (ex: 401)
*/
export function BearerTokenModal() {
const [open, setOpen] = useState(() => !getToken());
const [value, setValue] = useState("");
const [show, setShow] = useState(false);
useEffect(() => {
function onAuthRequired() {
setValue("");
setOpen(true);
}
window.addEventListener("auth-required", onAuthRequired);
return () => window.removeEventListener("auth-required", onAuthRequired);
}, []);
function handleSave(e: React.FormEvent) {
e.preventDefault();
const trimmed = value.trim();
if (!trimmed) return;
setToken(trimmed);
setOpen(false);
// Recharger pour que toutes les queries React repartent avec le token
window.location.reload();
}
function handleClear() {
clearToken();
setValue("");
}
if (!open) return null;
return (
<div
className="fixed inset-0 z-[60] bg-ink/60 flex items-end sm:items-center justify-center p-0 sm:p-4"
role="dialog"
aria-modal="true"
>
<div
className="w-full sm:max-w-md bg-bg border-t-2 sm:border-2 border-ink flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<header className="border-b border-ink px-4 py-3">
<Label prefix="[ AUTH ]">BEARER TOKEN REQUIS</Label>
</header>
<form onSubmit={handleSave} className="px-4 py-4 space-y-4">
<p className="font-sans text-sm text-ink">
Colle ton token pour accéder à l'API.
Il sera stocké en <em className="text-accent not-italic">localStorage</em> (clé{" "}
<code className="font-mono text-xs">ordinarthur.bearer</code>).
</p>
<div>
<Label className="mb-1 block">TOKEN</Label>
<div className="flex gap-2">
<input
type={show ? "text" : "password"}
value={value}
onChange={(e) => setValue(e.target.value)}
autoFocus
autoComplete="off"
spellCheck={false}
placeholder="xxxxxxxxxxxxxxxxxxxx"
className="flex-1 border border-ink bg-bg px-3 py-2 font-mono text-sm text-ink focus:outline-none focus:border-accent"
/>
<button
type="button"
onClick={() => setShow((s) => !s)}
className="border border-ink px-3 py-2 font-mono text-[10px] uppercase tracking-label text-muted hover:text-ink"
>
{show ? "Cacher" : "Voir"}
</button>
</div>
</div>
<footer className="flex items-center justify-between gap-3 pt-2 border-t border-ink -mx-4 px-4 -mb-4 pb-4">
<button
type="button"
onClick={handleClear}
className="font-mono text-[10px] uppercase tracking-label text-muted hover:text-accent"
>
Effacer
</button>
<div className="flex items-center gap-2">
{getToken() && (
<button
type="button"
onClick={() => setOpen(false)}
className="border border-ink px-4 py-2 font-mono text-[11px] uppercase tracking-label text-ink hover:bg-ink hover:text-bg"
>
Annuler
</button>
)}
<button
type="submit"
disabled={!value.trim()}
className="border border-ink bg-ink px-4 py-2 font-mono text-[11px] uppercase tracking-label text-bg hover:bg-accent hover:border-accent disabled:opacity-40"
>
Enregistrer
</button>
</div>
</footer>
</form>
</div>
</div>
);
}

View File

@ -1,6 +1,7 @@
import { Outlet, createRootRoute, Link } from "@tanstack/react-router"; import { Outlet, createRootRoute, Link } from "@tanstack/react-router";
import { AccentDot, Label } from "@/design"; import { AccentDot, Label } from "@/design";
import { BottomNav } from "@/components/shell/BottomNav"; import { BottomNav } from "@/components/shell/BottomNav";
import { BearerTokenModal } from "@/components/shell/BearerTokenModal";
export const Route = createRootRoute({ export const Route = createRootRoute({
component: RootLayout, component: RootLayout,
@ -40,6 +41,7 @@ function RootLayout() {
</footer> </footer>
<BottomNav /> <BottomNav />
<BearerTokenModal />
</div> </div>
); );
} }