@mixofreality-studio/archistrator-platform-framework-web 0.1.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/components/UserProfile.d.ts +31 -0
- package/dist/components/UserProfile.js +95 -0
- package/dist/context/UserContext.d.ts +11 -0
- package/dist/context/UserContext.js +69 -0
- package/dist/context/UserContextDefinition.d.ts +9 -0
- package/dist/context/UserContextDefinition.js +6 -0
- package/dist/hooks/useUser.d.ts +9 -0
- package/dist/hooks/useUser.js +16 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +6 -0
- package/dist/telemetry.d.ts +12 -0
- package/dist/telemetry.js +132 -0
- package/dist/types/UserInfo.d.ts +25 -0
- package/dist/types/UserInfo.js +16 -0
- package/package.json +53 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UserProfile component displays user avatar and menu with sign out option.
|
|
3
|
+
*
|
|
4
|
+
* Differences from the gtd-specific version:
|
|
5
|
+
* - Navigation callbacks (onCreateOrg, onSelectOrganization) are props instead
|
|
6
|
+
* of react-router-dom `useNavigate()` calls, removing the router dependency.
|
|
7
|
+
* - `accountUrl` is a prop instead of a hardcoded Keycloak/GTD URL.
|
|
8
|
+
* - UI test-id strings are inlined from the `User` namespace of UIIdentifiers
|
|
9
|
+
* rather than imported from the gtd constants file.
|
|
10
|
+
*/
|
|
11
|
+
import { type ReactNode } from 'react';
|
|
12
|
+
import type { UserInfo } from '../types/UserInfo.js';
|
|
13
|
+
export interface UserProfileProps {
|
|
14
|
+
user: UserInfo;
|
|
15
|
+
/**
|
|
16
|
+
* URL to the identity-provider account management page.
|
|
17
|
+
* Opened in a new tab when the user clicks their name/email in the menu.
|
|
18
|
+
*/
|
|
19
|
+
accountUrl: string;
|
|
20
|
+
/**
|
|
21
|
+
* Called when the user clicks "Create Organization".
|
|
22
|
+
* The host app is responsible for navigation.
|
|
23
|
+
*/
|
|
24
|
+
onCreateOrg: () => void;
|
|
25
|
+
/**
|
|
26
|
+
* Called when the user selects an organization to switch to.
|
|
27
|
+
* Receives the organization ID.
|
|
28
|
+
*/
|
|
29
|
+
onSelectOrganization: (orgId: string) => void;
|
|
30
|
+
}
|
|
31
|
+
export declare function UserProfile({ user, accountUrl, onCreateOrg, onSelectOrganization, }: UserProfileProps): ReactNode;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* UserProfile component displays user avatar and menu with sign out option.
|
|
4
|
+
*
|
|
5
|
+
* Differences from the gtd-specific version:
|
|
6
|
+
* - Navigation callbacks (onCreateOrg, onSelectOrganization) are props instead
|
|
7
|
+
* of react-router-dom `useNavigate()` calls, removing the router dependency.
|
|
8
|
+
* - `accountUrl` is a prop instead of a hardcoded Keycloak/GTD URL.
|
|
9
|
+
* - UI test-id strings are inlined from the `User` namespace of UIIdentifiers
|
|
10
|
+
* rather than imported from the gtd constants file.
|
|
11
|
+
*/
|
|
12
|
+
import { useState } from 'react';
|
|
13
|
+
import { IconButton, Avatar, Menu, MenuItem, ListItemIcon, ListItemText, Divider, Typography, Dialog, DialogTitle, DialogContent, List, ListItem, ListItemButton, Box, } from '@mui/material';
|
|
14
|
+
import { Logout as LogoutIcon, AccountCircle as AccountCircleIcon, SwapHoriz as SwapHorizIcon, Business as BusinessIcon, AddBusiness as AddBusinessIcon, } from '@mui/icons-material';
|
|
15
|
+
// Inline test-id strings (mirrors gtd UIIdentifiers.User namespace)
|
|
16
|
+
const USER_TEST_IDS = {
|
|
17
|
+
PROFILE_BUTTON: 'user-profile-button',
|
|
18
|
+
PROFILE_MENU: 'user-profile-menu',
|
|
19
|
+
ACCOUNT_BUTTON: 'user-account-button',
|
|
20
|
+
SWITCH_ORG_BUTTON: 'user-switch-org-button',
|
|
21
|
+
CREATE_ORG_BUTTON: 'user-create-org-button',
|
|
22
|
+
ORG_SWITCH_DIALOG: 'user-org-switch-dialog',
|
|
23
|
+
orgSwitchOption: (orgId) => `user-org-switch-option-${orgId}`,
|
|
24
|
+
LOGOUT_BUTTON: 'user-logout-button',
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Get user initials from name or email.
|
|
28
|
+
* Returns up to 2 characters for the avatar.
|
|
29
|
+
*/
|
|
30
|
+
function getUserInitials(user) {
|
|
31
|
+
if (user.name !== undefined && user.name !== '') {
|
|
32
|
+
const parts = user.name.trim().split(/\s+/);
|
|
33
|
+
const firstPart = parts[0];
|
|
34
|
+
const lastPart = parts[parts.length - 1];
|
|
35
|
+
if (parts.length >= 2 && firstPart !== undefined && lastPart !== undefined) {
|
|
36
|
+
const firstInitial = firstPart[0] ?? '';
|
|
37
|
+
const lastInitial = lastPart[0] ?? '';
|
|
38
|
+
return `${firstInitial}${lastInitial}`.toUpperCase();
|
|
39
|
+
}
|
|
40
|
+
return user.name.substring(0, 2).toUpperCase();
|
|
41
|
+
}
|
|
42
|
+
if (user.email !== undefined && user.email !== '') {
|
|
43
|
+
return user.email.substring(0, 2).toUpperCase();
|
|
44
|
+
}
|
|
45
|
+
return 'U';
|
|
46
|
+
}
|
|
47
|
+
export function UserProfile({ user, accountUrl, onCreateOrg, onSelectOrganization, }) {
|
|
48
|
+
const [anchorEl, setAnchorEl] = useState(null);
|
|
49
|
+
const [orgDialogOpen, setOrgDialogOpen] = useState(false);
|
|
50
|
+
const open = Boolean(anchorEl);
|
|
51
|
+
const handleClick = (event) => {
|
|
52
|
+
setAnchorEl(event.currentTarget);
|
|
53
|
+
};
|
|
54
|
+
const handleClose = () => {
|
|
55
|
+
setAnchorEl(null);
|
|
56
|
+
};
|
|
57
|
+
const handleSignOut = () => {
|
|
58
|
+
window.location.href = '/logout';
|
|
59
|
+
};
|
|
60
|
+
const handleAccountClick = () => {
|
|
61
|
+
window.open(accountUrl, '_blank', 'noopener,noreferrer');
|
|
62
|
+
handleClose();
|
|
63
|
+
};
|
|
64
|
+
const handleSwitchOrgClick = () => {
|
|
65
|
+
setOrgDialogOpen(true);
|
|
66
|
+
handleClose();
|
|
67
|
+
};
|
|
68
|
+
const handleCreateOrgClick = () => {
|
|
69
|
+
handleClose();
|
|
70
|
+
onCreateOrg();
|
|
71
|
+
};
|
|
72
|
+
const handleOrgDialogClose = () => {
|
|
73
|
+
setOrgDialogOpen(false);
|
|
74
|
+
};
|
|
75
|
+
const handleSelectOrganization = (orgId) => {
|
|
76
|
+
setOrgDialogOpen(false);
|
|
77
|
+
onSelectOrganization(orgId);
|
|
78
|
+
};
|
|
79
|
+
const displayName = user.name ?? user.email ?? 'User';
|
|
80
|
+
const displayEmail = user.email;
|
|
81
|
+
const orgEntries = Object.entries(user.organization ?? {});
|
|
82
|
+
const hasMultipleOrgs = orgEntries.length > 1;
|
|
83
|
+
return (_jsxs(_Fragment, { children: [_jsx(IconButton, { "aria-controls": open ? 'user-menu' : undefined, "aria-expanded": open ? 'true' : undefined, "aria-haspopup": "true", "aria-label": "user profile", "data-testid": USER_TEST_IDS.PROFILE_BUTTON, size: "small", onClick: handleClick, children: _jsx(Avatar, { alt: displayName, sx: { width: 32, height: 32 }, children: getUserInitials(user) }) }), _jsxs(Menu, { anchorEl: anchorEl, anchorOrigin: {
|
|
84
|
+
vertical: 'bottom',
|
|
85
|
+
horizontal: 'right',
|
|
86
|
+
}, "data-testid": USER_TEST_IDS.PROFILE_MENU, id: "user-menu", open: open, transformOrigin: {
|
|
87
|
+
vertical: 'top',
|
|
88
|
+
horizontal: 'right',
|
|
89
|
+
}, onClose: handleClose, children: [_jsxs(MenuItem, { "data-testid": USER_TEST_IDS.ACCOUNT_BUTTON, onClick: handleAccountClick, children: [_jsx(ListItemIcon, { children: _jsx(AccountCircleIcon, { fontSize: "small" }) }), _jsx(ListItemText, { primary: displayName, secondary: displayEmail, slotProps: {
|
|
90
|
+
primary: { variant: 'body2', fontWeight: 'medium' },
|
|
91
|
+
secondary: { variant: 'caption' },
|
|
92
|
+
} })] }), _jsx(Divider, {}), hasMultipleOrgs ? _jsxs(MenuItem, { "data-testid": USER_TEST_IDS.SWITCH_ORG_BUTTON, onClick: handleSwitchOrgClick, children: [_jsx(ListItemIcon, { children: _jsx(SwapHorizIcon, { fontSize: "small" }) }), _jsx(ListItemText, { children: _jsx(Typography, { variant: "body2", children: "Switch Organization" }) })] }) : null, _jsxs(MenuItem, { "data-testid": USER_TEST_IDS.CREATE_ORG_BUTTON, onClick: handleCreateOrgClick, children: [_jsx(ListItemIcon, { children: _jsx(AddBusinessIcon, { fontSize: "small" }) }), _jsx(ListItemText, { children: _jsx(Typography, { variant: "body2", children: "Create Organization" }) })] }), _jsxs(MenuItem, { "data-testid": USER_TEST_IDS.LOGOUT_BUTTON, onClick: handleSignOut, children: [_jsx(ListItemIcon, { children: _jsx(LogoutIcon, { fontSize: "small" }) }), _jsx(ListItemText, { children: _jsx(Typography, { variant: "body2", children: "Sign Out" }) })] })] }), _jsxs(Dialog, { "data-testid": USER_TEST_IDS.ORG_SWITCH_DIALOG, open: orgDialogOpen, onClose: handleOrgDialogClose, children: [_jsx(DialogTitle, { children: "Switch Organization" }), _jsx(DialogContent, { children: _jsx(List, { sx: { minWidth: 300 }, children: orgEntries.map(([orgName, orgDetails]) => (_jsx(ListItem, { disablePadding: true, children: _jsxs(ListItemButton, { "data-testid": USER_TEST_IDS.orgSwitchOption(orgDetails.id), onClick: () => {
|
|
93
|
+
handleSelectOrganization(orgDetails.id);
|
|
94
|
+
}, children: [_jsx(ListItemIcon, { children: _jsx(BusinessIcon, { color: "primary" }) }), _jsx(Box, { sx: { flexGrow: 1 }, children: _jsx(Typography, { variant: "body1", children: orgName }) })] }) }, orgDetails.id))) }) })] })] }));
|
|
95
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UserContext provides user authentication information to the application.
|
|
3
|
+
* Fetches user info from /api/userinfo on mount and provides it via context.
|
|
4
|
+
* The server returns mock user data in local mode.
|
|
5
|
+
*/
|
|
6
|
+
import { type ReactNode } from 'react';
|
|
7
|
+
interface UserProviderProps {
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
export declare function UserProvider({ children }: UserProviderProps): ReactNode;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* UserContext provides user authentication information to the application.
|
|
4
|
+
* Fetches user info from /api/userinfo on mount and provides it via context.
|
|
5
|
+
* The server returns mock user data in local mode.
|
|
6
|
+
*/
|
|
7
|
+
import { useState, useEffect } from 'react';
|
|
8
|
+
import { Box, CircularProgress, Alert, Button, Typography } from '@mui/material';
|
|
9
|
+
import { UserContext } from './UserContextDefinition.js';
|
|
10
|
+
export function UserProvider({ children }) {
|
|
11
|
+
const [user, setUser] = useState(null);
|
|
12
|
+
const [loading, setLoading] = useState(true);
|
|
13
|
+
const [error, setError] = useState(null);
|
|
14
|
+
const fetchUserInfo = async () => {
|
|
15
|
+
setLoading(true);
|
|
16
|
+
setError(null);
|
|
17
|
+
try {
|
|
18
|
+
const response = await fetch('/api/userinfo', {
|
|
19
|
+
headers: { 'Accept': 'application/json' },
|
|
20
|
+
});
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
if (response.status === 401) {
|
|
23
|
+
// Session expired or not authenticated — reload to trigger Envoy OIDC redirect
|
|
24
|
+
window.location.reload();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`Failed to fetch user info: ${response.status.toString()} ${response.statusText}`);
|
|
28
|
+
}
|
|
29
|
+
const userData = (await response.json());
|
|
30
|
+
setUser(userData);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
|
34
|
+
setError(errorMessage);
|
|
35
|
+
console.error('Error fetching user info:', err);
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
setLoading(false);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
void fetchUserInfo();
|
|
43
|
+
}, []);
|
|
44
|
+
// Loading state - show centered spinner
|
|
45
|
+
if (loading) {
|
|
46
|
+
return (_jsx(Box, { sx: {
|
|
47
|
+
display: 'flex',
|
|
48
|
+
justifyContent: 'center',
|
|
49
|
+
alignItems: 'center',
|
|
50
|
+
minHeight: '100vh',
|
|
51
|
+
}, children: _jsx(CircularProgress, {}) }));
|
|
52
|
+
}
|
|
53
|
+
// Error state - show error alert with retry button
|
|
54
|
+
if ((error !== null && error !== '') || user === null) {
|
|
55
|
+
return (_jsx(Box, { sx: {
|
|
56
|
+
display: 'flex',
|
|
57
|
+
flexDirection: 'column',
|
|
58
|
+
justifyContent: 'center',
|
|
59
|
+
alignItems: 'center',
|
|
60
|
+
minHeight: '100vh',
|
|
61
|
+
gap: 2,
|
|
62
|
+
p: 3,
|
|
63
|
+
}, children: _jsxs(Alert, { severity: "error", sx: { maxWidth: 600 }, children: [_jsx(Typography, { gutterBottom: true, variant: "h6", children: "Failed to Load User Information" }), _jsx(Typography, { sx: { mb: 2 }, variant: "body2", children: error ?? 'User information not available' }), _jsx(Button, { variant: "contained", onClick: () => {
|
|
64
|
+
void fetchUserInfo();
|
|
65
|
+
}, children: "Retry" })] }) }));
|
|
66
|
+
}
|
|
67
|
+
// Success - provide user via context
|
|
68
|
+
return _jsx(UserContext.Provider, { value: { user }, children: children });
|
|
69
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User Context Definition
|
|
3
|
+
* Separate file for context to satisfy react-refresh/only-export-components
|
|
4
|
+
*/
|
|
5
|
+
import type { UserInfo } from '../types/UserInfo.js';
|
|
6
|
+
export interface UserContextValue {
|
|
7
|
+
user: UserInfo;
|
|
8
|
+
}
|
|
9
|
+
export declare const UserContext: import("react").Context<UserContextValue | undefined>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook to access the current user from context.
|
|
3
|
+
*/
|
|
4
|
+
import type { UserContextValue } from '../context/UserContextDefinition.js';
|
|
5
|
+
/**
|
|
6
|
+
* Hook to access the current user from context.
|
|
7
|
+
* Must be used within a UserProvider.
|
|
8
|
+
*/
|
|
9
|
+
export declare function useUser(): UserContextValue;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook to access the current user from context.
|
|
3
|
+
*/
|
|
4
|
+
import { useContext } from 'react';
|
|
5
|
+
import { UserContext } from '../context/UserContextDefinition.js';
|
|
6
|
+
/**
|
|
7
|
+
* Hook to access the current user from context.
|
|
8
|
+
* Must be used within a UserProvider.
|
|
9
|
+
*/
|
|
10
|
+
export function useUser() {
|
|
11
|
+
const context = useContext(UserContext);
|
|
12
|
+
if (context === undefined) {
|
|
13
|
+
throw new Error('useUser must be used within a UserProvider');
|
|
14
|
+
}
|
|
15
|
+
return context;
|
|
16
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { setupTelemetry } from './telemetry.js';
|
|
2
|
+
export * from './types/UserInfo.js';
|
|
3
|
+
export { UserProvider } from './context/UserContext.js';
|
|
4
|
+
export { UserContext } from './context/UserContextDefinition.js';
|
|
5
|
+
export type { UserContextValue } from './context/UserContextDefinition.js';
|
|
6
|
+
export { useUser } from './hooks/useUser.js';
|
|
7
|
+
export { UserProfile } from './components/UserProfile.js';
|
|
8
|
+
export type { UserProfileProps } from './components/UserProfile.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { setupTelemetry } from './telemetry.js';
|
|
2
|
+
export * from './types/UserInfo.js';
|
|
3
|
+
export { UserProvider } from './context/UserContext.js';
|
|
4
|
+
export { UserContext } from './context/UserContextDefinition.js';
|
|
5
|
+
export { useUser } from './hooks/useUser.js';
|
|
6
|
+
export { UserProfile } from './components/UserProfile.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Initialises OpenTelemetry tracing and logging for a web application.
|
|
3
|
+
*
|
|
4
|
+
* Call this once at app startup (e.g. from main.tsx / index.tsx).
|
|
5
|
+
* Telemetry is only active when `mode === 'production'`; in dev/test the
|
|
6
|
+
* function returns immediately to avoid failed OTLP export calls.
|
|
7
|
+
*
|
|
8
|
+
* @param serviceName - The OTel service.name attribute (e.g. 'archistrator-webapp').
|
|
9
|
+
* @param mode - The build mode string; pass `import.meta.env.MODE` from
|
|
10
|
+
* the consuming app. Defaults to `'development'`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function setupTelemetry(serviceName: string, mode?: string): void;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { BatchSpanProcessor, WebTracerProvider, } from '@opentelemetry/sdk-trace-web';
|
|
2
|
+
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
|
3
|
+
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
|
|
4
|
+
import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load';
|
|
5
|
+
import { registerInstrumentations } from '@opentelemetry/instrumentation';
|
|
6
|
+
import { resourceFromAttributes } from '@opentelemetry/resources';
|
|
7
|
+
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
|
|
8
|
+
import { LoggerProvider, BatchLogRecordProcessor, } from '@opentelemetry/sdk-logs';
|
|
9
|
+
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
|
|
10
|
+
/**
|
|
11
|
+
* Initialises OpenTelemetry tracing and logging for a web application.
|
|
12
|
+
*
|
|
13
|
+
* Call this once at app startup (e.g. from main.tsx / index.tsx).
|
|
14
|
+
* Telemetry is only active when `mode === 'production'`; in dev/test the
|
|
15
|
+
* function returns immediately to avoid failed OTLP export calls.
|
|
16
|
+
*
|
|
17
|
+
* @param serviceName - The OTel service.name attribute (e.g. 'archistrator-webapp').
|
|
18
|
+
* @param mode - The build mode string; pass `import.meta.env.MODE` from
|
|
19
|
+
* the consuming app. Defaults to `'development'`.
|
|
20
|
+
*/
|
|
21
|
+
export function setupTelemetry(serviceName, mode = 'development') {
|
|
22
|
+
if (mode !== 'production') {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const resource = resourceFromAttributes({
|
|
26
|
+
[ATTR_SERVICE_NAME]: serviceName,
|
|
27
|
+
'deployment.environment.name': mode,
|
|
28
|
+
});
|
|
29
|
+
// --- Traces ---
|
|
30
|
+
const traceExporter = new OTLPTraceExporter({
|
|
31
|
+
url: '/otlp/v1/traces',
|
|
32
|
+
});
|
|
33
|
+
const tracerProvider = new WebTracerProvider({
|
|
34
|
+
resource,
|
|
35
|
+
spanProcessors: [new BatchSpanProcessor(traceExporter)],
|
|
36
|
+
});
|
|
37
|
+
tracerProvider.register();
|
|
38
|
+
registerInstrumentations({
|
|
39
|
+
instrumentations: [
|
|
40
|
+
new FetchInstrumentation({
|
|
41
|
+
ignoreUrls: [/\/otlp\//],
|
|
42
|
+
}),
|
|
43
|
+
new DocumentLoadInstrumentation(),
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
// --- Logs ---
|
|
47
|
+
const logExporter = new OTLPLogExporter({
|
|
48
|
+
url: '/otlp/v1/logs',
|
|
49
|
+
});
|
|
50
|
+
const loggerProvider = new LoggerProvider({
|
|
51
|
+
resource,
|
|
52
|
+
processors: [new BatchLogRecordProcessor(logExporter)],
|
|
53
|
+
});
|
|
54
|
+
const logger = loggerProvider.getLogger(serviceName);
|
|
55
|
+
// --- Global error capture ---
|
|
56
|
+
window.addEventListener('error', (event) => {
|
|
57
|
+
const err = event.error;
|
|
58
|
+
logger.emit({
|
|
59
|
+
severityText: 'ERROR',
|
|
60
|
+
body: event.message,
|
|
61
|
+
attributes: {
|
|
62
|
+
'exception.type': err instanceof Error ? err.name : 'Error',
|
|
63
|
+
'exception.message': event.message,
|
|
64
|
+
'exception.stacktrace': err instanceof Error ? (err.stack ?? '') : '',
|
|
65
|
+
'code.filepath': event.filename,
|
|
66
|
+
'code.lineno': event.lineno,
|
|
67
|
+
'code.column': event.colno,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
window.addEventListener('unhandledrejection', (event) => {
|
|
72
|
+
const reason = event.reason;
|
|
73
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
74
|
+
logger.emit({
|
|
75
|
+
severityText: 'ERROR',
|
|
76
|
+
body: `Unhandled promise rejection: ${message}`,
|
|
77
|
+
attributes: {
|
|
78
|
+
'exception.type': reason instanceof Error ? reason.name : 'UnhandledRejection',
|
|
79
|
+
'exception.message': message,
|
|
80
|
+
'exception.stacktrace': reason instanceof Error ? (reason.stack ?? '') : '',
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
// --- Fetch error logging ---
|
|
85
|
+
const originalFetch = window.fetch;
|
|
86
|
+
window.fetch = async (...args) => {
|
|
87
|
+
const url = args[0] instanceof Request ? args[0].url : String(args[0]);
|
|
88
|
+
// Don't log telemetry export failures (avoid recursion)
|
|
89
|
+
if (url.includes('/otlp/')) {
|
|
90
|
+
return originalFetch(...args);
|
|
91
|
+
}
|
|
92
|
+
const callsite = new Error();
|
|
93
|
+
try {
|
|
94
|
+
const response = await originalFetch(...args);
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
logger.emit({
|
|
97
|
+
severityText: 'ERROR',
|
|
98
|
+
body: `HTTP ${String(response.status)} ${response.statusText} — ${args[1]?.method ?? 'GET'} ${url}`,
|
|
99
|
+
attributes: {
|
|
100
|
+
'http.request.method': args[1]?.method ?? 'GET',
|
|
101
|
+
'url.full': url,
|
|
102
|
+
'http.response.status_code': response.status,
|
|
103
|
+
'exception.stacktrace': callsite.stack ?? '',
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return response;
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
111
|
+
logger.emit({
|
|
112
|
+
severityText: 'ERROR',
|
|
113
|
+
body: `Fetch failed — ${args[1]?.method ?? 'GET'} ${url}: ${error.message}`,
|
|
114
|
+
attributes: {
|
|
115
|
+
'http.request.method': args[1]?.method ?? 'GET',
|
|
116
|
+
'url.full': url,
|
|
117
|
+
'exception.type': error.name,
|
|
118
|
+
'exception.message': error.message,
|
|
119
|
+
'exception.stacktrace': callsite.stack ?? '',
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
// Flush buffered telemetry when user navigates away
|
|
126
|
+
document.addEventListener('visibilitychange', () => {
|
|
127
|
+
if (document.visibilityState === 'hidden') {
|
|
128
|
+
void tracerProvider.forceFlush();
|
|
129
|
+
void loggerProvider.forceFlush();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Represents an organization entry in the user's organization map.
|
|
3
|
+
*/
|
|
4
|
+
export interface OrganizationEntry {
|
|
5
|
+
id: string;
|
|
6
|
+
[key: string]: string | string[];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Map of organization names to their details.
|
|
10
|
+
*/
|
|
11
|
+
export type OrganizationMap = Record<string, OrganizationEntry>;
|
|
12
|
+
export interface UserInfo {
|
|
13
|
+
sub: string;
|
|
14
|
+
email?: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
preferred_username?: string;
|
|
17
|
+
picture?: string;
|
|
18
|
+
email_verified?: boolean;
|
|
19
|
+
organization?: OrganizationMap;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Extracts the first organization ID from the user's organization map.
|
|
23
|
+
* Returns 'default' if no organizations are present.
|
|
24
|
+
*/
|
|
25
|
+
export declare function getFirstOrganizationId(user: UserInfo): string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracts the first organization ID from the user's organization map.
|
|
3
|
+
* Returns 'default' if no organizations are present.
|
|
4
|
+
*/
|
|
5
|
+
export function getFirstOrganizationId(user) {
|
|
6
|
+
if (user.organization === undefined) {
|
|
7
|
+
return 'default';
|
|
8
|
+
}
|
|
9
|
+
const orgNames = Object.keys(user.organization);
|
|
10
|
+
const firstName = orgNames[0];
|
|
11
|
+
if (firstName === undefined) {
|
|
12
|
+
return 'default';
|
|
13
|
+
}
|
|
14
|
+
const firstOrg = user.organization[firstName];
|
|
15
|
+
return firstOrg?.id ?? 'default';
|
|
16
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mixofreality-studio/archistrator-platform-framework-web",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"typecheck": "tsc --noEmit"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/mixofreality-studio/archistrator-platform.git",
|
|
18
|
+
"directory": "web"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@mui/material": ">=7.0.0",
|
|
25
|
+
"@mui/icons-material": ">=7.0.0",
|
|
26
|
+
"@emotion/react": ">=11.0.0",
|
|
27
|
+
"@emotion/styled": ">=11.0.0",
|
|
28
|
+
"react": ">=19.0.0",
|
|
29
|
+
"react-dom": ">=19.0.0"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@opentelemetry/exporter-logs-otlp-http": "^0.212.0",
|
|
33
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.212.0",
|
|
34
|
+
"@opentelemetry/instrumentation": "^0.212.0",
|
|
35
|
+
"@opentelemetry/instrumentation-document-load": "^0.56.0",
|
|
36
|
+
"@opentelemetry/instrumentation-fetch": "^0.212.0",
|
|
37
|
+
"@opentelemetry/resources": "^2.5.1",
|
|
38
|
+
"@opentelemetry/sdk-logs": "^0.212.0",
|
|
39
|
+
"@opentelemetry/sdk-trace-web": "^2.5.1",
|
|
40
|
+
"@opentelemetry/semantic-conventions": "^1.39.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"typescript": "~5.9.3",
|
|
44
|
+
"@types/react": "^19.0.0",
|
|
45
|
+
"@types/react-dom": "^19.0.0",
|
|
46
|
+
"@mui/material": "^7.3.6",
|
|
47
|
+
"@mui/icons-material": "^7.3.6",
|
|
48
|
+
"@emotion/react": "^11.14.0",
|
|
49
|
+
"@emotion/styled": "^11.14.1",
|
|
50
|
+
"react": "^19.2.0",
|
|
51
|
+
"react-dom": "^19.2.0"
|
|
52
|
+
}
|
|
53
|
+
}
|