fix(web-auth): sign out works, drop passkey settings UI, add password reveal
- 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.
This commit is contained in:
parent
e0b0b4250b
commit
d94dfc7f9a
@ -23,6 +23,7 @@ export default function AuthForm({ passkeysAvailable, next }: Props) {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [passkeyError, setPasskeyError] = useState<string | null>(null);
|
const [passkeyError, setPasskeyError] = useState<string | null>(null);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@ -180,15 +181,58 @@ export default function AuthForm({ passkeysAvailable, next }: Props) {
|
|||||||
<span className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
<span className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
||||||
Password
|
Password
|
||||||
</span>
|
</span>
|
||||||
<input
|
<div className="relative">
|
||||||
name="password"
|
<input
|
||||||
type="password"
|
name="password"
|
||||||
autoComplete="current-password"
|
type={showPassword ? "text" : "password"}
|
||||||
value={password}
|
autoComplete="current-password"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
value={password}
|
||||||
disabled={isPending}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
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]"
|
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>
|
</label>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
import { requireSession } from "@/lib/auth";
|
|
||||||
import { readPasskeys } from "@/lib/auth-store";
|
|
||||||
import PasskeyList from "./passkey-list";
|
|
||||||
|
|
||||||
export default async function PasskeysPage() {
|
|
||||||
const session = await requireSession();
|
|
||||||
const list = await readPasskeys(session.username);
|
|
||||||
const visible = list.map(({ publicKey: _pk, ...rest }) => rest);
|
|
||||||
return <PasskeyList initial={visible} username={session.username} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
@ -1,275 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useTransition } from "react";
|
|
||||||
import {
|
|
||||||
beginRegistration,
|
|
||||||
finishRegistration,
|
|
||||||
deletePasskey,
|
|
||||||
} from "@/app/auth-actions";
|
|
||||||
import { startRegistration } from "@simplewebauthn/browser";
|
|
||||||
import ConfirmDialog from "@/components/confirm-dialog";
|
|
||||||
import FormDialogShell, { Field, inputClass } from "@/components/form-dialog-shell";
|
|
||||||
import Toast, { type ToastMessage } from "@/components/toast";
|
|
||||||
|
|
||||||
type Passkey = {
|
|
||||||
id: string;
|
|
||||||
counter: number;
|
|
||||||
transports: string[];
|
|
||||||
name: string;
|
|
||||||
createdAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
initial: Passkey[];
|
|
||||||
username: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function relativeTime(iso: string): string {
|
|
||||||
const then = new Date(iso).getTime();
|
|
||||||
if (!Number.isFinite(then)) return iso;
|
|
||||||
const now = Date.now();
|
|
||||||
const seconds = Math.max(1, Math.round((now - then) / 1000));
|
|
||||||
if (seconds < 60) return `${seconds}s ago`;
|
|
||||||
const minutes = Math.round(seconds / 60);
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
const hours = Math.round(minutes / 60);
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
const days = Math.round(hours / 24);
|
|
||||||
if (days < 30) return `${days}d ago`;
|
|
||||||
return new Date(iso).toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PasskeyList({ initial, username: _username }: Props) {
|
|
||||||
const [list, setList] = useState<Passkey[]>(initial);
|
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
|
||||||
const [deviceName, setDeviceName] = useState("");
|
|
||||||
const [addError, setAddError] = useState<string | null>(null);
|
|
||||||
const [addPending, startAdd] = useTransition();
|
|
||||||
const [removeTarget, setRemoveTarget] = useState<Passkey | null>(null);
|
|
||||||
const [removePending, startRemove] = useTransition();
|
|
||||||
const [removeError, setRemoveError] = useState<string | null>(null);
|
|
||||||
const [toast, setToast] = useState<ToastMessage | null>(null);
|
|
||||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setList(initial);
|
|
||||||
}, [initial]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!addOpen) return;
|
|
||||||
setDeviceName("");
|
|
||||||
setAddError(null);
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const isPointerDevice = window.matchMedia(
|
|
||||||
"(hover: hover) and (pointer: fine)",
|
|
||||||
).matches;
|
|
||||||
if (isPointerDevice) {
|
|
||||||
requestAnimationFrame(() => nameInputRef.current?.focus());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [addOpen]);
|
|
||||||
|
|
||||||
function submitAdd() {
|
|
||||||
const name = deviceName.trim();
|
|
||||||
if (!name) {
|
|
||||||
setAddError("Device name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setAddError(null);
|
|
||||||
startAdd(async () => {
|
|
||||||
try {
|
|
||||||
const options = await beginRegistration();
|
|
||||||
const response = await startRegistration({ optionsJSON: options });
|
|
||||||
const result = await finishRegistration(response, name);
|
|
||||||
if (result.ok) {
|
|
||||||
setAddOpen(false);
|
|
||||||
setToast({ type: "success", message: "Passkey added" });
|
|
||||||
} else {
|
|
||||||
setAddError(result.error);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const message =
|
|
||||||
err instanceof Error ? err.message : "Enrollment failed";
|
|
||||||
if (
|
|
||||||
message.toLowerCase().includes("notallowed") ||
|
|
||||||
message.toLowerCase().includes("cancel")
|
|
||||||
) {
|
|
||||||
setAddError("Enrollment was cancelled");
|
|
||||||
} else {
|
|
||||||
setAddError(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitRemove() {
|
|
||||||
const target = removeTarget;
|
|
||||||
if (!target) return;
|
|
||||||
setRemoveError(null);
|
|
||||||
startRemove(async () => {
|
|
||||||
const result = await deletePasskey(target.id);
|
|
||||||
if (result.ok) {
|
|
||||||
setRemoveTarget(null);
|
|
||||||
setToast({ type: "success", message: "Passkey removed" });
|
|
||||||
} else {
|
|
||||||
setRemoveError(result.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
|
||||||
Settings
|
|
||||||
</p>
|
|
||||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl">
|
|
||||||
Passkeys
|
|
||||||
<span className="ml-2 align-middle text-base font-medium text-zinc-400">
|
|
||||||
{list.length}
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setAddOpen(true)}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-full bg-zinc-900 px-4 py-2 text-xs font-medium text-white shadow-sm transition-colors hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
<span aria-hidden="true">+</span>
|
|
||||||
Add passkey
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{list.length === 0 ? (
|
|
||||||
<div className="rounded-2xl bg-white p-10 text-center ring-1 ring-zinc-200/60">
|
|
||||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-emerald-50 ring-1 ring-emerald-100">
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.6"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="h-5 w-5 text-emerald-700"
|
|
||||||
>
|
|
||||||
<path d="M7 11V8a5 5 0 0 1 10 0v3" />
|
|
||||||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="mt-4 text-sm font-medium text-zinc-900">
|
|
||||||
No passkeys enrolled yet
|
|
||||||
</p>
|
|
||||||
<p className="mx-auto mt-1 max-w-sm text-[13px] text-zinc-500">
|
|
||||||
Add one to sign in with Face ID, Touch ID, or fingerprint on this
|
|
||||||
device.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{list.map((p) => (
|
|
||||||
<li
|
|
||||||
key={p.id}
|
|
||||||
className="flex items-center gap-3 rounded-xl bg-white px-4 py-3 ring-1 ring-zinc-200/60"
|
|
||||||
>
|
|
||||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-zinc-100 text-zinc-600">
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.8"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="h-4 w-4"
|
|
||||||
>
|
|
||||||
<path d="M7 11V8a5 5 0 0 1 10 0v3" />
|
|
||||||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm font-semibold text-zinc-900">
|
|
||||||
{p.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-[11px] text-zinc-500">
|
|
||||||
Added {relativeTime(p.createdAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setRemoveError(null);
|
|
||||||
setRemoveTarget(p);
|
|
||||||
}}
|
|
||||||
aria-label={`Remove passkey ${p.name}`}
|
|
||||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-red-50 hover:text-red-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
|
||||||
>
|
|
||||||
<span aria-hidden="true" className="text-base leading-none">
|
|
||||||
×
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormDialogShell
|
|
||||||
open={addOpen}
|
|
||||||
onCancel={() => {
|
|
||||||
if (!addPending) setAddOpen(false);
|
|
||||||
}}
|
|
||||||
onSubmit={submitAdd}
|
|
||||||
title="Add passkey"
|
|
||||||
submitLabel="Continue"
|
|
||||||
pending={addPending}
|
|
||||||
error={addError}
|
|
||||||
>
|
|
||||||
<Field label="Device name" required hint="e.g. iPhone 15, MacBook Pro">
|
|
||||||
<input
|
|
||||||
ref={nameInputRef}
|
|
||||||
value={deviceName}
|
|
||||||
onChange={(e) => setDeviceName(e.target.value)}
|
|
||||||
disabled={addPending}
|
|
||||||
autoComplete="off"
|
|
||||||
spellCheck={false}
|
|
||||||
placeholder="iPhone 15"
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<p className="text-[11px] text-zinc-500">
|
|
||||||
Your browser will prompt for Face ID, Touch ID, or fingerprint.
|
|
||||||
</p>
|
|
||||||
</FormDialogShell>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={removeTarget !== null}
|
|
||||||
onCancel={() => {
|
|
||||||
if (!removePending) setRemoveTarget(null);
|
|
||||||
}}
|
|
||||||
onConfirm={submitRemove}
|
|
||||||
title="Remove passkey?"
|
|
||||||
message={
|
|
||||||
<>
|
|
||||||
Remove the passkey{" "}
|
|
||||||
<span className="font-mono text-zinc-900">
|
|
||||||
{removeTarget?.name ?? ""}
|
|
||||||
</span>
|
|
||||||
? You’ll lose the ability to sign in from this device until you
|
|
||||||
enroll it again.
|
|
||||||
{removeError && (
|
|
||||||
<span className="mt-2 block font-mono text-[11px] text-red-600">
|
|
||||||
{removeError}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
confirmLabel="Remove"
|
|
||||||
destructive
|
|
||||||
pending={removePending}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Toast toast={toast} onDismiss={() => setToast(null)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -9,9 +9,8 @@ type Props = { username: string };
|
|||||||
|
|
||||||
export default function Nav({ username }: Props) {
|
export default function Nav({ username }: Props) {
|
||||||
const pathname = usePathname() ?? "/";
|
const pathname = usePathname() ?? "/";
|
||||||
const isPasskeys = pathname.startsWith("/cm-passkeys");
|
|
||||||
const isUsers = pathname.startsWith("/users");
|
const isUsers = pathname.startsWith("/users");
|
||||||
const isAccounts = !isUsers && !isPasskeys;
|
const isAccounts = !isUsers;
|
||||||
const initial = (username[0] ?? "?").toUpperCase();
|
const initial = (username[0] ?? "?").toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -132,19 +131,18 @@ function AccountMenu({
|
|||||||
<div className="px-3 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wider text-zinc-400 sm:hidden">
|
<div className="px-3 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wider text-zinc-400 sm:hidden">
|
||||||
{username}
|
{username}
|
||||||
</div>
|
</div>
|
||||||
<Link
|
{/*
|
||||||
href="/cm-passkeys"
|
No onClick to close the menu — the click would trigger setOpen
|
||||||
role="menuitem"
|
(which unmounts the form on next render) and the form submit
|
||||||
onClick={() => setOpen(false)}
|
in parallel; React tears down the form before the POST flushes
|
||||||
className="block px-3 py-2 text-xs text-zinc-700 transition-colors hover:bg-zinc-50 hover:text-zinc-900"
|
and sign-out silently no-ops. The Server Action redirects to
|
||||||
>
|
/cm-auth on success, which navigates away and tears the menu
|
||||||
Passkey settings
|
down naturally.
|
||||||
</Link>
|
*/}
|
||||||
<form action={logout}>
|
<form action={logout}>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className="block w-full px-3 py-2 text-left text-xs text-zinc-700 transition-colors hover:bg-zinc-50 hover:text-red-600"
|
className="block w-full px-3 py-2 text-left text-xs text-zinc-700 transition-colors hover:bg-zinc-50 hover:text-red-600"
|
||||||
>
|
>
|
||||||
Sign out
|
Sign out
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user