import { useRef, useState } from "react"; import { Form, redirect, useNavigation, useSearchParams, Link } from "react-router"; import type { Route } from "./+types/home"; import { listTodos, createTodo, toggleTodo, deleteTodo, countActiveTodos, hasAnyCredential, } from "~/lib/db.server"; import { getSession, commitSession } from "~/lib/session.server"; export async function loader({ request }: Route.LoaderArgs) { const session = await getSession(request.headers.get("Cookie")); const userId = session.get("userId") as string | undefined; const username = session.get("username") as string | undefined; const url = new URL(request.url); const filter = url.searchParams.get("filter") ?? "all"; const hasCredentials = hasAnyCredential(); if (!userId) { return { isAuthenticated: false, hasCredentials, todos: [], activeCount: 0, filter, username: null }; } const todos = listTodos(userId, filter); const activeCount = countActiveTodos(userId); return { isAuthenticated: true, hasCredentials, todos, activeCount, filter, username: username ?? null }; } export async function action({ request }: Route.ActionArgs) { const session = await getSession(request.headers.get("Cookie")); const userId = session.get("userId") as string | undefined; if (!userId) throw new Response("Unauthorized", { status: 401 }); const form = await request.formData(); const intent = String(form.get("intent") ?? ""); if (intent === "create") { const title = String(form.get("title") ?? "").trim(); if (!title) return { error: "タイトルを入力してください" }; createTodo(userId, title); return redirect("/", { headers: { "Set-Cookie": await commitSession(session) } }); } if (intent === "toggle") { const id = String(form.get("id") ?? ""); const done = form.get("done") === "1"; if (id) toggleTodo(id, userId, done); return redirect(request.headers.get("Referer") ?? "/", { headers: { "Set-Cookie": await commitSession(session) }, }); } if (intent === "delete") { const id = String(form.get("id") ?? ""); if (id) deleteTodo(id, userId); return redirect(request.headers.get("Referer") ?? "/", { headers: { "Set-Cookie": await commitSession(session) }, }); } return { error: "不正なリクエスト" }; } 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 { isAuthenticated, hasCredentials, todos, activeCount, filter, username } = loaderData; const navigation = useNavigation(); const isSubmitting = navigation.state === "submitting"; const inputRef = useRef(null); const [searchParams] = useSearchParams(); 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() as Record; 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() as { error?: string }; throw new Error(err.error ?? "認証に失敗しました"); } window.location.href = "/"; } catch (e) { setLoginStatus("error"); setLoginError(e instanceof Error ? e.message : "エラーが発生しました"); } } const currentFilter = searchParams.get("filter") ?? "all"; if (!isAuthenticated) { return (

todo

{hasCredentials ? ( <> {loginStatus === "error" &&

{loginError}

} ) : ( はじめる )}
); } return (

todo

{ // Reset input after successful submit setTimeout(() => inputRef.current?.focus(), 50); }} >
{actionData && "error" in actionData &&

{actionData.error}

}
    {todos.length === 0 && (
  • {filter === "done" ? "完了したタスクはありません" : filter === "active" ? "未完了のタスクはありません" : "タスクがありません"}
  • )} {todos.map((todo) => (
  • {todo.title}
  • ))}
{todos.length > 0 && ( )}
); }