feat(web): /cm-auth login page with passkey + password options
This commit is contained in:
parent
0d0dfd593c
commit
9e74d75c94
227
web/app/cm-auth/auth-form.tsx
Normal file
227
web/app/cm-auth/auth-form.tsx
Normal file
@ -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<string | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(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<HTMLFormElement>) {
|
||||
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 (
|
||||
<div className="flex min-h-[calc(100vh-12rem)] items-center justify-center px-2">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="rounded-2xl bg-white p-8 ring-1 ring-zinc-200/60 sm:p-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-900 text-[11px] font-semibold tracking-tight text-white">
|
||||
CM
|
||||
</span>
|
||||
<h1 className="text-lg font-semibold tracking-tight text-zinc-900">
|
||||
Sign in
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-1 text-[13px] text-zinc-500">
|
||||
CM Bot V2 — operator console
|
||||
</p>
|
||||
|
||||
{showPasskey && (
|
||||
<>
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePasskey}
|
||||
disabled={isPending}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-zinc-900 px-4 py-2.5 text-xs font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-60"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-3.5 w-3.5"
|
||||
>
|
||||
<path d="M7 11V8a5 5 0 0 1 10 0v3" />
|
||||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
||||
</svg>
|
||||
{isPending ? "…" : "Sign in with passkey"}
|
||||
</button>
|
||||
{passkeyError && (
|
||||
<p className="font-mono text-[11px] text-red-600" role="alert">
|
||||
{passkeyError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="my-6 flex items-center gap-3">
|
||||
<span className="h-px flex-1 bg-zinc-200" />
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-zinc-400">
|
||||
or
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-zinc-200" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={`flex flex-col gap-3 ${showPasskey ? "" : "mt-6"}`}
|
||||
>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
||||
Agent ID
|
||||
</span>
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
autoFocus={pointerDevice}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={isPending}
|
||||
className="w-full min-w-0 rounded-md border-0 bg-zinc-100 px-3 py-2 font-mono text-base text-zinc-900 outline-none ring-1 ring-zinc-300 transition-colors focus:bg-white focus:ring-2 focus:ring-zinc-900 disabled:opacity-60 sm:text-[13px]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
||||
Password
|
||||
</span>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
className="w-full min-w-0 rounded-md border-0 bg-zinc-100 px-3 py-2 font-mono text-base text-zinc-900 outline-none ring-1 ring-zinc-300 transition-colors focus:bg-white focus:ring-2 focus:ring-zinc-900 disabled:opacity-60 sm:text-[13px]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || !username || !password}
|
||||
className={`mt-2 inline-flex items-center justify-center rounded-full px-4 py-2.5 text-xs font-medium transition-colors disabled:opacity-60 ${
|
||||
showPasskey
|
||||
? "bg-zinc-100 text-zinc-900 hover:bg-zinc-200"
|
||||
: "bg-zinc-900 text-white hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
{isPending ? "…" : "Sign in"}
|
||||
</button>
|
||||
|
||||
{formError && (
|
||||
<p className="font-mono text-[11px] text-red-600" role="alert">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="mt-6 flex items-center gap-2 rounded-xl bg-emerald-50 px-3 py-2 ring-1 ring-emerald-100">
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
|
||||
<p className="text-[11px] text-emerald-900/80">
|
||||
Forgot the password? Check the deployment’s{" "}
|
||||
<code className="rounded bg-white/70 px-1 py-px font-mono text-[10px] text-emerald-900">
|
||||
.env
|
||||
</code>{" "}
|
||||
for <code className="rounded bg-white/70 px-1 py-px font-mono text-[10px] text-emerald-900">CM_AGENT_PASSWORD</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
web/app/cm-auth/page.tsx
Normal file
23
web/app/cm-auth/page.tsx
Normal file
@ -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<SearchParams>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (session) {
|
||||
const dest = (await searchParams).next ?? "/";
|
||||
redirect(dest);
|
||||
}
|
||||
const passkeysAvailable = await hasPasskeysForLogin();
|
||||
const next = (await searchParams).next ?? "/";
|
||||
return <AuthForm passkeysAvailable={passkeysAvailable} next={next} />;
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
Loading…
x
Reference in New Issue
Block a user