@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.
Files changed (77) hide show
  1. package/dist/authService.d.ts +10 -0
  2. package/dist/authService.js +18 -3
  3. package/dist/components/AppErrorBoundary/AppErrorBoundary.d.ts +26 -0
  4. package/dist/components/AppErrorBoundary/AppErrorBoundary.js +25 -0
  5. package/dist/components/AppErrorBoundary/index.d.ts +2 -0
  6. package/dist/components/AppErrorBoundary/index.js +1 -0
  7. package/dist/components/AppLayout/AppLayout.d.ts +27 -0
  8. package/dist/components/AppLayout/AppLayout.js +45 -0
  9. package/dist/components/AppLayout/index.d.ts +2 -0
  10. package/dist/components/AppLayout/index.js +1 -0
  11. package/dist/components/AuthHeader/AuthHeader.d.ts +57 -0
  12. package/dist/components/AuthHeader/AuthHeader.js +135 -0
  13. package/dist/components/AuthHeader/index.d.ts +2 -0
  14. package/dist/components/AuthHeader/index.js +1 -0
  15. package/dist/components/ErrorPage/ErrorPage.d.ts +17 -0
  16. package/dist/components/ErrorPage/ErrorPage.js +19 -0
  17. package/dist/components/ErrorPage/index.d.ts +2 -0
  18. package/dist/components/ErrorPage/index.js +1 -0
  19. package/dist/components/Loader/Loader.d.ts +12 -0
  20. package/dist/components/Loader/Loader.js +15 -0
  21. package/dist/components/Loader/index.d.ts +2 -0
  22. package/dist/components/Loader/index.js +1 -0
  23. package/dist/components/LoginButton/LoginButton.d.ts +5 -0
  24. package/dist/components/LoginButton/LoginButton.js +15 -0
  25. package/dist/components/LoginButton/index.d.ts +2 -0
  26. package/dist/components/LoginButton/index.js +1 -0
  27. package/dist/components/NotificationsMenu/NotificationsMenu.d.ts +9 -0
  28. package/dist/components/NotificationsMenu/NotificationsMenu.js +29 -0
  29. package/dist/components/ProtectedRoute/ProtectedRoute.d.ts +33 -0
  30. package/dist/components/ProtectedRoute/ProtectedRoute.js +50 -0
  31. package/dist/components/ProtectedRoute/index.d.ts +2 -0
  32. package/dist/components/ProtectedRoute/index.js +1 -0
  33. package/dist/components/UnauthorizedPage/UnauthorizedPage.d.ts +5 -0
  34. package/dist/components/UnauthorizedPage/UnauthorizedPage.js +9 -0
  35. package/dist/components/UnauthorizedPage/index.d.ts +2 -0
  36. package/dist/components/UnauthorizedPage/index.js +1 -0
  37. package/dist/components/UsageMeter/UsageMeter.d.ts +35 -0
  38. package/dist/components/UsageMeter/UsageMeter.js +50 -0
  39. package/dist/components/UsageMeter/index.d.ts +4 -0
  40. package/dist/components/UsageMeter/index.js +2 -0
  41. package/dist/components/UsageMeter/useEntitlements.d.ts +27 -0
  42. package/dist/components/UsageMeter/useEntitlements.js +56 -0
  43. package/dist/components/tenant/TenantProvider.d.ts +11 -0
  44. package/dist/components/tenant/TenantProvider.js +53 -0
  45. package/dist/components/tenant/TenantSelector.d.ts +24 -0
  46. package/dist/components/tenant/TenantSelector.js +77 -0
  47. package/dist/components/tenant/index.d.ts +5 -0
  48. package/dist/components/tenant/index.js +3 -0
  49. package/dist/components/tenant/useTenant.d.ts +15 -0
  50. package/dist/components/tenant/useTenant.js +222 -0
  51. package/dist/components/tenant/useTenantDataRefresh.d.ts +6 -0
  52. package/dist/components/tenant/useTenantDataRefresh.js +28 -0
  53. package/dist/config/createConfigProvider.d.ts +44 -0
  54. package/dist/config/createConfigProvider.js +92 -0
  55. package/dist/federation.d.ts +46 -0
  56. package/dist/federation.js +37 -0
  57. package/dist/index.d.ts +26 -0
  58. package/dist/index.js +34 -1
  59. package/dist/monitoring/batch.js +3 -3
  60. package/dist/monitoring/config.js +1 -4
  61. package/dist/monitoring/console.js +1 -1
  62. package/dist/plansService.js +1 -1
  63. package/dist/routing/embedContext.d.ts +31 -0
  64. package/dist/routing/embedContext.js +63 -0
  65. package/dist/routing/index.d.ts +12 -0
  66. package/dist/routing/index.js +12 -0
  67. package/dist/sessionStorage.d.ts +1 -1
  68. package/dist/sessionStorage.js +1 -1
  69. package/dist/theme/createTumbalandTheme.d.ts +38 -0
  70. package/dist/theme/createTumbalandTheme.js +245 -0
  71. package/dist/theme/index.d.ts +2 -0
  72. package/dist/theme/index.js +2 -0
  73. package/dist/theme/tokens.d.ts +169 -0
  74. package/dist/theme/tokens.js +162 -0
  75. package/package.json +28 -2
  76. package/dist/test/setup.d.ts +0 -9
  77. 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 - Shared frontend auth/group/API-client logic
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';
@@ -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, {
@@ -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;