From d7a8e8a42b752aeaa22315e96b7e5fb69811fdbc Mon Sep 17 00:00:00 2001
From: yyamashita <yyamashita@hetzner.yyamashita.com>
Date: Sat, 20 Jun 2026 09:51:12 +0900
Subject: Move passkey login inline to home page, remove /auth/login route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
 app/routes/home.tsx | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 83 insertions(+), 4 deletions(-)

(limited to 'app/routes/home.tsx')

diff --git a/app/routes/home.tsx b/app/routes/home.tsx
index 7732326..9bea992 100644
--- a/app/routes/home.tsx
+++ b/app/routes/home.tsx
@@ -1,5 +1,5 @@
-import { useEffect, useRef } from "react";
-import { Form, Link, redirect, useNavigation } from "react-router";
+import { useEffect, useRef, useState } from "react";
+import { Form, redirect, useNavigation } from "react-router";
 import type { Route } from "./+types/home";
 import { createPost, deletePost, listPosts, listCredentials } from "~/lib/db.server";
 import { renderMarkdown } from "~/lib/md.server";
@@ -44,12 +44,84 @@ export async function action({ request }: Route.ActionArgs) {
   });
 }
 
+function bufferToB64url(buf: ArrayBuffer): string {
+  const bytes = new Uint8Array(buf);
+  let binary = "";
+  for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
+  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
+}
+
+function b64urlToBuffer(b64url: string): ArrayBuffer {
+  const base64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
+  const pad = "=".repeat((4 - (base64.length % 4)) % 4);
+  const binary = atob(base64 + pad);
+  const bytes = new Uint8Array(binary.length);
+  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+  return bytes.buffer;
+}
+
 export default function Home({ loaderData, actionData }: Route.ComponentProps) {
   const { posts, isAuthenticated, hasCredentials } = loaderData;
   const navigation = useNavigation();
   const isSubmitting = navigation.state === "submitting";
   const formRef = useRef<HTMLFormElement>(null);
   const wasSubmitting = useRef(false);
+  const [loginStatus, setLoginStatus] = useState<"idle" | "loading" | "error">("idle");
+  const [loginError, setLoginError] = useState("");
+
+  async function handleLogin() {
+    setLoginStatus("loading");
+    setLoginError("");
+    try {
+      const optRes = await fetch("/api/passkey?intent=login-options", { method: "POST" });
+      if (!optRes.ok) throw new Error("オプション取得に失敗しました");
+      const options = await optRes.json();
+
+      const credential = await navigator.credentials.get({
+        publicKey: {
+          challenge: b64urlToBuffer(options.challenge as string),
+          rpId: options.rpId as string,
+          allowCredentials: (options.allowCredentials as Array<{ id: string; transports?: string[] }> ?? []).map((c) => ({
+            id: b64urlToBuffer(c.id),
+            type: "public-key" as const,
+            transports: c.transports as AuthenticatorTransport[] | undefined,
+          })),
+          userVerification: "required" as UserVerificationRequirement,
+          timeout: 60000,
+        },
+      }) as PublicKeyCredential | null;
+
+      if (!credential) throw new Error("認証に失敗しました");
+
+      const assertion = credential.response as AuthenticatorAssertionResponse;
+      const verRes = await fetch("/api/passkey?intent=login-verify", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          id: credential.id,
+          rawId: bufferToB64url(credential.rawId),
+          response: {
+            clientDataJSON: bufferToB64url(assertion.clientDataJSON),
+            authenticatorData: bufferToB64url(assertion.authenticatorData),
+            signature: bufferToB64url(assertion.signature),
+            userHandle: assertion.userHandle ? bufferToB64url(assertion.userHandle) : undefined,
+          },
+          authenticatorAttachment: credential.authenticatorAttachment ?? undefined,
+          clientExtensionResults: credential.getClientExtensionResults(),
+          type: "public-key",
+        }),
+      });
+      if (!verRes.ok) {
+        const err = await verRes.json();
+        throw new Error(err.error ?? "認証に失敗しました");
+      }
+
+      window.location.href = "/";
+    } catch (e) {
+      setLoginStatus("error");
+      setLoginError(e instanceof Error ? e.message : "エラーが発生しました");
+    }
+  }
 
   useEffect(() => {
     if (navigation.state === "submitting") wasSubmitting.current = true;
@@ -69,9 +141,16 @@ export default function Home({ loaderData, actionData }: Route.ComponentProps) {
               <button type="submit" className="logout-btn">ログアウト</button>
             </Form>
           ) : hasCredentials ? (
-            <Link to="/auth/login" className="auth-link">ログイン</Link>
+            <>
+              <button className="auth-link" onClick={handleLogin} disabled={loginStatus === "loading"}>
+                {loginStatus === "loading" ? "認証中…" : "ログイン"}
+              </button>
+              {loginStatus === "error" && (
+                <span className="error-msg" style={{ marginLeft: ".5rem", fontSize: ".85em" }}>{loginError}</span>
+              )}
+            </>
           ) : (
-            <Link to="/auth/register" className="auth-link">登録</Link>
+            <a href="/auth/register" className="auth-link">登録</a>
           )}
         </nav>
       </header>
-- 
cgit v1.2.3

