freezmo/components/ApiKeyGate.tsx
Martin Dvorak 84a94581dc Key fields: keep the browser's password UI away, fix the keyboard layout
"Trying to use a different provider on the web app glitches out completely" -
an iPhone photo of the first-run gate with iOS's saved-password sheet laid
over it ("password for proxmox...") and the provider field stuck under the
keyboard.

- The key fields (gate, Settings, provider rows) were type=password with
  autocomplete=off, which iOS/Safari and 1Password/LastPass read as a login.
  They now carry autoComplete="new-password", data-form-type="other",
  data-lpignore="true", autoCorrect/autoCapitalize=off and inputMode=text:
  the mask stays, the password overlay goes. The custom base URL gets
  inputMode=url and the same hints.
- The gate's keyboard handling scrolled to the Continue button instead of the
  field being edited, so with the provider rows open the keyboard covered the
  input and the locked wrapper could not be scrolled to it. The wrapper is
  user-scrollable again, alignment anchors on the focused element (Continue
  is the fallback), and the corner controls hide while the keyboard is up
  rather than landing on the card.

providers-test drives a fake visualViewport (340px, installed before mount)
and pins the three layout contracts plus the hardening attributes. All 16
suites green, contrast clean. Version 0.7.2, SW v94.
2026-10-16 10:12:20 +00:00

532 lines
20 KiB
TypeScript

"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
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 what the listener is actually editing in view while the on-screen
// keyboard is up: the focused field when there is one (the provider rows sit
// far below the Continue button), the button otherwise. The wrapper stays
// user-scrollable underneath, this just does the first move.
const alignKeyboard = useCallback(() => {
const wrap = wrapRef.current;
if (!wrap) return;
const active = document.activeElement;
const focused =
active instanceof HTMLElement && active !== wrap && wrap.contains(active) ? active : null;
const target = focused ?? submitRef.current;
if (!target) return;
const wrapBox = wrap.getBoundingClientRect();
const box = target.getBoundingClientRect();
const below = box.bottom + 12 - wrapBox.bottom;
const above = wrapBox.top + 12 - box.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={cn("absolute z-20 flex items-center gap-2", keyboardOpen && "hidden")}
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 overflow-y-auto overscroll-contain px-5",
keyboardOpen ? "items-start py-3" : "items-center 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")}
/* An API key is not a login: "new-password" is what stops
iOS/Safari from raising its saved-password sheet over the
gate (it offered the user's Proxmox login here once). The
data-* hints do the same for 1Password/LastPass. */
autoComplete="new-password"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
inputMode="text"
enterKeyHint="go"
data-form-type="other"
data-lpignore="true"
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="scene-pause 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>
);
}