@tumbaland/frontend-core 1.14.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authService.d.ts +10 -0
- package/dist/authService.js +18 -3
- package/dist/components/AppErrorBoundary/AppErrorBoundary.d.ts +26 -0
- package/dist/components/AppErrorBoundary/AppErrorBoundary.js +25 -0
- package/dist/components/AppErrorBoundary/index.d.ts +2 -0
- package/dist/components/AppErrorBoundary/index.js +1 -0
- package/dist/components/AppLayout/AppLayout.d.ts +27 -0
- package/dist/components/AppLayout/AppLayout.js +45 -0
- package/dist/components/AppLayout/index.d.ts +2 -0
- package/dist/components/AppLayout/index.js +1 -0
- package/dist/components/AuthHeader/AuthHeader.d.ts +57 -0
- package/dist/components/AuthHeader/AuthHeader.js +135 -0
- package/dist/components/AuthHeader/index.d.ts +2 -0
- package/dist/components/AuthHeader/index.js +1 -0
- package/dist/components/ErrorPage/ErrorPage.d.ts +17 -0
- package/dist/components/ErrorPage/ErrorPage.js +19 -0
- package/dist/components/ErrorPage/index.d.ts +2 -0
- package/dist/components/ErrorPage/index.js +1 -0
- package/dist/components/Loader/Loader.d.ts +12 -0
- package/dist/components/Loader/Loader.js +15 -0
- package/dist/components/Loader/index.d.ts +2 -0
- package/dist/components/Loader/index.js +1 -0
- package/dist/components/LoginButton/LoginButton.d.ts +5 -0
- package/dist/components/LoginButton/LoginButton.js +15 -0
- package/dist/components/LoginButton/index.d.ts +2 -0
- package/dist/components/LoginButton/index.js +1 -0
- package/dist/components/NotificationsMenu/NotificationsMenu.d.ts +9 -0
- package/dist/components/NotificationsMenu/NotificationsMenu.js +29 -0
- package/dist/components/ProtectedRoute/ProtectedRoute.d.ts +33 -0
- package/dist/components/ProtectedRoute/ProtectedRoute.js +50 -0
- package/dist/components/ProtectedRoute/index.d.ts +2 -0
- package/dist/components/ProtectedRoute/index.js +1 -0
- package/dist/components/UnauthorizedPage/UnauthorizedPage.d.ts +5 -0
- package/dist/components/UnauthorizedPage/UnauthorizedPage.js +9 -0
- package/dist/components/UnauthorizedPage/index.d.ts +2 -0
- package/dist/components/UnauthorizedPage/index.js +1 -0
- package/dist/components/UsageMeter/UsageMeter.d.ts +35 -0
- package/dist/components/UsageMeter/UsageMeter.js +50 -0
- package/dist/components/UsageMeter/index.d.ts +4 -0
- package/dist/components/UsageMeter/index.js +2 -0
- package/dist/components/UsageMeter/useEntitlements.d.ts +27 -0
- package/dist/components/UsageMeter/useEntitlements.js +56 -0
- package/dist/components/tenant/TenantProvider.d.ts +11 -0
- package/dist/components/tenant/TenantProvider.js +53 -0
- package/dist/components/tenant/TenantSelector.d.ts +24 -0
- package/dist/components/tenant/TenantSelector.js +77 -0
- package/dist/components/tenant/index.d.ts +5 -0
- package/dist/components/tenant/index.js +3 -0
- package/dist/components/tenant/useTenant.d.ts +15 -0
- package/dist/components/tenant/useTenant.js +222 -0
- package/dist/components/tenant/useTenantDataRefresh.d.ts +6 -0
- package/dist/components/tenant/useTenantDataRefresh.js +28 -0
- package/dist/config/createConfigProvider.d.ts +44 -0
- package/dist/config/createConfigProvider.js +92 -0
- package/dist/federation.d.ts +46 -0
- package/dist/federation.js +37 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +34 -1
- package/dist/monitoring/batch.js +3 -3
- package/dist/monitoring/config.js +1 -4
- package/dist/monitoring/console.js +1 -1
- package/dist/plansService.js +1 -1
- package/dist/routing/embedContext.d.ts +31 -0
- package/dist/routing/embedContext.js +63 -0
- package/dist/routing/index.d.ts +12 -0
- package/dist/routing/index.js +12 -0
- package/dist/sessionStorage.d.ts +1 -1
- package/dist/sessionStorage.js +1 -1
- package/dist/theme/createTumbalandTheme.d.ts +38 -0
- package/dist/theme/createTumbalandTheme.js +245 -0
- package/dist/theme/index.d.ts +2 -0
- package/dist/theme/index.js +2 -0
- package/dist/theme/tokens.d.ts +169 -0
- package/dist/theme/tokens.js +162 -0
- package/package.json +28 -2
- package/dist/test/setup.d.ts +0 -9
- package/dist/test/setup.js +0 -26
package/dist/authService.d.ts
CHANGED
|
@@ -33,6 +33,16 @@ export declare function createAuthService(config: AuthServiceConfig): {
|
|
|
33
33
|
/** Full profile (e.g. firstName/lastName) — only auth-service's /auth/profile has this. */
|
|
34
34
|
getProfile(): Promise<User | null>;
|
|
35
35
|
isAuthenticated(): Promise<boolean>;
|
|
36
|
+
/**
|
|
37
|
+
* The cached answer, without a round trip, or null when there is none.
|
|
38
|
+
*
|
|
39
|
+
* For the one caller that has to decide what to paint *before* it can
|
|
40
|
+
* await anything: a gate that starts every mount in a "checking" state
|
|
41
|
+
* flashes a spinner even when the answer has been sitting in memory for
|
|
42
|
+
* seconds. Callers that can wait use `checkAuth`, which this never
|
|
43
|
+
* replaces — it only answers "do we already know?".
|
|
44
|
+
*/
|
|
45
|
+
getCachedAuth(): AuthResponse | null;
|
|
36
46
|
};
|
|
37
47
|
export type AuthService = ReturnType<typeof createAuthService>;
|
|
38
48
|
/** The auth-related keys every front's resolved config exposes. */
|
package/dist/authService.js
CHANGED
|
@@ -6,7 +6,7 @@ const SHELL_AUTH_TIMEOUT_MS = 3000;
|
|
|
6
6
|
/**
|
|
7
7
|
* The shell's origin, when this app runs inside it: framed, and loaded with the
|
|
8
8
|
* `embedded=1` flag the shell puts in the iframe src (see buildEmbeddedUrl in
|
|
9
|
-
* @tumbaland/
|
|
9
|
+
* @tumbaland/frontend-core). A direct visit is never embedded.
|
|
10
10
|
*/
|
|
11
11
|
function embeddingShellOrigin(shellUrl) {
|
|
12
12
|
if (typeof window === 'undefined' || window.parent === window.self || !shellUrl)
|
|
@@ -31,7 +31,7 @@ function embeddingShellOrigin(shellUrl) {
|
|
|
31
31
|
* The answer only decides what the app shows. Every API call still carries the
|
|
32
32
|
* session cookie and is checked by the service it goes to.
|
|
33
33
|
*
|
|
34
|
-
* Contract lives in @tumbaland/
|
|
34
|
+
* Contract lives in @tumbaland/frontend-core `routing/messages` (app:auth-request
|
|
35
35
|
* and shell:auth); a lower layer cannot import it, so the literals are repeated.
|
|
36
36
|
*/
|
|
37
37
|
function askShellForAuth(shellOrigin) {
|
|
@@ -74,7 +74,7 @@ function notifyShellOfLogout() {
|
|
|
74
74
|
if (typeof window === 'undefined' || window.parent === window.self)
|
|
75
75
|
return;
|
|
76
76
|
try {
|
|
77
|
-
// Contract lives in @tumbaland/
|
|
77
|
+
// Contract lives in @tumbaland/frontend-core `routing/messages` — a lower
|
|
78
78
|
// layer cannot import it, so the literal is repeated here.
|
|
79
79
|
window.parent.postMessage({ source: 'tumbaland-app', type: 'app:logout' }, '*');
|
|
80
80
|
}
|
|
@@ -224,6 +224,21 @@ export function createAuthService(config) {
|
|
|
224
224
|
async isAuthenticated() {
|
|
225
225
|
const result = await this.checkAuth();
|
|
226
226
|
return result.authenticated;
|
|
227
|
+
},
|
|
228
|
+
/**
|
|
229
|
+
* The cached answer, without a round trip, or null when there is none.
|
|
230
|
+
*
|
|
231
|
+
* For the one caller that has to decide what to paint *before* it can
|
|
232
|
+
* await anything: a gate that starts every mount in a "checking" state
|
|
233
|
+
* flashes a spinner even when the answer has been sitting in memory for
|
|
234
|
+
* seconds. Callers that can wait use `checkAuth`, which this never
|
|
235
|
+
* replaces — it only answers "do we already know?".
|
|
236
|
+
*/
|
|
237
|
+
getCachedAuth() {
|
|
238
|
+
if (authCache && Date.now() - authCacheTime < AUTH_CACHE_TTL) {
|
|
239
|
+
return authCache;
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
227
242
|
}
|
|
228
243
|
};
|
|
229
244
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Component, ErrorInfo, ReactNode } from 'react';
|
|
2
|
+
export interface AppErrorBoundaryProps {
|
|
3
|
+
children: ReactNode;
|
|
4
|
+
/** Reported to monitoring as the errored component/app (e.g. the app name) */
|
|
5
|
+
component?: string;
|
|
6
|
+
/** Fallback title shown to the user */
|
|
7
|
+
title?: string;
|
|
8
|
+
/** Fallback message shown to the user */
|
|
9
|
+
message?: string;
|
|
10
|
+
}
|
|
11
|
+
interface AppErrorBoundaryState {
|
|
12
|
+
hasError: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Catches render-time errors anywhere below it in the tree, reports them via
|
|
16
|
+
* captureException, and shows ErrorPage instead of an unhandled blank screen.
|
|
17
|
+
* Retrying reloads the page rather than resetting local state, since a
|
|
18
|
+
* subtree that just crashed is the least trustworthy place to re-render.
|
|
19
|
+
*/
|
|
20
|
+
export declare class AppErrorBoundary extends Component<AppErrorBoundaryProps, AppErrorBoundaryState> {
|
|
21
|
+
state: AppErrorBoundaryState;
|
|
22
|
+
static getDerivedStateFromError(): AppErrorBoundaryState;
|
|
23
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo): void;
|
|
24
|
+
render(): ReactNode;
|
|
25
|
+
}
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Component } from 'react';
|
|
3
|
+
import { captureException } from '../../monitoring';
|
|
4
|
+
import { ErrorPage } from '../ErrorPage';
|
|
5
|
+
/**
|
|
6
|
+
* Catches render-time errors anywhere below it in the tree, reports them via
|
|
7
|
+
* captureException, and shows ErrorPage instead of an unhandled blank screen.
|
|
8
|
+
* Retrying reloads the page rather than resetting local state, since a
|
|
9
|
+
* subtree that just crashed is the least trustworthy place to re-render.
|
|
10
|
+
*/
|
|
11
|
+
export class AppErrorBoundary extends Component {
|
|
12
|
+
state = { hasError: false };
|
|
13
|
+
static getDerivedStateFromError() {
|
|
14
|
+
return { hasError: true };
|
|
15
|
+
}
|
|
16
|
+
componentDidCatch(error, errorInfo) {
|
|
17
|
+
void captureException(error, { componentStack: errorInfo.componentStack }, { component: this.props.component });
|
|
18
|
+
}
|
|
19
|
+
render() {
|
|
20
|
+
if (this.state.hasError) {
|
|
21
|
+
return (_jsx(ErrorPage, { title: this.props.title ?? 'Something went wrong', message: this.props.message ?? 'An unexpected error occurred. Please try again.' }));
|
|
22
|
+
}
|
|
23
|
+
return this.props.children;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { AppErrorBoundary } from './AppErrorBoundary';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
import type { Breakpoint } from '@mui/material/styles';
|
|
3
|
+
export interface AppLayoutProps {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
/** The app's own header, drawn above the content and inside the same column. */
|
|
6
|
+
header?: ReactNode;
|
|
7
|
+
/** Content width cap. Default `xl`; a single-column app (sign-in) wants `sm`. */
|
|
8
|
+
maxWidth?: Breakpoint | false;
|
|
9
|
+
/** Let the page own its horizontal padding — for a full-bleed map or gallery. */
|
|
10
|
+
disableGutters?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The frame every front's pages sit in.
|
|
14
|
+
*
|
|
15
|
+
* There were five near-identical copies of this, one per app, and they had
|
|
16
|
+
* drifted: three different background gradients (two sky-blue, one indigo)
|
|
17
|
+
* against a pink product, two of them missing the `minWidth: 0` that stops a
|
|
18
|
+
* wide row dragging the whole page sideways on a phone, and each with its own
|
|
19
|
+
* idea of vertical padding. Opening a module from the shell changed the
|
|
20
|
+
* backdrop under it, which is the one thing the iframe seam needed hidden.
|
|
21
|
+
*
|
|
22
|
+
* The safe-area insets are no-ops while a module runs inside the shell's frame
|
|
23
|
+
* — the shell's own container already holds them, and `env()` resolves to zero
|
|
24
|
+
* in an iframe — and are what keeps a module opened directly on a phone clear
|
|
25
|
+
* of the notch and the home indicator.
|
|
26
|
+
*/
|
|
27
|
+
export declare function AppLayout({ children, header, maxWidth, disableGutters }: AppLayoutProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import Box from '@mui/material/Box';
|
|
3
|
+
import Container from '@mui/material/Container';
|
|
4
|
+
import { surface } from '../../theme/tokens';
|
|
5
|
+
/**
|
|
6
|
+
* The frame every front's pages sit in.
|
|
7
|
+
*
|
|
8
|
+
* There were five near-identical copies of this, one per app, and they had
|
|
9
|
+
* drifted: three different background gradients (two sky-blue, one indigo)
|
|
10
|
+
* against a pink product, two of them missing the `minWidth: 0` that stops a
|
|
11
|
+
* wide row dragging the whole page sideways on a phone, and each with its own
|
|
12
|
+
* idea of vertical padding. Opening a module from the shell changed the
|
|
13
|
+
* backdrop under it, which is the one thing the iframe seam needed hidden.
|
|
14
|
+
*
|
|
15
|
+
* The safe-area insets are no-ops while a module runs inside the shell's frame
|
|
16
|
+
* — the shell's own container already holds them, and `env()` resolves to zero
|
|
17
|
+
* in an iframe — and are what keeps a module opened directly on a phone clear
|
|
18
|
+
* of the notch and the home indicator.
|
|
19
|
+
*/
|
|
20
|
+
export function AppLayout({ children, header, maxWidth = 'xl', disableGutters = false }) {
|
|
21
|
+
return (_jsx(Box, { sx: {
|
|
22
|
+
minHeight: '100dvh',
|
|
23
|
+
background: surface.page,
|
|
24
|
+
backgroundAttachment: 'fixed',
|
|
25
|
+
display: 'flex',
|
|
26
|
+
// A flex item otherwise grows to fit its widest content, so one wide
|
|
27
|
+
// row anywhere on a page widened the app past the edge of the screen.
|
|
28
|
+
'& > *': { minWidth: 0 }
|
|
29
|
+
}, children: _jsxs(Box, { sx: {
|
|
30
|
+
flex: 1,
|
|
31
|
+
minWidth: 0,
|
|
32
|
+
display: 'flex',
|
|
33
|
+
flexDirection: 'column',
|
|
34
|
+
pt: 'env(safe-area-inset-top)',
|
|
35
|
+
pb: 'env(safe-area-inset-bottom)',
|
|
36
|
+
pl: 'env(safe-area-inset-left)',
|
|
37
|
+
pr: 'env(safe-area-inset-right)'
|
|
38
|
+
}, children: [header, _jsx(Container, { maxWidth: maxWidth, disableGutters: disableGutters, sx: {
|
|
39
|
+
flex: 1,
|
|
40
|
+
minWidth: 0,
|
|
41
|
+
// Pages set their own vertical rhythm. 32px here on top of theirs
|
|
42
|
+
// was dead space above every page on a phone.
|
|
43
|
+
py: { xs: 1.5, sm: 3 }
|
|
44
|
+
}, children: children })] }) }));
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { AppLayout } from './AppLayout';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { SxProps, Theme } from '@mui/material/styles';
|
|
3
|
+
export interface AuthHeaderUser {
|
|
4
|
+
name: string;
|
|
5
|
+
email: string;
|
|
6
|
+
picture?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface AuthHeaderNavigationItem {
|
|
9
|
+
label: string;
|
|
10
|
+
path: string;
|
|
11
|
+
icon?: React.ElementType;
|
|
12
|
+
}
|
|
13
|
+
export interface AuthHeaderTenantOption {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
type: 'personal' | 'group';
|
|
17
|
+
isSelected?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface AuthHeaderTenantSelectorProps {
|
|
20
|
+
selectedTenant: AuthHeaderTenantOption | null;
|
|
21
|
+
tenants: AuthHeaderTenantOption[];
|
|
22
|
+
onTenantChange: (tenant: AuthHeaderTenantOption) => void;
|
|
23
|
+
loading?: boolean;
|
|
24
|
+
error?: string | null;
|
|
25
|
+
sx?: SxProps<Theme>;
|
|
26
|
+
}
|
|
27
|
+
export interface AuthHeaderProps {
|
|
28
|
+
isLoading: boolean;
|
|
29
|
+
isAuthenticated: boolean;
|
|
30
|
+
user: AuthHeaderUser | null;
|
|
31
|
+
onLogin: () => void;
|
|
32
|
+
onLogout: () => void | Promise<void>;
|
|
33
|
+
navigationItems: AuthHeaderNavigationItem[];
|
|
34
|
+
/** Base URL of the shell app; account menu links (Profile/Groups/Billing) are built from it. */
|
|
35
|
+
shellFrontUrl?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Each consuming frontend already owns a byte-identical TenantSelector (backed by its own
|
|
38
|
+
* useTenant hook, localStorage, and group-API fetch). Moving that into this package is out of
|
|
39
|
+
* scope for this extraction, so it's injected here rather than imported directly.
|
|
40
|
+
*/
|
|
41
|
+
TenantSelectorComponent: React.ComponentType<AuthHeaderTenantSelectorProps>;
|
|
42
|
+
selectedTenant: AuthHeaderTenantOption | null;
|
|
43
|
+
availableTenants: AuthHeaderTenantOption[];
|
|
44
|
+
tenantsLoading?: boolean;
|
|
45
|
+
tenantsError?: string | null;
|
|
46
|
+
onTenantChange: (tenant: AuthHeaderTenantOption) => void;
|
|
47
|
+
/** Placeholder until real notification counts are wired up per app. */
|
|
48
|
+
notificationCount?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Running inside the shell's iframe. The shell's navigation already carries
|
|
51
|
+
* the account menu and sign-out there, so this header keeps to the app's own
|
|
52
|
+
* sections and group picker instead of drawing a second set. Read from the
|
|
53
|
+
* frame's URL when not given.
|
|
54
|
+
*/
|
|
55
|
+
embedded?: boolean;
|
|
56
|
+
}
|
|
57
|
+
export declare function AuthHeader({ isLoading, isAuthenticated, user, onLogin, onLogout, navigationItems, shellFrontUrl, TenantSelectorComponent, selectedTenant, availableTenants, tenantsLoading, tenantsError, onTenantChange, notificationCount, embedded }: AuthHeaderProps): React.JSX.Element;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { AppBar, Toolbar, Typography, Button, Avatar, Box, Menu, MenuItem, IconButton, CircularProgress, Divider, useMediaQuery } from '@mui/material';
|
|
4
|
+
import { useTheme } from '@mui/material/styles';
|
|
5
|
+
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
|
6
|
+
import { AccountCircle, Logout, Person, Groups, Payment } from '@mui/icons-material';
|
|
7
|
+
import { useNavigate, useLocation } from 'react-router';
|
|
8
|
+
import { NotificationsMenu } from '../NotificationsMenu/NotificationsMenu';
|
|
9
|
+
import { LoginButton } from '../LoginButton';
|
|
10
|
+
// A nav item's path of '/' would match every route via startsWith, so it needs an exact-match
|
|
11
|
+
// special case; every other item is matched by prefix so nested routes (e.g. album-front's
|
|
12
|
+
// /albums/:albumId) still highlight their parent nav entry.
|
|
13
|
+
function isNavItemActive(pathname, itemPath) {
|
|
14
|
+
if (itemPath === '/') {
|
|
15
|
+
return pathname === '/';
|
|
16
|
+
}
|
|
17
|
+
return pathname.startsWith(itemPath);
|
|
18
|
+
}
|
|
19
|
+
export function AuthHeader({ isLoading, isAuthenticated, user, onLogin, onLogout, navigationItems, shellFrontUrl, TenantSelectorComponent, selectedTenant, availableTenants, tenantsLoading = false, tenantsError = null, onTenantChange, notificationCount = 0, embedded }) {
|
|
20
|
+
const [anchorEl, setAnchorEl] = useState(null);
|
|
21
|
+
const [notificationAnchorEl, setNotificationAnchorEl] = useState(null);
|
|
22
|
+
const [mobileMenuAnchor, setMobileMenuAnchor] = useState(null);
|
|
23
|
+
const navigate = useNavigate();
|
|
24
|
+
const location = useLocation();
|
|
25
|
+
const theme = useTheme();
|
|
26
|
+
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
|
27
|
+
// Nothing infers this any more: the iframe flag it used to fall back on was
|
|
28
|
+
// set by the shell's frame `src`, and there is no frame. A caller that wants
|
|
29
|
+
// the header's own account menu hidden says so.
|
|
30
|
+
const isEmbedded = embedded ?? false;
|
|
31
|
+
const handleMenuOpen = (event) => setAnchorEl(event.currentTarget);
|
|
32
|
+
const handleMenuClose = () => setAnchorEl(null);
|
|
33
|
+
const handleNotificationClick = (event) => setNotificationAnchorEl(event.currentTarget);
|
|
34
|
+
const handleNotificationClose = () => setNotificationAnchorEl(null);
|
|
35
|
+
const handleMobileMenuOpen = (event) => setMobileMenuAnchor(event.currentTarget);
|
|
36
|
+
const handleMobileMenuClose = () => setMobileMenuAnchor(null);
|
|
37
|
+
const handleLogout = async () => {
|
|
38
|
+
await onLogout();
|
|
39
|
+
handleMenuClose();
|
|
40
|
+
};
|
|
41
|
+
const renderAuthSection = () => {
|
|
42
|
+
if (isLoading) {
|
|
43
|
+
return (_jsx(Box, { sx: { display: 'flex', alignItems: 'center' }, children: _jsx(CircularProgress, { size: 20, color: "inherit" }) }));
|
|
44
|
+
}
|
|
45
|
+
if (isAuthenticated && user) {
|
|
46
|
+
return (_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: { xs: 0.5, sm: 1 } }, children: [_jsx(NotificationsMenu, { onClick: handleNotificationClick, anchorEl: notificationAnchorEl, onClose: handleNotificationClose, notificationCount: notificationCount }), _jsx(IconButton, { onClick: handleMenuOpen, size: "small", edge: "end", "aria-label": "account menu", "aria-controls": "account-menu", "aria-haspopup": "true", color: "inherit", sx: {
|
|
47
|
+
padding: { xs: 0.5, sm: 1 }
|
|
48
|
+
}, children: user.picture ? (_jsx(Avatar, { src: user.picture, alt: user.name, sx: { width: { xs: 24, sm: 32 }, height: { xs: 24, sm: 32 } } })) : (_jsx(AccountCircle, { sx: { fontSize: { xs: 20, sm: 24 } } })) }), _jsxs(Menu, { id: "account-menu", anchorEl: anchorEl, open: Boolean(anchorEl), onClose: handleMenuClose, transformOrigin: { horizontal: 'right', vertical: 'top' }, anchorOrigin: { horizontal: 'right', vertical: 'bottom' }, slotProps: {
|
|
49
|
+
list: {
|
|
50
|
+
'aria-labelledby': 'account-button'
|
|
51
|
+
}
|
|
52
|
+
}, children: [_jsx(MenuItem, { disabled: true, children: _jsxs(Box, { sx: {
|
|
53
|
+
display: 'flex',
|
|
54
|
+
flexDirection: 'column',
|
|
55
|
+
alignItems: 'flex-start'
|
|
56
|
+
}, children: [_jsx(Typography, { variant: "body2", sx: {
|
|
57
|
+
fontWeight: 'bold'
|
|
58
|
+
}, children: user.name }), _jsx(Typography, { variant: "caption", sx: {
|
|
59
|
+
color: 'text.secondary'
|
|
60
|
+
}, children: user.email })] }) }), _jsx(Divider, {}), _jsxs(MenuItem, { onClick: () => window.top?.location && (window.top.location.href = `${shellFrontUrl}/account`), children: [_jsx(Person, { fontSize: "small", sx: { mr: 1 } }), "Profile"] }), _jsxs(MenuItem, { onClick: () => window.top?.location && (window.top.location.href = `${shellFrontUrl}/groups`), children: [_jsx(Groups, { fontSize: "small", sx: { mr: 1 } }), "Groups"] }), _jsxs(MenuItem, { onClick: () => window.top?.location && (window.top.location.href = `${shellFrontUrl}/payments`), children: [_jsx(Payment, { fontSize: "small", sx: { mr: 1 } }), "Billing"] }), _jsx(Divider, {}), _jsxs(MenuItem, { onClick: handleLogout, children: [_jsx(Logout, { fontSize: "small", sx: { mr: 1 } }), "Logout"] })] })] }));
|
|
61
|
+
}
|
|
62
|
+
return _jsx(LoginButton, { onClick: onLogin });
|
|
63
|
+
};
|
|
64
|
+
return (_jsx(AppBar, { position: "static", elevation: 0, sx: {
|
|
65
|
+
background: 'transparent',
|
|
66
|
+
color: '#222',
|
|
67
|
+
boxShadow: 'none',
|
|
68
|
+
borderBottom: '1px solid rgba(0,0,0,0.1)'
|
|
69
|
+
}, children: _jsx(Toolbar, { children: isMobile ? (_jsxs(_Fragment, { children: [_jsx(Box, { sx: { flexGrow: 1, display: 'flex', alignItems: 'center', minHeight: 56, minWidth: 0 }, children: _jsx(Button, { color: "inherit", onClick: handleMobileMenuOpen, endIcon: _jsx(ExpandMoreIcon, { sx: { fontSize: { xs: 16, sm: 20 } } }), sx: {
|
|
70
|
+
fontWeight: 500,
|
|
71
|
+
textTransform: 'none',
|
|
72
|
+
background: 'none',
|
|
73
|
+
px: { xs: 0.5, sm: 1.5 },
|
|
74
|
+
py: { xs: 0.5, sm: 1 },
|
|
75
|
+
minWidth: 0,
|
|
76
|
+
borderRadius: 2,
|
|
77
|
+
'&:hover': { background: 'rgba(0,0,0,0.04)' },
|
|
78
|
+
maxWidth: { xs: '50vw', sm: '70vw' },
|
|
79
|
+
overflow: 'hidden',
|
|
80
|
+
whiteSpace: 'nowrap',
|
|
81
|
+
textOverflow: 'ellipsis',
|
|
82
|
+
fontSize: { xs: '0.875rem', sm: '1rem' }
|
|
83
|
+
}, children: (() => {
|
|
84
|
+
let bestMatch = null;
|
|
85
|
+
let bestLength = -1;
|
|
86
|
+
for (const item of navigationItems) {
|
|
87
|
+
if (item.path === '/' && location.pathname === '/' && bestLength < 1) {
|
|
88
|
+
bestMatch = item;
|
|
89
|
+
bestLength = 1;
|
|
90
|
+
}
|
|
91
|
+
else if (item.path !== '/' &&
|
|
92
|
+
location.pathname.startsWith(item.path) &&
|
|
93
|
+
item.path.length > bestLength) {
|
|
94
|
+
bestMatch = item;
|
|
95
|
+
bestLength = item.path.length;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return bestMatch ? bestMatch.label : navigationItems[0]?.label;
|
|
99
|
+
})() }) }), isAuthenticated && user && (_jsx(Box, { sx: { mx: { xs: 1, sm: 1.5 }, flexShrink: 0 }, children: _jsx(TenantSelectorComponent, { selectedTenant: selectedTenant, tenants: availableTenants, onTenantChange: onTenantChange, loading: tenantsLoading, error: tenantsError, sx: {
|
|
100
|
+
minWidth: { xs: 80, sm: 120 },
|
|
101
|
+
'& .MuiSelect-select': {
|
|
102
|
+
fontSize: { xs: '0.7rem', sm: '0.875rem' },
|
|
103
|
+
padding: { xs: '2px 6px', sm: '6px 12px' }
|
|
104
|
+
}
|
|
105
|
+
} }) })), !isEmbedded && _jsx(Box, { sx: { flexGrow: 0, ml: { xs: 1, sm: 2 } }, children: renderAuthSection() }), _jsx(Menu, { anchorEl: mobileMenuAnchor, open: Boolean(mobileMenuAnchor), onClose: handleMobileMenuClose, anchorOrigin: { vertical: 'bottom', horizontal: 'left' }, transformOrigin: { vertical: 'top', horizontal: 'left' }, children: navigationItems.map((item) => {
|
|
106
|
+
const Icon = item.icon;
|
|
107
|
+
const isActive = isNavItemActive(location.pathname, item.path);
|
|
108
|
+
return (_jsxs(MenuItem, { selected: isActive, onClick: () => {
|
|
109
|
+
navigate(item.path);
|
|
110
|
+
handleMobileMenuClose();
|
|
111
|
+
}, children: [Icon && _jsx(Icon, { sx: { mr: 1 } }), item.label] }, item.path));
|
|
112
|
+
}) })] })) : (_jsxs(Box, { sx: {
|
|
113
|
+
display: 'flex',
|
|
114
|
+
alignItems: 'center',
|
|
115
|
+
width: '100%',
|
|
116
|
+
justifyContent: 'space-between'
|
|
117
|
+
}, children: [_jsx(Box, { sx: { display: 'flex', gap: 1, alignItems: 'center' }, children: navigationItems.map((item) => {
|
|
118
|
+
const isActive = isNavItemActive(location.pathname, item.path);
|
|
119
|
+
const buttonSx = {
|
|
120
|
+
fontWeight: isActive ? 700 : 500,
|
|
121
|
+
textTransform: 'none',
|
|
122
|
+
background: 'none',
|
|
123
|
+
borderRadius: 2,
|
|
124
|
+
px: 2,
|
|
125
|
+
color: isActive ? 'primary.main' : 'inherit',
|
|
126
|
+
'&:hover': { background: 'rgba(0,0,0,0.04)' }
|
|
127
|
+
};
|
|
128
|
+
const Icon = item.icon;
|
|
129
|
+
return (_jsx(Button, { onClick: () => navigate(item.path), sx: buttonSx, startIcon: Icon ? _jsx(Icon, { sx: { mr: 1 } }) : undefined, children: item.label }, item.path));
|
|
130
|
+
}) }), _jsxs(Box, { sx: {
|
|
131
|
+
display: 'flex',
|
|
132
|
+
alignItems: 'center',
|
|
133
|
+
gap: { xs: 1, sm: 2 }
|
|
134
|
+
}, children: [isAuthenticated && user && (_jsx(TenantSelectorComponent, { selectedTenant: selectedTenant, tenants: availableTenants, onTenantChange: onTenantChange, loading: tenantsLoading, error: tenantsError })), !isEmbedded && renderAuthSection()] })] })) }) }));
|
|
135
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { AuthHeader } from './AuthHeader';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Props for the ErrorPage component
|
|
4
|
+
*/
|
|
5
|
+
export interface ErrorPageProps {
|
|
6
|
+
/** The main error title to display */
|
|
7
|
+
title?: string;
|
|
8
|
+
/** The error message content (can be text or React elements) */
|
|
9
|
+
message?: string | ReactNode;
|
|
10
|
+
/** Custom icon to display (defaults to error icon) */
|
|
11
|
+
icon?: ReactNode;
|
|
12
|
+
/** Whether to show the retry button */
|
|
13
|
+
showRetry?: boolean;
|
|
14
|
+
/** Callback function when retry button is clicked */
|
|
15
|
+
onRetry?: () => void;
|
|
16
|
+
}
|
|
17
|
+
export declare function ErrorPage({ title, message, icon, showRetry, onRetry }: ErrorPageProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Typography, Button } from '@mui/material';
|
|
3
|
+
import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
|
|
4
|
+
export function ErrorPage({ title = 'Configuration Error', message = (_jsxs(_Fragment, { children: ["Failed to load application configuration.", _jsx("br", {}), "Please check your network connection and try again."] })), icon = _jsx(ErrorOutlinedIcon, { sx: { fontSize: 80, color: 'error.main' } }), showRetry = true, onRetry = () => window.location.reload() }) {
|
|
5
|
+
return (_jsxs(Box, { sx: {
|
|
6
|
+
display: 'flex',
|
|
7
|
+
flexDirection: 'column',
|
|
8
|
+
alignItems: 'center',
|
|
9
|
+
justifyContent: 'center',
|
|
10
|
+
height: '100vh',
|
|
11
|
+
gap: 2
|
|
12
|
+
}, children: [icon, _jsx(Typography, { variant: "h4", color: "error", gutterBottom: true, sx: {
|
|
13
|
+
fontWeight: 600
|
|
14
|
+
}, children: title }), _jsx(Typography, { variant: "body1", sx: {
|
|
15
|
+
color: 'text.secondary',
|
|
16
|
+
textAlign: 'center',
|
|
17
|
+
mb: showRetry ? 2 : 0
|
|
18
|
+
}, children: message }), showRetry && (_jsx(Button, { variant: "contained", color: "primary", onClick: onRetry, children: "Retry" }))] }));
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ErrorPage } from './ErrorPage';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Props for the Loader component
|
|
3
|
+
*/
|
|
4
|
+
export interface LoaderProps {
|
|
5
|
+
/** The loading message to display below the spinner */
|
|
6
|
+
message?: string;
|
|
7
|
+
/** Size of the loading spinner in pixels */
|
|
8
|
+
size?: number;
|
|
9
|
+
/** Whether to take full viewport height or auto height */
|
|
10
|
+
fullHeight?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function Loader({ message, size, fullHeight }: LoaderProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, CircularProgress, Typography } from '@mui/material';
|
|
3
|
+
export function Loader({ message = '', size = 48, fullHeight = true }) {
|
|
4
|
+
return (_jsxs(Box, { sx: {
|
|
5
|
+
display: 'flex',
|
|
6
|
+
flexDirection: 'column',
|
|
7
|
+
alignItems: 'center',
|
|
8
|
+
justifyContent: 'center',
|
|
9
|
+
height: fullHeight ? '100vh' : 'auto',
|
|
10
|
+
gap: 2,
|
|
11
|
+
py: fullHeight ? 0 : 8
|
|
12
|
+
}, children: [_jsx(CircularProgress, { size: size }), _jsx(Typography, { variant: "body1", sx: {
|
|
13
|
+
color: 'text.secondary'
|
|
14
|
+
}, children: message })] }));
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Loader } from './Loader';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Button } from '@mui/material';
|
|
3
|
+
import { Login } from '@mui/icons-material';
|
|
4
|
+
export function LoginButton({ onClick, text = '' }) {
|
|
5
|
+
return (_jsx(Button, { color: "inherit", onClick: onClick, startIcon: _jsx(Login, {}), size: 'large', sx: {
|
|
6
|
+
backgroundColor: 'transparent',
|
|
7
|
+
border: 'none',
|
|
8
|
+
minWidth: 'auto',
|
|
9
|
+
padding: '8px',
|
|
10
|
+
textTransform: 'none',
|
|
11
|
+
'&:hover': {
|
|
12
|
+
backgroundColor: 'rgba(255, 255, 255, 0.1)'
|
|
13
|
+
}
|
|
14
|
+
}, children: text }));
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { LoginButton } from './LoginButton';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export interface NotificationsMenuProps {
|
|
3
|
+
onClick: (event: React.MouseEvent<HTMLElement>) => void;
|
|
4
|
+
anchorEl: HTMLElement | null;
|
|
5
|
+
onClose: () => void;
|
|
6
|
+
notificationCount?: number;
|
|
7
|
+
marginRight?: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function NotificationsMenu({ onClick, anchorEl, onClose, notificationCount, marginRight }: NotificationsMenuProps): React.JSX.Element;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { IconButton, Menu, MenuItem, Typography } from '@mui/material';
|
|
3
|
+
import { Notifications } from '@mui/icons-material';
|
|
4
|
+
export function NotificationsMenu({ onClick, anchorEl, onClose, notificationCount = 0, marginRight = 1 }) {
|
|
5
|
+
return (_jsxs(_Fragment, { children: [_jsx(IconButton, { onClick: onClick, size: "small" // Changed from "large" to "small" for more compact mobile layout
|
|
6
|
+
, "aria-label": "notifications", color: "inherit", sx: {
|
|
7
|
+
position: 'relative',
|
|
8
|
+
mr: { xs: 0.5, sm: marginRight }, // Reduced margin on mobile
|
|
9
|
+
padding: { xs: 0.5, sm: 1 }, // Explicit padding control for mobile
|
|
10
|
+
'&::after': notificationCount > 0
|
|
11
|
+
? {
|
|
12
|
+
content: '""',
|
|
13
|
+
position: 'absolute',
|
|
14
|
+
top: { xs: 6, sm: 8 }, // Adjusted notification dot position for smaller button
|
|
15
|
+
right: { xs: 6, sm: 8 },
|
|
16
|
+
width: { xs: 6, sm: 8 }, // Smaller notification dot on mobile
|
|
17
|
+
height: { xs: 6, sm: 8 },
|
|
18
|
+
borderRadius: '50%',
|
|
19
|
+
backgroundColor: 'error.main',
|
|
20
|
+
border: '2px solid',
|
|
21
|
+
borderColor: 'background.paper'
|
|
22
|
+
}
|
|
23
|
+
: undefined
|
|
24
|
+
}, children: _jsx(Notifications, { sx: { fontSize: { xs: 20, sm: 24 } } }) }), _jsx(Menu, { anchorEl: anchorEl, open: Boolean(anchorEl), onClose: onClose, transformOrigin: { horizontal: 'right', vertical: 'top' }, anchorOrigin: { horizontal: 'right', vertical: 'bottom' }, children: notificationCount > 0 ? (_jsx(MenuItem, { disabled: true, children: _jsxs(Typography, { variant: "body2", sx: {
|
|
25
|
+
color: 'text.secondary'
|
|
26
|
+
}, children: ["You have ", notificationCount, " new notification", notificationCount !== 1 ? 's' : ''] }) })) : (_jsx(MenuItem, { disabled: true, children: _jsx(Typography, { variant: "body2", sx: {
|
|
27
|
+
color: 'text.secondary'
|
|
28
|
+
}, children: "No new notifications" }) })) })] }));
|
|
29
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
import { type AuthService } from '../../authService';
|
|
3
|
+
/** The subset of a per-app auth service that ProtectedRoute depends on. */
|
|
4
|
+
export type ProtectedRouteAuthService = Pick<AuthService, 'isAuthenticated' | 'redirectToLogin'> & Partial<Pick<AuthService, 'getCachedAuth'>>;
|
|
5
|
+
export interface ProtectedRouteProps {
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
/**
|
|
8
|
+
* The app's auth service (from `createAuthService`). Injected per app since
|
|
9
|
+
* each front wires the shared factory to its own config.
|
|
10
|
+
*/
|
|
11
|
+
authService: ProtectedRouteAuthService;
|
|
12
|
+
/** Message shown while the auth check is in flight. */
|
|
13
|
+
loadingMessage?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Gates its children behind an authenticated session: renders a Loader while
|
|
17
|
+
* the check is in flight, UnauthorizedPage (whose login action calls the auth
|
|
18
|
+
* service's redirectToLogin) when unauthenticated or the check fails, and the
|
|
19
|
+
* children once authenticated.
|
|
20
|
+
*
|
|
21
|
+
* The first state comes from the auth service's cache when it has one, so a
|
|
22
|
+
* remount does not flash "Checking authentication..." over an answer already
|
|
23
|
+
* held in memory. The shell mounts and unmounts this every time the user
|
|
24
|
+
* crosses between its own pages and a framed module — which, on a phone, is
|
|
25
|
+
* every other tap on the tab bar — and each of those was a spinner for an
|
|
26
|
+
* answer that never changed. The check below still runs either way; the cache
|
|
27
|
+
* only decides what is painted while it does.
|
|
28
|
+
*
|
|
29
|
+
* Consolidated from seven per-front copies that had drifted into two
|
|
30
|
+
* behaviourally different variants (one polling `checkAuth`, one
|
|
31
|
+
* `isAuthenticated`, with different initial/loading semantics).
|
|
32
|
+
*/
|
|
33
|
+
export declare function ProtectedRoute({ children, authService, loadingMessage }: ProtectedRouteProps): import("react").JSX.Element;
|