diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-09-26 00:19:53 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-09-26 00:19:53 +0900 |
| commit | 874749df46cc291ca61f2f98092ccabfdeffbf16 (patch) | |
| tree | e4cb53b5bfa23974e7935eb2616e0e005954f782 /app/routes/login.tsx | |
| parent | 999b7a3133fccc3f120ac5ae24c02bb38a4ecce9 (diff) | |
Single-user auth: any registered passkey logs in as the owner. First
registration needs a one-time token that the app writes to
data/registration-token when no passkey exists yet; once logged in,
more passkeys can be added without a token.
Logged in, /favorites manages a list of terms (e.g. band names).
Events whose title or artist contains a term (NFKC + case-insensitive)
get a ★, and the event list gains an "お気に入りのみ" filter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'app/routes/login.tsx')
| -rw-r--r-- | app/routes/login.tsx | 117 |
1 files changed, 117 insertions, 0 deletions
diff --git a/app/routes/login.tsx b/app/routes/login.tsx new file mode 100644 index 0000000..69353ea --- /dev/null +++ b/app/routes/login.tsx @@ -0,0 +1,117 @@ +import { useState } from "react"; +import { Link, useLoaderData, useNavigate } from "react-router"; +import { startAuthentication, startRegistration } from "@simplewebauthn/browser"; +import type { Route } from "./+types/login"; +import { ensureSetupToken, isLoggedIn } from "~/lib/auth.server"; + +export async function loader({ request }: Route.LoaderArgs) { + ensureSetupToken(); + const url = new URL(request.url); + const next = url.searchParams.get("next"); + return { + loggedIn: isLoggedIn(request), + token: url.searchParams.get("token"), + // Only allow same-site relative redirects + next: next && /^\/(?![/\\])/.test(next) ? next : "/favorites", + }; +} + +async function postJson(path: string, body: unknown) { + const res = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok || data.ok === false) throw new Error(data.error ?? `HTTP ${res.status}`); + return data; +} + +export default function Login() { + const { loggedIn, token, next } = useLoaderData<typeof loader>(); + const navigate = useNavigate(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + const [label, setLabel] = useState(""); + const canRegister = loggedIn || !!token; + + async function run(fn: () => Promise<void>) { + setBusy(true); + setError(null); + try { + await fn(); + navigate(next); + } catch (e) { + const err = e as Error; + setError(err.name === "NotAllowedError" ? "キャンセルされました" : err.message); + } finally { + setBusy(false); + } + } + + const login = () => + run(async () => { + const optionsJSON = await postJson("/api/auth/login-options", {}); + const response = await startAuthentication({ optionsJSON }); + await postJson("/api/auth/login-verify", { response }); + }); + + const register = () => + run(async () => { + const optionsJSON = await postJson("/api/auth/register-options", { token }); + const response = await startRegistration({ optionsJSON }); + await postJson("/api/auth/register-verify", { token, response, label: label || null }); + }); + + return ( + <div className="min-h-screen bg-gray-950 text-gray-100 flex items-center justify-center px-4"> + <div className="w-full max-w-sm rounded-xl border border-gray-800 bg-gray-900 p-6 flex flex-col gap-5"> + <Link to="/events" className="text-lg font-bold tracking-tight text-white"> + 🎸 ライブに行くしかない + </Link> + + {!loggedIn && ( + <button + onClick={login} + disabled={busy} + className="rounded-md bg-indigo-600 px-4 py-2.5 text-sm font-medium hover:bg-indigo-500 disabled:opacity-50 transition-colors" + > + 🔑 パスキーでログイン + </button> + )} + + {loggedIn && !token && ( + <p className="text-sm text-gray-300"> + ログイン済みです。{" "} + <Link to="/favorites" className="text-indigo-400 hover:text-indigo-300 underline"> + お気に入りへ + </Link> + </p> + )} + + {canRegister && ( + <div className="flex flex-col gap-2 border-t border-gray-800 pt-5"> + <p className="text-xs text-gray-400"> + {loggedIn ? "このデバイスのパスキーを追加登録" : "パスキーを新規登録"} + </p> + <input + value={label} + onChange={(e) => setLabel(e.target.value)} + placeholder="デバイス名(例: iPhone)" + className="rounded-md bg-gray-800 border border-gray-700 px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500" + /> + <button + onClick={register} + disabled={busy} + className="rounded-md bg-gray-700 px-4 py-2 text-sm font-medium hover:bg-gray-600 disabled:opacity-50 transition-colors" + > + パスキーを登録 + </button> + </div> + )} + + {error && <p className="text-sm text-red-400">{error}</p>} + </div> + </div> + ); +} |
