diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-08-22 19:21:12 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-08-22 19:21:12 +0900 |
| commit | 7c80f5ce7e44b95d16e19de40de0f8079377c6b9 (patch) | |
| tree | ad189b750361af8c7cb040b56f42df5d4e5a89ee /app | |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'app')
| -rw-r--r-- | app/app.css | 258 | ||||
| -rw-r--r-- | app/lib/db.server.ts | 160 | ||||
| -rw-r--r-- | app/lib/passkey.server.ts | 11 | ||||
| -rw-r--r-- | app/lib/session.server.ts | 12 | ||||
| -rw-r--r-- | app/root.tsx | 58 | ||||
| -rw-r--r-- | app/routes.ts | 8 | ||||
| -rw-r--r-- | app/routes/api.passkey.tsx | 163 | ||||
| -rw-r--r-- | app/routes/auth.logout.tsx | 18 | ||||
| -rw-r--r-- | app/routes/auth.register.tsx | 138 | ||||
| -rw-r--r-- | app/routes/home.tsx | 252 |
10 files changed, 1078 insertions, 0 deletions
diff --git a/app/app.css b/app/app.css new file mode 100644 index 0000000..52b88a8 --- /dev/null +++ b/app/app.css @@ -0,0 +1,258 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { + font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: #111827; + color: #f9fafb; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +/* ── Layout ── */ + +.wrap { max-width: 560px; margin: 0 auto; padding: 2.5rem 1.25rem 5rem; } + +/* ── Header ── */ + +.site-header { + margin-bottom: 2rem; + display: flex; + align-items: center; + justify-content: space-between; +} +.site-header h1 { + font-size: .8125rem; + font-weight: 500; + color: #6c63ff; + letter-spacing: .18em; + text-transform: uppercase; + font-family: ui-monospace, monospace; +} +.header-nav { display: flex; gap: 1rem; align-items: center; } +.username { + font-size: .75rem; + color: #6b7280; + font-family: ui-monospace, monospace; +} +.logout-btn { + font-size: .75rem; + font-family: ui-monospace, monospace; + color: #4b5563; + background: none; + border: none; + cursor: pointer; + padding: 0; +} +.logout-btn:hover { color: #9ca3af; } + +/* ── Login box ── */ + +.login-box { + margin-top: 4rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.btn-login { + display: inline-flex; + align-items: center; + gap: .5rem; + background: #6c63ff; + color: #fff; + border: none; + border-radius: 8px; + padding: .75rem 1.75rem; + font-size: .9375rem; + font-family: inherit; + font-weight: 500; + cursor: pointer; + text-decoration: none; + transition: background .15s; +} +.btn-login:hover { background: #5a52e0; } +.btn-login:disabled { opacity: .5; cursor: not-allowed; } + +/* ── Compose ── */ + +.compose { margin-bottom: 1.25rem; } +.compose-row { + display: flex; + gap: .5rem; +} +.compose-row input[type="text"] { + flex: 1; + background: #1f2937; + border: 1px solid #374151; + color: #f9fafb; + border-radius: 8px; + padding: .625rem .875rem; + font-size: .9375rem; + font-family: inherit; + outline: none; + transition: border-color .15s; +} +.compose-row input[type="text"]:focus { border-color: #6c63ff; } +.compose-row input[type="text"]::placeholder { color: #4b5563; } +.compose-row button[type="submit"] { + background: #6c63ff; + color: #fff; + border: none; + border-radius: 8px; + padding: .625rem 1.125rem; + font-size: .875rem; + font-family: inherit; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: background .15s; +} +.compose-row button[type="submit"]:hover { background: #5a52e0; } +.compose-row button[type="submit"]:disabled { opacity: .5; cursor: not-allowed; } + +/* ── Filters ── */ + +.filters { + display: flex; + gap: .25rem; + margin-bottom: 1rem; + border-bottom: 1px solid #1f2937; + padding-bottom: .5rem; +} +.filters a { + font-size: .8125rem; + color: #6b7280; + text-decoration: none; + padding: .25rem .625rem; + border-radius: 4px; + transition: color .15s; +} +.filters a:hover { color: #d1d5db; } +.filters a.active { + color: #6c63ff; + background: rgba(108, 99, 255, .12); +} + +/* ── Todo list ── */ + +.todo-list { + list-style: none; + display: flex; + flex-direction: column; + gap: .125rem; +} + +.todo-item { + display: flex; + align-items: center; + gap: .75rem; + padding: .75rem .5rem; + border-radius: 6px; + transition: background .1s; +} +.todo-item:hover { background: #1f2937; } + +.toggle-form, .delete-form { display: contents; } + +.checkbox { + flex-shrink: 0; + background: none; + border: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + color: inherit; +} + +.todo-title { + flex: 1; + font-size: .9375rem; + color: #e5e7eb; + word-break: break-word; +} +.todo-item.done .todo-title { + color: #4b5563; + text-decoration: line-through; +} + +.delete-btn { + flex-shrink: 0; + background: none; + border: none; + color: #374151; + cursor: pointer; + font-size: 1.125rem; + padding: 0 .25rem; + line-height: 1; + opacity: 0; + transition: color .15s, opacity .15s; +} +.todo-item:hover .delete-btn { opacity: 1; } +.delete-btn:hover { color: #f87171; } + +/* ── Footer ── */ + +.todo-footer { + margin-top: 1rem; + font-size: .75rem; + color: #4b5563; + font-family: ui-monospace, monospace; + padding-left: .5rem; +} + +/* ── Empty state ── */ + +.empty { + color: #374151; + font-size: .875rem; + padding: 2.5rem 0; + text-align: center; + list-style: none; +} + +/* ── Error ── */ + +.error-msg { + font-size: .8125rem; + color: #f87171; + margin-top: .5rem; +} + +/* ── Auth pages ── */ + +.auth-box { max-width: 360px; } +.auth-box h2 { font-size: 1.125rem; font-weight: 600; margin-bottom: .5rem; } +.auth-box p { font-size: .875rem; color: #6b7280; margin-bottom: 1.5rem; line-height: 1.6; } + +.field-group { display: flex; flex-direction: column; gap: .5rem; margin-bottom: 1rem; } +.field-group input { + background: #1f2937; + border: 1px solid #374151; + color: #f9fafb; + border-radius: 8px; + padding: .625rem .875rem; + font-size: .9375rem; + font-family: inherit; + outline: none; + transition: border-color .15s; +} +.field-group input:focus { border-color: #6c63ff; } +.field-group input::placeholder { color: #4b5563; } + +.btn { + display: inline-block; + background: #6c63ff; + color: #fff; + border: none; + border-radius: 8px; + padding: .625rem 1.375rem; + font-size: .9375rem; + font-family: inherit; + font-weight: 500; + cursor: pointer; + transition: background .15s; +} +.btn:hover { background: #5a52e0; } +.btn:disabled { opacity: .5; cursor: not-allowed; } diff --git a/app/lib/db.server.ts b/app/lib/db.server.ts new file mode 100644 index 0000000..7c31a18 --- /dev/null +++ b/app/lib/db.server.ts @@ -0,0 +1,160 @@ +import Database from "better-sqlite3"; +import path from "path"; + +let db: Database.Database | null = null; + +export function getDb(): Database.Database { + if (!db) { + const dbPath = process.env.DB_PATH ?? path.resolve("todo.db"); + db = new Database(dbPath); + db.pragma("journal_mode = WAL"); + db.pragma("foreign_keys = ON"); + initSchema(db); + } + return db; +} + +function initSchema(db: Database.Database) { + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS credentials ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + public_key TEXT NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + transports TEXT + ); + + CREATE TABLE IF NOT EXISTS todos ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + title TEXT NOT NULL, + done INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_todos_user ON todos(user_id, created_at DESC); + `); +} + +/* ── Users ── */ + +export interface User { + id: string; + username: string; + created_at: string; +} + +export function getUserById(id: string): User | null { + return getDb().prepare("SELECT * FROM users WHERE id = ?").get(id) as User | null; +} + +export function getUserByUsername(username: string): User | null { + return getDb().prepare("SELECT * FROM users WHERE username = ?").get(username) as User | null; +} + +export function upsertUser(id: string, username: string): User { + getDb() + .prepare("INSERT OR IGNORE INTO users (id, username) VALUES (?, ?)") + .run(id, username); + return getUserById(id) ?? getUserByUsername(username)!; +} + +/* ── Credentials ── */ + +export interface Credential { + id: string; + user_id: string; + public_key: string; + counter: number; + transports: string | null; +} + +export function hasAnyCredential(): boolean { + return (getDb().prepare("SELECT 1 FROM credentials LIMIT 1").get() as unknown) != null; +} + +export function listCredentials(): Credential[] { + return getDb().prepare("SELECT * FROM credentials").all() as Credential[]; +} + +export function getCredentialById(id: string): Credential | null { + return getDb().prepare("SELECT * FROM credentials WHERE id = ?").get(id) as Credential | null; +} + +export function saveCredential(input: { + id: string; + userId: string; + publicKey: Uint8Array; + counter: number; + transports?: string[]; +}): void { + getDb() + .prepare( + "INSERT INTO credentials (id, user_id, public_key, counter, transports) VALUES (?, ?, ?, ?, ?)" + ) + .run( + input.id, + input.userId, + Buffer.from(input.publicKey).toString("base64url"), + input.counter, + input.transports ? JSON.stringify(input.transports) : null + ); +} + +export function updateCredentialCounter(id: string, counter: number): void { + getDb().prepare("UPDATE credentials SET counter = ? WHERE id = ?").run(counter, id); +} + +/* ── Todos ── */ + +export interface Todo { + id: string; + user_id: string; + title: string; + done: number; + created_at: string; +} + +export function listTodos(userId: string, filter?: string): Todo[] { + if (filter === "active") { + return getDb() + .prepare("SELECT * FROM todos WHERE user_id = ? AND done = 0 ORDER BY created_at DESC") + .all(userId) as Todo[]; + } + if (filter === "done") { + return getDb() + .prepare("SELECT * FROM todos WHERE user_id = ? AND done = 1 ORDER BY created_at DESC") + .all(userId) as Todo[]; + } + return getDb() + .prepare("SELECT * FROM todos WHERE user_id = ? ORDER BY created_at DESC") + .all(userId) as Todo[]; +} + +export function createTodo(userId: string, title: string): void { + getDb() + .prepare("INSERT INTO todos (id, user_id, title) VALUES (?, ?, ?)") + .run(crypto.randomUUID(), userId, title); +} + +export function toggleTodo(id: string, userId: string, done: boolean): void { + getDb() + .prepare("UPDATE todos SET done = ? WHERE id = ? AND user_id = ?") + .run(done ? 1 : 0, id, userId); +} + +export function deleteTodo(id: string, userId: string): void { + getDb().prepare("DELETE FROM todos WHERE id = ? AND user_id = ?").run(id, userId); +} + +export function countActiveTodos(userId: string): number { + const row = getDb() + .prepare("SELECT COUNT(*) as n FROM todos WHERE user_id = ? AND done = 0") + .get(userId) as { n: number }; + return row.n; +} diff --git a/app/lib/passkey.server.ts b/app/lib/passkey.server.ts new file mode 100644 index 0000000..94b9835 --- /dev/null +++ b/app/lib/passkey.server.ts @@ -0,0 +1,11 @@ +export const rpID = + process.env.WEBAUTHN_RP_ID ?? + (process.env.NODE_ENV === "production" ? "todo.yyamashita.com" : "localhost"); + +export const origin = + process.env.WEBAUTHN_ORIGIN ?? + (process.env.NODE_ENV === "production" + ? "https://todo.yyamashita.com" + : "http://localhost:5173"); + +export const rpName = "Todo"; diff --git a/app/lib/session.server.ts b/app/lib/session.server.ts new file mode 100644 index 0000000..3e5a869 --- /dev/null +++ b/app/lib/session.server.ts @@ -0,0 +1,12 @@ +import { createCookieSessionStorage } from "react-router"; + +export const { getSession, commitSession, destroySession } = createCookieSessionStorage({ + cookie: { + name: "__todo_session", + secrets: [process.env.SESSION_SECRET ?? "dev-secret-change-in-production"], + secure: process.env.NODE_ENV === "production", + httpOnly: true, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 30, + }, +}); diff --git a/app/root.tsx b/app/root.tsx new file mode 100644 index 0000000..e16a2c4 --- /dev/null +++ b/app/root.tsx @@ -0,0 +1,58 @@ +import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; +import type { Route } from "./+types/root"; +import "./app.css"; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + <html lang="ja"> + <head> + <meta charSet="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta name="theme-color" content="#6c63ff" /> + <meta name="mobile-web-app-capable" content="yes" /> + <meta name="apple-mobile-web-app-capable" content="yes" /> + <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> + <meta name="apple-mobile-web-app-title" content="Todo" /> + <title>Todo</title> + <link rel="manifest" href="/manifest.json" /> + <link rel="apple-touch-icon" href="/icons/icon-192.png" /> + <Meta /> + <Links /> + </head> + <body> + {children} + <ScrollRestoration /> + <Scripts /> + <script + dangerouslySetInnerHTML={{ + __html: `if('serviceWorker' in navigator){window.addEventListener('load',()=>{navigator.serviceWorker.register('/sw.js');})}`, + }} + /> + </body> + </html> + ); +} + +export default function App() { + return <Outlet />; +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = "Error"; + let details = "An unexpected error occurred."; + + if (isRouteErrorResponse(error)) { + message = String(error.status); + details = error.statusText || details; + } else if (import.meta.env.DEV && error instanceof Error) { + details = error.message; + } + + return ( + <div className="wrap"> + <p style={{ color: "#f87171", fontFamily: "monospace" }}> + {message}: {details} + </p> + </div> + ); +} diff --git a/app/routes.ts b/app/routes.ts new file mode 100644 index 0000000..24d9c49 --- /dev/null +++ b/app/routes.ts @@ -0,0 +1,8 @@ +import { type RouteConfig, index, route } from "@react-router/dev/routes"; + +export default [ + index("routes/home.tsx"), + route("/api/passkey", "routes/api.passkey.tsx"), + route("/auth/register", "routes/auth.register.tsx"), + route("/auth/logout", "routes/auth.logout.tsx"), +] satisfies RouteConfig; diff --git a/app/routes/api.passkey.tsx b/app/routes/api.passkey.tsx new file mode 100644 index 0000000..1ab0537 --- /dev/null +++ b/app/routes/api.passkey.tsx @@ -0,0 +1,163 @@ +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, + type RegistrationResponseJSON, + type AuthenticationResponseJSON, + type AuthenticatorTransportFuture, +} from "@simplewebauthn/server"; +import type { Route } from "./+types/api.passkey"; +import { getSession, commitSession } from "~/lib/session.server"; +import { + upsertUser, + getUserById, + getCredentialById, + listCredentials, + saveCredential, + updateCredentialCounter, +} from "~/lib/db.server"; +import { rpID, rpName, origin } from "~/lib/passkey.server"; + +function uuidToBytes(uuid: string): Uint8Array { + const hex = uuid.replace(/-/g, ""); + const bytes = new Uint8Array(16); + for (let i = 0; i < 16; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + return bytes; +} + +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 === "reg-options") { + const body = (await request.json()) as { username?: string; token?: string }; + const registerToken = process.env.REGISTER_TOKEN; + if (!registerToken || body.token !== registerToken) { + return Response.json({ error: "トークンが違います" }, { status: 403 }); + } + const username = (body.username ?? "").trim(); + if (!username) return Response.json({ error: "ユーザー名を入力してください" }, { status: 400 }); + + const userId = crypto.randomUUID(); + const user = upsertUser(userId, username); + + const options = await generateRegistrationOptions({ + rpName, + rpID, + userID: uuidToBytes(user.id) as Uint8Array<ArrayBuffer>, + userName: user.username, + userDisplayName: user.username, + attestationType: "none", + authenticatorSelection: { residentKey: "required", userVerification: "required" }, + excludeCredentials: listCredentials() + .filter((c) => c.user_id === user.id) + .map((c) => ({ id: c.id })), + }); + + session.set("challenge", options.challenge); + session.set("pendingUserId", user.id); + return Response.json(options, { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } + + if (intent === "reg-verify") { + const challenge = session.get("challenge") as string | undefined; + const pendingUserId = session.get("pendingUserId") as string | undefined; + if (!challenge || !pendingUserId) { + return Response.json({ error: "セッション切れです" }, { status: 400 }); + } + const body = (await request.json()) as RegistrationResponseJSON; + let verification; + try { + verification = await verifyRegistrationResponse({ + response: body, + expectedChallenge: challenge, + expectedOrigin: origin, + expectedRPID: rpID, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return Response.json({ error: msg }, { status: 400 }); + } + if (!verification.verified || !verification.registrationInfo) { + return Response.json({ error: "登録に失敗しました" }, { status: 400 }); + } + + const { credential } = verification.registrationInfo; + saveCredential({ + id: credential.id, + userId: pendingUserId, + publicKey: credential.publicKey, + counter: credential.counter, + transports: (body.response as { transports?: AuthenticatorTransportFuture[] }).transports, + }); + + const user = getUserById(pendingUserId)!; + session.unset("challenge"); + session.unset("pendingUserId"); + session.set("userId", user.id); + session.set("username", user.username); + return Response.json({ ok: true }, { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } + + if (intent === "login-options") { + const credentials = listCredentials(); + const options = await generateAuthenticationOptions({ + rpID, + allowCredentials: credentials.map((c) => ({ + id: c.id, + transports: c.transports + ? (JSON.parse(c.transports) as AuthenticatorTransportFuture[]) + : undefined, + })), + userVerification: "required", + }); + session.set("challenge", options.challenge); + return Response.json(options, { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } + + if (intent === "login-verify") { + const challenge = session.get("challenge") as string | undefined; + if (!challenge) return Response.json({ error: "セッション切れです" }, { status: 400 }); + + const body = (await request.json()) as AuthenticationResponseJSON; + const cred = getCredentialById(body.id); + if (!cred) return Response.json({ error: "パスキーが見つかりません" }, { status: 400 }); + + const verification = await verifyAuthenticationResponse({ + response: body, + expectedChallenge: challenge, + expectedOrigin: origin, + expectedRPID: rpID, + credential: { + id: cred.id, + publicKey: new Uint8Array(Buffer.from(cred.public_key, "base64url")), + counter: cred.counter, + transports: cred.transports + ? (JSON.parse(cred.transports) as AuthenticatorTransportFuture[]) + : undefined, + }, + }); + if (!verification.verified) { + return Response.json({ error: "認証に失敗しました" }, { status: 400 }); + } + updateCredentialCounter(cred.id, verification.authenticationInfo.newCounter); + + const user = getUserById(cred.user_id)!; + session.unset("challenge"); + session.set("userId", user.id); + session.set("username", user.username); + return Response.json({ ok: true }, { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } + + return Response.json({ error: "不正なリクエスト" }, { status: 400 }); +} diff --git a/app/routes/auth.logout.tsx b/app/routes/auth.logout.tsx new file mode 100644 index 0000000..3b60231 --- /dev/null +++ b/app/routes/auth.logout.tsx @@ -0,0 +1,18 @@ +import { redirect } from "react-router"; +import type { Route } from "./+types/auth.logout"; +import { getSession, destroySession } from "~/lib/session.server"; + +export async function loader() { + return redirect("/"); +} + +export async function action({ request }: Route.ActionArgs) { + const session = await getSession(request.headers.get("Cookie")); + return redirect("/", { + headers: { "Set-Cookie": await destroySession(session) }, + }); +} + +export default function Logout() { + return null; +} diff --git a/app/routes/auth.register.tsx b/app/routes/auth.register.tsx new file mode 100644 index 0000000..de254a8 --- /dev/null +++ b/app/routes/auth.register.tsx @@ -0,0 +1,138 @@ +import { useState } from "react"; +import { redirect } from "react-router"; +import type { Route } from "./+types/auth.register"; +import { getSession } from "~/lib/session.server"; +import type { RegistrationResponseJSON, AuthenticatorTransportFuture } from "@simplewebauthn/server"; + +export async function loader({ request }: Route.LoaderArgs) { + const session = await getSession(request.headers.get("Cookie")); + if (session.get("userId")) return redirect("/"); + return {}; +} + +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; +} + +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, ""); +} + +export default function Register() { + const [username, setUsername] = useState(""); + const [token, setToken] = useState(""); + const [status, setStatus] = useState<"idle" | "loading" | "error">("idle"); + const [errorMsg, setErrorMsg] = useState(""); + + async function handleRegister() { + setStatus("loading"); + setErrorMsg(""); + try { + const optRes = await fetch("/api/passkey?intent=reg-options", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, token }), + }); + if (!optRes.ok) { + const err = await optRes.json() as { error?: string }; + throw new Error(err.error ?? "オプション取得に失敗しました"); + } + const optJSON = await optRes.json() as Record<string, unknown>; + + const userObj = optJSON.user as Record<string, string>; + const challenge = b64urlToBuffer(optJSON.challenge as string); + + const credential = await navigator.credentials.create({ + publicKey: { + rp: optJSON.rp as PublicKeyCredentialRpEntity, + user: { + id: b64urlToBuffer(userObj.id), + name: userObj.name, + displayName: userObj.displayName ?? userObj.name, + }, + challenge, + pubKeyCredParams: optJSON.pubKeyCredParams as PublicKeyCredentialParameters[], + authenticatorSelection: optJSON.authenticatorSelection as AuthenticatorSelectionCriteria, + timeout: 60000, + }, + }) as PublicKeyCredential | null; + + if (!credential) throw new Error("クレデンシャルの作成に失敗しました"); + + const attestation = credential.response as AuthenticatorAttestationResponse; + const regResponse: RegistrationResponseJSON = { + id: credential.id, + rawId: bufferToB64url(credential.rawId), + response: { + clientDataJSON: bufferToB64url(attestation.clientDataJSON), + attestationObject: bufferToB64url(attestation.attestationObject), + transports: attestation.getTransports + ? (attestation.getTransports() as AuthenticatorTransportFuture[]) + : [], + }, + authenticatorAttachment: (credential.authenticatorAttachment ?? undefined) as import("@simplewebauthn/server").AuthenticatorAttachment | undefined, + clientExtensionResults: credential.getClientExtensionResults(), + type: "public-key", + }; + + const verRes = await fetch("/api/passkey?intent=reg-verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(regResponse), + }); + if (!verRes.ok) { + const err = await verRes.json() as { error?: string }; + throw new Error(err.error ?? "登録に失敗しました"); + } + + window.location.href = "/"; + } catch (e) { + setStatus("error"); + setErrorMsg(e instanceof Error ? `${e.name}: ${e.message}` : String(e)); + } + } + + return ( + <div className="wrap"> + <header className="site-header"> + <h1>todo</h1> + </header> + <div className="auth-box"> + <h2>アカウント登録</h2> + <p>ユーザー名と登録トークンを入力してパスキーを作成します。</p> + <div className="field-group"> + <input + type="text" + placeholder="ユーザー名" + value={username} + onChange={(e) => setUsername(e.target.value)} + autoFocus + /> + <input + type="password" + placeholder="登録トークン" + value={token} + onChange={(e) => setToken(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleRegister()} + /> + </div> + <button + className="btn" + onClick={handleRegister} + disabled={status === "loading" || !username || !token} + > + {status === "loading" ? "登録中…" : "パスキーを登録"} + </button> + {status === "error" && <p className="error-msg">{errorMsg}</p>} + </div> + </div> + ); +} diff --git a/app/routes/home.tsx b/app/routes/home.tsx new file mode 100644 index 0000000..fd3c534 --- /dev/null +++ b/app/routes/home.tsx @@ -0,0 +1,252 @@ +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<HTMLInputElement>(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<string, unknown>; + + 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 ( + <div className="wrap"> + <header className="site-header"> + <h1>todo</h1> + </header> + <div className="login-box"> + {hasCredentials ? ( + <> + <button className="btn-login" onClick={handleLogin} disabled={loginStatus === "loading"}> + {loginStatus === "loading" ? "認証中…" : "パスキーでログイン"} + </button> + {loginStatus === "error" && <p className="error-msg">{loginError}</p>} + </> + ) : ( + <Link to="/auth/register" className="btn-login">はじめる</Link> + )} + </div> + </div> + ); + } + + return ( + <div className="wrap"> + <header className="site-header"> + <h1>todo</h1> + <nav className="header-nav"> + <span className="username">{username}</span> + <Form method="post" action="/auth/logout"> + <button type="submit" className="logout-btn">ログアウト</button> + </Form> + </nav> + </header> + + <div className="compose"> + <Form + method="post" + onSubmit={() => { + // Reset input after successful submit + setTimeout(() => inputRef.current?.focus(), 50); + }} + > + <input type="hidden" name="intent" value="create" /> + <div className="compose-row"> + <input + ref={inputRef} + type="text" + name="title" + placeholder="新しいタスクを追加…" + autoComplete="off" + autoFocus + /> + <button type="submit" disabled={isSubmitting}>追加</button> + </div> + </Form> + {actionData && "error" in actionData && <p className="error-msg">{actionData.error}</p>} + </div> + + <nav className="filters"> + <Link to="/" className={currentFilter === "all" ? "active" : ""}>すべて</Link> + <Link to="/?filter=active" className={currentFilter === "active" ? "active" : ""}>未完了</Link> + <Link to="/?filter=done" className={currentFilter === "done" ? "active" : ""}>完了</Link> + </nav> + + <ul className="todo-list"> + {todos.length === 0 && ( + <li className="empty"> + {filter === "done" ? "完了したタスクはありません" : filter === "active" ? "未完了のタスクはありません" : "タスクがありません"} + </li> + )} + {todos.map((todo) => ( + <li key={todo.id} className={`todo-item ${todo.done ? "done" : ""}`}> + <Form method="post" className="toggle-form"> + <input type="hidden" name="intent" value="toggle" /> + <input type="hidden" name="id" value={todo.id} /> + <input type="hidden" name="done" value={todo.done ? "0" : "1"} /> + <button type="submit" className="checkbox" aria-label={todo.done ? "未完了に戻す" : "完了にする"}> + {todo.done ? ( + <svg width="16" height="16" viewBox="0 0 16 16" fill="none"> + <rect width="16" height="16" rx="3" fill="#6c63ff" /> + <path d="M3.5 8L6.5 11L12.5 5" stroke="white" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /> + </svg> + ) : ( + <svg width="16" height="16" viewBox="0 0 16 16" fill="none"> + <rect x="0.5" y="0.5" width="15" height="15" rx="2.5" stroke="#4b5563" /> + </svg> + )} + </button> + </Form> + <span className="todo-title">{todo.title}</span> + <Form method="post" className="delete-form"> + <input type="hidden" name="intent" value="delete" /> + <input type="hidden" name="id" value={todo.id} /> + <button type="submit" className="delete-btn" aria-label="削除">×</button> + </Form> + </li> + ))} + </ul> + + {todos.length > 0 && ( + <footer className="todo-footer"> + <span>{activeCount} 件未完了</span> + </footer> + )} + </div> + ); +} |
