mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-04-22 23:08:53 +02:00
send email and invite link
This commit is contained in:
@@ -20,11 +20,26 @@ import Landing from "./routes/Landing";
|
||||
import Login from "./routes/Login";
|
||||
import Signup from "./routes/Signup";
|
||||
import AuthCallback from "./routes/AuthCallback";
|
||||
import InviteAccept from "./routes/InviteAccept";
|
||||
|
||||
// Import global styles
|
||||
import "./styles/tailwind.css";
|
||||
import "./styles/cookieconsent.css";
|
||||
import "./index.css";
|
||||
|
||||
// Load cookieconsent.css optionally - won't block UI if ad blocker blocks it
|
||||
const loadOptionalCSS = () => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = '/src/styles/cookieconsent.css';
|
||||
link.onerror = () => {
|
||||
console.debug('Cookie consent styles blocked by ad blocker - continuing without them');
|
||||
};
|
||||
document.head.appendChild(link);
|
||||
};
|
||||
// Load it once when app initializes
|
||||
if (typeof document !== 'undefined') {
|
||||
loadOptionalCSS();
|
||||
}
|
||||
import { RightRailProvider } from "./contexts/RightRailContext";
|
||||
import { ViewerProvider } from "./contexts/ViewerContext";
|
||||
import { SignatureProvider } from "./contexts/SignatureContext";
|
||||
@@ -59,6 +74,7 @@ export default function App() {
|
||||
{/* Auth routes - no FileContext or other providers needed */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/invite" element={<InviteAccept />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
|
||||
{/* Main app routes - wrapped with all providers */}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { Modal, Stack, Text, PasswordInput, Button, Alert } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import LocalIcon from './LocalIcon';
|
||||
import { accountService } from '../../services/accountService';
|
||||
import { alert } from '../toast';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
|
||||
import { useAuth } from '../../auth/UseSession';
|
||||
|
||||
interface FirstLoginModalProps {
|
||||
opened: boolean;
|
||||
@@ -20,6 +22,8 @@ interface FirstLoginModalProps {
|
||||
*/
|
||||
export default function FirstLoginModal({ opened, onPasswordChanged, username }: FirstLoginModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { signOut } = useAuth();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
@@ -38,11 +42,6 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError(t('firstLogin.passwordTooShort', 'Password must be at least 8 characters'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword === currentPassword) {
|
||||
setError(t('firstLogin.passwordMustBeDifferent', 'New password must be different from current password'));
|
||||
return;
|
||||
@@ -52,7 +51,8 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
await accountService.changePassword(currentPassword, newPassword);
|
||||
// Use changePasswordOnLogin to clear the first-use flag
|
||||
await accountService.changePasswordOnLogin(currentPassword, newPassword);
|
||||
|
||||
alert({
|
||||
alertType: 'success',
|
||||
@@ -64,11 +64,9 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
|
||||
// Wait a moment for the user to see the success message
|
||||
// Then the backend will have logged them out, and onPasswordChanged will handle redirect
|
||||
setTimeout(() => {
|
||||
onPasswordChanged();
|
||||
}, 1500);
|
||||
// Backend has logged us out, so clear frontend auth state and redirect to login
|
||||
await signOut();
|
||||
navigate('/login?messageType=passwordChanged');
|
||||
} catch (err: any) {
|
||||
console.error('Failed to change password:', err);
|
||||
setError(
|
||||
@@ -130,7 +128,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
|
||||
|
||||
<PasswordInput
|
||||
label={t('firstLogin.newPassword', 'New Password')}
|
||||
placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')}
|
||||
placeholder={t('firstLogin.enterNewPassword', 'Enter new password')}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.currentTarget.value)}
|
||||
required
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Modal,
|
||||
Select,
|
||||
Paper,
|
||||
Checkbox,
|
||||
Switch,
|
||||
Textarea,
|
||||
SegmentedControl,
|
||||
Tooltip,
|
||||
@@ -38,7 +38,7 @@ export default function PeopleSection() {
|
||||
const [editUserModalOpened, setEditUserModalOpened] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [inviteMode, setInviteMode] = useState<'email' | 'direct'>('direct');
|
||||
const [inviteMode, setInviteMode] = useState<'email' | 'direct' | 'link'>('direct');
|
||||
|
||||
// Form state for direct invite
|
||||
const [inviteForm, setInviteForm] = useState({
|
||||
@@ -56,6 +56,17 @@ export default function PeopleSection() {
|
||||
teamId: undefined as number | undefined,
|
||||
});
|
||||
|
||||
// Form state for invite link
|
||||
const [inviteLinkForm, setInviteLinkForm] = useState({
|
||||
email: '',
|
||||
role: 'ROLE_USER',
|
||||
teamId: undefined as number | undefined,
|
||||
expiryHours: 72,
|
||||
sendEmail: false,
|
||||
});
|
||||
|
||||
const [generatedInviteLink, setGeneratedInviteLink] = useState<string | null>(null);
|
||||
|
||||
// Form state for edit user modal
|
||||
const [editForm, setEditForm] = useState({
|
||||
role: 'ROLE_USER',
|
||||
@@ -189,6 +200,45 @@ export default function PeopleSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateInviteLink = async () => {
|
||||
// Email is optional - if provided, it will be pre-filled for the user
|
||||
// If not provided, user will be asked to enter their email during signup
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
const response = await userManagementService.generateInviteLink({
|
||||
email: inviteLinkForm.email.trim() || undefined,
|
||||
role: inviteLinkForm.role,
|
||||
teamId: inviteLinkForm.teamId,
|
||||
expiryHours: inviteLinkForm.expiryHours,
|
||||
sendEmail: inviteLinkForm.sendEmail,
|
||||
});
|
||||
|
||||
// Construct invite URL using current frontend origin (not backend URL)
|
||||
const frontendUrl = `${window.location.origin}/invite?token=${response.token}`;
|
||||
setGeneratedInviteLink(frontendUrl);
|
||||
|
||||
if (response.emailSent) {
|
||||
alert({ alertType: 'success', title: t('workspace.people.inviteLink.successWithEmail', 'Invite link generated and sent via email') });
|
||||
} else {
|
||||
alert({ alertType: 'success', title: t('workspace.people.inviteLink.success', 'Invite link generated successfully') });
|
||||
}
|
||||
|
||||
if (response.emailError) {
|
||||
alert({ alertType: 'warning', title: t('workspace.people.inviteLink.emailFailed', 'Email failed to send'), body: response.emailError });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to generate invite link:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t('workspace.people.inviteLink.error', 'Failed to generate invite link');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateUserRole = async () => {
|
||||
if (!selectedUser) return;
|
||||
|
||||
@@ -463,7 +513,17 @@ export default function PeopleSection() {
|
||||
{/* Add Member Modal */}
|
||||
<Modal
|
||||
opened={inviteModalOpened}
|
||||
onClose={() => setInviteModalOpened(false)}
|
||||
onClose={() => {
|
||||
setInviteModalOpened(false);
|
||||
setGeneratedInviteLink(null);
|
||||
setInviteLinkForm({
|
||||
email: '',
|
||||
role: 'ROLE_USER',
|
||||
teamId: undefined,
|
||||
expiryHours: 72,
|
||||
sendEmail: false,
|
||||
});
|
||||
}}
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
centered
|
||||
@@ -472,7 +532,17 @@ export default function PeopleSection() {
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<CloseButton
|
||||
onClick={() => setInviteModalOpened(false)}
|
||||
onClick={() => {
|
||||
setInviteModalOpened(false);
|
||||
setGeneratedInviteLink(null);
|
||||
setInviteLinkForm({
|
||||
email: '',
|
||||
role: 'ROLE_USER',
|
||||
teamId: undefined,
|
||||
expiryHours: 72,
|
||||
sendEmail: false,
|
||||
});
|
||||
}}
|
||||
size="lg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -496,32 +566,36 @@ export default function PeopleSection() {
|
||||
</Stack>
|
||||
|
||||
{/* Mode Toggle */}
|
||||
<Tooltip
|
||||
label={t('workspace.people.inviteMode.emailDisabled', 'Email invites require SMTP configuration and mail.enableInvites=true in settings')}
|
||||
disabled={!!config?.enableEmailInvites}
|
||||
position="bottom"
|
||||
withArrow
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 1}
|
||||
>
|
||||
<div>
|
||||
<SegmentedControl
|
||||
value={inviteMode}
|
||||
onChange={(value) => setInviteMode(value as 'email' | 'direct')}
|
||||
data={[
|
||||
{
|
||||
label: t('workspace.people.inviteMode.username', 'Username'),
|
||||
value: 'direct',
|
||||
},
|
||||
{
|
||||
label: t('workspace.people.inviteMode.email', 'Email'),
|
||||
value: 'email',
|
||||
disabled: !config?.enableEmailInvites,
|
||||
},
|
||||
]}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<SegmentedControl
|
||||
value={inviteMode}
|
||||
onChange={(value) => setInviteMode(value as 'email' | 'direct' | 'link')}
|
||||
data={[
|
||||
{
|
||||
label: t('workspace.people.directInvite.tab', 'Direct Create'),
|
||||
value: 'direct',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Tooltip
|
||||
label={t('workspace.people.inviteMode.emailDisabled', 'Email invites require SMTP configuration and mail.enableInvites=true')}
|
||||
disabled={!!config?.enableEmailInvites}
|
||||
position="bottom"
|
||||
withArrow
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 1}
|
||||
>
|
||||
<span>{t('workspace.people.emailInvite.tab', 'Email Invite')}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
value: 'email',
|
||||
disabled: !config?.enableEmailInvites,
|
||||
},
|
||||
{
|
||||
label: t('workspace.people.inviteLinkTab.tab', 'Invite Link'),
|
||||
value: 'link',
|
||||
},
|
||||
]}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{/* Email Mode */}
|
||||
{inviteMode === 'email' && config?.enableEmailInvites && (
|
||||
@@ -589,7 +663,7 @@ export default function PeopleSection() {
|
||||
clearable
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<Checkbox
|
||||
<Switch
|
||||
label={t('workspace.people.addMember.forcePasswordChange', 'Force password change on first login')}
|
||||
checked={inviteForm.forceChange}
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, forceChange: e.currentTarget.checked })}
|
||||
@@ -597,16 +671,147 @@ export default function PeopleSection() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Invite Link Mode */}
|
||||
{inviteMode === 'link' && (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('workspace.people.inviteLink.description', 'Generate a secure link that allows the user to set their own password')}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label={t('workspace.people.inviteLink.email', 'Email Address')}
|
||||
placeholder="user@example.com"
|
||||
description={t('workspace.people.inviteLink.emailOptional', 'Optional - leave blank for a general invite link')}
|
||||
value={inviteLinkForm.email}
|
||||
onChange={(e) => {
|
||||
const newEmail = e.currentTarget.value;
|
||||
const hasEmail = newEmail.trim().length > 0;
|
||||
const smtpEnabled = config?.enableEmailInvites || false;
|
||||
|
||||
setInviteLinkForm({
|
||||
...inviteLinkForm,
|
||||
email: newEmail,
|
||||
// Auto-enable sendEmail when email is provided and SMTP is configured
|
||||
// Disable when email is cleared
|
||||
sendEmail: hasEmail && smtpEnabled ? true : false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t('workspace.people.addMember.role')}
|
||||
data={roleOptions}
|
||||
value={inviteLinkForm.role}
|
||||
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, role: value || 'ROLE_USER' })}
|
||||
renderOption={renderRoleOption}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t('workspace.people.addMember.team')}
|
||||
placeholder={t('workspace.people.addMember.teamPlaceholder')}
|
||||
data={teamOptions}
|
||||
value={inviteLinkForm.teamId?.toString()}
|
||||
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, teamId: value ? parseInt(value) : undefined })}
|
||||
clearable
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
type="number"
|
||||
label={t('workspace.people.inviteLink.expiryHours', 'Expiry Hours')}
|
||||
description={t('workspace.people.inviteLink.expiryDescription', 'How many hours until the link expires')}
|
||||
value={inviteLinkForm.expiryHours}
|
||||
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, expiryHours: parseInt(e.currentTarget.value) || 72 })}
|
||||
min={1}
|
||||
max={720}
|
||||
/>
|
||||
|
||||
{inviteLinkForm.email.trim() && (
|
||||
<Tooltip
|
||||
label={t('workspace.people.inviteLink.smtpRequired', 'SMTP must be configured to send emails')}
|
||||
disabled={!!config?.enableEmailInvites}
|
||||
position="top-start"
|
||||
withArrow
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 1}
|
||||
>
|
||||
<div>
|
||||
<Switch
|
||||
label={t('workspace.people.inviteLink.sendEmail', 'Send invite link via email')}
|
||||
description={!config?.enableEmailInvites ? t('workspace.people.inviteLink.smtpRequired', 'SMTP must be configured to send emails') : undefined}
|
||||
checked={inviteLinkForm.sendEmail}
|
||||
disabled={!config?.enableEmailInvites}
|
||||
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, sendEmail: e.currentTarget.checked })}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{generatedInviteLink && (
|
||||
<Paper p="md" withBorder style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('workspace.people.inviteLink.generated', 'Invite Link Generated')}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<TextInput
|
||||
value={generatedInviteLink}
|
||||
readOnly
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={async () => {
|
||||
try {
|
||||
// Try modern clipboard API first (requires HTTPS or localhost)
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(generatedInviteLink);
|
||||
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard') });
|
||||
} else {
|
||||
// Fallback for HTTP (non-secure contexts)
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = generatedInviteLink;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
if (successful) {
|
||||
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard') });
|
||||
} else {
|
||||
throw new Error('Copy command failed');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
alert({ alertType: 'error', title: 'Failed to copy to clipboard' });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="content-copy" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action Button */}
|
||||
<Button
|
||||
onClick={inviteMode === 'email' ? handleEmailInvite : handleInviteUser}
|
||||
onClick={inviteMode === 'email' ? handleEmailInvite : inviteMode === 'link' ? handleGenerateInviteLink : handleInviteUser}
|
||||
loading={processing}
|
||||
fullWidth
|
||||
size="md"
|
||||
mt="md"
|
||||
leftSection={inviteMode === 'link' ? <LocalIcon icon="link" /> : undefined}
|
||||
>
|
||||
{inviteMode === 'email'
|
||||
? t('workspace.people.emailInvite.submit', 'Send Invites')
|
||||
: inviteMode === 'link'
|
||||
? t('workspace.people.inviteLink.generate', 'Generate Link')
|
||||
: t('workspace.people.addMember.submit')}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { alert } from '../../toast';
|
||||
import apiClient from '../../../services/apiClient';
|
||||
|
||||
export function useRestartServer() {
|
||||
const { t } = useTranslation();
|
||||
@@ -27,18 +28,12 @@ export function useRestartServer() {
|
||||
),
|
||||
});
|
||||
|
||||
const response = await fetch('/api/v1/admin/settings/restart', {
|
||||
method: 'POST',
|
||||
});
|
||||
await apiClient.post('/api/v1/admin/settings/restart');
|
||||
|
||||
if (response.ok) {
|
||||
// Wait a moment then reload the page
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 3000);
|
||||
} else {
|
||||
throw new Error('Failed to restart');
|
||||
}
|
||||
// Wait a moment then reload the page
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 3000);
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
|
||||
281
frontend/src/routes/InviteAccept.tsx
Normal file
281
frontend/src/routes/InviteAccept.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentMeta } from '../hooks/useDocumentMeta';
|
||||
import AuthLayout from './authShared/AuthLayout';
|
||||
import LoginHeader from './login/LoginHeader';
|
||||
import ErrorMessage from './login/ErrorMessage';
|
||||
import { BASE_PATH } from '../constants/app';
|
||||
import apiClient from '../services/apiClient';
|
||||
|
||||
interface InviteData {
|
||||
email: string | null;
|
||||
role: string;
|
||||
expiresAt: string;
|
||||
emailRequired: boolean;
|
||||
}
|
||||
|
||||
export default function InviteAccept() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [inviteData, setInviteData] = useState<InviteData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
const baseUrl = window.location.origin + BASE_PATH;
|
||||
|
||||
// Set document meta
|
||||
useDocumentMeta({
|
||||
title: `${t('invite.welcome', 'Welcome to Stirling PDF')} - Stirling PDF`,
|
||||
description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
|
||||
ogTitle: `${t('invite.welcome', 'Welcome to Stirling PDF')} - Stirling PDF`,
|
||||
ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
|
||||
ogImage: `${baseUrl}/og_images/home.png`,
|
||||
ogUrl: `${window.location.origin}${window.location.pathname}`
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError(t('invite.invalidToken', 'Invalid invitation link'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
validateToken();
|
||||
}, [token]);
|
||||
|
||||
const validateToken = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.get<InviteData>(`/api/v1/invite/validate/${token}`, {
|
||||
suppressErrorToast: true,
|
||||
} as any);
|
||||
setInviteData(response.data);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.error ||
|
||||
err.message ||
|
||||
t('invite.validationError', 'Failed to validate invitation link');
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccept = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate email if required
|
||||
if (inviteData?.emailRequired) {
|
||||
if (!email || email.trim().length === 0) {
|
||||
setError(t('invite.emailRequired', 'Email address is required'));
|
||||
return;
|
||||
}
|
||||
if (!email.includes('@')) {
|
||||
setError(t('invite.invalidEmail', 'Invalid email address'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate passwords
|
||||
if (!password) {
|
||||
setError(t('invite.passwordRequired', 'Password is required'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError(t('invite.passwordMismatch', 'Passwords do not match'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData();
|
||||
if (inviteData?.emailRequired) {
|
||||
formData.append('email', email.trim().toLowerCase());
|
||||
}
|
||||
formData.append('password', password);
|
||||
|
||||
await apiClient.post(`/api/v1/invite/accept/${token}`, formData, {
|
||||
suppressErrorToast: true,
|
||||
} as any);
|
||||
|
||||
// Success - redirect to login
|
||||
navigate('/login?messageType=accountCreated');
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.error ||
|
||||
err.message ||
|
||||
t('invite.acceptError', 'Failed to create account');
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader title={t('invite.validating', 'Validating invitation...')} />
|
||||
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !inviteData) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader title={t('invite.invalidInvitation', 'Invalid Invitation')} />
|
||||
<ErrorMessage error={error} />
|
||||
<div className="auth-section">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/login')}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold cursor-pointer border-0 auth-cta-button"
|
||||
>
|
||||
{t('invite.goToLogin', 'Go to Login')}
|
||||
</button>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader
|
||||
title={t('invite.welcomeTitle', "You've been invited!")}
|
||||
subtitle={t('invite.welcomeSubtitle', 'Complete your account setup to get started')}
|
||||
/>
|
||||
|
||||
{inviteData && !inviteData.emailRequired && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '1.25rem',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.08)',
|
||||
borderRadius: '0.75rem',
|
||||
border: '1px solid rgba(59, 130, 246, 0.2)'
|
||||
}}>
|
||||
<p style={{
|
||||
fontSize: '0.8125rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
color: '#6b7280',
|
||||
margin: '0 0 0.5rem 0',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
{t('invite.accountFor', 'Creating account for')}
|
||||
</p>
|
||||
<p style={{
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: 600,
|
||||
margin: '0 0 0.75rem 0',
|
||||
color: '#1f2937'
|
||||
}}>
|
||||
{inviteData.email}
|
||||
</p>
|
||||
<p style={{
|
||||
fontSize: '0.8125rem',
|
||||
color: '#6b7280',
|
||||
margin: 0
|
||||
}}>
|
||||
{t('invite.linkExpires', 'Link expires')}: {new Date(inviteData.expiresAt).toLocaleDateString()} at {new Date(inviteData.expiresAt).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
<form onSubmit={handleAccept}>
|
||||
{inviteData?.emailRequired && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label htmlFor="email" className="auth-label">
|
||||
{t('invite.email', 'Email address')}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('invite.emailPlaceholder', 'Enter your email address')}
|
||||
disabled={submitting}
|
||||
required
|
||||
className="auth-input"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label htmlFor="password" className="auth-label">
|
||||
{t('invite.choosePassword', 'Choose a password')}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('invite.passwordPlaceholder', 'Enter your password')}
|
||||
disabled={submitting}
|
||||
required
|
||||
className="auth-input"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label htmlFor="confirmPassword" className="auth-label">
|
||||
{t('invite.confirmPassword', 'Confirm password')}
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder={t('invite.confirmPasswordPlaceholder', 'Re-enter your password')}
|
||||
disabled={submitting}
|
||||
required
|
||||
className="auth-input"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="auth-section">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
|
||||
>
|
||||
{submitting ? t('invite.creating', 'Creating Account...') : t('invite.createAccount', 'Create Account')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div style={{ textAlign: 'center', margin: '1rem 0 0' }}>
|
||||
<p style={{ color: '#6b7280', fontSize: '0.875rem', margin: 0 }}>
|
||||
{t('invite.alreadyHaveAccount', 'Already have an account?')}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/login')}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t('invite.signIn', 'Sign in')}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { springAuth } from '../auth/springAuthClient'
|
||||
import { useAuth } from '../auth/UseSession'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -17,26 +17,42 @@ import { BASE_PATH } from '../constants/app'
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const { session, loading } = useAuth()
|
||||
const { t } = useTranslation()
|
||||
const [isSigningIn, setIsSigningIn] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
||||
const [showEmailForm, setShowEmailForm] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
// Prefill email from query param (e.g. after password reset)
|
||||
// Handle query params (email prefill and success messages)
|
||||
useEffect(() => {
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
const emailFromQuery = url.searchParams.get('email')
|
||||
const emailFromQuery = searchParams.get('email')
|
||||
if (emailFromQuery) {
|
||||
setEmail(emailFromQuery)
|
||||
}
|
||||
|
||||
const messageType = searchParams.get('messageType')
|
||||
if (messageType) {
|
||||
switch (messageType) {
|
||||
case 'accountCreated':
|
||||
setSuccessMessage(t('login.accountCreatedSuccess', 'Account created successfully! You can now sign in.'))
|
||||
break
|
||||
case 'passwordChanged':
|
||||
setSuccessMessage(t('login.passwordChangedSuccess', 'Password changed successfully! Please sign in with your new password.'))
|
||||
break
|
||||
case 'credsUpdated':
|
||||
setSuccessMessage(t('login.credentialsUpdated', 'Your credentials have been updated. Please sign in again.'))
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
}, [searchParams, t])
|
||||
|
||||
const baseUrl = window.location.origin + BASE_PATH;
|
||||
|
||||
@@ -121,6 +137,22 @@ export default function Login() {
|
||||
<AuthLayout>
|
||||
<LoginHeader title={t('login.login') || 'Sign in'} />
|
||||
|
||||
{/* Success message */}
|
||||
{successMessage && (
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
marginBottom: '1rem',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||
borderRadius: '0.5rem',
|
||||
color: '#16a34a'
|
||||
}}>
|
||||
<p style={{ margin: 0, fontSize: '0.875rem', textAlign: 'center' }}>
|
||||
{successMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
{/* OAuth first */}
|
||||
|
||||
@@ -40,8 +40,6 @@ export const useSignupFormValidation = () => {
|
||||
// Validate password
|
||||
if (!password) {
|
||||
fieldErrors.password = t('signup.passwordRequired', 'Password is required')
|
||||
} else if (password.length < 6) {
|
||||
fieldErrors.password = t('signup.passwordTooShort')
|
||||
}
|
||||
|
||||
// Validate confirm password
|
||||
|
||||
@@ -23,7 +23,7 @@ export const accountService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Change user password
|
||||
* Change user password (for regular password changes)
|
||||
*/
|
||||
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
const formData = new FormData();
|
||||
@@ -31,4 +31,14 @@ export const accountService = {
|
||||
formData.append('newPassword', newPassword);
|
||||
await apiClient.post('/api/v1/user/change-password', formData);
|
||||
},
|
||||
|
||||
/**
|
||||
* Change password on first login (clears the first-use flag)
|
||||
*/
|
||||
async changePasswordOnLogin(currentPassword: string, newPassword: 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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -62,6 +62,35 @@ export interface InviteUsersResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface InviteLinkRequest {
|
||||
email: string;
|
||||
role: string;
|
||||
teamId?: number;
|
||||
expiryHours?: number;
|
||||
sendEmail?: boolean;
|
||||
}
|
||||
|
||||
export interface InviteLinkResponse {
|
||||
token: string;
|
||||
inviteUrl: string;
|
||||
email: string;
|
||||
expiresAt: string;
|
||||
expiryHours: number;
|
||||
emailSent?: boolean;
|
||||
emailError?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface InviteToken {
|
||||
id: number;
|
||||
email: string;
|
||||
role: string;
|
||||
teamId?: number;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User Management Service
|
||||
* Provides functions to interact with user management backend APIs
|
||||
@@ -163,4 +192,60 @@ export const userManagementService = {
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate an invite link (admin only)
|
||||
*/
|
||||
async generateInviteLink(data: InviteLinkRequest): Promise<InviteLinkResponse> {
|
||||
const formData = new FormData();
|
||||
// Only append email if it's provided and not empty
|
||||
if (data.email && data.email.trim()) {
|
||||
formData.append('email', data.email);
|
||||
}
|
||||
formData.append('role', data.role);
|
||||
if (data.teamId) {
|
||||
formData.append('teamId', data.teamId.toString());
|
||||
}
|
||||
if (data.expiryHours) {
|
||||
formData.append('expiryHours', data.expiryHours.toString());
|
||||
}
|
||||
if (data.sendEmail !== undefined) {
|
||||
formData.append('sendEmail', data.sendEmail.toString());
|
||||
}
|
||||
|
||||
const response = await apiClient.post<InviteLinkResponse>(
|
||||
'/api/v1/invite/generate',
|
||||
formData,
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
} as any
|
||||
);
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get list of active invite links (admin only)
|
||||
*/
|
||||
async getInviteLinks(): Promise<InviteToken[]> {
|
||||
const response = await apiClient.get<{ invites: InviteToken[] }>('/api/v1/invite/list');
|
||||
return response.data.invites;
|
||||
},
|
||||
|
||||
/**
|
||||
* Revoke an invite link (admin only)
|
||||
*/
|
||||
async revokeInviteLink(inviteId: number): Promise<void> {
|
||||
await apiClient.delete(`/api/v1/invite/revoke/${inviteId}`, {
|
||||
suppressErrorToast: true,
|
||||
} as any);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clean up expired invite links (admin only)
|
||||
*/
|
||||
async cleanupExpiredInvites(): Promise<{ deletedCount: number }> {
|
||||
const response = await apiClient.post<{ deletedCount: number }>('/api/v1/invite/cleanup');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user