SPB Git
5.9 KB · 24 lines tsx
1"use client";2import { useEffect, useState } from "react";3import { useRouter } from "next/navigation";4import { startRegistration } from "@simplewebauthn/browser";5import { api } from "./api";6export function SecurityPanel({ email, totpEnabled, passkeys, mustChange }: { email: string; totpEnabled: boolean; passkeys: any[]; mustChange: boolean }) {7  const r = useRouter(); const [msg, setMsg] = useState(""); const [setup, setSetup] = useState<any>(null); const [sessions, setSessions] = useState<any[]>([]);8  useEffect(() => { api("/api/auth/sessions").then(setSessions).catch(() => {}); }, []);9  const note = (t: string) => setMsg(t);10  return <div className="mt-6 space-y-6">11    {msg && <p role="status" className="rounded-xl border border-line bg-bg-3/60 p-3 text-sm">{msg}</p>}12    <section className="card p-5"><h2 className="font-semibold">Password</h2><p className="text-xs text-fg-3">{email} · at least 14 characters with upper/lower case and a digit. Changing it signs out other sessions.</p>13      <form className="mt-3 grid gap-3 sm:grid-cols-3" onSubmit={async (e) => { e.preventDefault(); const fd = new FormData(e.currentTarget); try { await api("/api/auth/password", { method: "POST", body: JSON.stringify({ current: fd.get("current"), next: fd.get("next") }) }); note("Password changed."); if (mustChange) r.push("/admin"); r.refresh(); } catch (e) { note((e as Error).message); } }}>14        <input name="current" type="password" placeholder="Current password" autoComplete="current-password" className="input" required /><input name="next" type="password" placeholder="New password" autoComplete="new-password" className="input" required minLength={14} /><button className="btn-primary">Change password</button></form></section>15    <section className="card p-5"><h2 className="font-semibold">Authenticator app (TOTP) — {totpEnabled ? <span className="text-ok">enabled</span> : <span className="text-warn">disabled</span>}</h2>16      {!totpEnabled ? (!setup ? <button onClick={async () => setSetup(await api("/api/auth/totp", { method: "POST", body: JSON.stringify({ step: "setup" }) }))} className="btn-ghost mt-3" disabled={mustChange}>Set up TOTP</button> : <form className="mt-3 flex flex-wrap items-center gap-4" onSubmit={async (e) => { e.preventDefault(); const code = new FormData(e.currentTarget).get("code"); try { await api("/api/auth/totp", { method: "POST", body: JSON.stringify({ step: "enable", code, secret: setup.secret }) }); note("TOTP enabled. Other sessions were signed out."); setSetup(null); r.refresh(); } catch (e) { note((e as Error).message); } }}><img src={setup.qr} alt="TOTP QR code" width={180} height={180} className="rounded-lg border border-line" /><div className="space-y-2 text-sm"><p>Scan with your authenticator, then enter the 6-digit code.</p><p className="font-mono text-xs text-fg-3 break-all">{setup.secret}</p><input name="code" inputMode="numeric" pattern="\d{6}" className="input w-40 font-mono tracking-widest" required /><button className="btn-primary ml-2">Enable</button></div></form>)17      : <form className="mt-3 flex items-center gap-2" onSubmit={async (e) => { e.preventDefault(); const code = new FormData(e.currentTarget).get("code"); try { await api("/api/auth/totp", { method: "POST", body: JSON.stringify({ step: "disable", code }) }); note("TOTP disabled."); r.refresh(); } catch (e) { note((e as Error).message); } }}><input name="code" inputMode="numeric" pattern="\d{6}" placeholder="Code to disable" className="input w-44 font-mono" required /><button className="btn-ghost">Disable TOTP</button></form>}</section>18    <section className="card p-5"><h2 className="font-semibold">Passkeys (WebAuthn)</h2><p className="text-xs text-fg-3">Passkeys sign in with Face ID / Touch ID / a security key and count as MFA. Recommended as the primary method.</p>19      <ul className="mt-3 space-y-1 text-sm">{passkeys.map((p) => <li key={p.id} className="flex items-center justify-between rounded-lg border border-line px-3 py-2"><span>{p.label} <span className="text-xs text-fg-3">· added {new Date(p.createdAt).toLocaleDateString("en-CA")}{p.lastUsedAt ? ` · used ${new Date(p.lastUsedAt).toLocaleDateString("en-CA")}` : ""}</span></span><button onClick={async () => { if (confirm("Remove this passkey?")) { await api("/api/auth/passkey", { method: "DELETE", body: JSON.stringify({ id: p.id }) }); r.refresh(); } }} className="btn-ghost h-7 px-2 text-xs">Remove</button></li>)}{!passkeys.length && <li className="text-fg-3">No passkeys registered.</li>}</ul>20      <button onClick={async () => { try { const o = await api("/api/auth/passkey?step=register"); const resp = await startRegistration({ optionsJSON: o.options }); await api("/api/auth/passkey", { method: "POST", body: JSON.stringify({ challengeId: o.challengeId, response: resp, label: prompt("Label for this passkey", "This device") ?? "Passkey" }) }); note("Passkey registered."); r.refresh(); } catch (e) { note((e as Error).message); } }} className="btn-ghost mt-3" disabled={mustChange}>Add a passkey</button></section>21    <section className="card p-5"><h2 className="font-semibold">Active sessions</h2><ul className="mt-3 space-y-1 text-sm">{sessions.map((s) => <li key={s.id} className="flex items-center justify-between rounded-lg border border-line px-3 py-2"><span className="truncate">{s.current ? "This session" : "Session"} · {s.ip} · <span className="text-xs text-fg-3">{s.userAgent?.slice(0, 60)}</span> · last seen {new Date(s.lastSeenAt).toLocaleString("en-CA")}</span>{!s.current && <button onClick={async () => { await api("/api/auth/sessions", { method: "DELETE", body: JSON.stringify({ id: s.id }) }); setSessions((l) => l.filter((x) => x.id !== s.id)); }} className="btn-ghost h-7 px-2 text-xs">Revoke</button>}</li>)}</ul>{sessions.length > 1 && <button onClick={async () => { await api("/api/auth/sessions", { method: "DELETE", body: JSON.stringify({ others: true }) }); setSessions((l) => l.filter((x) => x.current)); }} className="btn-ghost mt-3">Sign out other sessions</button>}</section>22  </div>;23}24
Files · laptop-initial-20261102
Menu

Theme