mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
# Description of Changes Main fixes: - Fix the display of the username in the bottom left - Now displays as "User" when not logged in on self-hosted (desktop) and "Guest" on SaaS when logged in anonymously - Now updates properly when the user logs in/out in SaaS, desktop and self-hosted - Fix incremental build issues in the desktop app that have been here since the start (I hope at least - I think the issue is that the JLink is built read-only and then on subsequent builds you get OS errors when trying to override the JLink with the new version. There's no real need for it to be read-only that I know of, so we might as well just make it R/W and ship like that)
337 lines
10 KiB
TypeScript
337 lines
10 KiB
TypeScript
import {
|
|
createContext,
|
|
useContext,
|
|
useEffect,
|
|
useState,
|
|
ReactNode,
|
|
useCallback,
|
|
} from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { TFunction } from "i18next";
|
|
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
|
|
* Simplified version without SaaS-specific features (credits, subscriptions)
|
|
*/
|
|
interface AuthContextType {
|
|
session: Session | null;
|
|
user: User | null;
|
|
/**
|
|
* Human-readable name to show in the UI for the current session.
|
|
* - A real identity (username/email) when the user is signed in.
|
|
* - The localised "User" placeholder for anonymous sessions
|
|
* (proprietary'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;
|
|
loading: boolean;
|
|
error: AuthError | null;
|
|
signOut: () => Promise<void>;
|
|
refreshSession: () => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Derive a display name from the Spring user. Anonymous users get the
|
|
* localised "User" placeholder (proprietary's chosen label for unsigned-in
|
|
* 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.user", "User");
|
|
return user.username || user.email || null;
|
|
}
|
|
|
|
const AuthContext = createContext<AuthContextType>({
|
|
session: null,
|
|
user: null,
|
|
displayName: null,
|
|
loading: true,
|
|
error: null,
|
|
signOut: async () => {},
|
|
refreshSession: async () => {},
|
|
});
|
|
|
|
/**
|
|
* Auth Provider Component
|
|
*
|
|
* Manages authentication state and provides it to the entire app.
|
|
* Integrates with Spring Security + JWT backend.
|
|
*/
|
|
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);
|
|
|
|
// Debug: Track state transitions
|
|
useEffect(() => {
|
|
console.log("[Auth] State changed:", {
|
|
loading,
|
|
hasSession: !!session,
|
|
hasError: !!error,
|
|
userId: session?.user?.id,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}, [loading, session, error]);
|
|
|
|
/**
|
|
* Refresh current session
|
|
*/
|
|
const refreshSession = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
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);
|
|
setError(error);
|
|
setSession(null);
|
|
} else {
|
|
console.debug("[Auth] Session refreshed successfully");
|
|
setSession(data.session);
|
|
}
|
|
} catch (err) {
|
|
console.error("[Auth] Unexpected error during session refresh:", err);
|
|
setError(err as AuthError);
|
|
} finally {
|
|
console.debug("[Auth] refreshSession: done", { hasSession: !!session });
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
/**
|
|
* Sign out user
|
|
*/
|
|
const signOut = useCallback(async () => {
|
|
try {
|
|
setError(null);
|
|
console.debug("[Auth] Signing out...");
|
|
|
|
const { error } = await springAuth.signOut();
|
|
|
|
// Always clear the in-memory session: springAuth.signOut() removes the
|
|
// local token and platform user_info even when the backend POST fails,
|
|
// so the user is effectively signed out either way. Leaving session
|
|
// populated on error would mean the UI keeps the old user's badge until
|
|
// a manual reload (the SIGNED_OUT notifyListeners call also covers this
|
|
// path now, but clearing here is defence in depth).
|
|
setSession(null);
|
|
|
|
if (error) {
|
|
console.error("[Auth] Sign out error:", error);
|
|
setError(error);
|
|
} else {
|
|
console.debug("[Auth] Signed out successfully");
|
|
}
|
|
} catch (err) {
|
|
console.error("[Auth] Unexpected error during sign out:", err);
|
|
setSession(null);
|
|
setError(err as AuthError);
|
|
}
|
|
}, []);
|
|
|
|
/**
|
|
* Initialize auth on mount
|
|
*/
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
const mountId = Math.random().toString(36).substring(7);
|
|
console.log(`[Auth:${mountId}] 🔵 AuthProvider mounted`);
|
|
|
|
const initializeAuth = async () => {
|
|
try {
|
|
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")
|
|
) {
|
|
await clearPlatformAuthOnLoginInit();
|
|
}
|
|
|
|
// Skip config check entirely - let the app handle login state
|
|
// The config will be fetched by useAppConfig when needed
|
|
const { data, error } = await springAuth.getSession();
|
|
|
|
if (!mounted) return;
|
|
|
|
if (error) {
|
|
console.error("[Auth] Initial session error:", error);
|
|
setError(error);
|
|
} else {
|
|
console.debug("[Auth] Initial session loaded:", {
|
|
hasSession: !!data.session,
|
|
userId: data.session?.user?.id,
|
|
email: data.session?.user?.email,
|
|
});
|
|
setSession(data.session);
|
|
}
|
|
} catch (err) {
|
|
console.error(
|
|
"[Auth] Unexpected error during auth initialization:",
|
|
err,
|
|
);
|
|
if (mounted) {
|
|
setError(err as AuthError);
|
|
}
|
|
} finally {
|
|
console.debug(
|
|
`[Auth:${mountId}] Initialize auth complete. mounted=${mounted}`,
|
|
);
|
|
if (mounted) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
initializeAuth();
|
|
|
|
// Listen for jwt-available event (triggered by desktop auth or other sources)
|
|
const handleJwtAvailable = () => {
|
|
console.log(`[Auth:${mountId}] ════════════════════════════════════`);
|
|
console.log(`[Auth:${mountId}] 🔄 JWT available event received`);
|
|
console.log(
|
|
`[Auth:${mountId}] Current state: loading=${loading}, hasSession=${!!session}`,
|
|
);
|
|
console.log(
|
|
`[Auth:${mountId}] Setting loading=true to stabilize auth state`,
|
|
);
|
|
setLoading(true); // Prevent unstable renders during auth state transition
|
|
setError(null);
|
|
console.log(`[Auth:${mountId}] Refreshing session...`);
|
|
void initializeAuth();
|
|
};
|
|
|
|
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);
|
|
},
|
|
);
|
|
|
|
return () => {
|
|
console.log(`[Auth:${mountId}] 🔴 AuthProvider unmounting`);
|
|
mounted = false;
|
|
window.removeEventListener("jwt-available", handleJwtAvailable);
|
|
subscription.unsubscribe();
|
|
};
|
|
}, []);
|
|
|
|
const { t } = useTranslation();
|
|
const user = session?.user ?? null;
|
|
const value: AuthContextType = {
|
|
session,
|
|
user,
|
|
displayName: deriveDisplayName(user, t),
|
|
loading,
|
|
error,
|
|
signOut,
|
|
refreshSession,
|
|
};
|
|
|
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
}
|
|
|
|
/**
|
|
* Hook to access auth context
|
|
* Must be used within AuthProvider
|
|
*/
|
|
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
|
|
* Can be used in development to monitor auth state
|
|
*/
|
|
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,
|
|
});
|
|
}, [auth.session, auth.user, auth.loading, auth.error]);
|
|
|
|
return auth;
|
|
}
|