mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
@@ -556,6 +556,30 @@ webBrowserSettings = "Web Browser Setting"
|
||||
syncToBrowser = "Sync Account -> Browser"
|
||||
syncToAccount = "Sync Account <- Browser"
|
||||
|
||||
[account.mfa]
|
||||
title = "Two-factor authentication"
|
||||
setupFailed = "Unable to start two-factor setup. Please try again."
|
||||
codeRequired = "Enter the authentication code to continue."
|
||||
enabled = "Two-factor authentication enabled."
|
||||
enableFailed = "Unable to enable two-factor authentication. Check the code and try again."
|
||||
disabled = "Two-factor authentication disabled."
|
||||
disableFailed = "Unable to disable two-factor authentication. Check the code and try again."
|
||||
description = "Add an extra layer of security to your account."
|
||||
enableButton = "Enable two-factor authentication"
|
||||
disableButton = "Disable two-factor authentication"
|
||||
setupTitle = "Set up two-factor authentication"
|
||||
setupDescription = "Scan the QR code with your authenticator app, then enter the 6-digit code to confirm."
|
||||
manualKey = "Manual setup key"
|
||||
secretWarning = "Keep this key private. Anyone with access can generate valid authentication codes."
|
||||
codePlaceholder = "Enter 6-digit code"
|
||||
confirmEnable = "Enable"
|
||||
disableTitle = "Disable two-factor authentication"
|
||||
disableDescription = "Enter a valid authentication code to disable two-factor authentication."
|
||||
codeLabel = "Authentication code"
|
||||
confirmDisable = "Disable"
|
||||
ssoDescription = "Two-factor authentication is managed by your identity provider for single sign-on accounts."
|
||||
ssoManaged = "Configure MFA through your identity provider."
|
||||
|
||||
[adminUserSettings]
|
||||
title = "User Control Settings"
|
||||
header = "Admin User Control Settings"
|
||||
@@ -3736,6 +3760,14 @@ passwordChangedSuccess = "Password changed successfully! Please sign in with you
|
||||
credentialsUpdated = "Your credentials have been updated. Please sign in again."
|
||||
defaultCredentials = "Default Login Credentials"
|
||||
changePasswordWarning = "Please change your password after logging in for the first time"
|
||||
mfaRequired = "Two-factor code required"
|
||||
mfaCode = "Authentication Code"
|
||||
enterMfaCode = "Enter 6-digit code"
|
||||
mfaPromptTitle = "Two-factor authentication"
|
||||
mfaPromptBody = "Enter the authentication code from your authenticator app to continue."
|
||||
verifyingMfa = "Verifying..."
|
||||
verifyMfa = "Verify code"
|
||||
|
||||
|
||||
[login.slides.overview]
|
||||
alt = "Stirling PDF overview"
|
||||
@@ -5826,6 +5858,7 @@ usernameRequired = "Username and password are required"
|
||||
passwordTooShort = "Password must be at least 6 characters"
|
||||
success = "User created successfully"
|
||||
error = "Failed to create user"
|
||||
forceMFA = "Force MFA setup on next login"
|
||||
|
||||
[workspace.people.authType]
|
||||
password = "Password"
|
||||
@@ -5935,6 +5968,11 @@ slotsAvailable = "{{count}} user slot(s) available"
|
||||
noSlotsAvailable = "No slots available"
|
||||
currentUsage = "Currently using {{current}} of {{max}} user licences"
|
||||
|
||||
[workspace.people.mfa]
|
||||
adminDisableSuccess = "MFA disabled successfully for user"
|
||||
adminDisableError = "Failed to disable MFA for user"
|
||||
disableByAdmin = "Disable MFA"
|
||||
|
||||
[workspace.teams]
|
||||
title = "Teams"
|
||||
description = "Manage teams and organize workspace members"
|
||||
|
||||
@@ -33,15 +33,20 @@ fn get_keyring_entry() -> Result<Entry, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<(), String> {
|
||||
if token.is_empty() {
|
||||
let trimmed = token.trim();
|
||||
if trimmed.is_empty() {
|
||||
log::warn!("Attempted to save empty auth token");
|
||||
return Err("Token cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let entry = get_keyring_entry()?;
|
||||
|
||||
if trimmed.len() != token.len() {
|
||||
log::debug!("Auth token had surrounding whitespace; storing trimmed token");
|
||||
}
|
||||
|
||||
entry
|
||||
.set_password(&token)
|
||||
.set_password(trimmed)
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to set password in keyring: {}", e);
|
||||
format!("Failed to save token to keyring: {}", e)
|
||||
@@ -50,7 +55,7 @@ pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<()
|
||||
// Verify the save worked
|
||||
match entry.get_password() {
|
||||
Ok(retrieved_token) => {
|
||||
if retrieved_token != token {
|
||||
if retrieved_token != trimmed {
|
||||
log::error!("Token verification failed: Retrieved token doesn't match");
|
||||
return Err("Token verification failed after save".to_string());
|
||||
}
|
||||
@@ -201,6 +206,7 @@ pub async fn login(
|
||||
server_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
mfa_code: Option<String>,
|
||||
supabase_key: String,
|
||||
saas_server_url: String,
|
||||
) -> Result<LoginResponse, String> {
|
||||
@@ -272,12 +278,22 @@ pub async fn login(
|
||||
let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/'));
|
||||
log::debug!("Spring Boot login URL: {}", login_url);
|
||||
|
||||
let mut payload = serde_json::json!({
|
||||
"username": username,
|
||||
"password": password,
|
||||
});
|
||||
|
||||
if let Some(code) = mfa_code
|
||||
.as_ref()
|
||||
.map(|c| c.trim())
|
||||
.filter(|c| !c.is_empty())
|
||||
{
|
||||
payload["mfaCode"] = serde_json::Value::String(code.to_string());
|
||||
}
|
||||
|
||||
let response = client
|
||||
.post(&login_url)
|
||||
.json(&serde_json::json!({
|
||||
"username": username,
|
||||
"password": password,
|
||||
}))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
@@ -292,6 +308,19 @@ pub async fn login(
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
log::error!("Spring Boot login failed with status {}: {}", status, error_text);
|
||||
|
||||
if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(&error_text) {
|
||||
let error_code = error_json
|
||||
.get("error")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| value.to_string());
|
||||
|
||||
if let Some(code) = error_code {
|
||||
if code == "mfa_required" || code == "invalid_mfa_code" {
|
||||
return Err(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Err(if status.as_u16() == 401 {
|
||||
"Invalid username or password".to_string()
|
||||
} else if status.as_u16() == 403 {
|
||||
|
||||
@@ -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 () => {},
|
||||
};
|
||||
}
|
||||
+51
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,6 +15,9 @@ interface SelfHostedLoginScreenProps {
|
||||
enabledOAuthProviders?: SSOProviderConfig[];
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
|
||||
mfaCode: string;
|
||||
setMfaCode: (value: string) => void;
|
||||
requiresMfa: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -24,6 +27,9 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
|
||||
enabledOAuthProviders,
|
||||
onLogin,
|
||||
onOAuthSuccess,
|
||||
mfaCode,
|
||||
setMfaCode,
|
||||
requiresMfa,
|
||||
loading,
|
||||
error,
|
||||
}) => {
|
||||
@@ -44,6 +50,11 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiresMfa && !mfaCode.trim()) {
|
||||
setValidationError(t('login.mfaRequired', 'Two-factor code required'));
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError(null);
|
||||
await onLogin(username.trim(), password);
|
||||
};
|
||||
@@ -98,6 +109,13 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
|
||||
setPassword(value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
mfaCode={mfaCode}
|
||||
setMfaCode={(value) => {
|
||||
setMfaCode(value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
showMfaField={requiresMfa || Boolean(mfaCode)}
|
||||
requiresMfa={requiresMfa}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={loading}
|
||||
submitButtonText={t('setup.login.submit', 'Login')}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { SaaSSignupScreen } from '@app/components/SetupWizard/SaaSSignupScreen';
|
||||
import { ServerSelectionScreen } from '@app/components/SetupWizard/ServerSelectionScreen';
|
||||
import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLoginScreen';
|
||||
import { ServerConfig, connectionModeService } from '@app/services/connectionModeService';
|
||||
import { authService, UserInfo } from '@app/services/authService';
|
||||
import { AuthServiceError, authService, UserInfo } from '@app/services/authService';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
@@ -30,6 +30,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
const [serverConfig, setServerConfig] = useState<ServerConfig | null>({ url: STIRLING_SAAS_URL });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selfHostedMfaCode, setSelfHostedMfaCode] = useState('');
|
||||
const [selfHostedMfaRequired, setSelfHostedMfaRequired] = useState(false);
|
||||
|
||||
const handleSaaSLogin = async (username: string, password: string) => {
|
||||
if (!serverConfig) {
|
||||
@@ -97,6 +99,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
const handleServerSelection = (config: ServerConfig) => {
|
||||
setServerConfig(config);
|
||||
setError(null);
|
||||
setSelfHostedMfaCode('');
|
||||
setSelfHostedMfaRequired(false);
|
||||
setActiveStep(SetupStep.SelfHostedLogin);
|
||||
};
|
||||
|
||||
@@ -116,9 +120,14 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
setError(null);
|
||||
|
||||
console.log('[SetupWizard] Step 1: Authenticating with server...');
|
||||
await authService.login(serverConfig.url, username, password);
|
||||
const trimmedMfa = selfHostedMfaCode.trim();
|
||||
const mfaCode = trimmedMfa ? trimmedMfa : undefined;
|
||||
await authService.login(serverConfig.url, username, password, mfaCode);
|
||||
console.log('[SetupWizard] ✅ Authentication successful');
|
||||
|
||||
setSelfHostedMfaRequired(false);
|
||||
setSelfHostedMfaCode('');
|
||||
|
||||
console.log('[SetupWizard] Step 2: Switching to self-hosted mode...');
|
||||
await connectionModeService.switchToSelfHosted(serverConfig);
|
||||
console.log('[SetupWizard] ✅ Switched to self-hosted mode');
|
||||
@@ -131,7 +140,20 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error('[SetupWizard] ❌ Self-hosted login failed:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Self-hosted login failed';
|
||||
let errorMessage = 'Self-hosted login failed';
|
||||
if (err instanceof AuthServiceError) {
|
||||
if (err.code === 'mfa_required' || err.code === 'invalid_mfa_code') {
|
||||
setSelfHostedMfaRequired(true);
|
||||
}
|
||||
errorMessage = err.message;
|
||||
} else if (err instanceof Error) {
|
||||
errorMessage = err.message;
|
||||
} else if (typeof err === 'string') {
|
||||
errorMessage = err;
|
||||
}
|
||||
if (errorMessage.toLowerCase().includes('mfa_required') || errorMessage.toLowerCase().includes('invalid_mfa_code')) {
|
||||
setSelfHostedMfaRequired(true);
|
||||
}
|
||||
console.error('[SetupWizard] Error message:', errorMessage);
|
||||
setError(errorMessage);
|
||||
setLoading(false);
|
||||
@@ -239,6 +261,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
const handleBack = () => {
|
||||
setError(null);
|
||||
if (activeStep === SetupStep.SelfHostedLogin) {
|
||||
setSelfHostedMfaCode('');
|
||||
setSelfHostedMfaRequired(false);
|
||||
setActiveStep(SetupStep.ServerSelection);
|
||||
} else if (activeStep === SetupStep.ServerSelection) {
|
||||
setActiveStep(SetupStep.SaaSLogin);
|
||||
@@ -286,6 +310,9 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
enabledOAuthProviders={serverConfig?.enabledOAuthProviders}
|
||||
onLogin={handleSelfHostedLogin}
|
||||
onOAuthSuccess={handleSelfHostedOAuthSuccess}
|
||||
mfaCode={selfHostedMfaCode}
|
||||
setMfaCode={setSelfHostedMfaCode}
|
||||
requiresMfa={selfHostedMfaRequired}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
@@ -101,6 +101,9 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
|
||||
// Handle 401 Unauthorized - try to refresh token
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (originalRequest.skipAuthRedirect) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
originalRequest._retry = true;
|
||||
|
||||
const isRemote = await operationRouter.isSelfHostedMode();
|
||||
|
||||
@@ -11,6 +11,15 @@ export interface UserInfo {
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export class AuthServiceError extends Error {
|
||||
code?: string;
|
||||
|
||||
constructor(message: string, code?: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
token: string;
|
||||
username: string;
|
||||
@@ -82,7 +91,6 @@ export class AuthService {
|
||||
// Try Tauri store first
|
||||
try {
|
||||
const token = await invoke<string | null>('get_auth_token');
|
||||
|
||||
if (token) {
|
||||
console.log(`[Desktop AuthService] ✅ Token found in Tauri store (length: ${token.length})`);
|
||||
return token;
|
||||
@@ -222,7 +230,7 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async login(serverUrl: string, username: string, password: string): Promise<UserInfo> {
|
||||
async login(serverUrl: string, username: string, password: string, mfaCode?: string): Promise<UserInfo> {
|
||||
console.log(`[Desktop AuthService] 🔐 Starting login to: ${serverUrl}`);
|
||||
console.log(`[Desktop AuthService] Username: ${username}`);
|
||||
|
||||
@@ -246,6 +254,7 @@ export class AuthService {
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
mfaCode,
|
||||
supabaseKey: SUPABASE_KEY,
|
||||
saasServerUrl: STIRLING_SAAS_URL,
|
||||
});
|
||||
@@ -286,8 +295,21 @@ export class AuthService {
|
||||
console.error('[Desktop AuthService] ❌ Login failed:', error);
|
||||
|
||||
// Provide more detailed error messages based on the error type
|
||||
if (error instanceof Error) {
|
||||
const errMsg = error.message.toLowerCase();
|
||||
if (error instanceof Error || typeof error === 'string') {
|
||||
const rawMessage = typeof error === 'string' ? error : error.message;
|
||||
const errMsg = rawMessage.toLowerCase();
|
||||
|
||||
if (errMsg.includes('mfa_required')) {
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
console.error('[Desktop AuthService] Two-factor authentication required');
|
||||
throw new AuthServiceError('Two-factor code required.', 'mfa_required');
|
||||
}
|
||||
|
||||
if (errMsg.includes('invalid_mfa_code')) {
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
console.error('[Desktop AuthService] Invalid two-factor code provided');
|
||||
throw new AuthServiceError('Invalid two-factor code.', 'invalid_mfa_code');
|
||||
}
|
||||
|
||||
// Authentication errors
|
||||
if (errMsg.includes('401') || errMsg.includes('unauthorized') || errMsg.includes('invalid credentials')) {
|
||||
@@ -412,7 +434,6 @@ export class AuthService {
|
||||
this.cachedToken = token;
|
||||
console.log('[Desktop AuthService] ✅ Token cached in memory after retrieval');
|
||||
}
|
||||
|
||||
return token;
|
||||
} catch (error) {
|
||||
console.error('[Desktop AuthService] Failed to get auth token:', error);
|
||||
@@ -449,7 +470,7 @@ export class AuthService {
|
||||
|
||||
async refreshToken(serverUrl: string): Promise<boolean> {
|
||||
try {
|
||||
console.log('Refreshing auth token');
|
||||
console.log('[Desktop AuthService] Refreshing auth token');
|
||||
this.setAuthStatus('refreshing', this.userInfo);
|
||||
|
||||
const currentToken = await this.getAuthToken();
|
||||
@@ -477,10 +498,10 @@ export class AuthService {
|
||||
const userInfo = await this.getUserInfo();
|
||||
this.setAuthStatus('authenticated', userInfo);
|
||||
|
||||
console.log('Token refreshed successfully');
|
||||
console.log('[Desktop AuthService] Token refreshed successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
console.error('[Desktop AuthService] Token refresh failed:', error);
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
|
||||
// Clear stored credentials on refresh failure
|
||||
@@ -533,7 +554,7 @@ export class AuthService {
|
||||
*/
|
||||
async loginWithOAuth(provider: string, authServerUrl: string, successHtml: string, errorHtml: string): Promise<UserInfo> {
|
||||
try {
|
||||
console.log('Starting OAuth login with provider:', provider);
|
||||
console.log('[Desktop AuthService] Starting OAuth login with provider:', provider);
|
||||
this.setAuthStatus('oauth_pending', null);
|
||||
|
||||
// Validate Supabase key is configured for OAuth
|
||||
@@ -554,7 +575,7 @@ export class AuthService {
|
||||
errorHtml,
|
||||
});
|
||||
|
||||
console.log('OAuth authentication successful, storing tokens');
|
||||
console.log('[Desktop AuthService] OAuth authentication successful, storing tokens');
|
||||
|
||||
// Save token to all storage locations
|
||||
await this.saveTokenEverywhere(result.access_token);
|
||||
@@ -569,11 +590,11 @@ export class AuthService {
|
||||
});
|
||||
|
||||
this.setAuthStatus('authenticated', userInfo);
|
||||
console.log('OAuth login successful');
|
||||
console.log('[Desktop AuthService] OAuth login successful');
|
||||
|
||||
return userInfo;
|
||||
} catch (error) {
|
||||
console.error('Failed to complete OAuth login:', error);
|
||||
console.error('[Desktop AuthService] Failed to complete OAuth login:', error);
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface TauriHttpRequestConfig {
|
||||
// Custom properties for desktop
|
||||
operationName?: string;
|
||||
skipBackendReadyCheck?: boolean;
|
||||
skipAuthRedirect?: boolean;
|
||||
// Axios compatibility properties (ignored by Tauri HTTP)
|
||||
suppressErrorToast?: boolean;
|
||||
cancelToken?: any;
|
||||
|
||||
Vendored
+2
@@ -15,11 +15,13 @@ declare module 'assets/material-symbols-icons.json' {
|
||||
declare module 'axios' {
|
||||
export interface AxiosRequestConfig<_D = unknown> {
|
||||
suppressErrorToast?: boolean;
|
||||
skipAuthRedirect?: boolean;
|
||||
skipBackendReadyCheck?: boolean;
|
||||
}
|
||||
|
||||
export interface InternalAxiosRequestConfig<_D = unknown> {
|
||||
suppressErrorToast?: boolean;
|
||||
skipAuthRedirect?: boolean;
|
||||
skipBackendReadyCheck?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ export interface Session {
|
||||
export interface AuthError {
|
||||
message: string;
|
||||
status?: number;
|
||||
code?: string;
|
||||
mfaRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
@@ -181,11 +183,13 @@ class SpringAuthClient {
|
||||
async signInWithPassword(credentials: {
|
||||
email: string;
|
||||
password: string;
|
||||
mfaCode?: string;
|
||||
}): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await apiClient.post('/api/v1/auth/login', {
|
||||
username: credentials.email,
|
||||
password: credentials.password
|
||||
password: credentials.password,
|
||||
mfaCode: credentials.mfaCode,
|
||||
}, {
|
||||
withCredentials: true, // Include cookies for CSRF
|
||||
});
|
||||
@@ -213,6 +217,24 @@ class SpringAuthClient {
|
||||
return { user: data.user, session, error: null };
|
||||
} catch (error: unknown) {
|
||||
console.error('[SpringAuth] signInWithPassword error:', error);
|
||||
if (error instanceof AxiosError) {
|
||||
const errorCode = error.response?.data?.error as string | undefined;
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
'Login failed';
|
||||
return {
|
||||
user: null,
|
||||
session: null,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
status: error.response?.status,
|
||||
code: errorCode,
|
||||
mfaRequired: errorCode === 'mfa_required',
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
user: null,
|
||||
session: null,
|
||||
|
||||
@@ -58,6 +58,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
|
||||
teamId: undefined as number | undefined,
|
||||
authType: 'WEB' as 'WEB' | 'OAUTH2' | 'SAML2',
|
||||
forceChange: false,
|
||||
forceMFA: false,
|
||||
});
|
||||
|
||||
// Form state for email invite
|
||||
@@ -141,6 +142,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
|
||||
teamId: inviteForm.teamId,
|
||||
authType: inviteForm.authType,
|
||||
forceChange: inviteForm.forceChange,
|
||||
forceMFA: inviteForm.forceMFA,
|
||||
});
|
||||
alert({ alertType: 'success', title: t('workspace.people.addMember.success') });
|
||||
onClose();
|
||||
@@ -153,6 +155,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
|
||||
teamId: undefined,
|
||||
authType: 'WEB',
|
||||
forceChange: false,
|
||||
forceMFA: false,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Failed to invite user:', error);
|
||||
@@ -253,6 +256,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
|
||||
teamId: undefined,
|
||||
authType: 'WEB',
|
||||
forceChange: false,
|
||||
forceMFA: false,
|
||||
});
|
||||
setEmailInviteForm({
|
||||
emails: '',
|
||||
@@ -283,7 +287,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
|
||||
handleInviteUser();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
@@ -543,6 +547,11 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
|
||||
checked={inviteForm.forceChange}
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, forceChange: e.currentTarget.checked })}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('workspace.people.addMember.forceMFA', 'Force MFA setup on next login')}
|
||||
checked={inviteForm.forceMFA}
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, forceMFA: e.currentTarget.checked })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
+298
-4
@@ -1,12 +1,15 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, Paper, PasswordInput, Stack, Text, TextInput } from '@mantine/core';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Box, Group, Modal, Paper, PasswordInput, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { alert as showToast } from '@app/components/toast';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { accountService } from '@app/services/accountService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useAccountLogout } from '@app/extensions/accountLogout';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { MfaSetupResponse } from '@app/responses/Mfa/MfaResponse';
|
||||
|
||||
const AccountSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -25,6 +28,17 @@ const AccountSection: React.FC = () => {
|
||||
const [newUsername, setNewUsername] = useState('');
|
||||
const [usernameError, setUsernameError] = useState('');
|
||||
const [usernameSubmitting, setUsernameSubmitting] = useState(false);
|
||||
const [mfaEnabled, setMfaEnabled] = useState(false);
|
||||
const [mfaSetupModalOpen, setMfaSetupModalOpen] = useState(false);
|
||||
const [mfaDisableModalOpen, setMfaDisableModalOpen] = useState(false);
|
||||
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(null);
|
||||
const [mfaSetupCode, setMfaSetupCode] = useState('');
|
||||
const [mfaDisableCode, setMfaDisableCode] = useState('');
|
||||
const [mfaError, setMfaError] = useState('');
|
||||
const [mfaLoading, setMfaLoading] = useState(false);
|
||||
const [changeButtonDisabled, setChangeButtonDisabled] = useState(false);
|
||||
const normalizeMfaCode = useCallback((value: string) => value.replace(/\D/g, '').slice(0, 6), []);
|
||||
const qrLogoSrc = `${BASE_PATH}/modern-logo/StirlingPDFLogoNoTextDark.svg`;
|
||||
|
||||
const authTypeFromMetadata = useMemo(() => {
|
||||
const metadata = user?.app_metadata as { authType?: string; authenticationType?: string } | undefined;
|
||||
@@ -92,6 +106,124 @@ const AccountSection: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAccountData = async () => {
|
||||
setChangeButtonDisabled(true);
|
||||
try {
|
||||
const data = await accountService.getAccountData().then((data) => data).finally(() => {
|
||||
setChangeButtonDisabled(false);
|
||||
});
|
||||
setMfaEnabled(data.mfaEnabled ?? false);
|
||||
} catch {
|
||||
// ignore fetch errors for account data
|
||||
console.warn('Failed to fetch account data');
|
||||
} finally {
|
||||
setChangeButtonDisabled(false);
|
||||
}
|
||||
};
|
||||
void fetchAccountData();
|
||||
}, []);
|
||||
|
||||
const handleStartMfaSetup = useCallback(async () => {
|
||||
try {
|
||||
setMfaLoading(true);
|
||||
setMfaError('');
|
||||
setMfaSetupCode('');
|
||||
const data = await accountService.requestMfaSetup();
|
||||
setMfaSetupData(data);
|
||||
setMfaSetupModalOpen(true);
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { error?: string } } };
|
||||
setMfaError(
|
||||
axiosError.response?.data?.error ||
|
||||
t('account.mfa.setupFailed', 'Unable to start two-factor setup. Please try again.')
|
||||
);
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleEnableMfa = useCallback(
|
||||
async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!mfaSetupCode.trim()) {
|
||||
setMfaError(t('account.mfa.codeRequired', 'Enter the authentication code to continue.'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setMfaLoading(true);
|
||||
setMfaError('');
|
||||
await accountService.enableMfa(mfaSetupCode.trim());
|
||||
setMfaEnabled(true);
|
||||
setMfaSetupModalOpen(false);
|
||||
setMfaSetupData(null);
|
||||
setMfaSetupCode('');
|
||||
showToast({
|
||||
alertType: 'success',
|
||||
title: t('account.mfa.enabled', 'Two-factor authentication enabled.'),
|
||||
});
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { error?: string } } };
|
||||
setMfaError(
|
||||
axiosError.response?.data?.error ||
|
||||
t('account.mfa.enableFailed', 'Unable to enable two-factor authentication. Check the code and try again.')
|
||||
);
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
},
|
||||
[mfaSetupCode, t]
|
||||
);
|
||||
|
||||
const handleDisableMfa = useCallback(
|
||||
async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!mfaDisableCode.trim()) {
|
||||
setMfaError(t('account.mfa.codeRequired', 'Enter the authentication code to continue.'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setMfaLoading(true);
|
||||
setMfaError('');
|
||||
await accountService.disableMfa(mfaDisableCode.trim());
|
||||
setMfaEnabled(false);
|
||||
setMfaDisableModalOpen(false);
|
||||
setMfaDisableCode('');
|
||||
showToast({
|
||||
alertType: 'success',
|
||||
title: t('account.mfa.disabled', 'Two-factor authentication disabled.'),
|
||||
});
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { error?: string } } };
|
||||
setMfaError(
|
||||
axiosError.response?.data?.error ||
|
||||
t('account.mfa.disableFailed', 'Unable to disable two-factor authentication. Check the code and try again.')
|
||||
);
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
},
|
||||
[mfaDisableCode, t]
|
||||
);
|
||||
|
||||
const handleCloseMfaSetupModal = useCallback(async () => {
|
||||
setMfaSetupModalOpen(false);
|
||||
setMfaSetupData(null);
|
||||
setMfaSetupCode('');
|
||||
setMfaError('');
|
||||
try {
|
||||
await accountService.cancelMfaSetup();
|
||||
} catch {
|
||||
console.warn('Failed to clear pending MFA setup');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseMfaDisableModal = useCallback(() => {
|
||||
setMfaDisableModalOpen(false);
|
||||
setMfaDisableCode('');
|
||||
setMfaError('');
|
||||
}, []);
|
||||
|
||||
const handleUsernameSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -183,6 +315,50 @@ const AccountSection: React.FC = () => {
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600}>{t('account.mfa.title', 'Two-factor authentication')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('account.mfa.description', 'Add an extra layer of security to your account.')}
|
||||
</Text>
|
||||
{isSsoUser ? (
|
||||
<Alert icon={<LocalIcon icon="info" width="1rem" height="1rem" />} color="blue" variant="light">
|
||||
{t(
|
||||
'account.mfa.ssoManaged',
|
||||
'Two-factor authentication for this account is managed by your identity provider.'
|
||||
)}
|
||||
</Alert>
|
||||
) : (
|
||||
<Group gap="sm" wrap="wrap">
|
||||
{!mfaEnabled ? (
|
||||
<Button
|
||||
leftSection={<LocalIcon icon="check-circle-outline-rounded" />}
|
||||
onClick={handleStartMfaSetup}
|
||||
loading={mfaLoading}
|
||||
disabled={changeButtonDisabled}
|
||||
>
|
||||
{t('account.mfa.enableButton', 'Enable two-factor authentication')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
leftSection={<LocalIcon icon="close-rounded" />}
|
||||
onClick={() => {
|
||||
setMfaError('');
|
||||
setMfaDisableCode('');
|
||||
setMfaDisableModalOpen(true);
|
||||
}}
|
||||
disabled={changeButtonDisabled}
|
||||
>
|
||||
{t('account.mfa.disableButton', 'Disable two-factor authentication')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Modal
|
||||
opened={passwordModalOpen}
|
||||
onClose={() => setPasswordModalOpen(false)}
|
||||
@@ -238,6 +414,124 @@ const AccountSection: React.FC = () => {
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={mfaSetupModalOpen}
|
||||
onClose={handleCloseMfaSetupModal}
|
||||
title={t('account.mfa.setupTitle', 'Set up two-factor authentication')}
|
||||
withinPortal
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<form onSubmit={handleEnableMfa}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'account.mfa.setupDescription',
|
||||
'Scan the QR code with your authenticator app, then enter the 6-digit code to confirm.'
|
||||
)}
|
||||
</Text>
|
||||
{mfaSetupData && (
|
||||
<Stack gap="sm" align="center">
|
||||
<Box
|
||||
style={{
|
||||
padding: '1.5rem',
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<QRCodeSVG
|
||||
value={mfaSetupData.otpauthUri || ''}
|
||||
size={180}
|
||||
level="H"
|
||||
imageSettings={{
|
||||
src: qrLogoSrc,
|
||||
height: 40,
|
||||
width: 40,
|
||||
excavate: true,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('account.mfa.manualKey', 'Manual setup key')}: <strong>{mfaSetupData.secret}</strong>
|
||||
</Text>
|
||||
<Text size="xs" c="orange">
|
||||
{t(
|
||||
'account.mfa.secretWarning',
|
||||
'Keep this key private. Anyone with access can generate valid authentication codes.'
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
{mfaError && (
|
||||
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
|
||||
{mfaError}
|
||||
</Alert>
|
||||
)}
|
||||
<TextInput
|
||||
label={t('account.mfa.codeLabel', 'Authentication code')}
|
||||
placeholder={t('account.mfa.codePlaceholder', 'Enter 6-digit code')}
|
||||
value={mfaSetupCode}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
minLength={6}
|
||||
autoComplete="one-time-code"
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={handleCloseMfaSetupModal}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" loading={mfaLoading}>
|
||||
{t('account.mfa.confirmEnable', 'Enable')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={mfaDisableModalOpen}
|
||||
onClose={handleCloseMfaDisableModal}
|
||||
title={t('account.mfa.disableTitle', 'Disable two-factor authentication')}
|
||||
withinPortal
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<form onSubmit={handleDisableMfa}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('account.mfa.disableDescription', 'Enter a valid authentication code to disable two-factor authentication.')}
|
||||
</Text>
|
||||
{mfaError && (
|
||||
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
|
||||
{mfaError}
|
||||
</Alert>
|
||||
)}
|
||||
<TextInput
|
||||
label={t('account.mfa.codeLabel', 'Authentication code')}
|
||||
placeholder={t('account.mfa.codePlaceholder', 'Enter 6-digit code')}
|
||||
value={mfaDisableCode}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setMfaDisableCode(normalizeMfaCode(event.currentTarget.value))}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
minLength={6}
|
||||
autoComplete="one-time-code"
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={handleCloseMfaDisableModal}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" color="red" loading={mfaLoading}>
|
||||
{t('account.mfa.confirmDisable', 'Disable')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={usernameModalOpen}
|
||||
onClose={() => setUsernameModalOpen(false)}
|
||||
@@ -261,7 +555,7 @@ const AccountSection: React.FC = () => {
|
||||
label={t('changeCreds.newUsername', 'New Username')}
|
||||
placeholder={t('changeCreds.newUsername', 'New Username')}
|
||||
value={newUsername}
|
||||
onChange={(event) => setNewUsername(event.currentTarget.value)}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setNewUsername(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
@@ -269,7 +563,7 @@ const AccountSection: React.FC = () => {
|
||||
label={t('changeCreds.oldPassword', 'Current Password')}
|
||||
placeholder={t('changeCreds.oldPassword', 'Current Password')}
|
||||
value={currentPasswordForUsername}
|
||||
onChange={(event) => setCurrentPasswordForUsername(event.currentTarget.value)}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setCurrentPasswordForUsername(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ export default function PeopleSection() {
|
||||
...user,
|
||||
isActive: adminData.userSessions[user.username] || false,
|
||||
lastRequest: adminData.userLastRequest[user.username] || undefined,
|
||||
mfaEnabled: adminData.userSettings?.[user.username]?.mfaEnabled === 'true',
|
||||
}));
|
||||
|
||||
setUsers(enrichedUsers);
|
||||
@@ -200,7 +201,7 @@ export default function PeopleSection() {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch people data:', error);
|
||||
console.error('[PeopleSection] Failed to fetch people data:', error);
|
||||
alert({ alertType: 'error', title: 'Failed to load people data' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -221,7 +222,7 @@ export default function PeopleSection() {
|
||||
closeEditModal();
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update user:', error);
|
||||
console.error('[PeopleSection] Failed to update user:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
@@ -238,7 +239,7 @@ export default function PeopleSection() {
|
||||
alert({ alertType: 'success', title: t('workspace.people.toggleEnabled.success') });
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to toggle user status:', error);
|
||||
console.error('[PeopleSection] Failed to toggle user status:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
@@ -258,7 +259,7 @@ export default function PeopleSection() {
|
||||
alert({ alertType: 'success', title: t('workspace.people.deleteUserSuccess', 'User deleted successfully') });
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete user:', error);
|
||||
console.error('[PeopleSection] Failed to delete user:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
@@ -576,6 +577,7 @@ export default function PeopleSection() {
|
||||
</Tooltip>
|
||||
|
||||
{/* Actions menu */}
|
||||
{!isCurrentUser(user) && (
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" disabled={!loginEnabled}>
|
||||
@@ -589,9 +591,10 @@ export default function PeopleSection() {
|
||||
onClick={() => openEditModal(user)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t('workspace.people.editRole')}
|
||||
{t('workspace.people.editRole', 'Edit Role & Team')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isCurrentUser(user) && (
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="lock" width="1rem" height="1rem" />}
|
||||
onClick={() => openChangePasswordModal(user)}
|
||||
@@ -599,6 +602,7 @@ export default function PeopleSection() {
|
||||
>
|
||||
{t('workspace.people.changePassword.action', 'Change password')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isCurrentUser(user) && (
|
||||
<Menu.Item
|
||||
leftSection={user.enabled ? <LocalIcon icon="person-off" width="1rem" height="1rem" /> : <LocalIcon icon="person-check" width="1rem" height="1rem" />}
|
||||
@@ -608,6 +612,31 @@ export default function PeopleSection() {
|
||||
{user.enabled ? t('workspace.people.disable') : t('workspace.people.enable')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isCurrentUser(user) && user.mfaEnabled && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<LocalIcon icon="key" width="1rem" height="1rem" />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await userManagementService.disableMfaByAdmin(user.username);
|
||||
alert({ alertType: 'success', title: t('workspace.people.mfa.adminDisableSuccess', 'MFA disabled successfully for user') });
|
||||
} catch (error: any) {
|
||||
console.error('[PeopleSection] Failed to disable MFA for user:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t('workspace.people.mfa.adminDisableError', 'Failed to disable MFA for user');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
}
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t('workspace.people.mfa.disableByAdmin', 'Disable MFA')}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
{!isCurrentUser(user) && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
@@ -618,6 +647,7 @@ export default function PeopleSection() {
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -33,6 +33,8 @@ export default function Login() {
|
||||
const [showEmailForm, setShowEmailForm] = useState(false);
|
||||
const [email, setEmail] = useState(() => searchParams.get('email') ?? '');
|
||||
const [password, setPassword] = useState('');
|
||||
const [mfaCode, setMfaCode] = useState('');
|
||||
const [requiresMfa, setRequiresMfa] = useState(false);
|
||||
const [enabledProviders, setEnabledProviders] = useState<OAuthProvider[]>([]);
|
||||
const [hasSSOProviders, setHasSSOProviders] = useState(false);
|
||||
const [_enableLogin, setEnableLogin] = useState<boolean | null>(null);
|
||||
@@ -273,6 +275,11 @@ export default function Login() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiresMfa && !mfaCode.trim()) {
|
||||
setError(t('login.mfaRequired', 'Two-factor code required'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSigningIn(true);
|
||||
setError(null);
|
||||
@@ -281,14 +288,20 @@ export default function Login() {
|
||||
|
||||
const { user, session, error } = await springAuth.signInWithPassword({
|
||||
email: email.trim(),
|
||||
password: password
|
||||
password: password,
|
||||
mfaCode: requiresMfa ? mfaCode.trim() : undefined,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('[Login] Email sign in error:', error);
|
||||
setError(error.message);
|
||||
if (error.mfaRequired || error.code === 'invalid_mfa_code') {
|
||||
setRequiresMfa(true);
|
||||
}
|
||||
} else if (user && session) {
|
||||
console.log('[Login] Email sign in successful');
|
||||
setRequiresMfa(false);
|
||||
setMfaCode('');
|
||||
// Auth state will update automatically and Landing will redirect to home
|
||||
// No need to navigate manually here
|
||||
}
|
||||
@@ -362,6 +375,10 @@ export default function Login() {
|
||||
password={password}
|
||||
setEmail={setEmail}
|
||||
setPassword={setPassword}
|
||||
mfaCode={mfaCode}
|
||||
setMfaCode={setMfaCode}
|
||||
showMfaField={requiresMfa || Boolean(mfaCode)}
|
||||
requiresMfa={requiresMfa}
|
||||
onSubmit={signInWithEmail}
|
||||
isSubmitting={isSigningIn}
|
||||
submitButtonText={isSigningIn ? (t('login.loggingIn') || 'Signing in...') : (t('login.login') || 'Sign in')}
|
||||
|
||||
@@ -22,6 +22,10 @@ interface EmailPasswordFormProps {
|
||||
password: string
|
||||
setEmail: (email: string) => void
|
||||
setPassword: (password: string) => void
|
||||
mfaCode?: string
|
||||
setMfaCode?: (code: string) => void
|
||||
showMfaField?: boolean
|
||||
requiresMfa?: boolean
|
||||
onSubmit: () => void
|
||||
isSubmitting: boolean
|
||||
submitButtonText: string
|
||||
@@ -29,6 +33,7 @@ interface EmailPasswordFormProps {
|
||||
fieldErrors?: {
|
||||
email?: string
|
||||
password?: string
|
||||
mfaCode?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +42,10 @@ export default function EmailPasswordForm({
|
||||
password,
|
||||
setEmail,
|
||||
setPassword,
|
||||
mfaCode = '',
|
||||
setMfaCode,
|
||||
showMfaField = false,
|
||||
requiresMfa = false,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
submitButtonText,
|
||||
@@ -66,6 +75,7 @@ export default function EmailPasswordForm({
|
||||
error={fieldErrors.email}
|
||||
classNames={{ label: 'auth-label' }}
|
||||
styles={authInputStyles}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -85,11 +95,32 @@ export default function EmailPasswordForm({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showMfaField && (
|
||||
<div className="auth-field">
|
||||
<TextInput
|
||||
id="mfaCode"
|
||||
label={t('login.mfaCode', 'Authentication code')}
|
||||
type="text"
|
||||
name="mfaCode"
|
||||
autoComplete="one-time-code"
|
||||
placeholder={t('login.enterMfaCode', 'Enter 6-digit code')}
|
||||
value={mfaCode}
|
||||
inputMode="numeric"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode?.(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
minLength={6}
|
||||
error={fieldErrors.mfaCode}
|
||||
classNames={{ label: 'auth-label' }}
|
||||
styles={authInputStyles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !email || (showPasswordField && !password)}
|
||||
disabled={isSubmitting || !email || (showPasswordField && !password) || (requiresMfa && !mfaCode.trim())}
|
||||
className="auth-button"
|
||||
fullWidth
|
||||
loading={isSubmitting}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface User {
|
||||
// Enriched client-side fields
|
||||
isActive?: boolean;
|
||||
lastRequest?: number; // timestamp in milliseconds
|
||||
mfaEnabled?: boolean; // whether MFA is enabled for the user
|
||||
}
|
||||
|
||||
export interface AdminSettingsData {
|
||||
@@ -38,6 +39,7 @@ export interface AdminSettingsData {
|
||||
licenseMaxUsers: number;
|
||||
premiumEnabled: boolean;
|
||||
mailEnabled: boolean;
|
||||
userSettings?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
@@ -47,6 +49,7 @@ export interface CreateUserRequest {
|
||||
teamId?: number;
|
||||
authType: 'WEB' | 'OAUTH2' | 'SAML2';
|
||||
forceChange?: boolean;
|
||||
forceMFA?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateUserRoleRequest {
|
||||
@@ -145,6 +148,9 @@ export const userManagementService = {
|
||||
if (data.forceChange !== undefined) {
|
||||
formData.append('forceChange', data.forceChange.toString());
|
||||
}
|
||||
if (data.forceMFA !== undefined) {
|
||||
formData.append('forceMFA', data.forceMFA.toString());
|
||||
}
|
||||
await apiClient.post('/api/v1/user/admin/saveUser', formData, {
|
||||
suppressErrorToast: true, // Component will handle error display
|
||||
} as any);
|
||||
@@ -291,4 +297,12 @@ export const userManagementService = {
|
||||
suppressErrorToast: true, // Component will handle error display
|
||||
} as any);
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable MFA for a user (admin only)
|
||||
*/
|
||||
async disableMfaByAdmin(username: string): Promise<void> {
|
||||
await apiClient.post(`/api/v1/auth/mfa/disable/admin/${encodeURIComponent(username)}`, undefined);
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user