summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/app.css258
-rw-r--r--app/lib/db.server.ts160
-rw-r--r--app/lib/passkey.server.ts11
-rw-r--r--app/lib/session.server.ts12
-rw-r--r--app/root.tsx58
-rw-r--r--app/routes.ts8
-rw-r--r--app/routes/api.passkey.tsx163
-rw-r--r--app/routes/auth.logout.tsx18
-rw-r--r--app/routes/auth.register.tsx138
-rw-r--r--app/routes/home.tsx252
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>
+ );
+}