auth(2fa): add one-time recovery codes with regenerate/status APIs
All checks were successful
CI / test (push) Successful in 40s
All checks were successful
CI / test (push) Successful in 40s
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user