mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Moar Login Fixes (#4948)
This commit is contained in:
@@ -2,6 +2,9 @@ package stirling.software.SPDF.config;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import jakarta.servlet.Filter;
|
import jakarta.servlet.Filter;
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
@@ -9,17 +12,14 @@ import jakarta.servlet.ServletRequest;
|
|||||||
import jakarta.servlet.ServletResponse;
|
import jakarta.servlet.ServletResponse;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.SPDF.service.WeeklyActiveUsersService;
|
import stirling.software.SPDF.service.WeeklyActiveUsersService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter to track browser IDs for Weekly Active Users (WAU) counting.
|
* Filter to track browser IDs for Weekly Active Users (WAU) counting. Only active when security is
|
||||||
* Only active when security is disabled (no-login mode).
|
* disabled (no-login mode).
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@ConditionalOnProperty(name = "security.enableLogin", havingValue = "false")
|
@ConditionalOnProperty(name = "security.enableLogin", havingValue = "false")
|
||||||
|
|||||||
@@ -369,7 +369,8 @@ public class MetricsController {
|
|||||||
// Check if WAU service is available (only when security.enableLogin=false)
|
// Check if WAU service is available (only when security.enableLogin=false)
|
||||||
if (wauService.isEmpty()) {
|
if (wauService.isEmpty()) {
|
||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
.body("WAU tracking is only available when security is disabled (no-login mode)");
|
.body(
|
||||||
|
"WAU tracking is only available when security is disabled (no-login mode)");
|
||||||
}
|
}
|
||||||
|
|
||||||
WeeklyActiveUsersService service = wauService.get();
|
WeeklyActiveUsersService service = wauService.get();
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import org.springframework.stereotype.Service;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for tracking Weekly Active Users (WAU) in no-login mode.
|
* Service for tracking Weekly Active Users (WAU) in no-login mode. Uses in-memory storage with
|
||||||
* Uses in-memory storage with automatic cleanup of old entries.
|
* automatic cleanup of old entries.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -28,6 +28,7 @@ public class WeeklyActiveUsersService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Records a browser access with the current timestamp
|
* Records a browser access with the current timestamp
|
||||||
|
*
|
||||||
* @param browserId Unique browser identifier from X-Browser-Id header
|
* @param browserId Unique browser identifier from X-Browser-Id header
|
||||||
*/
|
*/
|
||||||
public void recordBrowserAccess(String browserId) {
|
public void recordBrowserAccess(String browserId) {
|
||||||
@@ -46,6 +47,7 @@ public class WeeklyActiveUsersService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the count of unique browsers seen in the last 7 days
|
* Gets the count of unique browsers seen in the last 7 days
|
||||||
|
*
|
||||||
* @return Weekly Active Users count
|
* @return Weekly Active Users count
|
||||||
*/
|
*/
|
||||||
public long getWeeklyActiveUsers() {
|
public long getWeeklyActiveUsers() {
|
||||||
@@ -55,6 +57,7 @@ public class WeeklyActiveUsersService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the total count of unique browsers ever seen
|
* Gets the total count of unique browsers ever seen
|
||||||
|
*
|
||||||
* @return Total unique browsers count
|
* @return Total unique browsers count
|
||||||
*/
|
*/
|
||||||
public long getTotalUniqueBrowsers() {
|
public long getTotalUniqueBrowsers() {
|
||||||
@@ -63,6 +66,7 @@ public class WeeklyActiveUsersService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the number of days the service has been running
|
* Gets the number of days the service has been running
|
||||||
|
*
|
||||||
* @return Days online
|
* @return Days online
|
||||||
*/
|
*/
|
||||||
public long getDaysOnline() {
|
public long getDaysOnline() {
|
||||||
@@ -71,23 +75,20 @@ public class WeeklyActiveUsersService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the timestamp when tracking started
|
* Gets the timestamp when tracking started
|
||||||
|
*
|
||||||
* @return Start time
|
* @return Start time
|
||||||
*/
|
*/
|
||||||
public Instant getStartTime() {
|
public Instant getStartTime() {
|
||||||
return startTime;
|
return startTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Removes entries older than 7 days */
|
||||||
* Removes entries older than 7 days
|
|
||||||
*/
|
|
||||||
private void cleanupOldEntries() {
|
private void cleanupOldEntries() {
|
||||||
Instant sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS);
|
Instant sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS);
|
||||||
activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo));
|
activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Manual cleanup trigger (can be called by scheduled task if needed) */
|
||||||
* Manual cleanup trigger (can be called by scheduled task if needed)
|
|
||||||
*/
|
|
||||||
public void performCleanup() {
|
public void performCleanup() {
|
||||||
int sizeBefore = activeBrowsers.size();
|
int sizeBefore = activeBrowsers.size();
|
||||||
cleanupOldEntries();
|
cleanupOldEntries();
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
|
|||||||
public class JwtService implements JwtServiceInterface {
|
public class JwtService implements JwtServiceInterface {
|
||||||
|
|
||||||
private static final String ISSUER = "https://stirling.com";
|
private static final String ISSUER = "https://stirling.com";
|
||||||
private static final long EXPIRATION = 3600000;
|
private static final long EXPIRATION = 43200000;
|
||||||
|
|
||||||
private final KeyPersistenceServiceInterface keyPersistenceService;
|
private final KeyPersistenceServiceInterface keyPersistenceService;
|
||||||
private final boolean v2Enabled;
|
private final boolean v2Enabled;
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
import { waitFor, renderHook, act } from '@testing-library/react';
|
||||||
|
import { AppConfigProvider, useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
|
import apiClient from '@app/services/apiClient';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
// Mock apiClient
|
||||||
|
vi.mock('@app/services/apiClient');
|
||||||
|
|
||||||
|
describe('AppConfigContext', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Mock window.location.pathname
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { pathname: '/' },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<AppConfigProvider>{children}</AppConfigProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
it('should fetch and provide app config on non-auth pages', async () => {
|
||||||
|
const mockConfig = {
|
||||||
|
enableLogin: false,
|
||||||
|
appNameNavbar: 'Stirling PDF',
|
||||||
|
languages: ['en-US', 'en-GB'],
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: mockConfig,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||||
|
|
||||||
|
// Initially loading
|
||||||
|
expect(result.current.loading).toBe(true);
|
||||||
|
expect(result.current.config).toBeNull();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(result.current.config).toEqual(mockConfig);
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/api/v1/config/app-config', {
|
||||||
|
suppressErrorToast: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip fetch on auth pages and use default config', async () => {
|
||||||
|
// Mock being on login page
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { pathname: '/login' },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(result.current.config).toEqual({ enableLogin: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should NOT call API on auth pages
|
||||||
|
expect(apiClient.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 401 error gracefully', async () => {
|
||||||
|
const mockError = Object.assign(new Error('Unauthorized'), {
|
||||||
|
response: { status: 401, data: {} },
|
||||||
|
});
|
||||||
|
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(result.current.config).toEqual({ enableLogin: true });
|
||||||
|
// 401 should be handled gracefully, error may be null or set
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle network errors', async () => {
|
||||||
|
const errorMessage = 'Network error occurred';
|
||||||
|
const mockError = new Error(errorMessage);
|
||||||
|
// Network errors don't have response property
|
||||||
|
// Mock rejection for all retry attempts (default is 3 attempts)
|
||||||
|
vi.mocked(apiClient.get)
|
||||||
|
.mockRejectedValueOnce(mockError)
|
||||||
|
.mockRejectedValueOnce(mockError)
|
||||||
|
.mockRejectedValueOnce(mockError);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(result.current.config).toEqual({ enableLogin: true });
|
||||||
|
expect(result.current.error).toBe(errorMessage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip fetch on signup page', async () => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { pathname: '/signup' },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(result.current.config).toEqual({ enableLogin: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiClient.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip fetch on auth callback page', async () => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { pathname: '/auth/callback' },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(result.current.config).toEqual({ enableLogin: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiClient.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip fetch on invite accept page', async () => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { pathname: '/invite/abc123' },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(result.current.config).toEqual({ enableLogin: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiClient.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should refetch config when jwt-available event is triggered', async () => {
|
||||||
|
const initialConfig = {
|
||||||
|
enableLogin: true,
|
||||||
|
appNameNavbar: 'Stirling PDF',
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedConfig = {
|
||||||
|
enableLogin: true,
|
||||||
|
appNameNavbar: 'Stirling PDF',
|
||||||
|
isAdmin: true,
|
||||||
|
enableAnalytics: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// First call returns initial config
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: initialConfig,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.config).toEqual(initialConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup second call for refetch
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: updatedConfig,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// Trigger jwt-available event wrapped in act
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||||
|
// Wait a tick for event handler to run
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.config).toEqual(updatedConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide refetch function', async () => {
|
||||||
|
const mockConfig = {
|
||||||
|
enableLogin: false,
|
||||||
|
appNameNavbar: 'Test App',
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
data: mockConfig,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.config).toEqual(mockConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call refetch wrapped in act
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.refetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not fetch twice without force flag', async () => {
|
||||||
|
const mockConfig = {
|
||||||
|
enableLogin: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
data: mockConfig,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.config).toEqual(mockConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should only be called once (no duplicate fetches)
|
||||||
|
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle initial config prop', async () => {
|
||||||
|
const initialConfig = {
|
||||||
|
enableLogin: false,
|
||||||
|
appNameNavbar: 'Initial App',
|
||||||
|
};
|
||||||
|
|
||||||
|
const customWrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<AppConfigProvider initialConfig={initialConfig}>
|
||||||
|
{children}
|
||||||
|
</AppConfigProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAppConfig(), {
|
||||||
|
wrapper: customWrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
// With blocking mode (default), should still fetch even with initial config
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should still make API call
|
||||||
|
expect(apiClient.get).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use suppressErrorToast for all config requests', async () => {
|
||||||
|
const mockConfig = { enableLogin: true };
|
||||||
|
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: mockConfig,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
renderHook(() => useAppConfig(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/api/v1/config/app-config', {
|
||||||
|
suppressErrorToast: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -114,7 +114,8 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// apiClient automatically adds JWT header if available via interceptors
|
// apiClient automatically adds JWT header if available via interceptors
|
||||||
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config', !isBlockingMode ? { suppressErrorToast: true } : undefined);
|
// Always suppress error toast - we handle 401 errors locally
|
||||||
|
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config', { suppressErrorToast: true });
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
console.debug('[AppConfig] Config fetched successfully:', data);
|
console.debug('[AppConfig] Config fetched successfully:', data);
|
||||||
@@ -159,8 +160,25 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
|||||||
}, [fetchCount, hasResolvedConfig, isBlockingMode, maxRetries, initialDelay]);
|
}, [fetchCount, hasResolvedConfig, isBlockingMode, maxRetries, initialDelay]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Always try to fetch config to check if login is disabled
|
// Skip config fetch on auth pages (/login, /signup, /auth/callback, /invite/*)
|
||||||
// The endpoint should be public and return proper JSON
|
// 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) {
|
if (autoFetch) {
|
||||||
fetchConfig();
|
fetchConfig();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '[email protected]',
|
||||||
|
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: '[email protected]',
|
||||||
|
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: '[email protected]',
|
||||||
|
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: '[email protected]',
|
||||||
|
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: '[email protected]',
|
||||||
|
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: '[email protected]',
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -134,6 +134,7 @@ class SpringAuthClient {
|
|||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
|
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
|
||||||
});
|
});
|
||||||
|
|
||||||
console.debug('[SpringAuth] /me response status:', response.status);
|
console.debug('[SpringAuth] /me response status:', response.status);
|
||||||
@@ -314,6 +315,7 @@ class SpringAuthClient {
|
|||||||
'X-XSRF-TOKEN': this.getCsrfToken() || '',
|
'X-XSRF-TOKEN': this.getCsrfToken() || '',
|
||||||
},
|
},
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
|
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|||||||
@@ -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: '[email protected]',
|
||||||
|
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(
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthCallback />
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthCallback />
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthCallback />
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthCallback />
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthCallback />
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Completing authentication...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '@app/auth/UseSession';
|
import { springAuth } from '@app/auth/springAuthClient';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OAuth Callback Handler
|
* OAuth Callback Handler
|
||||||
@@ -11,7 +11,6 @@ import { useAuth } from '@app/auth/UseSession';
|
|||||||
*/
|
*/
|
||||||
export default function AuthCallback() {
|
export default function AuthCallback() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { refreshSession } = useAuth();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleCallback = async () => {
|
const handleCallback = async () => {
|
||||||
@@ -37,12 +36,23 @@ export default function AuthCallback() {
|
|||||||
console.log('[AuthCallback] JWT stored in localStorage');
|
console.log('[AuthCallback] JWT stored in localStorage');
|
||||||
|
|
||||||
// Dispatch custom event for other components to react to JWT availability
|
// 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
|
// Validate the token and load user info
|
||||||
await refreshSession();
|
// 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
|
// Clear the hash from URL and redirect to home page
|
||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
@@ -56,7 +66,7 @@ export default function AuthCallback() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleCallback();
|
handleCallback();
|
||||||
}, [navigate, refreshSession]);
|
}, [navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
@@ -0,0 +1,473 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import Login from '@app/routes/Login';
|
||||||
|
import { useAuth } from '@app/auth/UseSession';
|
||||||
|
import { springAuth } from '@app/auth/springAuthClient';
|
||||||
|
|
||||||
|
// Mock i18n to return fallback text
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, fallback?: string | Record<string, unknown>) => {
|
||||||
|
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 }) => (
|
||||||
|
<MantineProvider>{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<TestWrapper>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Login />
|
||||||
|
</BrowserRouter>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
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: '[email protected]',
|
||||||
|
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(
|
||||||
|
<TestWrapper>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Login />
|
||||||
|
</BrowserRouter>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<TestWrapper>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Login />
|
||||||
|
</BrowserRouter>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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: '[email protected]',
|
||||||
|
username: '[email protected]',
|
||||||
|
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(
|
||||||
|
<TestWrapper>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Login />
|
||||||
|
</BrowserRouter>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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, '[email protected]');
|
||||||
|
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: '[email protected]',
|
||||||
|
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(
|
||||||
|
<TestWrapper>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Login />
|
||||||
|
</BrowserRouter>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
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, '[email protected]');
|
||||||
|
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(
|
||||||
|
<TestWrapper>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Login />
|
||||||
|
</BrowserRouter>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<TestWrapper>
|
||||||
|
<MemoryRouter initialEntries={['/login?expired=true']}>
|
||||||
|
<Login />
|
||||||
|
</MemoryRouter>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/session.*expired/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display account created success message', () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<MemoryRouter initialEntries={['/login?messageType=accountCreated']}>
|
||||||
|
<Login />
|
||||||
|
</MemoryRouter>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/account created/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefill email from query param', () => {
|
||||||
|
const email = '[email protected]';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<MemoryRouter initialEntries={[`/login?email=${email}`]}>
|
||||||
|
<Login />
|
||||||
|
</MemoryRouter>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<TestWrapper>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Login />
|
||||||
|
</BrowserRouter>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<TestWrapper>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Login />
|
||||||
|
</BrowserRouter>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<TestWrapper>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Login />
|
||||||
|
</BrowserRouter>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<TestWrapper>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Login />
|
||||||
|
</BrowserRouter>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
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, '[email protected]');
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,6 +30,14 @@ export default function Login() {
|
|||||||
const [hasSSOProviders, setHasSSOProviders] = useState(false);
|
const [hasSSOProviders, setHasSSOProviders] = useState(false);
|
||||||
const [_enableLogin, setEnableLogin] = useState<boolean | null>(null);
|
const [_enableLogin, setEnableLogin] = useState<boolean | null>(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
|
// Fetch enabled SSO providers and login config from backend
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchProviders = async () => {
|
const fetchProviders = async () => {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useDocumentMeta } from '@app/hooks/useDocumentMeta';
|
import { useDocumentMeta } from '@app/hooks/useDocumentMeta';
|
||||||
|
import { useAuth } from '@app/auth/UseSession';
|
||||||
import AuthLayout from '@app/routes/authShared/AuthLayout';
|
import AuthLayout from '@app/routes/authShared/AuthLayout';
|
||||||
import '@app/routes/authShared/auth.css';
|
import '@app/routes/authShared/auth.css';
|
||||||
import { BASE_PATH } from '@app/constants/app';
|
import { BASE_PATH } from '@app/constants/app';
|
||||||
@@ -17,6 +18,7 @@ import { useAuthService } from '@app/routes/signup/AuthService';
|
|||||||
export default function Signup() {
|
export default function Signup() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { session, loading } = useAuth();
|
||||||
const [isSigningUp, setIsSigningUp] = useState(false);
|
const [isSigningUp, setIsSigningUp] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -24,6 +26,14 @@ export default function Signup() {
|
|||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({});
|
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({});
|
||||||
|
|
||||||
|
// 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;
|
const baseUrl = window.location.origin + BASE_PATH;
|
||||||
|
|
||||||
// Set document meta
|
// Set document meta
|
||||||
|
|||||||
Reference in New Issue
Block a user