Fix any type usage in the saas/ folder (#5934)

# Description of Changes
Ages ago I made #4835 to try and fix all the `any` type usage in the
system but never got it finished, and there were just too many to review
and ensure it still worked. There's even more now.

My new tactic is to fix folder by folder. This fixes the `any` typing in
the `saas/` folder, and also enables `no-unnecessary-type-assertion`,
which really helps reduce pointless `as` casts that AI generates when
the type is already known. I hope to expand both of these to the rest of
the folders soon, but one folder is better than none.
This commit is contained in:
James Brunton
2026-03-16 11:51:16 +00:00
committed by GitHub
parent 1722733802
commit dbff05814f
22 changed files with 123 additions and 112 deletions

View File

@@ -59,6 +59,20 @@ export default defineConfig(
],
},
},
// Folders that have been cleaned up and are now conformant - stricter rules enforced here
{
files: ['src/saas/**/*.{js,mjs,jsx,ts,tsx}'],
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
},
},
// Config for browser scripts
{
files: srcGlobs,

View File

@@ -12,6 +12,12 @@ declare module 'assets/material-symbols-icons.json' {
export default value;
}
declare global {
interface Window {
__STIRLING_PDF_BASE_URL__?: string;
}
}
declare module 'axios' {
export interface AxiosRequestConfig<_D = unknown> {
suppressErrorToast?: boolean;

View File

@@ -114,7 +114,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setSubscription(subscriptionInfo)
console.debug('[Auth Debug] Credits fetched successfully:', credits)
} catch (error: any) {
} catch (error: unknown) {
console.debug('[Auth Debug] Failed to fetch credits:', error)
// Don't set error state for credit fetching failures to avoid disrupting auth flow
// Credits might not be available in all deployments
@@ -149,7 +149,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setIsPro(isProUser)
console.debug('[Auth Debug] Pro status fetched:', isProUser)
}
} catch (error: any) {
} catch (error: unknown) {
console.debug('[Auth Debug] Failed to fetch pro status:', error)
setIsPro(false) // Default to false if there's an error
}
@@ -206,7 +206,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} else {
setTrialStatus(null)
}
} catch (error: any) {
} catch (error: unknown) {
console.debug('[Auth Debug] Failed to fetch trial status:', error)
setTrialStatus(null)
}
@@ -243,7 +243,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setProfilePictureUrl(data.signedUrl)
console.debug('[Auth Debug] Profile picture URL fetched successfully')
}
} catch (error: any) {
} catch (error: unknown) {
console.debug('[Auth Debug] Failed to fetch profile picture:', error)
setProfilePictureUrl(null)
}
@@ -267,7 +267,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const metadata = await getProfilePictureMetadata(currentSession.user.id)
setProfilePictureMetadata(metadata)
console.debug('[Auth Debug] Profile picture metadata fetched:', metadata)
} catch (error: any) {
} catch (error: unknown) {
console.debug('[Auth Debug] Failed to fetch profile picture metadata:', error)
setProfilePictureMetadata(null)
}

View File

@@ -83,7 +83,7 @@ export const signInAnonymously = async () => {
// Account linking functions
export const linkEmailIdentity = async (email: string, password?: string) => {
try {
const updateData: any = { email }
const updateData: { email: string; password?: string } = { email }
if (password) {
updateData.password = password
}
@@ -143,7 +143,7 @@ export const linkOAuthIdentity = async (provider: 'google' | 'github' | 'apple'
}
// Helper function to check if user is anonymous
export const isUserAnonymous = (user: any) => {
export const isUserAnonymous = (user: { is_anonymous?: boolean }) => {
return user?.is_anonymous === true
}

View File

@@ -38,16 +38,16 @@ export function ManageBillingButton({
setLoading(true);
setErr(null);
try {
const { data, error } = await supabase.functions.invoke('manage-billing', {
const { data, error } = await supabase.functions.invoke<{ url: string; error?: string }>('manage-billing', {
body: {
name: 'Functions',
return_url: returnUrl},
})
if (error) throw error;
if (!data || 'error' in data) throw new Error((data as any)?.error ?? 'No portal URL');
window.location.href = (data as any).url;
} catch (e: any) {
setErr(e.message ?? 'Could not open billing portal');
if (!data || 'error' in data) throw new Error(data?.error ?? 'No portal URL');
window.location.href = data.url;
} catch (e: unknown) {
setErr(e instanceof Error ? e.message : 'Could not open billing portal');
} finally {
setLoading(false);
}

View File

@@ -24,7 +24,7 @@ interface StripeCheckoutProps {
currency?: string;
isTrialConversion?: boolean;
// Proprietary-specific props (for compatibility)
planGroup?: any;
planGroup?: unknown;
minimumSeats?: number;
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void;
hostedCheckoutSuccess?: {

View File

@@ -162,7 +162,7 @@ export function createScale(domain: [number, number], range: [number, number]) {
* @param wait The wait time in milliseconds
* @returns Debounced function
*/
export function debounce<T extends (...args: any[]) => any>(
export function debounce<T extends (...args: unknown[]) => unknown>(
func: T,
wait: number
): (...args: Parameters<T>) => void {

View File

@@ -149,8 +149,8 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
// Clear success message after 3 seconds
setTimeout(() => setSuccess(null), 3000);
} catch (error: any) {
setProfileError(error.message || 'Failed to switch to custom picture');
} catch (error: unknown) {
setProfileError(error instanceof Error ? error.message : 'Failed to switch to custom picture');
} finally {
setProfileUploading(false);
}
@@ -181,8 +181,8 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
setSuccess('Account upgraded successfully! You can now sign in with your email.');
setEmail('');
setPassword('');
} catch (err: any) {
setUpgradeError(err?.message || 'Failed to upgrade account');
} catch (err: unknown) {
setUpgradeError(err instanceof Error ? err.message : 'Failed to upgrade account');
} finally {
setIsLoading(false);
}
@@ -404,7 +404,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
style={{ width: 16, height: 16 }}
/>
}
onClick={() => handleOAuthUpgrade(provider.id as any)}
onClick={() => handleOAuthUpgrade(provider.id as 'github' | 'google' | 'apple' | 'azure')}
disabled={isLoading}
>
{provider.label}

View File

@@ -55,8 +55,8 @@ const PasswordSecurity: React.FC = () => {
setOpened(false);
setDidUpdate(false);
}, 2000);
} catch (e: any) {
setError(e?.message || 'Failed to change password');
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to change password');
} finally {
setIsLoading(false);
}

View File

@@ -1,8 +1,11 @@
import { useCallback, useEffect, useState } from "react";
import { isAxiosError } from "axios";
import apiClient from "@app/services/apiClient";
import { useAuth } from "@app/auth/UseSession";
import { isUserAnonymous } from "@app/auth/supabase";
type ApiKeyResponse = string | { apiKey?: string };
export function useApiKey() {
const { session, loading, user } = useAuth();
const isAnonymous = Boolean(user && isUserAnonymous(user));
@@ -17,24 +20,21 @@ export function useApiKey() {
setError(null);
try {
// Backend is POST for get and update
const res = await apiClient.post("/api/v1/user/get-api-key");
const value = typeof res.data === "string" ? res.data : res.data?.apiKey;
const res = await apiClient.post<ApiKeyResponse>("/api/v1/user/get-api-key");
const value = typeof res.data === "string" ? res.data : res.data.apiKey;
if (typeof value === "string") setApiKey(value);
} catch (e: any) {
} catch (e: unknown) {
// If not found, try to create one by calling update endpoint
if (e?.response?.status === 404) {
if (isAxiosError(e) && e.response?.status === 404) {
try {
const createRes = await apiClient.post("/api/v1/user/update-api-key");
const created =
typeof createRes.data === "string"
? createRes.data
: createRes.data?.apiKey;
const createRes = await apiClient.post<ApiKeyResponse>("/api/v1/user/update-api-key");
const created = typeof createRes.data === "string" ? createRes.data : createRes.data.apiKey;
if (typeof created === "string") setApiKey(created);
} catch (createErr: any) {
setError(createErr);
} catch (createErr: unknown) {
setError(createErr instanceof Error ? createErr : new Error(String(createErr)));
}
} else {
setError(e);
setError(e instanceof Error ? e : new Error(String(e)));
}
} finally {
setIsLoading(false);
@@ -46,11 +46,11 @@ export function useApiKey() {
setIsRefreshing(true);
setError(null);
try {
const res = await apiClient.post("/api/v1/user/update-api-key");
const value = typeof res.data === "string" ? res.data : res.data?.apiKey;
const res = await apiClient.post<ApiKeyResponse>("/api/v1/user/update-api-key");
const value = typeof res.data === "string" ? res.data : res.data.apiKey;
if (typeof value === "string") setApiKey(value);
} catch (e: any) {
setError(e);
} catch (e: unknown) {
setError(e instanceof Error ? e : new Error(String(e)));
} finally {
setIsRefreshing(false);
}

View File

@@ -6,10 +6,10 @@ import { isUserAnonymous } from "@app/auth/supabase";
function coerceNumber(value: unknown, fallback = 0): number {
const n = typeof value === "string" ? Number(value) : (value as number);
return Number.isFinite(n) ? (n as number) : fallback;
return Number.isFinite(n) ? n : fallback;
}
function normalizeCredits(raw: any): ApiCredits {
function normalizeCredits(raw: Record<string, unknown>): ApiCredits {
// Accept a variety of possible backend keys to be resilient
return {
weeklyCreditsRemaining: coerceNumber(
@@ -28,9 +28,9 @@ function normalizeCredits(raw: any): ApiCredits {
raw?.totalAvailableCredits ?? raw?.totalRemaining ?? raw?.available_total
),
weeklyResetDate:
(raw?.weeklyResetDate ?? raw?.weeklyReset ?? raw?.reset_date) || "",
String(raw?.weeklyResetDate ?? raw?.weeklyReset ?? raw?.reset_date ?? ""),
lastApiUsage:
(raw?.lastApiUsage ?? raw?.lastApiUse ?? raw?.last_used_at) || "",
String(raw?.lastApiUsage ?? raw?.lastApiUse ?? raw?.last_used_at ?? ""),
};
}
@@ -46,7 +46,7 @@ export function useCredits() {
setIsLoading(true);
setError(null);
try {
const res = await apiClient.get("/api/v1/credits");
const res = await apiClient.get<Record<string, unknown>>("/api/v1/credits");
const normalized = normalizeCredits(res.data);
// If backend returns an "empty" payload, keep data null so the UI stays in loading/skeleton
const isEmpty = !normalized.weeklyCreditsAllocated &&
@@ -57,8 +57,8 @@ export function useCredits() {
!normalized.weeklyResetDate &&
!normalized.lastApiUsage;
setData(isEmpty ? null : normalized);
} catch (e: any) {
setError(e);
} catch (e: unknown) {
setError(e instanceof Error ? e : new Error(String(e)));
} finally {
setIsLoading(false);
setHasAttempted(true);

View File

@@ -7,9 +7,9 @@ import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard
interface AvailablePlansSectionProps {
plans: PlanTier[];
currentPlan?: PlanTier;
currentLicenseInfo?: any;
onUpgradeClick: (plan: PlanTier | any) => void; // Accept PlanTierGroup for compatibility
onManageClick?: (plan: PlanTier | any) => void; // Accept PlanTierGroup for compatibility
currentLicenseInfo?: unknown;
onUpgradeClick: (plan: PlanTier) => void;
onManageClick?: (plan: PlanTier) => void;
currency?: string;
onCurrencyChange?: (currency: string) => void;
currencyOptions?: Array<{ value: string; label: string }>;

View File

@@ -5,15 +5,15 @@ import { PlanTier } from '@app/hooks/usePlans';
interface PlanCardProps {
plan?: PlanTier;
planGroup?: any; // For proprietary PlanTierGroup compatibility
planGroup?: { monthly?: PlanTier; yearly?: PlanTier }; // For proprietary PlanTierGroup compatibility
isCurrentPlan?: boolean;
isCurrentTier?: boolean;
isDowngrade?: boolean;
isUserProOrAbove?: boolean;
currentLicenseInfo?: any;
currentLicenseInfo?: unknown;
currentTier?: string | null; // Accept null for proprietary compatibility
onUpgradeClick?: (plan: any) => void; // Accept PlanTierGroup or PlanTier
onManageClick?: (plan: any) => void;
onUpgradeClick?: (plan: PlanTier) => void;
onManageClick?: (plan: PlanTier) => void;
loginEnabled?: boolean;
}
@@ -31,7 +31,7 @@ const PlanCard: React.FC<PlanCardProps> = ({
loginEnabled: _loginEnabled
}) => {
// Use plan from props, or extract from planGroup if proprietary is using it
const plan = propPlan || (planGroup as any)?.monthly || (planGroup as any)?.yearly;
const plan = propPlan || planGroup?.monthly || planGroup?.yearly;
const { t } = useTranslation();
if (!plan) return null; // Safety check

View File

@@ -5,11 +5,10 @@ export * from '@core/constants/app';
// Get base URL with fallback (for use outside React components)
export const getBaseUrl = (): string => {
// Try to get from window object if set by app config
const baseUrl = (window as any).__STIRLING_PDF_BASE_URL__ || window.location.origin;
return baseUrl;
return window.__STIRLING_PDF_BASE_URL__ || window.location.origin;
};
// Helper to set base URL (to be called when app config loads)
export const setBaseUrl = (url: string): void => {
(window as any).__STIRLING_PDF_BASE_URL__ = url;
window.__STIRLING_PDF_BASE_URL__ = url;
};

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { isAxiosError } from 'axios';
import apiClient from '@app/services/apiClient';
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
@@ -153,9 +154,9 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
setEndpointStatus(fullStatus.status);
setEndpointDetails(prev => ({ ...prev, ...fullStatus.details }));
globalFetchedSets.add(endpointsKey);
} catch (err: any) {
} catch (err: unknown) {
// On 401 (auth error), use optimistic fallback instead of disabling
if (err.response?.status === 401) {
if (isAxiosError(err) && err.response?.status === 401) {
console.warn('[useEndpointConfig] 401 error - using optimistic fallback');
const optimisticStatus = endpoints.reduce(
(acc, endpoint) => {

View File

@@ -7,7 +7,7 @@ import { withBasePath } from '@app/constants/app'
interface CallbackState {
status: 'processing' | 'success' | 'error'
message: string
details?: Record<string, any>
details?: Record<string, unknown>
}
export default function AuthCallback() {

View File

@@ -68,7 +68,7 @@ export default function Login() {
const redirectTo = absoluteWithBasePath('/auth/callback')
console.log(`[Login] Signing in with ${provider}`)
const oauthOptions: any = { redirectTo }
const oauthOptions: { redirectTo: string; queryParams?: Record<string, string> } = { redirectTo }
if (provider === 'apple') {
oauthOptions.queryParams = { scope: 'email name' }
} else if (provider === 'azure') {
@@ -175,12 +175,9 @@ export default function Login() {
setError(null)
console.log('[Login] Signing in anonymously')
const { data, error } = await signInAnonymously()
const { data } = await signInAnonymously()
if (error) {
console.error('[Login] Anonymous sign in error:', error)
setError((error as any)?.message || 'Unknown error')
} else if (data.user) {
if (data.user) {
console.log('[Login] Anonymous sign in successful, refreshing session...')
// Refresh session to ensure backend endpoints are properly synchronized

View File

@@ -62,12 +62,9 @@ export default function Signup() {
setError(null)
console.log('[Signup] Initiating anonymous sign-in...')
const { data, error } = await signInAnonymously()
const { data } = await signInAnonymously()
if (error) {
console.error('[Signup] Anonymous sign-in error:', error)
setError(`Failed to create guest account: ${(error as any)?.message || 'Unknown error'}`)
} else if (data.user) {
if (data.user) {
console.log('[Signup] Anonymous sign-in successful, refreshing session...')
// Refresh session to ensure backend endpoints are properly synchronized

View File

@@ -32,7 +32,7 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
position="top"
>
<button
onClick={() => onProviderClick(p.id as any)}
onClick={() => onProviderClick(p.id as 'github' | 'google')}
disabled={isSubmitting || p.isDisabled}
className="oauth-button-icon"
aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
@@ -55,7 +55,7 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
position="top"
>
<button
onClick={() => onProviderClick(p.id as any)}
onClick={() => onProviderClick(p.id as 'github' | 'google')}
disabled={isSubmitting || p.isDisabled}
className="oauth-button-grid"
aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
@@ -74,7 +74,7 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
{oauthProviders.map((p) => (
<button
key={p.id}
onClick={() => onProviderClick(p.id as any)}
onClick={() => onProviderClick(p.id as 'github' | 'google')}
disabled={isSubmitting || p.isDisabled}
className="oauth-button-fullwidth"
title={p.label}
@@ -92,7 +92,7 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
{oauthProviders.map((p) => (
<button
key={p.id}
onClick={() => onProviderClick(p.id as any)}
onClick={() => onProviderClick(p.id as 'github' | 'google')}
disabled={isSubmitting || p.isDisabled}
className="oauth-button-vertical"
title={p.label}

View File

@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Session, AuthError } from '@supabase/supabase-js';
import { supabase } from '@app/auth/supabase';
// Mock supabase
@@ -20,13 +21,13 @@ describe('apiClient', () => {
it('should add JWT token to request headers when session exists', async () => {
const mockToken = 'test-jwt-token-12345';
const mockSession: any = {
const mockSession = {
access_token: mockToken,
refresh_token: 'refresh-token',
expires_in: 3600,
token_type: 'bearer',
user: { id: 'user-123' },
};
} as unknown as Session;
// Mock getSession to return a session with token
vi.mocked(supabase.auth.getSession).mockResolvedValue({
@@ -99,21 +100,21 @@ describe('apiClient', () => {
const oldToken = 'old-token';
const newToken = 'new-refreshed-token';
const oldSession: any = {
const oldSession = {
access_token: oldToken,
refresh_token: 'refresh-token',
expires_in: 3600,
token_type: 'bearer',
user: { id: 'user-123' },
};
} as unknown as Session;
const newSession: any = {
const newSession = {
access_token: newToken,
refresh_token: 'new-refresh-token',
expires_in: 3600,
token_type: 'bearer',
user: { id: 'user-123' },
};
} as unknown as Session;
// Mock initial session for first request
let getSessionCallCount = 0;
@@ -129,8 +130,8 @@ describe('apiClient', () => {
// Mock refresh to return new session
vi.mocked(supabase.auth.refreshSession).mockResolvedValue({
data: { user: null, session: newSession },
error: null as any,
} as any);
error: null,
});
// Import apiClient after mocking
const { default: apiClient } = await import('@app/services/apiClient');
@@ -143,12 +144,10 @@ describe('apiClient', () => {
if (requestCount === 1) {
// Verify first request has old token
expect(config.headers.Authorization).toBe(`Bearer ${oldToken}`);
const error: any = new Error('Unauthorized');
error.response = {
status: 401,
data: { error: 'Unauthorized' },
};
error.config = config;
const error = Object.assign(new Error('Unauthorized'), {
response: { status: 401, data: { error: 'Unauthorized' } },
config,
});
return Promise.reject(error);
}
@@ -187,35 +186,32 @@ describe('apiClient', () => {
// Mock initial session
vi.mocked(supabase.auth.getSession).mockResolvedValue({
data: { session: oldSession },
data: { session: oldSession as unknown as Session },
error: null,
} as any);
});
vi.mocked(supabase.auth.getSession).mockResolvedValue({
data: { session: oldSession },
data: { session: oldSession as unknown as Session },
error: null,
} as any);
});
// Mock refresh to fail
vi.mocked(supabase.auth.refreshSession).mockResolvedValue({
data: { user: null, session: null },
error: { name: 'AuthError', message: 'Refresh failed', status: 400, code: 'auth_error', __isAuthError: true } as any,
} as any);
error: { name: 'AuthError', message: 'Refresh failed', status: 400, code: 'auth_error' } as unknown as AuthError,
});
// Import apiClient after mocking
const { default: apiClient } = await import('@app/services/apiClient');
// Mock window.location for redirect test
delete (window as any).location;
window.location = { href: '' } as any;
Object.defineProperty(window, 'location', { writable: true, value: { href: '' } });
const mockAdapter = vi.fn((config) => {
// Always return 401 to trigger refresh
const error: any = new Error('Unauthorized');
error.response = {
status: 401,
data: { error: 'Unauthorized' },
};
error.config = config;
const error = Object.assign(new Error('Unauthorized'), {
response: { status: 401, data: { error: 'Unauthorized' } },
config,
});
return Promise.reject(error);
});

View File

@@ -31,7 +31,7 @@ export interface AdminSettingsData {
disabledUsers: number;
currentUsername?: string;
roleDetails?: Record<string, string>;
teams?: any[];
teams?: Array<{ id: number; name: string; [key: string]: unknown }>;
maxPaidUsers?: number;
// License information
maxAllowedUsers: number;
@@ -138,7 +138,7 @@ export const userManagementService = {
}
await apiClient.post('/api/v1/user/admin/saveUser', formData, {
suppressErrorToast: true, // Component will handle error display
} as any);
});
},
/**
@@ -153,7 +153,7 @@ export const userManagementService = {
}
await apiClient.post('/api/v1/user/admin/changeRole', formData, {
suppressErrorToast: true,
} as any);
});
},
/**
@@ -164,7 +164,7 @@ export const userManagementService = {
formData.append('enabled', enabled.toString());
await apiClient.post(`/api/v1/user/admin/changeUserEnabled/${username}`, formData, {
suppressErrorToast: true,
} as any);
});
},
/**
@@ -208,7 +208,7 @@ export const userManagementService = {
formData,
{
suppressErrorToast: true, // Component will handle error display
} as any
}
);
return response.data;
@@ -239,7 +239,7 @@ export const userManagementService = {
formData,
{
suppressErrorToast: true,
} as any
}
);
return response.data;
@@ -259,7 +259,7 @@ export const userManagementService = {
async revokeInviteLink(inviteId: number): Promise<void> {
await apiClient.delete(`/api/v1/invite/revoke/${inviteId}`, {
suppressErrorToast: true,
} as any);
});
},
/**

View File

@@ -89,15 +89,16 @@ for (let i = 0; i < 32; i++) {
Object.defineProperty(globalThis, 'crypto', {
value: {
subtle: {
digest: vi.fn().mockImplementation(async (_algorithm: string, _data: any) => {
digest: vi.fn().mockImplementation(async (_algorithm: string, _data: BufferSource) => {
// Always return the mock hash buffer regardless of input
return mockHashBuffer.slice();
}),
},
getRandomValues: vi.fn().mockImplementation((array: any) => {
getRandomValues: vi.fn().mockImplementation(<T extends ArrayBufferView>(array: T): T => {
// Mock getRandomValues if needed
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256);
const view = new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
for (let i = 0; i < view.length; i++) {
view[i] = Math.floor(Math.random() * 256);
}
return array;
}),