From 9e74d75c94512a18259ed2f7b906570ee542bee1 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 3 May 2026 08:29:25 +0800 Subject: [PATCH] feat(web): /cm-auth login page with passkey + password options --- web/app/cm-auth/auth-form.tsx | 227 ++++++++++++++++++++++++++++++++++ web/app/cm-auth/page.tsx | 23 ++++ 2 files changed, 250 insertions(+) create mode 100644 web/app/cm-auth/auth-form.tsx create mode 100644 web/app/cm-auth/page.tsx diff --git a/web/app/cm-auth/auth-form.tsx b/web/app/cm-auth/auth-form.tsx new file mode 100644 index 0000000..13dd4d8 --- /dev/null +++ b/web/app/cm-auth/auth-form.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useEffect, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { startAuthentication } from "@simplewebauthn/browser"; +import { + loginWithPassword, + beginAuthentication, + finishAuthentication, +} from "@/app/auth-actions"; + +type Props = { + passkeysAvailable: boolean; + next: string; +}; + +export default function AuthForm({ passkeysAvailable, next }: Props) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [platformReady, setPlatformReady] = useState(false); + const [pointerDevice, setPointerDevice] = useState(false); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [passkeyError, setPasskeyError] = useState(null); + const [formError, setFormError] = useState(null); + + useEffect(() => { + if (typeof window === "undefined") return; + setPointerDevice( + window.matchMedia("(hover: hover) and (pointer: fine)").matches, + ); + if ( + !passkeysAvailable || + typeof window.PublicKeyCredential === "undefined" || + typeof window.PublicKeyCredential + .isUserVerifyingPlatformAuthenticatorAvailable !== "function" + ) { + return; + } + let cancelled = false; + window.PublicKeyCredential + .isUserVerifyingPlatformAuthenticatorAvailable() + .then((ok) => { + if (!cancelled) setPlatformReady(Boolean(ok)); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [passkeysAvailable]); + + const showPasskey = passkeysAvailable && platformReady; + const destination = next && next.startsWith("/") ? next : "/"; + + function handlePasskey() { + setPasskeyError(null); + setFormError(null); + startTransition(async () => { + try { + const options = await beginAuthentication(); + const response = await startAuthentication({ optionsJSON: options }); + const result = await finishAuthentication(response); + if (result.ok) { + router.push(destination); + router.refresh(); + } else { + setPasskeyError(result.error); + } + } catch (err) { + const message = + err instanceof Error ? err.message : "Passkey sign-in failed"; + if ( + message.toLowerCase().includes("notallowed") || + message.toLowerCase().includes("cancel") + ) { + setPasskeyError("Sign-in was cancelled"); + } else { + setPasskeyError(message); + } + } + }); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setFormError(null); + setPasskeyError(null); + startTransition(async () => { + const result = await loginWithPassword(username, password); + if (result.ok) { + router.push(destination); + router.refresh(); + } else { + setFormError(result.error); + } + }); + } + + return ( +
+
+
+
+ + CM + +

+ Sign in +

+
+

+ CM Bot V2 — operator console +

+ + {showPasskey && ( + <> +
+ + {passkeyError && ( +

+ {passkeyError} +

+ )} +
+ +
+ + + or + + +
+ + )} + +
+ + + + + + + {formError && ( +

+ {formError} +

+ )} +
+ +
+ +

+ Forgot the password? Check the deployment’s{" "} + + .env + {" "} + for CM_AGENT_PASSWORD. +

+
+
+
+
+ ); +} diff --git a/web/app/cm-auth/page.tsx b/web/app/cm-auth/page.tsx new file mode 100644 index 0000000..5d1c8c5 --- /dev/null +++ b/web/app/cm-auth/page.tsx @@ -0,0 +1,23 @@ +import { hasPasskeysForLogin } from "@/app/auth-actions"; +import { getSession } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import AuthForm from "./auth-form"; + +type SearchParams = { next?: string }; + +export default async function AuthPage({ + searchParams, +}: { + searchParams: Promise; +}) { + const session = await getSession(); + if (session) { + const dest = (await searchParams).next ?? "/"; + redirect(dest); + } + const passkeysAvailable = await hasPasskeysForLogin(); + const next = (await searchParams).next ?? "/"; + return ; +} + +export const dynamic = "force-dynamic";