freezmo/components/ApiKeyGate.tsx
Martin Dvorak 8651674cf5 First-run gate offers free Ollama vs paid providers; add Google Gemini
The gate (user-key installs) now headlines Ollama with a "Free" badge and
adds an "or - Use a paid provider instead" option that opens the same
provider rows as Settings, framed as billed by the provider directly.
Saving a provider key there lists its models and switches study
generation to it automatically, so the gate steps aside with no Ollama
key at all.

Gemini joins the registry via its OpenAI-compatible endpoint
(generativelanguage.googleapis.com/v1beta/openai); the privacy policy
names it (en+cs).

Providers suite grows to 20 checks: the gate scenario runs against a
second user-key-mode app instance (free badge, paid option, provider
rows, pass-through, generation switch). All other gates green:
contrast 0 findings, mobile 126/126, chat 102/102, wizard 95/95,
bubbles 34/34, regression 44/44, i18n 13/13, native 33/33.
Version 0.4.9, SW -> freezmo-v73.
2026-10-14 07:09:05 +00:00

519 lines
20 KiB
TypeScript

"use client";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useTheme } from "@/lib/theme";
import {
CheckCircleIcon,
ExternalLinkIcon,
EyeIcon,
EyeOffIcon,
MoonIcon,
QuestionIcon,
SunIcon,
XIcon,
} from "@/components/icons";
import { Spinner } from "@/components/ui";
import { LOCALES, useI18n } from "@/lib/i18n";
import { verifyOllamaKey } from "@/lib/client-api";
import { ProvidersSection } from "@/components/ProvidersSection";
import { PROVIDERS } from "@/lib/providers";
import { modelsFor, setGeneration, useProviders } from "@/lib/providers-client";
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();
const providers = useProviders();
// Server-key and local-daemon installs never need a browser key.
const needsKeyMode = health?.authMode !== "local" && health?.authMode !== "server";
// A paid provider is a valid first-run choice too. As soon as a key is saved
// the study generation switches to that provider — Ollama stays untouched —
// and the gate steps aside (the provider's models were listed on save).
const firstEnabled = PROVIDERS.find((provider) => providers.keys[provider.id]);
useEffect(() => {
if (!firstEnabled || providers.generation) return;
const models = modelsFor(providers, firstEnabled.id);
if (models.length === 0) return;
setGeneration({ provider: firstEnabled.id, textModel: models[0], visionModel: models[0] });
}, [firstEnabled, providers]);
const providerReady = Boolean(providers.generation && providers.keys[providers.generation.provider]);
// 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 || providerReady || !needsKeyMode) return <>{children}</>;
return <GateScreen />;
}
/** Ghost icon button used for the two top-right controls. */
function CornerButton({
label,
title,
onClick,
children,
}: {
label: string;
title: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={title}
className="grid size-10 place-items-center rounded-full border border-[var(--field-border)] bg-[var(--gate-card)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
{children}
</button>
);
}
function GateScreen() {
const reduceMotion = useReducedMotion();
const { theme, toggle } = useTheme();
const { locale, setLocale, t } = useI18n();
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 [providersOpen, setProvidersOpen] = useState(false);
const [keyboardOpen, setKeyboardOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const submitRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
// Don't force the on-screen keyboard open on touch devices — let the user
// tap the field when they're ready.
if (window.matchMedia("(pointer: fine)").matches) inputRef.current?.focus();
}, []);
// Keep the field and the Continue button in view while the on-screen keyboard
// is up (the card itself is locked, so this is done by scrolling the wrapper
// programmatically rather than by the user).
const alignKeyboard = useCallback(() => {
const wrap = wrapRef.current;
const submit = submitRef.current;
if (!wrap || !submit) return;
const wrapBox = wrap.getBoundingClientRect();
const btnBox = submit.getBoundingClientRect();
const below = btnBox.bottom + 12 - wrapBox.bottom;
const above = wrapBox.top + 12 - btnBox.top;
if (below > 0) wrap.scrollTop += below;
else if (above > 0) wrap.scrollTop -= above;
}, []);
// Pin the gate to the *visual* viewport so the browser never has to pan the
// page to reveal the focused field. While the on-screen keyboard is up the
// card is locked (not user-scrollable).
useEffect(() => {
const vv = window.visualViewport;
const root = rootRef.current;
if (!vv || !root) return;
const baseline = window.innerHeight;
let frame = 0;
const sync = () => {
if (frame) cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
root.style.height = `${vv.height}px`;
root.style.top = `${vv.offsetTop}px`;
const open = baseline - vv.height > 120;
setKeyboardOpen(open);
if (open) alignKeyboard();
});
};
sync();
vv.addEventListener("resize", sync);
vv.addEventListener("scroll", sync);
return () => {
if (frame) cancelAnimationFrame(frame);
vv.removeEventListener("resize", sync);
vv.removeEventListener("scroll", sync);
};
}, [alignKeyboard]);
// Re-align when the card grows/shrinks (e.g. an error message appears) while
// the keyboard is open.
useEffect(() => {
if (!keyboardOpen) return;
const frame = requestAnimationFrame(alignKeyboard);
return () => cancelAnimationFrame(frame);
}, [keyboardOpen, error, busy, alignKeyboard]);
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
ref={rootRef}
className="fixed inset-0 h-dvh overflow-hidden overscroll-none"
>
{/* corner controls: theme + help ---------------------------------- */}
<div
className="absolute z-20 flex items-center gap-2"
style={{
top: "max(env(safe-area-inset-top), 1.25rem)",
right: "max(env(safe-area-inset-right), 1.25rem)",
}}
>
<CornerButton
label={theme === "dark" ? t("common.switchToLight") : t("common.switchToDark")}
title={theme === "dark" ? t("common.lightMode") : t("common.darkMode")}
onClick={toggle}
>
{theme === "dark" ? <SunIcon className="size-[18px]" /> : <MoonIcon className="size-[18px]" />}
</CornerButton>
<CornerButton
label={t("language.label")}
title={t("language.label")}
onClick={() => {
const index = LOCALES.findIndex((entry) => entry.id === locale);
setLocale(LOCALES[(index + 1) % LOCALES.length].id);
}}
>
<span className="text-[12px] font-bold tracking-wide">
{LOCALES.find((entry) => entry.id === locale)?.short ?? "EN"}
</span>
</CornerButton>
<div className="group relative">
<CornerButton
label={t("gate.howGetAria")}
title={t("gate.howGet")}
onClick={() => setHelpOpen(true)}
>
<QuestionIcon className="size-[18px]" />
</CornerButton>
<span
role="tooltip"
className="pointer-events-none absolute right-0 top-full mt-2 whitespace-nowrap rounded-lg border border-[var(--hairline)] bg-[var(--gate-card)] 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"
>
{t("gate.howGet")}
</span>
</div>
</div>
{/* card ----------------------------------------------------------- */}
<div
ref={wrapRef}
className={cn(
"relative z-10 flex h-full justify-center px-5",
keyboardOpen
? "items-start touch-none overflow-hidden py-3"
: "items-center overflow-y-auto overscroll-contain 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="gate-card my-auto w-full max-w-[420px] p-6 px-7 text-center sm:p-8"
>
{/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */}
<img
src="/icons/icon-192.png"
alt=""
aria-hidden
className="mx-auto size-14 rounded-[16px] shadow-[var(--shadow-card)]"
/>
<h1 className="mt-6 text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[28px]">
{t("gate.title")}
</h1>
<p className="mx-auto mt-1.5 max-w-[300px] text-balance text-[15px] leading-relaxed text-[var(--text-subtle)]">
{t("gate.subtitle")}
</p>
<div className="mt-6 flex items-center justify-center gap-2">
<span className="text-[13px] font-semibold text-[var(--text)]">{t("gate.ollamaLabel")}</span>
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-700 dark:text-emerald-300">
{t("gate.free")}
</span>
</div>
<form className="mt-2.5" onSubmit={submit} noValidate>
<div className="relative">
<label className="sr-only" htmlFor="gate-api-key">
{t("gate.apiKeyLabel")}
</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={t("gate.placeholder")}
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-left 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 ? t("gate.hide") : t("gate.show")}
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"
ref={submitRef}
disabled={!draft.trim() || busy}
className={cn(
"btn-primary mt-3 h-12 w-full rounded-[13px] text-[14.5px] font-medium",
!draft.trim() && "cursor-not-allowed opacity-45",
)}
>
{busy ? (
<>
<Spinner className="size-4" /> {t("gate.checking")}
</>
) : (
t("gate.continue")
)}
</button>
</form>
<div className="mt-4 text-center">
<button
type="button"
onClick={() => setHelpOpen(true)}
className="inline-flex items-center gap-1.5 text-[12.5px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] hover:underline"
>
<QuestionIcon className="size-3.5" />
{t("gate.howGet")}
</button>
</div>
<div className="mt-5 flex items-center gap-3" aria-hidden>
<span className="h-px flex-1 bg-[var(--hairline-soft)]" />
<span className="text-[11px] font-medium uppercase tracking-wide text-[var(--text-subtle)]">{t("gate.or")}</span>
<span className="h-px flex-1 bg-[var(--hairline-soft)]" />
</div>
<button
type="button"
onClick={() => setProvidersOpen((open) => !open)}
aria-expanded={providersOpen}
className="btn-secondary mt-3 h-11 w-full rounded-[13px] text-[13.5px]"
>
{t("gate.paidOption")}
</button>
{providersOpen ? (
<div className="mt-3 space-y-2 text-left">
<p className="text-[11.5px] leading-snug text-[var(--text-subtle)]">{t("gate.paidNote")}</p>
<ProvidersSection />
</div>
) : null}
<p className="mx-auto mt-4 max-w-[320px] text-balance text-[12px] leading-relaxed text-[var(--text-subtle)]">
{t("gate.stays")}
</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();
const { t } = useI18n();
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="gate-card glass-sheet thin-scroll max-h-[90dvh] w-full max-w-[480px] overflow-y-auto p-6 pb-safe sm:pb-6"
>
<div className="flex items-center justify-between gap-4">
<h2
id="gate-help-title"
className="text-[19px] font-semibold leading-snug tracking-[-0.02em] text-[var(--text)]"
>
{t("gate.helpTitle")}
</h2>
<button
type="button"
onClick={onClose}
aria-label={t("common.close")}
className="grid size-9 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>
<div className="mt-4 flex items-start gap-2.5 rounded-xl border border-[var(--gate-callout-border)] bg-[var(--gate-callout-bg)] px-3.5 py-3">
<CheckCircleIcon className="mt-px size-[18px] shrink-0 text-[var(--text-muted)]" />
<p className="text-[12.5px] leading-relaxed text-[var(--text-muted)]">
{t("gate.freeTier.before")}{" "}
<strong className="font-semibold text-[var(--text)]">{t("gate.freeTier.strong")}</strong>
{t("gate.freeTier.after")}
</p>
</div>
<ol className="mt-5 space-y-4">
<Step index={1}>
{t("gate.step1.before")}{" "}
<ExternalLink href="https://ollama.com">ollama.com</ExternalLink>{" "}
{t("gate.step1.after")}
</Step>
<Step index={2}>
{t("gate.step2.before")}{" "}
<ExternalLink href={KEYS_URL}>ollama.com/settings/keys</ExternalLink>
{t("gate.step2.after")}
</Step>
<Step index={3}>
{t("gate.step3.before")}{" "}
<span className="font-medium text-[var(--text-muted)]">{t("gate.step3.strong")}</span>
{t("gate.step3.after")}
</Step>
<Step index={4}>{t("gate.step4")}</Step>
</ol>
<p className="mt-5 text-[12px] leading-relaxed text-[var(--text-subtle)]">
{t("gate.footnote")}
</p>
<button
type="button"
onClick={onClose}
className="btn-primary mt-5 h-11 w-full rounded-[13px] text-[14px] font-medium"
>
{t("gate.gotIt")}
</button>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
);
}
function ExternalLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 font-medium text-[var(--text)] underline underline-offset-2"
>
{children}
<ExternalLinkIcon className="size-3.5 opacity-60" />
</a>
);
}
function Step({ index, children }: { index: number; children: React.ReactNode }) {
return (
<li className="flex items-start gap-3">
<span
aria-hidden
className="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-pretty text-[13px] leading-6 text-[var(--text-muted)]">{children}</span>
</li>
);
}