feat(security): add TOTP-based multi-factor authentication with backend and UI support (#5417)

# Description of Changes

This pull request introduces several improvements and new features
across the authentication and admin data APIs, with a particular focus
on multi-factor authentication (MFA) support and better handling of user
settings. The changes include integrating MFA status into account data
responses, masking sensitive user settings in admin views, and
refactoring code to use more robust user creation methods. Additionally,
there are minor code cleanups and consistency improvements.

### Multi-factor Authentication (MFA) Integration

* Added `mfaEnabled` and `mfaRequired` fields to the `AccountData`
response, populated using the new `MfaService`, to provide clients with
MFA status information for users.
[[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R430-R431)
[[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R72)
[[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L83-R86)
[[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R98)
[[5]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R574-R575)

### User Settings Handling and Security

* Admin settings API now returns user settings for each user, with the
`mfaSecret` field masked to protect sensitive information. This is
achieved by fetching settings via `findByIdWithSettings` and
copying/masking the relevant field before returning.
[[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R252-R259)
[[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L302-R322)
[[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R378)
[[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R563)

### Refactoring and Code Consistency

* Refactored user creation in `InitialSecuritySetup` to use the
`SaveUserRequest` builder and `saveUserCore` for better maintainability
and clarity, replacing direct calls to `saveUser`.
[[1]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850R22)
[[2]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L116-R124)
[[3]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L130-R144)
[[4]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L140-R160)
* Standardized checks for internal team membership by comparing with
`TeamService.INTERNAL_TEAM_NAME` on the left side for consistency.
[[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L267-R273)
[[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L332-R351)
[[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L421-R443)
[[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L449-R471)
[[5]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L462-R485)

### API and DTO Changes

* Changed the login API to accept `UsernameAndPassMfa` instead of
`UsernameAndPass`, paving the way for MFA code support in authentication
requests.
[[1]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L30-R41)
[[2]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L61-R71)
* Updated import statements and controller dependencies to include new
DTOs and services related to MFA.
[[1]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L14-R19)
[[2]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8R56-R57)

These updates improve security, prepare the system for MFA rollout, and
make admin and authentication APIs more robust and informative.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Ludy
2026-01-23 21:34:57 +00:00
committed by GitHub
co-authored by Copilot
parent 0436460c03
commit 0b86dd79d3
52 changed files with 3739 additions and 558 deletions
+23
View File
@@ -0,0 +1,23 @@
export interface AuthContextType {
session: null;
user: null;
loading: boolean;
error: Error | null;
signOut: () => Promise<void>;
refreshSession: () => Promise<void>;
}
/**
* Core (open-source) auth hook stub.
* Proprietary/desktop builds override this file via path resolution.
*/
export function useAuth(): AuthContextType {
return {
session: null,
user: null,
loading: false,
error: null,
signOut: async () => {},
refreshSession: async () => {},
};
}
@@ -68,6 +68,57 @@
color: var(--onboarding-body, #1f2937);
}
.mfaSlideContent {
display: flex;
justify-content: center;
margin-top: 12px;
}
.mfaCard {
width: 100%;
max-width: 560px;
background: var(--bg-surface, #ffffff);
border-radius: 16px;
padding: 20px;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
}
.mfaSetupGrid {
display: grid;
grid-template-columns: 180px 1fr;
gap: 16px;
align-items: center;
}
.mfaQrCard {
padding: 12px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 8px 16px rgba(15, 23, 42, 0.12);
display: flex;
justify-content: center;
}
.mfaSteps {
margin: 0;
padding-left: 18px;
color: var(--onboarding-body, #1f2937);
font-size: 14px;
line-height: 1.5;
}
.mfaForm {
width: 100%;
}
@media (max-width: 640px) {
.mfaSetupGrid {
grid-template-columns: 1fr;
justify-items: center;
}
}
.heroIconsContainer {
display: flex;
gap: 32px;
@@ -26,6 +26,8 @@ import { useServerExperience } from '@app/hooks/useServerExperience';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import apiClient from '@app/services/apiClient';
import '@app/components/onboarding/OnboardingTour.css';
import { useAccountLogout } from '@app/extensions/accountLogout';
import { useAuth } from '@app/auth/UseSession';
export default function Onboarding() {
const { t } = useTranslation();
@@ -45,15 +47,30 @@ export default function Onboarding() {
const [analyticsLoading, setAnalyticsLoading] = useState(false);
const [showAnalyticsModal, setShowAnalyticsModal] = useState(false);
const [analyticsModalDismissed, setAnalyticsModalDismissed] = useState(false);
const [firstLoginModalOpen, setFirstLoginModalOpen] = useState(false);
const [mfaModalOpen, setMfaModalOpen] = useState(false);
const accountLogout = useAccountLogout();
const { signOut } = useAuth();
const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => {
actions.updateRuntimeState({ selectedRole: role });
serverExperience.setSelfReportedAdmin(role === 'admin');
}, [actions, serverExperience]);
const handlePasswordChanged = useCallback(() => {
const redirectToLogin = useCallback(() => {
window.location.assign('/login');
}, []);
const handlePasswordChanged = useCallback(async () => {
actions.updateRuntimeState({ requiresPasswordChange: false });
window.location.href = '/login';
// delete session and redirect to login page
await accountLogout({ signOut, redirectToLogin });
}, [actions, accountLogout, redirectToLogin, signOut]);
const handleMfaSetupComplete = useCallback(() => {
actions.updateRuntimeState({ requiresMfaSetup: false });
setMfaModalOpen(false);
actions.complete();
}, [actions]);
// Check if we should show analytics modal before onboarding
@@ -68,17 +85,17 @@ export default function Onboarding() {
setAnalyticsLoading(true);
setAnalyticsError(null);
try {
const formData = new FormData();
formData.append('enabled', enableAnalytics.toString());
const formData = new FormData();
formData.append('enabled', enableAnalytics.toString());
try {
await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
await refetchConfig();
setShowAnalyticsModal(false);
setAnalyticsModalDismissed(true);
setAnalyticsLoading(false);
} catch (error) {
setAnalyticsError(error instanceof Error ? error.message : 'Unknown error');
} finally {
setAnalyticsLoading(false);
}
}, [analyticsLoading, refetchConfig]);
@@ -223,6 +240,27 @@ export default function Onboarding() {
return () => removeAllGlows();
}, [isTourOpen]);
// Handle first-login password change modal
useEffect(() => {
if(runtimeState.requiresPasswordChange === true) {
console.log('[Onboarding] User requires password change on first login.');
setFirstLoginModalOpen(true);
} else {
setFirstLoginModalOpen(false);
}
}, [runtimeState.requiresPasswordChange]);
// Handle MFA setup modal
useEffect(() => {
if(runtimeState.requiresMfaSetup === true) {
console.log('[Onboarding] User requires MFA setup.');
setMfaModalOpen(true);
} else {
console.log('[Onboarding] User does not require MFA setup.');
setMfaModalOpen(false);
}
}, [runtimeState.requiresMfaSetup]);
const finishTour = useCallback(() => {
setIsTourOpen(false);
if (runtimeState.tourType === 'admin') {
@@ -272,8 +310,9 @@ export default function Onboarding() {
usingDefaultCredentials: runtimeState.usingDefaultCredentials,
analyticsError,
analyticsLoading,
onMfaSetupComplete: handleMfaSetupComplete,
});
}, [analyticsError, analyticsLoading, currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]);
}, [analyticsError, analyticsLoading, currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged, handleMfaSetupComplete]);
const modalSlideCount = useMemo(() => {
return activeFlow.filter((step) => step.type === 'modal-slide').length;
@@ -325,6 +364,65 @@ export default function Onboarding() {
);
}
if (firstLoginModalOpen) {
const baseSlideDefinition = SLIDE_DEFINITIONS['first-login'];
const slideContent = baseSlideDefinition.createSlide({
osLabel: '',
osUrl: '',
selectedRole: null,
onRoleSelect: () => {},
firstLoginUsername: runtimeState.firstLoginUsername,
onPasswordChanged: handlePasswordChanged,
usingDefaultCredentials: runtimeState.usingDefaultCredentials,
});
return (
<OnboardingModalSlide
slideDefinition={baseSlideDefinition}
slideContent={slideContent}
runtimeState={runtimeState}
modalSlideCount={1}
currentModalSlideIndex={0}
onSkip={() => {}}
onAction={async (action) => {
if (action === 'complete-close') {
handlePasswordChanged();
}
}}
allowDismiss={false}
/>
);
}
if (mfaModalOpen) {
console.log('[Onboarding] Rendering MFA setup modal slide.');
const baseSlideDefinition = SLIDE_DEFINITIONS['mfa-setup'];
const slideContent = baseSlideDefinition.createSlide({
osLabel: '',
osUrl: '',
selectedRole: null,
onRoleSelect: () => {},
onMfaSetupComplete: handleMfaSetupComplete,
});
return (
<OnboardingModalSlide
slideDefinition={baseSlideDefinition}
slideContent={slideContent}
runtimeState={runtimeState}
modalSlideCount={1}
currentModalSlideIndex={0}
onSkip={() => {}}
onAction={async (action) => {
if (action === 'complete-close') {
handleMfaSetupComplete();
}
}}
allowDismiss={false}
/>
);
}
if (showLicenseSlide) {
const baseSlideDefinition = SLIDE_DEFINITIONS['server-license'];
// Remove back button for external license notice
@@ -403,6 +501,7 @@ export default function Onboarding() {
currentModalSlideIndex={currentModalSlideIndex}
onSkip={actions.skip}
onAction={handleButtonAction}
allowDismiss={currentStep.allowDismiss}
/>
);
@@ -6,6 +6,7 @@ import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseS
import FirstLoginSlide from '@app/components/onboarding/slides/FirstLoginSlide';
import TourOverviewSlide from '@app/components/onboarding/slides/TourOverviewSlide';
import AnalyticsChoiceSlide from '@app/components/onboarding/slides/AnalyticsChoiceSlide';
import MFASetupSlide from '@app/components/onboarding/slides/MFASetupSlide';
import { SlideConfig, LicenseNotice } from '@app/types/types';
export type SlideId =
@@ -16,7 +17,8 @@ export type SlideId =
| 'admin-overview'
| 'server-license'
| 'tour-overview'
| 'analytics-choice';
| 'analytics-choice'
| 'mfa-setup';
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock' | 'analytics';
@@ -61,6 +63,7 @@ export interface SlideFactoryParams {
usingDefaultCredentials?: boolean;
analyticsError?: string | null;
analyticsLoading?: boolean;
onMfaSetupComplete?: () => void;
}
export interface HeroDefinition {
@@ -281,5 +284,11 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
},
],
},
'mfa-setup': {
id: 'mfa-setup',
createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) => MFASetupSlide({ onMfaSetupComplete }),
hero: { type: 'lock' },
buttons: [], // Form has its own submit button
},
};
@@ -7,7 +7,8 @@ export type OnboardingStepId =
| 'tool-layout'
| 'tour-overview'
| 'server-license'
| 'analytics-choice';
| 'analytics-choice'
| 'mfa-setup';
export type OnboardingStepType =
| 'modal-slide'
@@ -30,6 +31,7 @@ export interface OnboardingRuntimeState {
requiresPasswordChange: boolean;
firstLoginUsername: string;
usingDefaultCredentials: boolean;
requiresMfaSetup: boolean;
}
export interface OnboardingConditionContext extends OnboardingRuntimeState {
@@ -41,7 +43,8 @@ export interface OnboardingStep {
id: OnboardingStepId;
type: OnboardingStepType;
condition: (ctx: OnboardingConditionContext) => boolean;
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license' | 'tour-overview' | 'analytics-choice';
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license' | 'tour-overview' | 'analytics-choice' | 'mfa-setup';
allowDismiss?: boolean;
}
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
@@ -61,6 +64,7 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
firstLoginUsername: '',
usingDefaultCredentials: false,
desktopSlideEnabled: true,
requiresMfaSetup: false,
};
export const ONBOARDING_STEPS: OnboardingStep[] = [
@@ -111,6 +115,12 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
slideId: 'server-license',
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
},
{
id: 'mfa-setup',
type: 'modal-slide',
slideId: 'mfa-setup',
condition: (ctx) => ctx.requiresMfaSetup,
}
];
export function getStepById(id: OnboardingStepId): OnboardingStep | undefined {
@@ -89,6 +89,18 @@ function clearRuntimeStateSession(): void {
}
}
function parseMfaRequired(settings: string | null | undefined): boolean {
if (!settings) return false;
try {
const parsed = JSON.parse(settings) as { mfaRequired?: string };
return parsed.mfaRequired?.toLowerCase() === 'true';
} catch (error) {
console.warn('[useOnboardingOrchestrator] Failed to parse account settings JSON:', error);
return false;
}
}
export interface OnboardingOrchestratorState {
/** Whether onboarding is currently active */
isActive: boolean;
@@ -205,8 +217,10 @@ export function useOnboardingOrchestrator(
requiresPasswordChange: accountData.changeCredsFlag,
firstLoginUsername: accountData.username,
usingDefaultCredentials: loginPageData.showDefaultCredentials,
requiresMfaSetup: parseMfaRequired(accountData.settings),
}));
} catch {
} catch (error) {
console.log('[OnboardingOrchestrator] Failed to fetch account data for onboarding runtime state:', error);
// Account endpoint failed - user not logged in or security disabled
}
};
@@ -230,12 +244,8 @@ export function useOnboardingOrchestrator(
}), [serverExperience, runtimeState]);
const activeFlow = useMemo(() => {
// If password change is required, ONLY show the first-login step
if (runtimeState.requiresPasswordChange) {
return ONBOARDING_STEPS.filter((step) => step.id === 'first-login');
}
return ONBOARDING_STEPS.filter((step) => step.condition(conditionContext));
}, [conditionContext, runtimeState.requiresPasswordChange]);
}, [conditionContext]);
// Wait for config AND admin status before calculating initial step
const adminStatusResolved = !configLoading && (
@@ -255,7 +265,7 @@ export function useOnboardingOrchestrator(
}
// If onboarding has been completed, don't show it
if (isOnboardingCompleted() && !runtimeState.requiresPasswordChange) {
if (isOnboardingCompleted()) {
setCurrentStepIndex(activeFlow.length);
initialIndexSet.current = true;
return;
@@ -266,7 +276,7 @@ export function useOnboardingOrchestrator(
setCurrentStepIndex(0);
initialIndexSet.current = true;
}
}, [activeFlow, configLoading, adminStatusResolved, runtimeState.requiresPasswordChange]);
}, [activeFlow, configLoading, adminStatusResolved]);
const totalSteps = activeFlow.length;
@@ -51,7 +51,7 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
setLoading(true);
setError('');
await accountService.changePasswordOnLogin(currentPassword, newPassword);
await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword);
showToast({
alertType: 'success',
@@ -127,6 +127,7 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')}
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
minLength={8}
required
styles={{
input: { height: 44 },
@@ -139,6 +140,7 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
required
minLength={8}
styles={{
input: { height: 44 },
}}
@@ -148,7 +150,7 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
fullWidth
onClick={handleSubmit}
loading={loading}
disabled={!newPassword || !confirmPassword}
disabled={!newPassword || !confirmPassword || newPassword.length < 8 || confirmPassword.length < 8}
size="md"
mt="xs"
>
@@ -169,9 +171,9 @@ export default function FirstLoginSlide({
key: 'first-login',
title: 'Set Your Password',
body: (
<FirstLoginForm
username={username}
onPasswordChanged={onPasswordChanged}
<FirstLoginForm
username={username}
onPasswordChanged={onPasswordChanged}
usingDefaultCredentials={usingDefaultCredentials}
/>
),
@@ -0,0 +1,221 @@
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import { Alert, Box, Button, Group, Loader, Stack, Text, TextInput } from "@mantine/core";
import { QRCodeSVG } from "qrcode.react";
import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import { accountService } from "@app/services/accountService";
import { useAccountLogout } from '@app/extensions/accountLogout';
import { useAuth } from "@app/auth/UseSession";
import LocalIcon from "@app/components/shared/LocalIcon";
import { BASE_PATH } from "@app/constants/app";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
import { MfaSetupResponse } from "@app/responses/Mfa/MfaResponse";
interface MFASetupSlideProps {
onMfaSetupComplete?: () => void;
}
function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(null);
const [mfaSetupCode, setMfaSetupCode] = useState("");
const [mfaError, setMfaError] = useState("");
const [mfaLoading, setMfaLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [setupComplete, setSetupComplete] = useState(false);
const setupCompleteRef = useRef(false);
const { signOut } = useAuth();
const accountLogout = useAccountLogout();
const qrLogoSrc = `${BASE_PATH}/modern-logo/StirlingPDFLogoNoTextDark.svg`;
const normalizeMfaCode = useCallback((value: string) => value.replace(/\D/g, "").slice(0, 6), []);
const fetchMfaSetup = useCallback(async () => {
try {
setMfaLoading(true);
setMfaError("");
setMfaSetupCode("");
const data = await accountService.requestMfaSetup();
setMfaSetupData(data);
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(axiosError.response?.data?.error || "Unable to start two-factor setup. Please try again.");
} finally {
setMfaLoading(false);
}
}, []);
useEffect(() => {
setupCompleteRef.current = setupComplete;
}, [setupComplete]);
useEffect(() => {
void fetchMfaSetup();
return () => {
if (!setupCompleteRef.current) {
void accountService.cancelMfaSetup();
}
};
}, [fetchMfaSetup]);
const redirectToLogin = useCallback(() => {
window.location.assign('/login');
}, []);
const onLogout = useCallback(async() => {
await accountLogout({ signOut, redirectToLogin });
}, [accountLogout, redirectToLogin, signOut]);
const handleEnableMfa = useCallback(
async (event: FormEvent) => {
event.preventDefault();
if (!mfaSetupCode.trim()) {
setMfaError("Enter the authentication code to continue.");
return;
}
try {
setSubmitting(true);
setMfaError("");
await accountService.enableMfa(mfaSetupCode.trim());
setSetupComplete(true);
onMfaSetupComplete?.();
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error || "Unable to enable two-factor authentication. Check the code and try again."
);
} finally {
setSubmitting(false);
}
},
[mfaSetupCode, onMfaSetupComplete]
);
const isReady = Boolean(mfaSetupData);
const mfaSetupContent = mfaSetupData ? (
<div className={styles.mfaSetupGrid}>
<Box className={styles.mfaQrCard}>
<QRCodeSVG
value={mfaSetupData.otpauthUri ?? ""}
size={168}
level="H"
imageSettings={{
src: qrLogoSrc,
height: 36,
width: 36,
excavate: true,
}}
/>
</Box>
<Stack gap="xs">
<Text size="sm" fw={600}>
Step-by-step
</Text>
<ol className={styles.mfaSteps}>
<li>Open Google Authenticator, Authy, or 1Password.</li>
<li>Scan the QR code or enter the setup key below.</li>
<li>Enter the 6-digit code from your app.</li>
</ol>
<Text size="xs" c="dimmed">
Setup key (manual entry)
</Text>
<TextInput
value={mfaSetupData.secret ?? ""}
readOnly
variant="filled"
styles={{ input: { fontFamily: "monospace" } }}
/>
</Stack>
</div>
) : null;
return (
<div className={styles.mfaSlideContent}>
<div className={styles.mfaCard}>
<Stack gap="md">
<Text size="sm" c="dimmed">
Secure your account by linking an authenticator app. Scan the QR code or enter the setup key, then confirm the
6-digit code to finish.
</Text>
{mfaError && (
<Alert icon={<LocalIcon icon="error" width={16} height={16} />} color="red" variant="light">
{mfaError}
</Alert>
)}
{mfaLoading && !isReady && (
<Group gap="sm">
<Loader size="sm" />
<Text size="sm">Generating your QR code</Text>
</Group>
)}
{isReady && mfaSetupContent}
<form onSubmit={handleEnableMfa} className={styles.mfaForm}>
<Stack gap="sm">
<TextInput
id="mfa-setup-code"
label="Authentication code"
placeholder="123456"
value={mfaSetupCode}
onChange={(event) => setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))}
inputMode="numeric"
maxLength={6}
minLength={6}
disabled={!isReady || submitting || setupComplete}
/>
<Group justify="space-between" wrap="wrap">
<Button
type="button"
variant="light"
onClick={fetchMfaSetup}
disabled={mfaLoading || submitting || setupComplete}
>
Regenerate QR code
</Button>
<Button
type="submit"
loading={submitting}
disabled={!isReady || setupComplete || mfaSetupCode.length < 6}
>
Enable MFA
</Button>
<Button
type="button"
variant="light"
onClick={onLogout}
>
Logout
</Button>
</Group>
{setupComplete && (
<Alert color="green" variant="light">
MFA has been enabled. You can now continue.
</Alert>
)}
</Stack>
</form>
</Stack>
</div>
</div>
);
}
export default function MFASetupSlide({ onMfaSetupComplete }: MFASetupSlideProps = {}): SlideConfig {
return {
key: "mfa-setup-slide",
title: "Multi-Factor Authentication Setup",
body: <MFASetupContent onMfaSetupComplete={onMfaSetupComplete} />,
background: {
gradientStops: ["#059669", "#0891B2"], // Green to teal - security/trust colors
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -52,7 +52,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
setLoading(true);
setError('');
await accountService.changePasswordOnLogin(currentPassword, newPassword);
await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword);
alert({
alertType: 'success',
@@ -133,6 +133,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')}
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
minLength={8}
required
/>
@@ -141,6 +142,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
placeholder={t('firstLogin.reEnterNewPassword', 'Re-enter new password')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
minLength={8}
required
/>
@@ -148,7 +150,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
fullWidth
onClick={handleSubmit}
loading={loading}
disabled={!currentPassword || !newPassword || !confirmPassword}
disabled={!currentPassword || !newPassword || !confirmPassword || newPassword.length < 8 || confirmPassword.length < 8}
mt="md"
>
{t('firstLogin.changePassword', 'Change Password')}
@@ -0,0 +1,20 @@
type SignOutFn = () => Promise<void>;
interface AccountLogoutDeps {
signOut: SignOutFn;
redirectToLogin: () => void;
}
/**
* Core (open-source) logout handler: sign out and redirect to /login.
* Proprietary/desktop builds override this file via path resolution.
*/
export function useAccountLogout() {
return async ({ signOut, redirectToLogin }: AccountLogoutDeps): Promise<void> => {
try {
await signOut();
} finally {
redirectToLogin();
}
};
}
@@ -0,0 +1,24 @@
export interface MfaErrorResponse {
error: string;
}
export interface MfaSetupResponse {
otpauthUri: string | null;
secret: string | null;
error: MfaErrorResponse | null;
}
export interface MfaSetupCancelResponse {
cleared: boolean | null;
error: MfaErrorResponse | null;
}
/**
* /mfa/disable/admin/{username}
* /mfa/disable
* /mfa/enable
*/
export interface MfaStatusResponse {
enabled: boolean | null;
error: MfaErrorResponse | null;
}
+22 -2
View File
@@ -1,3 +1,4 @@
import { MfaSetupResponse } from '@app/responses/Mfa/MfaResponse';
import apiClient from '@app/services/apiClient';
export interface AccountData {
@@ -7,6 +8,7 @@ export interface AccountData {
changeCredsFlag: boolean;
oAuth2Login: boolean;
saml2Login: boolean;
mfaEnabled?: boolean;
}
export interface LoginPageData {
@@ -50,11 +52,12 @@ export const accountService = {
/**
* Change user password on first login (resets firstLogin flag)
*/
async changePasswordOnLogin(currentPassword: string, newPassword: string): Promise<void> {
async changePasswordOnLogin(currentPassword: string, newPassword: string, confirmPassword: string): Promise<void> {
const formData = new FormData();
formData.append('currentPassword', currentPassword);
formData.append('newPassword', newPassword);
await apiClient.post('/api/v1/user/change-password-on-login', formData);
formData.append('confirmPassword', confirmPassword);
await apiClient.post('/api/v1/user/change-password-on-login', formData, { responseType: 'json'});
},
/**
@@ -66,4 +69,21 @@ export const accountService = {
formData.append('newUsername', newUsername);
await apiClient.post('/api/v1/user/change-username', formData);
},
async requestMfaSetup(): Promise<MfaSetupResponse> {
const response = await apiClient.get<MfaSetupResponse>('/api/v1/auth/mfa/setup', { suppressErrorToast: true });
return response.data;
},
async enableMfa(code: string): Promise<void> {
await apiClient.post('/api/v1/auth/mfa/enable', { code }, { skipAuthRedirect: true });
},
async disableMfa(code: string): Promise<void> {
await apiClient.post('/api/v1/auth/mfa/disable', { code }, { skipAuthRedirect: true });
},
async cancelMfaSetup(): Promise<void> {
await apiClient.post('/api/v1/auth/mfa/setup/cancel', undefined, { suppressErrorToast: true });
},
};