summaryrefslogtreecommitdiff
path: root/app/routes
diff options
context:
space:
mode:
Diffstat (limited to 'app/routes')
-rw-r--r--app/routes/auth.login.tsx108
-rw-r--r--app/routes/home.tsx87
2 files changed, 83 insertions, 112 deletions
diff --git a/app/routes/auth.login.tsx b/app/routes/auth.login.tsx
deleted file mode 100644
index c02045b..0000000
--- a/app/routes/auth.login.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import { useState } from "react";
-import { redirect } from "react-router";
-import type { AuthenticationResponseJSON, AuthenticatorTransportFuture } from "@simplewebauthn/server";
-import type { Route } from "./+types/auth.login";
-import { getSession } from "~/lib/session.server";
-import { listCredentials } from "~/lib/db.server";
-
-export async function loader({ request }: Route.LoaderArgs) {
- const session = await getSession(request.headers.get("Cookie"));
- if (session.get("authenticated")) return redirect("/");
- if (listCredentials().length === 0) return redirect("/auth/register");
- return {};
-}
-
-function bufferToB64url(buf: ArrayBuffer): string {
- const bytes = new Uint8Array(buf);
- let binary = "";
- for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
-}
-
-export default function Login() {
- const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
- const [errorMsg, setErrorMsg] = useState("");
-
- async function handleLogin() {
- setStatus("loading");
- setErrorMsg("");
- try {
- const optRes = await fetch("/api/passkey?intent=login-options", { method: "POST" });
- if (!optRes.ok) throw new Error("オプション取得に失敗しました");
- const options = await optRes.json();
-
- const b64urlToBuffer = (b64url: string): ArrayBuffer => {
- const base64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
- const pad = "=".repeat((4 - (base64.length % 4)) % 4);
- const binary = atob(base64 + pad);
- const bytes = new Uint8Array(binary.length);
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
- return bytes.buffer;
- };
-
- const credential = await navigator.credentials.get({
- publicKey: {
- challenge: b64urlToBuffer(options.challenge as string),
- rpId: options.rpId as string,
- allowCredentials: (options.allowCredentials as Array<{ id: string; transports?: string[] }> ?? []).map((c) => ({
- id: b64urlToBuffer(c.id),
- type: "public-key" as const,
- transports: c.transports as AuthenticatorTransport[] | undefined,
- })),
- userVerification: "required" as UserVerificationRequirement,
- timeout: 60000,
- },
- }) as PublicKeyCredential | null;
-
- if (!credential) throw new Error("認証に失敗しました");
-
- const assertion = credential.response as AuthenticatorAssertionResponse;
- const authResponse: AuthenticationResponseJSON = {
- id: credential.id,
- rawId: bufferToB64url(credential.rawId),
- response: {
- clientDataJSON: bufferToB64url(assertion.clientDataJSON),
- authenticatorData: bufferToB64url(assertion.authenticatorData),
- signature: bufferToB64url(assertion.signature),
- userHandle: assertion.userHandle ? bufferToB64url(assertion.userHandle) : undefined,
- },
- authenticatorAttachment: credential.authenticatorAttachment ?? undefined,
- clientExtensionResults: credential.getClientExtensionResults(),
- type: "public-key",
- };
-
- const verRes = await fetch("/api/passkey?intent=login-verify", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(authResponse),
- });
- if (!verRes.ok) {
- const err = await verRes.json();
- throw new Error(err.error ?? "認証に失敗しました");
- }
-
- window.location.href = "/";
- } catch (e) {
- setStatus("error");
- setErrorMsg(e instanceof Error ? e.message : "エラーが発生しました");
- }
- }
-
- return (
- <div className="wrap">
- <header className="site-header">
- <h1>log</h1>
- </header>
- <div className="auth-box">
- <h2>ログイン</h2>
- <p>登録済みのパスキーで認証します。</p>
- <button className="btn" onClick={handleLogin} disabled={status === "loading"}>
- {status === "loading" ? "認証中…" : "パスキーでログイン"}
- </button>
- {status === "error" && (
- <p className="error-msg" style={{ marginTop: ".75rem" }}>{errorMsg}</p>
- )}
- </div>
- </div>
- );
-}
diff --git a/app/routes/home.tsx b/app/routes/home.tsx
index 7732326..9bea992 100644
--- a/app/routes/home.tsx
+++ b/app/routes/home.tsx
@@ -1,5 +1,5 @@
-import { useEffect, useRef } from "react";
-import { Form, Link, redirect, useNavigation } from "react-router";
+import { useEffect, useRef, useState } from "react";
+import { Form, redirect, useNavigation } from "react-router";
import type { Route } from "./+types/home";
import { createPost, deletePost, listPosts, listCredentials } from "~/lib/db.server";
import { renderMarkdown } from "~/lib/md.server";
@@ -44,12 +44,84 @@ export async function action({ request }: Route.ActionArgs) {
});
}
+function bufferToB64url(buf: ArrayBuffer): string {
+ const bytes = new Uint8Array(buf);
+ let binary = "";
+ for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
+}
+
+function b64urlToBuffer(b64url: string): ArrayBuffer {
+ const base64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
+ const pad = "=".repeat((4 - (base64.length % 4)) % 4);
+ const binary = atob(base64 + pad);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return bytes.buffer;
+}
+
export default function Home({ loaderData, actionData }: Route.ComponentProps) {
const { posts, isAuthenticated, hasCredentials } = loaderData;
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const formRef = useRef<HTMLFormElement>(null);
const wasSubmitting = useRef(false);
+ const [loginStatus, setLoginStatus] = useState<"idle" | "loading" | "error">("idle");
+ const [loginError, setLoginError] = useState("");
+
+ async function handleLogin() {
+ setLoginStatus("loading");
+ setLoginError("");
+ try {
+ const optRes = await fetch("/api/passkey?intent=login-options", { method: "POST" });
+ if (!optRes.ok) throw new Error("オプション取得に失敗しました");
+ const options = await optRes.json();
+
+ const credential = await navigator.credentials.get({
+ publicKey: {
+ challenge: b64urlToBuffer(options.challenge as string),
+ rpId: options.rpId as string,
+ allowCredentials: (options.allowCredentials as Array<{ id: string; transports?: string[] }> ?? []).map((c) => ({
+ id: b64urlToBuffer(c.id),
+ type: "public-key" as const,
+ transports: c.transports as AuthenticatorTransport[] | undefined,
+ })),
+ userVerification: "required" as UserVerificationRequirement,
+ timeout: 60000,
+ },
+ }) as PublicKeyCredential | null;
+
+ if (!credential) throw new Error("認証に失敗しました");
+
+ const assertion = credential.response as AuthenticatorAssertionResponse;
+ const verRes = await fetch("/api/passkey?intent=login-verify", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ id: credential.id,
+ rawId: bufferToB64url(credential.rawId),
+ response: {
+ clientDataJSON: bufferToB64url(assertion.clientDataJSON),
+ authenticatorData: bufferToB64url(assertion.authenticatorData),
+ signature: bufferToB64url(assertion.signature),
+ userHandle: assertion.userHandle ? bufferToB64url(assertion.userHandle) : undefined,
+ },
+ authenticatorAttachment: credential.authenticatorAttachment ?? undefined,
+ clientExtensionResults: credential.getClientExtensionResults(),
+ type: "public-key",
+ }),
+ });
+ if (!verRes.ok) {
+ const err = await verRes.json();
+ throw new Error(err.error ?? "認証に失敗しました");
+ }
+
+ window.location.href = "/";
+ } catch (e) {
+ setLoginStatus("error");
+ setLoginError(e instanceof Error ? e.message : "エラーが発生しました");
+ }
+ }
useEffect(() => {
if (navigation.state === "submitting") wasSubmitting.current = true;
@@ -69,9 +141,16 @@ export default function Home({ loaderData, actionData }: Route.ComponentProps) {
<button type="submit" className="logout-btn">ログアウト</button>
</Form>
) : hasCredentials ? (
- <Link to="/auth/login" className="auth-link">ログイン</Link>
+ <>
+ <button className="auth-link" onClick={handleLogin} disabled={loginStatus === "loading"}>
+ {loginStatus === "loading" ? "認証中…" : "ログイン"}
+ </button>
+ {loginStatus === "error" && (
+ <span className="error-msg" style={{ marginLeft: ".5rem", fontSize: ".85em" }}>{loginError}</span>
+ )}
+ </>
) : (
- <Link to="/auth/register" className="auth-link">登録</Link>
+ <a href="/auth/register" className="auth-link">登録</a>
)}
</nav>
</header>