intermittent commit

This commit is contained in:
2026-07-14 13:22:10 +02:00
parent 8aa1943581
commit e6f7c45f0a
76 changed files with 6039 additions and 2188 deletions

View File

@@ -1,9 +1,9 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { fetchMe, updateProfile } from "./api/auth";
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform";
import { AUTH_REQUIRED_EVENT, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
import type { ApiSettings, AuthInfo, AuthTenant, AuthTenantMembership, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage";
import LoginModal from "./features/auth/LoginModal";
@@ -47,9 +47,9 @@ export default function App() {
saveApiSettings(next);
}
function updateAuth(next: AuthInfo | null, accessToken?: string) {
function updateAuth(next: AuthUpdate | null, accessToken?: string) {
const nextSettings = accessToken !== undefined ? { ...settings, accessToken } : settings;
setAuth(next ? normalizeAuthInfo(next) : null);
setAuth((current) => next ? normalizeAuthInfo(mergeAuthPayload(current, next)) : null);
if (accessToken !== undefined) {
setSettings(nextSettings);
saveApiSettings(nextSettings);
@@ -117,21 +117,26 @@ export default function App() {
useEffect(() => {
let cancelled = false;
setCheckingSession(true);
fetchMe(settings).
then((me) => {if (!cancelled) setAuth(normalizeAuthInfo(me));}).
catch(() => {
if (!cancelled) {
const cleared = { ...settings, accessToken: "" };
setSettings(cleared);
saveApiSettings(cleared);
setAuth(null);
setPlatformModules(null);
setRemoteWebModules([]);
async function bootstrapAuth() {
try {
const shellAuth = await fetchShellAuth(settings);
if (!cancelled) setAuth(normalizeAuthInfo(shellAuth));
} catch {
if (!cancelled) {
const cleared = { ...settings, accessToken: "" };
setSettings(cleared);
saveApiSettings(cleared);
setAuth(null);
setPlatformModules(null);
setRemoteWebModules([]);
}
} finally {
if (!cancelled) setCheckingSession(false);
}
}).
finally(() => {
if (!cancelled) setCheckingSession(false);
});
}
void bootstrapAuth();
return () => {cancelled = true;};
}, [settings.apiBaseUrl, settings.apiKey]);
@@ -180,11 +185,25 @@ export default function App() {
root.classList.toggle("ui-hide-help-hints", !preferences.show_inline_help_hints);
root.classList.toggle("ui-reduce-motion", preferences.reduce_motion);
root.classList.toggle("ui-no-sticky-section-sidebars", !preferences.sticky_section_sidebars);
if (preferences.theme === "system") {
delete root.dataset.theme;
} else {
root.dataset.theme = preferences.theme;
const systemDarkQuery = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
const applyTheme = () => {
const resolvedTheme = preferences.theme === "system" ?
systemDarkQuery?.matches ? "dark" : "light" :
preferences.theme;
root.dataset.theme = resolvedTheme;
root.dataset.themePreference = preferences.theme;
};
applyTheme();
if (preferences.theme !== "system" || !systemDarkQuery) {
return undefined;
}
systemDarkQuery.addEventListener("change", applyTheme);
return () => {
systemDarkQuery.removeEventListener("change", applyTheme);
};
}, [
auth?.user.ui_preferences?.compact_tables,
auth?.user.ui_preferences?.show_inline_help_hints,
@@ -208,8 +227,11 @@ export default function App() {
useEffect(() => {
if (!auth) return;
const currentAuth = auth;
let cancelled = false;
let inFlight = false;
let lastRefreshAt = 0;
let lastShellRefreshAt = Date.now();
async function refreshVisibleSession() {
if (document.visibilityState === "hidden" || inFlight) return;
@@ -219,7 +241,17 @@ export default function App() {
inFlight = true;
lastRefreshAt = now;
try {
setAuth(normalizeAuthInfo(await fetchMe(settings)));
const sessionInfo = await fetchSession(settings);
if (cancelled) return;
const shellRefreshDue = now - lastShellRefreshAt >= 60_000;
if (!sessionMatchesAuth(sessionInfo, currentAuth) || shellRefreshDue) {
const shellAuth = await fetchShellAuth(settings);
if (cancelled) return;
lastShellRefreshAt = Date.now();
setAuth((current) => current && sessionMatchesAuth(sessionInfo, current)
? normalizeAuthInfo(mergeAuthPayload(current, shellAuth))
: normalizeAuthInfo(shellAuth));
}
} catch {
@@ -230,6 +262,7 @@ export default function App() {
window.addEventListener("focus", refreshVisibleSession);
document.addEventListener("visibilitychange", refreshVisibleSession);
return () => {
cancelled = true;
window.removeEventListener("focus", refreshVisibleSession);
document.removeEventListener("visibilitychange", refreshVisibleSession);
};
@@ -327,18 +360,40 @@ export default function App() {
}
type AuthPayload = Partial<AuthInfo> & {
principal?: AuthInfo["principal"];
user?: Partial<AuthUser> | null;
tenant?: AuthTenant | null;
active_tenant?: AuthTenant | null;
tenants?: AuthTenantMembership[] | null;
};
type AuthPayload = AuthUpdate;
function mergeAuthPayload(current: AuthInfo | null, next: AuthPayload): AuthPayload {
if (!current) return next;
const nextActiveTenant = next.active_tenant ?? next.tenant ?? null;
const currentActiveTenant = current.active_tenant ?? current.tenant;
const tenantChanged = Boolean(nextActiveTenant && nextActiveTenant.id !== currentActiveTenant.id);
return {
...current,
...next,
user: next.user ? { ...current.user, ...next.user } : current.user,
tenant: nextActiveTenant ?? current.tenant,
active_tenant: nextActiveTenant ?? currentActiveTenant,
tenants: next.tenants ?? current.tenants,
scopes: next.scopes ?? current.scopes,
roles: next.roles ?? (next.roles_loaded === false || tenantChanged ? [] : current.roles),
groups: next.groups ?? (next.groups_loaded === false || tenantChanged ? [] : current.groups),
principal: next.principal === undefined ? current.principal : next.principal,
available_languages: next.available_languages ?? (tenantChanged ? undefined : current.available_languages),
enabled_language_codes: next.enabled_language_codes ?? (tenantChanged ? undefined : current.enabled_language_codes),
default_language: next.default_language ?? (tenantChanged ? undefined : current.default_language),
profile_loaded: next.profile_loaded ?? (tenantChanged ? false : current.profile_loaded),
roles_loaded: next.roles_loaded ?? (tenantChanged ? false : current.roles_loaded),
groups_loaded: next.groups_loaded ?? (tenantChanged ? false : current.groups_loaded)
};
}
function normalizeAuthInfo(response: AuthPayload): AuthInfo {
const principal = response.principal ?? null;
const activeTenant = response.active_tenant ?? response.tenant ?? response.tenants?.[0] ?? null;
const user = normalizeAuthUser(response.user, principal);
const profileLoaded = response.profile_loaded ?? hasFullProfilePayload(response);
const rolesLoaded = response.roles_loaded ?? response.roles !== undefined;
const groupsLoaded = response.groups_loaded ?? response.groups !== undefined;
if (!activeTenant) {
throw new Error("Authentication response did not include an active tenant.");
@@ -356,12 +411,25 @@ function normalizeAuthInfo(response: AuthPayload): AuthInfo {
roles: response.roles ?? [],
groups: response.groups ?? [],
principal,
available_languages: response.available_languages ?? [],
enabled_language_codes: response.enabled_language_codes ?? activeTenant.enabled_language_codes ?? [],
default_language: response.default_language ?? user.preferred_language ?? activeTenant.default_locale ?? "en"
available_languages: response.available_languages,
enabled_language_codes: profileLoaded ? response.enabled_language_codes ?? activeTenant.enabled_language_codes ?? [] : undefined,
default_language: profileLoaded ? response.default_language ?? user.preferred_language ?? activeTenant.default_locale ?? "en" : undefined,
profile_loaded: profileLoaded,
roles_loaded: rolesLoaded,
groups_loaded: groupsLoaded
};
}
function hasFullProfilePayload(response: AuthPayload): boolean {
return Boolean(
response.default_language ||
response.available_languages ||
response.enabled_language_codes ||
response.roles ||
response.groups
);
}
function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal: AuthInfo["principal"]): AuthUser | null {
if (user?.id && user.account_id) {
return {
@@ -396,6 +464,17 @@ function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal
};
}
function sessionMatchesAuth(sessionInfo: AuthSessionInfo, auth: AuthInfo): boolean {
const activeTenant = auth.active_tenant ?? auth.tenant;
if (sessionInfo.user.id !== auth.user.id) return false;
if (sessionInfo.user.account_id !== auth.user.account_id) return false;
if ((sessionInfo.active_tenant ?? sessionInfo.tenant).id !== activeTenant.id) return false;
if (auth.principal?.auth_method && sessionInfo.auth_method !== auth.principal.auth_method) return false;
if (auth.principal?.session_id && sessionInfo.session_id && auth.principal.session_id !== sessionInfo.session_id) return false;
if (auth.principal?.api_key_id && sessionInfo.api_key_id && auth.principal.api_key_id !== sessionInfo.api_key_id) return false;
return true;
}
function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences {
const theme = value?.theme === "light" || value?.theme === "dark" || value?.theme === "system" ? value.theme : "system";
return {