@tumbaland/frontend-core 1.14.1 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authService.d.ts +10 -0
- package/dist/authService.js +18 -3
- package/dist/components/AppErrorBoundary/AppErrorBoundary.d.ts +26 -0
- package/dist/components/AppErrorBoundary/AppErrorBoundary.js +25 -0
- package/dist/components/AppErrorBoundary/index.d.ts +2 -0
- package/dist/components/AppErrorBoundary/index.js +1 -0
- package/dist/components/AppLayout/AppLayout.d.ts +27 -0
- package/dist/components/AppLayout/AppLayout.js +45 -0
- package/dist/components/AppLayout/index.d.ts +2 -0
- package/dist/components/AppLayout/index.js +1 -0
- package/dist/components/AuthHeader/AuthHeader.d.ts +57 -0
- package/dist/components/AuthHeader/AuthHeader.js +135 -0
- package/dist/components/AuthHeader/index.d.ts +2 -0
- package/dist/components/AuthHeader/index.js +1 -0
- package/dist/components/ErrorPage/ErrorPage.d.ts +17 -0
- package/dist/components/ErrorPage/ErrorPage.js +19 -0
- package/dist/components/ErrorPage/index.d.ts +2 -0
- package/dist/components/ErrorPage/index.js +1 -0
- package/dist/components/Loader/Loader.d.ts +12 -0
- package/dist/components/Loader/Loader.js +15 -0
- package/dist/components/Loader/index.d.ts +2 -0
- package/dist/components/Loader/index.js +1 -0
- package/dist/components/LoginButton/LoginButton.d.ts +5 -0
- package/dist/components/LoginButton/LoginButton.js +15 -0
- package/dist/components/LoginButton/index.d.ts +2 -0
- package/dist/components/LoginButton/index.js +1 -0
- package/dist/components/NotificationsMenu/NotificationsMenu.d.ts +9 -0
- package/dist/components/NotificationsMenu/NotificationsMenu.js +29 -0
- package/dist/components/ProtectedRoute/ProtectedRoute.d.ts +33 -0
- package/dist/components/ProtectedRoute/ProtectedRoute.js +50 -0
- package/dist/components/ProtectedRoute/index.d.ts +2 -0
- package/dist/components/ProtectedRoute/index.js +1 -0
- package/dist/components/UnauthorizedPage/UnauthorizedPage.d.ts +5 -0
- package/dist/components/UnauthorizedPage/UnauthorizedPage.js +9 -0
- package/dist/components/UnauthorizedPage/index.d.ts +2 -0
- package/dist/components/UnauthorizedPage/index.js +1 -0
- package/dist/components/UsageMeter/UsageMeter.d.ts +35 -0
- package/dist/components/UsageMeter/UsageMeter.js +50 -0
- package/dist/components/UsageMeter/index.d.ts +4 -0
- package/dist/components/UsageMeter/index.js +2 -0
- package/dist/components/UsageMeter/useEntitlements.d.ts +27 -0
- package/dist/components/UsageMeter/useEntitlements.js +56 -0
- package/dist/components/tenant/TenantProvider.d.ts +11 -0
- package/dist/components/tenant/TenantProvider.js +53 -0
- package/dist/components/tenant/TenantSelector.d.ts +24 -0
- package/dist/components/tenant/TenantSelector.js +77 -0
- package/dist/components/tenant/index.d.ts +5 -0
- package/dist/components/tenant/index.js +3 -0
- package/dist/components/tenant/useTenant.d.ts +15 -0
- package/dist/components/tenant/useTenant.js +222 -0
- package/dist/components/tenant/useTenantDataRefresh.d.ts +6 -0
- package/dist/components/tenant/useTenantDataRefresh.js +28 -0
- package/dist/config/createConfigProvider.d.ts +44 -0
- package/dist/config/createConfigProvider.js +92 -0
- package/dist/federation.d.ts +46 -0
- package/dist/federation.js +37 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +34 -1
- package/dist/monitoring/batch.js +3 -3
- package/dist/monitoring/config.js +1 -4
- package/dist/monitoring/console.js +1 -1
- package/dist/plansService.js +1 -1
- package/dist/routing/embedContext.d.ts +31 -0
- package/dist/routing/embedContext.js +63 -0
- package/dist/routing/index.d.ts +12 -0
- package/dist/routing/index.js +12 -0
- package/dist/sessionStorage.d.ts +1 -1
- package/dist/sessionStorage.js +1 -1
- package/dist/theme/createTumbalandTheme.d.ts +38 -0
- package/dist/theme/createTumbalandTheme.js +245 -0
- package/dist/theme/index.d.ts +2 -0
- package/dist/theme/index.js +2 -0
- package/dist/theme/tokens.d.ts +169 -0
- package/dist/theme/tokens.js +162 -0
- package/package.json +28 -2
- package/dist/test/setup.d.ts +0 -9
- package/dist/test/setup.js +0 -26
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { logger } from '../../monitoring';
|
|
4
|
+
import { Loader } from '../Loader';
|
|
5
|
+
import { UnauthorizedPage } from '../UnauthorizedPage';
|
|
6
|
+
/**
|
|
7
|
+
* Gates its children behind an authenticated session: renders a Loader while
|
|
8
|
+
* the check is in flight, UnauthorizedPage (whose login action calls the auth
|
|
9
|
+
* service's redirectToLogin) when unauthenticated or the check fails, and the
|
|
10
|
+
* children once authenticated.
|
|
11
|
+
*
|
|
12
|
+
* The first state comes from the auth service's cache when it has one, so a
|
|
13
|
+
* remount does not flash "Checking authentication..." over an answer already
|
|
14
|
+
* held in memory. The shell mounts and unmounts this every time the user
|
|
15
|
+
* crosses between its own pages and a framed module — which, on a phone, is
|
|
16
|
+
* every other tap on the tab bar — and each of those was a spinner for an
|
|
17
|
+
* answer that never changed. The check below still runs either way; the cache
|
|
18
|
+
* only decides what is painted while it does.
|
|
19
|
+
*
|
|
20
|
+
* Consolidated from seven per-front copies that had drifted into two
|
|
21
|
+
* behaviourally different variants (one polling `checkAuth`, one
|
|
22
|
+
* `isAuthenticated`, with different initial/loading semantics).
|
|
23
|
+
*/
|
|
24
|
+
export function ProtectedRoute({ children, authService, loadingMessage = 'Checking authentication...' }) {
|
|
25
|
+
const [isAuthenticated, setIsAuthenticated] = useState(() => authService.getCachedAuth?.()?.authenticated ?? null);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
let active = true;
|
|
28
|
+
authService
|
|
29
|
+
.isAuthenticated()
|
|
30
|
+
.then((authenticated) => {
|
|
31
|
+
if (active)
|
|
32
|
+
setIsAuthenticated(authenticated);
|
|
33
|
+
})
|
|
34
|
+
.catch((error) => {
|
|
35
|
+
logger.error('Auth check failed', error);
|
|
36
|
+
if (active)
|
|
37
|
+
setIsAuthenticated(false);
|
|
38
|
+
});
|
|
39
|
+
return () => {
|
|
40
|
+
active = false;
|
|
41
|
+
};
|
|
42
|
+
}, [authService]);
|
|
43
|
+
if (isAuthenticated === null) {
|
|
44
|
+
return _jsx(Loader, { message: loadingMessage });
|
|
45
|
+
}
|
|
46
|
+
if (!isAuthenticated) {
|
|
47
|
+
return _jsx(UnauthorizedPage, { onLogin: () => authService.redirectToLogin() });
|
|
48
|
+
}
|
|
49
|
+
return _jsx(_Fragment, { children: children });
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ProtectedRoute } from './ProtectedRoute';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Typography, Button, Container, Paper } from '@mui/material';
|
|
3
|
+
import { Lock, Login } from '@mui/icons-material';
|
|
4
|
+
export const UnauthorizedPage = ({ onLogin }) => {
|
|
5
|
+
return (_jsx(Container, { maxWidth: "sm", sx: { mt: 8 }, children: _jsxs(Paper, { elevation: 3, sx: { p: 4, textAlign: 'center' }, children: [_jsx(Lock, { sx: { fontSize: 64, color: 'primary.main', mb: 2 } }), _jsx(Typography, { variant: "h4", component: "h1", gutterBottom: true, color: "primary", children: "Access Restricted" }), _jsx(Typography, { variant: "body1", sx: {
|
|
6
|
+
color: 'text.secondary',
|
|
7
|
+
mb: 2
|
|
8
|
+
}, children: "You need to be logged in to access the Tumbaland." }), _jsx(Box, { sx: { mt: 4 }, children: _jsx(Button, { variant: "contained", size: "large", startIcon: _jsx(Login, {}), onClick: onLogin, sx: { px: 4, py: 1.5 }, children: "Sign In to Continue" }) })] }) }));
|
|
9
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { UnauthorizedPage } from './UnauthorizedPage';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface UsageMeterProps {
|
|
2
|
+
/** What is being metered, e.g. "Storage" or "Tracked tickers". */
|
|
3
|
+
label: string;
|
|
4
|
+
used: number;
|
|
5
|
+
/** `-1` (or any negative) means no ceiling. */
|
|
6
|
+
limit: number;
|
|
7
|
+
/** Bytes are humanized; counts are printed as-is. */
|
|
8
|
+
unit?: 'bytes' | 'count';
|
|
9
|
+
/** Shown once the meter is at or past the warning threshold. */
|
|
10
|
+
onUpgrade?: () => void;
|
|
11
|
+
upgradeLabel?: string;
|
|
12
|
+
/** Tighter spacing, for a meter sitting inside a list row rather than a card. */
|
|
13
|
+
dense?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Humanizes to the largest unit that keeps the number readable.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately binary (1 KB = 1024 B) to match the limits, which are set in
|
|
19
|
+
* powers of two — `1 * GB` in `DEFAULT_PLAN_LIMITS` is 1073741824, and
|
|
20
|
+
* rendering that as "1.07 GB" against a "1 GB" plan would look like an error.
|
|
21
|
+
*/
|
|
22
|
+
export declare function formatBytes(bytes: number): string;
|
|
23
|
+
/**
|
|
24
|
+
* A labelled usage bar: what you have used, out of what your plan allows.
|
|
25
|
+
*
|
|
26
|
+
* The single highest-leverage piece of the paywall — people upgrade when they
|
|
27
|
+
* can see the ceiling coming, not when they hit it. It is also what keeps
|
|
28
|
+
* enforcement from reading as breakage: a 402 with no meter beside it is
|
|
29
|
+
* indistinguishable from a bug.
|
|
30
|
+
*
|
|
31
|
+
* Purely presentational. It takes numbers, never fetches them, so the same
|
|
32
|
+
* component serves the album dashboard, the ticker list, the group member list
|
|
33
|
+
* and the pricing table without any of them agreeing on a data layer.
|
|
34
|
+
*/
|
|
35
|
+
export declare function UsageMeter({ label, used, limit, unit, onUpgrade, upgradeLabel, dense }: UsageMeterProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Button, LinearProgress, Tooltip, Typography } from '@mui/material';
|
|
3
|
+
import { isUnlimited, usageRatio } from '../../entitlementsService';
|
|
4
|
+
/** Fraction of a meter above which the bar turns amber. */
|
|
5
|
+
const WARN_AT = 0.8;
|
|
6
|
+
/**
|
|
7
|
+
* Humanizes to the largest unit that keeps the number readable.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately binary (1 KB = 1024 B) to match the limits, which are set in
|
|
10
|
+
* powers of two — `1 * GB` in `DEFAULT_PLAN_LIMITS` is 1073741824, and
|
|
11
|
+
* rendering that as "1.07 GB" against a "1 GB" plan would look like an error.
|
|
12
|
+
*/
|
|
13
|
+
export function formatBytes(bytes) {
|
|
14
|
+
if (!Number.isFinite(bytes) || bytes <= 0)
|
|
15
|
+
return '0 B';
|
|
16
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
17
|
+
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
18
|
+
const value = bytes / Math.pow(1024, exponent);
|
|
19
|
+
// Whole units read better without a trailing ".00"; fractions need a digit.
|
|
20
|
+
const decimals = exponent === 0 || Number.isInteger(value) ? 0 : value < 10 ? 2 : 1;
|
|
21
|
+
return `${value.toFixed(decimals)} ${units[exponent]}`;
|
|
22
|
+
}
|
|
23
|
+
function formatValue(value, unit) {
|
|
24
|
+
return unit === 'bytes' ? formatBytes(value) : value.toLocaleString();
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* A labelled usage bar: what you have used, out of what your plan allows.
|
|
28
|
+
*
|
|
29
|
+
* The single highest-leverage piece of the paywall — people upgrade when they
|
|
30
|
+
* can see the ceiling coming, not when they hit it. It is also what keeps
|
|
31
|
+
* enforcement from reading as breakage: a 402 with no meter beside it is
|
|
32
|
+
* indistinguishable from a bug.
|
|
33
|
+
*
|
|
34
|
+
* Purely presentational. It takes numbers, never fetches them, so the same
|
|
35
|
+
* component serves the album dashboard, the ticker list, the group member list
|
|
36
|
+
* and the pricing table without any of them agreeing on a data layer.
|
|
37
|
+
*/
|
|
38
|
+
export function UsageMeter({ label, used, limit, unit = 'count', onUpgrade, upgradeLabel = 'Upgrade', dense = false }) {
|
|
39
|
+
const unlimited = isUnlimited(limit);
|
|
40
|
+
const ratio = usageRatio(used, limit);
|
|
41
|
+
const atLimit = !unlimited && used >= limit;
|
|
42
|
+
const warning = !unlimited && ratio >= WARN_AT;
|
|
43
|
+
const color = atLimit ? 'error' : warning ? 'warning' : 'primary';
|
|
44
|
+
const summary = unlimited
|
|
45
|
+
? `${formatValue(used, unit)} used`
|
|
46
|
+
: `${formatValue(used, unit)} of ${formatValue(limit, unit)}`;
|
|
47
|
+
return (_jsxs(Box, { sx: { width: '100%', mb: dense ? 1 : 2 }, children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 1, mb: 0.5 }, children: [_jsx(Typography, { variant: dense ? 'caption' : 'body2', sx: { color: 'text.secondary', fontWeight: 500 }, children: label }), _jsx(Typography, { variant: dense ? 'caption' : 'body2', sx: { fontWeight: 600, color: atLimit ? 'error.main' : 'text.primary' }, children: summary })] }), unlimited ? (_jsx(Typography, { variant: "caption", sx: { color: 'text.secondary' }, children: "Unlimited on your plan" })) : (_jsx(Tooltip, { title: `${Math.round(ratio * 100)}% of ${label.toLowerCase()} used`, children: _jsx(LinearProgress, { variant: "determinate", value: ratio * 100, color: color, "aria-label": label, "aria-valuenow": used, "aria-valuemin": 0, "aria-valuemax": limit, sx: { height: dense ? 4 : 8, borderRadius: 4 } }) })), onUpgrade && warning && (_jsxs(Box, { sx: { mt: 1, display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(Typography, { variant: "caption", sx: { color: atLimit ? 'error.main' : 'warning.main' }, children: atLimit
|
|
48
|
+
? `You have reached your ${label.toLowerCase()} limit.`
|
|
49
|
+
: `You are close to your ${label.toLowerCase()} limit.` }), _jsx(Button, { size: "small", variant: "text", onClick: onUpgrade, children: upgradeLabel })] }))] }));
|
|
50
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Entitlements } from '../../entitlementsService';
|
|
2
|
+
export interface UseEntitlementsResult {
|
|
3
|
+
entitlements: Entitlements | null;
|
|
4
|
+
loading: boolean;
|
|
5
|
+
/** Set when the lookup failed. The UI should hide the meter, not show an error. */
|
|
6
|
+
error: Error | null;
|
|
7
|
+
reload: () => void;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Loads the caller's plan, limits and live usage once per mount.
|
|
11
|
+
*
|
|
12
|
+
* Takes the fetcher rather than building it, because each front resolves
|
|
13
|
+
* `PAYMENT_API_URL` through its own `ConfigProvider` and this library has no
|
|
14
|
+
* access to any of them.
|
|
15
|
+
*
|
|
16
|
+
* **For the five fronts without `@tanstack/react-query`** — account, group,
|
|
17
|
+
* payment, relationship and shell. album-front and finance-front already have
|
|
18
|
+
* react-query and should use `useQuery` instead, which gives them caching and
|
|
19
|
+
* revalidation this deliberately does not try to reimplement.
|
|
20
|
+
*
|
|
21
|
+
* **Failure is not surfaced as an error state by design.** A meter is
|
|
22
|
+
* decoration on top of a page that works without it — if payment-service is
|
|
23
|
+
* unreachable, the album dashboard should still render albums. Callers check
|
|
24
|
+
* `entitlements` for null and omit the meter; nobody gets a red box because a
|
|
25
|
+
* usage bar could not load.
|
|
26
|
+
*/
|
|
27
|
+
export declare function useEntitlements(fetcher: () => Promise<Entitlements>): UseEntitlementsResult;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Loads the caller's plan, limits and live usage once per mount.
|
|
4
|
+
*
|
|
5
|
+
* Takes the fetcher rather than building it, because each front resolves
|
|
6
|
+
* `PAYMENT_API_URL` through its own `ConfigProvider` and this library has no
|
|
7
|
+
* access to any of them.
|
|
8
|
+
*
|
|
9
|
+
* **For the five fronts without `@tanstack/react-query`** — account, group,
|
|
10
|
+
* payment, relationship and shell. album-front and finance-front already have
|
|
11
|
+
* react-query and should use `useQuery` instead, which gives them caching and
|
|
12
|
+
* revalidation this deliberately does not try to reimplement.
|
|
13
|
+
*
|
|
14
|
+
* **Failure is not surfaced as an error state by design.** A meter is
|
|
15
|
+
* decoration on top of a page that works without it — if payment-service is
|
|
16
|
+
* unreachable, the album dashboard should still render albums. Callers check
|
|
17
|
+
* `entitlements` for null and omit the meter; nobody gets a red box because a
|
|
18
|
+
* usage bar could not load.
|
|
19
|
+
*/
|
|
20
|
+
export function useEntitlements(fetcher) {
|
|
21
|
+
const [entitlements, setEntitlements] = useState(null);
|
|
22
|
+
const [loading, setLoading] = useState(true);
|
|
23
|
+
const [error, setError] = useState(null);
|
|
24
|
+
const [reloadToken, setReloadToken] = useState(0);
|
|
25
|
+
const reload = useCallback(() => setReloadToken((token) => token + 1), []);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
// Guards against a state update after unmount, and against a slow first
|
|
28
|
+
// response overwriting a fresher one after `reload`.
|
|
29
|
+
let active = true;
|
|
30
|
+
setLoading(true);
|
|
31
|
+
fetcher()
|
|
32
|
+
.then((result) => {
|
|
33
|
+
if (!active)
|
|
34
|
+
return;
|
|
35
|
+
setEntitlements(result);
|
|
36
|
+
setError(null);
|
|
37
|
+
})
|
|
38
|
+
.catch((err) => {
|
|
39
|
+
if (!active)
|
|
40
|
+
return;
|
|
41
|
+
setEntitlements(null);
|
|
42
|
+
setError(err);
|
|
43
|
+
})
|
|
44
|
+
.finally(() => {
|
|
45
|
+
if (active)
|
|
46
|
+
setLoading(false);
|
|
47
|
+
});
|
|
48
|
+
return () => {
|
|
49
|
+
active = false;
|
|
50
|
+
};
|
|
51
|
+
// `fetcher` is intentionally not a dependency: callers commonly pass an
|
|
52
|
+
// inline arrow, and depending on it would refetch on every render.
|
|
53
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
54
|
+
}, [reloadToken]);
|
|
55
|
+
return { entitlements, loading, error, reload };
|
|
56
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export interface TenantOption {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
type: 'personal' | 'group';
|
|
6
|
+
}
|
|
7
|
+
export declare const TenantProvider: React.FC<{
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
}>;
|
|
10
|
+
export declare const useTenantListener: (callback: (tenant: TenantOption) => void) => void;
|
|
11
|
+
export declare const useTenantContext: () => TenantOption | null;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useCallback, createContext, useContext, useEffect } from 'react';
|
|
3
|
+
import { logger } from '../../monitoring';
|
|
4
|
+
const TenantContext = createContext(null);
|
|
5
|
+
// Global listeners for tenant changes
|
|
6
|
+
const tenantChangeListeners = [];
|
|
7
|
+
export const TenantProvider = ({ children }) => {
|
|
8
|
+
const [globalTenant, setGlobalTenant] = useState(null);
|
|
9
|
+
const handleTenantChange = useCallback((tenant) => {
|
|
10
|
+
setGlobalTenant(tenant);
|
|
11
|
+
// Notify all registered listeners
|
|
12
|
+
tenantChangeListeners.forEach((listener) => {
|
|
13
|
+
try {
|
|
14
|
+
listener(tenant);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
logger.error('Error in tenant change listener', error);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
}, []);
|
|
21
|
+
const registerTenantChangeListener = useCallback((callback) => {
|
|
22
|
+
tenantChangeListeners.push(callback);
|
|
23
|
+
// Return cleanup function
|
|
24
|
+
return () => {
|
|
25
|
+
const index = tenantChangeListeners.indexOf(callback);
|
|
26
|
+
if (index > -1) {
|
|
27
|
+
tenantChangeListeners.splice(index, 1);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}, []);
|
|
31
|
+
const contextValue = {
|
|
32
|
+
selectedTenant: globalTenant,
|
|
33
|
+
onTenantChange: handleTenantChange,
|
|
34
|
+
registerTenantChangeListener
|
|
35
|
+
};
|
|
36
|
+
return _jsx(TenantContext.Provider, { value: contextValue, children: children });
|
|
37
|
+
};
|
|
38
|
+
// Hook to listen for tenant changes in components
|
|
39
|
+
export const useTenantListener = (callback) => {
|
|
40
|
+
const context = useContext(TenantContext);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!context) {
|
|
43
|
+
logger.warn('useTenantListener used outside TenantProvider');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
return context.registerTenantChangeListener(callback);
|
|
47
|
+
}, [context, callback]);
|
|
48
|
+
};
|
|
49
|
+
// Hook to get current tenant context
|
|
50
|
+
export const useTenantContext = () => {
|
|
51
|
+
const context = useContext(TenantContext);
|
|
52
|
+
return context?.selectedTenant || null;
|
|
53
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { SxProps, Theme } from '@mui/material';
|
|
3
|
+
export interface TenantOption {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
type: 'personal' | 'group';
|
|
7
|
+
isSelected?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface TenantSelectorProps {
|
|
10
|
+
/** Current selected tenant */
|
|
11
|
+
selectedTenant: TenantOption | null;
|
|
12
|
+
/** Available tenant options */
|
|
13
|
+
tenants: TenantOption[];
|
|
14
|
+
/** Callback when tenant is selected */
|
|
15
|
+
onTenantChange: (tenant: TenantOption) => void;
|
|
16
|
+
/** Loading state for fetching tenants */
|
|
17
|
+
loading?: boolean;
|
|
18
|
+
/** Error state */
|
|
19
|
+
error?: string | null;
|
|
20
|
+
/** Custom styling */
|
|
21
|
+
sx?: SxProps<Theme>;
|
|
22
|
+
}
|
|
23
|
+
declare const TenantSelector: React.FC<TenantSelectorProps>;
|
|
24
|
+
export default TenantSelector;
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
const TenantSelector = ({ selectedTenant, tenants = [], onTenantChange, loading = false, error = null, sx = {} }) => {
|
|
6
|
+
const [anchorEl, setAnchorEl] = useState(null);
|
|
7
|
+
const isOpen = Boolean(anchorEl);
|
|
8
|
+
const handleClick = (event) => {
|
|
9
|
+
setAnchorEl(event.currentTarget);
|
|
10
|
+
};
|
|
11
|
+
const handleClose = () => {
|
|
12
|
+
setAnchorEl(null);
|
|
13
|
+
};
|
|
14
|
+
const handleTenantSelect = (tenant) => {
|
|
15
|
+
onTenantChange(tenant);
|
|
16
|
+
handleClose();
|
|
17
|
+
};
|
|
18
|
+
const getDisplayName = (tenant) => {
|
|
19
|
+
if (!tenant)
|
|
20
|
+
return 'Select Context';
|
|
21
|
+
return tenant.type === 'personal' ? 'Personal' : tenant.name;
|
|
22
|
+
};
|
|
23
|
+
const getIcon = (type) => {
|
|
24
|
+
return type === 'personal' ? _jsx(PersonOutlined, { fontSize: "small" }) : _jsx(Groups, { fontSize: "small" });
|
|
25
|
+
};
|
|
26
|
+
if (error) {
|
|
27
|
+
return _jsx(Chip, { label: "Error loading contexts", color: "error", size: "small", sx: { ...sx } });
|
|
28
|
+
}
|
|
29
|
+
return (_jsxs(Box, { sx: { ...sx }, children: [_jsx(Button, { onClick: handleClick, disabled: loading || tenants.length === 0, endIcon: _jsx(ExpandMore, {}), startIcon: selectedTenant ? getIcon(selectedTenant.type) : _jsx(PersonOutlined, { fontSize: "small" }), sx: {
|
|
30
|
+
textTransform: 'none',
|
|
31
|
+
fontWeight: 500,
|
|
32
|
+
borderRadius: 2,
|
|
33
|
+
px: 2,
|
|
34
|
+
py: 0.5,
|
|
35
|
+
minWidth: 140,
|
|
36
|
+
justifyContent: 'flex-start',
|
|
37
|
+
color: 'text.primary',
|
|
38
|
+
border: '1px solid',
|
|
39
|
+
borderColor: 'divider',
|
|
40
|
+
'&:hover': {
|
|
41
|
+
borderColor: 'primary.main',
|
|
42
|
+
backgroundColor: 'action.hover'
|
|
43
|
+
}
|
|
44
|
+
}, children: _jsxs(Box, { sx: { flexGrow: 1, textAlign: 'left', overflow: 'hidden' }, children: [_jsx(Typography, { variant: "caption", sx: {
|
|
45
|
+
color: 'text.secondary',
|
|
46
|
+
display: 'block',
|
|
47
|
+
fontSize: 10
|
|
48
|
+
}, children: "Context" }), _jsx(Typography, { variant: "body2", sx: {
|
|
49
|
+
display: 'block',
|
|
50
|
+
whiteSpace: 'nowrap',
|
|
51
|
+
overflow: 'hidden',
|
|
52
|
+
textOverflow: 'ellipsis',
|
|
53
|
+
maxWidth: 100
|
|
54
|
+
}, children: loading ? 'Loading...' : getDisplayName(selectedTenant) })] }) }), _jsxs(Menu, { anchorEl: anchorEl, open: isOpen, onClose: handleClose, anchorOrigin: {
|
|
55
|
+
vertical: 'bottom',
|
|
56
|
+
horizontal: 'left'
|
|
57
|
+
}, transformOrigin: {
|
|
58
|
+
vertical: 'top',
|
|
59
|
+
horizontal: 'left'
|
|
60
|
+
}, children: [_jsx(MenuItem, { disabled: true, children: _jsx(Typography, { variant: "caption", sx: {
|
|
61
|
+
color: 'text.secondary',
|
|
62
|
+
fontWeight: 600
|
|
63
|
+
}, children: "SELECT CONTEXT" }) }), _jsx(Divider, {}), tenants.map((tenant) => {
|
|
64
|
+
const isSelected = selectedTenant?.id === tenant.id;
|
|
65
|
+
return (_jsxs(MenuItem, { onClick: () => handleTenantSelect(tenant), selected: isSelected, sx: {
|
|
66
|
+
py: 1.5,
|
|
67
|
+
'&.Mui-selected': {
|
|
68
|
+
backgroundColor: 'primary.50'
|
|
69
|
+
}
|
|
70
|
+
}, children: [_jsx(ListItemIcon, { children: getIcon(tenant.type) }), _jsx(ListItemText, { primary: _jsxs(Box, { sx: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' }, children: [_jsx(Typography, { variant: "body2", sx: { fontWeight: 500 }, children: tenant.type === 'personal' ? 'Personal' : tenant.name }), isSelected && _jsx(Check, { fontSize: "small", color: "primary" })] }), secondary: _jsx(Typography, { variant: "caption", sx: {
|
|
71
|
+
color: 'text.secondary'
|
|
72
|
+
}, children: tenant.type === 'personal' ? 'Your personal data' : 'Group workspace' }) })] }, tenant.id));
|
|
73
|
+
}), tenants.length === 0 && !loading && (_jsx(MenuItem, { disabled: true, children: _jsx(ListItemText, { primary: _jsx(Typography, { variant: "body2", sx: {
|
|
74
|
+
color: 'text.secondary'
|
|
75
|
+
}, children: "No contexts available" }) }) }))] })] }));
|
|
76
|
+
};
|
|
77
|
+
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, userEmail?: string) => UseTenantResult;
|