mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
V2: Login Feature (#4701)
This PR migrates the login features from V1 into V2.
---
- Login via username/password
- SSO login (Google & GitHub)
-- Fixed issue where users authenticating via SSO (OAuth2/SAML2) were
identified by configurable username attributes (email,
preferred_username, etc.), causing:
- Duplicate accounts when username attributes changed
- Authentication failures when claim/NameID configuration changed
- Data redundancy from same user having multiple accounts
- Added `sso_provider_id` column to store provider's unique identifier
(OIDC sub claim / SAML2 NameID)
- Added `sso_provider` column to store provider name (e.g., "google",
"github", "saml2")
- User.java:65-69
Backend Changes:
- Updated UserRepository with findBySsoProviderAndSsoProviderId() method
(UserRepository.java:25)
- Modified UserService.processSSOPostLogin() to implement lookup
priority:
a. Find by (`ssoProvider`, `ssoProviderId`) first
b. Fallback to username for backward compatibility
c. Automatically migrate existing users by adding provider IDs
(UserService.java:64-107)
- Updated saveUserCore() to accept and store SSO provider details
(UserService.java:506-566)
OAuth2 Integration:
- CustomOAuth2UserService: Extracts OIDC sub claim and registration ID
(CustomOAuth2UserService.java:49-59)
- CustomOAuth2AuthenticationSuccessHandler: Passes provider info to
processSSOPostLogin()
(CustomOAuth2AuthenticationSuccessHandler.java:95-108)
SAML2 Integration:
- CustomSaml2AuthenticationSuccessHandler: Extracts NameID from SAML2
assertion (CustomSaml2AuthenticationSuccessHandler.java:120-133)
---
- Configurable Rate Limiting
Changes:
- Added RateLimit configuration class to ApplicationProperties.Security
(ApplicationProperties.java:314-317)
- Made reset schedule configurable: security.rate-limit.reset-schedule
(default: "0 0 0 * * MON")
- Made max requests configurable: security.rate-limit.max-requests
(default: 1000)
- Updated RateLimitResetScheduler to use @Scheduled(cron =
"${security.rate-limit.reset-schedule:0 0 0 * * MON}")
(RateLimitResetScheduler.java:16)
- Updated SecurityConfiguration.rateLimitingFilter() to use configured
value (SecurityConfiguration.java:377)
---
- Enable access without security features
Backend:
- Added /api/v1/config to permitAll endpoints
(SecurityConfiguration.java:261)
- Config endpoint already returns enableLogin status
(ConfigController.java:60)
Frontend:
- AuthProvider now checks enableLogin before attempting JWT validation
(UseSession.tsx:98-112)
- If enableLogin=false, skips authentication entirely and sets
session=null
- Landing component bypasses auth check when enableLogin=false
(Landing.tsx:42-46)
- Added createAnonymousUser() and createAnonymousSession() utilities
(springAuthClient.ts:440-464)
Closes #3046
---
## Checklist
### General
- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [x] 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)
- [x] I have performed a self-review of my own code
- [x] 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)
- [x] 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.
---------
Signed-off-by: dependabot[bot] <[email protected]>
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ludy <[email protected]>
Co-authored-by: EthanHealy01 <[email protected]>
Co-authored-by: Ethan <[email protected]>
Co-authored-by: Anthony Stirling <[email protected]>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Ludy
EthanHealy01
Ethan
Anthony Stirling
stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
parent
c9eee00d66
commit
848ff9688b
@@ -0,0 +1,36 @@
|
||||
import './dividerWithText/DividerWithText.css'
|
||||
|
||||
interface TextDividerProps {
|
||||
text?: string
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
variant?: 'default' | 'subcategory'
|
||||
respondsToDarkMode?: boolean
|
||||
opacity?: number
|
||||
}
|
||||
|
||||
export default function DividerWithText({ text, className = '', style, variant = 'default', respondsToDarkMode = true, opacity }: TextDividerProps) {
|
||||
const variantClass = variant === 'subcategory' ? 'subcategory' : ''
|
||||
const themeClass = respondsToDarkMode ? '' : 'force-light'
|
||||
const styleWithOpacity = opacity !== undefined ? { ...(style || {}), ['--text-divider-opacity' as any]: opacity } : style
|
||||
|
||||
if (text) {
|
||||
return (
|
||||
<div
|
||||
className={`text-divider ${variantClass} ${themeClass} ${className}`}
|
||||
style={styleWithOpacity}
|
||||
>
|
||||
<div className="text-divider__rule" />
|
||||
<span className="text-divider__label">{text}</span>
|
||||
<div className="text-divider__rule" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`h-px my-2.5 ${themeClass} ${className}`}
|
||||
style={styleWithOpacity}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BASE_PATH } from '../../constants/app';
|
||||
|
||||
type ImageSlide = { src: string; alt?: string; cornerModelUrl?: string; title?: string; subtitle?: string; followMouseTilt?: boolean; tiltMaxDeg?: number }
|
||||
|
||||
export default function LoginRightCarousel({
|
||||
imageSlides = [],
|
||||
showBackground = true,
|
||||
initialSeconds = 5,
|
||||
slideSeconds = 8,
|
||||
}: {
|
||||
imageSlides?: ImageSlide[]
|
||||
showBackground?: boolean
|
||||
initialSeconds?: number
|
||||
slideSeconds?: number
|
||||
}) {
|
||||
const totalSlides = imageSlides.length
|
||||
const [index, setIndex] = useState(0)
|
||||
const mouse = useRef({ x: 0, y: 0 })
|
||||
|
||||
const durationsMs = useMemo(() => {
|
||||
if (imageSlides.length === 0) return []
|
||||
return imageSlides.map((_, i) => (i === 0 ? (initialSeconds ?? slideSeconds) : slideSeconds) * 1000)
|
||||
}, [imageSlides, initialSeconds, slideSeconds])
|
||||
|
||||
useEffect(() => {
|
||||
if (totalSlides <= 1) return
|
||||
const timeout = setTimeout(() => {
|
||||
setIndex((i) => (i + 1) % totalSlides)
|
||||
}, durationsMs[index] ?? slideSeconds * 1000)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [index, totalSlides, durationsMs, slideSeconds])
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1
|
||||
mouse.current.y = (e.clientY / window.innerHeight) * 2 - 1
|
||||
}
|
||||
window.addEventListener('mousemove', onMove)
|
||||
return () => window.removeEventListener('mousemove', onMove)
|
||||
}, [])
|
||||
|
||||
function TiltImage({ src, alt, enabled, maxDeg = 6 }: { src: string; alt?: string; enabled: boolean; maxDeg?: number }) {
|
||||
const imgRef = useRef<HTMLImageElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = imgRef.current
|
||||
if (!el) return
|
||||
|
||||
let raf = 0
|
||||
const tick = () => {
|
||||
if (enabled) {
|
||||
const rotY = (mouse.current.x || 0) * maxDeg
|
||||
const rotX = -(mouse.current.y || 0) * maxDeg
|
||||
el.style.transform = `translateY(-2rem) rotateX(${rotX.toFixed(2)}deg) rotateY(${rotY.toFixed(2)}deg)`
|
||||
} else {
|
||||
el.style.transform = 'translateY(-2rem)'
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [enabled, maxDeg])
|
||||
|
||||
return (
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt={alt ?? 'Carousel slide'}
|
||||
style={{
|
||||
maxWidth: '86%',
|
||||
maxHeight: '78%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: '18px',
|
||||
background: 'transparent',
|
||||
transform: 'translateY(-2rem)',
|
||||
transition: 'transform 80ms ease-out',
|
||||
willChange: 'transform',
|
||||
transformOrigin: '50% 50%',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', overflow: 'hidden', width: '100%', height: '100%' }}>
|
||||
{showBackground && (
|
||||
<img
|
||||
src={`${BASE_PATH}/Login/LoginBackgroundPanel.png`}
|
||||
alt="Background panel"
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Image slides */}
|
||||
{imageSlides.map((s, idx) => (
|
||||
<div
|
||||
key={s.src}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'opacity 600ms ease',
|
||||
opacity: index === idx ? 1 : 0,
|
||||
perspective: '900px',
|
||||
}}
|
||||
>
|
||||
{(s.title || s.subtitle) && (
|
||||
<div style={{ position: 'absolute', bottom: 24 + 32, left: 0, right: 0, textAlign: 'center', padding: '0 2rem', width: '100%' }}>
|
||||
{s.title && (
|
||||
<div style={{ fontSize: 20, fontWeight: 800, color: '#ffffff', textShadow: '0 2px 6px rgba(0,0,0,0.25)', marginBottom: 6 }}>{s.title}</div>
|
||||
)}
|
||||
{s.subtitle && (
|
||||
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.92)', textShadow: '0 1px 4px rgba(0,0,0,0.25)' }}>{s.subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<TiltImage src={s.src} alt={s.alt} enabled={index === idx && !!s.followMouseTilt} maxDeg={s.tiltMaxDeg ?? 6} />
|
||||
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Dot navigation */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: totalSlides }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
onClick={() => setIndex(i)}
|
||||
style={{
|
||||
width: '10px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: i === index ? '#ffffff' : 'rgba(255,255,255,0.5)',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.25)',
|
||||
display: 'block',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, Code, Group, Badge, Alert, Loader } from '@mantine/core';
|
||||
import { Stack, Text, Code, Group, Badge, Alert, Loader, Button } from '@mantine/core';
|
||||
import { useAppConfig } from '../../../../hooks/useAppConfig';
|
||||
import { useAuth } from '../../../../auth/UseSession';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const Overview: React.FC = () => {
|
||||
const { config, loading, error } = useAppConfig();
|
||||
const { signOut, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const renderConfigSection = (title: string, data: any) => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
@@ -54,6 +58,15 @@ const Overview: React.FC = () => {
|
||||
SSOAutoLogin: config.SSOAutoLogin,
|
||||
} : null;
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
navigate('/login');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" py="md">
|
||||
@@ -74,10 +87,24 @@ const Overview: React.FC = () => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">Application Configuration</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Current application settings and configuration details.
|
||||
</Text>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||
<div>
|
||||
<Text fw={600} size="lg">Application Configuration</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Current application settings and configuration details.
|
||||
</Text>
|
||||
{user?.email && (
|
||||
<Text size="xs" c="dimmed" mt="0.25rem">
|
||||
Signed in as: {user.email}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{user && (
|
||||
<Button color="red" variant="filled" onClick={handleLogout}>
|
||||
Log out
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config && (
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
|
||||
.text-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem; /* 12px */
|
||||
margin-top: 0.375rem; /* 6px */
|
||||
margin-bottom: 0.5rem; /* 8px */
|
||||
}
|
||||
|
||||
.text-divider .text-divider__rule {
|
||||
height: 0.0625rem; /* 1px */
|
||||
flex: 1 1 0%;
|
||||
background-color: rgb(var(--text-divider-rule-rgb, var(--gray-200)) / var(--text-divider-opacity, 1));
|
||||
}
|
||||
|
||||
.text-divider .text-divider__label {
|
||||
color: rgb(var(--text-divider-label-rgb, var(--gray-400)) / var(--text-divider-opacity, 1));
|
||||
font-size: 0.75rem; /* 12px */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-divider.subcategory {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.text-divider.subcategory .text-divider__rule {
|
||||
background-color: var(--tool-subcategory-rule-color);
|
||||
}
|
||||
|
||||
.text-divider.subcategory .text-divider__label {
|
||||
color: var(--tool-subcategory-text-color);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Force light theme colors regardless of dark mode */
|
||||
.text-divider.force-light .text-divider__rule {
|
||||
background-color: rgb(var(--text-divider-rule-rgb-light, var(--gray-200)) / var(--text-divider-opacity, 1));
|
||||
}
|
||||
|
||||
.text-divider.force-light .text-divider__label {
|
||||
color: rgb(var(--text-divider-label-rgb-light, var(--gray-400)) / var(--text-divider-opacity, 1));
|
||||
}
|
||||
|
||||
.text-divider.subcategory.force-light .text-divider__rule {
|
||||
background-color: var(--tool-subcategory-rule-color-light);
|
||||
}
|
||||
|
||||
.text-divider.subcategory.force-light .text-divider__label {
|
||||
color: var(--tool-subcategory-text-color-light);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { BASE_PATH } from '../../constants/app';
|
||||
|
||||
export type LoginCarouselSlide = {
|
||||
src: string
|
||||
alt?: string
|
||||
title?: string
|
||||
subtitle?: string
|
||||
cornerModelUrl?: string
|
||||
followMouseTilt?: boolean
|
||||
tiltMaxDeg?: number
|
||||
}
|
||||
|
||||
export const loginSlides: LoginCarouselSlide[] = [
|
||||
{
|
||||
src: `${BASE_PATH}/Login/Firstpage.png`,
|
||||
alt: 'Stirling PDF overview',
|
||||
title: 'Your one-stop-shop for all your PDF needs.',
|
||||
subtitle:
|
||||
'A privacy-first cloud suite for PDFs that lets you convert, sign, redact, and manage documents, along with 50+ other powerful tools.',
|
||||
followMouseTilt: true,
|
||||
tiltMaxDeg: 5,
|
||||
},
|
||||
{
|
||||
src: `${BASE_PATH}/Login/AddToPDF.png`,
|
||||
alt: 'Edit PDFs',
|
||||
title: 'Edit PDFs to display/secure the information you want',
|
||||
subtitle:
|
||||
'With over a dozen tools to help you redact, sign, read and manipulate PDFs, you will be sure to find what you are looking for.',
|
||||
followMouseTilt: true,
|
||||
tiltMaxDeg: 5,
|
||||
},
|
||||
{
|
||||
src: `${BASE_PATH}/Login/SecurePDF.png`,
|
||||
alt: 'Secure PDFs',
|
||||
title: 'Protect sensitive information in your PDFs',
|
||||
subtitle:
|
||||
'Add passwords, redact content, and manage certificates with ease.',
|
||||
followMouseTilt: true,
|
||||
tiltMaxDeg: 5,
|
||||
},
|
||||
]
|
||||
|
||||
export default loginSlides
|
||||
Reference in New Issue
Block a user