Files
dashfront/src/automats/authAutomat.js
2026-10-04 23:38:46 +02:00

232 lines
8.1 KiB
JavaScript

/**
* @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<Object>} 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<Object>} 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<AuthUser>} 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<AuthUser>} 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<void>}
*/
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<AuthState>}
*/
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;