Add frontend autoformatting and set CI to require formatted code for all languages (#6052)

# Description of Changes
Changes the strategy for autoformatting to reject PRs if they are not
formatted correctly instead of allowing them to merge and then spawning
a new PR to fix the formatting. The old strategy just caused more work
for us because we'd have to manually approve the followup PR and get it
merged, which required 2 reviewers so in practice it rarely got done and
just meant everyone's PRs ended up containing reformatting for unrelated
files, which makes code review unnecessarily difficult. If the PR's code
is not formatted correctly after this PR, a comment will be added
automatically to tell the author how to run the formatter script to fix
their code so it can go in.

This also enables autoformatting for the frontend code, using Prettier.
I've enabled it for pretty much everything in the frontend folder, other
than 3rd party files and files it doesn't make sense for. I also
excluded Markdown because it sounds likely to be more annoying to have
to autoformat the Markdown in the frontend folder but nowhere else. Open
to changing this though if people disagree.

> [!note]
> 
> Advice to reviewers: The first commit contains all of the actual logic
I've introduced (CI changes, Prettier config, etc.)
> The second commit is just the reformatting of the entire frontend
folder.
> The first commit needs proper review, the second one just give it a
spot-check that it's doing what you'd expect.
This commit is contained in:
James Brunton
2026-04-10 17:41:19 +01:00
committed by GitHub
parent 33b2b5827a
commit a3e45bc182
1359 changed files with 57784 additions and 57460 deletions
+60 -65
View File
@@ -1,7 +1,7 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
import { springAuth } from '@app/auth/springAuthClient';
import { clearPlatformAuthOnLoginInit } from '@app/extensions/authSessionCleanup';
import type { Session, User, AuthError, AuthChangeEvent } from '@app/auth/springAuthClient';
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from "react";
import { springAuth } from "@app/auth/springAuthClient";
import { clearPlatformAuthOnLoginInit } from "@app/extensions/authSessionCleanup";
import type { Session, User, AuthError, AuthChangeEvent } from "@app/auth/springAuthClient";
/**
* Auth Context Type
@@ -38,12 +38,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Debug: Track state transitions
useEffect(() => {
console.log('[Auth] State changed:', {
console.log("[Auth] State changed:", {
loading,
hasSession: !!session,
hasError: !!error,
userId: session?.user?.id,
timestamp: new Date().toISOString()
timestamp: new Date().toISOString(),
});
}, [loading, session, error]);
@@ -54,24 +54,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
setLoading(true);
setError(null);
console.debug('[Auth] refreshSession: start', { path: window.location.pathname });
console.debug('[Auth] Refreshing session...');
console.debug("[Auth] refreshSession: start", { path: window.location.pathname });
console.debug("[Auth] Refreshing session...");
const { data, error } = await springAuth.refreshSession();
if (error) {
console.error('[Auth] Session refresh error:', error);
console.error("[Auth] Session refresh error:", error);
setError(error);
setSession(null);
} else {
console.debug('[Auth] Session refreshed successfully');
console.debug("[Auth] Session refreshed successfully");
setSession(data.session);
}
} catch (err) {
console.error('[Auth] Unexpected error during session refresh:', err);
console.error("[Auth] Unexpected error during session refresh:", err);
setError(err as AuthError);
} finally {
console.debug('[Auth] refreshSession: done', { hasSession: !!session });
console.debug("[Auth] refreshSession: done", { hasSession: !!session });
setLoading(false);
}
}, []);
@@ -82,20 +82,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const signOut = useCallback(async () => {
try {
setError(null);
console.debug('[Auth] Signing out...');
console.debug("[Auth] Signing out...");
const { error } = await springAuth.signOut();
if (error) {
console.error('[Auth] Sign out error:', error);
console.error("[Auth] Sign out error:", error);
setError(error);
} else {
console.debug('[Auth] Signed out successfully');
console.debug("[Auth] Signed out successfully");
setSession(null);
}
} catch (err) {
console.error('[Auth] Unexpected error during sign out:', err);
console.error("[Auth] Unexpected error during sign out:", err);
setError(err as AuthError);
}
}, []);
@@ -113,7 +112,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
console.debug(`[Auth:${mountId}] Initializing auth...`);
console.debug(`[Auth:${mountId}] Path: ${window.location.pathname} Search: ${window.location.search}`);
// Clear any platform-specific cached auth on login page init.
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/login')) {
if (typeof window !== "undefined" && window.location.pathname.startsWith("/login")) {
await clearPlatformAuthOnLoginInit();
}
@@ -124,10 +123,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (!mounted) return;
if (error) {
console.error('[Auth] Initial session error:', error);
console.error("[Auth] Initial session error:", error);
setError(error);
} else {
console.debug('[Auth] Initial session loaded:', {
console.debug("[Auth] Initial session loaded:", {
hasSession: !!data.session,
userId: data.session?.user?.id,
email: data.session?.user?.email,
@@ -135,7 +134,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setSession(data.session);
}
} catch (err) {
console.error('[Auth] Unexpected error during auth initialization:', err);
console.error("[Auth] Unexpected error during auth initialization:", err);
if (mounted) {
setError(err as AuthError);
}
@@ -161,50 +160,50 @@ export function AuthProvider({ children }: { children: ReactNode }) {
void initializeAuth();
};
window.addEventListener('jwt-available', handleJwtAvailable);
window.addEventListener("jwt-available", handleJwtAvailable);
// Subscribe to auth state changes
const { data: { subscription } } = springAuth.onAuthStateChange(
async (event: AuthChangeEvent, newSession: Session | null) => {
if (!mounted) {
console.log(`[Auth:${mountId}] ⚠️ Auth state change ignored (unmounted): ${event}`);
return;
}
console.log(`[Auth:${mountId}] ════════════════════════════════════`);
console.log(`[Auth:${mountId}] 📢 Auth state change event: ${event}`);
console.log(`[Auth:${mountId}] Has session: ${!!newSession}`);
console.log(`[Auth:${mountId}] User: ${newSession?.user?.email || 'none'}`);
console.log(`[Auth:${mountId}] Timestamp: ${new Date().toISOString()}`);
// Schedule state update
setTimeout(() => {
if (mounted) {
console.log(`[Auth:${mountId}] Applying session update (event: ${event})`);
setSession(newSession);
setError(null);
// Handle specific events
if (event === 'SIGNED_OUT') {
console.log(`[Auth:${mountId}] ✓ User signed out, session cleared`);
} else if (event === 'SIGNED_IN') {
console.log(`[Auth:${mountId}] ✓ User signed in successfully`);
} else if (event === 'TOKEN_REFRESHED') {
console.log(`[Auth:${mountId}] ✓ Token refreshed`);
} else if (event === 'USER_UPDATED') {
console.log(`[Auth:${mountId}] ✓ User updated`);
}
} else {
console.log(`[Auth:${mountId}] ⚠️ Session update skipped (unmounted during timeout)`);
}
}, 0);
const {
data: { subscription },
} = springAuth.onAuthStateChange(async (event: AuthChangeEvent, newSession: Session | null) => {
if (!mounted) {
console.log(`[Auth:${mountId}] ⚠️ Auth state change ignored (unmounted): ${event}`);
return;
}
);
console.log(`[Auth:${mountId}] ════════════════════════════════════`);
console.log(`[Auth:${mountId}] 📢 Auth state change event: ${event}`);
console.log(`[Auth:${mountId}] Has session: ${!!newSession}`);
console.log(`[Auth:${mountId}] User: ${newSession?.user?.email || "none"}`);
console.log(`[Auth:${mountId}] Timestamp: ${new Date().toISOString()}`);
// Schedule state update
setTimeout(() => {
if (mounted) {
console.log(`[Auth:${mountId}] Applying session update (event: ${event})`);
setSession(newSession);
setError(null);
// Handle specific events
if (event === "SIGNED_OUT") {
console.log(`[Auth:${mountId}] ✓ User signed out, session cleared`);
} else if (event === "SIGNED_IN") {
console.log(`[Auth:${mountId}] ✓ User signed in successfully`);
} else if (event === "TOKEN_REFRESHED") {
console.log(`[Auth:${mountId}] ✓ Token refreshed`);
} else if (event === "USER_UPDATED") {
console.log(`[Auth:${mountId}] ✓ User updated`);
}
} else {
console.log(`[Auth:${mountId}] ⚠️ Session update skipped (unmounted during timeout)`);
}
}, 0);
});
return () => {
console.log(`[Auth:${mountId}] 🔴 AuthProvider unmounting`);
mounted = false;
window.removeEventListener('jwt-available', handleJwtAvailable);
window.removeEventListener("jwt-available", handleJwtAvailable);
subscription.unsubscribe();
};
}, []);
@@ -218,11 +217,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
refreshSession,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
/**
@@ -233,7 +228,7 @@ export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
@@ -247,7 +242,7 @@ export function useAuthDebug() {
const auth = useAuth();
useEffect(() => {
console.debug('[Auth Debug] Current auth state:', {
console.debug("[Auth Debug] Current auth state:", {
hasSession: !!auth.session,
hasUser: !!auth.user,
loading: auth.loading,
@@ -2,7 +2,7 @@
* Helper utilities for clearing cached OAuth redirect/session state
*/
const OAUTH_REDIRECT_COOKIE = 'stirling_redirect_path';
const OAUTH_REDIRECT_COOKIE = "stirling_redirect_path";
/**
* Clear any persisted OAuth redirect path/cached state so the app
@@ -11,21 +11,21 @@ const OAUTH_REDIRECT_COOKIE = 'stirling_redirect_path';
export function resetOAuthState(): void {
try {
// Remove redirect cookie
if (typeof document !== 'undefined') {
if (typeof document !== "undefined") {
document.cookie = `${OAUTH_REDIRECT_COOKIE}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;SameSite=Lax`;
}
} catch (err) {
console.warn('[OAuthStorage] Failed to clear redirect cookie', err);
console.warn("[OAuthStorage] Failed to clear redirect cookie", err);
}
// Remove any related localStorage entries we might have used
try {
if (typeof window !== 'undefined' && window.localStorage) {
if (typeof window !== "undefined" && window.localStorage) {
window.localStorage.removeItem(OAUTH_REDIRECT_COOKIE);
window.localStorage.removeItem('oauth_redirect_path');
window.localStorage.removeItem("oauth_redirect_path");
}
} catch (err) {
console.warn('[OAuthStorage] Failed to clear OAuth localStorage', err);
console.warn("[OAuthStorage] Failed to clear OAuth localStorage", err);
}
}
+9 -9
View File
@@ -3,17 +3,17 @@
* Custom providers are also supported - the backend determines availability.
*/
export const KNOWN_OAUTH_PROVIDERS = [
'github',
'google',
'apple',
'azure',
'keycloak',
'cloudron',
'authentik',
'oidc',
"github",
"google",
"apple",
"azure",
"keycloak",
"cloudron",
"authentik",
"oidc",
] as const;
export type KnownOAuthProvider = typeof KNOWN_OAUTH_PROVIDERS[number];
export type KnownOAuthProvider = (typeof KNOWN_OAUTH_PROVIDERS)[number];
/**
* OAuth provider ID - can be any known provider or custom string.
@@ -1,16 +1,16 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { springAuth } from '@app/auth/springAuthClient';
import { startOAuthNavigation } from '@app/extensions/oauthNavigation';
import apiClient from '@app/services/apiClient';
import { AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios';
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { springAuth } from "@app/auth/springAuthClient";
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
import apiClient from "@app/services/apiClient";
import { AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from "axios";
// Mock apiClient
vi.mock('@app/services/apiClient');
vi.mock('@app/extensions/oauthNavigation', () => ({
vi.mock("@app/services/apiClient");
vi.mock("@app/extensions/oauthNavigation", () => ({
startOAuthNavigation: vi.fn().mockResolvedValue(false),
}));
describe('SpringAuthClient', () => {
describe("SpringAuthClient", () => {
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear();
@@ -22,8 +22,8 @@ describe('SpringAuthClient', () => {
vi.restoreAllMocks();
});
describe('getSession', () => {
it('should return null session when no JWT in localStorage', async () => {
describe("getSession", () => {
it("should return null session when no JWT in localStorage", async () => {
const result = await springAuth.getSession();
expect(result.data.session).toBeNull();
@@ -31,16 +31,16 @@ describe('SpringAuthClient', () => {
expect(apiClient.get).not.toHaveBeenCalled();
});
it('should validate JWT and return session when JWT exists', async () => {
const mockToken = 'mock-jwt-token';
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',
id: "123",
email: "[email protected]",
username: "testuser",
role: "USER",
};
localStorage.setItem('stirling_jwt', mockToken);
localStorage.setItem("stirling_jwt", mockToken);
vi.mocked(apiClient.get).mockResolvedValueOnce({
status: 200,
@@ -49,7 +49,7 @@ describe('SpringAuthClient', () => {
const result = await springAuth.getSession();
expect(apiClient.get).toHaveBeenCalledWith('/api/v1/auth/me', {
expect(apiClient.get).toHaveBeenCalledWith("/api/v1/auth/me", {
headers: { Authorization: `Bearer ${mockToken}` },
suppressErrorToast: true,
skipAuthRedirect: true,
@@ -60,76 +60,64 @@ describe('SpringAuthClient', () => {
expect(result.error).toBeNull();
});
it('should clear invalid JWT on 401 error', async () => {
const mockToken = 'invalid-jwt-token';
localStorage.setItem('stirling_jwt', mockToken);
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 InternalAxiosRequestConfig,
}
);
const mockError = new AxiosError("Unauthorized", "ERR_BAD_REQUEST", undefined, undefined, {
status: 401,
statusText: "Unauthorized",
data: {},
headers: {},
config: {} as InternalAxiosRequestConfig,
});
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const result = await springAuth.getSession();
expect(localStorage.getItem('stirling_jwt')).toBeNull();
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);
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 InternalAxiosRequestConfig,
}
);
const mockError = new AxiosError("Forbidden", "ERR_BAD_REQUEST", undefined, undefined, {
status: 403,
statusText: "Forbidden",
data: {},
headers: {},
config: {} as InternalAxiosRequestConfig,
});
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const result = await springAuth.getSession();
expect(localStorage.getItem('stirling_jwt')).toBeNull();
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 () => {
describe("signInWithPassword", () => {
it("should successfully sign in with email and password", async () => {
const credentials = {
email: '[email protected]',
password: 'password123',
email: "[email protected]",
password: "password123",
};
const mockToken = 'new-jwt-token';
const mockToken = "new-jwt-token";
const mockUser = {
id: '123',
id: "123",
email: credentials.email,
username: credentials.email,
role: 'USER',
role: "USER",
};
vi.mocked(apiClient.post).mockResolvedValueOnce({
@@ -144,34 +132,32 @@ describe('SpringAuthClient', () => {
} as unknown as AxiosResponse);
// Spy on window.dispatchEvent
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
const dispatchEventSpy = vi.spyOn(window, "dispatchEvent");
const result = await springAuth.signInWithPassword(credentials);
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/auth/login',
"/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' })
{ 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 () => {
it("should return error on failed login", async () => {
const credentials = {
email: '[email protected]',
password: 'wrongpassword',
email: "[email protected]",
password: "wrongpassword",
};
const errorMessage = 'Invalid credentials';
const errorMessage = "Invalid credentials";
const mockError = Object.assign(new Error(errorMessage), {
isAxiosError: true,
response: {
@@ -191,18 +177,18 @@ describe('SpringAuthClient', () => {
});
});
describe('signUp', () => {
it('should successfully register new user', async () => {
describe("signUp", () => {
it("should successfully register new user", async () => {
const credentials = {
email: '[email protected]',
password: 'newpassword123',
email: "[email protected]",
password: "newpassword123",
};
const mockUser = {
id: '456',
id: "456",
email: credentials.email,
username: credentials.email,
role: 'USER',
role: "USER",
};
vi.mocked(apiClient.post).mockResolvedValueOnce({
@@ -213,25 +199,25 @@ describe('SpringAuthClient', () => {
const result = await springAuth.signUp(credentials);
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/user/register',
"/api/v1/user/register",
{
username: credentials.email,
password: credentials.password,
},
{ withCredentials: true }
{ 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 () => {
it("should return error on failed registration", async () => {
const credentials = {
email: '[email protected]',
password: 'password123',
email: "[email protected]",
password: "password123",
};
const errorMessage = 'User already exists';
const errorMessage = "User already exists";
const mockError = Object.assign(new Error(errorMessage), {
isAxiosError: true,
response: {
@@ -251,10 +237,10 @@ describe('SpringAuthClient', () => {
});
});
describe('signOut', () => {
it('should successfully sign out and clear JWT', async () => {
const mockToken = 'jwt-to-clear';
localStorage.setItem('stirling_jwt', mockToken);
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,
@@ -264,39 +250,39 @@ describe('SpringAuthClient', () => {
const result = await springAuth.signOut();
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/auth/logout',
"/api/v1/auth/logout",
null,
expect.objectContaining({ withCredentials: true })
expect.objectContaining({ withCredentials: true }),
);
expect(localStorage.getItem('stirling_jwt')).toBeNull();
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);
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',
message: "Server error",
});
const result = await springAuth.signOut();
expect(localStorage.getItem('stirling_jwt')).toBeNull();
expect(localStorage.getItem("stirling_jwt")).toBeNull();
expect(result.error).toBeTruthy();
});
});
describe('refreshSession', () => {
it('should refresh JWT token successfully', async () => {
const newToken = 'refreshed-jwt-token';
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',
id: "123",
email: "[email protected]",
username: "testuser",
role: "USER",
};
vi.mocked(apiClient.post).mockResolvedValueOnce({
@@ -312,33 +298,33 @@ describe('SpringAuthClient', () => {
const result = await springAuth.refreshSession();
expect(localStorage.getItem('stirling_jwt')).toBe(newToken);
expect(localStorage.getItem("stirling_jwt")).toBe(newToken);
// Note: refreshSession does not dispatch jwt-available event, only notifies listeners
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');
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',
message: "Token expired",
});
const result = await springAuth.refreshSession();
expect(localStorage.getItem('stirling_jwt')).toBeNull();
expect(localStorage.getItem("stirling_jwt")).toBeNull();
expect(result.data.session).toBeNull();
expect(result.error).toBeTruthy();
});
});
describe('signInWithOAuth', () => {
it('should redirect to OAuth provider', async () => {
describe("signInWithOAuth", () => {
it("should redirect to OAuth provider", async () => {
const mockAssign = vi.fn();
Object.defineProperty(window, 'location', {
Object.defineProperty(window, "location", {
value: { assign: mockAssign },
writable: true,
});
@@ -346,18 +332,18 @@ describe('SpringAuthClient', () => {
vi.mocked(startOAuthNavigation).mockResolvedValueOnce(false);
const result = await springAuth.signInWithOAuth({
provider: '/oauth2/authorization/github',
options: { redirectTo: '/auth/callback' },
provider: "/oauth2/authorization/github",
options: { redirectTo: "/auth/callback" },
});
expect(startOAuthNavigation).toHaveBeenCalledWith('/oauth2/authorization/github');
expect(mockAssign).toHaveBeenCalledWith('/oauth2/authorization/github');
expect(startOAuthNavigation).toHaveBeenCalledWith("/oauth2/authorization/github");
expect(mockAssign).toHaveBeenCalledWith("/oauth2/authorization/github");
expect(result.error).toBeNull();
});
it('should skip redirect when handled by extension', async () => {
it("should skip redirect when handled by extension", async () => {
const mockAssign = vi.fn();
Object.defineProperty(window, 'location', {
Object.defineProperty(window, "location", {
value: { assign: mockAssign },
writable: true,
});
@@ -365,11 +351,11 @@ describe('SpringAuthClient', () => {
vi.mocked(startOAuthNavigation).mockResolvedValueOnce(true);
const result = await springAuth.signInWithOAuth({
provider: '/oauth2/authorization/github',
options: { redirectTo: '/auth/callback' },
provider: "/oauth2/authorization/github",
options: { redirectTo: "/auth/callback" },
});
expect(startOAuthNavigation).toHaveBeenCalledWith('/oauth2/authorization/github');
expect(startOAuthNavigation).toHaveBeenCalledWith("/oauth2/authorization/github");
expect(mockAssign).not.toHaveBeenCalled();
expect(result.error).toBeNull();
});
+123 -125
View File
@@ -7,28 +7,28 @@
* - No email confirmation flow (auto-confirmed on registration)
*/
import apiClient from '@app/services/apiClient';
import { AxiosError } from 'axios';
import { BASE_PATH } from '@app/constants/app';
import { type OAuthProvider } from '@app/auth/oauthTypes';
import { resetOAuthState } from '@app/auth/oauthStorage';
import { clearPlatformAuthAfterSignOut } from '@app/extensions/authSessionCleanup';
import apiClient from "@app/services/apiClient";
import { AxiosError } from "axios";
import { BASE_PATH } from "@app/constants/app";
import { type OAuthProvider } from "@app/auth/oauthTypes";
import { resetOAuthState } from "@app/auth/oauthStorage";
import { clearPlatformAuthAfterSignOut } from "@app/extensions/authSessionCleanup";
import {
getPlatformSessionUser,
isDesktopSaaSAuthMode,
refreshPlatformSession,
savePlatformToken,
} from '@app/extensions/platformSessionBridge';
import { startOAuthNavigation } from '@app/extensions/oauthNavigation';
} from "@app/extensions/platformSessionBridge";
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
function getHttpStatus(error: unknown): number | undefined {
if (error instanceof AxiosError) {
return error.response?.status;
}
if (error && typeof error === 'object' && 'response' in error) {
if (error && typeof error === "object" && "response" in error) {
const response = (error as { response?: { status?: unknown } }).response;
if (response && typeof response.status === 'number') {
if (response && typeof response.status === "number") {
return response.status;
}
}
@@ -44,26 +44,26 @@ function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
const OAUTH_REDIRECT_COOKIE = 'stirling_redirect_path';
const OAUTH_REDIRECT_COOKIE = "stirling_redirect_path";
const OAUTH_REDIRECT_COOKIE_MAX_AGE = 60 * 5; // 5 minutes
const DEFAULT_REDIRECT_PATH = `${BASE_PATH || ''}/auth/callback`;
const DEFAULT_REDIRECT_PATH = `${BASE_PATH || ""}/auth/callback`;
function normalizeRedirectPath(target?: string): string {
if (!target || typeof target !== 'string') {
if (!target || typeof target !== "string") {
return DEFAULT_REDIRECT_PATH;
}
try {
const parsed = new URL(target, window.location.origin);
const path = parsed.pathname || '/';
const query = parsed.search || '';
const path = parsed.pathname || "/";
const query = parsed.search || "";
return `${path}${query}`;
} catch {
const trimmed = target.trim();
if (!trimmed) {
return DEFAULT_REDIRECT_PATH;
}
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}
}
@@ -108,11 +108,7 @@ export interface AuthResponse {
error: AuthError | null;
}
export type AuthChangeEvent =
| 'SIGNED_IN'
| 'SIGNED_OUT'
| 'TOKEN_REFRESHED'
| 'USER_UPDATED';
export type AuthChangeEvent = "SIGNED_IN" | "SIGNED_OUT" | "TOKEN_REFRESHED" | "USER_UPDATED";
type AuthChangeCallback = (event: AuthChangeEvent, session: Session | null) => void;
@@ -142,15 +138,15 @@ class SpringAuthClient {
try {
const payload = this.decodeJwtPayload(token);
if (!payload) {
console.warn('[SpringAuth] Cannot decode token for adaptive intervals, using defaults');
console.warn("[SpringAuth] Cannot decode token for adaptive intervals, using defaults");
return;
}
const expSeconds = typeof payload?.exp === 'number' ? payload.exp : 0;
const iatSeconds = typeof payload?.iat === 'number' ? payload.iat : 0;
const expSeconds = typeof payload?.exp === "number" ? payload.exp : 0;
const iatSeconds = typeof payload?.iat === "number" ? payload.iat : 0;
if (expSeconds <= 0 || iatSeconds <= 0) {
console.warn('[SpringAuth] Token missing exp/iat claims, using default intervals');
console.warn("[SpringAuth] Token missing exp/iat claims, using default intervals");
return;
}
@@ -166,30 +162,30 @@ class SpringAuthClient {
// Max: 5 minutes (don't wait too long for long-lived tokens)
this.tokenRefreshThresholdMs = Math.max(30000, Math.min(300000, tokenLifetimeMs / 4));
console.log('[SpringAuth] 📊 Adaptive intervals calculated:', {
tokenLifetime: Math.floor(tokenLifetimeMs / 1000) + 's',
checkInterval: Math.floor(this.sessionCheckIntervalMs / 1000) + 's',
refreshThreshold: Math.floor(this.tokenRefreshThresholdMs / 1000) + 's',
console.log("[SpringAuth] 📊 Adaptive intervals calculated:", {
tokenLifetime: Math.floor(tokenLifetimeMs / 1000) + "s",
checkInterval: Math.floor(this.sessionCheckIntervalMs / 1000) + "s",
refreshThreshold: Math.floor(this.tokenRefreshThresholdMs / 1000) + "s",
});
// Restart monitoring with new interval
this.restartSessionMonitoring();
} catch (error) {
console.warn('[SpringAuth] Failed to calculate adaptive intervals:', error);
console.warn("[SpringAuth] Failed to calculate adaptive intervals:", error);
}
}
private decodeJwtPayload(token: string): Record<string, unknown> | null {
const parts = token.split('.');
const parts = token.split(".");
if (parts.length < 2) {
return null;
}
const base64Url = parts[1];
const base64 = base64Url
.replace(/-/g, '+')
.replace(/_/g, '/')
.padEnd(Math.ceil(base64Url.length / 4) * 4, '=');
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(Math.ceil(base64Url.length / 4) * 4, "=");
return JSON.parse(atob(base64));
}
@@ -198,10 +194,10 @@ class SpringAuthClient {
try {
const payload = this.decodeJwtPayload(token);
if (!payload) {
throw new Error('Token payload missing');
throw new Error("Token payload missing");
}
const expSeconds = typeof payload?.exp === 'number' ? payload.exp : 0;
const expSeconds = typeof payload?.exp === "number" ? payload.exp : 0;
const expiresAt = expSeconds > 0 ? expSeconds * 1000 : Date.now() + 3600 * 1000;
const expiresIn = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
@@ -217,10 +213,10 @@ class SpringAuthClient {
* Helper to get CSRF token from cookie
*/
private getCsrfToken(): string | null {
const cookies = document.cookie.split(';');
const cookies = document.cookie.split(";");
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'XSRF-TOKEN') {
const [name, value] = cookie.trim().split("=");
if (name === "XSRF-TOKEN") {
return decodeURIComponent(value);
}
}
@@ -234,7 +230,7 @@ class SpringAuthClient {
async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
try {
// Get JWT from localStorage
let token = localStorage.getItem('stirling_jwt');
let token = localStorage.getItem("stirling_jwt");
if (!token) {
// console.debug('[SpringAuth] getSession: No JWT in localStorage');
@@ -246,13 +242,13 @@ class SpringAuthClient {
if (tokenExpiry.expiresIn <= this.DESKTOP_SAAS_REFRESH_EARLY_SECONDS) {
const refreshed = await refreshPlatformSession();
if (!refreshed) {
localStorage.removeItem('stirling_jwt');
localStorage.removeItem("stirling_jwt");
return { data: { session: null }, error: null };
}
const refreshedToken = localStorage.getItem('stirling_jwt');
const refreshedToken = localStorage.getItem("stirling_jwt");
if (!refreshedToken) {
localStorage.removeItem('stirling_jwt');
localStorage.removeItem("stirling_jwt");
return { data: { session: null }, error: null };
}
@@ -261,7 +257,7 @@ class SpringAuthClient {
}
if (tokenExpiry.expiresIn <= 0) {
localStorage.removeItem('stirling_jwt');
localStorage.removeItem("stirling_jwt");
return { data: { session: null }, error: null };
}
@@ -269,10 +265,10 @@ class SpringAuthClient {
const session: Session = {
user: {
id: platformUser?.email || platformUser?.username || 'desktop-saas-user',
email: platformUser?.email || '',
username: platformUser?.username || platformUser?.email || 'User',
role: 'USER',
id: platformUser?.email || platformUser?.username || "desktop-saas-user",
email: platformUser?.email || "",
username: platformUser?.username || platformUser?.email || "User",
role: "USER",
},
access_token: token,
expires_in: tokenExpiry.expiresIn,
@@ -285,9 +281,9 @@ class SpringAuthClient {
// Verify with backend
// Note: We pass the token explicitly here, overriding the interceptor's default
// console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
const response = await apiClient.get('/api/v1/auth/me', {
const response = await apiClient.get("/api/v1/auth/me", {
headers: {
'Authorization': `Bearer ${token}`,
Authorization: `Bearer ${token}`,
},
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
// Session bootstrap should not trigger global 401 refresh/redirect loops.
@@ -310,7 +306,7 @@ class SpringAuthClient {
// console.debug('[SpringAuth] getSession: Session retrieved successfully');
return { data: { session }, error: null };
} catch (error: unknown) {
console.error('[SpringAuth] getSession error:', error);
console.error("[SpringAuth] getSession error:", error);
// If 401/403, token is invalid - try explicit refresh
const status = getHttpStatus(error);
@@ -321,8 +317,8 @@ class SpringAuthClient {
if (!refreshResult.error && refreshResult.data.session) {
return refreshResult;
}
localStorage.removeItem('stirling_jwt');
console.debug('[SpringAuth] getSession: Not authenticated');
localStorage.removeItem("stirling_jwt");
console.debug("[SpringAuth] getSession: Not authenticated");
return { data: { session: null }, error: null };
}
@@ -330,7 +326,7 @@ class SpringAuthClient {
// The token is still valid, just can't verify it right now
return {
data: { session: null },
error: { message: getErrorMessage(error, 'Unknown error') },
error: { message: getErrorMessage(error, "Unknown error") },
};
}
}
@@ -338,25 +334,25 @@ class SpringAuthClient {
/**
* Sign in with email and password
*/
async signInWithPassword(credentials: {
email: string;
password: string;
mfaCode?: string;
}): Promise<AuthResponse> {
async signInWithPassword(credentials: { email: string; password: string; mfaCode?: string }): Promise<AuthResponse> {
try {
const response = await apiClient.post('/api/v1/auth/login', {
username: credentials.email,
password: credentials.password,
mfaCode: credentials.mfaCode,
}, {
withCredentials: true, // Include cookies for CSRF
});
const response = await apiClient.post(
"/api/v1/auth/login",
{
username: credentials.email,
password: credentials.password,
mfaCode: credentials.mfaCode,
},
{
withCredentials: true, // Include cookies for CSRF
},
);
const data = response.data;
const token = data.session.access_token;
// Store JWT in localStorage
localStorage.setItem('stirling_jwt', token);
localStorage.setItem("stirling_jwt", token);
// console.log('[SpringAuth] JWT stored in localStorage');
// Sync token to platform-specific storage (Tauri store for desktop)
@@ -366,7 +362,7 @@ class SpringAuthClient {
this.calculateAdaptiveIntervals(token);
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent('jwt-available'));
window.dispatchEvent(new CustomEvent("jwt-available"));
const session: Session = {
user: data.user,
@@ -376,18 +372,14 @@ class SpringAuthClient {
};
// Notify listeners
this.notifyListeners('SIGNED_IN', session);
this.notifyListeners("SIGNED_IN", session);
return { user: data.user, session, error: null };
} catch (error: unknown) {
console.error('[SpringAuth] signInWithPassword error:', error);
console.error("[SpringAuth] signInWithPassword error:", error);
if (error instanceof AxiosError) {
const errorCode = error.response?.data?.error as string | undefined;
const errorMessage =
error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
'Login failed';
const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || "Login failed";
return {
user: null,
session: null,
@@ -395,14 +387,14 @@ class SpringAuthClient {
message: errorMessage,
status: error.response?.status,
code: errorCode,
mfaRequired: errorCode === 'mfa_required',
mfaRequired: errorCode === "mfa_required",
},
};
}
return {
user: null,
session: null,
error: { message: getErrorMessage(error, 'Login failed') },
error: { message: getErrorMessage(error, "Login failed") },
};
}
}
@@ -416,12 +408,16 @@ class SpringAuthClient {
options?: { data?: { full_name?: string }; emailRedirectTo?: string };
}): Promise<AuthResponse> {
try {
const response = await apiClient.post('/api/v1/user/register', {
username: credentials.email,
password: credentials.password,
}, {
withCredentials: true,
});
const response = await apiClient.post(
"/api/v1/user/register",
{
username: credentials.email,
password: credentials.password,
},
{
withCredentials: true,
},
);
const data = response.data;
@@ -429,11 +425,11 @@ class SpringAuthClient {
// Return user but no session (user needs to login)
return { user: data.user, session: null, error: null };
} catch (error: unknown) {
console.error('[SpringAuth] signUp error:', error);
console.error("[SpringAuth] signUp error:", error);
return {
user: null,
session: null,
error: { message: getErrorMessage(error, 'Registration failed') },
error: { message: getErrorMessage(error, "Registration failed") },
};
}
}
@@ -466,7 +462,7 @@ class SpringAuthClient {
return { error: null };
} catch (error) {
return {
error: { message: error instanceof Error ? error.message : 'SSO redirect failed' },
error: { message: error instanceof Error ? error.message : "SSO redirect failed" },
};
}
}
@@ -476,12 +472,12 @@ class SpringAuthClient {
*/
async signOut(): Promise<{ error: AuthError | null }> {
try {
if (typeof window !== 'undefined') {
window.sessionStorage.setItem('stirling_sso_auto_login_logged_out', '1');
if (typeof window !== "undefined") {
window.sessionStorage.setItem("stirling_sso_auto_login_logged_out", "1");
}
const response = await apiClient.post('/api/v1/auth/logout', null, {
const response = await apiClient.post("/api/v1/auth/logout", null, {
headers: {
'X-XSRF-TOKEN': this.getCsrfToken() || '',
"X-XSRF-TOKEN": this.getCsrfToken() || "",
},
withCredentials: true,
});
@@ -491,52 +487,52 @@ class SpringAuthClient {
}
// Clean up local storage
localStorage.removeItem('stirling_jwt');
localStorage.removeItem("stirling_jwt");
try {
Object.keys(localStorage)
.filter((key) => key.startsWith('sb-') || key.includes('supabase'))
.filter((key) => key.startsWith("sb-") || key.includes("supabase"))
.forEach((key) => localStorage.removeItem(key));
// Clear any cached OAuth redirect/session state
resetOAuthState();
} catch (err) {
console.warn('[SpringAuth] Failed to clear Supabase/local auth tokens', err);
console.warn("[SpringAuth] Failed to clear Supabase/local auth tokens", err);
}
// Clear cookies that might hold refresh/session tokens
try {
document.cookie.split(';').forEach(cookie => {
const eqPos = cookie.indexOf('=');
document.cookie.split(";").forEach((cookie) => {
const eqPos = cookie.indexOf("=");
const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
if (name) {
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;`;
}
});
} catch (err) {
console.warn('[SpringAuth] Failed to clear cookies on sign out', err);
console.warn("[SpringAuth] Failed to clear cookies on sign out", err);
}
try {
await clearPlatformAuthAfterSignOut();
} catch (cleanupError) {
console.warn('[SpringAuth] Failed to run platform auth cleanup', cleanupError);
console.warn("[SpringAuth] Failed to run platform auth cleanup", cleanupError);
}
// Notify listeners
this.notifyListeners('SIGNED_OUT', null);
this.notifyListeners("SIGNED_OUT", null);
return { error: null };
} catch (error: unknown) {
console.error('[SpringAuth] signOut error:', error);
console.error("[SpringAuth] signOut error:", error);
// Still remove token even if backend call fails
localStorage.removeItem('stirling_jwt');
localStorage.removeItem("stirling_jwt");
try {
await clearPlatformAuthAfterSignOut();
} catch (cleanupError) {
console.warn('[SpringAuth] Failed to run platform auth cleanup after error', cleanupError);
console.warn("[SpringAuth] Failed to run platform auth cleanup after error", cleanupError);
}
return {
error: { message: getErrorMessage(error, 'Logout failed') },
error: { message: getErrorMessage(error, "Logout failed") },
};
}
}
@@ -549,10 +545,10 @@ class SpringAuthClient {
if (await isDesktopSaaSAuthMode()) {
const refreshed = await refreshPlatformSession();
if (!refreshed) {
localStorage.removeItem('stirling_jwt');
localStorage.removeItem("stirling_jwt");
return {
data: { session: null },
error: { message: 'Token refresh failed - please log in again' },
error: { message: "Token refresh failed - please log in again" },
};
}
@@ -560,23 +556,23 @@ class SpringAuthClient {
if (error || !data.session) {
return {
data: { session: null },
error: error || { message: 'Token refresh failed - please log in again' },
error: error || { message: "Token refresh failed - please log in again" },
};
}
// Calculate adaptive intervals for desktop SaaS mode
const token = localStorage.getItem('stirling_jwt');
const token = localStorage.getItem("stirling_jwt");
if (token) {
this.calculateAdaptiveIntervals(token);
}
this.notifyListeners('TOKEN_REFRESHED', data.session);
this.notifyListeners("TOKEN_REFRESHED", data.session);
return { data, error: null };
}
const response = await apiClient.post('/api/v1/auth/refresh', null, {
const response = await apiClient.post("/api/v1/auth/refresh", null, {
headers: {
'X-XSRF-TOKEN': this.getCsrfToken() || '',
"X-XSRF-TOKEN": this.getCsrfToken() || "",
},
withCredentials: true,
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
@@ -586,7 +582,7 @@ class SpringAuthClient {
const token = data.session.access_token;
// Update local storage with new token
localStorage.setItem('stirling_jwt', token);
localStorage.setItem("stirling_jwt", token);
// Sync token to platform-specific storage (Tauri store for desktop)
await savePlatformToken(token);
@@ -602,24 +598,24 @@ class SpringAuthClient {
};
// Notify listeners
this.notifyListeners('TOKEN_REFRESHED', session);
this.notifyListeners("TOKEN_REFRESHED", session);
console.debug('[SpringAuth] Token refreshed successfully');
console.debug("[SpringAuth] Token refreshed successfully");
return { data: { session }, error: null };
} catch (error: unknown) {
console.error('[SpringAuth] refreshSession error:', error);
localStorage.removeItem('stirling_jwt');
console.error("[SpringAuth] refreshSession error:", error);
localStorage.removeItem("stirling_jwt");
// Handle different error statuses
const status = getHttpStatus(error);
if (status === 401 || status === 403) {
return { data: { session: null }, error: { message: 'Token refresh failed - please log in again' } };
return { data: { session: null }, error: { message: "Token refresh failed - please log in again" } };
}
return {
data: { session: null },
error: { message: getErrorMessage(error, 'Token refresh failed') },
error: { message: getErrorMessage(error, "Token refresh failed") },
};
}
}
@@ -650,7 +646,7 @@ class SpringAuthClient {
try {
callback(event, session);
} catch (error) {
console.error('[SpringAuth] Error in auth state change listener:', error);
console.error("[SpringAuth] Error in auth state change listener:", error);
}
});
}, 0);
@@ -670,12 +666,14 @@ class SpringAuthClient {
// Refresh if token expires soon (threshold is adaptive)
if (timeUntilExpiry > 0 && timeUntilExpiry < this.tokenRefreshThresholdMs) {
console.log('[SpringAuth] 🔄 Proactively refreshing token (expires in ' + Math.floor(timeUntilExpiry / 1000) + 's)');
console.log(
"[SpringAuth] 🔄 Proactively refreshing token (expires in " + Math.floor(timeUntilExpiry / 1000) + "s)",
);
await this.refreshSession();
}
}
} catch (error) {
console.error('[SpringAuth] Session monitoring error:', error);
console.error("[SpringAuth] Session monitoring error:", error);
}
}, this.sessionCheckIntervalMs);
}
@@ -720,14 +718,14 @@ export const isUserAnonymous = (user: User | null) => {
*/
export const createAnonymousUser = (): User => {
return {
id: 'anonymous',
email: 'anonymous@local',
username: 'Anonymous User',
role: 'USER',
id: "anonymous",
email: "anonymous@local",
username: "Anonymous User",
role: "USER",
enabled: true,
is_anonymous: true,
app_metadata: {
provider: 'anonymous',
provider: "anonymous",
},
};
};
@@ -738,7 +736,7 @@ export const createAnonymousUser = (): User => {
export const createAnonymousSession = (): Session => {
return {
user: createAnonymousUser(),
access_token: '',
access_token: "",
expires_in: Number.MAX_SAFE_INTEGER,
expires_at: Number.MAX_SAFE_INTEGER,
};