Add full-screen API-key gate
In user-key mode a browser with no stored Ollama key now gets a polished gate instead of the app shell: one masked input with a show/hide toggle, a single Continue button, inline validation errors, and a "?" help sheet with the steps to a free key. - Validates the draft key through /api/health (real 1-token probe) before saving; invalid-key and network failures read differently. - Key still lives only in this browser's localStorage; nothing server-side. - Gate wraps /, /create and deck study; server-key and local-daemon modes skip it; removing the key in Settings brings it back. - Removes the now-redundant dashboard/create API-key banner. - Monochrome corner glows (dark near-black / light near-white), 24px card, 48px field and button, gentle fade/slide-in, reduced-motion aware. - Help modal: centred dialog on desktop, bottom sheet on mobile, closes via X, Escape or backdrop. - README and browser suites updated (34 gate checks, 15 key-flow checks, 12 shipped-default checks; ui/folders/docs/pwa/security/contrast green).
This commit is contained in:
parent
532b5a18fe
commit
dfa7b7502a
13 changed files with 505 additions and 94 deletions
321
components/ApiKeyGate.tsx
Normal file
321
components/ApiKeyGate.tsx
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { EyeIcon, EyeOffIcon, QuestionIcon, SnowflakeIcon, XIcon } from "@/components/icons";
|
||||
import { Spinner } from "@/components/ui";
|
||||
import { verifyOllamaKey } from "@/lib/client-api";
|
||||
import { saveSettings, settingsStore } from "@/lib/settings";
|
||||
import { useHealth } from "@/lib/use-health";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const KEYS_URL = "https://ollama.com/settings/keys";
|
||||
|
||||
type GateError = { tone: "invalid" | "offline"; message: string } | null;
|
||||
|
||||
/**
|
||||
* Full-screen gate shown when this browser has no Ollama API key yet (and the
|
||||
* server runs in `user-key` mode). It replaces the app shell entirely: no
|
||||
* sidebar, no header. The key is validated with a real one-token probe before
|
||||
* it is saved, and it is still stored only in this browser.
|
||||
*/
|
||||
export function ApiKeyGate({ children }: { children: React.ReactNode }) {
|
||||
const storedKey = useSyncExternalStore(
|
||||
settingsStore.subscribe,
|
||||
() => settingsStore.getSnapshot().apiKey,
|
||||
() => "",
|
||||
);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
const { health } = useHealth();
|
||||
|
||||
// Server-key and local-daemon installs never need a browser key.
|
||||
const needsKeyMode = health?.authMode !== "local" && health?.authMode !== "server";
|
||||
|
||||
// Until hydration finishes, render the page as usual so a key holder never
|
||||
// sees the gate flash. After that, a missing key replaces the app.
|
||||
if (!mounted || storedKey || !needsKeyMode) return <>{children}</>;
|
||||
|
||||
return <GateScreen />;
|
||||
}
|
||||
|
||||
function GateScreen() {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const [draft, setDraft] = useState("");
|
||||
const [reveal, setReveal] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<GateError>(null);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!helpOpen) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setHelpOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [helpOpen]);
|
||||
|
||||
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const key = draft.trim();
|
||||
if (!key || busy) return;
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const check = await verifyOllamaKey(key);
|
||||
|
||||
if (check.result === "valid") {
|
||||
// Saving flips the gate off and drops the user into the library.
|
||||
saveSettings({ apiKey: key });
|
||||
return;
|
||||
}
|
||||
setError({ tone: check.result === "invalid" ? "invalid" : "offline", message: check.message });
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="gate-bg relative min-h-dvh w-full overflow-hidden">
|
||||
{/* help -------------------------------------------------------- */}
|
||||
<div
|
||||
className="group fixed z-20"
|
||||
style={{
|
||||
top: "max(env(safe-area-inset-top), 1rem)",
|
||||
right: "max(env(safe-area-inset-right), 1rem)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHelpOpen(true)}
|
||||
aria-label="How do I get an Ollama API key?"
|
||||
title="How do I get a key?"
|
||||
className="grid size-8 place-items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<QuestionIcon className="size-4" />
|
||||
</button>
|
||||
<span
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute right-0 top-full mt-2 whitespace-nowrap rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-2.5 py-1.5 text-[11.5px] text-[var(--text-muted)] opacity-0 shadow-[var(--shadow-card)] transition group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
>
|
||||
How do I get a key?
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* card -------------------------------------------------------- */}
|
||||
<div className="relative z-10 flex min-h-dvh items-center justify-center px-5 py-10">
|
||||
<motion.div
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="w-full max-w-[420px] rounded-[24px] border border-[var(--hairline)] bg-[var(--surface)] p-6 shadow-[var(--shadow-gate)] sm:p-8"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid size-14 place-items-center rounded-[16px] bg-[var(--text)] text-[var(--page)]"
|
||||
>
|
||||
<SnowflakeIcon className="size-7" />
|
||||
</span>
|
||||
|
||||
<h1 className="mt-6 text-[26px] font-medium leading-tight tracking-[-0.01em] text-[var(--text)] sm:text-[28px]">
|
||||
Connect your Ollama key
|
||||
</h1>
|
||||
<p className="mt-1.5 text-[13.5px] leading-relaxed text-[var(--text-subtle)]">
|
||||
One step and you’re ready to freeze your first deck.
|
||||
</p>
|
||||
|
||||
<form className="mt-6" onSubmit={submit} noValidate>
|
||||
<div className="relative">
|
||||
<label className="sr-only" htmlFor="gate-api-key">
|
||||
Ollama API key
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id="gate-api-key"
|
||||
type={reveal ? "text" : "password"}
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
placeholder="Paste your Ollama API key"
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
disabled={busy}
|
||||
aria-invalid={error?.tone === "invalid" || undefined}
|
||||
aria-describedby={error ? "gate-api-key-error" : undefined}
|
||||
className="h-12 w-full rounded-[13px] border border-[var(--field-border)] bg-[var(--field)] pl-3.5 pr-12 text-[14px] text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-[var(--text-subtle)] disabled:opacity-60"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReveal((value) => !value)}
|
||||
aria-label={reveal ? "Hide API key" : "Show API key"}
|
||||
aria-pressed={reveal}
|
||||
className="absolute right-1.5 top-1/2 grid size-9 -translate-y-1/2 place-items-center rounded-[10px] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
{reveal ? <EyeOffIcon className="size-[18px]" /> : <EyeIcon className="size-[18px]" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p
|
||||
id="gate-api-key-error"
|
||||
role="alert"
|
||||
className={cn(
|
||||
"mt-2.5 text-[12.5px] leading-relaxed",
|
||||
error.tone === "invalid"
|
||||
? "text-rose-700 dark:text-rose-300"
|
||||
: "text-amber-700 dark:text-amber-300",
|
||||
)}
|
||||
>
|
||||
{error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!draft.trim() || busy}
|
||||
className="btn-primary mt-3.5 h-12 w-full rounded-[13px] text-[14.5px] font-medium disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{busy ? (
|
||||
<>
|
||||
<Spinner className="size-4" /> Checking…
|
||||
</>
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-[12px] leading-relaxed text-[var(--text-subtle)]">
|
||||
Your key stays in this browser. Freezmo’s server never stores it.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* “How do I get a key?” */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center sm:p-6"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="gate-help-title"
|
||||
initial={reduceMotion ? false : { y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={reduceMotion ? { opacity: 0 } : { y: 24, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 340, damping: 32 }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="thin-scroll max-h-[90dvh] w-full max-w-md overflow-y-auto rounded-t-[24px] border border-[var(--hairline)] bg-[var(--surface)] p-6 pb-safe shadow-[var(--shadow-gate)] sm:rounded-[24px] sm:pb-6"
|
||||
>
|
||||
<div className="sheet-grabber sm:hidden" aria-hidden />
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h2
|
||||
id="gate-help-title"
|
||||
className="text-[19px] font-medium leading-snug tracking-[-0.01em] text-[var(--text)]"
|
||||
>
|
||||
How to get your Ollama API key
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="grid size-8 shrink-0 place-items-center rounded-[10px] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface-sunken)] px-3.5 py-3 text-[12.5px] leading-relaxed text-[var(--text-muted)]">
|
||||
You only need the free cloud tier. No payment and no credit card required.
|
||||
</p>
|
||||
|
||||
<ol className="mt-5 space-y-4">
|
||||
<Step index={1}>
|
||||
Go to{" "}
|
||||
<a
|
||||
href="https://ollama.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium text-[var(--text)] underline underline-offset-2"
|
||||
>
|
||||
ollama.com
|
||||
</a>{" "}
|
||||
and sign in, or create a free account.
|
||||
</Step>
|
||||
<Step index={2}>
|
||||
Open <span className="text-[var(--text)]">Settings → API keys</span> at{" "}
|
||||
<a
|
||||
href={KEYS_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium text-[var(--text)] underline underline-offset-2"
|
||||
>
|
||||
ollama.com/settings/keys
|
||||
</a>
|
||||
.
|
||||
</Step>
|
||||
<Step index={3}>
|
||||
Click <span className="text-[var(--text)]">“Create new key”</span>, give it
|
||||
any name, and copy it.
|
||||
</Step>
|
||||
<Step index={4}>Paste it into Freezmo and press Continue.</Step>
|
||||
</ol>
|
||||
|
||||
<p className="mt-5 text-[12px] leading-relaxed text-[var(--text-subtle)]">
|
||||
The free tier is enough for generating decks. You can swap or remove your key anytime in
|
||||
Settings.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-primary mt-5 h-11 w-full rounded-[13px] text-[14px] font-medium"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
function Step({ index, children }: { index: number; children: React.ReactNode }) {
|
||||
return (
|
||||
<li className="flex gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-0.5 grid size-6 shrink-0 place-items-center rounded-full border border-[var(--hairline)] text-[11.5px] font-medium tabular-nums text-[var(--text-subtle)]"
|
||||
>
|
||||
{index}
|
||||
</span>
|
||||
<span className="text-[13px] leading-relaxed text-[var(--text-muted)]">{children}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue