summaryrefslogtreecommitdiff
path: root/app/routes/api.auth.$step.ts
diff options
context:
space:
mode:
authoryyamashita <yyamashita@hetzner.yyamashita.com>2026-09-26 00:19:53 +0900
committeryyamashita <yyamashita@hetzner.yyamashita.com>2026-09-26 00:19:53 +0900
commit874749df46cc291ca61f2f98092ccabfdeffbf16 (patch)
treee4cb53b5bfa23974e7935eb2616e0e005954f782 /app/routes/api.auth.$step.ts
parent999b7a3133fccc3f120ac5ae24c02bb38a4ecce9 (diff)
Add passkey login and favorite search termsHEADmaster
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 <noreply@anthropic.com>
Diffstat (limited to 'app/routes/api.auth.$step.ts')
-rw-r--r--app/routes/api.auth.$step.ts63
1 files changed, 63 insertions, 0 deletions
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<string>): Promise<Response> {
+ 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 });
+}