auth(2fa): add one-time recovery codes with regenerate/status APIs
All checks were successful
CI / test (push) Successful in 40s

This commit is contained in:
2026-03-08 19:16:15 +03:00
parent f91a6493ff
commit fb812c9a39
10 changed files with 320 additions and 10 deletions

View File

@@ -5,8 +5,13 @@ export async function registerRequest(email: string, name: string, username: str
await http.post("/auth/register", { email, name, username, password });
}
export async function loginRequest(email: string, password: string, otpCode?: string): Promise<TokenPair> {
const { data } = await http.post<TokenPair>("/auth/login", { email, password, otp_code: otpCode || undefined });
export async function loginRequest(email: string, password: string, otpCode?: string, recoveryCode?: string): Promise<TokenPair> {
const { data } = await http.post<TokenPair>("/auth/login", {
email,
password,
otp_code: otpCode || undefined,
recovery_code: recoveryCode || undefined,
});
return data;
}
@@ -62,3 +67,21 @@ export async function enableTwoFactor(code: string): Promise<void> {
export async function disableTwoFactor(code: string): Promise<void> {
await http.post("/auth/2fa/disable", { code });
}
export interface TwoFactorRecoveryStatusResponse {
remaining_codes: number;
}
export interface TwoFactorRecoveryCodesResponse {
codes: string[];
}
export async function getTwoFactorRecoveryStatus(): Promise<TwoFactorRecoveryStatusResponse> {
const { data } = await http.get<TwoFactorRecoveryStatusResponse>("/auth/2fa/recovery-codes/status");
return data;
}
export async function regenerateTwoFactorRecoveryCodes(code: string): Promise<TwoFactorRecoveryCodesResponse> {
const { data } = await http.post<TwoFactorRecoveryCodesResponse>("/auth/2fa/recovery-codes/regenerate", { code });
return data;
}

View File

@@ -3,7 +3,16 @@ import { createPortal } from "react-dom";
import QRCode from "qrcode";
import { listBlockedUsers, updateMyProfile } from "../api/users";
import { requestUploadUrl, uploadToPresignedUrl } from "../api/chats";
import { disableTwoFactor, enableTwoFactor, listSessions, revokeAllSessions, revokeSession, setupTwoFactor } from "../api/auth";
import {
disableTwoFactor,
enableTwoFactor,
getTwoFactorRecoveryStatus,
listSessions,
regenerateTwoFactorRecoveryCodes,
revokeAllSessions,
revokeSession,
setupTwoFactor
} from "../api/auth";
import { getNotifications, type NotificationItem } from "../api/notifications";
import type { AuthSession, AuthUser } from "../chat/types";
import { useAuthStore } from "../store/authStore";
@@ -34,6 +43,8 @@ export function SettingsPanel({ open, onClose }: Props) {
const [twofaUrl, setTwofaUrl] = useState<string | null>(null);
const [twofaQrUrl, setTwofaQrUrl] = useState<string | null>(null);
const [twofaError, setTwofaError] = useState<string | null>(null);
const [recoveryRemaining, setRecoveryRemaining] = useState<number>(0);
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
const [privacyLastSeen, setPrivacyLastSeen] = useState<"everyone" | "contacts" | "nobody">("everyone");
const [privacyAvatar, setPrivacyAvatar] = useState<"everyone" | "contacts" | "nobody">("everyone");
const [privacyGroupInvites, setPrivacyGroupInvites] = useState<"everyone" | "contacts">("everyone");
@@ -116,6 +127,29 @@ export function SettingsPanel({ open, onClose }: Props) {
};
}, [open, page]);
useEffect(() => {
if (!open || page !== "privacy" || !me?.twofa_enabled) {
setRecoveryRemaining(0);
return;
}
let cancelled = false;
void (async () => {
try {
const status = await getTwoFactorRecoveryStatus();
if (!cancelled) {
setRecoveryRemaining(status.remaining_codes);
}
} catch {
if (!cancelled) {
setRecoveryRemaining(0);
}
}
})();
return () => {
cancelled = true;
};
}, [open, page, me?.twofa_enabled]);
useEffect(() => {
if (!open || page !== "notifications") {
return;
@@ -572,6 +606,8 @@ export function SettingsPanel({ open, onClose }: Props) {
setTwofaSecret(null);
setTwofaUrl(null);
setTwofaQrUrl(null);
setRecoveryCodes([]);
setRecoveryRemaining(0);
} catch {
setTwofaError("Invalid 2FA code");
}
@@ -583,6 +619,43 @@ export function SettingsPanel({ open, onClose }: Props) {
</div>
)}
{twofaError ? <p className="mt-2 text-xs text-red-400">{twofaError}</p> : null}
{me.twofa_enabled ? (
<div className="mt-3 rounded bg-slate-900/50 px-2 py-2">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-xs text-slate-300">Recovery codes remaining: {recoveryRemaining}</p>
<button
className="rounded bg-slate-700 px-2 py-1 text-[11px]"
onClick={async () => {
setTwofaError(null);
try {
const result = await regenerateTwoFactorRecoveryCodes(twofaCode);
setRecoveryCodes(result.codes);
setRecoveryRemaining(result.codes.length);
setTwofaCode("");
showToast("New recovery codes generated");
} catch {
setTwofaError("Invalid 2FA code for recovery codes");
}
}}
type="button"
>
Generate recovery codes
</button>
</div>
{recoveryCodes.length > 0 ? (
<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>
<div className="grid grid-cols-2 gap-1">
{recoveryCodes.map((item) => (
<code className="rounded bg-slate-900 px-1.5 py-1 text-[11px]" key={item}>
{item}
</code>
))}
</div>
</div>
) : null}
</div>
) : null}
</section>
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
<div className="mb-2 flex items-center justify-between">