From dbff05814f4c2197cc5fcc57ea3a28ca1891b25f Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 16 Mar 2026 11:51:16 +0000 Subject: [PATCH] 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. --- frontend/eslint.config.mjs | 14 +++++ frontend/src/global.d.ts | 6 +++ frontend/src/saas/auth/UseSession.tsx | 10 ++-- frontend/src/saas/auth/supabase.ts | 4 +- .../components/shared/ManageBillingButton.tsx | 10 ++-- .../components/shared/StripeCheckoutSaas.tsx | 2 +- .../components/shared/charts/utils/d3Utils.ts | 2 +- .../shared/config/configSections/Overview.tsx | 10 ++-- .../configSections/PasswordSecurity.tsx | 4 +- .../configSections/apiKeys/hooks/useApiKey.ts | 32 ++++++------ .../apiKeys/hooks/useCredits.ts | 14 ++--- .../plan/AvailablePlansSection.tsx | 6 +-- .../config/configSections/plan/PlanCard.tsx | 10 ++-- frontend/src/saas/constants/app.ts | 5 +- frontend/src/saas/hooks/useEndpointConfig.ts | 5 +- frontend/src/saas/routes/AuthCallback.tsx | 2 +- frontend/src/saas/routes/Login.tsx | 9 ++-- frontend/src/saas/routes/Signup.tsx | 7 +-- .../src/saas/routes/login/OAuthButtons.tsx | 8 +-- frontend/src/saas/services/apiClient.test.ts | 52 +++++++++---------- .../saas/services/userManagementService.ts | 14 ++--- frontend/src/saas/setupTests.ts | 9 ++-- 22 files changed, 123 insertions(+), 112 deletions(-) diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 64f7acefd2..b8cbc95a1d 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -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, diff --git a/frontend/src/global.d.ts b/frontend/src/global.d.ts index c09d1b3a9b..09487d4a06 100644 --- a/frontend/src/global.d.ts +++ b/frontend/src/global.d.ts @@ -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; diff --git a/frontend/src/saas/auth/UseSession.tsx b/frontend/src/saas/auth/UseSession.tsx index 0de2231676..fd46263694 100644 --- a/frontend/src/saas/auth/UseSession.tsx +++ b/frontend/src/saas/auth/UseSession.tsx @@ -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) } diff --git a/frontend/src/saas/auth/supabase.ts b/frontend/src/saas/auth/supabase.ts index b8b473f785..338c5c8adc 100644 --- a/frontend/src/saas/auth/supabase.ts +++ b/frontend/src/saas/auth/supabase.ts @@ -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 } diff --git a/frontend/src/saas/components/shared/ManageBillingButton.tsx b/frontend/src/saas/components/shared/ManageBillingButton.tsx index 507aeb07e0..3f9558bbf3 100644 --- a/frontend/src/saas/components/shared/ManageBillingButton.tsx +++ b/frontend/src/saas/components/shared/ManageBillingButton.tsx @@ -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); } diff --git a/frontend/src/saas/components/shared/StripeCheckoutSaas.tsx b/frontend/src/saas/components/shared/StripeCheckoutSaas.tsx index 61c164b236..27f1f1abc3 100644 --- a/frontend/src/saas/components/shared/StripeCheckoutSaas.tsx +++ b/frontend/src/saas/components/shared/StripeCheckoutSaas.tsx @@ -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?: { diff --git a/frontend/src/saas/components/shared/charts/utils/d3Utils.ts b/frontend/src/saas/components/shared/charts/utils/d3Utils.ts index 44b5b50988..ddeed92197 100644 --- a/frontend/src/saas/components/shared/charts/utils/d3Utils.ts +++ b/frontend/src/saas/components/shared/charts/utils/d3Utils.ts @@ -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 any>( +export function debounce unknown>( func: T, wait: number ): (...args: Parameters) => void { diff --git a/frontend/src/saas/components/shared/config/configSections/Overview.tsx b/frontend/src/saas/components/shared/config/configSections/Overview.tsx index 90e89c6768..d535fdc2a7 100644 --- a/frontend/src/saas/components/shared/config/configSections/Overview.tsx +++ b/frontend/src/saas/components/shared/config/configSections/Overview.tsx @@ -149,8 +149,8 @@ const Overview: React.FC = ({ 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 = ({ 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 = ({ 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} diff --git a/frontend/src/saas/components/shared/config/configSections/PasswordSecurity.tsx b/frontend/src/saas/components/shared/config/configSections/PasswordSecurity.tsx index e0ff18ddc4..55ab3cdc2b 100644 --- a/frontend/src/saas/components/shared/config/configSections/PasswordSecurity.tsx +++ b/frontend/src/saas/components/shared/config/configSections/PasswordSecurity.tsx @@ -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); } diff --git a/frontend/src/saas/components/shared/config/configSections/apiKeys/hooks/useApiKey.ts b/frontend/src/saas/components/shared/config/configSections/apiKeys/hooks/useApiKey.ts index 22e4c51714..870700eb26 100644 --- a/frontend/src/saas/components/shared/config/configSections/apiKeys/hooks/useApiKey.ts +++ b/frontend/src/saas/components/shared/config/configSections/apiKeys/hooks/useApiKey.ts @@ -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("/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("/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("/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); } diff --git a/frontend/src/saas/components/shared/config/configSections/apiKeys/hooks/useCredits.ts b/frontend/src/saas/components/shared/config/configSections/apiKeys/hooks/useCredits.ts index 1b52e1b0f3..20c0a93866 100644 --- a/frontend/src/saas/components/shared/config/configSections/apiKeys/hooks/useCredits.ts +++ b/frontend/src/saas/components/shared/config/configSections/apiKeys/hooks/useCredits.ts @@ -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): 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>("/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); diff --git a/frontend/src/saas/components/shared/config/configSections/plan/AvailablePlansSection.tsx b/frontend/src/saas/components/shared/config/configSections/plan/AvailablePlansSection.tsx index 25f4322f0a..c8fd19321a 100644 --- a/frontend/src/saas/components/shared/config/configSections/plan/AvailablePlansSection.tsx +++ b/frontend/src/saas/components/shared/config/configSections/plan/AvailablePlansSection.tsx @@ -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 }>; diff --git a/frontend/src/saas/components/shared/config/configSections/plan/PlanCard.tsx b/frontend/src/saas/components/shared/config/configSections/plan/PlanCard.tsx index 9551f358fb..de02c959ab 100644 --- a/frontend/src/saas/components/shared/config/configSections/plan/PlanCard.tsx +++ b/frontend/src/saas/components/shared/config/configSections/plan/PlanCard.tsx @@ -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 = ({ 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 diff --git a/frontend/src/saas/constants/app.ts b/frontend/src/saas/constants/app.ts index dbe845aaed..90a130c76c 100644 --- a/frontend/src/saas/constants/app.ts +++ b/frontend/src/saas/constants/app.ts @@ -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; }; diff --git a/frontend/src/saas/hooks/useEndpointConfig.ts b/frontend/src/saas/hooks/useEndpointConfig.ts index 5da0fdd2af..c98dec5966 100644 --- a/frontend/src/saas/hooks/useEndpointConfig.ts +++ b/frontend/src/saas/hooks/useEndpointConfig.ts @@ -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) => { diff --git a/frontend/src/saas/routes/AuthCallback.tsx b/frontend/src/saas/routes/AuthCallback.tsx index 8738a64509..7b596d7858 100644 --- a/frontend/src/saas/routes/AuthCallback.tsx +++ b/frontend/src/saas/routes/AuthCallback.tsx @@ -7,7 +7,7 @@ import { withBasePath } from '@app/constants/app' interface CallbackState { status: 'processing' | 'success' | 'error' message: string - details?: Record + details?: Record } export default function AuthCallback() { diff --git a/frontend/src/saas/routes/Login.tsx b/frontend/src/saas/routes/Login.tsx index 9347cfeef9..6a0109d583 100644 --- a/frontend/src/saas/routes/Login.tsx +++ b/frontend/src/saas/routes/Login.tsx @@ -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 } = { 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 diff --git a/frontend/src/saas/routes/Signup.tsx b/frontend/src/saas/routes/Signup.tsx index 94df229650..24a83c0c5f 100644 --- a/frontend/src/saas/routes/Signup.tsx +++ b/frontend/src/saas/routes/Signup.tsx @@ -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 diff --git a/frontend/src/saas/routes/login/OAuthButtons.tsx b/frontend/src/saas/routes/login/OAuthButtons.tsx index aeb6c337e6..e53bd3cf06 100644 --- a/frontend/src/saas/routes/login/OAuthButtons.tsx +++ b/frontend/src/saas/routes/login/OAuthButtons.tsx @@ -32,7 +32,7 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = ' position="top" >