Add audit system, invite links, and usage analytics (#4749)

# Description of Changes

New Features
Audit System: Complete audit logging with dashboard, event tracking, and
export capabilities

Invite Links: Secure invite system with email notifications and
expiration

Usage Analytics: Endpoint usage statistics and visualization

License Management: User counting with grandfathering and license
enforcement
## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
Anthony Stirling
2025-11-06 17:29:34 +00:00
committed by GitHub
co-authored by James Brunton
parent f5c67a3239
commit ac3e10eb99
64 changed files with 5269 additions and 461 deletions
@@ -0,0 +1,237 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Stack, Text, Paper, Center, Loader, TextInput, PasswordInput, Anchor } from '@mantine/core';
import { useDocumentMeta } from '@app/hooks/useDocumentMeta';
import AuthLayout from '@app/routes/authShared/AuthLayout';
import LoginHeader from '@app/routes/login/LoginHeader';
import ErrorMessage from '@app/routes/login/ErrorMessage';
import { BASE_PATH } from '@app/constants/app';
import apiClient from '@app/services/apiClient';
interface InviteData {
email: string | null;
role: string;
expiresAt: string;
emailRequired: boolean;
}
export default function InviteAccept() {
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
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...')} />
<Center py="xl">
<Loader size="md" />
</Center>
</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 && (
<Paper withBorder p="md" mb="lg" bg="blue.0" style={{ borderColor: 'var(--mantine-color-blue-3)' }}>
<Stack gap="xs" align="center">
<Text size="xs" tt="uppercase" c="dimmed" fw={500} style={{ letterSpacing: '0.05em' }}>
{t('invite.accountFor', 'Creating account for')}
</Text>
<Text size="lg" fw={600}>
{inviteData.email}
</Text>
<Text size="xs" c="dimmed">
{t('invite.linkExpires', 'Link expires')}: {new Date(inviteData.expiresAt).toLocaleDateString()} at {new Date(inviteData.expiresAt).toLocaleTimeString()}
</Text>
</Stack>
</Paper>
)}
<ErrorMessage error={error} />
<form onSubmit={handleAccept}>
<Stack gap="md">
{inviteData?.emailRequired && (
<TextInput
label={t('invite.email', 'Email address')}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('invite.emailPlaceholder', 'Enter your email address')}
disabled={submitting}
required
autoComplete="email"
/>
)}
<PasswordInput
label={t('invite.choosePassword', 'Choose a password')}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('invite.passwordPlaceholder', 'Enter your password')}
disabled={submitting}
required
autoComplete="new-password"
/>
<PasswordInput
label={t('invite.confirmPassword', 'Confirm password')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t('invite.confirmPasswordPlaceholder', 'Re-enter your password')}
disabled={submitting}
required
autoComplete="new-password"
/>
<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>
</Stack>
</form>
<Center mt="md">
<Text size="sm" c="dimmed">
{t('invite.alreadyHaveAccount', 'Already have an account?')}{' '}
<Anchor component="button" type="button" onClick={() => navigate('/login')} c="dark">
{t('invite.signIn', 'Sign in')}
</Anchor>
</Text>
</Center>
</AuthLayout>
);
}
+37 -5
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 '@app/auth/springAuthClient';
import { useAuth } from '@app/auth/UseSession';
import { useTranslation } from 'react-i18next';
@@ -17,26 +17,42 @@ import { BASE_PATH } from '@app/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 */}