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
+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,
};