AuthContext.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
  2. import { ApiError, api, getAuthToken, setAuthToken } from '../api/client';
  3. import type { LoginResponse, Permission, TokenPersistence, UserResponse } from '../api/client';
  4. interface AuthContextType {
  5. user: UserResponse | null;
  6. authEnabled: boolean;
  7. requiresSetup: boolean;
  8. loading: boolean;
  9. isAdmin: boolean;
  10. /** Login with username/password. Returns LoginResponse (may include requires_2fa). */
  11. login: (username: string, password: string, persistence?: TokenPersistence) => Promise<LoginResponse>;
  12. /** Finalise login after 2FA or OIDC — store token and set user directly. */
  13. loginWithToken: (token: string, user: UserResponse, persistence?: TokenPersistence) => void;
  14. logout: () => void;
  15. refreshUser: () => Promise<void>;
  16. refreshAuth: () => Promise<void>;
  17. hasPermission: (permission: Permission) => boolean;
  18. hasAnyPermission: (...permissions: Permission[]) => boolean;
  19. hasAllPermissions: (...permissions: Permission[]) => boolean;
  20. canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
  21. }
  22. const AuthContext = createContext<AuthContextType | undefined>(undefined);
  23. export function AuthProvider({ children }: { children: React.ReactNode }) {
  24. const [user, setUser] = useState<UserResponse | null>(null);
  25. const [authEnabled, setAuthEnabled] = useState(false);
  26. const [requiresSetup, setRequiresSetup] = useState(false);
  27. const [loading, setLoading] = useState(true);
  28. const hasRedirectedRef = useRef(false);
  29. const mountedRef = useRef(true);
  30. const checkAuthStatus = async () => {
  31. try {
  32. // Bootstrap: if URL has ?token= param, store it session-only first and
  33. // strip it from the URL. Allows SpoolBuddy kiosk to pass an API key via
  34. // URL on first load. Persistence to localStorage is deferred until the
  35. // token has been verified by the server (L-4: prevents session fixation
  36. // where an attacker-crafted URL immediately persists a forged/stolen token).
  37. const urlParams = new URLSearchParams(window.location.search);
  38. // Streaming pages own their scoped URL tokens. Consuming one as a login
  39. // token removes authentication on reload and can overwrite a user session.
  40. const isStreamingPage = /^\/(?:overlay\/[^/]+|camwall)\/?$/i.test(window.location.pathname);
  41. const urlToken = isStreamingPage ? null : urlParams.get('token');
  42. if (urlToken) {
  43. setAuthToken(urlToken, 'session'); // session-only until server confirms it's valid
  44. urlParams.delete('token');
  45. const cleanSearch = urlParams.toString();
  46. const cleanUrl = window.location.pathname
  47. + (cleanSearch ? `?${cleanSearch}` : '')
  48. + window.location.hash;
  49. window.history.replaceState({}, '', cleanUrl);
  50. }
  51. const status = await api.getAuthStatus();
  52. if (!mountedRef.current) return;
  53. setAuthEnabled(status.auth_enabled);
  54. setRequiresSetup(status.requires_setup);
  55. if (status.auth_enabled) {
  56. const token = getAuthToken();
  57. if (token) {
  58. // Validate the stored token. A transient failure here (backend not
  59. // yet ready after a container/proxy restart, a brief network blip)
  60. // must NOT discard a valid persisted token — doing so logs the user
  61. // out and, because the token is deleted, a reload can't recover it
  62. // (#1889). Only a definitive 401 invalid-token response clears the
  63. // token, and `request()` already does that clearing + dispatches
  64. // `auth:expired`; here we just retry the transient cases and keep the
  65. // token so the session survives a slow load.
  66. let currentUser: UserResponse | null = null;
  67. let definitiveAuthFailure = false;
  68. const maxAttempts = 3;
  69. for (let attempt = 1; attempt <= maxAttempts; attempt++) {
  70. try {
  71. currentUser = await api.getCurrentUser();
  72. break;
  73. } catch (err) {
  74. if (!mountedRef.current) return;
  75. // 401 invalid-token → genuinely logged out. `request()` has
  76. // already cleared the token; stop retrying.
  77. if (err instanceof ApiError && err.status === 401) {
  78. definitiveAuthFailure = true;
  79. break;
  80. }
  81. // Transient (network / 5xx / other) → back off and retry. Leave
  82. // the token in place so a subsequent load can recover.
  83. if (attempt < maxAttempts) {
  84. await new Promise((r) => setTimeout(r, 400 * attempt));
  85. }
  86. }
  87. }
  88. if (!mountedRef.current) return;
  89. if (currentUser) {
  90. setUser(currentUser);
  91. // Persist kiosk token only after the server confirms it is valid.
  92. if (urlToken && token === urlToken) {
  93. setAuthToken(urlToken, 'persistent');
  94. }
  95. } else {
  96. // No user: either a definitive 401 (token already cleared by
  97. // request()) or transient failures exhausted their retries. In the
  98. // transient case we deliberately keep the token so a reload retries
  99. // rather than forcing a re-login.
  100. if (definitiveAuthFailure) {
  101. setAuthToken(null);
  102. }
  103. setUser(null);
  104. }
  105. } else {
  106. setUser(null);
  107. }
  108. } else {
  109. // Auth not enabled, allow access
  110. setUser(null);
  111. }
  112. } catch {
  113. if (!mountedRef.current) return;
  114. setAuthEnabled(false);
  115. setUser(null);
  116. } finally {
  117. if (mountedRef.current) {
  118. setLoading(false);
  119. }
  120. }
  121. };
  122. useEffect(() => {
  123. mountedRef.current = true;
  124. // Check auth status on mount
  125. checkAuthStatus();
  126. // Listen for token-expiry events from the API client. setAuthToken(null)
  127. // in client.ts only clears storage; without this listener, `user` stays
  128. // populated and ProtectedRoute keeps rendering the protected tree until a
  129. // manual refresh — every request silently fails in the meantime (#1698).
  130. const handleAuthExpired = () => {
  131. if (!mountedRef.current) return;
  132. setUser(null);
  133. };
  134. window.addEventListener('auth:expired', handleAuthExpired);
  135. return () => {
  136. mountedRef.current = false;
  137. window.removeEventListener('auth:expired', handleAuthExpired);
  138. };
  139. }, []);
  140. // Separate effect to handle redirect only when setup is required
  141. useEffect(() => {
  142. // Only redirect if setup is truly required (first time setup)
  143. // Don't redirect if user manually navigated to /setup or is on camera page
  144. if (!loading && requiresSetup && !authEnabled) {
  145. const currentPath = window.location.pathname;
  146. // Only redirect if not already on setup page or camera page, and haven't redirected yet
  147. if (currentPath !== '/setup' && !currentPath.startsWith('/camera/') && !hasRedirectedRef.current) {
  148. hasRedirectedRef.current = true;
  149. window.location.href = '/setup';
  150. }
  151. } else if (!requiresSetup) {
  152. // Reset redirect flag when setup is no longer required
  153. hasRedirectedRef.current = false;
  154. }
  155. }, [loading, requiresSetup, authEnabled]);
  156. const login = async (username: string, password: string, persistence: TokenPersistence = 'session'): Promise<LoginResponse> => {
  157. const response = await api.login({ username, password });
  158. if (!response.requires_2fa && response.access_token) {
  159. setAuthToken(response.access_token, persistence);
  160. await checkAuthStatus();
  161. }
  162. return response;
  163. };
  164. const loginWithToken = (token: string, userObj: UserResponse, persistence: TokenPersistence = 'session') => {
  165. setAuthToken(token, persistence);
  166. setUser(userObj);
  167. setAuthEnabled(true);
  168. };
  169. const logout = () => {
  170. setAuthToken(null);
  171. setUser(null);
  172. api.logout().catch(() => {
  173. // Ignore logout errors
  174. });
  175. window.location.href = '/login';
  176. };
  177. const refreshUser = async () => {
  178. if (authEnabled && getAuthToken()) {
  179. try {
  180. const currentUser = await api.getCurrentUser();
  181. if (mountedRef.current) {
  182. setUser(currentUser);
  183. }
  184. } catch {
  185. setAuthToken(null);
  186. if (mountedRef.current) {
  187. setUser(null);
  188. }
  189. }
  190. }
  191. };
  192. const refreshAuth = async () => {
  193. await checkAuthStatus();
  194. };
  195. // Memoize permission set for efficient lookups
  196. const permissionSet = useMemo(() => {
  197. return new Set(user?.permissions ?? []);
  198. }, [user?.permissions]);
  199. // Computed admin status
  200. const isAdmin = useMemo(() => {
  201. if (!authEnabled) return true; // Auth disabled = admin access
  202. return user?.is_admin ?? false;
  203. }, [authEnabled, user?.is_admin]);
  204. // Permission check functions
  205. const hasPermission = useCallback((permission: Permission): boolean => {
  206. if (!authEnabled) return true; // Auth disabled = allow all
  207. if (isAdmin) return true; // Admins have all permissions
  208. return permissionSet.has(permission);
  209. }, [authEnabled, isAdmin, permissionSet]);
  210. const hasAnyPermission = useCallback((...permissions: Permission[]): boolean => {
  211. if (!authEnabled) return true;
  212. if (isAdmin) return true;
  213. return permissions.some(p => permissionSet.has(p));
  214. }, [authEnabled, isAdmin, permissionSet]);
  215. const hasAllPermissions = useCallback((...permissions: Permission[]): boolean => {
  216. if (!authEnabled) return true;
  217. if (isAdmin) return true;
  218. return permissions.every(p => permissionSet.has(p));
  219. }, [authEnabled, isAdmin, permissionSet]);
  220. // Ownership-based permission check
  221. const canModify = useCallback((
  222. resource: 'queue' | 'archives' | 'library',
  223. action: 'update' | 'delete' | 'reprint',
  224. createdById: number | null | undefined,
  225. ): boolean => {
  226. if (!authEnabled) return true; // Auth disabled, allow all
  227. if (isAdmin) return true; // Admins can modify anything
  228. const allPerm = `${resource}:${action}_all` as Permission;
  229. const ownPerm = `${resource}:${action}_own` as Permission;
  230. // User has *_all permission - can modify any item
  231. if (permissionSet.has(allPerm)) return true;
  232. // User has *_own permission - can only modify their own items
  233. if (permissionSet.has(ownPerm)) {
  234. // Ownerless items (null created_by_id) require *_all permission
  235. if (createdById == null) return false;
  236. return createdById === user?.id;
  237. }
  238. return false;
  239. }, [authEnabled, isAdmin, permissionSet, user?.id]);
  240. return (
  241. <AuthContext.Provider
  242. value={{
  243. user,
  244. authEnabled,
  245. requiresSetup,
  246. loading,
  247. isAdmin,
  248. login,
  249. loginWithToken,
  250. logout,
  251. refreshUser,
  252. refreshAuth,
  253. hasPermission,
  254. hasAnyPermission,
  255. hasAllPermissions,
  256. canModify,
  257. }}
  258. >
  259. {children}
  260. </AuthContext.Provider>
  261. );
  262. }
  263. export function useAuth() {
  264. const context = useContext(AuthContext);
  265. if (context === undefined) {
  266. throw new Error('useAuth must be used within an AuthProvider');
  267. }
  268. return context;
  269. }