summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authoryyamashita <yyamashita@hetzner.yyamashita.com>2026-06-19 19:45:41 +0900
committeryyamashita <yyamashita@hetzner.yyamashita.com>2026-06-19 19:45:41 +0900
commitcba06f7e23417a9aef6c15ae26715385feb225ba (patch)
tree042f8e8343e7203ac9c6576e3c18fc6865a59ba1 /app
parentc6bb8a0c75b4307a73a3e36d97c7b5e69294cfe9 (diff)
Add white theme, post delete, and passkey authentication
- Rewrite CSS to light/white theme - Add delete button (auth-gated) with confirm dialog - Add passkey register/login/logout routes via @simplewebauthn - Gate compose form and delete behind session authentication - Add credentials table to SQLite schema - Add SESSION_SECRET and WebAuthn env vars to docker-compose Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'app')
-rw-r--r--app/app.css162
-rw-r--r--app/lib/db.server.ts52
-rw-r--r--app/lib/passkey.server.ts11
-rw-r--r--app/lib/session.server.ts12
-rw-r--r--app/routes.ts3
-rw-r--r--app/routes/auth.login.tsx132
-rw-r--r--app/routes/auth.logout.tsx18
-rw-r--r--app/routes/auth.register.tsx140
-rw-r--r--app/routes/home.tsx121
9 files changed, 575 insertions, 76 deletions
diff --git a/app/app.css b/app/app.css
index d289252..92f0933 100644
--- a/app/app.css
+++ b/app/app.css
@@ -2,30 +2,30 @@
html {
font-family: ui-serif, Georgia, 'Times New Roman', Times, serif;
- background: #0c0e14;
- color: #c9cdd6;
+ background: #ffffff;
+ color: #1a1a1a;
line-height: 1.6;
}
textarea {
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
font-size: .875rem;
- background: #161924;
- color: #c9cdd6;
- border: 1px solid #2a2f3e;
+ background: #f9fafb;
+ color: #1a1a1a;
+ border: 1px solid #d1d5db;
border-radius: 4px;
padding: .75rem 1rem;
resize: vertical;
width: 100%;
line-height: 1.6;
}
-textarea:focus { outline: 1px solid #5c6bc0; border-color: #5c6bc0; }
-textarea::placeholder { color: #3d4560; }
+textarea:focus { outline: 1px solid #2563eb; border-color: #2563eb; }
+textarea::placeholder { color: #9ca3af; }
button[type="submit"] {
- background: #1e2640;
- border: 1px solid #3d4f7c;
- color: #8899cc;
+ background: #f3f4f6;
+ border: 1px solid #d1d5db;
+ color: #374151;
border-radius: 4px;
padding: .375rem 1.125rem;
font-size: .8125rem;
@@ -33,7 +33,7 @@ button[type="submit"] {
cursor: pointer;
letter-spacing: .02em;
}
-button[type="submit"]:hover { background: #253058; color: #aabce0; border-color: #5068a0; }
+button[type="submit"]:hover { background: #e5e7eb; color: #111827; border-color: #9ca3af; }
button[type="submit"]:disabled { opacity: .4; cursor: not-allowed; }
/* ── Layout ── */
@@ -42,109 +42,189 @@ button[type="submit"]:disabled { opacity: .4; cursor: not-allowed; }
/* ── Header ── */
-.site-header { margin-bottom: 2.5rem; padding-bottom: 1.25rem; border-bottom: 1px solid #1a1d29; }
+.site-header {
+ margin-bottom: 2.5rem;
+ padding-bottom: 1.25rem;
+ border-bottom: 1px solid #e5e7eb;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
.site-header h1 {
font-size: .875rem;
font-weight: 400;
- color: #4b5267;
+ color: #9ca3af;
letter-spacing: .12em;
text-transform: lowercase;
font-family: ui-monospace, monospace;
}
+/* ── Auth header actions ── */
+
+.header-nav { display: flex; gap: .75rem; align-items: center; }
+
+.auth-link {
+ font-size: .75rem;
+ font-family: ui-monospace, monospace;
+ color: #9ca3af;
+ text-decoration: none;
+ letter-spacing: .04em;
+}
+.auth-link:hover { color: #374151; }
+
+.logout-btn {
+ font-size: .75rem;
+ font-family: ui-monospace, monospace;
+ color: #9ca3af;
+ background: none;
+ border: none;
+ cursor: pointer;
+ letter-spacing: .04em;
+ padding: 0;
+}
+.logout-btn:hover { color: #374151; }
+
/* ── Compose form ── */
.compose { margin-bottom: 2.75rem; }
.compose textarea { margin-bottom: .5rem; min-height: 96px; }
.compose-row { display: flex; justify-content: space-between; align-items: center; }
-.hint { font-size: .6875rem; color: #32384d; font-family: ui-monospace, monospace; letter-spacing: .04em; }
-.error-msg { font-size: .8125rem; color: #e06c75; margin-top: .375rem; margin-bottom: .375rem; }
+.hint { font-size: .6875rem; color: #9ca3af; font-family: ui-monospace, monospace; letter-spacing: .04em; }
+.error-msg { font-size: .8125rem; color: #dc2626; margin-top: .375rem; margin-bottom: .375rem; }
/* ── Feed ── */
.feed { display: flex; flex-direction: column; }
-.post { padding: 1.375rem 0; border-top: 1px solid #15182200; }
-.feed > .post:first-child { border-top: 1px solid #1a1d29; }
-.post + .post { border-top: 1px solid #1a1d29; }
-.post:last-child { border-bottom: 1px solid #1a1d29; }
+.post { padding: 1.375rem 0; border-top: 1px solid transparent; }
+.feed > .post:first-child { border-top: 1px solid #e5e7eb; }
+.post + .post { border-top: 1px solid #e5e7eb; }
+.post:last-child { border-bottom: 1px solid #e5e7eb; }
+
+.post-header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ margin-bottom: .625rem;
+}
.post-meta {
- display: block;
font-size: .6875rem;
- color: #3d4560;
+ color: #9ca3af;
font-family: ui-monospace, monospace;
- margin-bottom: .625rem;
letter-spacing: .04em;
}
+.delete-btn {
+ font-size: .6875rem;
+ font-family: ui-monospace, monospace;
+ color: #d1d5db;
+ background: none;
+ border: none;
+ cursor: pointer;
+ letter-spacing: .04em;
+ padding: 0;
+ line-height: 1;
+}
+.delete-btn:hover { color: #dc2626; }
+
/* ── Prose (rendered markdown) ── */
.prose { font-size: .9375rem; }
-.prose p { margin-bottom: .75rem; color: #c9cdd6; line-height: 1.8; }
+.prose p { margin-bottom: .75rem; color: #1a1a1a; line-height: 1.8; }
.prose p:last-child { margin-bottom: 0; }
-.prose h1 { font-size: 1.25rem; color: #e8eaf0; margin: 1.25rem 0 .5rem; font-weight: 600; }
-.prose h2 { font-size: 1.0625rem; color: #e8eaf0; margin: 1.125rem 0 .375rem; font-weight: 600; }
-.prose h3 { font-size: .9375rem; color: #dde1ed; margin: 1rem 0 .25rem; font-weight: 600; }
+.prose h1 { font-size: 1.25rem; color: #111827; margin: 1.25rem 0 .5rem; font-weight: 600; }
+.prose h2 { font-size: 1.0625rem; color: #111827; margin: 1.125rem 0 .375rem; font-weight: 600; }
+.prose h3 { font-size: .9375rem; color: #1f2937; margin: 1rem 0 .25rem; font-weight: 600; }
.prose h1:first-child,
.prose h2:first-child,
.prose h3:first-child { margin-top: 0; }
-.prose strong { color: #dde1ed; font-weight: 600; }
+.prose strong { color: #111827; font-weight: 600; }
.prose em { font-style: italic; }
-.prose del { color: #6b7280; }
+.prose del { color: #9ca3af; }
-.prose a { color: #7aa2f7; }
-.prose a:hover { color: #9db8ff; text-decoration: underline; }
+.prose a { color: #2563eb; }
+.prose a:hover { color: #1d4ed8; text-decoration: underline; }
.prose code {
font-family: ui-monospace, monospace;
font-size: .8125rem;
- background: #1a1d29;
- color: #a9b1d6;
+ background: #f3f4f6;
+ color: #1f2937;
padding: .1em .35em;
border-radius: 3px;
}
.prose pre {
- background: #161924;
- border: 1px solid #2a2f3e;
+ background: #f9fafb;
+ border: 1px solid #e5e7eb;
border-radius: 4px;
padding: .875rem 1rem;
overflow-x: auto;
margin-bottom: .75rem;
}
-.prose pre code { background: none; padding: 0; color: #a9b1d6; font-size: .8125rem; }
+.prose pre code { background: none; padding: 0; color: #1f2937; font-size: .8125rem; }
.prose ul { padding-left: 1.5rem; margin-bottom: .75rem; list-style: disc; }
.prose ol { padding-left: 1.5rem; margin-bottom: .75rem; list-style: decimal; }
.prose li { margin-bottom: .25rem; line-height: 1.75; }
.prose blockquote {
- border-left: 2px solid #2a2f3e;
+ border-left: 2px solid #d1d5db;
padding-left: .875rem;
- color: #7a8099;
+ color: #6b7280;
margin-bottom: .75rem;
font-style: italic;
}
-.prose hr { border: none; border-top: 1px solid #1a1d29; margin: 1.25rem 0; }
+.prose hr { border: none; border-top: 1px solid #e5e7eb; margin: 1.25rem 0; }
.prose table { width: 100%; border-collapse: collapse; font-size: .875rem; margin-bottom: .75rem; }
.prose th,
-.prose td { padding: .375rem .75rem; border: 1px solid #2a2f3e; text-align: left; }
-.prose th { background: #161924; color: #c9cdd6; }
+.prose td { padding: .375rem .75rem; border: 1px solid #e5e7eb; text-align: left; }
+.prose th { background: #f9fafb; color: #374151; }
.prose img { max-width: 100%; border-radius: 4px; }
/* ── Empty state ── */
.empty {
- color: #32384d;
+ color: #9ca3af;
font-size: .875rem;
padding: 2.5rem 0;
text-align: center;
font-style: italic;
font-family: ui-monospace, monospace;
}
+
+/* ── Auth pages ── */
+
+.auth-box { max-width: 380px; }
+.auth-box h2 { font-size: 1rem; font-weight: 600; margin-bottom: .625rem; }
+.auth-box p { font-size: .875rem; color: #6b7280; margin-bottom: 1.25rem; line-height: 1.6; }
+
+.btn {
+ display: inline-block;
+ background: #1a1a1a;
+ color: #fff;
+ border: none;
+ border-radius: 4px;
+ padding: .5rem 1.25rem;
+ font-size: .875rem;
+ font-family: ui-monospace, monospace;
+ cursor: pointer;
+ letter-spacing: .02em;
+}
+.btn:hover { background: #374151; }
+.btn:disabled { opacity: .4; cursor: not-allowed; }
+
+.auth-sub {
+ margin-top: 1rem;
+ font-size: .8125rem;
+ color: #9ca3af;
+ font-family: ui-monospace, monospace;
+}
+.auth-sub a { color: #6b7280; }
+.auth-sub a:hover { color: #1a1a1a; }
diff --git a/app/lib/db.server.ts b/app/lib/db.server.ts
index b076ec7..cd8d198 100644
--- a/app/lib/db.server.ts
+++ b/app/lib/db.server.ts
@@ -22,9 +22,18 @@ function initSchema(db: Database.Database) {
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at DESC);
+
+ CREATE TABLE IF NOT EXISTS credentials (
+ id TEXT PRIMARY KEY,
+ public_key TEXT NOT NULL,
+ counter INTEGER NOT NULL DEFAULT 0,
+ transports TEXT
+ );
`);
}
+/* ── Posts ── */
+
export interface Post {
id: string;
content: string;
@@ -48,3 +57,46 @@ export function createPost(input: CreatePostInput): Post {
getDb().prepare("INSERT INTO posts (id, content) VALUES (?, ?)").run(input.id, input.content);
return getPostById(input.id)!;
}
+
+export function deletePost(id: string): void {
+ getDb().prepare("DELETE FROM posts WHERE id = ?").run(id);
+}
+
+/* ── Credentials (passkey) ── */
+
+export interface Credential {
+ id: string;
+ public_key: string;
+ counter: number;
+ transports: string | 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;
+ publicKey: Uint8Array;
+ counter: number;
+ transports?: string[];
+}): void {
+ getDb()
+ .prepare(
+ "INSERT INTO credentials (id, public_key, counter, transports) VALUES (?, ?, ?, ?)"
+ )
+ .run(
+ input.id,
+ 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);
+}
diff --git a/app/lib/passkey.server.ts b/app/lib/passkey.server.ts
new file mode 100644
index 0000000..e88c4f8
--- /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" ? "blog.yyamashita.com" : "localhost");
+
+export const origin =
+ process.env.WEBAUTHN_ORIGIN ??
+ (process.env.NODE_ENV === "production"
+ ? "https://blog.yyamashita.com"
+ : "http://localhost:5173");
+
+export const rpName = "microblog";
diff --git a/app/lib/session.server.ts b/app/lib/session.server.ts
new file mode 100644
index 0000000..13a7cd1
--- /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: "__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/routes.ts b/app/routes.ts
index e839849..8ca1209 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -3,4 +3,7 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("/api/posts", "routes/api.posts.tsx"),
+ route("/auth/register", "routes/auth.register.tsx"),
+ route("/auth/login", "routes/auth.login.tsx"),
+ route("/auth/logout", "routes/auth.logout.tsx"),
] satisfies RouteConfig;
diff --git a/app/routes/auth.login.tsx b/app/routes/auth.login.tsx
new file mode 100644
index 0000000..f8e9028
--- /dev/null
+++ b/app/routes/auth.login.tsx
@@ -0,0 +1,132 @@
+import { useState } from "react";
+import { redirect } from "react-router";
+import {
+ generateAuthenticationOptions,
+ verifyAuthenticationResponse,
+ type AuthenticationResponseJSON,
+ type AuthenticatorTransportFuture,
+} from "@simplewebauthn/server";
+import type { Route } from "./+types/auth.login";
+import { getSession, commitSession } from "~/lib/session.server";
+import { listCredentials, getCredentialById, updateCredentialCounter } from "~/lib/db.server";
+import { rpID, origin } from "~/lib/passkey.server";
+
+export async function loader({ request }: Route.LoaderArgs) {
+ const session = await getSession(request.headers.get("Cookie"));
+ if (session.get("authenticated")) return redirect("/");
+ if (listCredentials().length === 0) return redirect("/auth/register");
+ return {};
+}
+
+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 === "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 === "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 credential = getCredentialById(body.id);
+ if (!credential) return Response.json({ error: "パスキーが見つかりません" }, { status: 400 });
+
+ const verification = await verifyAuthenticationResponse({
+ response: body,
+ expectedChallenge: challenge,
+ expectedOrigin: origin,
+ expectedRPID: rpID,
+ credential: {
+ id: credential.id,
+ publicKey: new Uint8Array(Buffer.from(credential.public_key, "base64url")),
+ counter: credential.counter,
+ transports: credential.transports
+ ? (JSON.parse(credential.transports) as AuthenticatorTransportFuture[])
+ : undefined,
+ },
+ });
+
+ if (!verification.verified) {
+ return Response.json({ error: "認証に失敗しました" }, { status: 400 });
+ }
+
+ updateCredentialCounter(credential.id, verification.authenticationInfo.newCounter);
+ session.unset("challenge");
+ session.set("authenticated", true);
+ return redirect("/", {
+ headers: { "Set-Cookie": await commitSession(session) },
+ });
+ }
+
+ return Response.json({ error: "不正なリクエスト" }, { status: 400 });
+}
+
+export default function Login() {
+ const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
+ const [errorMsg, setErrorMsg] = useState("");
+
+ async function handleLogin() {
+ setStatus("loading");
+ setErrorMsg("");
+ try {
+ const { startAuthentication } = await import("@simplewebauthn/browser");
+
+ const optRes = await fetch("/auth/login?intent=options", { method: "POST" });
+ if (!optRes.ok) throw new Error("オプション取得に失敗しました");
+ const options = await optRes.json();
+
+ const authResponse = await startAuthentication({ optionsJSON: options });
+
+ const verRes = await fetch("/auth/login?intent=verify", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(authResponse),
+ });
+ if (!verRes.ok) {
+ const err = await verRes.json();
+ throw new Error(err.error ?? "認証に失敗しました");
+ }
+
+ window.location.href = "/";
+ } catch (e) {
+ setStatus("error");
+ setErrorMsg(e instanceof Error ? e.message : "エラーが発生しました");
+ }
+ }
+
+ return (
+ <div className="wrap">
+ <header className="site-header">
+ <h1>log</h1>
+ </header>
+ <div className="auth-box">
+ <h2>ログイン</h2>
+ <p>登録済みのパスキーで認証します。</p>
+ <button className="btn" onClick={handleLogin} disabled={status === "loading"}>
+ {status === "loading" ? "認証中…" : "パスキーでログイン"}
+ </button>
+ {status === "error" && (
+ <p className="error-msg" style={{ marginTop: ".75rem" }}>{errorMsg}</p>
+ )}
+ </div>
+ </div>
+ );
+}
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..cc4e6a9
--- /dev/null
+++ b/app/routes/auth.register.tsx
@@ -0,0 +1,140 @@
+import { useState } from "react";
+import { Link, redirect } from "react-router";
+import {
+ generateRegistrationOptions,
+ verifyRegistrationResponse,
+ type RegistrationResponseJSON,
+ type AuthenticatorTransportFuture,
+} from "@simplewebauthn/server";
+import type { Route } from "./+types/auth.register";
+import { getSession, commitSession } from "~/lib/session.server";
+import { listCredentials, saveCredential } from "~/lib/db.server";
+import { rpID, rpName, origin } from "~/lib/passkey.server";
+
+export async function loader({ request }: Route.LoaderArgs) {
+ const session = await getSession(request.headers.get("Cookie"));
+ if (session.get("authenticated")) return redirect("/");
+ const hasCredentials = listCredentials().length > 0;
+ return { hasCredentials };
+}
+
+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 === "options") {
+ const options = await generateRegistrationOptions({
+ rpName,
+ rpID,
+ userName: "admin",
+ attestationType: "none",
+ authenticatorSelection: {
+ residentKey: "required",
+ userVerification: "required",
+ },
+ });
+ session.set("challenge", options.challenge);
+ return Response.json(options, {
+ headers: { "Set-Cookie": await commitSession(session) },
+ });
+ }
+
+ if (intent === "verify") {
+ const challenge = session.get("challenge") as string | undefined;
+ if (!challenge) return Response.json({ error: "セッション切れです" }, { status: 400 });
+
+ const body = (await request.json()) as RegistrationResponseJSON;
+ const verification = await verifyRegistrationResponse({
+ response: body,
+ expectedChallenge: challenge,
+ expectedOrigin: origin,
+ expectedRPID: rpID,
+ });
+
+ if (!verification.verified || !verification.registrationInfo) {
+ return Response.json({ error: "登録に失敗しました" }, { status: 400 });
+ }
+
+ const { credential } = verification.registrationInfo;
+ saveCredential({
+ id: credential.id,
+ publicKey: credential.publicKey,
+ counter: credential.counter,
+ transports: (body.response as { transports?: AuthenticatorTransportFuture[] }).transports,
+ });
+
+ session.unset("challenge");
+ session.set("authenticated", true);
+ return redirect("/", {
+ headers: { "Set-Cookie": await commitSession(session) },
+ });
+ }
+
+ return Response.json({ error: "不正なリクエスト" }, { status: 400 });
+}
+
+export default function Register({ loaderData }: Route.ComponentProps) {
+ const { hasCredentials } = loaderData;
+ const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
+ const [errorMsg, setErrorMsg] = useState("");
+
+ async function handleRegister() {
+ setStatus("loading");
+ setErrorMsg("");
+ try {
+ const { startRegistration } = await import("@simplewebauthn/browser");
+
+ const optRes = await fetch("/auth/register?intent=options", { method: "POST" });
+ if (!optRes.ok) throw new Error("オプション取得に失敗しました");
+ const options = await optRes.json();
+
+ const regResponse = await startRegistration({ optionsJSON: options });
+
+ const verRes = await fetch("/auth/register?intent=verify", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(regResponse),
+ });
+ if (!verRes.ok) {
+ const err = await verRes.json();
+ throw new Error(err.error ?? "登録に失敗しました");
+ }
+
+ window.location.href = "/";
+ } catch (e) {
+ setStatus("error");
+ setErrorMsg(e instanceof Error ? e.message : "エラーが発生しました");
+ }
+ }
+
+ return (
+ <div className="wrap">
+ <header className="site-header">
+ <h1>log</h1>
+ </header>
+ <div className="auth-box">
+ <h2>パスキーを登録</h2>
+ {hasCredentials ? (
+ <>
+ <p>すでにパスキーが登録されています。</p>
+ <Link to="/auth/login" className="btn" style={{ textDecoration: "none" }}>
+ ログインへ
+ </Link>
+ </>
+ ) : (
+ <>
+ <p>
+ このデバイスの生体認証(Touch ID / Face ID など)でパスキーを作成します。
+ 一度登録すると、次回からはパスキーでログインできます。
+ </p>
+ <button className="btn" onClick={handleRegister} disabled={status === "loading"}>
+ {status === "loading" ? "登録中…" : "パスキーを登録"}
+ </button>
+ {status === "error" && <p className="error-msg" style={{ marginTop: ".75rem" }}>{errorMsg}</p>}
+ </>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/app/routes/home.tsx b/app/routes/home.tsx
index 58004c9..7732326 100644
--- a/app/routes/home.tsx
+++ b/app/routes/home.tsx
@@ -1,31 +1,51 @@
import { useEffect, useRef } from "react";
-import { Form, redirect, useNavigation } from "react-router";
+import { Form, Link, redirect, useNavigation } from "react-router";
import type { Route } from "./+types/home";
-import { createPost, listPosts } from "~/lib/db.server";
+import { createPost, deletePost, listPosts, listCredentials } from "~/lib/db.server";
import { renderMarkdown } from "~/lib/md.server";
+import { getSession, commitSession } from "~/lib/session.server";
-export async function loader() {
+export async function loader({ request }: Route.LoaderArgs) {
+ const session = await getSession(request.headers.get("Cookie"));
+ const isAuthenticated = session.get("authenticated") === true;
+ const hasCredentials = listCredentials().length > 0;
const posts = listPosts();
- return { posts: posts.map((p) => ({ ...p, html: renderMarkdown(p.content) })) };
+ return {
+ posts: posts.map((p) => ({ ...p, html: renderMarkdown(p.content) })),
+ isAuthenticated,
+ hasCredentials,
+ };
}
export async function action({ request }: Route.ActionArgs) {
+ const session = await getSession(request.headers.get("Cookie"));
+ if (session.get("authenticated") !== true) {
+ throw new Response("Unauthorized", { status: 401 });
+ }
+
const form = await request.formData();
- const content = String(form.get("content") ?? "").trim();
+ const intent = String(form.get("intent") ?? "create");
- if (!content) {
- return { error: "内容を入力してください", content };
- }
- if (content.length > 10000) {
- return { error: "投稿が長すぎます(最大10,000文字)", content };
+ if (intent === "delete") {
+ const id = String(form.get("id") ?? "");
+ if (id) deletePost(id);
+ return redirect("/", {
+ headers: { "Set-Cookie": await commitSession(session) },
+ });
}
+ const content = String(form.get("content") ?? "").trim();
+ if (!content) return { error: "内容を入力してください", content };
+ if (content.length > 10000) return { error: "投稿が長すぎます(最大10,000文字)", content };
+
createPost({ id: crypto.randomUUID(), content });
- return redirect("/");
+ return redirect("/", {
+ headers: { "Set-Cookie": await commitSession(session) },
+ });
}
export default function Home({ loaderData, actionData }: Route.ComponentProps) {
- const { posts } = loaderData;
+ const { posts, isAuthenticated, hasCredentials } = loaderData;
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const formRef = useRef<HTMLFormElement>(null);
@@ -43,28 +63,42 @@ export default function Home({ loaderData, actionData }: Route.ComponentProps) {
<div className="wrap">
<header className="site-header">
<h1>log</h1>
+ <nav className="header-nav">
+ {isAuthenticated ? (
+ <Form method="post" action="/auth/logout">
+ <button type="submit" className="logout-btn">ログアウト</button>
+ </Form>
+ ) : hasCredentials ? (
+ <Link to="/auth/login" className="auth-link">ログイン</Link>
+ ) : (
+ <Link to="/auth/register" className="auth-link">登録</Link>
+ )}
+ </nav>
</header>
- <div className="compose">
- <Form method="post" ref={formRef}>
- <textarea
- name="content"
- placeholder="Markdown で投稿..."
- rows={4}
- defaultValue={actionData?.content ?? ""}
- autoFocus
- />
- {actionData?.error && (
- <p className="error-msg">{actionData.error}</p>
- )}
- <div className="compose-row">
- <span className="hint">markdown</span>
- <button type="submit" disabled={isSubmitting}>
- {isSubmitting ? "投稿中…" : "投稿"}
- </button>
- </div>
- </Form>
- </div>
+ {isAuthenticated && (
+ <div className="compose">
+ <Form method="post" ref={formRef}>
+ <input type="hidden" name="intent" value="create" />
+ <textarea
+ name="content"
+ placeholder="Markdown で投稿..."
+ rows={4}
+ defaultValue={actionData?.content ?? ""}
+ autoFocus
+ />
+ {actionData?.error && (
+ <p className="error-msg">{actionData.error}</p>
+ )}
+ <div className="compose-row">
+ <span className="hint">markdown</span>
+ <button type="submit" disabled={isSubmitting}>
+ {isSubmitting ? "投稿中…" : "投稿"}
+ </button>
+ </div>
+ </Form>
+ </div>
+ )}
<div className="feed">
{posts.length === 0 && (
@@ -72,9 +106,26 @@ export default function Home({ loaderData, actionData }: Route.ComponentProps) {
)}
{posts.map((post) => (
<article key={post.id} className="post">
- <time className="post-meta" dateTime={post.created_at + "Z"}>
- {formatDate(post.created_at)}
- </time>
+ <div className="post-header">
+ <time className="post-meta" dateTime={post.created_at + "Z"}>
+ {formatDate(post.created_at)}
+ </time>
+ {isAuthenticated && (
+ <Form method="post">
+ <input type="hidden" name="intent" value="delete" />
+ <input type="hidden" name="id" value={post.id} />
+ <button
+ type="submit"
+ className="delete-btn"
+ onClick={(e) => {
+ if (!confirm("この投稿を削除しますか?")) e.preventDefault();
+ }}
+ >
+ 削除
+ </button>
+ </Form>
+ )}
+ </div>
<div
className="prose"
dangerouslySetInnerHTML={{ __html: post.html }}