feat(settings): harden 2fa recovery code UX with warning copy and download
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-03-08 21:02:49 +03:00
parent c222c93628
commit eb27371f0d
2 changed files with 55 additions and 1 deletions

View File

@@ -38,7 +38,7 @@ Legend:
29. Archive - `DONE` 29. Archive - `DONE`
30. Blacklist - `DONE` 30. Blacklist - `DONE`
31. Privacy - `PARTIAL` (avatar/last-seen/group-invites + PM policy `everyone|contacts|nobody`; group-invite `nobody` is available in API and web settings; integration tests cover PM policy matrix (`everyone/contacts/nobody`), group-invite policy matrix (`everyone/contacts/nobody`), and private chat counterpart visibility for `nobody/contacts`, remaining UX/matrix hardening) 31. Privacy - `PARTIAL` (avatar/last-seen/group-invites + PM policy `everyone|contacts|nobody`; group-invite `nobody` is available in API and web settings; integration tests cover PM policy matrix (`everyone/contacts/nobody`), group-invite policy matrix (`everyone/contacts/nobody`), and private chat counterpart visibility for `nobody/contacts`, remaining UX/matrix hardening)
32. Security - `PARTIAL` (sessions + revoke + 2FA base + access-session visibility; integration tests cover single-session revoke and revoke-all invalidation/force-disconnect; 2FA setup now blocked after enable to prevent secret re-issuance; one-time recovery codes added and covered for normalization/lifecycle (`remaining_codes` decrement + one-time usage); web settings now has safer revoke UX with confirmation/loading/error feedback; web auth panel supports recovery-code login) 32. Security - `PARTIAL` (sessions + revoke + 2FA base + access-session visibility; integration tests cover single-session revoke and revoke-all invalidation/force-disconnect; 2FA setup now blocked after enable to prevent secret re-issuance; one-time recovery codes added and covered for normalization/lifecycle (`remaining_codes` decrement + one-time usage); web auth panel supports recovery-code login; settings now warns when recovery codes are empty and provides copy/download actions for freshly generated codes)
33. Realtime Events - `DONE` (connect/disconnect/send/receive/typing/read/delivered/online/offline + chat/message updates + chat_deleted) 33. Realtime Events - `DONE` (connect/disconnect/send/receive/typing/read/delivered/online/offline + chat/message updates + chat_deleted)
34. Sync - `PARTIAL` (cross-device via backend state + realtime; reconciliation improved for loaded chats/messages, chat-info panel hot-refreshes on `chat_updated`, delete/leave updates realtime subscriptions, full-chat delete emits `chat_deleted`) 34. Sync - `PARTIAL` (cross-device via backend state + realtime; reconciliation improved for loaded chats/messages, chat-info panel hot-refreshes on `chat_updated`, delete/leave updates realtime subscriptions, full-chat delete emits `chat_deleted`)
35. Additional - `PARTIAL` (drafts/link preview partial/autoload media basic) 35. Additional - `PARTIAL` (drafts/link preview partial/autoload media basic)

View File

@@ -240,6 +240,37 @@ export function SettingsPanel({ open, onClose }: Props) {
} }
} }
async function copyRecoveryCodes(codes: string[]) {
if (!codes.length) {
showToast("No recovery codes to copy");
return;
}
try {
await navigator.clipboard.writeText(codes.join("\n"));
showToast("Recovery codes copied");
} catch {
showToast("Failed to copy recovery codes");
}
}
function downloadRecoveryCodes(codes: string[]) {
if (!codes.length) {
showToast("No recovery codes to download");
return;
}
const content = `BenyaMessenger 2FA Recovery Codes\n\n${codes.join("\n")}\n`;
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "benya-recovery-codes.txt";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
showToast("Recovery codes downloaded");
}
return createPortal( return createPortal(
<div className="fixed inset-0 z-[150] bg-slate-950/60" onClick={onClose}> <div className="fixed inset-0 z-[150] bg-slate-950/60" onClick={onClose}>
<aside <aside
@@ -633,6 +664,11 @@ export function SettingsPanel({ open, onClose }: Props) {
{twofaError ? <p className="mt-2 text-xs text-red-400">{twofaError}</p> : null} {twofaError ? <p className="mt-2 text-xs text-red-400">{twofaError}</p> : null}
{me.twofa_enabled ? ( {me.twofa_enabled ? (
<div className="mt-3 rounded bg-slate-900/50 px-2 py-2"> <div className="mt-3 rounded bg-slate-900/50 px-2 py-2">
{recoveryRemaining === 0 ? (
<p className="mb-2 rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-[11px] text-amber-200">
You have no recovery codes. Generate and save them to avoid lockout.
</p>
) : null}
<div className="mb-2 flex items-center justify-between gap-2"> <div className="mb-2 flex items-center justify-between gap-2">
<p className="text-xs text-slate-300">Recovery codes remaining: {recoveryRemaining}</p> <p className="text-xs text-slate-300">Recovery codes remaining: {recoveryRemaining}</p>
<button <button
@@ -657,6 +693,24 @@ export function SettingsPanel({ open, onClose }: Props) {
{recoveryCodes.length > 0 ? ( {recoveryCodes.length > 0 ? (
<div className="rounded border border-amber-500/40 bg-amber-500/10 p-2"> <div className="rounded border border-amber-500/40 bg-amber-500/10 p-2">
<p className="mb-1 text-[11px] text-amber-200">Save these codes now. They are shown only once.</p> <p className="mb-1 text-[11px] text-amber-200">Save these codes now. They are shown only once.</p>
<div className="mb-2 flex items-center gap-2">
<button
className="rounded bg-slate-700 px-2 py-1 text-[11px]"
onClick={() => {
void copyRecoveryCodes(recoveryCodes);
}}
type="button"
>
Copy
</button>
<button
className="rounded bg-slate-700 px-2 py-1 text-[11px]"
onClick={() => downloadRecoveryCodes(recoveryCodes)}
type="button"
>
Download .txt
</button>
</div>
<div className="grid grid-cols-2 gap-1"> <div className="grid grid-cols-2 gap-1">
{recoveryCodes.map((item) => ( {recoveryCodes.map((item) => (
<code className="rounded bg-slate-900 px-1.5 py-1 text-[11px]" key={item}> <code className="rounded bg-slate-900 px-1.5 py-1 text-[11px]" key={item}>