232 lines
8.1 KiB
JavaScript
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;
|