mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-01-21 19:25:43 +00:00
Add TOTP-based two-factor authentication
- Add 2FA setup, enable, disable, and backup code management - Integrate 2FA challenge flow into login process - Add frontend modal for 2FA configuration - Support backup codes for account recovery
This commit is contained in:
@@ -1,9 +1,22 @@
|
||||
import * as api from "./base";
|
||||
import type { TokenResponse } from "./responseTypes";
|
||||
import type { TokenResponse, TwoFactorChallengeResponse } from "./responseTypes";
|
||||
|
||||
export async function getToken(identity: string, secret: string): Promise<TokenResponse> {
|
||||
export type LoginResponse = TokenResponse | TwoFactorChallengeResponse;
|
||||
|
||||
export function isTwoFactorChallenge(response: LoginResponse): response is TwoFactorChallengeResponse {
|
||||
return "requires2fa" in response && response.requires2fa === true;
|
||||
}
|
||||
|
||||
export async function getToken(identity: string, secret: string): Promise<LoginResponse> {
|
||||
return await api.post({
|
||||
url: "/tokens",
|
||||
data: { identity, secret },
|
||||
});
|
||||
}
|
||||
|
||||
export async function verify2FA(challengeToken: string, code: string): Promise<TokenResponse> {
|
||||
return await api.post({
|
||||
url: "/tokens/2fa",
|
||||
data: { challengeToken, code },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,3 +60,4 @@ export * from "./updateStream";
|
||||
export * from "./updateUser";
|
||||
export * from "./uploadCertificate";
|
||||
export * from "./validateCertificate";
|
||||
export * from "./twoFactor";
|
||||
|
||||
@@ -25,3 +25,22 @@ export interface VersionCheckResponse {
|
||||
latest: string | null;
|
||||
updateAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface TwoFactorChallengeResponse {
|
||||
requires2fa: boolean;
|
||||
challengeToken: string;
|
||||
}
|
||||
|
||||
export interface TwoFactorStatusResponse {
|
||||
enabled: boolean;
|
||||
backupCodesRemaining: number;
|
||||
}
|
||||
|
||||
export interface TwoFactorSetupResponse {
|
||||
secret: string;
|
||||
otpauthUrl: string;
|
||||
}
|
||||
|
||||
export interface TwoFactorEnableResponse {
|
||||
backupCodes: string[];
|
||||
}
|
||||
|
||||
58
frontend/src/api/backend/twoFactor.ts
Normal file
58
frontend/src/api/backend/twoFactor.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { camelizeKeys, decamelizeKeys } from "humps";
|
||||
import AuthStore from "src/modules/AuthStore";
|
||||
import type {
|
||||
TwoFactorEnableResponse,
|
||||
TwoFactorSetupResponse,
|
||||
TwoFactorStatusResponse,
|
||||
} from "./responseTypes";
|
||||
import * as api from "./base";
|
||||
|
||||
export async function get2FAStatus(userId: number | "me"): Promise<TwoFactorStatusResponse> {
|
||||
return await api.get({
|
||||
url: `/users/${userId}/2fa`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function start2FASetup(userId: number | "me"): Promise<TwoFactorSetupResponse> {
|
||||
return await api.post({
|
||||
url: `/users/${userId}/2fa/setup`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function enable2FA(userId: number | "me", code: string): Promise<TwoFactorEnableResponse> {
|
||||
return await api.put({
|
||||
url: `/users/${userId}/2fa/enable`,
|
||||
data: { code },
|
||||
});
|
||||
}
|
||||
|
||||
export async function disable2FA(userId: number | "me", code: string): Promise<{ success: boolean }> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (AuthStore.token) {
|
||||
headers.Authorization = `Bearer ${AuthStore.token.token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/users/${userId}/2fa`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
body: JSON.stringify(decamelizeKeys({ code })),
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error?.messageI18n || payload.error?.message || "Failed to disable 2FA");
|
||||
}
|
||||
return camelizeKeys(payload) as { success: boolean };
|
||||
}
|
||||
|
||||
export async function regenerateBackupCodes(
|
||||
userId: number | "me",
|
||||
code: string,
|
||||
): Promise<TwoFactorEnableResponse> {
|
||||
return await api.post({
|
||||
url: `/users/${userId}/2fa/backup-codes`,
|
||||
data: { code },
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { IconLock, IconLogout, IconUser } from "@tabler/icons-react";
|
||||
import { IconLock, IconLogout, IconShieldLock, IconUser } from "@tabler/icons-react";
|
||||
import { LocalePicker, NavLink, ThemeSwitcher } from "src/components";
|
||||
import { useAuthState } from "src/context";
|
||||
import { useUser } from "src/hooks";
|
||||
import { T } from "src/locale";
|
||||
import { showChangePasswordModal, showUserModal } from "src/modals";
|
||||
import { showChangePasswordModal, showTwoFactorModal, showUserModal } from "src/modals";
|
||||
import styles from "./SiteHeader.module.css";
|
||||
|
||||
export function SiteHeader() {
|
||||
@@ -108,6 +108,17 @@ export function SiteHeader() {
|
||||
<IconLock width={18} />
|
||||
<T id="user.change-password" />
|
||||
</a>
|
||||
<a
|
||||
href="?"
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
showTwoFactorModal("me");
|
||||
}}
|
||||
>
|
||||
<IconShieldLock width={18} />
|
||||
<T id="user.two-factor" />
|
||||
</a>
|
||||
<div className="dropdown-divider" />
|
||||
<a
|
||||
href="?"
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { createContext, type ReactNode, useContext, useState } from "react";
|
||||
import { useIntervalWhen } from "rooks";
|
||||
import { getToken, loginAsUser, refreshToken, type TokenResponse } from "src/api/backend";
|
||||
import {
|
||||
getToken,
|
||||
isTwoFactorChallenge,
|
||||
loginAsUser,
|
||||
refreshToken,
|
||||
verify2FA,
|
||||
type TokenResponse,
|
||||
} from "src/api/backend";
|
||||
import AuthStore from "src/modules/AuthStore";
|
||||
|
||||
// 2FA challenge state
|
||||
export interface TwoFactorChallenge {
|
||||
challengeToken: string;
|
||||
}
|
||||
|
||||
// Context
|
||||
export interface AuthContextType {
|
||||
authenticated: boolean;
|
||||
twoFactorChallenge: TwoFactorChallenge | null;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
verifyTwoFactor: (code: string) => Promise<void>;
|
||||
cancelTwoFactor: () => void;
|
||||
loginAs: (id: number) => Promise<void>;
|
||||
logout: () => void;
|
||||
token?: string;
|
||||
@@ -24,17 +39,35 @@ interface Props {
|
||||
function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [authenticated, setAuthenticated] = useState(AuthStore.hasActiveToken());
|
||||
const [twoFactorChallenge, setTwoFactorChallenge] = useState<TwoFactorChallenge | null>(null);
|
||||
|
||||
const handleTokenUpdate = (response: TokenResponse) => {
|
||||
AuthStore.set(response);
|
||||
setAuthenticated(true);
|
||||
setTwoFactorChallenge(null);
|
||||
};
|
||||
|
||||
const login = async (identity: string, secret: string) => {
|
||||
const response = await getToken(identity, secret);
|
||||
if (isTwoFactorChallenge(response)) {
|
||||
setTwoFactorChallenge({ challengeToken: response.challengeToken });
|
||||
return;
|
||||
}
|
||||
handleTokenUpdate(response);
|
||||
};
|
||||
|
||||
const verifyTwoFactor = async (code: string) => {
|
||||
if (!twoFactorChallenge) {
|
||||
throw new Error("No 2FA challenge pending");
|
||||
}
|
||||
const response = await verify2FA(twoFactorChallenge.challengeToken, code);
|
||||
handleTokenUpdate(response);
|
||||
};
|
||||
|
||||
const cancelTwoFactor = () => {
|
||||
setTwoFactorChallenge(null);
|
||||
};
|
||||
|
||||
const loginAs = async (id: number) => {
|
||||
const response = await loginAsUser(id);
|
||||
AuthStore.add(response);
|
||||
@@ -69,7 +102,15 @@ function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props)
|
||||
true,
|
||||
);
|
||||
|
||||
const value = { authenticated, login, logout, loginAs };
|
||||
const value = {
|
||||
authenticated,
|
||||
twoFactorChallenge,
|
||||
login,
|
||||
verifyTwoFactor,
|
||||
cancelTwoFactor,
|
||||
loginAs,
|
||||
logout,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,61 @@
|
||||
{
|
||||
"2fa.backup-codes-remaining": {
|
||||
"defaultMessage": "Backup codes remaining: {count}"
|
||||
},
|
||||
"2fa.backup-warning": {
|
||||
"defaultMessage": "Save these backup codes in a secure place. Each code can only be used once."
|
||||
},
|
||||
"2fa.disable": {
|
||||
"defaultMessage": "Disable Two-Factor Authentication"
|
||||
},
|
||||
"2fa.disable-confirm": {
|
||||
"defaultMessage": "Disable 2FA"
|
||||
},
|
||||
"2fa.disable-warning": {
|
||||
"defaultMessage": "Disabling two-factor authentication will make your account less secure."
|
||||
},
|
||||
"2fa.disabled": {
|
||||
"defaultMessage": "Disabled"
|
||||
},
|
||||
"2fa.done": {
|
||||
"defaultMessage": "I have saved my backup codes"
|
||||
},
|
||||
"2fa.enable": {
|
||||
"defaultMessage": "Enable Two-Factor Authentication"
|
||||
},
|
||||
"2fa.enabled": {
|
||||
"defaultMessage": "Enabled"
|
||||
},
|
||||
"2fa.enter-code": {
|
||||
"defaultMessage": "Enter verification code"
|
||||
},
|
||||
"2fa.enter-code-disable": {
|
||||
"defaultMessage": "Enter verification code to disable"
|
||||
},
|
||||
"2fa.regenerate": {
|
||||
"defaultMessage": "Regenerate"
|
||||
},
|
||||
"2fa.regenerate-backup": {
|
||||
"defaultMessage": "Regenerate Backup Codes"
|
||||
},
|
||||
"2fa.regenerate-instructions": {
|
||||
"defaultMessage": "Enter a verification code to generate new backup codes. Your old codes will be invalidated."
|
||||
},
|
||||
"2fa.secret-key": {
|
||||
"defaultMessage": "Secret Key"
|
||||
},
|
||||
"2fa.setup-instructions": {
|
||||
"defaultMessage": "Scan this QR code with your authenticator app, or enter the secret manually."
|
||||
},
|
||||
"2fa.status": {
|
||||
"defaultMessage": "Status"
|
||||
},
|
||||
"2fa.title": {
|
||||
"defaultMessage": "Two-Factor Authentication"
|
||||
},
|
||||
"2fa.verify-enable": {
|
||||
"defaultMessage": "Verify and Enable"
|
||||
},
|
||||
"access-list": {
|
||||
"defaultMessage": "Access List"
|
||||
},
|
||||
@@ -386,6 +443,21 @@
|
||||
"loading": {
|
||||
"defaultMessage": "Loading…"
|
||||
},
|
||||
"login.2fa-code": {
|
||||
"defaultMessage": "Verification Code"
|
||||
},
|
||||
"login.2fa-code-placeholder": {
|
||||
"defaultMessage": "Enter code"
|
||||
},
|
||||
"login.2fa-description": {
|
||||
"defaultMessage": "Enter the code from your authenticator app"
|
||||
},
|
||||
"login.2fa-title": {
|
||||
"defaultMessage": "Two-Factor Authentication"
|
||||
},
|
||||
"login.2fa-verify": {
|
||||
"defaultMessage": "Verify"
|
||||
},
|
||||
"login.title": {
|
||||
"defaultMessage": "Login to your account"
|
||||
},
|
||||
@@ -674,6 +746,9 @@
|
||||
"user.switch-light": {
|
||||
"defaultMessage": "Switch to Light mode"
|
||||
},
|
||||
"user.two-factor": {
|
||||
"defaultMessage": "Two-Factor Auth"
|
||||
},
|
||||
"username": {
|
||||
"defaultMessage": "Username"
|
||||
},
|
||||
|
||||
368
frontend/src/modals/TwoFactorModal.tsx
Normal file
368
frontend/src/modals/TwoFactorModal.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import EasyModal, { type InnerModalProps } from "ez-modal-react";
|
||||
import { Field, Form, Formik } from "formik";
|
||||
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { Alert } from "react-bootstrap";
|
||||
import Modal from "react-bootstrap/Modal";
|
||||
import {
|
||||
disable2FA,
|
||||
enable2FA,
|
||||
get2FAStatus,
|
||||
regenerateBackupCodes,
|
||||
start2FASetup,
|
||||
} from "src/api/backend";
|
||||
import { Button } from "src/components";
|
||||
import { T } from "src/locale";
|
||||
import { validateString } from "src/modules/Validations";
|
||||
|
||||
type Step = "loading" | "status" | "setup" | "verify" | "backup" | "disable";
|
||||
|
||||
const showTwoFactorModal = (id: number | "me") => {
|
||||
EasyModal.show(TwoFactorModal, { id });
|
||||
};
|
||||
|
||||
interface Props extends InnerModalProps {
|
||||
id: number | "me";
|
||||
}
|
||||
|
||||
const TwoFactorModal = EasyModal.create(({ id, visible, remove }: Props) => {
|
||||
const [error, setError] = useState<ReactNode | null>(null);
|
||||
const [step, setStep] = useState<Step>("loading");
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [backupCodesRemaining, setBackupCodesRemaining] = useState(0);
|
||||
const [setupData, setSetupData] = useState<{ secret: string; otpauthUrl: string } | null>(null);
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
try {
|
||||
const status = await get2FAStatus(id);
|
||||
setIsEnabled(status.enabled);
|
||||
setBackupCodesRemaining(status.backupCodesRemaining);
|
||||
setStep("status");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to load 2FA status");
|
||||
setStep("status");
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, [loadStatus]);
|
||||
|
||||
const handleStartSetup = async () => {
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const data = await start2FASetup(id);
|
||||
setSetupData(data);
|
||||
setStep("setup");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to start 2FA setup");
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleVerify = async (values: { code: string }) => {
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await enable2FA(id, values.code);
|
||||
setBackupCodes(result.backupCodes);
|
||||
setStep("backup");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to enable 2FA");
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleDisable = async (values: { code: string }) => {
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await disable2FA(id, values.code);
|
||||
setIsEnabled(false);
|
||||
setStep("status");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to disable 2FA");
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleRegenerateBackup = async (values: { code: string }) => {
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await regenerateBackupCodes(id, values.code);
|
||||
setBackupCodes(result.backupCodes);
|
||||
setStep("backup");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to regenerate backup codes");
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleBackupDone = () => {
|
||||
setIsEnabled(true);
|
||||
setBackupCodes([]);
|
||||
loadStatus();
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (step === "loading") {
|
||||
return (
|
||||
<div className="text-center py-4">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "status") {
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className="mb-4">
|
||||
<div className="d-flex align-items-center justify-content-between mb-2">
|
||||
<span className="fw-bold">
|
||||
<T id="2fa.status" />
|
||||
</span>
|
||||
<span className={`badge text-white ${isEnabled ? "bg-success" : "bg-secondary"}`}>
|
||||
{isEnabled ? <T id="2fa.enabled" /> : <T id="2fa.disabled" />}
|
||||
</span>
|
||||
</div>
|
||||
{isEnabled && (
|
||||
<p className="text-muted small mb-0">
|
||||
<T id="2fa.backup-codes-remaining" data={{ count: backupCodesRemaining }} />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!isEnabled ? (
|
||||
<Button
|
||||
fullWidth
|
||||
color="azure"
|
||||
onClick={handleStartSetup}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
<T id="2fa.enable" />
|
||||
</Button>
|
||||
) : (
|
||||
<div className="d-flex flex-column gap-2">
|
||||
<Button fullWidth onClick={() => setStep("disable")}>
|
||||
<T id="2fa.disable" />
|
||||
</Button>
|
||||
<Button fullWidth onClick={() => setStep("verify")}>
|
||||
<T id="2fa.regenerate-backup" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "setup" && setupData) {
|
||||
return (
|
||||
<div className="py-2">
|
||||
<p className="text-muted mb-3">
|
||||
<T id="2fa.setup-instructions" />
|
||||
</p>
|
||||
<div className="text-center mb-3">
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(setupData.otpauthUrl)}`}
|
||||
alt="QR Code"
|
||||
className="img-fluid"
|
||||
style={{ maxWidth: "200px" }}
|
||||
/>
|
||||
</div>
|
||||
<label className="mb-3 d-block">
|
||||
<span className="form-label small text-muted">
|
||||
<T id="2fa.secret-key" />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control font-monospace"
|
||||
value={setupData.secret}
|
||||
readOnly
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
</label>
|
||||
<Formik initialValues={{ code: "" }} onSubmit={handleVerify}>
|
||||
{() => (
|
||||
<Form>
|
||||
<Field name="code" validate={validateString(6, 6)}>
|
||||
{({ field, form }: any) => (
|
||||
<label className="mb-3 d-block">
|
||||
<span className="form-label">
|
||||
<T id="2fa.enter-code" />
|
||||
</span>
|
||||
<input
|
||||
{...field}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
className={`form-control ${form.errors.code && form.touched.code ? "is-invalid" : ""}`}
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.code}</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
<div className="d-flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
fullWidth
|
||||
onClick={() => setStep("status")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<T id="cancel" />
|
||||
</Button>
|
||||
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
|
||||
<T id="2fa.verify-enable" />
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "backup") {
|
||||
return (
|
||||
<div className="py-2">
|
||||
<Alert variant="warning">
|
||||
<T id="2fa.backup-warning" />
|
||||
</Alert>
|
||||
<div className="mb-3">
|
||||
<div className="row g-2">
|
||||
{backupCodes.map((code, index) => (
|
||||
<div key={index} className="col-6">
|
||||
<code className="d-block p-2 bg-light rounded text-center">{code}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button fullWidth color="azure" onClick={handleBackupDone}>
|
||||
<T id="2fa.done" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "disable") {
|
||||
return (
|
||||
<div className="py-2">
|
||||
<Alert variant="warning">
|
||||
<T id="2fa.disable-warning" />
|
||||
</Alert>
|
||||
<Formik initialValues={{ code: "" }} onSubmit={handleDisable}>
|
||||
{() => (
|
||||
<Form>
|
||||
<Field name="code" validate={validateString(6, 6)}>
|
||||
{({ field, form }: any) => (
|
||||
<label className="mb-3 d-block">
|
||||
<span className="form-label">
|
||||
<T id="2fa.enter-code-disable" />
|
||||
</span>
|
||||
<input
|
||||
{...field}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
className={`form-control ${form.errors.code && form.touched.code ? "is-invalid" : ""}`}
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.code}</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
<div className="d-flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
fullWidth
|
||||
onClick={() => setStep("status")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<T id="cancel" />
|
||||
</Button>
|
||||
<Button type="submit" fullWidth color="red" isLoading={isSubmitting}>
|
||||
<T id="2fa.disable-confirm" />
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "verify") {
|
||||
return (
|
||||
<div className="py-2">
|
||||
<p className="text-muted mb-3">
|
||||
<T id="2fa.regenerate-instructions" />
|
||||
</p>
|
||||
<Formik initialValues={{ code: "" }} onSubmit={handleRegenerateBackup}>
|
||||
{() => (
|
||||
<Form>
|
||||
<Field name="code" validate={validateString(6, 6)}>
|
||||
{({ field, form }: any) => (
|
||||
<label className="mb-3 d-block">
|
||||
<span className="form-label">
|
||||
<T id="2fa.enter-code" />
|
||||
</span>
|
||||
<input
|
||||
{...field}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
className={`form-control ${form.errors.code && form.touched.code ? "is-invalid" : ""}`}
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.code}</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
<div className="d-flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
fullWidth
|
||||
onClick={() => setStep("status")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<T id="cancel" />
|
||||
</Button>
|
||||
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
|
||||
<T id="2fa.regenerate" />
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={visible} onHide={remove}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
<T id="2fa.title" />
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Alert variant="danger" show={!!error} onClose={() => setError(null)} dismissible>
|
||||
{error}
|
||||
</Alert>
|
||||
{renderContent()}
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export { showTwoFactorModal };
|
||||
@@ -13,4 +13,5 @@ export * from "./RedirectionHostModal";
|
||||
export * from "./RenewCertificateModal";
|
||||
export * from "./SetPasswordModal";
|
||||
export * from "./StreamModal";
|
||||
export * from "./TwoFactorModal";
|
||||
export * from "./UserModal";
|
||||
|
||||
@@ -8,8 +8,77 @@ import { intl, T } from "src/locale";
|
||||
import { validateEmail, validateString } from "src/modules/Validations";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
export default function Login() {
|
||||
const emailRef = useRef(null);
|
||||
function TwoFactorForm() {
|
||||
const codeRef = useRef<HTMLInputElement>(null);
|
||||
const [formErr, setFormErr] = useState("");
|
||||
const { verifyTwoFactor, cancelTwoFactor } = useAuthState();
|
||||
|
||||
const onSubmit = async (values: any, { setSubmitting }: any) => {
|
||||
setFormErr("");
|
||||
try {
|
||||
await verifyTwoFactor(values.code);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setFormErr(err.message);
|
||||
}
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
codeRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="h2 text-center mb-4">
|
||||
<T id="login.2fa-title" />
|
||||
</h2>
|
||||
<p className="text-secondary text-center mb-4">
|
||||
<T id="login.2fa-description" />
|
||||
</p>
|
||||
{formErr !== "" && <Alert variant="danger">{formErr}</Alert>}
|
||||
<Formik initialValues={{ code: "" }} onSubmit={onSubmit}>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<div className="mb-3">
|
||||
<Field name="code" validate={validateString(6, 20)}>
|
||||
{({ field, form }: any) => (
|
||||
<label className="form-label">
|
||||
<T id="login.2fa-code" />
|
||||
<input
|
||||
{...field}
|
||||
ref={codeRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
required
|
||||
maxLength={20}
|
||||
className={`form-control ${form.errors.code && form.touched.code ? "is-invalid" : ""}`}
|
||||
placeholder={intl.formatMessage({ id: "login.2fa-code-placeholder" })}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.code}</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="form-footer d-flex gap-2">
|
||||
<Button type="button" fullWidth onClick={cancelTwoFactor} disabled={isSubmitting}>
|
||||
<T id="cancel" />
|
||||
</Button>
|
||||
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
|
||||
<T id="login.2fa-verify" />
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginForm() {
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
const [formErr, setFormErr] = useState("");
|
||||
const { login } = useAuthState();
|
||||
|
||||
@@ -26,10 +95,79 @@ export default function Login() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
|
||||
emailRef.current.focus();
|
||||
emailRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="h2 text-center mb-4">
|
||||
<T id="login.title" />
|
||||
</h2>
|
||||
{formErr !== "" && <Alert variant="danger">{formErr}</Alert>}
|
||||
<Formik
|
||||
initialValues={
|
||||
{
|
||||
email: "",
|
||||
password: "",
|
||||
} as any
|
||||
}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<div className="mb-3">
|
||||
<Field name="email" validate={validateEmail()}>
|
||||
{({ field, form }: any) => (
|
||||
<label className="form-label">
|
||||
<T id="email-address" />
|
||||
<input
|
||||
{...field}
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
required
|
||||
className={`form-control ${form.errors.email && form.touched.email ? " is-invalid" : ""}`}
|
||||
placeholder={intl.formatMessage({ id: "email-address" })}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.email}</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<Field name="password" validate={validateString(8, 255)}>
|
||||
{({ field, form }: any) => (
|
||||
<>
|
||||
<label className="form-label">
|
||||
<T id="password" />
|
||||
<input
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
maxLength={255}
|
||||
className={`form-control ${form.errors.password && form.touched.password ? " is-invalid" : ""}`}
|
||||
placeholder={intl.formatMessage({ id: "password" })}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.password}</div>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="form-footer">
|
||||
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
|
||||
<T id="sign-in" />
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const { twoFactorChallenge } = useAuthState();
|
||||
const health = useHealth();
|
||||
|
||||
const getVersion = () => {
|
||||
@@ -56,68 +194,7 @@ export default function Login() {
|
||||
</div>
|
||||
<div className="card card-md">
|
||||
<div className="card-body">
|
||||
<h2 className="h2 text-center mb-4">
|
||||
<T id="login.title" />
|
||||
</h2>
|
||||
{formErr !== "" && <Alert variant="danger">{formErr}</Alert>}
|
||||
<Formik
|
||||
initialValues={
|
||||
{
|
||||
email: "",
|
||||
password: "",
|
||||
} as any
|
||||
}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<div className="mb-3">
|
||||
<Field name="email" validate={validateEmail()}>
|
||||
{({ field, form }: any) => (
|
||||
<label className="form-label">
|
||||
<T id="email-address" />
|
||||
<input
|
||||
{...field}
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
required
|
||||
className={`form-control ${form.errors.email && form.touched.email ? " is-invalid" : ""}`}
|
||||
placeholder={intl.formatMessage({ id: "email-address" })}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.email}</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<Field name="password" validate={validateString(8, 255)}>
|
||||
{({ field, form }: any) => (
|
||||
<>
|
||||
<label className="form-label">
|
||||
<T id="password" />
|
||||
<input
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
maxLength={255}
|
||||
className={`form-control ${form.errors.password && form.touched.password ? " is-invalid" : ""}`}
|
||||
placeholder={intl.formatMessage({ id: "password" })}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.password}</div>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="form-footer">
|
||||
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
|
||||
<T id="sign-in" />
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
{twoFactorChallenge ? <TwoFactorForm /> : <LoginForm />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center text-secondary mt-3">{getVersion()}</div>
|
||||
|
||||
Reference in New Issue
Block a user