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