Files
freno-dev/src/context/auth.tsx
2026-01-07 14:37:40 -05:00

174 lines
4.7 KiB
TypeScript

/**
* Auth Context Provider
* Provides convenient access to auth state throughout the app
*
* Security Note:
* - Context is for UI display only (showing/hiding buttons, user email, etc.)
* - Server endpoints ALWAYS validate independently from cookies
* - Never trust client-side state for authorization decisions
*/
import {
createContext,
useContext,
createSignal,
onMount,
onCleanup,
createEffect,
Accessor,
ParentComponent
} from "solid-js";
import { createAsync, revalidate } from "@solidjs/router";
import { getUserState, type UserState } from "~/lib/auth-query";
import { tokenRefreshManager } from "~/lib/token-refresh";
interface AuthContextType {
/** Current user state (for UI display) */
userState: Accessor<UserState | undefined>;
/** Is user authenticated (convenience) */
isAuthenticated: Accessor<boolean>;
/** User email (null if not authenticated) */
email: Accessor<string | null>;
/** User display name (null if not set) */
displayName: Accessor<string | null>;
/** User ID (null if not authenticated) */
userId: Accessor<string | null>;
/** Is user admin (for UI display only - server still validates) */
isAdmin: Accessor<boolean>;
/** Is email verified */
isEmailVerified: Accessor<boolean>;
/** Refresh auth state from server */
refreshAuth: () => void;
}
const AuthContext = createContext<AuthContextType>();
export const AuthProvider: ParentComponent = (props) => {
// Signal to force re-fetch when auth state changes
const [refreshTrigger, setRefreshTrigger] = createSignal(0);
// Get server state via SolidStart query - tracks refreshTrigger for reactivity
const serverAuth = createAsync(
() => {
refreshTrigger(); // Track the signal to force re-run
return getUserState();
},
{ deferStream: true }
);
// Refresh callback that invalidates cache and forces re-fetch
const refreshAuth = () => {
revalidate(getUserState.key);
setRefreshTrigger((prev) => prev + 1); // Trigger re-fetch
};
// Server-side refresh in getUserState() handles auto-signin during SSR
// No client-side fallback needed - server handles everything with httpOnly cookies
// Listen for auth refresh events from external sources (token refresh, etc.)
onMount(() => {
if (typeof window === "undefined") return;
const handleAuthRefresh = () => {
console.log("[AuthContext] Received auth refresh event");
refreshAuth();
};
window.addEventListener("auth-state-changed", handleAuthRefresh);
onCleanup(() => {
window.removeEventListener("auth-state-changed", handleAuthRefresh);
});
});
// Convenience accessors with safe defaults
const isAuthenticated = () => serverAuth()?.isAuthenticated ?? false;
const email = () => serverAuth()?.email ?? null;
const displayName = () => serverAuth()?.displayName ?? null;
const userId = () => serverAuth()?.userId ?? null;
const isAdmin = () => serverAuth()?.privilegeLevel === "admin";
const isEmailVerified = () => serverAuth()?.emailVerified ?? false;
// Start/stop token refresh manager based on auth state
let previousAuth: boolean | undefined = undefined;
createEffect(() => {
const authenticated = isAuthenticated();
console.log(
`[AuthContext] createEffect triggered - authenticated: ${authenticated}, previousAuth: ${previousAuth}`
);
// Only act if auth state actually changed
if (authenticated === previousAuth) {
console.log("[AuthContext] Auth state unchanged, skipping");
return;
}
previousAuth = authenticated;
if (authenticated) {
console.log(
"[AuthContext] User authenticated, starting token refresh manager"
);
tokenRefreshManager.start(true);
} else {
console.log(
"[AuthContext] User not authenticated, stopping token refresh manager"
);
tokenRefreshManager.stop();
}
});
// Cleanup on unmount
onCleanup(() => {
tokenRefreshManager.stop();
});
const value: AuthContextType = {
userState: serverAuth,
isAuthenticated,
email,
displayName,
userId,
isAdmin,
isEmailVerified,
refreshAuth
};
return (
<AuthContext.Provider value={value}>{props.children}</AuthContext.Provider>
);
};
/**
* Hook to access auth state anywhere in the app
*
* @example
* ```tsx
* function MyComponent() {
* const { isAuthenticated, email, refreshAuth } = useAuth();
*
* return (
* <Show when={isAuthenticated()}>
* <p>Welcome, {email()}!</p>
* <button onClick={() => refreshAuth()}>Refresh</button>
* </Show>
* );
* }
* ```
*/
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}