@tumbaland/frontend-core 1.14.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authService.d.ts +10 -0
- package/dist/authService.js +18 -3
- package/dist/components/AppErrorBoundary/AppErrorBoundary.d.ts +26 -0
- package/dist/components/AppErrorBoundary/AppErrorBoundary.js +25 -0
- package/dist/components/AppErrorBoundary/index.d.ts +2 -0
- package/dist/components/AppErrorBoundary/index.js +1 -0
- package/dist/components/AppLayout/AppLayout.d.ts +27 -0
- package/dist/components/AppLayout/AppLayout.js +45 -0
- package/dist/components/AppLayout/index.d.ts +2 -0
- package/dist/components/AppLayout/index.js +1 -0
- package/dist/components/AuthHeader/AuthHeader.d.ts +57 -0
- package/dist/components/AuthHeader/AuthHeader.js +135 -0
- package/dist/components/AuthHeader/index.d.ts +2 -0
- package/dist/components/AuthHeader/index.js +1 -0
- package/dist/components/ErrorPage/ErrorPage.d.ts +17 -0
- package/dist/components/ErrorPage/ErrorPage.js +19 -0
- package/dist/components/ErrorPage/index.d.ts +2 -0
- package/dist/components/ErrorPage/index.js +1 -0
- package/dist/components/Loader/Loader.d.ts +12 -0
- package/dist/components/Loader/Loader.js +15 -0
- package/dist/components/Loader/index.d.ts +2 -0
- package/dist/components/Loader/index.js +1 -0
- package/dist/components/LoginButton/LoginButton.d.ts +5 -0
- package/dist/components/LoginButton/LoginButton.js +15 -0
- package/dist/components/LoginButton/index.d.ts +2 -0
- package/dist/components/LoginButton/index.js +1 -0
- package/dist/components/NotificationsMenu/NotificationsMenu.d.ts +9 -0
- package/dist/components/NotificationsMenu/NotificationsMenu.js +29 -0
- package/dist/components/ProtectedRoute/ProtectedRoute.d.ts +33 -0
- package/dist/components/ProtectedRoute/ProtectedRoute.js +50 -0
- package/dist/components/ProtectedRoute/index.d.ts +2 -0
- package/dist/components/ProtectedRoute/index.js +1 -0
- package/dist/components/UnauthorizedPage/UnauthorizedPage.d.ts +5 -0
- package/dist/components/UnauthorizedPage/UnauthorizedPage.js +9 -0
- package/dist/components/UnauthorizedPage/index.d.ts +2 -0
- package/dist/components/UnauthorizedPage/index.js +1 -0
- package/dist/components/UsageMeter/UsageMeter.d.ts +35 -0
- package/dist/components/UsageMeter/UsageMeter.js +50 -0
- package/dist/components/UsageMeter/index.d.ts +4 -0
- package/dist/components/UsageMeter/index.js +2 -0
- package/dist/components/UsageMeter/useEntitlements.d.ts +27 -0
- package/dist/components/UsageMeter/useEntitlements.js +56 -0
- package/dist/components/tenant/TenantProvider.d.ts +11 -0
- package/dist/components/tenant/TenantProvider.js +53 -0
- package/dist/components/tenant/TenantSelector.d.ts +24 -0
- package/dist/components/tenant/TenantSelector.js +77 -0
- package/dist/components/tenant/index.d.ts +5 -0
- package/dist/components/tenant/index.js +3 -0
- package/dist/components/tenant/useTenant.d.ts +15 -0
- package/dist/components/tenant/useTenant.js +222 -0
- package/dist/components/tenant/useTenantDataRefresh.d.ts +6 -0
- package/dist/components/tenant/useTenantDataRefresh.js +28 -0
- package/dist/config/createConfigProvider.d.ts +44 -0
- package/dist/config/createConfigProvider.js +92 -0
- package/dist/federation.d.ts +46 -0
- package/dist/federation.js +37 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +34 -1
- package/dist/monitoring/batch.js +3 -3
- package/dist/monitoring/config.js +1 -4
- package/dist/monitoring/console.js +1 -1
- package/dist/plansService.js +1 -1
- package/dist/routing/embedContext.d.ts +31 -0
- package/dist/routing/embedContext.js +63 -0
- package/dist/routing/index.d.ts +12 -0
- package/dist/routing/index.js +12 -0
- package/dist/sessionStorage.d.ts +1 -1
- package/dist/sessionStorage.js +1 -1
- package/dist/theme/createTumbalandTheme.d.ts +38 -0
- package/dist/theme/createTumbalandTheme.js +245 -0
- package/dist/theme/index.d.ts +2 -0
- package/dist/theme/index.js +2 -0
- package/dist/theme/tokens.d.ts +169 -0
- package/dist/theme/tokens.js +162 -0
- package/package.json +28 -2
- package/dist/test/setup.d.ts +0 -9
- package/dist/test/setup.js +0 -26
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { TenantProvider, useTenantListener, useTenantContext } from './TenantProvider';
|
|
3
|
+
import { getCorrelationId, getSessionId, logger } from '../../monitoring';
|
|
4
|
+
import { TENANT_STORAGE_KEY } from '../../sessionStorage';
|
|
5
|
+
// Re-export for convenience
|
|
6
|
+
export { TenantProvider, useTenantListener, useTenantContext };
|
|
7
|
+
// Owned here, but declared in frontend-core so `logout()` can clear it — see
|
|
8
|
+
// the note on session-scoped storage there.
|
|
9
|
+
const STORAGE_KEY = TENANT_STORAGE_KEY;
|
|
10
|
+
/** The stamp is storage bookkeeping; consumers see a plain TenantOption. */
|
|
11
|
+
const toOption = ({ id, name, type }) => ({ id, name, type });
|
|
12
|
+
// Global cache to prevent multiple simultaneous requests across all hook instances
|
|
13
|
+
let globalGroupsFetchCache = null;
|
|
14
|
+
let globalGroupsCacheTimestamp = 0;
|
|
15
|
+
let globalGroupsCacheKey = '';
|
|
16
|
+
let requestCounter = 0; // Debug counter
|
|
17
|
+
const CACHE_DURATION = 30000; // 30 seconds
|
|
18
|
+
// Global function to fetch groups - shared across all hook instances. Keyed by
|
|
19
|
+
// user as well as URL: the cache outlives a sign-out, and handing the incoming
|
|
20
|
+
// account the previous one's group list is how a stale selection gets
|
|
21
|
+
// "validated" and survives.
|
|
22
|
+
const fetchGroupsGlobally = async (groupApiUrl, userEmail) => {
|
|
23
|
+
requestCounter++;
|
|
24
|
+
logger.debug('fetchGroupsGlobally called', { requestNumber: requestCounter, groupApiUrl });
|
|
25
|
+
// Check if we have a valid cached request for the same API URL and user
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
const cacheKey = `${userEmail ?? ''}|${groupApiUrl}`;
|
|
28
|
+
if (globalGroupsFetchCache &&
|
|
29
|
+
globalGroupsCacheKey === cacheKey &&
|
|
30
|
+
now - globalGroupsCacheTimestamp < CACHE_DURATION) {
|
|
31
|
+
logger.debug('Using cached groups request', { groupApiUrl });
|
|
32
|
+
return globalGroupsFetchCache;
|
|
33
|
+
}
|
|
34
|
+
logger.debug('Making new groups API request', { groupApiUrl });
|
|
35
|
+
// Create new request and cache it
|
|
36
|
+
const fetchRequest = async () => {
|
|
37
|
+
try {
|
|
38
|
+
// Check if groupApiUrl is provided
|
|
39
|
+
if (!groupApiUrl) {
|
|
40
|
+
logger.warn('No GROUP_API_URL provided to useTenant hook');
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
const headers = {
|
|
44
|
+
'Content-Type': 'application/json',
|
|
45
|
+
'x-correlation-id': getCorrelationId(),
|
|
46
|
+
'x-session-id': getSessionId()
|
|
47
|
+
};
|
|
48
|
+
// Use user authentication for fetching groups; the httpOnly access_token
|
|
49
|
+
// cookie is attached automatically by the browser.
|
|
50
|
+
const response = await fetch(`${groupApiUrl}/api/groups/mine`, {
|
|
51
|
+
method: 'GET',
|
|
52
|
+
credentials: 'include',
|
|
53
|
+
headers,
|
|
54
|
+
signal: AbortSignal.timeout(5000) // 5 second timeout
|
|
55
|
+
});
|
|
56
|
+
logger.debug('Groups API response', { status: response.status, statusText: response.statusText });
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw new Error(`Failed to fetch groups: ${response.status} ${response.statusText}`);
|
|
59
|
+
}
|
|
60
|
+
const data = await response.json();
|
|
61
|
+
if (!data.success) {
|
|
62
|
+
throw new Error('API returned unsuccessful response');
|
|
63
|
+
}
|
|
64
|
+
// Transform groups to tenant options
|
|
65
|
+
return data.data.map((group) => ({
|
|
66
|
+
id: group._id,
|
|
67
|
+
name: group.name,
|
|
68
|
+
type: 'group'
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
logger.error('Error fetching user groups', err);
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
// Cache the request
|
|
77
|
+
globalGroupsFetchCache = fetchRequest();
|
|
78
|
+
globalGroupsCacheTimestamp = now;
|
|
79
|
+
globalGroupsCacheKey = cacheKey;
|
|
80
|
+
// Clear cache after request completes (success or failure). `.finally()`
|
|
81
|
+
// returns a new derived promise that rejects if the request did — nothing
|
|
82
|
+
// else observes that derived promise (callers only await the original
|
|
83
|
+
// `globalGroupsFetchCache` reference returned below), so without a no-op
|
|
84
|
+
// `.catch()` here a failed request logs as an unhandled rejection.
|
|
85
|
+
globalGroupsFetchCache
|
|
86
|
+
.finally(() => {
|
|
87
|
+
setTimeout(() => {
|
|
88
|
+
if (globalGroupsFetchCache && Date.now() - globalGroupsCacheTimestamp >= CACHE_DURATION) {
|
|
89
|
+
globalGroupsFetchCache = null;
|
|
90
|
+
globalGroupsCacheKey = '';
|
|
91
|
+
}
|
|
92
|
+
}, CACHE_DURATION);
|
|
93
|
+
})
|
|
94
|
+
.catch(() => { });
|
|
95
|
+
return globalGroupsFetchCache;
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Custom hook for managing tenant selection
|
|
99
|
+
* Handles both personal account and group contexts
|
|
100
|
+
*/
|
|
101
|
+
export const useTenant = (groupApiUrl, userEmail) => {
|
|
102
|
+
const [selectedTenant, setSelectedTenantState] = useState(null);
|
|
103
|
+
const [availableTenants, setAvailableTenants] = useState([]);
|
|
104
|
+
const [loading, setLoading] = useState(true);
|
|
105
|
+
const [error, setError] = useState(null);
|
|
106
|
+
// Load saved tenant selection from localStorage
|
|
107
|
+
const loadSavedTenant = useCallback(() => {
|
|
108
|
+
try {
|
|
109
|
+
const saved = localStorage.getItem(STORAGE_KEY);
|
|
110
|
+
if (saved) {
|
|
111
|
+
const tenant = JSON.parse(saved);
|
|
112
|
+
return tenant;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
logger.warn('Failed to load saved tenant selection', { error: err });
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}, []);
|
|
120
|
+
// Save tenant selection to localStorage, stamped with the current account
|
|
121
|
+
const saveTenant = useCallback((tenant) => {
|
|
122
|
+
try {
|
|
123
|
+
const stored = { ...tenant, userEmail };
|
|
124
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
logger.warn('Failed to save tenant selection', { error: err });
|
|
128
|
+
}
|
|
129
|
+
}, [userEmail]);
|
|
130
|
+
// Fetch available groups from API using user authentication
|
|
131
|
+
const fetchGroups = useCallback(async () => {
|
|
132
|
+
return fetchGroupsGlobally(groupApiUrl, userEmail);
|
|
133
|
+
}, [groupApiUrl, userEmail]);
|
|
134
|
+
// Refresh tenants list
|
|
135
|
+
const refreshTenants = useCallback(async () => {
|
|
136
|
+
if (!userEmail) {
|
|
137
|
+
setLoading(false);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
setLoading(true);
|
|
141
|
+
setError(null);
|
|
142
|
+
try {
|
|
143
|
+
// Always include personal account
|
|
144
|
+
const personalTenant = {
|
|
145
|
+
id: 'personal',
|
|
146
|
+
name: 'Personal',
|
|
147
|
+
type: 'personal'
|
|
148
|
+
};
|
|
149
|
+
// Fetch groups
|
|
150
|
+
const groupTenants = await fetchGroups();
|
|
151
|
+
const allTenants = [personalTenant, ...groupTenants];
|
|
152
|
+
setAvailableTenants(allTenants);
|
|
153
|
+
// Set default selection if none exists
|
|
154
|
+
const savedTenant = loadSavedTenant();
|
|
155
|
+
// A selection made by a different account is not ours to keep, even if
|
|
156
|
+
// this account happens to belong to that group too — an unstamped one
|
|
157
|
+
// predates this check and is validated by membership alone.
|
|
158
|
+
const belongsToThisUser = !savedTenant?.userEmail || savedTenant.userEmail === userEmail;
|
|
159
|
+
const stillAMember = allTenants.some((t) => t.id === savedTenant?.id);
|
|
160
|
+
if (savedTenant && belongsToThisUser && stillAMember) {
|
|
161
|
+
setSelectedTenantState(toOption(savedTenant));
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
// Fall back to personal: the saved tenant is gone, or was someone else's
|
|
165
|
+
setSelectedTenantState(personalTenant);
|
|
166
|
+
saveTenant(personalTenant);
|
|
167
|
+
// Sibling components read the raw storage value synchronously on mount
|
|
168
|
+
// (useTenantFilter), so by now they have already fired their requests
|
|
169
|
+
// against the discarded groupId and are showing 403s. Reload for the
|
|
170
|
+
// same reason switching tenant by hand reloads — it is the only way
|
|
171
|
+
// this app re-reads the context. Storage now says personal, so the
|
|
172
|
+
// next pass takes the branch above and this does not loop.
|
|
173
|
+
if (savedTenant?.type === 'group') {
|
|
174
|
+
logger.debug('Discarded a tenant selection this account cannot use; reloading');
|
|
175
|
+
window.location.reload();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
const errorMessage = err instanceof Error ? err.message : 'Failed to load contexts';
|
|
181
|
+
logger.error('Error in refreshTenants', err);
|
|
182
|
+
setError(errorMessage);
|
|
183
|
+
// The groups call failed, so we cannot tell a stale selection from a
|
|
184
|
+
// valid one. Keep showing what storage says rather than claiming
|
|
185
|
+
// "Personal" while useTenantFilter keeps sending the saved groupId —
|
|
186
|
+
// that mismatch is what made the UI look like it had switched context
|
|
187
|
+
// when it had not. TenantSelector renders the error state instead.
|
|
188
|
+
const personalTenant = {
|
|
189
|
+
id: 'personal',
|
|
190
|
+
name: 'Personal',
|
|
191
|
+
type: 'personal'
|
|
192
|
+
};
|
|
193
|
+
setAvailableTenants([personalTenant]);
|
|
194
|
+
const savedTenant = loadSavedTenant();
|
|
195
|
+
setSelectedTenantState(savedTenant ? toOption(savedTenant) : personalTenant);
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
setLoading(false);
|
|
199
|
+
}
|
|
200
|
+
}, [userEmail, fetchGroups, loadSavedTenant, saveTenant]);
|
|
201
|
+
// Set selected tenant with page reload
|
|
202
|
+
const setSelectedTenant = useCallback((tenant) => {
|
|
203
|
+
logger.debug('Tenant changing', { to: tenant.type === 'personal' ? 'Personal' : tenant.name });
|
|
204
|
+
// Save the new tenant selection
|
|
205
|
+
saveTenant(tenant);
|
|
206
|
+
// Reload the page to refresh all data with the new context
|
|
207
|
+
logger.debug('Reloading page to apply new tenant context');
|
|
208
|
+
window.location.reload();
|
|
209
|
+
}, [saveTenant]);
|
|
210
|
+
// Initialize on mount and when user changes
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
refreshTenants();
|
|
213
|
+
}, [refreshTenants]);
|
|
214
|
+
return {
|
|
215
|
+
selectedTenant,
|
|
216
|
+
availableTenants,
|
|
217
|
+
loading,
|
|
218
|
+
error,
|
|
219
|
+
setSelectedTenant,
|
|
220
|
+
refreshTenants
|
|
221
|
+
};
|
|
222
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook to get current tenant ID for data filtering
|
|
3
|
+
* Returns the groupId to be used in API calls, or undefined for personal context
|
|
4
|
+
* Since we reload the page when tenant changes, this can be a simple read from localStorage
|
|
5
|
+
*/
|
|
6
|
+
export declare const useTenantFilter: () => string | undefined;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { logger } from '../../monitoring';
|
|
2
|
+
import { TENANT_STORAGE_KEY } from '../../sessionStorage';
|
|
3
|
+
/**
|
|
4
|
+
* Hook to get current tenant ID for data filtering
|
|
5
|
+
* Returns the groupId to be used in API calls, or undefined for personal context
|
|
6
|
+
* Since we reload the page when tenant changes, this can be a simple read from localStorage
|
|
7
|
+
*/
|
|
8
|
+
export const useTenantFilter = () => {
|
|
9
|
+
const selectedTenant = getTenantFromStorage();
|
|
10
|
+
// Return groupId for group context, undefined for personal context
|
|
11
|
+
return selectedTenant?.type === 'group' ? selectedTenant.id : undefined;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Utility to get current tenant from localStorage
|
|
15
|
+
* Used for getting tenant context without triggering re-renders
|
|
16
|
+
*/
|
|
17
|
+
const getTenantFromStorage = () => {
|
|
18
|
+
try {
|
|
19
|
+
const saved = localStorage.getItem(TENANT_STORAGE_KEY);
|
|
20
|
+
if (saved) {
|
|
21
|
+
return JSON.parse(saved);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
logger.warn('Failed to load tenant from storage', { error: err });
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
import { ErrorPageProps } from '../components/ErrorPage';
|
|
3
|
+
export interface CreateConfigProviderOptions<TConfig extends {
|
|
4
|
+
PUBLIC_API_URL?: string;
|
|
5
|
+
}> {
|
|
6
|
+
/** Passed to initMonitoring as the `service` tag. */
|
|
7
|
+
serviceName: string;
|
|
8
|
+
/** Pass `import.meta.env.DEV` from the calling app — reading `import.meta` here would not be reliably
|
|
9
|
+
* processed by Vite once compiled into this shared, pre-built package. */
|
|
10
|
+
isDev: boolean;
|
|
11
|
+
/** Pass `import.meta.env.MODE` from the calling app, for the same reason as `isDev`. */
|
|
12
|
+
mode: string;
|
|
13
|
+
/** Builds the config from `import.meta.env.VITE_*` — must be provided by the calling app so the
|
|
14
|
+
* `VITE_` env var reads stay in app source where Vite can statically resolve them. */
|
|
15
|
+
getDevConfig: () => TConfig;
|
|
16
|
+
/** Override the default ErrorPage props (e.g. shell-front's custom icon/title). */
|
|
17
|
+
errorPageProps?: ErrorPageProps;
|
|
18
|
+
/** Console levels to intercept in dev. Omit to use initMonitoring's own default (error + warn only). */
|
|
19
|
+
devCaptureConsoleLevels?: ('debug' | 'info' | 'warn' | 'error')[];
|
|
20
|
+
/**
|
|
21
|
+
* Where the production config lives. Defaults to `/config.json`, which a
|
|
22
|
+
* browser resolves against the *page*. A module-federation remote runs inside
|
|
23
|
+
* shell-front's page, where that is the shell's file and carries none of this
|
|
24
|
+
* app's service URLs — so remotes pass a URL on their own origin, built as
|
|
25
|
+
* `new URL('/config.json', import.meta.url).href`.
|
|
26
|
+
*/
|
|
27
|
+
configUrl?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Builds a per-front ConfigProvider instance. Each of the 8 frontends previously hand-rolled their
|
|
31
|
+
* own (8 divergent copies) — this factory keeps the dev/prod config loading, monitoring init, and
|
|
32
|
+
* loading/error UI in one place. Each app still declares its own `AppConfig` shape and reads its own
|
|
33
|
+
* `VITE_*` env vars via `getDevConfig`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function createConfigProvider<TConfig extends {
|
|
36
|
+
PUBLIC_API_URL?: string;
|
|
37
|
+
}>(options: CreateConfigProviderOptions<TConfig>): {
|
|
38
|
+
ConfigContext: import("react").Context<TConfig | null>;
|
|
39
|
+
ConfigProvider: ({ children }: {
|
|
40
|
+
children: ReactNode;
|
|
41
|
+
}) => import("react").JSX.Element;
|
|
42
|
+
getGlobalConfig: () => TConfig;
|
|
43
|
+
useConfig: () => TConfig;
|
|
44
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useEffect, useState } from 'react';
|
|
3
|
+
import { CssBaseline, ThemeProvider } from '@mui/material';
|
|
4
|
+
import { Loader } from '../components/Loader';
|
|
5
|
+
import { ErrorPage } from '../components/ErrorPage';
|
|
6
|
+
import { initMonitoring, initWebVitals } from '../monitoring';
|
|
7
|
+
import { tumbalandTheme } from '../theme';
|
|
8
|
+
/**
|
|
9
|
+
* Builds a per-front ConfigProvider instance. Each of the 8 frontends previously hand-rolled their
|
|
10
|
+
* own (8 divergent copies) — this factory keeps the dev/prod config loading, monitoring init, and
|
|
11
|
+
* loading/error UI in one place. Each app still declares its own `AppConfig` shape and reads its own
|
|
12
|
+
* `VITE_*` env vars via `getDevConfig`.
|
|
13
|
+
*/
|
|
14
|
+
export function createConfigProvider(options) {
|
|
15
|
+
let globalConfig = null;
|
|
16
|
+
const ConfigContext = createContext(null);
|
|
17
|
+
function getGlobalConfig() {
|
|
18
|
+
if (!globalConfig) {
|
|
19
|
+
if (options.isDev) {
|
|
20
|
+
const fallbackConfig = options.getDevConfig();
|
|
21
|
+
console.warn('Using fallback configuration during HMR:', fallbackConfig);
|
|
22
|
+
return fallbackConfig;
|
|
23
|
+
}
|
|
24
|
+
throw new Error('Configuration not initialized. Make sure ConfigProvider is mounted.');
|
|
25
|
+
}
|
|
26
|
+
return globalConfig;
|
|
27
|
+
}
|
|
28
|
+
function ConfigProvider({ children }) {
|
|
29
|
+
// Starts from an already-loaded config: the shell mounts a remote's
|
|
30
|
+
// provider every time the user navigates into that app, and re-fetching
|
|
31
|
+
// — with a loader in between — on each visit is the reload federation is
|
|
32
|
+
// here to avoid.
|
|
33
|
+
const [config, setConfig] = useState(() => globalConfig);
|
|
34
|
+
const [error, setError] = useState(null);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (globalConfig) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (options.isDev) {
|
|
40
|
+
const devConfig = options.getDevConfig();
|
|
41
|
+
console.log('Using development configuration from environment variables:', devConfig);
|
|
42
|
+
setConfig(devConfig);
|
|
43
|
+
globalConfig = devConfig;
|
|
44
|
+
initMonitoring(options.mode, {
|
|
45
|
+
service: options.serviceName,
|
|
46
|
+
component: 'frontend',
|
|
47
|
+
elkUrl: devConfig.PUBLIC_API_URL ? `${devConfig.PUBLIC_API_URL}/logs` : undefined,
|
|
48
|
+
captureConsoleLevels: options.devCaptureConsoleLevels
|
|
49
|
+
});
|
|
50
|
+
initWebVitals({ isDevelopment: true }).catch(console.error);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
fetch(options.configUrl ?? '/config.json')
|
|
54
|
+
.then((res) => {
|
|
55
|
+
if (!res.ok)
|
|
56
|
+
throw new Error('Config fetch failed');
|
|
57
|
+
return res.json();
|
|
58
|
+
})
|
|
59
|
+
.then((loadedConfig) => {
|
|
60
|
+
console.log('Using production configuration from config.json:', loadedConfig);
|
|
61
|
+
setConfig(loadedConfig);
|
|
62
|
+
globalConfig = loadedConfig;
|
|
63
|
+
initMonitoring('production', {
|
|
64
|
+
service: options.serviceName,
|
|
65
|
+
component: 'frontend',
|
|
66
|
+
elkUrl: loadedConfig.PUBLIC_API_URL ? `${loadedConfig.PUBLIC_API_URL}/logs` : undefined
|
|
67
|
+
});
|
|
68
|
+
initWebVitals({ isDevelopment: false }).catch(console.error);
|
|
69
|
+
})
|
|
70
|
+
.catch((err) => {
|
|
71
|
+
console.error('Configuration load error:', err);
|
|
72
|
+
setError(err.message);
|
|
73
|
+
});
|
|
74
|
+
}, []);
|
|
75
|
+
if (error) {
|
|
76
|
+
return _jsx(ErrorPage, { ...options.errorPageProps });
|
|
77
|
+
}
|
|
78
|
+
if (!config) {
|
|
79
|
+
return _jsx(Loader, {});
|
|
80
|
+
}
|
|
81
|
+
const content = (_jsxs(_Fragment, { children: [_jsx(CssBaseline, {}), children] }));
|
|
82
|
+
return (_jsx(ConfigContext.Provider, { value: config, children: _jsx(ThemeProvider, { theme: tumbalandTheme, children: content }) }));
|
|
83
|
+
}
|
|
84
|
+
function useConfig() {
|
|
85
|
+
const config = useContext(ConfigContext);
|
|
86
|
+
if (!config) {
|
|
87
|
+
throw new Error('useConfig must be used within a ConfigProvider');
|
|
88
|
+
}
|
|
89
|
+
return config;
|
|
90
|
+
}
|
|
91
|
+
return { ConfigContext, ConfigProvider, getGlobalConfig, useConfig };
|
|
92
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-federation settings every Tumbaland front has to agree on.
|
|
3
|
+
*
|
|
4
|
+
* shell-front is the host; journal, album, money, account, groups and billing
|
|
5
|
+
* are remotes it loads at runtime from their own origins. This replaces the
|
|
6
|
+
* iframe-per-module shell: an iframe meant each module switch threw away a
|
|
7
|
+
* whole JS context and booted a new one, which is why opening a module
|
|
8
|
+
* re-checked the session, re-fetched its data and lost whatever the previous
|
|
9
|
+
* one had on screen.
|
|
10
|
+
*
|
|
11
|
+
* The shared singletons are the libraries that hold React context or global
|
|
12
|
+
* state. A remote running its own copy of React breaks hooks outright, and its
|
|
13
|
+
* own copy of the router, Emotion or React Query reads an empty context instead
|
|
14
|
+
* of the one the host's providers filled — so every app must take the single
|
|
15
|
+
* copy the host loaded. The list lives here rather than in seven vite configs
|
|
16
|
+
* because drift between those copies is precisely that failure.
|
|
17
|
+
*
|
|
18
|
+
* Plain data with no React imports: each `vite.config.mts` loads it in Node.
|
|
19
|
+
*/
|
|
20
|
+
export declare const TUMBALAND_FEDERATION_SHARED: {
|
|
21
|
+
react: {
|
|
22
|
+
singleton: boolean;
|
|
23
|
+
};
|
|
24
|
+
'react-dom': {
|
|
25
|
+
singleton: boolean;
|
|
26
|
+
};
|
|
27
|
+
'react-router': {
|
|
28
|
+
singleton: boolean;
|
|
29
|
+
};
|
|
30
|
+
'@emotion/react': {
|
|
31
|
+
singleton: boolean;
|
|
32
|
+
};
|
|
33
|
+
'@emotion/styled': {
|
|
34
|
+
singleton: boolean;
|
|
35
|
+
};
|
|
36
|
+
'@mui/material': {
|
|
37
|
+
singleton: boolean;
|
|
38
|
+
};
|
|
39
|
+
'@tanstack/react-query': {
|
|
40
|
+
singleton: boolean;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
/** File each remote serves its federation entry as, at the root of its origin. */
|
|
44
|
+
export declare const TUMBALAND_REMOTE_ENTRY = "remoteEntry.js";
|
|
45
|
+
/** The module every remote exposes: its routes wrapped in its own providers. */
|
|
46
|
+
export declare const TUMBALAND_REMOTE_MODULE = "./App";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-federation settings every Tumbaland front has to agree on.
|
|
3
|
+
*
|
|
4
|
+
* shell-front is the host; journal, album, money, account, groups and billing
|
|
5
|
+
* are remotes it loads at runtime from their own origins. This replaces the
|
|
6
|
+
* iframe-per-module shell: an iframe meant each module switch threw away a
|
|
7
|
+
* whole JS context and booted a new one, which is why opening a module
|
|
8
|
+
* re-checked the session, re-fetched its data and lost whatever the previous
|
|
9
|
+
* one had on screen.
|
|
10
|
+
*
|
|
11
|
+
* The shared singletons are the libraries that hold React context or global
|
|
12
|
+
* state. A remote running its own copy of React breaks hooks outright, and its
|
|
13
|
+
* own copy of the router, Emotion or React Query reads an empty context instead
|
|
14
|
+
* of the one the host's providers filled — so every app must take the single
|
|
15
|
+
* copy the host loaded. The list lives here rather than in seven vite configs
|
|
16
|
+
* because drift between those copies is precisely that failure.
|
|
17
|
+
*
|
|
18
|
+
* Plain data with no React imports: each `vite.config.mts` loads it in Node.
|
|
19
|
+
*/
|
|
20
|
+
export const TUMBALAND_FEDERATION_SHARED = {
|
|
21
|
+
react: { singleton: true },
|
|
22
|
+
'react-dom': { singleton: true },
|
|
23
|
+
// Tumbaland is on `react-router` v8, which is the package that holds the
|
|
24
|
+
// router context — there is no `react-router-dom` to share.
|
|
25
|
+
'react-router': { singleton: true },
|
|
26
|
+
'@emotion/react': { singleton: true },
|
|
27
|
+
'@emotion/styled': { singleton: true },
|
|
28
|
+
'@mui/material': { singleton: true },
|
|
29
|
+
// Only album and money use it today. Shared anyway: a second copy in one of
|
|
30
|
+
// them would read an empty cache rather than fail loudly, which is the
|
|
31
|
+
// harder bug to find.
|
|
32
|
+
'@tanstack/react-query': { singleton: true }
|
|
33
|
+
};
|
|
34
|
+
/** File each remote serves its federation entry as, at the root of its origin. */
|
|
35
|
+
export const TUMBALAND_REMOTE_ENTRY = 'remoteEntry.js';
|
|
36
|
+
/** The module every remote exposes: its routes wrapped in its own providers. */
|
|
37
|
+
export const TUMBALAND_REMOTE_MODULE = './App';
|
package/dist/index.d.ts
CHANGED
|
@@ -12,3 +12,29 @@ export type { User, AuthResponse, Group, ApiResponse } from './types';
|
|
|
12
12
|
export { TENANT_STORAGE_KEY, clearSessionScopedStorage } from './sessionStorage';
|
|
13
13
|
export { initMonitoring, captureException, captureMessage, setUser, setTag, startTransaction, recordMetric, initWebVitals, flushLogs, initConsoleInterception, generateCorrelationId, getCorrelationId, getSessionId, logger } from './monitoring';
|
|
14
14
|
export type { MonitoringConfig } from './monitoring';
|
|
15
|
+
export { Loader } from './components/Loader';
|
|
16
|
+
export { ErrorPage } from './components/ErrorPage';
|
|
17
|
+
export { AppErrorBoundary } from './components/AppErrorBoundary';
|
|
18
|
+
export { UnauthorizedPage } from './components/UnauthorizedPage';
|
|
19
|
+
export { ProtectedRoute } from './components/ProtectedRoute';
|
|
20
|
+
export { LoginButton } from './components/LoginButton';
|
|
21
|
+
export { NotificationsMenu } from './components/NotificationsMenu/NotificationsMenu';
|
|
22
|
+
export { AuthHeader } from './components/AuthHeader';
|
|
23
|
+
export { AppLayout } from './components/AppLayout';
|
|
24
|
+
export { UsageMeter, formatBytes, useEntitlements } from './components/UsageMeter';
|
|
25
|
+
export { TenantSelector, TenantProvider, useTenant, useTenantListener, useTenantContext, useTenantFilter } from './components/tenant';
|
|
26
|
+
export type { LoaderProps } from './components/Loader';
|
|
27
|
+
export type { ErrorPageProps } from './components/ErrorPage';
|
|
28
|
+
export type { AppErrorBoundaryProps } from './components/AppErrorBoundary';
|
|
29
|
+
export type { AppLayoutProps } from './components/AppLayout';
|
|
30
|
+
export type { UnauthorizedPageProps } from './components/UnauthorizedPage';
|
|
31
|
+
export type { ProtectedRouteProps, ProtectedRouteAuthService } from './components/ProtectedRoute';
|
|
32
|
+
export type { LoginButtonProps } from './components/LoginButton';
|
|
33
|
+
export type { NotificationsMenuProps } from './components/NotificationsMenu/NotificationsMenu';
|
|
34
|
+
export type { AuthHeaderProps, AuthHeaderUser, AuthHeaderNavigationItem, AuthHeaderTenantOption, AuthHeaderTenantSelectorProps } from './components/AuthHeader';
|
|
35
|
+
export type { TenantOption, TenantSelectorProps, UseTenantResult } from './components/tenant';
|
|
36
|
+
export type { UsageMeterProps, UseEntitlementsResult } from './components/UsageMeter';
|
|
37
|
+
export { createConfigProvider } from './config/createConfigProvider';
|
|
38
|
+
export type { CreateConfigProviderOptions } from './config/createConfigProvider';
|
|
39
|
+
export { toOrigin } from './routing';
|
|
40
|
+
export { brand, ink, surface, gradient, moduleAccent, wordmark, glass, glassInteractive, brandButton, section, gutter, gutterSx, TOUCH_TARGET, PHONE, createTumbalandTheme, tumbalandTheme, TUMBALAND_COLORS } from './theme';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
// @tumbaland/frontend-core
|
|
1
|
+
// @tumbaland/frontend-core — everything the Tumbaland frontends share:
|
|
2
|
+
// the headless auth/group/API-client logic, and the React components and theme
|
|
3
|
+
// built on top of it.
|
|
4
|
+
//
|
|
5
|
+
// These were two packages, @tumbaland/frontend-core and @tumbaland/frontend-core,
|
|
6
|
+
// and every one of the eight frontends depended on both. Splitting them bought
|
|
7
|
+
// nothing a consumer could use and cost a release ordering problem: components
|
|
8
|
+
// pinned frontend-core exactly, so the two had to publish in dependency order
|
|
9
|
+
// or the published tarball pinned a version that was about to be replaced.
|
|
10
|
+
//
|
|
11
|
+
// The one thing the split did guarantee is kept by `./federation`: that entry
|
|
12
|
+
// point imports nothing, so the seven vite configs that read it in Node at
|
|
13
|
+
// build time never pull React in.
|
|
2
14
|
export { createApiClient, ApiError } from './apiClient';
|
|
3
15
|
export { createAuthService, createAppAuthService } from './authService';
|
|
4
16
|
export { createGroupService } from './groupService';
|
|
@@ -7,3 +19,24 @@ export { createPlansService } from './plansService';
|
|
|
7
19
|
export { TENANT_STORAGE_KEY, clearSessionScopedStorage } from './sessionStorage';
|
|
8
20
|
// Monitoring: headless logging, error capture, correlation IDs, and web-vitals
|
|
9
21
|
export { initMonitoring, captureException, captureMessage, setUser, setTag, startTransaction, recordMetric, initWebVitals, flushLogs, initConsoleInterception, generateCorrelationId, getCorrelationId, getSessionId, logger } from './monitoring';
|
|
22
|
+
// --- Components, theme and providers (formerly @tumbaland/frontend-core) ---
|
|
23
|
+
export { Loader } from './components/Loader';
|
|
24
|
+
export { ErrorPage } from './components/ErrorPage';
|
|
25
|
+
export { AppErrorBoundary } from './components/AppErrorBoundary';
|
|
26
|
+
export { UnauthorizedPage } from './components/UnauthorizedPage';
|
|
27
|
+
export { ProtectedRoute } from './components/ProtectedRoute';
|
|
28
|
+
export { LoginButton } from './components/LoginButton';
|
|
29
|
+
export { NotificationsMenu } from './components/NotificationsMenu/NotificationsMenu';
|
|
30
|
+
export { AuthHeader } from './components/AuthHeader';
|
|
31
|
+
export { AppLayout } from './components/AppLayout';
|
|
32
|
+
// Entitlements: the usage bar and the hook that feeds it
|
|
33
|
+
export { UsageMeter, formatBytes, useEntitlements } from './components/UsageMeter';
|
|
34
|
+
// Tenant selection: provider, selector, and hooks shared across domain frontends
|
|
35
|
+
export { TenantSelector, TenantProvider, useTenant, useTenantListener, useTenantContext, useTenantFilter } from './components/tenant';
|
|
36
|
+
// Config provider factory
|
|
37
|
+
export { createConfigProvider } from './config/createConfigProvider';
|
|
38
|
+
// A plain URL helper; the iframe bridge it belonged to is gone (see routing/index).
|
|
39
|
+
export { toOrigin } from './routing';
|
|
40
|
+
// Theme: the palette, the MUI theme built from it, and the raw tokens for the
|
|
41
|
+
// places that have to name a colour outside a `sx` prop.
|
|
42
|
+
export { brand, ink, surface, gradient, moduleAccent, wordmark, glass, glassInteractive, brandButton, section, gutter, gutterSx, TOUCH_TARGET, PHONE, createTumbalandTheme, tumbalandTheme, TUMBALAND_COLORS } from './theme';
|
package/dist/monitoring/batch.js
CHANGED
|
@@ -18,9 +18,9 @@ const sendBatch = async () => {
|
|
|
18
18
|
const response = await fetch(config.elkUrl, {
|
|
19
19
|
method: 'POST',
|
|
20
20
|
headers: {
|
|
21
|
-
'Content-Type': 'application/json'
|
|
21
|
+
'Content-Type': 'application/json'
|
|
22
22
|
},
|
|
23
|
-
body: JSON.stringify(batchToSend)
|
|
23
|
+
body: JSON.stringify(batchToSend)
|
|
24
24
|
});
|
|
25
25
|
if (!response.ok) {
|
|
26
26
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
@@ -39,7 +39,7 @@ const sendBatch = async () => {
|
|
|
39
39
|
try {
|
|
40
40
|
console.warn('Failed to send log batch, dropping logs:', error);
|
|
41
41
|
if (config.isDevelopment) {
|
|
42
|
-
batchToSend.forEach(log => {
|
|
42
|
+
batchToSend.forEach((log) => {
|
|
43
43
|
console.warn('📝 [DROPPED LOG]:', JSON.stringify(log));
|
|
44
44
|
});
|
|
45
45
|
}
|
|
@@ -30,10 +30,7 @@ export const initMonitoring = (environment = 'development', options = {}) => {
|
|
|
30
30
|
console.log(`📝 Console interception enabled for levels: ${captureConsoleLevels.join(', ')}`);
|
|
31
31
|
}
|
|
32
32
|
// Initialize console interception and error handlers after modules are loaded
|
|
33
|
-
Promise.all([
|
|
34
|
-
import('./console'),
|
|
35
|
-
import('./errors')
|
|
36
|
-
]).then(([{ initConsoleInterception }, { captureException, captureMessage }]) => {
|
|
33
|
+
Promise.all([import('./console'), import('./errors')]).then(([{ initConsoleInterception }, { captureException, captureMessage }]) => {
|
|
37
34
|
// Initialize console interception with capture function
|
|
38
35
|
initConsoleInterception(captureConsoleLevels, captureMessage);
|
|
39
36
|
// Set up global error handlers
|
|
@@ -18,7 +18,7 @@ export const initConsoleInterception = (captureConsoleLevels = ['error', 'warn']
|
|
|
18
18
|
return;
|
|
19
19
|
// Send to logging API with recursion protection
|
|
20
20
|
try {
|
|
21
|
-
const message = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' ');
|
|
21
|
+
const message = args.map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg))).join(' ');
|
|
22
22
|
// Set interception flag to prevent recursion during capture
|
|
23
23
|
window._monitoringInterceptingConsole = true;
|
|
24
24
|
captureMessageFn(message, level, {
|
package/dist/plansService.js
CHANGED
|
@@ -19,7 +19,7 @@ export function createPlansService(config) {
|
|
|
19
19
|
const plans = body?.plans ?? [];
|
|
20
20
|
// A plan without resolved limits cannot be rendered honestly — every line
|
|
21
21
|
// on its card is a limit — so drop it rather than draw a card with holes.
|
|
22
|
-
return plans.filter(plan => Boolean(plan?.effectiveLimits?.meters));
|
|
22
|
+
return plans.filter((plan) => Boolean(plan?.effectiveLimits?.meters));
|
|
23
23
|
}
|
|
24
24
|
};
|
|
25
25
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers shared by both sides of the shell/child iframe bridge.
|
|
3
|
+
*
|
|
4
|
+
* The child's initial path travels in the iframe `src` rather than over
|
|
5
|
+
* postMessage so the app boots straight into the deep-linked page instead of
|
|
6
|
+
* flashing its default route first. Every later navigation goes over
|
|
7
|
+
* postMessage — the `src` must stay stable or the child remounts.
|
|
8
|
+
*/
|
|
9
|
+
export declare const EMBED_FLAG = "embedded";
|
|
10
|
+
export declare const EMBED_PATH = "embedPath";
|
|
11
|
+
export interface EmbedContext {
|
|
12
|
+
embedded: boolean;
|
|
13
|
+
initialPath: string;
|
|
14
|
+
}
|
|
15
|
+
/** Path the child router starts on: always absolute, defaults to the root. */
|
|
16
|
+
export declare const normalizeEmbedPath: (path?: string | null) => string;
|
|
17
|
+
/** `/album` + `/albums/1?x=2` -> `/album/albums/1?x=2` (root path -> `/album`). */
|
|
18
|
+
export declare const joinShellPath: (appRoute: string, childPath: string) => string;
|
|
19
|
+
/** Everything after the app prefix, e.g. `/album/albums/1` -> `/albums/1`. */
|
|
20
|
+
export declare const extractChildPath: (appRoute: string, shellPath: string, search?: string) => string;
|
|
21
|
+
/** Config values are full URLs; postMessage needs a bare origin. */
|
|
22
|
+
export declare const toOrigin: (url?: string) => string | undefined;
|
|
23
|
+
export declare const safeUrl: (url: string, base?: string) => URL | undefined;
|
|
24
|
+
/** Iframe `src` for an app, carrying the embed flag and the deep-linked path. */
|
|
25
|
+
export declare const buildEmbeddedUrl: (appUrl: string, childPath: string) => string;
|
|
26
|
+
/**
|
|
27
|
+
* Read on the child side. Embedded only when actually framed *and* flagged, so
|
|
28
|
+
* a direct visit to album.tumbaland.eu always gets a real BrowserRouter even if
|
|
29
|
+
* someone pastes the query params.
|
|
30
|
+
*/
|
|
31
|
+
export declare const readEmbedContext: (search?: string, isFramed?: boolean) => EmbedContext;
|