diff --git a/src/context/CurrentUserContext.jsx b/src/context/CurrentUserContext.jsx index 6cf250d..2c8329b 100644 --- a/src/context/CurrentUserContext.jsx +++ b/src/context/CurrentUserContext.jsx @@ -1,9 +1,8 @@ -import { createContext, useContext, useEffect, useState } from 'react'; -import { getCurrentUser, login as apiLogin, refreshToken } from '../services/api'; -import { getToken, setToken, clearToken, getTokenExpiry } from '../services/authStorage'; -import { CHECK_INTERVAL_MS, shouldRefresh } from '../services/sessionKeepAlive'; +import { createContext, useContext } from 'react'; -const CurrentUserContext = createContext({ +// The provider lives in CurrentUserProvider.jsx: keeping components and plain +// exports in separate files lets Vite's fast refresh work for both. +export const CurrentUserContext = createContext({ user: null, tenant: null, roles: [], @@ -16,104 +15,4 @@ const CurrentUserContext = createContext({ logout: () => {}, }); -const emptyState = { - user: null, - tenant: null, - roles: [], - permissions: [], - loading: false, - error: null, -}; - -/** - * Owns login/logout state and resolves the caller's identity, tenant, and - * permissions via GET /rbac/me (once on mount if a token is already stored, - * and again after every login()). - * - * `hasPermission` returns false while loading and on error (fail-hidden) — - * gated UI stays hidden rather than flashing in and then disappearing. - */ -export const CurrentUserProvider = ({ children }) => { - // Lazy initializer computes the correct starting shape synchronously - // (loading only when there's actually a token to resolve) instead of - // always starting at loading:true and having the mount effect - // synchronously flip it back to false when there's no token — that - // setState was pure derived state, not a real effect. - const [state, setState] = useState(() => - getToken() ? { ...emptyState, loading: true } : { ...emptyState, loading: false } - ); - - const refreshCurrentUser = async () => { - try { - const data = await getCurrentUser(); - setState({ - user: data.user, - tenant: data.tenant, - roles: data.roles, - permissions: data.permissions, - loading: false, - error: null, - }); - } catch (err) { - // A stale/expired token is already cleared by api.js on 401 — - // just fall back to the logged-out shape without retrying. - console.warn('[NCCRD] Could not resolve current user/tenant — permission-gated UI will stay hidden.', err); - setState({ ...emptyState, error: err.message }); - } - }; - - useEffect(() => { - if (getToken()) { - refreshCurrentUser(); - } - }, []); - - // Renew the token while the user is actively working, so a long capture - // session never runs into the fixed token lifetime (see sessionKeepAlive.js). - useEffect(() => { - if (!state.user) return undefined; - let lastActivityAt = Date.now(); - const markActive = () => { lastActivityAt = Date.now(); }; - const events = ['keydown', 'pointerdown', 'input', 'scroll']; - events.forEach((e) => window.addEventListener(e, markActive, { passive: true, capture: true })); - - const timer = setInterval(async () => { - if (!shouldRefresh({ expiresAt: getTokenExpiry(), lastActivityAt, now: Date.now() })) return; - try { - setToken((await refreshToken()).access_token); - } catch (err) { - // Expired already: the next save will prompt to log in again. - console.warn('[NCCRD] Could not renew session.', err); - } - }, CHECK_INTERVAL_MS); - - return () => { - clearInterval(timer); - events.forEach((e) => window.removeEventListener(e, markActive, { capture: true })); - }; - }, [state.user]); - - const login = async (email, password) => { - const data = await apiLogin(email, password); - setToken(data.access_token); - await refreshCurrentUser(); - return data; - }; - - const logout = () => { - clearToken(); - setState({ ...emptyState, loading: false }); - }; - - const hasPermission = (name) => state.permissions.includes(name); - - return ( - - {children} - - ); -}; - export const useCurrentUser = () => useContext(CurrentUserContext); diff --git a/src/context/CurrentUserProvider.jsx b/src/context/CurrentUserProvider.jsx new file mode 100644 index 0000000..fd33cd6 --- /dev/null +++ b/src/context/CurrentUserProvider.jsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react'; +import { getCurrentUser, login as apiLogin, refreshToken } from '../services/api'; +import { getToken, setToken, clearToken, getTokenExpiry } from '../services/authStorage'; +import { CHECK_INTERVAL_MS, shouldRefresh } from '../services/sessionKeepAlive'; +import { CurrentUserContext } from './CurrentUserContext'; + +const emptyState = { + user: null, + tenant: null, + roles: [], + permissions: [], + loading: false, + error: null, +}; + +/** + * Owns login/logout state and resolves the caller's identity, tenant, and + * permissions via GET /rbac/me (once on mount if a token is already stored, + * and again after every login()). + * + * `hasPermission` returns false while loading and on error (fail-hidden) — + * gated UI stays hidden rather than flashing in and then disappearing. + */ +export const CurrentUserProvider = ({ children }) => { + // Lazy initializer computes the correct starting shape synchronously + // (loading only when there's actually a token to resolve) instead of + // always starting at loading:true and having the mount effect + // synchronously flip it back to false when there's no token — that + // setState was pure derived state, not a real effect. + const [state, setState] = useState(() => + getToken() ? { ...emptyState, loading: true } : { ...emptyState, loading: false } + ); + + const refreshCurrentUser = async () => { + try { + const data = await getCurrentUser(); + setState({ + user: data.user, + tenant: data.tenant, + roles: data.roles, + permissions: data.permissions, + loading: false, + error: null, + }); + } catch (err) { + // A stale/expired token is already cleared by api.js on 401 — + // just fall back to the logged-out shape without retrying. + console.warn('[NCCRD] Could not resolve current user/tenant — permission-gated UI will stay hidden.', err); + setState({ ...emptyState, error: err.message }); + } + }; + + useEffect(() => { + if (getToken()) { + refreshCurrentUser(); + } + }, []); + + // Renew the token while the user is actively working, so a long capture + // session never runs into the fixed token lifetime (see sessionKeepAlive.js). + useEffect(() => { + if (!state.user) return undefined; + let lastActivityAt = Date.now(); + const markActive = () => { lastActivityAt = Date.now(); }; + const events = ['keydown', 'pointerdown', 'input', 'scroll']; + events.forEach((e) => window.addEventListener(e, markActive, { passive: true, capture: true })); + + const timer = setInterval(async () => { + if (!shouldRefresh({ expiresAt: getTokenExpiry(), lastActivityAt, now: Date.now() })) return; + try { + setToken((await refreshToken()).access_token); + } catch (err) { + // Expired already: the next save will prompt to log in again. + console.warn('[NCCRD] Could not renew session.', err); + } + }, CHECK_INTERVAL_MS); + + return () => { + clearInterval(timer); + events.forEach((e) => window.removeEventListener(e, markActive, { capture: true })); + }; + }, [state.user]); + + const login = async (email, password) => { + const data = await apiLogin(email, password); + setToken(data.access_token); + await refreshCurrentUser(); + return data; + }; + + const logout = () => { + clearToken(); + setState({ ...emptyState, loading: false }); + }; + + const hasPermission = (name) => state.permissions.includes(name); + + return ( + + {children} + + ); +}; diff --git a/src/main.jsx b/src/main.jsx index 7dbc8da..a217220 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -2,7 +2,7 @@ import React from 'react' import ReactDOM from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App' -import { CurrentUserProvider } from './context/CurrentUserContext' +import { CurrentUserProvider } from './context/CurrentUserProvider' import './index.css' ReactDOM.createRoot(document.getElementById('root')).render(