From cba06f7e23417a9aef6c15ae26715385feb225ba Mon Sep 17 00:00:00 2001
From: yyamashita <yyamashita@hetzner.yyamashita.com>
Date: Fri, 19 Jun 2026 19:45:41 +0900
Subject: Add white theme, post delete, and passkey authentication

- Rewrite CSS to light/white theme
- Add delete button (auth-gated) with confirm dialog
- Add passkey register/login/logout routes via @simplewebauthn
- Gate compose form and delete behind session authentication
- Add credentials table to SQLite schema
- Add SESSION_SECRET and WebAuthn env vars to docker-compose

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
 app/routes/auth.register.tsx | 140 +++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 140 insertions(+)
 create mode 100644 app/routes/auth.register.tsx

(limited to 'app/routes/auth.register.tsx')

diff --git a/app/routes/auth.register.tsx b/app/routes/auth.register.tsx
new file mode 100644
index 0000000..cc4e6a9
--- /dev/null
+++ b/app/routes/auth.register.tsx
@@ -0,0 +1,140 @@
+import { useState } from "react";
+import { Link, redirect } from "react-router";
+import {
+  generateRegistrationOptions,
+  verifyRegistrationResponse,
+  type RegistrationResponseJSON,
+  type AuthenticatorTransportFuture,
+} from "@simplewebauthn/server";
+import type { Route } from "./+types/auth.register";
+import { getSession, commitSession } from "~/lib/session.server";
+import { listCredentials, saveCredential } from "~/lib/db.server";
+import { rpID, rpName, origin } from "~/lib/passkey.server";
+
+export async function loader({ request }: Route.LoaderArgs) {
+  const session = await getSession(request.headers.get("Cookie"));
+  if (session.get("authenticated")) return redirect("/");
+  const hasCredentials = listCredentials().length > 0;
+  return { hasCredentials };
+}
+
+export async function action({ request }: Route.ActionArgs) {
+  const url = new URL(request.url);
+  const intent = url.searchParams.get("intent");
+  const session = await getSession(request.headers.get("Cookie"));
+
+  if (intent === "options") {
+    const options = await generateRegistrationOptions({
+      rpName,
+      rpID,
+      userName: "admin",
+      attestationType: "none",
+      authenticatorSelection: {
+        residentKey: "required",
+        userVerification: "required",
+      },
+    });
+    session.set("challenge", options.challenge);
+    return Response.json(options, {
+      headers: { "Set-Cookie": await commitSession(session) },
+    });
+  }
+
+  if (intent === "verify") {
+    const challenge = session.get("challenge") as string | undefined;
+    if (!challenge) return Response.json({ error: "セッション切れです" }, { status: 400 });
+
+    const body = (await request.json()) as RegistrationResponseJSON;
+    const verification = await verifyRegistrationResponse({
+      response: body,
+      expectedChallenge: challenge,
+      expectedOrigin: origin,
+      expectedRPID: rpID,
+    });
+
+    if (!verification.verified || !verification.registrationInfo) {
+      return Response.json({ error: "登録に失敗しました" }, { status: 400 });
+    }
+
+    const { credential } = verification.registrationInfo;
+    saveCredential({
+      id: credential.id,
+      publicKey: credential.publicKey,
+      counter: credential.counter,
+      transports: (body.response as { transports?: AuthenticatorTransportFuture[] }).transports,
+    });
+
+    session.unset("challenge");
+    session.set("authenticated", true);
+    return redirect("/", {
+      headers: { "Set-Cookie": await commitSession(session) },
+    });
+  }
+
+  return Response.json({ error: "不正なリクエスト" }, { status: 400 });
+}
+
+export default function Register({ loaderData }: Route.ComponentProps) {
+  const { hasCredentials } = loaderData;
+  const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
+  const [errorMsg, setErrorMsg] = useState("");
+
+  async function handleRegister() {
+    setStatus("loading");
+    setErrorMsg("");
+    try {
+      const { startRegistration } = await import("@simplewebauthn/browser");
+
+      const optRes = await fetch("/auth/register?intent=options", { method: "POST" });
+      if (!optRes.ok) throw new Error("オプション取得に失敗しました");
+      const options = await optRes.json();
+
+      const regResponse = await startRegistration({ optionsJSON: options });
+
+      const verRes = await fetch("/auth/register?intent=verify", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(regResponse),
+      });
+      if (!verRes.ok) {
+        const err = await verRes.json();
+        throw new Error(err.error ?? "登録に失敗しました");
+      }
+
+      window.location.href = "/";
+    } catch (e) {
+      setStatus("error");
+      setErrorMsg(e instanceof Error ? e.message : "エラーが発生しました");
+    }
+  }
+
+  return (
+    <div className="wrap">
+      <header className="site-header">
+        <h1>log</h1>
+      </header>
+      <div className="auth-box">
+        <h2>パスキーを登録</h2>
+        {hasCredentials ? (
+          <>
+            <p>すでにパスキーが登録されています。</p>
+            <Link to="/auth/login" className="btn" style={{ textDecoration: "none" }}>
+              ログインへ
+            </Link>
+          </>
+        ) : (
+          <>
+            <p>
+              このデバイスの生体認証（Touch ID / Face ID など）でパスキーを作成します。
+              一度登録すると、次回からはパスキーでログインできます。
+            </p>
+            <button className="btn" onClick={handleRegister} disabled={status === "loading"}>
+              {status === "loading" ? "登録中…" : "パスキーを登録"}
+            </button>
+            {status === "error" && <p className="error-msg" style={{ marginTop: ".75rem" }}>{errorMsg}</p>}
+          </>
+        )}
+      </div>
+    </div>
+  );
+}
-- 
cgit v1.2.3

