refactor(session): one session store for login, logout and 401

This commit is contained in:
lobo committed 2026-09-30 07:36:08 +02:00
1 parent 2a22789435
commit cbafa3d540
23 files changed
+246 -280

No files matched your search

+21 -36
View File
@@ -1,17 +1,16 @@
import {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
import {request} from '../utils/api.js';
import {SESSION_KEY, hasSession} from '../utils/auth.js';
import {hasSession} from '../utils/auth.js';
import useSession from '../hooks/useSession.js';
import {ME_URL, REQUEST_TIMEOUT_MS} from '../utils/settings.js';
const UserContext = createContext(null);
// Apart from the user, so a component that only reloads or clears it does not render again on each change.
const ActionsContext = createContext(null);
const CurrentUserContext = createContext(null);
/**
* GET /api/users/me for the whole app. `user` is undefined until the first answer and null without a session.
* `reload()` loads it again and resolves to the `request()` result (not ok for a 200 without a body).
* `updateUser(fn)` changes it locally after a save, and skips it when the user is gone by then; `setUser(null)`
* drops it. A save that replaces settings still loads /me itself (see saveSettings), as it must be fresh.
* `updateUser(fn)` changes it locally after a save, and skips it when the user is gone by then. It follows the
* session. A save that replaces settings still loads /me itself (see saveSettings), as it must be fresh.
*/
export function CurrentUserProvider({children}) {
const [user, setUser] = useState(() => (hasSession() ? undefined : null));
@@ -32,43 +31,29 @@ export function CurrentUserProvider({children}) {
return result;
}, []);
// A new stamp is a login, a "view as" or a settings save, also in another tab.
const {stamp} = useSession();
useEffect(() => {
if (hasSession()) reload();
// A login, logout or "view as" in another tab.
const onStorage = (event) => {
if (event.key !== null && event.key !== SESSION_KEY) return;
if (hasSession()) reload();
else setUser(null);
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, [reload]);
if (stamp) {
reload();
} else {
// A /me answer still on the way must not bring the user back.
latest.current += 1;
setUser(null);
}
}, [stamp, reload]);
const updateUser = useCallback((change) => setUser((current) => current && change(current)), []);
const actions = useMemo(() => ({reload, setUser, updateUser}), [reload, updateUser]);
const value = useMemo(() => ({user, reload, updateUser}), [user, reload, updateUser]);
return (
<ActionsContext.Provider value={actions}>
<UserContext.Provider value={user}>{children}</UserContext.Provider>
</ActionsContext.Provider>
);
}
const NO_ACTIONS = {reload: async () => ({ok: false, status: 0, data: null}), setUser: () => {}, updateUser: () => {}};
/**
* `{reload, setUser, updateUser}`, without the user. Outside a provider (e.g. a test of one component) they do
* nothing.
*/
export function useCurrentUserActions() {
return useContext(ActionsContext) ?? NO_ACTIONS;
return <CurrentUserContext.Provider value={value}>{children}</CurrentUserContext.Provider>;
}
/**
* `{user, reload, setUser, updateUser}`; outside a provider there is no user.
* `{user, reload, updateUser}`.
*/
export function useCurrentUserContext() {
const user = useContext(UserContext);
const actions = useContext(ActionsContext);
return {user: actions ? user : null, ...(actions ?? NO_ACTIONS)};
const value = useContext(CurrentUserContext);
if (!value) throw new Error('useCurrentUserContext must be used inside <CurrentUserProvider>');
return value;
}