- nav: the menu's onClick={setOpen(false)} on the Sign-out submit button
was racing the form POST — React unmounted the form before the request
flushed, so logout silently no-op'd. Drop the onClick; the Server
Action's redirect to /cm-auth tears the menu down naturally.
- nav: drop the 'Passkey settings' link (passkey UI is gone).
- Delete web/app/cm-passkeys/. The WebAuthn Server Actions in
auth-actions.ts are unreachable now (hasPasskeysForLogin always returns
false in practice — no enrollment path), so the 'Sign in with passkey'
button on /cm-auth never renders. The action handlers stay in case we
reinstate enrollment later; they're dead code but harmless.
- auth-form: add an eye-toggle button on the password field that flips
type=password ↔ text. tabIndex=-1 so Tab still goes input → submit
without stopping at the toggle. Right-padded the input (pr-10) so the
glyph doesn't overlap typed characters.
268 lines
10 KiB
TypeScript
268 lines
10 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);
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
|
|
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>
|
|
<div className="relative">
|
|
<input
|
|
name="password"
|
|
type={showPassword ? "text" : "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 pr-10 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]"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword((v) => !v)}
|
|
disabled={isPending}
|
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
|
aria-pressed={showPassword}
|
|
tabIndex={-1}
|
|
className="absolute inset-y-0 right-0 flex items-center justify-center px-3 text-zinc-400 transition-colors hover:text-zinc-700 focus:outline-none focus-visible:text-zinc-700 disabled:opacity-60"
|
|
>
|
|
{showPassword ? (
|
|
<svg
|
|
aria-hidden="true"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className="h-4 w-4"
|
|
>
|
|
<path d="M17.94 17.94A10.94 10.94 0 0 1 12 19c-7 0-11-7-11-7a19.5 19.5 0 0 1 5.06-5.94" />
|
|
<path d="M9.9 4.24A10.94 10.94 0 0 1 12 4c7 0 11 7 11 7a19.6 19.6 0 0 1-3.17 4.19" />
|
|
<path d="M14.12 14.12a3 3 0 1 1-4.24-4.24" />
|
|
<line x1="1" y1="1" x2="23" y2="23" />
|
|
</svg>
|
|
) : (
|
|
<svg
|
|
aria-hidden="true"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className="h-4 w-4"
|
|
>
|
|
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</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? Please contact IT.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|