From 7c80f5ce7e44b95d16e19de40de0f8079377c6b9 Mon Sep 17 00:00:00 2001 From: yyamashita Date: Sat, 22 Aug 2026 19:21:12 +0900 Subject: Initial commit: todo app with passkey auth and PWA support Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 7 + Dockerfile | 31 + app/app.css | 258 +++ app/lib/db.server.ts | 160 ++ app/lib/passkey.server.ts | 11 + app/lib/session.server.ts | 12 + app/root.tsx | 58 + app/routes.ts | 8 + app/routes/api.passkey.tsx | 163 ++ app/routes/auth.logout.tsx | 18 + app/routes/auth.register.tsx | 138 ++ app/routes/home.tsx | 252 +++ docker-compose.yml | 25 + package-lock.json | 4904 ++++++++++++++++++++++++++++++++++++++++++ package.json | 35 + public/icons/icon-192.png | Bin 0 -> 1099 bytes public/icons/icon-512.png | Bin 0 -> 3683 bytes public/manifest.json | 13 + public/sw.js | 26 + react-router.config.ts | 5 + scripts/gen-icons.mjs | 98 + tsconfig.json | 26 + vite.config.ts | 8 + 23 files changed, 6256 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/app.css create mode 100644 app/lib/db.server.ts create mode 100644 app/lib/passkey.server.ts create mode 100644 app/lib/session.server.ts create mode 100644 app/root.tsx create mode 100644 app/routes.ts create mode 100644 app/routes/api.passkey.tsx create mode 100644 app/routes/auth.logout.tsx create mode 100644 app/routes/auth.register.tsx create mode 100644 app/routes/home.tsx create mode 100644 docker-compose.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 public/manifest.json create mode 100644 public/sw.js create mode 100644 react-router.config.ts create mode 100644 scripts/gen-icons.mjs create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94235ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +build/ +.react-router/ +data/ +*.db +.env +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4cd1bc8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# syntax=docker/dockerfile:1 + +FROM node:20-alpine AS deps +WORKDIR /app +RUN apk add --no-cache python3 make g++ +COPY package.json package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm ci + +FROM node:20-alpine AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN node scripts/gen-icons.mjs +RUN npm run build + +FROM node:20-alpine AS prod-deps +WORKDIR /app +RUN apk add --no-cache python3 make g++ +COPY package.json package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm ci --omit=dev + +FROM node:20-alpine AS app +WORKDIR /app +COPY package.json ./ +COPY --from=prod-deps /app/node_modules ./node_modules +COPY --from=build /app/build ./build +RUN mkdir -p /app/data +EXPOSE 3000 +CMD ["npm", "start"] 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 ( + + + + + + + + + + Todo + + + + + + + {children} + + +