AuthContext.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
  2. import { api, getAuthToken, setAuthToken } from '../api/client';
  3. import type { Permission, 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: (username: string, password: string) => Promise<void>;
  11. logout: () => void;
  12. refreshUser: () => Promise<void>;
  13. refreshAuth: () => Promise<void>;
  14. hasPermission: (permission: Permission) => boolean;
  15. hasAnyPermission: (...permissions: Permission[]) => boolean;
  16. hasAllPermissions: (...permissions: Permission[]) => boolean;
  17. canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
  18. }
  19. const AuthContext = createContext<AuthContextType | undefined>(undefined);
  20. export function AuthProvider({ children }: { children: React.ReactNode }) {
  21. const [user, setUser] = useState<UserResponse | null>(null);
  22. const [authEnabled, setAuthEnabled] = useState(false);
  23. const [requiresSetup, setRequiresSetup] = useState(false);
  24. const [loading, setLoading] = useState(true);
  25. const hasRedirectedRef = useRef(false);
  26. const mountedRef = useRef(true);
  27. const checkAuthStatus = async () => {
  28. try {
  29. const status = await api.getAuthStatus();
  30. if (!mountedRef.current) return;
  31. setAuthEnabled(status.auth_enabled);
  32. setRequiresSetup(status.requires_setup);
  33. if (status.auth_enabled) {
  34. const token = getAuthToken();
  35. if (token) {
  36. try {
  37. const currentUser = await api.getCurrentUser();
  38. if (!mountedRef.current) return;
  39. setUser(currentUser);
  40. } catch {
  41. // Token invalid, clear it
  42. setAuthToken(null);
  43. if (!mountedRef.current) return;
  44. setUser(null);
  45. }
  46. } else {
  47. setUser(null);
  48. }
  49. } else {
  50. // Auth not enabled, allow access
  51. setUser(null);
  52. }
  53. } catch {
  54. if (!mountedRef.current) return;
  55. setAuthEnabled(false);
  56. setUser(null);
  57. } finally {
  58. if (mountedRef.current) {
  59. setLoading(false);
  60. }
  61. }
  62. };
  63. useEffect(() => {
  64. mountedRef.current = true;
  65. // Check auth status on mount
  66. checkAuthStatus();
  67. return () => {
  68. mountedRef.current = false;
  69. };
  70. }, []);
  71. // Separate effect to handle redirect only when setup is required
  72. useEffect(() => {
  73. // Only redirect if setup is truly required (first time setup)
  74. // Don't redirect if user manually navigated to /setup or is on camera page
  75. if (!loading && requiresSetup && !authEnabled) {
  76. const currentPath = window.location.pathname;
  77. // Only redirect if not already on setup page or camera page, and haven't redirected yet
  78. if (currentPath !== '/setup' && !currentPath.startsWith('/camera/') && !hasRedirectedRef.current) {
  79. hasRedirectedRef.current = true;
  80. window.location.href = '/setup';
  81. }
  82. } else if (!requiresSetup) {
  83. // Reset redirect flag when setup is no longer required
  84. hasRedirectedRef.current = false;
  85. }
  86. }, [loading, requiresSetup, authEnabled]);
  87. const login = async (username: string, password: string) => {
  88. const response = await api.login({ username, password });
  89. setAuthToken(response.access_token);
  90. setUser(response.user);
  91. };
  92. const logout = () => {
  93. setAuthToken(null);
  94. setUser(null);
  95. api.logout().catch(() => {
  96. // Ignore logout errors
  97. });
  98. window.location.href = '/login';
  99. };
  100. const refreshUser = async () => {
  101. if (authEnabled && getAuthToken()) {
  102. try {
  103. const currentUser = await api.getCurrentUser();
  104. if (mountedRef.current) {
  105. setUser(currentUser);
  106. }
  107. } catch {
  108. setAuthToken(null);
  109. if (mountedRef.current) {
  110. setUser(null);
  111. }
  112. }
  113. }
  114. };
  115. const refreshAuth = async () => {
  116. await checkAuthStatus();
  117. };
  118. // Memoize permission set for efficient lookups
  119. const permissionSet = useMemo(() => {
  120. return new Set(user?.permissions ?? []);
  121. }, [user?.permissions]);
  122. // Computed admin status
  123. const isAdmin = useMemo(() => {
  124. if (!authEnabled) return true; // Auth disabled = admin access
  125. return user?.is_admin ?? false;
  126. }, [authEnabled, user?.is_admin]);
  127. // Permission check functions
  128. const hasPermission = useCallback((permission: Permission): boolean => {
  129. if (!authEnabled) return true; // Auth disabled = allow all
  130. if (isAdmin) return true; // Admins have all permissions
  131. return permissionSet.has(permission);
  132. }, [authEnabled, isAdmin, permissionSet]);
  133. const hasAnyPermission = useCallback((...permissions: Permission[]): boolean => {
  134. if (!authEnabled) return true;
  135. if (isAdmin) return true;
  136. return permissions.some(p => permissionSet.has(p));
  137. }, [authEnabled, isAdmin, permissionSet]);
  138. const hasAllPermissions = useCallback((...permissions: Permission[]): boolean => {
  139. if (!authEnabled) return true;
  140. if (isAdmin) return true;
  141. return permissions.every(p => permissionSet.has(p));
  142. }, [authEnabled, isAdmin, permissionSet]);
  143. // Ownership-based permission check
  144. const canModify = useCallback((
  145. resource: 'queue' | 'archives' | 'library',
  146. action: 'update' | 'delete' | 'reprint',
  147. createdById: number | null | undefined,
  148. ): boolean => {
  149. if (!authEnabled) return true; // Auth disabled, allow all
  150. if (isAdmin) return true; // Admins can modify anything
  151. const allPerm = `${resource}:${action}_all` as Permission;
  152. const ownPerm = `${resource}:${action}_own` as Permission;
  153. // User has *_all permission - can modify any item
  154. if (permissionSet.has(allPerm)) return true;
  155. // User has *_own permission - can only modify their own items
  156. if (permissionSet.has(ownPerm)) {
  157. // Ownerless items (null created_by_id) require *_all permission
  158. if (createdById == null) return false;
  159. return createdById === user?.id;
  160. }
  161. return false;
  162. }, [authEnabled, isAdmin, permissionSet, user?.id]);
  163. return (
  164. <AuthContext.Provider
  165. value={{
  166. user,
  167. authEnabled,
  168. requiresSetup,
  169. loading,
  170. isAdmin,
  171. login,
  172. logout,
  173. refreshUser,
  174. refreshAuth,
  175. hasPermission,
  176. hasAnyPermission,
  177. hasAllPermissions,
  178. canModify,
  179. }}
  180. >
  181. {children}
  182. </AuthContext.Provider>
  183. );
  184. }
  185. export function useAuth() {
  186. const context = useContext(AuthContext);
  187. if (context === undefined) {
  188. throw new Error('useAuth must be used within an AuthProvider');
  189. }
  190. return context;
  191. }