summaryrefslogtreecommitdiff
path: root/app/routes/login.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'app/routes/login.tsx')
-rw-r--r--app/routes/login.tsx117
1 files changed, 117 insertions, 0 deletions
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<typeof loader>();
+ const navigate = useNavigate();
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [label, setLabel] = useState("");
+ const canRegister = loggedIn || !!token;
+
+ async function run(fn: () => Promise<void>) {
+ 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 (
+ <div className="min-h-screen bg-gray-950 text-gray-100 flex items-center justify-center px-4">
+ <div className="w-full max-w-sm rounded-xl border border-gray-800 bg-gray-900 p-6 flex flex-col gap-5">
+ <Link to="/events" className="text-lg font-bold tracking-tight text-white">
+ 🎸 ライブに行くしかない
+ </Link>
+
+ {!loggedIn && (
+ <button
+ onClick={login}
+ disabled={busy}
+ className="rounded-md bg-indigo-600 px-4 py-2.5 text-sm font-medium hover:bg-indigo-500 disabled:opacity-50 transition-colors"
+ >
+ 🔑 パスキーでログイン
+ </button>
+ )}
+
+ {loggedIn && !token && (
+ <p className="text-sm text-gray-300">
+ ログイン済みです。{" "}
+ <Link to="/favorites" className="text-indigo-400 hover:text-indigo-300 underline">
+ お気に入りへ
+ </Link>
+ </p>
+ )}
+
+ {canRegister && (
+ <div className="flex flex-col gap-2 border-t border-gray-800 pt-5">
+ <p className="text-xs text-gray-400">
+ {loggedIn ? "このデバイスのパスキーを追加登録" : "パスキーを新規登録"}
+ </p>
+ <input
+ value={label}
+ onChange={(e) => 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"
+ />
+ <button
+ onClick={register}
+ disabled={busy}
+ className="rounded-md bg-gray-700 px-4 py-2 text-sm font-medium hover:bg-gray-600 disabled:opacity-50 transition-colors"
+ >
+ パスキーを登録
+ </button>
+ </div>
+ )}
+
+ {error && <p className="text-sm text-red-400">{error}</p>}
+ </div>
+ </div>
+ );
+}