= ({
}, [fetchCount, hasResolvedConfig, isBlockingMode, maxRetries, initialDelay]);
useEffect(() => {
- // Always try to fetch config to check if login is disabled
- // The endpoint should be public and return proper JSON
+ // Skip config fetch on auth pages (/login, /signup, /auth/callback, /invite/*)
+ // Config will be fetched after successful authentication via jwt-available event
+ const currentPath = window.location.pathname;
+ const isAuthPage = currentPath.includes('/login') ||
+ currentPath.includes('/signup') ||
+ currentPath.includes('/auth/callback') ||
+ currentPath.includes('/invite/');
+
+ // On auth pages, always skip the config fetch
+ // The config will be fetched after authentication via jwt-available event
+ if (isAuthPage) {
+ console.debug('[AppConfig] On auth page - using default config, skipping fetch');
+ setConfig({ enableLogin: true });
+ setHasResolvedConfig(true);
+ setLoading(false);
+ return;
+ }
+
+ // On non-auth pages, fetch config (will validate JWT if present)
if (autoFetch) {
fetchConfig();
}
diff --git a/frontend/src/proprietary/auth/springAuthClient.test.ts b/frontend/src/proprietary/auth/springAuthClient.test.ts
new file mode 100644
index 000000000..5c6af7a75
--- /dev/null
+++ b/frontend/src/proprietary/auth/springAuthClient.test.ts
@@ -0,0 +1,354 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import { springAuth } from '@app/auth/springAuthClient';
+import apiClient from '@app/services/apiClient';
+import { AxiosError } from 'axios';
+
+// Mock apiClient
+vi.mock('@app/services/apiClient');
+
+describe('SpringAuthClient', () => {
+ beforeEach(() => {
+ // Clear localStorage before each test
+ localStorage.clear();
+ // Clear all mocks
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('getSession', () => {
+ it('should return null session when no JWT in localStorage', async () => {
+ const result = await springAuth.getSession();
+
+ expect(result.data.session).toBeNull();
+ expect(result.error).toBeNull();
+ expect(apiClient.get).not.toHaveBeenCalled();
+ });
+
+ it('should validate JWT and return session when JWT exists', async () => {
+ const mockToken = 'mock-jwt-token';
+ const mockUser = {
+ id: '123',
+ email: 'test@example.com',
+ username: 'testuser',
+ role: 'USER',
+ };
+
+ localStorage.setItem('stirling_jwt', mockToken);
+
+ vi.mocked(apiClient.get).mockResolvedValueOnce({
+ status: 200,
+ data: { user: mockUser },
+ } as any);
+
+ const result = await springAuth.getSession();
+
+ expect(apiClient.get).toHaveBeenCalledWith('/api/v1/auth/me', {
+ headers: { Authorization: `Bearer ${mockToken}` },
+ suppressErrorToast: true,
+ });
+ expect(result.data.session).toBeTruthy();
+ expect(result.data.session?.user).toEqual(mockUser);
+ expect(result.data.session?.access_token).toBe(mockToken);
+ expect(result.error).toBeNull();
+ });
+
+ it('should clear invalid JWT on 401 error', async () => {
+ const mockToken = 'invalid-jwt-token';
+ localStorage.setItem('stirling_jwt', mockToken);
+
+ const mockError = new AxiosError(
+ 'Unauthorized',
+ 'ERR_BAD_REQUEST',
+ undefined,
+ undefined,
+ {
+ status: 401,
+ statusText: 'Unauthorized',
+ data: {},
+ headers: {},
+ config: {} as any,
+ }
+ );
+
+ vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
+
+ const result = await springAuth.getSession();
+
+ expect(localStorage.getItem('stirling_jwt')).toBeNull();
+ expect(result.data.session).toBeNull();
+ // 401 is handled gracefully, so error should be null
+ expect(result.error).toBeNull();
+ });
+
+ it('should clear invalid JWT on 403 error', async () => {
+ const mockToken = 'forbidden-jwt-token';
+ localStorage.setItem('stirling_jwt', mockToken);
+
+ const mockError = new AxiosError(
+ 'Forbidden',
+ 'ERR_BAD_REQUEST',
+ undefined,
+ undefined,
+ {
+ status: 403,
+ statusText: 'Forbidden',
+ data: {},
+ headers: {},
+ config: {} as any,
+ }
+ );
+
+ vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
+
+ const result = await springAuth.getSession();
+
+ expect(localStorage.getItem('stirling_jwt')).toBeNull();
+ expect(result.data.session).toBeNull();
+ // 403 is handled gracefully, so error should be null
+ expect(result.error).toBeNull();
+ });
+ });
+
+ describe('signInWithPassword', () => {
+ it('should successfully sign in with email and password', async () => {
+ const credentials = {
+ email: 'test@example.com',
+ password: 'password123',
+ };
+
+ const mockToken = 'new-jwt-token';
+ const mockUser = {
+ id: '123',
+ email: credentials.email,
+ username: credentials.email,
+ role: 'USER',
+ };
+
+ vi.mocked(apiClient.post).mockResolvedValueOnce({
+ status: 200,
+ data: {
+ user: mockUser,
+ session: {
+ access_token: mockToken,
+ expires_in: 3600,
+ },
+ },
+ } as any);
+
+ // Spy on window.dispatchEvent
+ const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
+
+ const result = await springAuth.signInWithPassword(credentials);
+
+ expect(apiClient.post).toHaveBeenCalledWith(
+ '/api/v1/auth/login',
+ {
+ username: credentials.email,
+ password: credentials.password,
+ },
+ { withCredentials: true }
+ );
+ expect(localStorage.getItem('stirling_jwt')).toBe(mockToken);
+ expect(dispatchEventSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'jwt-available' })
+ );
+ expect(result.user).toEqual(mockUser);
+ expect(result.session?.access_token).toBe(mockToken);
+ expect(result.error).toBeNull();
+ });
+
+ it('should return error on failed login', async () => {
+ const credentials = {
+ email: 'wrong@example.com',
+ password: 'wrongpassword',
+ };
+
+ const errorMessage = 'Invalid credentials';
+ const mockError = Object.assign(new Error(errorMessage), {
+ isAxiosError: true,
+ response: {
+ status: 401,
+ data: { message: errorMessage },
+ },
+ });
+
+ vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
+
+ const result = await springAuth.signInWithPassword(credentials);
+
+ expect(result.user).toBeNull();
+ expect(result.session).toBeNull();
+ expect(result.error).toBeTruthy();
+ expect(result.error?.message).toBe(errorMessage);
+ });
+ });
+
+ describe('signUp', () => {
+ it('should successfully register new user', async () => {
+ const credentials = {
+ email: 'newuser@example.com',
+ password: 'newpassword123',
+ };
+
+ const mockUser = {
+ id: '456',
+ email: credentials.email,
+ username: credentials.email,
+ role: 'USER',
+ };
+
+ vi.mocked(apiClient.post).mockResolvedValueOnce({
+ status: 200,
+ data: { user: mockUser },
+ } as any);
+
+ const result = await springAuth.signUp(credentials);
+
+ expect(apiClient.post).toHaveBeenCalledWith(
+ '/api/v1/user/register',
+ {
+ username: credentials.email,
+ password: credentials.password,
+ },
+ { withCredentials: true }
+ );
+ expect(result.user).toEqual(mockUser);
+ expect(result.session).toBeNull(); // No auto-login on signup
+ expect(result.error).toBeNull();
+ });
+
+ it('should return error on failed registration', async () => {
+ const credentials = {
+ email: 'existing@example.com',
+ password: 'password123',
+ };
+
+ const errorMessage = 'User already exists';
+ const mockError = Object.assign(new Error(errorMessage), {
+ isAxiosError: true,
+ response: {
+ status: 409,
+ data: { message: errorMessage },
+ },
+ });
+
+ vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
+
+ const result = await springAuth.signUp(credentials);
+
+ expect(result.user).toBeNull();
+ expect(result.session).toBeNull();
+ expect(result.error).toBeTruthy();
+ expect(result.error?.message).toBe(errorMessage);
+ });
+ });
+
+ describe('signOut', () => {
+ it('should successfully sign out and clear JWT', async () => {
+ const mockToken = 'jwt-to-clear';
+ localStorage.setItem('stirling_jwt', mockToken);
+
+ vi.mocked(apiClient.post).mockResolvedValueOnce({
+ status: 200,
+ data: {},
+ } as any);
+
+ const result = await springAuth.signOut();
+
+ expect(apiClient.post).toHaveBeenCalledWith(
+ '/api/v1/auth/logout',
+ null,
+ expect.objectContaining({ withCredentials: true })
+ );
+ expect(localStorage.getItem('stirling_jwt')).toBeNull();
+ expect(result.error).toBeNull();
+ });
+
+ it('should clear JWT even if logout request fails', async () => {
+ const mockToken = 'jwt-to-clear';
+ localStorage.setItem('stirling_jwt', mockToken);
+
+ vi.mocked(apiClient.post).mockRejectedValueOnce({
+ isAxiosError: true,
+ response: { status: 500 },
+ message: 'Server error',
+ });
+
+ const result = await springAuth.signOut();
+
+ expect(localStorage.getItem('stirling_jwt')).toBeNull();
+ expect(result.error).toBeTruthy();
+ });
+ });
+
+ describe('refreshSession', () => {
+ it('should refresh JWT token successfully', async () => {
+ const newToken = 'refreshed-jwt-token';
+ const mockUser = {
+ id: '123',
+ email: 'test@example.com',
+ username: 'testuser',
+ role: 'USER',
+ };
+
+ vi.mocked(apiClient.post).mockResolvedValueOnce({
+ status: 200,
+ data: {
+ user: mockUser,
+ session: {
+ access_token: newToken,
+ expires_in: 3600,
+ },
+ },
+ } as any);
+
+ const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
+
+ const result = await springAuth.refreshSession();
+
+ expect(localStorage.getItem('stirling_jwt')).toBe(newToken);
+ expect(dispatchEventSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'jwt-available' })
+ );
+ expect(result.data.session?.access_token).toBe(newToken);
+ expect(result.error).toBeNull();
+ });
+
+ it('should clear JWT and return error on 401', async () => {
+ localStorage.setItem('stirling_jwt', 'expired-token');
+
+ vi.mocked(apiClient.post).mockRejectedValueOnce({
+ isAxiosError: true,
+ response: { status: 401 },
+ message: 'Token expired',
+ });
+
+ const result = await springAuth.refreshSession();
+
+ expect(localStorage.getItem('stirling_jwt')).toBeNull();
+ expect(result.data.session).toBeNull();
+ expect(result.error).toBeTruthy();
+ });
+ });
+
+ describe('signInWithOAuth', () => {
+ it('should redirect to OAuth provider', async () => {
+ const mockAssign = vi.fn();
+ Object.defineProperty(window, 'location', {
+ value: { assign: mockAssign },
+ writable: true,
+ });
+
+ const result = await springAuth.signInWithOAuth({
+ provider: 'github',
+ options: { redirectTo: '/auth/callback' },
+ });
+
+ expect(mockAssign).toHaveBeenCalledWith('/oauth2/authorization/github');
+ expect(result.error).toBeNull();
+ });
+ });
+});
diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts
index 390ea28e5..5aec9625e 100644
--- a/frontend/src/proprietary/auth/springAuthClient.ts
+++ b/frontend/src/proprietary/auth/springAuthClient.ts
@@ -134,6 +134,7 @@ class SpringAuthClient {
headers: {
'Authorization': `Bearer ${token}`,
},
+ suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
});
console.debug('[SpringAuth] /me response status:', response.status);
@@ -314,6 +315,7 @@ class SpringAuthClient {
'X-XSRF-TOKEN': this.getCsrfToken() || '',
},
withCredentials: true,
+ suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
});
const data = response.data;
diff --git a/frontend/src/proprietary/routes/AuthCallback.test.tsx b/frontend/src/proprietary/routes/AuthCallback.test.tsx
new file mode 100644
index 000000000..25bca74f6
--- /dev/null
+++ b/frontend/src/proprietary/routes/AuthCallback.test.tsx
@@ -0,0 +1,177 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, waitFor } from '@testing-library/react';
+import { BrowserRouter } from 'react-router-dom';
+import AuthCallback from '@app/routes/AuthCallback';
+import { springAuth } from '@app/auth/springAuthClient';
+
+// Mock springAuth
+vi.mock('@app/auth/springAuthClient', () => ({
+ springAuth: {
+ getSession: vi.fn(),
+ },
+}));
+
+// Mock useNavigate
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom');
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+describe('AuthCallback', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ vi.clearAllMocks();
+ // Reset window.location.hash
+ window.location.hash = '';
+ });
+
+ it('should extract JWT from URL hash and validate it', async () => {
+ const mockToken = 'oauth-jwt-token';
+ const mockUser = {
+ id: '123',
+ email: 'oauth@example.com',
+ username: 'oauthuser',
+ role: 'USER',
+ };
+
+ // Set URL hash with access token
+ window.location.hash = `#access_token=${mockToken}`;
+
+ // Mock successful session validation
+ vi.mocked(springAuth.getSession).mockResolvedValueOnce({
+ data: {
+ session: {
+ user: mockUser,
+ access_token: mockToken,
+ expires_in: 3600,
+ expires_at: Date.now() + 3600000,
+ },
+ },
+ error: null,
+ });
+
+ const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ // Verify JWT was stored
+ expect(localStorage.getItem('stirling_jwt')).toBe(mockToken);
+
+ // Verify jwt-available event was dispatched
+ expect(dispatchEventSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'jwt-available' })
+ );
+
+ // Verify getSession was called to validate token
+ expect(springAuth.getSession).toHaveBeenCalled();
+
+ // Verify navigation to home
+ expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
+ });
+ });
+
+ it('should redirect to login when no access token in hash', async () => {
+ // No hash or empty hash
+ window.location.hash = '';
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('/login', {
+ replace: true,
+ state: { error: 'OAuth login failed - no token received.' },
+ });
+ expect(localStorage.getItem('stirling_jwt')).toBeNull();
+ });
+ });
+
+ it('should redirect to login when token validation fails', async () => {
+ const invalidToken = 'invalid-oauth-token';
+ window.location.hash = `#access_token=${invalidToken}`;
+
+ // Mock failed session validation
+ vi.mocked(springAuth.getSession).mockResolvedValueOnce({
+ data: { session: null },
+ error: { message: 'Invalid token' },
+ });
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ // JWT should be stored initially
+ expect(localStorage.getItem('stirling_jwt')).toBeNull(); // Cleared after validation failure
+
+ // Verify redirect to login
+ expect(mockNavigate).toHaveBeenCalledWith('/login', {
+ replace: true,
+ state: { error: 'OAuth login failed - invalid token.' },
+ });
+ });
+ });
+
+ it('should handle errors gracefully', async () => {
+ const mockToken = 'error-token';
+ window.location.hash = `#access_token=${mockToken}`;
+
+ // Mock getSession throwing error
+ vi.mocked(springAuth.getSession).mockRejectedValueOnce(
+ new Error('Network error')
+ );
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('/login', {
+ replace: true,
+ state: { error: 'OAuth login failed. Please try again.' },
+ });
+ });
+ });
+
+ it('should display loading state while processing', () => {
+ window.location.hash = '#access_token=processing-token';
+
+ vi.mocked(springAuth.getSession).mockImplementationOnce(
+ () =>
+ new Promise((resolve) =>
+ setTimeout(
+ () =>
+ resolve({
+ data: { session: null },
+ error: { message: 'Token expired' },
+ }),
+ 100
+ )
+ )
+ );
+
+ const { getByText } = render(
+
+
+
+ );
+
+ expect(getByText('Completing authentication...')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/proprietary/routes/AuthCallback.tsx b/frontend/src/proprietary/routes/AuthCallback.tsx
index 0c7128368..488a54146 100644
--- a/frontend/src/proprietary/routes/AuthCallback.tsx
+++ b/frontend/src/proprietary/routes/AuthCallback.tsx
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
-import { useAuth } from '@app/auth/UseSession';
+import { springAuth } from '@app/auth/springAuthClient';
/**
* OAuth Callback Handler
@@ -11,7 +11,6 @@ import { useAuth } from '@app/auth/UseSession';
*/
export default function AuthCallback() {
const navigate = useNavigate();
- const { refreshSession } = useAuth();
useEffect(() => {
const handleCallback = async () => {
@@ -37,12 +36,23 @@ export default function AuthCallback() {
console.log('[AuthCallback] JWT stored in localStorage');
// Dispatch custom event for other components to react to JWT availability
- window.dispatchEvent(new CustomEvent('jwt-available'))
+ window.dispatchEvent(new CustomEvent('jwt-available'));
- // Refresh session to load user info into state
- await refreshSession();
+ // Validate the token and load user info
+ // This calls /api/v1/auth/me with the JWT to get user details
+ const { data, error } = await springAuth.getSession();
- console.log('[AuthCallback] Session refreshed, redirecting to home');
+ if (error || !data.session) {
+ console.error('[AuthCallback] Failed to validate token:', error);
+ localStorage.removeItem('stirling_jwt');
+ navigate('/login', {
+ replace: true,
+ state: { error: 'OAuth login failed - invalid token.' }
+ });
+ return;
+ }
+
+ console.log('[AuthCallback] Token validated, redirecting to home');
// Clear the hash from URL and redirect to home page
navigate('/', { replace: true });
@@ -56,7 +66,7 @@ export default function AuthCallback() {
};
handleCallback();
- }, [navigate, refreshSession]);
+ }, [navigate]);
return (
({
+ useTranslation: () => ({
+ t: (key: string, fallback?: string | Record) => {
+ if (typeof fallback === 'string') return fallback;
+ return key;
+ },
+ }),
+}));
+
+// Mock useAuth hook
+vi.mock('@app/auth/UseSession', () => ({
+ useAuth: vi.fn(),
+}));
+
+// Mock springAuth
+vi.mock('@app/auth/springAuthClient', () => ({
+ springAuth: {
+ signInWithPassword: vi.fn(),
+ signInWithOAuth: vi.fn(),
+ },
+}));
+
+// Mock useDocumentMeta
+vi.mock('@app/hooks/useDocumentMeta', () => ({
+ useDocumentMeta: vi.fn(),
+}));
+
+// Mock fetch for provider list
+global.fetch = vi.fn();
+
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom');
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+// Test wrapper with MantineProvider
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+describe('Login', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Default auth state - not logged in
+ vi.mocked(useAuth).mockReturnValue({
+ session: null,
+ user: null,
+ loading: false,
+ error: null,
+ signOut: vi.fn(),
+ refreshSession: vi.fn(),
+ });
+
+ // Mock fetch for login UI data
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ enableLogin: true,
+ providerList: {},
+ }),
+ } as Response);
+ });
+
+ it('should render login form', async () => {
+ render(
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ // Check for login form elements - use id since it's more reliable
+ const emailInput = document.getElementById('email');
+ expect(emailInput).toBeTruthy();
+ });
+ });
+
+ it('should redirect authenticated user to home', async () => {
+ const mockSession = {
+ user: {
+ id: '123',
+ email: 'test@example.com',
+ username: 'testuser',
+ role: 'USER',
+ },
+ access_token: 'mock-token',
+ expires_in: 3600,
+ };
+
+ vi.mocked(useAuth).mockReturnValue({
+ session: mockSession,
+ user: mockSession.user,
+ loading: false,
+ error: null,
+ signOut: vi.fn(),
+ refreshSession: vi.fn(),
+ });
+
+ render(
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
+ });
+ });
+
+ it('should show loading state while auth is loading', () => {
+ vi.mocked(useAuth).mockReturnValue({
+ session: null,
+ user: null,
+ loading: true,
+ error: null,
+ signOut: vi.fn(),
+ refreshSession: vi.fn(),
+ });
+
+ render(
+
+
+
+
+
+ );
+
+ // Component shouldn't redirect or show form while loading
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('should handle email/password login', async () => {
+ const user = userEvent.setup();
+ const mockUser = {
+ id: '123',
+ email: 'test@example.com',
+ username: 'test@example.com',
+ role: 'USER',
+ };
+
+ const mockSession = {
+ user: mockUser,
+ access_token: 'new-token',
+ expires_in: 3600,
+ };
+
+ vi.mocked(springAuth.signInWithPassword).mockResolvedValueOnce({
+ user: mockUser,
+ session: mockSession,
+ error: null,
+ });
+
+ render(
+
+
+
+
+
+ );
+
+ // Wait for form to load
+ await waitFor(() => {
+ const emailInput = document.getElementById('email');
+ expect(emailInput).toBeTruthy();
+ const passwordInput = document.getElementById('password');
+ expect(passwordInput).toBeTruthy();
+ }, { timeout: 3000 });
+
+ // Fill in form using getElementById
+ const emailInput = document.getElementById('email') as HTMLInputElement;
+ const passwordInput = document.getElementById('password') as HTMLInputElement;
+
+ if (!emailInput || !passwordInput) {
+ throw new Error('Form inputs not found');
+ }
+
+ await user.type(emailInput, 'test@example.com');
+ await user.type(passwordInput, 'password123');
+
+ // Submit form - use a more flexible query
+ // Look for button with type="submit" in the form
+ const submitButton = await waitFor(() => {
+ const buttons = screen.queryAllByRole('button');
+ const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit');
+ if (!submitBtn) {
+ throw new Error('Submit button not found');
+ }
+ return submitBtn;
+ }, { timeout: 5000 });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(springAuth.signInWithPassword).toHaveBeenCalledWith({
+ email: 'test@example.com',
+ password: 'password123',
+ });
+ });
+ });
+
+ it('should show error on failed login', async () => {
+ const user = userEvent.setup();
+ const errorMessage = 'Invalid credentials';
+
+ vi.mocked(springAuth.signInWithPassword).mockResolvedValueOnce({
+ user: null,
+ session: null,
+ error: { message: errorMessage },
+ });
+
+ render(
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ const emailInput = document.getElementById('email');
+ const passwordInput = document.getElementById('password');
+ expect(emailInput).toBeTruthy();
+ expect(passwordInput).toBeTruthy();
+ }, { timeout: 3000 });
+
+ const emailInput = document.getElementById('email') as HTMLInputElement;
+ const passwordInput = document.getElementById('password') as HTMLInputElement;
+
+ await user.type(emailInput, 'wrong@example.com');
+ await user.type(passwordInput, 'wrongpassword');
+
+ const submitButton = await waitFor(() => {
+ const buttons = screen.queryAllByRole('button');
+ const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit');
+ if (!submitBtn) {
+ throw new Error('Submit button not found');
+ }
+ return submitBtn;
+ }, { timeout: 5000 });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByText(errorMessage)).toBeInTheDocument();
+ });
+ });
+
+ it('should validate empty email and password', async () => {
+ render(
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(document.getElementById('email')).toBeTruthy();
+ }, { timeout: 3000 });
+
+ // Find the submit button
+ const submitButton = await waitFor(() => {
+ const buttons = screen.queryAllByRole('button');
+ const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit');
+ if (!submitBtn) {
+ throw new Error('Submit button not found');
+ }
+ return submitBtn;
+ }, { timeout: 5000 });
+
+ // Button should be disabled when email/password are empty
+ expect(submitButton).toBeDisabled();
+
+ // Verify sign in was not called
+ expect(springAuth.signInWithPassword).not.toHaveBeenCalled();
+ });
+
+ it('should display session expired message from URL param', () => {
+ render(
+
+
+
+
+
+ );
+
+ expect(screen.getByText(/session.*expired/i)).toBeInTheDocument();
+ });
+
+ it('should display account created success message', () => {
+ render(
+
+
+
+
+
+ );
+
+ expect(screen.getByText(/account created/i)).toBeInTheDocument();
+ });
+
+ it('should prefill email from query param', () => {
+ const email = 'prefilled@example.com';
+
+ render(
+
+
+
+
+
+ );
+
+ waitFor(() => {
+ const emailInput = document.getElementById('email') as HTMLInputElement;
+ expect(emailInput.value).toBe(email);
+ });
+ });
+
+ it('should redirect to home when login disabled', async () => {
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ enableLogin: false,
+ providerList: {},
+ }),
+ } as Response);
+
+ render(
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('/');
+ });
+ });
+
+ it('should handle OAuth provider click', async () => {
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ enableLogin: true,
+ providerList: {
+ '/oauth2/authorization/github': 'GitHub',
+ },
+ }),
+ } as Response);
+
+ vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
+ error: null,
+ });
+
+ render(
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ const githubButton = screen.queryByText(/github/i);
+ if (githubButton) {
+ expect(githubButton).toBeInTheDocument();
+ }
+ });
+
+ // Since OAuth buttons might be dynamically rendered based on config,
+ // we just verify the mock is set up correctly
+ expect(springAuth.signInWithOAuth).toBeDefined();
+ });
+
+ it('should show email form by default when no SSO providers', async () => {
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ enableLogin: true,
+ providerList: {}, // No providers
+ }),
+ } as Response);
+
+ render(
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(document.getElementById('email')).toBeInTheDocument();
+ expect(document.getElementById('password')).toBeInTheDocument();
+ });
+ });
+
+ it('should disable submit button while signing in', async () => {
+ const user = userEvent.setup();
+
+ vi.mocked(springAuth.signInWithPassword).mockImplementationOnce(
+ () =>
+ new Promise((resolve) =>
+ setTimeout(
+ () =>
+ resolve({
+ user: null,
+ session: null,
+ error: { message: 'Error' },
+ }),
+ 100
+ )
+ )
+ );
+
+ render(
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ const emailInput = document.getElementById('email');
+ const passwordInput = document.getElementById('password');
+ expect(emailInput).toBeTruthy();
+ expect(passwordInput).toBeTruthy();
+ }, { timeout: 3000 });
+
+ const emailInput = document.getElementById('email') as HTMLInputElement;
+ const passwordInput = document.getElementById('password') as HTMLInputElement;
+
+ await user.type(emailInput, 'test@example.com');
+ await user.type(passwordInput, 'password123');
+
+ const submitButton = await waitFor(() => {
+ const buttons = screen.queryAllByRole('button');
+ const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit');
+ if (!submitBtn) {
+ throw new Error('Submit button not found');
+ }
+ return submitBtn;
+ }, { timeout: 5000 });
+ await user.click(submitButton);
+
+ // Button should be disabled while signing in
+ expect(submitButton).toBeDisabled();
+
+ // Wait for completion
+ await waitFor(() => {
+ expect(submitButton).not.toBeDisabled();
+ });
+ });
+});
diff --git a/frontend/src/proprietary/routes/Login.tsx b/frontend/src/proprietary/routes/Login.tsx
index b50de410f..a64fac500 100644
--- a/frontend/src/proprietary/routes/Login.tsx
+++ b/frontend/src/proprietary/routes/Login.tsx
@@ -30,6 +30,14 @@ export default function Login() {
const [hasSSOProviders, setHasSSOProviders] = useState(false);
const [_enableLogin, setEnableLogin] = useState(null);
+ // Redirect immediately if user has valid session (JWT already validated by AuthProvider)
+ useEffect(() => {
+ if (!loading && session) {
+ console.debug('[Login] User already authenticated, redirecting to home');
+ navigate('/', { replace: true });
+ }
+ }, [session, loading, navigate]);
+
// Fetch enabled SSO providers and login config from backend
useEffect(() => {
const fetchProviders = async () => {
diff --git a/frontend/src/proprietary/routes/Signup.tsx b/frontend/src/proprietary/routes/Signup.tsx
index 192a3a95a..ad1b7dc00 100644
--- a/frontend/src/proprietary/routes/Signup.tsx
+++ b/frontend/src/proprietary/routes/Signup.tsx
@@ -1,7 +1,8 @@
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useDocumentMeta } from '@app/hooks/useDocumentMeta';
+import { useAuth } from '@app/auth/UseSession';
import AuthLayout from '@app/routes/authShared/AuthLayout';
import '@app/routes/authShared/auth.css';
import { BASE_PATH } from '@app/constants/app';
@@ -17,6 +18,7 @@ import { useAuthService } from '@app/routes/signup/AuthService';
export default function Signup() {
const navigate = useNavigate();
const { t } = useTranslation();
+ const { session, loading } = useAuth();
const [isSigningUp, setIsSigningUp] = useState(false);
const [error, setError] = useState(null);
const [email, setEmail] = useState('');
@@ -24,6 +26,14 @@ export default function Signup() {
const [confirmPassword, setConfirmPassword] = useState('');
const [fieldErrors, setFieldErrors] = useState({});
+ // Redirect immediately if user has valid session (JWT already validated by AuthProvider)
+ useEffect(() => {
+ if (!loading && session) {
+ console.debug('[Signup] User already authenticated, redirecting to home');
+ navigate('/', { replace: true });
+ }
+ }, [session, loading, navigate]);
+
const baseUrl = window.location.origin + BASE_PATH;
// Set document meta