send email and invite link

This commit is contained in:
Anthony Stirling
2025-10-27 11:02:00 +00:00
parent 0d6966de92
commit 31fda096ec
21 changed files with 1393 additions and 76 deletions

View File

@@ -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 */}

View File

@@ -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

View File

@@ -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>

View File

@@ -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',

View 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>
);
}

View File

@@ -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 */}

View File

@@ -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

View File

@@ -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);
},
};

View File

@@ -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;
},
};