diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-06-19 19:45:41 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-06-19 19:45:41 +0900 |
| commit | cba06f7e23417a9aef6c15ae26715385feb225ba (patch) | |
| tree | 042f8e8343e7203ac9c6576e3c18fc6865a59ba1 /app | |
| parent | c6bb8a0c75b4307a73a3e36d97c7b5e69294cfe9 (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.css | 162 | ||||
| -rw-r--r-- | app/lib/db.server.ts | 52 | ||||
| -rw-r--r-- | app/lib/passkey.server.ts | 11 | ||||
| -rw-r--r-- | app/lib/session.server.ts | 12 | ||||
| -rw-r--r-- | app/routes.ts | 3 | ||||
| -rw-r--r-- | app/routes/auth.login.tsx | 132 | ||||
| -rw-r--r-- | app/routes/auth.logout.tsx | 18 | ||||
| -rw-r--r-- | app/routes/auth.register.tsx | 140 | ||||
| -rw-r--r-- | app/routes/home.tsx | 121 |
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 }} |
