/** * @file authAutomat.js * @description Central authentication and multi-tenant session manager. * Tracks current user, Google OAuth client ID, and active company selection. * * Upstream Triggers: * - `AuthBootstrap.jsx`: Calls `bootstrapSession(session)` on initial app load. * - `LoginPage.jsx`: Calls `loginWithGoogle(credential)` or `loginWithCredentials(body)`. * - `UserMenu.jsx`: Calls `switchCompany(companyId)` or `logout()`. * * Downstream Triggers: * Subscribed to by almost all company-scoped automats, orchestrating their loading * when authenticated (`authed` + `selectedCompanyId`) and reset on logout (`anon`): * - `companyAutomat`, `categoryAutomat`, `brandAutomat`, `unitAutomat`, `posterminalAutomat` * - `attributeAutomat`, `propertyAutomat`, `supplierAutomat`, `warehouseAutomat` * - `sseAutomat`: Disconnects/reconnects SSE stream to `/api/events` per company * - `chatAutomat`: Reconnects AI chat SSE stream and reloads conversations * - `articleIndexAutomat`: Resets the derived index on logout; loads it lazily from * the category views once the session is established * - `createListAutomat` instances (`deliveryAutomat`, `quoteAutomat`, * `orderAutomat`, `entityAutomat`, `customergroupAutomat`, `numberAutomat`) * - Grid history automats (`articleHistoryGridAutomat`, `globalHistoryGridAutomat`) * - `artikelAutomat`: Clears cached articles on logout */ import { Automat } from 'react-automat'; import { auth, company } from '../api'; /** * @typedef {Object} AuthUser * @property {string|number|null} [selectedCompanyId] Currently selected company. * @property {*} [key] Additional user properties returned by the API. */ /** * @typedef {Object} AuthState * @property {AuthUser|null} user Authenticated user, or `null` for anonymous sessions. * @property {string|null} googleClientId Google Identity Services client ID. * @property {'loading'|'anon'|'authed'} status Current authentication lifecycle state. */ /** Emitted after an authenticated session has been established. */ export const AUTH_SESSION_ESTABLISHED = 'auth/sessionEstablished'; /** Emitted after the active company has changed. */ export const AUTH_COMPANY_CHANGED = 'auth/companyChanged'; /** Emitted after the current user has logged out. */ export const AUTH_LOGGED_OUT = 'auth/loggedOut'; /** @type {AuthState} */ const initialState = { user: null, googleClientId: null, status: 'loading', }; let emit = () => { }; /** * Registers the application-level dispatcher used for authentication events. * * @param {(event: {type: string}) => void} dispatch Event dispatcher. * @returns {void} */ export function setAuthEventDispatcher(dispatch) { emit = dispatch; } function normalizePicture(picture) { if (!picture || typeof picture !== 'string') return null; if (picture.startsWith('http://') || picture.startsWith('https://') || picture.startsWith('data:')) { return picture; } const mime = picture.startsWith('/9j/') ? 'image/jpeg' : 'image/png'; return `data:${mime};base64,${picture}`; } /** * Merges API user data while retaining the current company selection when the * response does not contain one. * * @param {AuthUser|null} current Current user. * @param {AuthUser|null|undefined} incoming User data returned by the API. * @returns {AuthUser|null} Merged user. */ function mergeUser(current, incoming) { if (!incoming) return current; const selectedCompanyId = incoming.selectedCompanyId ?? current?.selectedCompanyId ?? null; const merged = { ...current, ...incoming, selectedCompanyId }; if (merged.picture) { merged.picture = normalizePicture(merged.picture); } return merged; } let authAutomat; const actions = { /** * Restores an existing session or requests it from the authentication API. * * @param {{user?: AuthUser|null, googleClientId?: string|null}|null} [session] * Optional preloaded session. * @returns {Promise} Resolved session response. */ async bootstrapSession(session) { try { const nextSession = session ?? await auth.me(); const googleClientId = nextSession.googleClientId ?? authAutomat.state.googleClientId; if (!nextSession.user) { if (authAutomat.state.status !== 'authed') { authAutomat.setState({ user: null, googleClientId, status: 'anon' }); } else { authAutomat.setState({ googleClientId }); } return nextSession; } const user = mergeUser(authAutomat.state.user, nextSession.user); authAutomat.setState({ user, googleClientId, status: 'authed' }); emit({ type: AUTH_SESSION_ESTABLISHED }); return nextSession; } catch (error) { if (authAutomat.state.status === 'loading') { authAutomat.setState({ user: null, status: 'anon' }); } throw error; } }, /** * Exchanges a Google credential for an authenticated application session. * * @param {string} credential Google Identity Services credential. * @returns {Promise} Authenticated session. */ async loginWithGoogle(credential) { try { const { session } = await auth.loginWithGoogle(credential); if (!session?.user) { throw new Error('Login ok but session cookie not accepted (/api/auth/me still unauthorized)'); } const user = mergeUser(authAutomat.state.user, session.user); authAutomat.setState({ user, googleClientId: session.googleClientId ?? authAutomat.state.googleClientId, status: 'authed', }); emit({ type: AUTH_SESSION_ESTABLISHED }); return session; } catch (error) { authAutomat.setState({ user: null, status: 'anon' }); throw error; } }, /** * Selects the company used by the current session. * * @param {string|number} companyId Company identifier. * @returns {Promise} Updated user. */ async selectCompany(companyId) { const data = await company.select(companyId); const user = mergeUser( authAutomat.state.user, data.user ?? { selectedCompanyId: companyId }, ); authAutomat.setState({ user, status: 'authed' }); emit({ type: AUTH_COMPANY_CHANGED }); return user; }, /** * Creates a company and selects it for the current user. * * @param {Object} payload Company creation payload. * @returns {Promise} Updated user. */ async createCompany(payload) { const data = await company.create(payload); let incoming = data.user; if (!incoming && data.company?.id) { const selected = await company.select(data.company.id); incoming = selected.user ?? { selectedCompanyId: data.company.id }; } if (!incoming) { throw new Error('Unternehmen angelegt, aber keine Benutzerdaten erhalten.'); } const user = mergeUser(authAutomat.state.user, incoming); authAutomat.setState({ user, status: 'authed' }); emit({ type: AUTH_COMPANY_CHANGED }); return user; }, /** * Ends the server session, clears the current user, and returns to `/`. * * @returns {Promise} */ async logout() { await auth.logout(); window.history.replaceState(null, '', '/'); authAutomat.setState({ user: null, status: 'anon' }); emit({ type: AUTH_LOGGED_OUT }); }, }; /** * Global authentication state container. * * @type {Automat} */ authAutomat = new Automat(initialState, actions, { name: 'auth' }); /** * Re-reads `GET /api/auth/me` from the server. * * Called after login, logout, and company switches so the automat reflects * the current server session rather than an optimistic guess. * * @returns {Promise<{user: AuthUser|null, googleClientId: string|null}>} Session. */ export function bootstrapAuthMe() { return auth.me(); } export default authAutomat;