feat(auth): support 2fa recovery code login in web auth panel
Some checks are pending
CI / test (push) Has started running
Some checks are pending
CI / test (push) Has started running
This commit is contained in:
@@ -14,6 +14,8 @@ export function AuthPanel() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [recoveryCode, setRecoveryCode] = useState("");
|
||||
const [useRecoveryCode, setUseRecoveryCode] = useState(false);
|
||||
const [checkingEmail, setCheckingEmail] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
@@ -52,13 +54,18 @@ export function AuthPanel() {
|
||||
return;
|
||||
}
|
||||
|
||||
await login(email, password, otpCode.trim() || undefined);
|
||||
await login(
|
||||
email,
|
||||
password,
|
||||
useRecoveryCode ? undefined : (otpCode.trim() || undefined),
|
||||
useRecoveryCode ? (recoveryCode.trim() || undefined) : undefined
|
||||
);
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
if (step === "password" && message.toLowerCase().includes("2fa code required")) {
|
||||
setStep("otp");
|
||||
setError(null);
|
||||
setSuccess("Enter 2FA code.");
|
||||
setSuccess("Enter 2FA code or use a recovery code.");
|
||||
return;
|
||||
}
|
||||
setError(message);
|
||||
@@ -70,6 +77,8 @@ export function AuthPanel() {
|
||||
setStep("email");
|
||||
setPassword("");
|
||||
setOtpCode("");
|
||||
setRecoveryCode("");
|
||||
setUseRecoveryCode(false);
|
||||
setName("");
|
||||
setUsername("");
|
||||
setError(null);
|
||||
@@ -142,12 +151,30 @@ export function AuthPanel() {
|
||||
) : null}
|
||||
|
||||
{step === "otp" ? (
|
||||
<>
|
||||
<button
|
||||
className="text-xs text-sky-300 hover:text-sky-200"
|
||||
onClick={() => setUseRecoveryCode((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
{useRecoveryCode ? "Use 2FA code instead" : "Use recovery code"}
|
||||
</button>
|
||||
{useRecoveryCode ? (
|
||||
<input
|
||||
className="w-full rounded bg-slate-800 px-3 py-2"
|
||||
placeholder="Recovery code"
|
||||
value={recoveryCode}
|
||||
onChange={(e) => setRecoveryCode(e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, "").slice(0, 24))}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="w-full rounded bg-slate-800 px-3 py-2"
|
||||
placeholder="2FA code"
|
||||
value={otpCode}
|
||||
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, "").slice(0, 8))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<button className="w-full rounded bg-accent px-3 py-2 font-semibold text-black disabled:opacity-50" disabled={isBusy} type="submit">
|
||||
|
||||
@@ -8,7 +8,7 @@ interface AuthState {
|
||||
me: AuthUser | null;
|
||||
loading: boolean;
|
||||
setTokens: (accessToken: string, refreshToken: string) => void;
|
||||
login: (email: string, password: string, otpCode?: string) => Promise<void>;
|
||||
login: (email: string, password: string, otpCode?: string, recoveryCode?: string) => Promise<void>;
|
||||
loadMe: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
logout: () => void;
|
||||
@@ -27,10 +27,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
localStorage.setItem(REFRESH_KEY, refreshToken);
|
||||
set({ accessToken, refreshToken });
|
||||
},
|
||||
login: async (email, password, otpCode) => {
|
||||
login: async (email, password, otpCode, recoveryCode) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const data = await loginRequest(email, password, otpCode);
|
||||
const data = await loginRequest(email, password, otpCode, recoveryCode);
|
||||
get().setTokens(data.access_token, data.refresh_token);
|
||||
await get().loadMe();
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user