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/components/EventCard.tsx | 1 + app/components/EventListRow.tsx | 1 + app/components/FilterBar.tsx | 19 ++- app/lib/auth.server.ts | 304 ++++++++++++++++++++++++++++++++++++++++ app/lib/db.server.ts | 64 ++++++++- app/routes.ts | 3 + 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 + 13 files changed, 748 insertions(+), 10 deletions(-) create mode 100644 app/lib/auth.server.ts 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') diff --git a/app/components/EventCard.tsx b/app/components/EventCard.tsx index b23742d..ad55a57 100644 --- a/app/components/EventCard.tsx +++ b/app/components/EventCard.tsx @@ -30,6 +30,7 @@ export default function EventCard({ event }: Props) {
{/* Title */}

+ {event.is_favorite ? ★ : null} {event.title}

diff --git a/app/components/EventListRow.tsx b/app/components/EventListRow.tsx index 30800fb..42f3e8b 100644 --- a/app/components/EventListRow.tsx +++ b/app/components/EventListRow.tsx @@ -31,6 +31,7 @@ export default function EventListRow({ event }: Props) { {/* Title + artist */} + {event.is_favorite ? ★ : null} {event.title} {event.artist && ( diff --git a/app/components/FilterBar.tsx b/app/components/FilterBar.tsx index 7a95d2c..f897e67 100644 --- a/app/components/FilterBar.tsx +++ b/app/components/FilterBar.tsx @@ -6,9 +6,11 @@ interface Props { areas?: string[]; defaultDateFrom?: string; defaultDateTo?: string; + /** Show the "favorites only" toggle (logged in only) */ + showFavorites?: boolean; } -export default function FilterBar({ venues, areas, defaultDateFrom, defaultDateTo }: Props) { +export default function FilterBar({ venues, areas, defaultDateFrom, defaultDateTo, showFavorites }: Props) { const [searchParams] = useSearchParams(); return ( @@ -98,6 +100,19 @@ export default function FilterBar({ venues, areas, defaultDateFrom, defaultDateT
+ {showFavorites && ( + + )} + + + {actionData?.error &&

{actionData.error}

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

まだ登録されていません

+ ) : ( + + )} + + +
+

パスキー

+
    + {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