mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
# 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]>
336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { DesktopAuthLayout } from '@app/components/SetupWizard/DesktopAuthLayout';
|
|
import { SaaSLoginScreen } from '@app/components/SetupWizard/SaaSLoginScreen';
|
|
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 { 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';
|
|
import { useEffect } from 'react';
|
|
import '@app/routes/authShared/auth.css';
|
|
|
|
enum SetupStep {
|
|
SaaSLogin,
|
|
SaaSSignup,
|
|
ServerSelection,
|
|
SelfHostedLogin,
|
|
}
|
|
|
|
interface SetupWizardProps {
|
|
onComplete: () => void;
|
|
}
|
|
|
|
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
|
const { t } = useTranslation();
|
|
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.SaaSLogin);
|
|
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) {
|
|
setError('No SaaS server configured');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
// Only attempt password login if a password is provided
|
|
// If password is empty, assume OAuth login already completed
|
|
const isAlreadyAuthenticated = await authService.isAuthenticated();
|
|
if (!isAlreadyAuthenticated && password) {
|
|
await authService.login(serverConfig.url, username, password);
|
|
}
|
|
|
|
await connectionModeService.switchToSaaS(serverConfig.url);
|
|
tauriBackendService.startBackend().catch(console.error);
|
|
onComplete();
|
|
} catch (err) {
|
|
console.error('SaaS login failed:', err);
|
|
setError(err instanceof Error ? err.message : 'SaaS login failed');
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSaaSLoginOAuth = async (_userInfo: UserInfo) => {
|
|
if (!serverConfig) {
|
|
setError('No SaaS server configured');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
// OAuth already completed by authService.loginWithOAuth
|
|
await connectionModeService.switchToSaaS(serverConfig.url);
|
|
tauriBackendService.startBackend().catch(console.error);
|
|
onComplete();
|
|
} catch (err) {
|
|
console.error('SaaS OAuth login completion failed:', err);
|
|
setError(err instanceof Error ? err.message : 'Failed to complete SaaS login');
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSelfHostedClick = () => {
|
|
setError(null);
|
|
setActiveStep(SetupStep.ServerSelection);
|
|
};
|
|
|
|
const handleSwitchToSignup = () => {
|
|
setError(null);
|
|
setActiveStep(SetupStep.SaaSSignup);
|
|
};
|
|
|
|
const handleSwitchToLogin = () => {
|
|
setError(null);
|
|
setActiveStep(SetupStep.SaaSLogin);
|
|
};
|
|
|
|
const handleServerSelection = (config: ServerConfig) => {
|
|
setServerConfig(config);
|
|
setError(null);
|
|
setSelfHostedMfaCode('');
|
|
setSelfHostedMfaRequired(false);
|
|
setActiveStep(SetupStep.SelfHostedLogin);
|
|
};
|
|
|
|
const handleSelfHostedLogin = async (username: string, password: string) => {
|
|
console.log('[SetupWizard] 🔐 Starting self-hosted login');
|
|
console.log(`[SetupWizard] Server: ${serverConfig?.url}`);
|
|
console.log(`[SetupWizard] Username: ${username}`);
|
|
|
|
if (!serverConfig) {
|
|
console.error('[SetupWizard] ❌ No server configured');
|
|
setError('No server configured');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
console.log('[SetupWizard] Step 1: Authenticating with server...');
|
|
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');
|
|
|
|
console.log('[SetupWizard] Step 3: Initializing external backend...');
|
|
await tauriBackendService.initializeExternalBackend();
|
|
console.log('[SetupWizard] ✅ External backend initialized');
|
|
|
|
console.log('[SetupWizard] ✅ Setup complete, calling onComplete()');
|
|
onComplete();
|
|
} catch (err) {
|
|
console.error('[SetupWizard] ❌ Self-hosted login failed:', err);
|
|
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);
|
|
}
|
|
};
|
|
|
|
const handleSelfHostedOAuthSuccess = async (_userInfo: UserInfo) => {
|
|
console.log('[SetupWizard] 🔐 OAuth login successful, completing setup');
|
|
console.log(`[SetupWizard] Server: ${serverConfig?.url}`);
|
|
|
|
if (!serverConfig) {
|
|
console.error('[SetupWizard] ❌ No server configured');
|
|
setError('No server configured');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
console.log('[SetupWizard] Step 1: OAuth already completed');
|
|
console.log('[SetupWizard] Step 2: Switching to self-hosted mode...');
|
|
await connectionModeService.switchToSelfHosted(serverConfig);
|
|
console.log('[SetupWizard] ✅ Switched to self-hosted mode');
|
|
|
|
console.log('[SetupWizard] Step 3: Initializing external backend...');
|
|
await tauriBackendService.initializeExternalBackend();
|
|
console.log('[SetupWizard] ✅ External backend initialized');
|
|
|
|
console.log('[SetupWizard] ✅ Setup complete, calling onComplete()');
|
|
onComplete();
|
|
} catch (err) {
|
|
console.error('[SetupWizard] ❌ Self-hosted OAuth login completion failed:', err);
|
|
const errorMessage = err instanceof Error ? err.message : 'Failed to complete login';
|
|
console.error('[SetupWizard] Error message:', errorMessage);
|
|
setError(errorMessage);
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const unsubscribePromise = listen<string>('deep-link', async (event) => {
|
|
const url = event.payload;
|
|
if (!url) return;
|
|
|
|
try {
|
|
const parsed = new URL(url);
|
|
|
|
// Supabase sends tokens in the URL hash
|
|
const hash = parsed.hash.replace(/^#/, '');
|
|
const params = new URLSearchParams(hash);
|
|
const accessToken = params.get('access_token');
|
|
const type = params.get('type') || parsed.searchParams.get('type');
|
|
const accessTokenFromHash = params.get('access_token');
|
|
const accessTokenFromQuery = parsed.searchParams.get('access_token');
|
|
const serverFromQuery = parsed.searchParams.get('server');
|
|
|
|
// Handle self-hosted SSO deep link
|
|
if (type === 'sso' || type === 'sso-selfhosted') {
|
|
const token = accessTokenFromHash || accessTokenFromQuery;
|
|
const serverUrl = serverFromQuery || serverConfig?.url || STIRLING_SAAS_URL;
|
|
if (!token || !serverUrl) {
|
|
console.error('[SetupWizard] Deep link missing token or server for SSO completion');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
await authService.completeSelfHostedSession(serverUrl, token);
|
|
await connectionModeService.switchToSelfHosted({ url: serverUrl });
|
|
await tauriBackendService.initializeExternalBackend();
|
|
onComplete();
|
|
return;
|
|
}
|
|
|
|
if (!type || (type !== 'signup' && type !== 'recovery' && type !== 'magiclink')) {
|
|
return;
|
|
}
|
|
|
|
if (!accessToken) {
|
|
console.error('[SetupWizard] Deep link missing access_token');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
await authService.completeSupabaseSession(accessToken, serverConfig?.url || STIRLING_SAAS_URL);
|
|
await connectionModeService.switchToSaaS(serverConfig?.url || STIRLING_SAAS_URL);
|
|
tauriBackendService.startBackend().catch(console.error);
|
|
onComplete();
|
|
} catch (err) {
|
|
console.error('[SetupWizard] Failed to handle deep link', err);
|
|
setError(err instanceof Error ? err.message : 'Failed to complete signup');
|
|
setLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
void unsubscribePromise.then((unsub) => unsub());
|
|
};
|
|
}, [onComplete, serverConfig?.url]);
|
|
|
|
const handleBack = () => {
|
|
setError(null);
|
|
if (activeStep === SetupStep.SelfHostedLogin) {
|
|
setSelfHostedMfaCode('');
|
|
setSelfHostedMfaRequired(false);
|
|
setActiveStep(SetupStep.ServerSelection);
|
|
} else if (activeStep === SetupStep.ServerSelection) {
|
|
setActiveStep(SetupStep.SaaSLogin);
|
|
setServerConfig({ url: STIRLING_SAAS_URL });
|
|
} else if (activeStep === SetupStep.SaaSSignup) {
|
|
setActiveStep(SetupStep.SaaSLogin);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<DesktopAuthLayout>
|
|
{/* Step Content */}
|
|
{activeStep === SetupStep.SaaSLogin && (
|
|
<SaaSLoginScreen
|
|
serverUrl={serverConfig?.url || STIRLING_SAAS_URL}
|
|
onLogin={handleSaaSLogin}
|
|
onOAuthSuccess={handleSaaSLoginOAuth}
|
|
onSelfHostedClick={handleSelfHostedClick}
|
|
onSwitchToSignup={handleSwitchToSignup}
|
|
loading={loading}
|
|
error={error}
|
|
/>
|
|
)}
|
|
|
|
{activeStep === SetupStep.SaaSSignup && (
|
|
<SaaSSignupScreen
|
|
loading={loading}
|
|
error={error}
|
|
onLogin={handleSaaSLogin}
|
|
onSwitchToLogin={handleSwitchToLogin}
|
|
/>
|
|
)}
|
|
|
|
{activeStep === SetupStep.ServerSelection && (
|
|
<ServerSelectionScreen
|
|
onSelect={handleServerSelection}
|
|
loading={loading}
|
|
error={error}
|
|
/>
|
|
)}
|
|
|
|
{activeStep === SetupStep.SelfHostedLogin && (
|
|
<SelfHostedLoginScreen
|
|
serverUrl={serverConfig?.url || ''}
|
|
enabledOAuthProviders={serverConfig?.enabledOAuthProviders}
|
|
onLogin={handleSelfHostedLogin}
|
|
onOAuthSuccess={handleSelfHostedOAuthSuccess}
|
|
mfaCode={selfHostedMfaCode}
|
|
setMfaCode={setSelfHostedMfaCode}
|
|
requiresMfa={selfHostedMfaRequired}
|
|
loading={loading}
|
|
error={error}
|
|
/>
|
|
)}
|
|
|
|
{/* Back Button */}
|
|
{activeStep > SetupStep.SaaSLogin && !loading && (
|
|
<div className="navigation-link-container" style={{ marginTop: '1.5rem' }}>
|
|
<button
|
|
type="button"
|
|
onClick={handleBack}
|
|
className="navigation-link-button"
|
|
>
|
|
{t('common.back', 'Back')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</DesktopAuthLayout>
|
|
);
|
|
};
|