Desktop login and connections match theme
This commit is contained in:
@@ -5815,6 +5815,7 @@ subtitle = "Sign in with your Stirling account"
|
||||
[setup.selfhosted]
|
||||
title = "Sign in to Server"
|
||||
subtitle = "Enter your server credentials"
|
||||
link = "or connect to a self hosted account"
|
||||
|
||||
[setup.server]
|
||||
title = "Connect to Server"
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LoginRightCarousel from '@app/components/shared/LoginRightCarousel';
|
||||
import buildLoginSlides from '@app/components/shared/loginSlides';
|
||||
import styles from '@app/routes/authShared/AuthLayout.module.css';
|
||||
import { useLogoVariant } from '@app/hooks/useLogoVariant';
|
||||
|
||||
interface DesktopAuthLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DesktopAuthLayout: React.FC<DesktopAuthLayoutProps> = ({ children }) => {
|
||||
const { t } = useTranslation();
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const [hideRightPanel, setHideRightPanel] = useState(false);
|
||||
const logoVariant = useLogoVariant();
|
||||
const imageSlides = useMemo(() => buildLoginSlides(logoVariant, t), [logoVariant, t]);
|
||||
|
||||
// Force light mode on auth pages
|
||||
useEffect(() => {
|
||||
const htmlElement = document.documentElement;
|
||||
const previousColorScheme = htmlElement.getAttribute('data-mantine-color-scheme');
|
||||
|
||||
// Set light mode
|
||||
htmlElement.setAttribute('data-mantine-color-scheme', 'light');
|
||||
|
||||
// Cleanup: restore previous theme when leaving auth pages
|
||||
return () => {
|
||||
if (previousColorScheme) {
|
||||
htmlElement.setAttribute('data-mantine-color-scheme', previousColorScheme);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
// Use viewport to avoid hysteresis when the card is already in single-column mode
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // matches min(73.75rem, 96vw)
|
||||
const columnWidth = cardWidthIfTwoCols / 2;
|
||||
const tooNarrow = columnWidth < 470;
|
||||
const tooShort = viewportHeight < 740;
|
||||
setHideRightPanel(tooNarrow || tooShort);
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
window.addEventListener('orientationchange', update);
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
window.removeEventListener('orientationchange', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.authContainer}>
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ''}`}
|
||||
>
|
||||
<div className={styles.authLeftPanel}>
|
||||
<div className={styles.authContent}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{!hideRightPanel && (
|
||||
<LoginRightCarousel imageSlides={imageSlides} initialSeconds={5} slideSeconds={8} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { authService, UserInfo } from '@app/services/authService';
|
||||
import { buildOAuthCallbackHtml } from '@app/utils/oauthCallbackHtml';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
|
||||
interface DesktopOAuthButtonsProps {
|
||||
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
|
||||
onError: (error: string) => void;
|
||||
isDisabled: boolean;
|
||||
serverUrl: string;
|
||||
providers: Array<'google' | 'github'>;
|
||||
}
|
||||
|
||||
export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
|
||||
onOAuthSuccess,
|
||||
onError,
|
||||
isDisabled,
|
||||
serverUrl,
|
||||
providers,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [oauthLoading, setOauthLoading] = useState(false);
|
||||
|
||||
const handleOAuthLogin = async (provider: 'google' | 'github') => {
|
||||
// Prevent concurrent OAuth attempts
|
||||
if (oauthLoading || isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setOauthLoading(true);
|
||||
|
||||
// Build callback page HTML with translations and dark mode support
|
||||
const successHtml = buildOAuthCallbackHtml({
|
||||
title: t('oauth.success.title', 'Authentication Successful'),
|
||||
message: t('oauth.success.message', 'You can close this window and return to Stirling PDF.'),
|
||||
isError: false,
|
||||
});
|
||||
|
||||
const errorHtml = buildOAuthCallbackHtml({
|
||||
title: t('oauth.error.title', 'Authentication Failed'),
|
||||
message: t('oauth.error.message', 'Authentication was not successful. You can close this window and try again.'),
|
||||
isError: true,
|
||||
errorPlaceholder: true, // {error} will be replaced by Rust
|
||||
});
|
||||
|
||||
const userInfo = await authService.loginWithOAuth(provider, serverUrl, successHtml, errorHtml);
|
||||
|
||||
// Call the onOAuthSuccess callback to complete setup
|
||||
await onOAuthSuccess(userInfo);
|
||||
} catch (error) {
|
||||
console.error('OAuth login failed:', error);
|
||||
|
||||
const errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: t('setup.login.error.oauthFailed', 'OAuth login failed. Please try again.');
|
||||
|
||||
onError(errorMessage);
|
||||
setOauthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const providerConfig = {
|
||||
google: { label: 'Google', file: 'google.svg' },
|
||||
github: { label: 'GitHub', file: 'github.svg' },
|
||||
};
|
||||
|
||||
if (providers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="oauth-container-vertical">
|
||||
{providers.map((providerId) => {
|
||||
const provider = providerConfig[providerId];
|
||||
return (
|
||||
<button
|
||||
key={providerId}
|
||||
onClick={() => handleOAuthLogin(providerId)}
|
||||
disabled={isDisabled || oauthLoading}
|
||||
className="oauth-button-vertical"
|
||||
title={provider.label}
|
||||
>
|
||||
<img
|
||||
src={`${BASE_PATH}/Login/${provider.file}`}
|
||||
alt={provider.label}
|
||||
className="oauth-icon-tiny"
|
||||
/>
|
||||
{provider.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{oauthLoading && (
|
||||
<p style={{ margin: '0.5rem 0', fontSize: '0.875rem', color: '#6b7280', textAlign: 'center' }}>
|
||||
{t('setup.login.oauthPending', 'Opening browser for authentication...')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,225 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack, TextInput, PasswordInput, Button, Text, Divider, Group, Collapse, Anchor, Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { authService } from '@app/services/authService';
|
||||
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
||||
import { buildOAuthCallbackHtml } from '@app/utils/oauthCallbackHtml';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
|
||||
interface LoginFormProps {
|
||||
serverUrl: string;
|
||||
isSaaS?: boolean;
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, isSaaS = false, onLogin, loading }) => {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [oauthLoading, setOauthLoading] = useState(false);
|
||||
const [showInstructions, setShowInstructions] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validation
|
||||
if (!username.trim()) {
|
||||
setValidationError(isSaaS
|
||||
? t('setup.login.error.emptyEmail', 'Please enter your email')
|
||||
: t('setup.login.error.emptyUsername', 'Please enter your username'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password'));
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError(null);
|
||||
await onLogin(username.trim(), password);
|
||||
};
|
||||
|
||||
const handleOAuthLogin = async (provider: 'google' | 'github') => {
|
||||
// Prevent concurrent OAuth attempts
|
||||
if (oauthLoading || loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setOauthLoading(true);
|
||||
setValidationError(null);
|
||||
|
||||
// For SaaS, use configured SaaS URL; for self-hosted, derive from serverUrl
|
||||
const authServerUrl = isSaaS
|
||||
? STIRLING_SAAS_URL
|
||||
: serverUrl; // Self-hosted might have its own auth
|
||||
|
||||
// Build callback page HTML with translations and dark mode support
|
||||
const successHtml = buildOAuthCallbackHtml({
|
||||
title: t('oauth.success.title', 'Authentication Successful'),
|
||||
message: t('oauth.success.message', 'You can close this window and return to Stirling PDF.'),
|
||||
isError: false,
|
||||
});
|
||||
|
||||
const errorHtml = buildOAuthCallbackHtml({
|
||||
title: t('oauth.error.title', 'Authentication Failed'),
|
||||
message: t('oauth.error.message', 'Authentication was not successful. You can close this window and try again.'),
|
||||
isError: true,
|
||||
errorPlaceholder: true, // {error} will be replaced by Rust
|
||||
});
|
||||
|
||||
const userInfo = await authService.loginWithOAuth(provider, authServerUrl, successHtml, errorHtml);
|
||||
|
||||
// Call the onLogin callback to complete setup (username/password not needed for OAuth)
|
||||
await onLogin(userInfo.username, '');
|
||||
} catch (error) {
|
||||
console.error('OAuth login failed:', error);
|
||||
|
||||
const errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: t('setup.login.error.oauthFailed', 'OAuth login failed. Please try again.');
|
||||
|
||||
setValidationError(errorMessage);
|
||||
setOauthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('setup.login.connectingTo', 'Connecting to:')} <strong>{isSaaS ? 'stirling.com' : serverUrl}</strong>
|
||||
</Text>
|
||||
|
||||
{/* Login requirement note for self-hosted servers */}
|
||||
{!isSaaS && (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('setup.login.serverRequirement', 'Note: The server must have login enabled.')}{' '}
|
||||
<Anchor
|
||||
size="xs"
|
||||
onClick={() => setShowInstructions(!showInstructions)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{showInstructions
|
||||
? t('setup.login.hideInstructions', 'Hide instructions')
|
||||
: t('setup.login.showInstructions', 'How to enable?')}
|
||||
</Anchor>
|
||||
</Text>
|
||||
|
||||
<Collapse in={showInstructions}>
|
||||
<Box mt="xs" p="sm" style={{ backgroundColor: 'var(--mantine-color-gray-0)', borderRadius: '4px' }}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('setup.login.instructions', 'To enable login on your Stirling PDF server:')}
|
||||
</Text>
|
||||
<Text size="xs" mt="xs" c="dimmed">
|
||||
{t('setup.login.instructionsEnvVar', 'Set the environment variable:')}
|
||||
</Text>
|
||||
<Text size="xs" mt="4px" ff="monospace" c="dark">
|
||||
SECURITY_ENABLELOGIN=true
|
||||
</Text>
|
||||
<Text size="xs" mt="xs" c="dimmed">
|
||||
{t('setup.login.instructionsOrYml', 'Or in settings.yml:')}
|
||||
</Text>
|
||||
<Text size="xs" mt="4px" ff="monospace" c="dark">
|
||||
security.enableLogin: true
|
||||
</Text>
|
||||
<Text size="xs" mt="xs" c="dimmed">
|
||||
{t('setup.login.instructionsRestart', 'Then restart your server for the changes to take effect.')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* OAuth Login Buttons - Only show for SaaS */}
|
||||
{isSaaS && (
|
||||
<>
|
||||
<Stack gap="xs">
|
||||
<Group grow>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<img src={`${BASE_PATH}/Login/google.svg`} alt="Google" width={18} height={18} />}
|
||||
onClick={() => handleOAuthLogin('google')}
|
||||
disabled={loading || oauthLoading}
|
||||
styles={{
|
||||
root: { height: '42px' },
|
||||
}}
|
||||
>
|
||||
{t('setup.login.signInWith', 'Sign in with')} Google
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<img src={`${BASE_PATH}/Login/github.svg`} alt="GitHub" width={18} height={18} />}
|
||||
onClick={() => handleOAuthLogin('github')}
|
||||
disabled={loading || oauthLoading}
|
||||
styles={{
|
||||
root: { height: '42px' },
|
||||
}}
|
||||
>
|
||||
{t('setup.login.signInWith', 'Sign in with')} GitHub
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{oauthLoading && (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t('setup.login.oauthPending', 'Opening browser for authentication...')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider label={t('setup.login.orContinueWith', 'Or continue with email')} labelPosition="center" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label={isSaaS
|
||||
? t('setup.login.email.label', 'Email')
|
||||
: t('setup.login.username.label', 'Username')}
|
||||
placeholder={isSaaS
|
||||
? t('setup.login.email.placeholder', 'Enter your email')
|
||||
: t('setup.login.username.placeholder', 'Enter your username')}
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t('setup.login.password.label', 'Password')}
|
||||
placeholder={t('setup.login.password.placeholder', 'Enter your password')}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
|
||||
{validationError && (
|
||||
<Text c="red" size="sm">
|
||||
{validationError}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
mt="md"
|
||||
fullWidth
|
||||
color="#AF3434"
|
||||
>
|
||||
{t('setup.login.submit', 'Login')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Stack, Button, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import ComputerIcon from '@mui/icons-material/Computer';
|
||||
|
||||
interface ModeSelectionProps {
|
||||
onSelect: (mode: 'saas' | 'selfhosted') => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Button
|
||||
size="xl"
|
||||
variant="default"
|
||||
onClick={() => onSelect('saas')}
|
||||
disabled={loading}
|
||||
leftSection={<CloudIcon />}
|
||||
styles={{
|
||||
root: {
|
||||
height: 'auto',
|
||||
padding: '1.25rem',
|
||||
},
|
||||
inner: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
section: {
|
||||
marginRight: '1rem',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<Text fw={600} size="md">{t('setup.mode.saas.title', 'Use SaaS')}</Text>
|
||||
<Text size="sm" c="dimmed" fw={400}>
|
||||
{t('setup.mode.saas.description', 'Sign in to Stirling PDF cloud service')}
|
||||
</Text>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="xl"
|
||||
variant="default"
|
||||
onClick={() => onSelect('selfhosted')}
|
||||
disabled={loading}
|
||||
leftSection={<ComputerIcon />}
|
||||
styles={{
|
||||
root: {
|
||||
height: 'auto',
|
||||
padding: '1.25rem',
|
||||
},
|
||||
inner: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
section: {
|
||||
marginRight: '1rem',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<Text fw={600} size="md">{t('setup.mode.selfhosted.title', 'Self-Hosted Server')}</Text>
|
||||
<Text size="sm" c="dimmed" fw={400}>
|
||||
{t('setup.mode.selfhosted.description', 'Connect to your own Stirling PDF server with your personal account')}
|
||||
</Text>
|
||||
</div>
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LoginHeader from '@app/routes/login/LoginHeader';
|
||||
import ErrorMessage from '@app/routes/login/ErrorMessage';
|
||||
import EmailPasswordForm from '@app/routes/login/EmailPasswordForm';
|
||||
import DividerWithText from '@app/components/shared/DividerWithText';
|
||||
import { DesktopOAuthButtons } from '@app/components/SetupWizard/DesktopOAuthButtons';
|
||||
import { SelfHostedLink } from '@app/components/SetupWizard/SelfHostedLink';
|
||||
import { UserInfo } from '@app/services/authService';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
|
||||
interface SaaSLoginScreenProps {
|
||||
serverUrl: string;
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
|
||||
onSelfHostedClick: () => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
||||
serverUrl,
|
||||
onLogin,
|
||||
onOAuthSuccess,
|
||||
onSelfHostedClick,
|
||||
loading,
|
||||
error,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const handleEmailPasswordSubmit = async () => {
|
||||
// Validation
|
||||
if (!email.trim()) {
|
||||
setValidationError(t('setup.login.error.emptyEmail', 'Please enter your email'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password'));
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError(null);
|
||||
await onLogin(email.trim(), password);
|
||||
};
|
||||
|
||||
const handleOAuthError = (errorMessage: string) => {
|
||||
setValidationError(errorMessage);
|
||||
};
|
||||
|
||||
const displayError = error || validationError;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoginHeader title={t('setup.saas.title', 'Sign in to Stirling Cloud')} />
|
||||
|
||||
<ErrorMessage error={displayError} />
|
||||
|
||||
<DesktopOAuthButtons
|
||||
onOAuthSuccess={onOAuthSuccess}
|
||||
onError={handleOAuthError}
|
||||
isDisabled={loading}
|
||||
serverUrl={serverUrl}
|
||||
providers={['google', 'github']}
|
||||
/>
|
||||
|
||||
<DividerWithText
|
||||
text={t('setup.login.orContinueWith', 'Or continue with email')}
|
||||
respondsToDarkMode={false}
|
||||
opacity={0.4}
|
||||
/>
|
||||
|
||||
<EmailPasswordForm
|
||||
email={email}
|
||||
password={password}
|
||||
setEmail={(value) => {
|
||||
setEmail(value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
setPassword={(value) => {
|
||||
setPassword(value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
onSubmit={handleEmailPasswordSubmit}
|
||||
isSubmitting={loading}
|
||||
submitButtonText={t('setup.login.submit', 'Login')}
|
||||
/>
|
||||
|
||||
<SelfHostedLink onClick={onSelfHostedClick} disabled={loading} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
|
||||
interface SelfHostedLinkProps {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SelfHostedLink: React.FC<SelfHostedLinkProps> = ({ onClick, disabled = false }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="navigation-link-container" style={{ marginTop: '1.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="navigation-link-button"
|
||||
>
|
||||
{t('setup.selfhosted.link', 'or connect to a self hosted account')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LoginHeader from '@app/routes/login/LoginHeader';
|
||||
import ErrorMessage from '@app/routes/login/ErrorMessage';
|
||||
import EmailPasswordForm from '@app/routes/login/EmailPasswordForm';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
|
||||
interface SelfHostedLoginScreenProps {
|
||||
serverUrl: string;
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
|
||||
serverUrl,
|
||||
onLogin,
|
||||
loading,
|
||||
error,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validation
|
||||
if (!username.trim()) {
|
||||
setValidationError(t('setup.login.error.emptyUsername', 'Please enter your username'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password'));
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError(null);
|
||||
await onLogin(username.trim(), password);
|
||||
};
|
||||
|
||||
const displayError = error || validationError;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoginHeader
|
||||
title={t('setup.selfhosted.title', 'Sign in to Server')}
|
||||
subtitle={t('setup.selfhosted.subtitle', 'Enter your server credentials')}
|
||||
/>
|
||||
|
||||
<ErrorMessage error={displayError} />
|
||||
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<p style={{ fontSize: '0.875rem', color: '#6b7280', margin: 0 }}>
|
||||
{t('setup.login.connectingTo', 'Connecting to:')} <strong>{serverUrl}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<EmailPasswordForm
|
||||
email={username}
|
||||
password={password}
|
||||
setEmail={(value) => {
|
||||
setUsername(value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
setPassword={(value) => {
|
||||
setPassword(value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={loading}
|
||||
submitButtonText={t('setup.login.submit', 'Login')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LoginHeader from '@app/routes/login/LoginHeader';
|
||||
import ErrorMessage from '@app/routes/login/ErrorMessage';
|
||||
import { ServerSelection } from '@app/components/SetupWizard/ServerSelection';
|
||||
import { ServerConfig } from '@app/services/connectionModeService';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
|
||||
interface ServerSelectionScreenProps {
|
||||
onSelect: (config: ServerConfig) => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const ServerSelectionScreen: React.FC<ServerSelectionScreenProps> = ({
|
||||
onSelect,
|
||||
loading,
|
||||
error,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoginHeader
|
||||
title={t('setup.server.title', 'Connect to Server')}
|
||||
subtitle={t('setup.server.subtitle', 'Enter your self-hosted server URL')}
|
||||
/>
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
<ServerSelection onSelect={onSelect} loading={loading} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
.setup-container {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg,
|
||||
light-dark(#f5f5f5, #1a1a1a) 0%,
|
||||
light-dark(#e8e8e8, #0d0d0d) 100%
|
||||
);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.setup-wrapper {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.setup-card {
|
||||
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||
box-shadow: 0 20px 60px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Container, Paper, Stack, Title, Text, Button, Image } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ModeSelection } from '@app/components/SetupWizard/ModeSelection';
|
||||
import { ServerSelection } from '@app/components/SetupWizard/ServerSelection';
|
||||
import { LoginForm } from '@app/components/SetupWizard/LoginForm';
|
||||
import { connectionModeService, ServerConfig } from '@app/services/connectionModeService';
|
||||
import { authService } from '@app/services/authService';
|
||||
import { DesktopAuthLayout } from '@app/components/SetupWizard/DesktopAuthLayout';
|
||||
import { SaaSLoginScreen } from '@app/components/SetupWizard/SaaSLoginScreen';
|
||||
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 { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { useLogoPath } from '@app/hooks/useLogoPath';
|
||||
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
|
||||
import '@app/components/SetupWizard/SetupWizard.css';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
|
||||
enum SetupStep {
|
||||
ModeSelection,
|
||||
SaaSLogin,
|
||||
ServerSelection,
|
||||
SelfHostedLogin,
|
||||
@@ -24,25 +22,11 @@ interface SetupWizardProps {
|
||||
|
||||
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
const { t } = useTranslation();
|
||||
const logoPath = useLogoPath();
|
||||
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.ModeSelection);
|
||||
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
|
||||
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 handleModeSelection = (mode: 'saas' | 'selfhosted') => {
|
||||
setError(null);
|
||||
|
||||
if (mode === 'saas') {
|
||||
// For SaaS, go directly to login screen with SaaS URL
|
||||
setServerConfig({ url: STIRLING_SAAS_URL });
|
||||
setActiveStep(SetupStep.SaaSLogin);
|
||||
} else {
|
||||
// For self-hosted, show server selection first
|
||||
setActiveStep(SetupStep.ServerSelection);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaaSLogin = async (username: string, password: string) => {
|
||||
if (!serverConfig) {
|
||||
setError('No SaaS server configured');
|
||||
@@ -70,6 +54,32 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
}
|
||||
};
|
||||
|
||||
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 handleServerSelection = (config: ServerConfig) => {
|
||||
setServerConfig(config);
|
||||
setError(null);
|
||||
@@ -99,118 +109,57 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
|
||||
const handleBack = () => {
|
||||
setError(null);
|
||||
if (activeStep === SetupStep.SaaSLogin) {
|
||||
setActiveStep(SetupStep.ModeSelection);
|
||||
setServerConfig(null);
|
||||
} else if (activeStep === SetupStep.SelfHostedLogin) {
|
||||
if (activeStep === SetupStep.SelfHostedLogin) {
|
||||
setActiveStep(SetupStep.ServerSelection);
|
||||
} else if (activeStep === SetupStep.ServerSelection) {
|
||||
setActiveStep(SetupStep.ModeSelection);
|
||||
setServerConfig(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStepTitle = () => {
|
||||
switch (activeStep) {
|
||||
case SetupStep.ModeSelection:
|
||||
return t('setup.welcome', 'Welcome to Stirling PDF');
|
||||
case SetupStep.SaaSLogin:
|
||||
return t('setup.saas.title', 'Sign in to Stirling Cloud');
|
||||
case SetupStep.ServerSelection:
|
||||
return t('setup.server.title', 'Connect to Server');
|
||||
case SetupStep.SelfHostedLogin:
|
||||
return t('setup.selfhosted.title', 'Sign in to Server');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getStepSubtitle = () => {
|
||||
switch (activeStep) {
|
||||
case SetupStep.ModeSelection:
|
||||
return t('setup.description', 'Get started by choosing how you want to use Stirling PDF');
|
||||
case SetupStep.SaaSLogin:
|
||||
return t('setup.saas.subtitle', 'Sign in with your Stirling account');
|
||||
case SetupStep.ServerSelection:
|
||||
return t('setup.server.subtitle', 'Enter your self-hosted server URL');
|
||||
case SetupStep.SelfHostedLogin:
|
||||
return t('setup.selfhosted.subtitle', 'Enter your server credentials');
|
||||
default:
|
||||
return '';
|
||||
setActiveStep(SetupStep.SaaSLogin);
|
||||
setServerConfig({ url: STIRLING_SAAS_URL });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="setup-container">
|
||||
<Container size="sm" className="setup-wrapper">
|
||||
<Paper shadow="xl" p="xl" radius="lg" className="setup-card">
|
||||
<Stack gap="lg">
|
||||
{/* Logo Header */}
|
||||
<Stack gap="xs" align="center">
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt="Stirling PDF"
|
||||
h={64}
|
||||
fit="contain"
|
||||
/>
|
||||
<Title order={1} ta="center" style={{ fontSize: '2rem', fontWeight: 800 }}>
|
||||
{getStepTitle()}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{getStepSubtitle()}
|
||||
</Text>
|
||||
</Stack>
|
||||
<DesktopAuthLayout>
|
||||
{/* Step Content */}
|
||||
{activeStep === SetupStep.SaaSLogin && (
|
||||
<SaaSLoginScreen
|
||||
serverUrl={serverConfig?.url || STIRLING_SAAS_URL}
|
||||
onLogin={handleSaaSLogin}
|
||||
onOAuthSuccess={handleSaaSLoginOAuth}
|
||||
onSelfHostedClick={handleSelfHostedClick}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<Paper p="md" bg="red.0" style={{ border: '1px solid var(--mantine-color-red-3)' }}>
|
||||
<Text size="sm" c="red.7" ta="center">
|
||||
{error}
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
{activeStep === SetupStep.ServerSelection && (
|
||||
<ServerSelectionScreen
|
||||
onSelect={handleServerSelection}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step Content */}
|
||||
{activeStep === SetupStep.ModeSelection && (
|
||||
<ModeSelection onSelect={handleModeSelection} loading={loading} />
|
||||
)}
|
||||
{activeStep === SetupStep.SelfHostedLogin && (
|
||||
<SelfHostedLoginScreen
|
||||
serverUrl={serverConfig?.url || ''}
|
||||
onLogin={handleSelfHostedLogin}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.SaaSLogin && (
|
||||
<LoginForm
|
||||
serverUrl={serverConfig?.url || ''}
|
||||
isSaaS={true}
|
||||
onLogin={handleSaaSLogin}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.ServerSelection && (
|
||||
<ServerSelection onSelect={handleServerSelection} loading={loading} />
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.SelfHostedLogin && (
|
||||
<LoginForm
|
||||
serverUrl={serverConfig?.url || ''}
|
||||
isSaaS={false}
|
||||
onLogin={handleSelfHostedLogin}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Back Button */}
|
||||
{activeStep > SetupStep.ModeSelection && !loading && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={handleBack}
|
||||
fullWidth
|
||||
mt="md"
|
||||
>
|
||||
{t('common.back', 'Back')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
</div>
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user