summaryrefslogtreecommitdiff
path: root/app/routes/home.tsx
diff options
context:
space:
mode:
authoryyamashita <yyamashita@hetzner.yyamashita.com>2026-06-20 09:51:12 +0900
committeryyamashita <yyamashita@hetzner.yyamashita.com>2026-06-20 09:51:12 +0900
commitd7a8e8a42b752aeaa22315e96b7e5fb69811fdbc (patch)
tree4ef9d41b1db3e8bf21ced831952e7cd29599c7c6 /app/routes/home.tsx
parent077fdc0b950deb018a0470894c700cc4ab83557a (diff)
Move passkey login inline to home page, remove /auth/login routeHEADmaster
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'app/routes/home.tsx')
-rw-r--r--app/routes/home.tsx87
1 files changed, 83 insertions, 4 deletions
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>