From 874749df46cc291ca61f2f98092ccabfdeffbf16 Mon Sep 17 00:00:00 2001 From: yyamashita Date: Sat, 26 Sep 2026 00:19:53 +0900 Subject: Add passkey login and favorite search terms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-user auth: any registered passkey logs in as the owner. First registration needs a one-time token that the app writes to data/registration-token when no passkey exists yet; once logged in, more passkeys can be added without a token. Logged in, /favorites manages a list of terms (e.g. band names). Events whose title or artist contains a term (NFKC + case-insensitive) get a ★, and the event list gains an "お気に入りのみ" filter. Co-Authored-By: Claude Opus 5.5 --- app/routes/api.auth.$step.ts | 63 +++++++++++++++++ app/routes/events.$id.tsx | 1 + app/routes/events._index.tsx | 15 ++-- app/routes/events.by-date.tsx | 8 ++- app/routes/favorites.tsx | 161 ++++++++++++++++++++++++++++++++++++++++++ app/routes/login.tsx | 117 ++++++++++++++++++++++++++++++ app/routes/venues.tsx | 1 + 7 files changed, 361 insertions(+), 5 deletions(-) create mode 100644 app/routes/api.auth.$step.ts create mode 100644 app/routes/favorites.tsx create mode 100644 app/routes/login.tsx (limited to 'app/routes') diff --git a/app/routes/api.auth.$step.ts b/app/routes/api.auth.$step.ts new file mode 100644 index 0000000..64940f2 --- /dev/null +++ b/app/routes/api.auth.$step.ts @@ -0,0 +1,63 @@ +/** + * Resource route: POST /api/auth/:step + * + * register-options / register-verify — パスキー登録(ログイン済み or 登録トークン必須) + * login-options / login-verify — パスキーでログイン + * logout — セッション破棄して /events へ + */ +import { redirect } from "react-router"; +import type { Route } from "./+types/api.auth.$step"; +import { + authenticationOptions, + canRegister, + isLoggedIn, + logout, + registrationOptions, + verifyAuthentication, + verifyRegistration, +} from "~/lib/auth.server"; + +export async function action({ request, params }: Route.ActionArgs) { + switch (params.step) { + case "register-options": { + const { token } = await request.json(); + if (!canRegister(request, token ?? null)) return forbidden(); + const { options, setCookie } = await registrationOptions(request); + return Response.json(options, { headers: { "Set-Cookie": setCookie } }); + } + case "register-verify": { + const { token, response, label } = await request.json(); + const loggedIn = isLoggedIn(request); + if (!canRegister(request, token ?? null)) return forbidden(); + return withSession(() => + verifyRegistration(request, response, label ?? null, !loggedIn) + ); + } + case "login-options": { + const { options, setCookie } = await authenticationOptions(request); + return Response.json(options, { headers: { "Set-Cookie": setCookie } }); + } + case "login-verify": { + const { response } = await request.json(); + return withSession(() => verifyAuthentication(request, response)); + } + case "logout": + return redirect("/events", { headers: { "Set-Cookie": logout(request) } }); + default: + throw new Response("Not Found", { status: 404 }); + } +} + +async function withSession(verify: () => Promise): Promise { + try { + const setCookie = await verify(); + return Response.json({ ok: true }, { headers: { "Set-Cookie": setCookie } }); + } catch (e) { + console.warn("[auth]", e); + return Response.json({ ok: false, error: (e as Error).message }, { status: 400 }); + } +} + +function forbidden(): Response { + return Response.json({ ok: false, error: "登録トークンが無効です" }, { status: 403 }); +} diff --git a/app/routes/events.$id.tsx b/app/routes/events.$id.tsx index 87836ed..6837430 100644 --- a/app/routes/events.$id.tsx +++ b/app/routes/events.$id.tsx @@ -73,6 +73,7 @@ export default function EventDetail() { イベント 日付別 会場一覧 + ★ お気に入り diff --git a/app/routes/events._index.tsx b/app/routes/events._index.tsx index 33568df..563b0c0 100644 --- a/app/routes/events._index.tsx +++ b/app/routes/events._index.tsx @@ -6,6 +6,7 @@ import EventListRow from "~/components/EventListRow"; import FilterBar from "~/components/FilterBar"; import ScrapeButton from "~/components/ScrapeButton"; import { useVenueFilter } from "~/lib/venue-filter"; +import { isLoggedIn } from "~/lib/auth.server"; function defaultWindow() { const today = new Date(); @@ -27,19 +28,24 @@ export async function loader({ request }: Route.LoaderArgs) { const keyword = url.searchParams.get("keyword") ?? undefined; const capacity_range = (url.searchParams.get("capacity_range") ?? undefined) as CapacityRange | undefined; const area = url.searchParams.get("area") ?? undefined; + const loggedIn = isLoggedIn(request); + const favorites_only = loggedIn && url.searchParams.get("fav") === "1"; const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10)); const limit = 30; const offset = (page - 1) * limit; - const events = queryEvents({ date_from, date_to, venue_id, keyword, capacity_range, area, limit, offset }); + const events = queryEvents({ + date_from, date_to, venue_id, keyword, capacity_range, area, + with_favorites: loggedIn, favorites_only, limit, offset, + }); const venues = getVenues(); const areas = [...new Set(venues.filter((v) => v.area).map((v) => v.area as string))].sort(); - return { events, venues, areas, page, hasMore: events.length === limit, date_from, date_to }; + return { events, venues, areas, page, hasMore: events.length === limit, date_from, date_to, loggedIn }; } export default function EventsIndex() { - const { events, venues, areas, page, hasMore, date_from, date_to } = useLoaderData(); + const { events, venues, areas, page, hasMore, date_from, date_to, loggedIn } = useLoaderData(); const [searchParams, setSearchParams] = useSearchParams(); const view = (searchParams.get("view") ?? "card") as "card" | "list"; const { hiddenIds } = useVenueFilter(); @@ -67,6 +73,7 @@ export default function EventsIndex() { イベント 日付別 会場一覧 + ★ お気に入り @@ -94,7 +101,7 @@ export default function EventsIndex() { - + {hiddenEventCount > 0 && (

diff --git a/app/routes/events.by-date.tsx b/app/routes/events.by-date.tsx index ee4a873..5c7876f 100644 --- a/app/routes/events.by-date.tsx +++ b/app/routes/events.by-date.tsx @@ -2,6 +2,7 @@ import { useLoaderData, useSearchParams, Link, Form } from "react-router"; import { queryEvents, getVenues, type CapacityRange } from "~/lib/db.server"; import type { Event } from "~/lib/db.server"; import { useVenueFilter } from "~/lib/venue-filter"; +import { isLoggedIn } from "~/lib/auth.server"; function todayJst(): string { // JST = UTC+9 @@ -38,7 +39,10 @@ export async function loader({ request }: { request: Request }) { const capacity_range = (url.searchParams.get("capacity_range") ?? undefined) as CapacityRange | undefined; const area = url.searchParams.get("area") ?? undefined; - const events = queryEvents({ date_from: date, date_to: date, keyword, venue_id, capacity_range, area, limit: 500 }); + const events = queryEvents({ + date_from: date, date_to: date, keyword, venue_id, capacity_range, area, + with_favorites: isLoggedIn(request), limit: 500, + }); const venues = getVenues(); const areas = [...new Set(venues.filter((v) => v.area).map((v) => v.area as string))].sort(); @@ -89,6 +93,7 @@ export default function EventsByDate() { イベント 日付別 会場一覧 + ★ お気に入り @@ -265,6 +270,7 @@ function VenueEventRow({ event }: { event: Event }) { {/* Title + artist */} + {event.is_favorite ? ★ : null} {event.title} {event.artist && ( diff --git a/app/routes/favorites.tsx b/app/routes/favorites.tsx new file mode 100644 index 0000000..e2f620d --- /dev/null +++ b/app/routes/favorites.tsx @@ -0,0 +1,161 @@ +import { Form, Link, useLoaderData, useNavigation } from "react-router"; +import type { Route } from "./+types/favorites"; +import { + addFavorite, + deleteFavorite, + deletePasskey, + listFavorites, + listPasskeys, + requireLogin, +} from "~/lib/auth.server"; + +export async function loader({ request }: Route.LoaderArgs) { + requireLogin(request); + return { favorites: listFavorites(), passkeys: listPasskeys() }; +} + +export async function action({ request }: Route.ActionArgs) { + requireLogin(request); + const form = await request.formData(); + switch (form.get("intent")) { + case "add": { + const added = addFavorite(String(form.get("term") ?? "")); + return { error: added ? null : "空欄か、登録済みのワードです" }; + } + case "delete": + deleteFavorite(Number(form.get("id"))); + return { error: null }; + case "delete-passkey": + return { + error: deletePasskey(String(form.get("id"))) ? null : "最後のパスキーは削除できません", + }; + } + return { error: null }; +} + +export default function Favorites({ actionData }: Route.ComponentProps) { + const { favorites, passkeys } = useLoaderData(); + const navigation = useNavigation(); + const adding = navigation.formData?.get("intent") === "add"; + + return ( +

+
+ + 🎸 ライブに行くしかない + + +
+ +
+
+
+

お気に入り

+ + 該当イベントを見る → + +
+

+ バンド名などを登録すると、タイトルか出演者に含まれるイベントに ★ が付きます(全角/半角・大文字/小文字は区別しません)。 +

+ +
+ + + +
+ {actionData?.error &&

{actionData.error}

} + + {favorites.length === 0 ? ( +

まだ登録されていません

+ ) : ( +
    + {favorites.map((f) => ( +
  • + + {f.term} + + 0 ? "text-indigo-300" : "text-gray-600"}`}> + {f.upcoming_count} 件 + +
    + + + +
    +
  • + ))} +
+ )} +
+ +
+

パスキー

+
    + {passkeys.map((p) => ( +
  • + {p.label ?? "(名前なし)"} + + {p.last_used_at ? `最終使用 ${p.last_used_at.slice(0, 10)}` : `登録 ${p.created_at.slice(0, 10)}`} + + {passkeys.length > 1 && ( +
    + + + +
    + )} +
  • + ))} +
+
+ + パスキーを追加 + +
+ +
+
+
+
+
+ ); +} diff --git a/app/routes/login.tsx b/app/routes/login.tsx new file mode 100644 index 0000000..69353ea --- /dev/null +++ b/app/routes/login.tsx @@ -0,0 +1,117 @@ +import { useState } from "react"; +import { Link, useLoaderData, useNavigate } from "react-router"; +import { startAuthentication, startRegistration } from "@simplewebauthn/browser"; +import type { Route } from "./+types/login"; +import { ensureSetupToken, isLoggedIn } from "~/lib/auth.server"; + +export async function loader({ request }: Route.LoaderArgs) { + ensureSetupToken(); + const url = new URL(request.url); + const next = url.searchParams.get("next"); + return { + loggedIn: isLoggedIn(request), + token: url.searchParams.get("token"), + // Only allow same-site relative redirects + next: next && /^\/(?![/\\])/.test(next) ? next : "/favorites", + }; +} + +async function postJson(path: string, body: unknown) { + const res = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok || data.ok === false) throw new Error(data.error ?? `HTTP ${res.status}`); + return data; +} + +export default function Login() { + const { loggedIn, token, next } = useLoaderData(); + const navigate = useNavigate(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [label, setLabel] = useState(""); + const canRegister = loggedIn || !!token; + + async function run(fn: () => Promise) { + setBusy(true); + setError(null); + try { + await fn(); + navigate(next); + } catch (e) { + const err = e as Error; + setError(err.name === "NotAllowedError" ? "キャンセルされました" : err.message); + } finally { + setBusy(false); + } + } + + const login = () => + run(async () => { + const optionsJSON = await postJson("/api/auth/login-options", {}); + const response = await startAuthentication({ optionsJSON }); + await postJson("/api/auth/login-verify", { response }); + }); + + const register = () => + run(async () => { + const optionsJSON = await postJson("/api/auth/register-options", { token }); + const response = await startRegistration({ optionsJSON }); + await postJson("/api/auth/register-verify", { token, response, label: label || null }); + }); + + return ( +
+
+ + 🎸 ライブに行くしかない + + + {!loggedIn && ( + + )} + + {loggedIn && !token && ( +

+ ログイン済みです。{" "} + + お気に入りへ + +

+ )} + + {canRegister && ( +
+

+ {loggedIn ? "このデバイスのパスキーを追加登録" : "パスキーを新規登録"} +

+ setLabel(e.target.value)} + placeholder="デバイス名(例: iPhone)" + className="rounded-md bg-gray-800 border border-gray-700 px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500" + /> + +
+ )} + + {error &&

{error}

} +
+
+ ); +} diff --git a/app/routes/venues.tsx b/app/routes/venues.tsx index 9b8093a..690b1c2 100644 --- a/app/routes/venues.tsx +++ b/app/routes/venues.tsx @@ -26,6 +26,7 @@ export default function Venues() { イベント 日付別 会場一覧 + ★ お気に入り -- cgit v1.3