summaryrefslogtreecommitdiff
path: root/app/routes/login.tsx
blob: 69353eae74030d514cb76a8e79856008c4400474 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
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>
  );
}