@tumbaland/frontend-core 1.14.1 → 1.16.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 (81) 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/SectionBar/SectionBar.d.ts +49 -0
  34. package/dist/components/SectionBar/SectionBar.js +143 -0
  35. package/dist/components/SectionBar/index.d.ts +2 -0
  36. package/dist/components/SectionBar/index.js +1 -0
  37. package/dist/components/UnauthorizedPage/UnauthorizedPage.d.ts +5 -0
  38. package/dist/components/UnauthorizedPage/UnauthorizedPage.js +9 -0
  39. package/dist/components/UnauthorizedPage/index.d.ts +2 -0
  40. package/dist/components/UnauthorizedPage/index.js +1 -0
  41. package/dist/components/UsageMeter/UsageMeter.d.ts +35 -0
  42. package/dist/components/UsageMeter/UsageMeter.js +50 -0
  43. package/dist/components/UsageMeter/index.d.ts +4 -0
  44. package/dist/components/UsageMeter/index.js +2 -0
  45. package/dist/components/UsageMeter/useEntitlements.d.ts +27 -0
  46. package/dist/components/UsageMeter/useEntitlements.js +56 -0
  47. package/dist/components/tenant/TenantProvider.d.ts +11 -0
  48. package/dist/components/tenant/TenantProvider.js +53 -0
  49. package/dist/components/tenant/TenantSelector.d.ts +33 -0
  50. package/dist/components/tenant/TenantSelector.js +88 -0
  51. package/dist/components/tenant/index.d.ts +5 -0
  52. package/dist/components/tenant/index.js +3 -0
  53. package/dist/components/tenant/useTenant.d.ts +15 -0
  54. package/dist/components/tenant/useTenant.js +278 -0
  55. package/dist/components/tenant/useTenantDataRefresh.d.ts +6 -0
  56. package/dist/components/tenant/useTenantDataRefresh.js +28 -0
  57. package/dist/config/createConfigProvider.d.ts +44 -0
  58. package/dist/config/createConfigProvider.js +92 -0
  59. package/dist/federation.d.ts +46 -0
  60. package/dist/federation.js +37 -0
  61. package/dist/index.d.ts +28 -0
  62. package/dist/index.js +35 -1
  63. package/dist/monitoring/batch.js +3 -3
  64. package/dist/monitoring/config.js +1 -4
  65. package/dist/monitoring/console.js +1 -1
  66. package/dist/plansService.js +1 -1
  67. package/dist/routing/embedContext.d.ts +31 -0
  68. package/dist/routing/embedContext.js +63 -0
  69. package/dist/routing/index.d.ts +12 -0
  70. package/dist/routing/index.js +12 -0
  71. package/dist/sessionStorage.d.ts +1 -1
  72. package/dist/sessionStorage.js +1 -1
  73. package/dist/theme/createTumbalandTheme.d.ts +38 -0
  74. package/dist/theme/createTumbalandTheme.js +245 -0
  75. package/dist/theme/index.d.ts +2 -0
  76. package/dist/theme/index.js +2 -0
  77. package/dist/theme/tokens.d.ts +186 -0
  78. package/dist/theme/tokens.js +182 -0
  79. package/package.json +28 -2
  80. package/dist/test/setup.d.ts +0 -9
  81. package/dist/test/setup.js +0 -26
@@ -0,0 +1,88 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Button, Menu, MenuItem, ListItemIcon, ListItemText, Divider, Typography, Chip } from '@mui/material';
4
+ import { PersonOutlined, Groups, ExpandMore, Check } from '@mui/icons-material';
5
+ import { brand, ink, surface, TOUCH_TARGET } from '../../theme/tokens';
6
+ /**
7
+ * Whose data the page is showing: the person's own, or one of their groups.
8
+ *
9
+ * Drawn as the same rounded pill the section bar beside it uses, in the brand
10
+ * violet rather than MUI's default blue, so the one control that changes what
11
+ * every module shows reads as part of the chrome instead of a stray form field.
12
+ */
13
+ const TenantSelector = ({ selectedTenant, tenants = [], onTenantChange, loading = false, error = null, compact = false, sx = {} }) => {
14
+ const [anchorEl, setAnchorEl] = useState(null);
15
+ const isOpen = Boolean(anchorEl);
16
+ const handleClick = (event) => {
17
+ setAnchorEl(event.currentTarget);
18
+ };
19
+ const handleClose = () => {
20
+ setAnchorEl(null);
21
+ };
22
+ const handleTenantSelect = (tenant) => {
23
+ onTenantChange(tenant);
24
+ handleClose();
25
+ };
26
+ const getDisplayName = (tenant) => {
27
+ if (!tenant)
28
+ return 'Choose context';
29
+ return tenant.type === 'personal' ? 'Personal' : tenant.name;
30
+ };
31
+ const getIcon = (type) => {
32
+ return type === 'personal' ? _jsx(PersonOutlined, { fontSize: "small" }) : _jsx(Groups, { fontSize: "small" });
33
+ };
34
+ if (error) {
35
+ return _jsx(Chip, { label: "Error loading contexts", color: "error", size: "small", sx: { ...sx } });
36
+ }
37
+ return (_jsxs(Box, { sx: { ...sx }, children: [_jsx(Button, { onClick: handleClick, disabled: loading || tenants.length === 0, "aria-haspopup": "menu", "aria-expanded": isOpen, endIcon: _jsx(ExpandMore, {}), startIcon: selectedTenant ? getIcon(selectedTenant.type) : _jsx(PersonOutlined, { fontSize: "small" }), sx: {
38
+ textTransform: 'none',
39
+ fontWeight: 600,
40
+ borderRadius: 999,
41
+ px: compact ? 1.25 : 1.75,
42
+ minHeight: compact ? 38 : TOUCH_TARGET,
43
+ minWidth: 0,
44
+ justifyContent: 'flex-start',
45
+ color: ink.strong,
46
+ backgroundColor: surface.paper,
47
+ border: `1px solid ${surface.divider}`,
48
+ transition: 'border-color .2s ease, box-shadow .2s ease',
49
+ '& .MuiButton-startIcon': { color: brand.violetInk, flexShrink: 0 },
50
+ '& .MuiButton-endIcon': { color: ink.muted, flexShrink: 0 },
51
+ '&:hover': {
52
+ backgroundColor: surface.paper,
53
+ borderColor: `${brand.violetInk}66`,
54
+ boxShadow: `0 8px 20px -14px ${brand.violetInk}`
55
+ },
56
+ '&.Mui-disabled': { color: ink.muted, border: `1px solid ${surface.divider}` }
57
+ }, children: _jsxs(Box, { sx: { flexGrow: 1, textAlign: 'left', overflow: 'hidden' }, children: [!compact && (_jsx(Typography, { variant: "caption", sx: { color: ink.muted, display: 'block', fontSize: 10, letterSpacing: 0.4, lineHeight: 1.2 }, children: "Viewing" })), _jsx(Typography, { variant: "body2", sx: {
58
+ display: 'block',
59
+ fontWeight: 600,
60
+ lineHeight: 1.3,
61
+ whiteSpace: 'nowrap',
62
+ overflow: 'hidden',
63
+ textOverflow: 'ellipsis'
64
+ }, children: loading ? 'Loading...' : getDisplayName(selectedTenant) })] }) }), _jsxs(Menu, { anchorEl: anchorEl, open: isOpen, onClose: handleClose, anchorOrigin: { vertical: 'bottom', horizontal: 'right' }, transformOrigin: { vertical: 'top', horizontal: 'right' }, slotProps: {
65
+ paper: {
66
+ sx: {
67
+ mt: 0.5,
68
+ minWidth: 248,
69
+ borderRadius: 3,
70
+ border: `1px solid ${surface.divider}`,
71
+ boxShadow: '0 18px 40px -24px rgba(88,28,135,0.5)'
72
+ }
73
+ }
74
+ }, children: [_jsx(MenuItem, { disabled: true, sx: { '&.Mui-disabled': { opacity: 1 } }, children: _jsx(Typography, { variant: "caption", sx: { color: ink.muted, fontWeight: 700, letterSpacing: 0.4 }, children: "Switch to" }) }), _jsx(Divider, {}), tenants.map((tenant) => {
75
+ const isSelected = selectedTenant?.id === tenant.id;
76
+ return (_jsxs(MenuItem, { onClick: () => handleTenantSelect(tenant), selected: isSelected, sx: {
77
+ mx: 0.5,
78
+ py: 1.25,
79
+ borderRadius: 2,
80
+ minHeight: TOUCH_TARGET,
81
+ color: isSelected ? brand.violetInk : ink.body,
82
+ '&.Mui-selected': { backgroundColor: `${brand.violetInk}14`, color: brand.violetInk },
83
+ '&.Mui-selected:hover': { backgroundColor: `${brand.violetInk}20` },
84
+ '&:hover': { backgroundColor: surface.sunken }
85
+ }, children: [_jsx(ListItemIcon, { sx: { color: 'inherit', minWidth: 36 }, children: getIcon(tenant.type) }), _jsx(ListItemText, { primary: _jsxs(Box, { sx: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }, children: [_jsx(Typography, { variant: "body2", noWrap: true, sx: { fontWeight: isSelected ? 700 : 500 }, children: tenant.type === 'personal' ? 'Personal' : tenant.name }), isSelected && _jsx(Check, { fontSize: "small", sx: { color: 'inherit' } })] }), secondary: _jsx(Typography, { variant: "caption", sx: { color: ink.muted }, children: tenant.type === 'personal' ? 'Your personal data' : 'Group workspace' }) })] }, tenant.id));
86
+ }), tenants.length === 0 && !loading && (_jsx(MenuItem, { disabled: true, children: _jsx(ListItemText, { primary: _jsx(Typography, { variant: "body2", sx: { color: ink.muted }, children: "No contexts available" }) }) }))] })] }));
87
+ };
88
+ export default TenantSelector;
@@ -0,0 +1,5 @@
1
+ export { default as TenantSelector } from './TenantSelector';
2
+ export type { TenantOption, TenantSelectorProps } from './TenantSelector';
3
+ export { useTenant, TenantProvider, useTenantListener, useTenantContext } from './useTenant';
4
+ export type { UseTenantResult } from './useTenant';
5
+ export { useTenantFilter } from './useTenantDataRefresh';
@@ -0,0 +1,3 @@
1
+ export { default as TenantSelector } from './TenantSelector';
2
+ export { useTenant, TenantProvider, useTenantListener, useTenantContext } from './useTenant';
3
+ export { useTenantFilter } from './useTenantDataRefresh';
@@ -0,0 +1,15 @@
1
+ import { TenantOption, TenantProvider, useTenantListener, useTenantContext } from './TenantProvider';
2
+ export { TenantProvider, useTenantListener, useTenantContext, type TenantOption };
3
+ export interface UseTenantResult {
4
+ selectedTenant: TenantOption | null;
5
+ availableTenants: TenantOption[];
6
+ loading: boolean;
7
+ error: string | null;
8
+ setSelectedTenant: (tenant: TenantOption) => void;
9
+ refreshTenants: () => Promise<void>;
10
+ }
11
+ /**
12
+ * Custom hook for managing tenant selection
13
+ * Handles both personal account and group contexts
14
+ */
15
+ export declare const useTenant: (groupApiUrl: string, userId?: string) => UseTenantResult;
@@ -0,0 +1,278 @@
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
+ /**
11
+ * Which context each account last worked in, keyed by user id.
12
+ *
13
+ * Deliberately *not* in SESSION_SCOPED_KEYS: `logout()` clears the active
14
+ * selection above, which is right — `useTenantFilter` reads that key
15
+ * synchronously with no idea who is signed in, so leaving a group id there
16
+ * across a sign-out is what sent the next account's requests out with a
17
+ * groupId it is not a member of. This is the other half: a preference, per
18
+ * account, that survives a sign-out so signing back in returns you to the
19
+ * group you were working in rather than dropping you on Personal every time.
20
+ *
21
+ * Keyed by email rather than stamped with one, so a second account signing in
22
+ * on the same browser reads its own entry or none, never someone else's.
23
+ */
24
+ const LAST_TENANT_KEY = 'lastTenantByUser';
25
+ const readLastTenants = () => {
26
+ try {
27
+ const raw = localStorage.getItem(LAST_TENANT_KEY);
28
+ return raw ? JSON.parse(raw) : {};
29
+ }
30
+ catch (err) {
31
+ logger.warn('Failed to read the last tenant per account', { error: err });
32
+ return {};
33
+ }
34
+ };
35
+ /** The context this account was last in, or null for an account with no history. */
36
+ const loadLastTenant = (userId) => {
37
+ if (!userId)
38
+ return null;
39
+ return readLastTenants()[userId] ?? null;
40
+ };
41
+ const rememberLastTenant = (tenant, userId) => {
42
+ if (!userId)
43
+ return;
44
+ try {
45
+ localStorage.setItem(LAST_TENANT_KEY, JSON.stringify({ ...readLastTenants(), [userId]: tenant }));
46
+ }
47
+ catch (err) {
48
+ logger.warn('Failed to remember the last tenant', { error: err });
49
+ }
50
+ };
51
+ /** The stamp is storage bookkeeping; consumers see a plain TenantOption. */
52
+ const toOption = ({ id, name, type }) => ({ id, name, type });
53
+ // Global cache to prevent multiple simultaneous requests across all hook instances
54
+ let globalGroupsFetchCache = null;
55
+ let globalGroupsCacheTimestamp = 0;
56
+ let globalGroupsCacheKey = '';
57
+ let requestCounter = 0; // Debug counter
58
+ const CACHE_DURATION = 30000; // 30 seconds
59
+ // Global function to fetch groups - shared across all hook instances. Keyed by
60
+ // user as well as URL: the cache outlives a sign-out, and handing the incoming
61
+ // account the previous one's group list is how a stale selection gets
62
+ // "validated" and survives.
63
+ const fetchGroupsGlobally = async (groupApiUrl, userId) => {
64
+ requestCounter++;
65
+ logger.debug('fetchGroupsGlobally called', { requestNumber: requestCounter, groupApiUrl });
66
+ // Check if we have a valid cached request for the same API URL and user
67
+ const now = Date.now();
68
+ const cacheKey = `${userId ?? ''}|${groupApiUrl}`;
69
+ if (globalGroupsFetchCache &&
70
+ globalGroupsCacheKey === cacheKey &&
71
+ now - globalGroupsCacheTimestamp < CACHE_DURATION) {
72
+ logger.debug('Using cached groups request', { groupApiUrl });
73
+ return globalGroupsFetchCache;
74
+ }
75
+ logger.debug('Making new groups API request', { groupApiUrl });
76
+ // Create new request and cache it
77
+ const fetchRequest = async () => {
78
+ try {
79
+ // Check if groupApiUrl is provided
80
+ if (!groupApiUrl) {
81
+ logger.warn('No GROUP_API_URL provided to useTenant hook');
82
+ return [];
83
+ }
84
+ const headers = {
85
+ 'Content-Type': 'application/json',
86
+ 'x-correlation-id': getCorrelationId(),
87
+ 'x-session-id': getSessionId()
88
+ };
89
+ // Use user authentication for fetching groups; the httpOnly access_token
90
+ // cookie is attached automatically by the browser.
91
+ const response = await fetch(`${groupApiUrl}/api/groups/mine`, {
92
+ method: 'GET',
93
+ credentials: 'include',
94
+ headers,
95
+ signal: AbortSignal.timeout(5000) // 5 second timeout
96
+ });
97
+ logger.debug('Groups API response', { status: response.status, statusText: response.statusText });
98
+ if (!response.ok) {
99
+ throw new Error(`Failed to fetch groups: ${response.status} ${response.statusText}`);
100
+ }
101
+ const data = await response.json();
102
+ if (!data.success) {
103
+ throw new Error('API returned unsuccessful response');
104
+ }
105
+ // Transform groups to tenant options
106
+ return data.data.map((group) => ({
107
+ id: group._id,
108
+ name: group.name,
109
+ type: 'group'
110
+ }));
111
+ }
112
+ catch (err) {
113
+ logger.error('Error fetching user groups', err);
114
+ throw err;
115
+ }
116
+ };
117
+ // Cache the request
118
+ globalGroupsFetchCache = fetchRequest();
119
+ globalGroupsCacheTimestamp = now;
120
+ globalGroupsCacheKey = cacheKey;
121
+ // Clear cache after request completes (success or failure). `.finally()`
122
+ // returns a new derived promise that rejects if the request did — nothing
123
+ // else observes that derived promise (callers only await the original
124
+ // `globalGroupsFetchCache` reference returned below), so without a no-op
125
+ // `.catch()` here a failed request logs as an unhandled rejection.
126
+ globalGroupsFetchCache
127
+ .finally(() => {
128
+ setTimeout(() => {
129
+ if (globalGroupsFetchCache && Date.now() - globalGroupsCacheTimestamp >= CACHE_DURATION) {
130
+ globalGroupsFetchCache = null;
131
+ globalGroupsCacheKey = '';
132
+ }
133
+ }, CACHE_DURATION);
134
+ })
135
+ .catch(() => { });
136
+ return globalGroupsFetchCache;
137
+ };
138
+ /**
139
+ * Custom hook for managing tenant selection
140
+ * Handles both personal account and group contexts
141
+ */
142
+ export const useTenant = (groupApiUrl, userId) => {
143
+ const [selectedTenant, setSelectedTenantState] = useState(null);
144
+ const [availableTenants, setAvailableTenants] = useState([]);
145
+ const [loading, setLoading] = useState(true);
146
+ const [error, setError] = useState(null);
147
+ // Load saved tenant selection from localStorage
148
+ const loadSavedTenant = useCallback(() => {
149
+ try {
150
+ const saved = localStorage.getItem(STORAGE_KEY);
151
+ if (saved) {
152
+ const tenant = JSON.parse(saved);
153
+ return tenant;
154
+ }
155
+ }
156
+ catch (err) {
157
+ logger.warn('Failed to load saved tenant selection', { error: err });
158
+ }
159
+ return null;
160
+ }, []);
161
+ // Save tenant selection to localStorage, stamped with the current account
162
+ const saveTenant = useCallback((tenant) => {
163
+ try {
164
+ const stored = { ...tenant, userId };
165
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
166
+ }
167
+ catch (err) {
168
+ logger.warn('Failed to save tenant selection', { error: err });
169
+ }
170
+ // Outlives the sign-out that clears the line above.
171
+ rememberLastTenant(tenant, userId);
172
+ }, [userId]);
173
+ // Fetch available groups from API using user authentication
174
+ const fetchGroups = useCallback(async () => {
175
+ return fetchGroupsGlobally(groupApiUrl, userId);
176
+ }, [groupApiUrl, userId]);
177
+ // Refresh tenants list
178
+ const refreshTenants = useCallback(async () => {
179
+ if (!userId) {
180
+ setLoading(false);
181
+ return;
182
+ }
183
+ setLoading(true);
184
+ setError(null);
185
+ try {
186
+ // Always include personal account
187
+ const personalTenant = {
188
+ id: 'personal',
189
+ name: 'Personal',
190
+ type: 'personal'
191
+ };
192
+ // Fetch groups
193
+ const groupTenants = await fetchGroups();
194
+ const allTenants = [personalTenant, ...groupTenants];
195
+ setAvailableTenants(allTenants);
196
+ // Set default selection if none exists
197
+ const savedTenant = loadSavedTenant();
198
+ // A selection made by a different account is not ours to keep, even if
199
+ // this account happens to belong to that group too — an unstamped one
200
+ // predates this check and is validated by membership alone.
201
+ const belongsToThisUser = !savedTenant?.userId || savedTenant.userId === userId;
202
+ const stillAMember = allTenants.some((t) => t.id === savedTenant?.id);
203
+ if (savedTenant && belongsToThisUser && stillAMember) {
204
+ setSelectedTenantState(toOption(savedTenant));
205
+ }
206
+ else {
207
+ // No active selection this account can use. Before falling back to
208
+ // Personal, see where this account was last working: a sign-out clears
209
+ // the active key, and without this every sign-in landed on Personal
210
+ // however long you had been in a group.
211
+ const lastUsed = loadLastTenant(userId);
212
+ const restorable = lastUsed && lastUsed.type === 'group' && allTenants.some((tenant) => tenant.id === lastUsed.id)
213
+ ? lastUsed
214
+ : null;
215
+ const next = restorable ?? personalTenant;
216
+ setSelectedTenantState(next);
217
+ saveTenant(next);
218
+ // Sibling components read the raw storage value synchronously on mount
219
+ // (useTenantFilter), so by now they have already fired their requests
220
+ // against whatever storage said a moment ago — the discarded groupId,
221
+ // or nothing at all where we have just restored one. Reload for the
222
+ // same reason switching tenant by hand reloads: it is the only way
223
+ // this app re-reads the context. Storage now agrees with state, so the
224
+ // next pass takes the branch above and this does not loop.
225
+ if (restorable) {
226
+ logger.debug('Restored the context this account last used', { name: restorable.name });
227
+ window.location.reload();
228
+ }
229
+ else if (savedTenant?.type === 'group') {
230
+ logger.debug('Discarded a tenant selection this account cannot use; reloading');
231
+ window.location.reload();
232
+ }
233
+ }
234
+ }
235
+ catch (err) {
236
+ const errorMessage = err instanceof Error ? err.message : 'Failed to load contexts';
237
+ logger.error('Error in refreshTenants', err);
238
+ setError(errorMessage);
239
+ // The groups call failed, so we cannot tell a stale selection from a
240
+ // valid one. Keep showing what storage says rather than claiming
241
+ // "Personal" while useTenantFilter keeps sending the saved groupId —
242
+ // that mismatch is what made the UI look like it had switched context
243
+ // when it had not. TenantSelector renders the error state instead.
244
+ const personalTenant = {
245
+ id: 'personal',
246
+ name: 'Personal',
247
+ type: 'personal'
248
+ };
249
+ setAvailableTenants([personalTenant]);
250
+ const savedTenant = loadSavedTenant();
251
+ setSelectedTenantState(savedTenant ? toOption(savedTenant) : personalTenant);
252
+ }
253
+ finally {
254
+ setLoading(false);
255
+ }
256
+ }, [userId, fetchGroups, loadSavedTenant, saveTenant]);
257
+ // Set selected tenant with page reload
258
+ const setSelectedTenant = useCallback((tenant) => {
259
+ logger.debug('Tenant changing', { to: tenant.type === 'personal' ? 'Personal' : tenant.name });
260
+ // Save the new tenant selection
261
+ saveTenant(tenant);
262
+ // Reload the page to refresh all data with the new context
263
+ logger.debug('Reloading page to apply new tenant context');
264
+ window.location.reload();
265
+ }, [saveTenant]);
266
+ // Initialize on mount and when user changes
267
+ useEffect(() => {
268
+ refreshTenants();
269
+ }, [refreshTenants]);
270
+ return {
271
+ selectedTenant,
272
+ availableTenants,
273
+ loading,
274
+ error,
275
+ setSelectedTenant,
276
+ refreshTenants
277
+ };
278
+ };
@@ -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';