@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.
- 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/SectionBar/SectionBar.d.ts +49 -0
- package/dist/components/SectionBar/SectionBar.js +143 -0
- package/dist/components/SectionBar/index.d.ts +2 -0
- package/dist/components/SectionBar/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 +33 -0
- package/dist/components/tenant/TenantSelector.js +88 -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 +278 -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 +28 -0
- package/dist/index.js +35 -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 +186 -0
- package/dist/theme/tokens.js +182 -0
- package/package.json +28 -2
- package/dist/test/setup.d.ts +0 -9
- 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,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';
|