228 lines
8.3 KiB
TypeScript
228 lines
8.3 KiB
TypeScript
"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>
|
|
);
|
|
}
|