mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Complete legacy-credits teardown ("Group 3"). The per-user/per-team
credit model is fully superseded by PAYG (`wallet_ledger`) — confirmed
no PAYG code references it. Authorized to also remove the `TeamCredit`
pool + its monthly reset.
## Frontend (saas)
- Deleted `saas/hooks/useCredits.ts`, `apiKeys/hooks/useCredits.ts`,
`types/credits.ts`, `apiKeys/UsageSection.tsx`.
- `UseSession.tsx`: removed credit members (`creditBalance`,
`creditSummary`, `hasSufficientCredits`, `updateCredits`,
`refreshCredits`, `fetchCredits`) + the credit types + global
credit-update callback. **Kept** `isPro`/`refreshProStatus` and the
Supabase auth subscription listener.
- `services/apiClient.ts`: removed the dead `x-credits-remaining`
handler + low-credit plumbing (token-refresh / PAYG / 401 logic
untouched).
- Credit refs removed from `ApiKeys.tsx`, `AppConfigModal.tsx`,
`auth/teamSession.ts`.
## Java (:saas)
**Deleted (15):** `UserCredit`(+repo),
`TeamCredit`(+repo)+`TeamCreditService`, `CreditService`,
`CreditHeaderUtils`, `CreditResetScheduler`, `CreditController`,
`CreditInterceptorConfig`, `UnifiedCreditInterceptor`,
`CreditSuccessAdvice`, `CreditErrorAdvice`, `CreditConsumptionResult` (+
the CreditController test).
**Edited — stripped legacy credit side-effects, preserved
auth/role/AI/PAYG logic:**
- `AiCreate`/`AiProxyController`: dropped the
`X-Credits-Remaining`/`X-Credit-Source` response header (its only
consumer, the desktop credit system, was already removed).
- `SaasTeamService`: dropped UserCredit/TeamCredit init on team-create +
seat-update.
- `SupabaseAuthenticationFilter` / `SupabaseSecurityConfig`: dropped
`getOrCreateUserCredits` on signup + the credit field/CORS header.
- `UserRoleService`: dropped `resetCycleAllocationForRoleChange`;
`ROLE_PRO_USER` grant/revoke preserved.
- proprietary `UserRepository`: dropped
`findUsersWithApiKeyButNoCredits()`.
- Tests updated to drop credit mocks/refs.
## Kept / scope
- `isPro` / `is_pro` RPC / `ROLE_PRO_USER` (that's the separate Group-4
/ EE effort) and **all PAYG** are untouched.
- **No DB tables dropped.** `user_credits`/`team_credits` stay until a
later **gated** migration — which this PR unblocks (the JPA entities
that pinned them are gone).
## Verify
`:saas:compileJava` + `:saas:compileTestJava` pass; FE `tsc --noEmit`
(saas) + eslint clean; 0 stray artifacts; no residual source refs to the
deleted classes.
## Follow-up (not in this PR)
`ErrorTrackingService` (+
`UserErrorTracker`/`ProcessingErrorType`/`CreditsProperties`) is now a
dead island — its only callers were the deleted interceptors. Safe to
delete, but it cascades beyond the credit scope, so it's a separate
tidy-up.
Targets `feat/desktop-cloud-saas-reuse`.
539 lines
17 KiB
TypeScript
539 lines
17 KiB
TypeScript
import {
|
|
createContext,
|
|
useContext,
|
|
useEffect,
|
|
useState,
|
|
ReactNode,
|
|
useCallback,
|
|
} from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { TFunction } from "i18next";
|
|
import { supabase } from "@app/auth/supabase";
|
|
import type {
|
|
Session,
|
|
User as SupabaseUser,
|
|
AuthError,
|
|
} from "@supabase/supabase-js";
|
|
import { synchronizeUserUpgrade } from "@app/services/userService";
|
|
import {
|
|
syncOAuthAvatar,
|
|
getProfilePictureMetadata,
|
|
getProviderAvatarUrl,
|
|
type ProfilePictureMetadata,
|
|
} from "@app/services/avatarSyncService";
|
|
|
|
// Extend Supabase User to include optional username for compatibility
|
|
export type User = SupabaseUser & { username?: string };
|
|
|
|
/**
|
|
* Derive a display name from the Supabase user. Prefers the OAuth-provided
|
|
* full_name / name, then the email. Anonymous users get the localised
|
|
* "Guest" placeholder (SaaS's chosen label for guest sessions); returns
|
|
* null only when there is no user object at all so consumers can pick
|
|
* their own fallback.
|
|
*
|
|
* Exported for unit testing.
|
|
*/
|
|
export function deriveDisplayName(
|
|
user: User | null | undefined,
|
|
t: TFunction,
|
|
): string | null {
|
|
if (!user) return null;
|
|
if (user.is_anonymous) return t("auth.displayName.guest", "Guest");
|
|
const metadata = user.user_metadata as
|
|
| { full_name?: string; name?: string }
|
|
| undefined;
|
|
return (
|
|
user.username || metadata?.full_name || metadata?.name || user.email || null
|
|
);
|
|
}
|
|
|
|
interface AuthContextType {
|
|
session: Session | null;
|
|
user: User | null;
|
|
/**
|
|
* Human-readable name to show in the UI for the current session.
|
|
* - A real identity (full_name / name / email) when the user is signed in.
|
|
* - The localised "Guest" placeholder for anonymous (Supabase
|
|
* `is_anonymous`) sessions - SaaS's chosen label, see deriveDisplayName.
|
|
* - null only when there is no user object at all (signed-out), so
|
|
* consumers can fall back to whatever makes sense.
|
|
*/
|
|
displayName: string | null;
|
|
/** Whether the current session is an anonymous (Supabase `is_anonymous`) guest. */
|
|
isAnonymous: boolean;
|
|
loading: boolean;
|
|
error: AuthError | null;
|
|
isPro: boolean | null;
|
|
profilePictureUrl: string | null;
|
|
profilePictureMetadata: ProfilePictureMetadata | null;
|
|
signOut: () => Promise<void>;
|
|
refreshSession: () => Promise<void>;
|
|
refreshProStatus: () => Promise<void>;
|
|
refreshProfilePicture: () => Promise<void>;
|
|
refreshProfilePictureMetadata: () => Promise<void>;
|
|
}
|
|
|
|
const AuthContext = createContext<AuthContextType>({
|
|
session: null,
|
|
user: null,
|
|
displayName: null,
|
|
isAnonymous: false,
|
|
loading: true,
|
|
error: null,
|
|
isPro: null,
|
|
profilePictureUrl: null,
|
|
profilePictureMetadata: null,
|
|
signOut: async () => {},
|
|
refreshSession: async () => {},
|
|
refreshProStatus: async () => {},
|
|
refreshProfilePicture: async () => {},
|
|
refreshProfilePictureMetadata: async () => {},
|
|
});
|
|
|
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
const [session, setSession] = useState<Session | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<AuthError | null>(null);
|
|
const [isPro, setIsPro] = useState<boolean | null>(null);
|
|
const [profilePictureUrl, setProfilePictureUrl] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [profilePictureMetadata, setProfilePictureMetadata] =
|
|
useState<ProfilePictureMetadata | null>(null);
|
|
|
|
const fetchProStatus = useCallback(
|
|
async (sessionToUse?: Session | null) => {
|
|
const currentSession = sessionToUse ?? session;
|
|
|
|
if (!currentSession?.user) {
|
|
console.debug(
|
|
"[Auth Debug] No user session, skipping pro status fetch",
|
|
);
|
|
setIsPro(null);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
console.debug(
|
|
"[Auth Debug] Fetching pro status for user:",
|
|
currentSession.user.id,
|
|
);
|
|
const { data: proStatus, error } = await supabase.rpc("is_pro");
|
|
|
|
if (error) {
|
|
console.error("[Auth Debug] Error checking Pro status:", error);
|
|
setIsPro(false); // Default to false if there's an error
|
|
} else {
|
|
const isProUser = Boolean(proStatus);
|
|
setIsPro(isProUser);
|
|
console.debug("[Auth Debug] Pro status fetched:", isProUser);
|
|
}
|
|
} catch (error: unknown) {
|
|
console.debug("[Auth Debug] Failed to fetch pro status:", error);
|
|
setIsPro(false); // Default to false if there's an error
|
|
}
|
|
},
|
|
[session],
|
|
);
|
|
|
|
const refreshProStatus = useCallback(async () => {
|
|
await fetchProStatus();
|
|
}, [fetchProStatus]);
|
|
|
|
// Provider photo as interim fallback when the bucket copy is missing —
|
|
// skipped when the user explicitly chose upload/removal (source "upload").
|
|
const providerAvatarFallback = useCallback(
|
|
async (user: SupabaseUser): Promise<string | null> => {
|
|
try {
|
|
const metadata = await getProfilePictureMetadata(user.id);
|
|
if (metadata?.source === "upload") return null;
|
|
return getProviderAvatarUrl(user);
|
|
} catch {
|
|
return getProviderAvatarUrl(user);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const fetchProfilePicture = useCallback(
|
|
async (sessionToUse?: Session | null) => {
|
|
const currentSession = sessionToUse ?? session;
|
|
|
|
if (!currentSession?.user) {
|
|
console.debug(
|
|
"[Auth Debug] No user session, skipping profile picture fetch",
|
|
);
|
|
setProfilePictureUrl(null);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const PROFILE_BUCKET = "profile-pictures";
|
|
const profilePath = `${currentSession.user.id}/avatar`;
|
|
|
|
console.debug(
|
|
"[Auth Debug] Fetching profile picture for user:",
|
|
currentSession.user.id,
|
|
);
|
|
const { data, error } = await supabase.storage
|
|
.from(PROFILE_BUCKET)
|
|
.createSignedUrl(profilePath, 60 * 60);
|
|
|
|
if (error) {
|
|
// Profile picture not found is expected for users without uploads
|
|
console.debug(
|
|
"[Auth Debug] Profile picture not available:",
|
|
error.message,
|
|
);
|
|
setProfilePictureUrl(
|
|
await providerAvatarFallback(currentSession.user),
|
|
);
|
|
} else {
|
|
setProfilePictureUrl(data.signedUrl);
|
|
console.debug(
|
|
"[Auth Debug] Profile picture URL fetched successfully",
|
|
);
|
|
}
|
|
} catch (error: unknown) {
|
|
console.debug("[Auth Debug] Failed to fetch profile picture:", error);
|
|
setProfilePictureUrl(await providerAvatarFallback(currentSession.user));
|
|
}
|
|
},
|
|
[session, providerAvatarFallback],
|
|
);
|
|
|
|
const refreshProfilePicture = useCallback(async () => {
|
|
await fetchProfilePicture();
|
|
}, [fetchProfilePicture]);
|
|
|
|
const fetchProfilePictureMetadata = useCallback(
|
|
async (sessionToUse?: Session | null) => {
|
|
const currentSession = sessionToUse ?? session;
|
|
|
|
if (!currentSession?.user) {
|
|
console.debug(
|
|
"[Auth Debug] No user session, skipping profile picture metadata fetch",
|
|
);
|
|
setProfilePictureMetadata(null);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
console.debug(
|
|
"[Auth Debug] Fetching profile picture metadata for user:",
|
|
currentSession.user.id,
|
|
);
|
|
const metadata = await getProfilePictureMetadata(
|
|
currentSession.user.id,
|
|
);
|
|
setProfilePictureMetadata(metadata);
|
|
console.debug(
|
|
"[Auth Debug] Profile picture metadata fetched:",
|
|
metadata,
|
|
);
|
|
} catch (error: unknown) {
|
|
console.debug(
|
|
"[Auth Debug] Failed to fetch profile picture metadata:",
|
|
error,
|
|
);
|
|
setProfilePictureMetadata(null);
|
|
}
|
|
},
|
|
[session],
|
|
);
|
|
|
|
const refreshProfilePictureMetadata = useCallback(async () => {
|
|
await fetchProfilePictureMetadata();
|
|
}, [fetchProfilePictureMetadata]);
|
|
|
|
const refreshSession = async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const { data, error } = await supabase.auth.refreshSession();
|
|
|
|
if (error) {
|
|
console.error("[Auth Debug] Session refresh error:", error);
|
|
setError(error);
|
|
setSession(null);
|
|
} else {
|
|
console.debug("[Auth Debug] Session refreshed successfully");
|
|
setSession(data.session);
|
|
}
|
|
} catch (err) {
|
|
console.error(
|
|
"[Auth Debug] Unexpected error during session refresh:",
|
|
err,
|
|
);
|
|
setError(err as AuthError);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const signOut = async () => {
|
|
try {
|
|
setError(null);
|
|
const { error } = await supabase.auth.signOut();
|
|
|
|
if (error) {
|
|
console.error("[Auth Debug] Sign out error:", error);
|
|
setError(error);
|
|
} else {
|
|
console.debug("[Auth Debug] Signed out successfully");
|
|
setSession(null);
|
|
}
|
|
} catch (err) {
|
|
console.error("[Auth Debug] Unexpected error during sign out:", err);
|
|
setError(err as AuthError);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
|
|
// Load current session on first mount
|
|
const initializeAuth = async () => {
|
|
try {
|
|
console.debug("[Auth Debug] Initializing auth...");
|
|
const { data, error } = await supabase.auth.getSession();
|
|
|
|
if (!mounted) return;
|
|
|
|
if (error) {
|
|
console.error("[Auth Debug] Initial session error:", error);
|
|
setError(error);
|
|
} else {
|
|
console.debug("[Auth Debug] Initial session loaded:", {
|
|
hasSession: !!data.session,
|
|
userId: data.session?.user?.id,
|
|
email: data.session?.user?.email,
|
|
});
|
|
setSession(data.session);
|
|
|
|
// Fetch pro status, profile picture metadata, and profile picture using the session from the response
|
|
if (data.session?.user) {
|
|
// Sync OAuth avatar in background; fetch the picture once the
|
|
// sync settles instead of guessing with a fixed delay.
|
|
syncOAuthAvatar(data.session.user)
|
|
.catch((err) => {
|
|
console.debug(
|
|
"[Auth Debug] Failed to sync OAuth avatar on init:",
|
|
err,
|
|
);
|
|
return false;
|
|
})
|
|
.then(() => fetchProfilePicture(data.session));
|
|
|
|
await fetchProStatus(data.session);
|
|
await fetchProfilePictureMetadata(data.session);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error(
|
|
"[Auth Debug] Unexpected error during auth initialization:",
|
|
err,
|
|
);
|
|
if (mounted) {
|
|
setError(err as AuthError);
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
initializeAuth();
|
|
|
|
// Subscribe to auth state changes
|
|
const {
|
|
data: { subscription },
|
|
} = supabase.auth.onAuthStateChange(async (event, newSession) => {
|
|
if (!mounted) return;
|
|
|
|
console.debug("[Auth Debug] Auth state change:", {
|
|
event,
|
|
hasSession: !!newSession,
|
|
userId: newSession?.user?.id,
|
|
email: newSession?.user?.email,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
|
|
// Don't run supabase calls inside this callback; schedule them
|
|
setTimeout(() => {
|
|
if (mounted) {
|
|
setSession(newSession);
|
|
setError(null);
|
|
|
|
// Additional handling for specific events
|
|
if (event === "SIGNED_OUT") {
|
|
console.debug("[Auth Debug] User signed out, clearing session");
|
|
// Clear pro status, profile picture, and metadata on sign out
|
|
setIsPro(null);
|
|
setProfilePictureUrl(null);
|
|
setProfilePictureMetadata(null);
|
|
} else if (event === "SIGNED_IN") {
|
|
console.debug("[Auth Debug] User signed in successfully");
|
|
if (newSession?.user) {
|
|
// Note: we deliberately do NOT toggle `loading` here. Supabase
|
|
// also fires SIGNED_IN on tab visibility / token-refresh wakeups
|
|
// (per its docs: "SIGNED_IN is fired when a user signs in OR
|
|
// when the access token is refreshed"), and gating the UI on
|
|
// `loading` would unmount Landing -> HomePage every time the
|
|
// user switches tabs back. Initial-mount loading is handled by
|
|
// `initializeAuth` above; downstream fetches expose their own
|
|
// null/loading states.
|
|
|
|
// Sync OAuth avatar in background (don't block other fetches)
|
|
const avatarSync = syncOAuthAvatar(newSession.user).catch(
|
|
(err) => {
|
|
console.debug(
|
|
"[Auth Debug] Failed to sync OAuth avatar:",
|
|
err,
|
|
);
|
|
return false;
|
|
},
|
|
);
|
|
|
|
// Fetch user data in parallel
|
|
Promise.all([
|
|
fetchProStatus(newSession),
|
|
fetchProfilePictureMetadata(newSession),
|
|
]).then(() => {
|
|
// Fetch the picture once the avatar sync settles.
|
|
avatarSync.then(() => {
|
|
fetchProfilePicture(newSession).finally(() => {
|
|
console.debug(
|
|
"[Auth Debug] User data fully loaded after sign in",
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
} else if (event === "TOKEN_REFRESHED") {
|
|
console.debug("[Auth Debug] Token refreshed");
|
|
// Optionally refresh pro status, profile picture metadata, and profile picture on token refresh
|
|
if (newSession?.user) {
|
|
Promise.all([
|
|
fetchProStatus(newSession),
|
|
fetchProfilePictureMetadata(newSession),
|
|
fetchProfilePicture(newSession),
|
|
]).then(() => {
|
|
console.debug(
|
|
"[Auth Debug] User data refreshed after token refresh",
|
|
);
|
|
});
|
|
}
|
|
} else if (event === "USER_UPDATED") {
|
|
console.debug("[Auth Debug] User updated");
|
|
|
|
// Check if this is a pending OAuth upgrade completion
|
|
const pendingUpgrade = sessionStorage.getItem("pendingUpgrade");
|
|
const upgradeProvider = sessionStorage.getItem("upgradeProvider");
|
|
|
|
if (
|
|
pendingUpgrade &&
|
|
newSession?.user &&
|
|
newSession.user.is_anonymous === false
|
|
) {
|
|
console.debug(
|
|
"[Auth Debug] Processing pending OAuth upgrade:",
|
|
upgradeProvider,
|
|
);
|
|
|
|
// Clear the flags first to prevent loops
|
|
sessionStorage.removeItem("pendingUpgrade");
|
|
sessionStorage.removeItem("upgradeProvider");
|
|
|
|
// Synchronize with backend
|
|
synchronizeUserUpgrade(upgradeProvider || undefined)
|
|
.then(() => {
|
|
console.debug(
|
|
"[Auth Debug] User upgrade synchronized successfully",
|
|
);
|
|
|
|
// Refresh pro status, profile picture metadata, and profile picture after upgrade
|
|
if (newSession?.user) {
|
|
return Promise.all([
|
|
fetchProStatus(newSession),
|
|
fetchProfilePictureMetadata(newSession),
|
|
fetchProfilePicture(newSession),
|
|
]);
|
|
}
|
|
})
|
|
.then(() => {
|
|
console.debug(
|
|
"[Auth Debug] User data refreshed after upgrade",
|
|
);
|
|
})
|
|
.catch((err) => {
|
|
console.error(
|
|
"[Auth Debug] Failed to synchronize user upgrade:",
|
|
err,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}, 0);
|
|
});
|
|
|
|
return () => {
|
|
mounted = false;
|
|
subscription.unsubscribe();
|
|
};
|
|
}, []);
|
|
|
|
const { t } = useTranslation();
|
|
const user = session?.user ?? null;
|
|
const value: AuthContextType = {
|
|
session,
|
|
user,
|
|
displayName: deriveDisplayName(user, t),
|
|
isAnonymous: Boolean(user?.is_anonymous),
|
|
loading,
|
|
error,
|
|
isPro,
|
|
profilePictureUrl,
|
|
profilePictureMetadata,
|
|
signOut,
|
|
refreshSession,
|
|
refreshProStatus,
|
|
refreshProfilePicture,
|
|
refreshProfilePictureMetadata,
|
|
};
|
|
|
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
}
|
|
|
|
export function useAuth() {
|
|
const context = useContext(AuthContext);
|
|
|
|
if (context === undefined) {
|
|
throw new Error("useAuth must be used within an AuthProvider");
|
|
}
|
|
|
|
return context;
|
|
}
|
|
|
|
// Debug hook to expose auth state for debugging
|
|
export function useAuthDebug() {
|
|
const auth = useAuth();
|
|
|
|
useEffect(() => {
|
|
console.debug("[Auth Debug] Current auth state:", {
|
|
hasSession: !!auth.session,
|
|
hasUser: !!auth.user,
|
|
loading: auth.loading,
|
|
hasError: !!auth.error,
|
|
userId: auth.user?.id,
|
|
email: auth.user?.email,
|
|
provider: auth.user?.app_metadata?.provider,
|
|
});
|
|
}, [auth.session, auth.user, auth.loading, auth.error]);
|
|
|
|
return auth;
|
|
}
|