@dashadmin/dash-admin 1.3.44 → 1.3.46
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/i18n/BridgedLocalesMenuButton.js +1 -2
- package/dist/components/menu/DarkToggleMode.js +1 -1
- package/dist/components/serviceAccount/ApiKeyRevealDialog.js +110 -0
- package/dist/components/serviceAccount/ServiceAccountStatusSwitch.js +94 -0
- package/dist/components/subscription/PlanPricesSettings.js +4 -2
- package/dist/components/tenancy/GlobalTenantWrapper.js +64 -0
- package/dist/components/tenancy/TenantSwitcher.js +389 -0
- package/dist/contexts/com/components/EchoNotificationsCenter.js +40 -0
- package/dist/default-theme/menu/AppMenuComponents/expanded/SidebarItem.js +1 -0
- package/dist/index.js +60 -40
- package/dist/pages/Profile.js +32 -38
- package/dist/providers/i18n/en.js +108 -0
- package/dist/providers/i18n/es.js +108 -0
- package/dist/providers/i18n/languages.js +3 -1
- package/dist/providers/i18n/mergeTranslations.js +18 -0
- package/dist/resources/User/components/AvatarUploadField.js +61 -0
- package/dist/resources/User/components/PasswordFields.js +67 -0
- package/dist/resources/User/getProfileMenu.js +36 -0
- package/dist/resources/User/profileResource.js +33 -0
- package/dist/resources/User/userLayout.js +58 -0
- package/dist/resources/User/userResource.js +90 -0
- package/dist/resources/User/userSchema.js +206 -0
- package/dist/resources/User/usersFilters.js +22 -0
- package/dist/systemResources.js +30 -19
- package/dist/templates/ResourceTemplateCreate.js +5 -2
- package/package.json +4 -1
- package/src/components/i18n/BridgedLocalesMenuButton.tsx +1 -2
- package/src/components/menu/DarkToggleMode.tsx +1 -1
- package/src/components/serviceAccount/ApiKeyRevealDialog.tsx +129 -0
- package/src/components/serviceAccount/ServiceAccountStatusSwitch.tsx +99 -0
- package/src/components/subscription/PlanPricesSettings.tsx +2 -2
- package/src/components/tenancy/GlobalTenantWrapper.tsx +84 -0
- package/src/components/tenancy/TenantSwitcher.tsx +485 -0
- package/src/contexts/com/components/EchoNotificationsCenter.tsx +65 -0
- package/src/default-theme/menu/AppMaterialMenu.tsx +1 -1
- package/src/default-theme/menu/AppMenuComponents/expanded/SidebarItem.tsx +1 -0
- package/src/default-theme/menu/SidebarActions.tsx +1 -1
- package/src/index.tsx +15 -0
- package/src/pages/Profile.tsx +69 -107
- package/src/providers/i18n/en.ts +113 -0
- package/src/providers/i18n/es.ts +113 -0
- package/src/providers/i18n/languages.tsx +3 -0
- package/src/providers/i18n/mergeTranslations.ts +53 -0
- package/src/resources/User/components/AvatarUploadField.tsx +57 -0
- package/src/resources/User/components/PasswordFields.tsx +73 -0
- package/src/resources/User/getProfileMenu.ts +57 -0
- package/src/resources/User/profileResource.tsx +51 -0
- package/src/resources/User/userLayout.tsx +62 -0
- package/src/resources/User/userResource.tsx +111 -0
- package/src/resources/User/userSchema.tsx +213 -0
- package/src/resources/User/usersFilters.tsx +21 -0
- package/src/systemResources.tsx +31 -20
- package/src/templates/ResourceTemplateCreate.tsx +17 -2
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { useRecordContext, useNotify, useRefresh } from "react-admin";
|
|
3
|
+
import { useAxios } from "dash-axios-hook";
|
|
4
|
+
import Box from "@mui/material/Box";
|
|
5
|
+
import Switch from "@mui/material/Switch";
|
|
6
|
+
import FormControlLabel from "@mui/material/FormControlLabel";
|
|
7
|
+
import CircularProgress from "@mui/material/CircularProgress";
|
|
8
|
+
import { IDashAutoAdminCustomFieldComponent } from "dash-auto-admin";
|
|
9
|
+
import { useI18nBridge } from "../../contexts/I18nBridgeContext";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Enable/disable switch for a service account API key.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately NOT a form field. Status is changed through the dedicated
|
|
15
|
+
* enable/disable endpoints rather than the update payload, for two reasons:
|
|
16
|
+
*
|
|
17
|
+
* - The update contract rejects everything except role_ids, so submitting a
|
|
18
|
+
* status would 422.
|
|
19
|
+
* - Disabling must not rotate the credential. The endpoints flip a flag and
|
|
20
|
+
* leave the token untouched, so re-enabling restores the same key rather
|
|
21
|
+
* than forcing every integration to redeploy a new secret.
|
|
22
|
+
*
|
|
23
|
+
* The switch therefore acts immediately and refreshes, instead of waiting for
|
|
24
|
+
* a form save that would never carry it.
|
|
25
|
+
*/
|
|
26
|
+
const ServiceAccountStatusSwitch: React.FC<IDashAutoAdminCustomFieldComponent> = ({
|
|
27
|
+
resourceConfig,
|
|
28
|
+
}) => {
|
|
29
|
+
const record = useRecordContext();
|
|
30
|
+
const notify = useNotify();
|
|
31
|
+
const refresh = useRefresh();
|
|
32
|
+
const axios = useAxios();
|
|
33
|
+
const { i18nProvider } = useI18nBridge();
|
|
34
|
+
const [loading, setLoading] = useState(false);
|
|
35
|
+
|
|
36
|
+
const translate = React.useCallback(
|
|
37
|
+
(key: string, fallback: string) => {
|
|
38
|
+
try {
|
|
39
|
+
return i18nProvider?.translate(key, { _: fallback }) ?? fallback;
|
|
40
|
+
} catch {
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
[i18nProvider]
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
if (!record) return null;
|
|
48
|
+
|
|
49
|
+
const isActive = record.status === "active";
|
|
50
|
+
|
|
51
|
+
const handleToggle = async () => {
|
|
52
|
+
setLoading(true);
|
|
53
|
+
try {
|
|
54
|
+
const action = isActive ? "disable" : "enable";
|
|
55
|
+
|
|
56
|
+
await axios.post(`${resourceConfig.model}/${record.id}/${action}`);
|
|
57
|
+
|
|
58
|
+
notify(
|
|
59
|
+
isActive
|
|
60
|
+
? translate("serviceAccount.notify.disabled", "API key disabled")
|
|
61
|
+
: translate("serviceAccount.notify.enabled", "API key enabled"),
|
|
62
|
+
{ type: "success" }
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
refresh();
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error("ServiceAccountStatusSwitch: toggle failed", error);
|
|
68
|
+
notify(
|
|
69
|
+
translate("serviceAccount.notify.toggle_error", "Could not change the key status"),
|
|
70
|
+
{ type: "error" }
|
|
71
|
+
);
|
|
72
|
+
} finally {
|
|
73
|
+
setLoading(false);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<Box sx={{ display: "flex", alignItems: "center", gap: 1, my: 1 }}>
|
|
79
|
+
<FormControlLabel
|
|
80
|
+
control={
|
|
81
|
+
<Switch
|
|
82
|
+
checked={isActive}
|
|
83
|
+
onChange={handleToggle}
|
|
84
|
+
disabled={loading}
|
|
85
|
+
color="success"
|
|
86
|
+
/>
|
|
87
|
+
}
|
|
88
|
+
label={
|
|
89
|
+
isActive
|
|
90
|
+
? translate("serviceAccount.status.active", "Active")
|
|
91
|
+
: translate("serviceAccount.status.disabled", "Disabled")
|
|
92
|
+
}
|
|
93
|
+
/>
|
|
94
|
+
{loading ? <CircularProgress size={16} /> : null}
|
|
95
|
+
</Box>
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export default ServiceAccountStatusSwitch;
|
|
@@ -53,7 +53,7 @@ const PlanPricesView: React.FC<IDashAutoAdminCustomFieldComponent> = () => {
|
|
|
53
53
|
const prices = plan?.prices;
|
|
54
54
|
|
|
55
55
|
const { data: currencies, isLoading } = useGetList<Currency>(
|
|
56
|
-
'
|
|
56
|
+
'system/currency', // core "shared master data" — works for every domain, unlike ecommerce/currency (kitchntabs-backend-domain only)
|
|
57
57
|
{
|
|
58
58
|
pagination: { page: 1, perPage: 100 },
|
|
59
59
|
sort: { field: 'code', order: 'ASC' },
|
|
@@ -161,7 +161,7 @@ const PlanPricesEdit: React.FC<IDashAutoAdminCustomFieldComponent> = ({ method }
|
|
|
161
161
|
|
|
162
162
|
// Fetch available currencies
|
|
163
163
|
const { data: currencies, isLoading, error } = useGetList<Currency>(
|
|
164
|
-
'
|
|
164
|
+
'system/currency', // core "shared master data" — works for every domain, unlike ecommerce/currency (kitchntabs-backend-domain only)
|
|
165
165
|
{
|
|
166
166
|
pagination: { page: 1, perPage: 100 },
|
|
167
167
|
sort: { field: 'code', order: 'ASC' },
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GlobalTenantWrapper
|
|
3
|
+
*
|
|
4
|
+
* Tenant Data Fetcher Component that runs inside DashThemeProvider — takes
|
|
5
|
+
* tenant data (fetched by a mall/self-service session flow, before any user
|
|
6
|
+
* authentication exists) and:
|
|
7
|
+
* - Sets panel-settings logos in Redux from the tenant's images
|
|
8
|
+
* - Recreates the MUI theme from the tenant's theme settings
|
|
9
|
+
* - Persists a "public" auth shape (tenant only, no user) via
|
|
10
|
+
* AuthPersistenceService, so the rest of the app can read tenant context
|
|
11
|
+
*
|
|
12
|
+
* Consolidated 2026-08-03 from seven byte-identical app-level copies. Two of
|
|
13
|
+
* its five real consumers (kitchntabs-app's and vanexa-app's
|
|
14
|
+
* kt-kiosk/components/MallClientWrapper.tsx) had a stale, commented-out
|
|
15
|
+
* import with a wrong path — GlobalTenantWrapper was referenced in JSX but
|
|
16
|
+
* never actually imported, a pre-existing bug fixed as part of repointing
|
|
17
|
+
* those call sites here.
|
|
18
|
+
*/
|
|
19
|
+
import { AuthPersistenceService } from "dash-auth";
|
|
20
|
+
import { DASH_REDUX_ACTIONS } from "dash-admin-state";
|
|
21
|
+
import { useDispatch } from "react-redux";
|
|
22
|
+
import DashThemeContext from '../../default-theme/DashThemeContext';
|
|
23
|
+
import React, { PropsWithChildren, useEffect } from "react";
|
|
24
|
+
|
|
25
|
+
export interface IGlobalTenantWrapper extends PropsWithChildren {
|
|
26
|
+
tenantData?: any;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Tenant Data Fetcher Component that runs inside DashThemeProvider
|
|
30
|
+
const GlobalTenantWrapper: React.FC<IGlobalTenantWrapper> = (props) => {
|
|
31
|
+
const {
|
|
32
|
+
tenantData = null,
|
|
33
|
+
children
|
|
34
|
+
} = props;
|
|
35
|
+
|
|
36
|
+
const dispatch = useDispatch();
|
|
37
|
+
const themeContext = React.useContext(DashThemeContext);
|
|
38
|
+
const recreateTheme = themeContext?.recreateTheme;
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!tenantData) {
|
|
42
|
+
// No data to process
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Process tenant images for panel settings
|
|
47
|
+
if (tenantData.tenantImages) {
|
|
48
|
+
const logos = {
|
|
49
|
+
...(tenantData.tenantImages.horizontal_logo?.original && {
|
|
50
|
+
horizontalLogo: tenantData.tenantImages.horizontal_logo.original
|
|
51
|
+
}),
|
|
52
|
+
...(tenantData.tenantImages.squared_logo?.original && {
|
|
53
|
+
squaredLogo: tenantData.tenantImages.squared_logo.original
|
|
54
|
+
}),
|
|
55
|
+
...(tenantData.tenantImages.banner?.original && {
|
|
56
|
+
loginBackground: tenantData.tenantImages.banner.original
|
|
57
|
+
})
|
|
58
|
+
};
|
|
59
|
+
dispatch(DASH_REDUX_ACTIONS.setPanelSettings(logos));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Process tenant settings for theme
|
|
63
|
+
if (tenantData.tenantSettings && recreateTheme) {
|
|
64
|
+
recreateTheme(tenantData.tenantSettings);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Store tenant data for persistence (without user authentication)
|
|
68
|
+
const publicAuthData = {
|
|
69
|
+
auth: {
|
|
70
|
+
tenantImages: tenantData.tenantImages,
|
|
71
|
+
tenantSettings: tenantData.tenantSettings,
|
|
72
|
+
tenant: tenantData.tenant,
|
|
73
|
+
},
|
|
74
|
+
systemValues: tenantData.systemValues
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
AuthPersistenceService.saveAuth(publicAuthData);
|
|
78
|
+
|
|
79
|
+
}, [tenantData]);
|
|
80
|
+
|
|
81
|
+
return <></>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export default GlobalTenantWrapper;
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TenantSwitcher
|
|
3
|
+
*
|
|
4
|
+
* A dropdown component that allows switching between the Tenancy Account and
|
|
5
|
+
* individual Tenants. It reads the available tenants from the persisted
|
|
6
|
+
* systemValues (populated by the tenancyAuth endpoint) and allows the user
|
|
7
|
+
* to select one, triggering a full environment switch:
|
|
8
|
+
*
|
|
9
|
+
* - Persists the selected tenant_id to dashStorage
|
|
10
|
+
* - Dispatches a 'tenant_switch' CustomEvent so the PrivateAppLoader can
|
|
11
|
+
* reload the correct resources, routes, and auth data
|
|
12
|
+
* - Re-fetches the appropriate getAuth endpoint (tenancyAuth vs getauth)
|
|
13
|
+
* - Updates CSS variables and logos from the new tenant's settings
|
|
14
|
+
*
|
|
15
|
+
* Consolidated 2026-08-03 from four byte-identical app-level copies
|
|
16
|
+
* (kitchntabs-app, kitchntabs-web, vanexa-app, vanexa-web). The only per-app
|
|
17
|
+
* difference was which local brand assets each app fell back to when
|
|
18
|
+
* switching back to the tenancy level — that's now the defaultHorizontalLogo/
|
|
19
|
+
* defaultSquaredLogo/defaultLoginBackground props instead of hardcoded
|
|
20
|
+
* relative imports (a relative image import moved into this package would
|
|
21
|
+
* have resolved against dash-admin's own assets, not each app's brand).
|
|
22
|
+
*/
|
|
23
|
+
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
24
|
+
import {
|
|
25
|
+
Avatar,
|
|
26
|
+
Box,
|
|
27
|
+
Typography,
|
|
28
|
+
CircularProgress,
|
|
29
|
+
} from '@mui/material';
|
|
30
|
+
import BusinessIcon from '@mui/icons-material/Business';
|
|
31
|
+
import StoreIcon from '@mui/icons-material/Store';
|
|
32
|
+
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
|
|
33
|
+
import ReactDOM from 'react-dom';
|
|
34
|
+
import { dashStorage } from 'dash-utils';
|
|
35
|
+
import { AuthPersistenceService } from 'dash-auth';
|
|
36
|
+
import { DASHAdminSystemConstants, getEnv } from 'dash-constants';
|
|
37
|
+
import { useAxios } from 'dash-axios-hook';
|
|
38
|
+
import { updateDomCssVariables } from 'dash-utils';
|
|
39
|
+
import DASHLayoutSettings from '../../theme/AppLayoutSetting';
|
|
40
|
+
import { useDispatch, useSelector } from 'react-redux';
|
|
41
|
+
import { setPanelSettings } from 'dash-admin-state/redux/actions/Common';
|
|
42
|
+
import { IDASHAppState } from 'dash-admin-state';
|
|
43
|
+
import { useWindowSize } from 'dash-utils';
|
|
44
|
+
import { setAuthEvent } from '../../contexts/auth';
|
|
45
|
+
|
|
46
|
+
export interface TenantSwitcherProps {
|
|
47
|
+
/** Fallback horizontal logo used when switching back to the tenancy level */
|
|
48
|
+
defaultHorizontalLogo?: string;
|
|
49
|
+
/** Fallback squared logo used when switching back to the tenancy level */
|
|
50
|
+
defaultSquaredLogo?: string;
|
|
51
|
+
/** Fallback login background used when switching back to the tenancy level */
|
|
52
|
+
defaultLoginBackground?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Shape of a tenant from systemValues.tenants */
|
|
56
|
+
interface TenantOption {
|
|
57
|
+
id: string;
|
|
58
|
+
name: string;
|
|
59
|
+
slug: string | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The value emitted in the tenant_switch CustomEvent */
|
|
63
|
+
export interface TenantSwitchEventDetail {
|
|
64
|
+
tenantId: string | null; // null = tenancy account level
|
|
65
|
+
tenantName: string;
|
|
66
|
+
isTenanancyLevel: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Dispatch a tenant_switch event so other parts of the app can react.
|
|
71
|
+
*/
|
|
72
|
+
export const dispatchTenantSwitchEvent = (detail: TenantSwitchEventDetail) => {
|
|
73
|
+
window.dispatchEvent(new CustomEvent('tenant_switch', { detail }));
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get the currently active tenant_id from storage (null = tenancy level).
|
|
78
|
+
*/
|
|
79
|
+
export const getActiveTenantId = (): string | null => {
|
|
80
|
+
if (!isTenantImpersonationEnabled()) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return dashStorage.getItem('active_tenant_id') || null;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check whether tenant impersonation is enabled.
|
|
88
|
+
*/
|
|
89
|
+
export const isTenantImpersonationEnabled = (): boolean => {
|
|
90
|
+
const val = DASHAdminSystemConstants.system.ENABLE_TENANT_IMPERSONATION;
|
|
91
|
+
if (typeof val === 'boolean') return val;
|
|
92
|
+
if (typeof val === 'string') return val === 'true' || val === '1';
|
|
93
|
+
return !!val;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
|
99
|
+
defaultHorizontalLogo,
|
|
100
|
+
defaultSquaredLogo,
|
|
101
|
+
defaultLoginBackground,
|
|
102
|
+
}) => {
|
|
103
|
+
const axios = useAxios();
|
|
104
|
+
const dispatch = useDispatch();
|
|
105
|
+
const windowSize = useWindowSize();
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
const [open, setOpen] = useState(false);
|
|
110
|
+
const [switching, setSwitching] = useState(false);
|
|
111
|
+
const avatarRef = useRef<HTMLDivElement>(null);
|
|
112
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
113
|
+
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 });
|
|
114
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
115
|
+
const [webView, setWebView] = useState(false);
|
|
116
|
+
|
|
117
|
+
// Current active tenant id
|
|
118
|
+
const [activeTenantId, setActiveTenantId] = useState<string | null>(
|
|
119
|
+
getActiveTenantId()
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Read panel settings for logos
|
|
123
|
+
const panelSettings = useSelector(
|
|
124
|
+
(state: IDASHAppState<any, any, any>) => state.common?.panelSettings
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Get primary and secondary sidebar positions from Redux panelSettings
|
|
128
|
+
const sidebarPosition = panelSettings?.sidebarPosition || 'left';
|
|
129
|
+
|
|
130
|
+
// Get tenants from persisted systemValues
|
|
131
|
+
const systemValues = AuthPersistenceService.getSystemValues();
|
|
132
|
+
const tenants: TenantOption[] = systemValues?.tenants || [];
|
|
133
|
+
const tenancy = systemValues?.tenancy;
|
|
134
|
+
|
|
135
|
+
// Get current tenant auth data for display
|
|
136
|
+
const auth = AuthPersistenceService.getAuth();
|
|
137
|
+
const currentTenantName = activeTenantId
|
|
138
|
+
? tenants.find(t => t.id === activeTenantId)?.name || 'Tenant'
|
|
139
|
+
: tenancy?.public_name || auth?.tenant?.name || 'Tenancy Account';
|
|
140
|
+
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (document.body.classList.contains('webview')) {
|
|
143
|
+
setWebView(true);
|
|
144
|
+
}
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
// Don't render if impersonation is disabled or no tenants available
|
|
148
|
+
if (!isTenantImpersonationEnabled() || tenants.length === 0) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const calculateMenuPosition = () => {
|
|
153
|
+
if (avatarRef.current && windowSize.width && windowSize.height) {
|
|
154
|
+
const rect = avatarRef.current.getBoundingClientRect();
|
|
155
|
+
const menuWidth = 230; // minWidth from styles
|
|
156
|
+
const menuHeight = 120; // approximate menu height
|
|
157
|
+
|
|
158
|
+
let left = rect.left + window.scrollX;
|
|
159
|
+
let top = rect.top + window.scrollY;
|
|
160
|
+
|
|
161
|
+
// Ensure menu doesn't go off-screen horizontally
|
|
162
|
+
if (left + menuWidth > windowSize.width) {
|
|
163
|
+
left = windowSize.width - menuWidth - 10; // 10px margin
|
|
164
|
+
}
|
|
165
|
+
if (left < 10) {
|
|
166
|
+
left = 10; // 10px margin
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// For bottom sidebar position, open menu above the avatar
|
|
170
|
+
if (sidebarPosition === 'bottom') {
|
|
171
|
+
top = rect.top + window.scrollY - menuHeight - 10; // Open above
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
setMenuPosition({
|
|
175
|
+
top: top,
|
|
176
|
+
left: left + 40
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
182
|
+
e.preventDefault();
|
|
183
|
+
if (timeoutRef.current) {
|
|
184
|
+
clearTimeout(timeoutRef.current);
|
|
185
|
+
timeoutRef.current = null;
|
|
186
|
+
}
|
|
187
|
+
setOpen(true);
|
|
188
|
+
calculateMenuPosition();
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
setOpen(prev => !prev);
|
|
194
|
+
calculateMenuPosition();
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const handleMouseLeave = () => {
|
|
198
|
+
if (!webView) {
|
|
199
|
+
timeoutRef.current = setTimeout(() => setOpen(false), 150);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const handleMenuMouseEnter = () => {
|
|
204
|
+
if (timeoutRef.current) {
|
|
205
|
+
clearTimeout(timeoutRef.current);
|
|
206
|
+
timeoutRef.current = null;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const handleMenuMouseLeave = () => {
|
|
211
|
+
if (!webView) {
|
|
212
|
+
setOpen(false);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
return () => {
|
|
218
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
219
|
+
};
|
|
220
|
+
}, []);
|
|
221
|
+
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
if (open) calculateMenuPosition();
|
|
224
|
+
}, [open, windowSize.width]);
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Core switching logic.
|
|
228
|
+
* - Always uses tenancyAuth endpoint (preserves tenancy/tenants in response)
|
|
229
|
+
* - Passes X-Tenant-Id header when switching to a specific tenant
|
|
230
|
+
* - Persists auth data
|
|
231
|
+
* - Updates CSS variables, logos
|
|
232
|
+
* - Dispatches tenant_switch event for PrivateAppLoader
|
|
233
|
+
*/
|
|
234
|
+
const handleSwitch = useCallback(async (tenantId: string | null) => {
|
|
235
|
+
if (switching) return;
|
|
236
|
+
if (tenantId === activeTenantId) {
|
|
237
|
+
setOpen(false);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
setSwitching(true);
|
|
242
|
+
setOpen(false);
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
const isTenancyLevel = tenantId === null;
|
|
246
|
+
// Always use tenancyAuth - it returns tenancy/tenants list + respects X-Tenant-Id header
|
|
247
|
+
const authEndpoint = getEnv('APP_GETAUTH_ENDPOINT') || 'auth/tenancyAuth';
|
|
248
|
+
|
|
249
|
+
// If switching to a specific tenant, set the header so backend knows
|
|
250
|
+
const headers: Record<string, string> = {};
|
|
251
|
+
if (!isTenancyLevel && tenantId) {
|
|
252
|
+
headers['X-Tenant-Id'] = tenantId;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Persist active_tenant_id BEFORE the request so the axios interceptor picks it up
|
|
256
|
+
if (tenantId) {
|
|
257
|
+
dashStorage.setItem('active_tenant_id', tenantId);
|
|
258
|
+
dashStorage.setItem('tenant_id', tenantId);
|
|
259
|
+
} else {
|
|
260
|
+
dashStorage.removeItem('active_tenant_id');
|
|
261
|
+
dashStorage.removeItem('tenant_id');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Fetch new auth data from the correct endpoint
|
|
265
|
+
const { data: authData } = await axios.get(authEndpoint, { headers });
|
|
266
|
+
|
|
267
|
+
// Persist auth data using the standard service
|
|
268
|
+
AuthPersistenceService.saveAuth(authData);
|
|
269
|
+
|
|
270
|
+
// Update basic localStorage for react-admin compatibility
|
|
271
|
+
dashStorage.setItem('authenticated', 'true');
|
|
272
|
+
dashStorage.setItem('user', JSON.stringify(authData.user));
|
|
273
|
+
if (authData.user?.roles) {
|
|
274
|
+
dashStorage.setItem('roles', JSON.stringify(authData.user.roles));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Trigger auth context update
|
|
278
|
+
setAuthEvent({
|
|
279
|
+
authenticated: true,
|
|
280
|
+
user: authData.user,
|
|
281
|
+
auth: authData.auth,
|
|
282
|
+
token: dashStorage.getItem('token'),
|
|
283
|
+
roles: authData.user?.roles,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Update CSS variables from new tenant settings
|
|
287
|
+
const tenantSettings = authData.auth?.tenantSettings;
|
|
288
|
+
try {
|
|
289
|
+
|
|
290
|
+
// Read current theme mode instead of hardcoding 'dark'
|
|
291
|
+
const currentThemeMode = document.documentElement.getAttribute('data-theme') || DASHLayoutSettings.THEME_TYPE_DARK;
|
|
292
|
+
// Always call updateDomCssVariables — when colors is undefined,
|
|
293
|
+
// it correctly resets to compiled LESS defaults for tenants without custom colors.
|
|
294
|
+
updateDomCssVariables(currentThemeMode, tenantSettings?.colors, tenantSettings?.values);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
console.error('[TenantSwitcher] Error updating CSS variables:', err);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Update logos in Redux panel settings
|
|
300
|
+
const tenantImages = authData.auth?.tenantImages;
|
|
301
|
+
if (tenantImages) {
|
|
302
|
+
AuthPersistenceService.setTenantImages(tenantImages);
|
|
303
|
+
dispatch(setPanelSettings({
|
|
304
|
+
...(panelSettings || {}),
|
|
305
|
+
horizontalLogo: tenantImages.horizontal_logo?.original || panelSettings?.horizontalLogo,
|
|
306
|
+
squaredLogo: tenantImages.squared_logo?.original || panelSettings?.squaredLogo,
|
|
307
|
+
loginBackground: tenantImages.banner?.original || panelSettings?.loginBackground,
|
|
308
|
+
}));
|
|
309
|
+
} else {
|
|
310
|
+
// Switching to tenancy level — clear stale tenant images
|
|
311
|
+
// and reset Redux logos to system defaults
|
|
312
|
+
AuthPersistenceService.clearTenantImages();
|
|
313
|
+
dispatch(setPanelSettings({
|
|
314
|
+
...(panelSettings || {}),
|
|
315
|
+
horizontalLogo: defaultHorizontalLogo,
|
|
316
|
+
squaredLogo: defaultSquaredLogo,
|
|
317
|
+
loginBackground: defaultLoginBackground,
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Update tenant settings in persistence
|
|
322
|
+
if (tenantSettings) {
|
|
323
|
+
AuthPersistenceService.setTenantSettings(tenantSettings);
|
|
324
|
+
} else {
|
|
325
|
+
// Switching to tenancy level — clear stale tenant settings
|
|
326
|
+
// so DashThemeProvider reads null on remount and uses defaults
|
|
327
|
+
AuthPersistenceService.clearTenantSettings();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
setActiveTenantId(tenantId);
|
|
331
|
+
|
|
332
|
+
// Find tenant name for event
|
|
333
|
+
const tenantName = isTenancyLevel
|
|
334
|
+
? (tenancy?.public_name || 'Tenancy Account')
|
|
335
|
+
: (tenants.find(t => t.id === tenantId)?.name || 'Tenant');
|
|
336
|
+
|
|
337
|
+
// Dispatch switch event so PrivateAppLoader can reload resources
|
|
338
|
+
dispatchTenantSwitchEvent({
|
|
339
|
+
tenantId,
|
|
340
|
+
tenantName,
|
|
341
|
+
isTenanancyLevel: isTenancyLevel,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
console.log(`[TenantSwitcher] Switched to ${isTenancyLevel ? 'Tenancy Account' : tenantName} (${tenantId})`);
|
|
345
|
+
|
|
346
|
+
} catch (error) {
|
|
347
|
+
console.error('[TenantSwitcher] Switch failed:', error);
|
|
348
|
+
// Revert storage on failure
|
|
349
|
+
if (activeTenantId) {
|
|
350
|
+
dashStorage.setItem('active_tenant_id', activeTenantId);
|
|
351
|
+
dashStorage.setItem('tenant_id', activeTenantId);
|
|
352
|
+
} else {
|
|
353
|
+
dashStorage.removeItem('active_tenant_id');
|
|
354
|
+
dashStorage.removeItem('tenant_id');
|
|
355
|
+
}
|
|
356
|
+
} finally {
|
|
357
|
+
setSwitching(false);
|
|
358
|
+
}
|
|
359
|
+
}, [activeTenantId, switching, axios, dispatch, panelSettings, tenants, tenancy, defaultHorizontalLogo, defaultSquaredLogo, defaultLoginBackground]);
|
|
360
|
+
|
|
361
|
+
const isSelected = (id: string | null) => id === activeTenantId;
|
|
362
|
+
|
|
363
|
+
return (
|
|
364
|
+
<>
|
|
365
|
+
<div
|
|
366
|
+
ref={avatarRef}
|
|
367
|
+
className="dash-tenant-switcher-trigger"
|
|
368
|
+
{...(!webView
|
|
369
|
+
? { onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave }
|
|
370
|
+
: { onClick: handleClick }
|
|
371
|
+
)}
|
|
372
|
+
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}
|
|
373
|
+
>
|
|
374
|
+
{switching ? (
|
|
375
|
+
<CircularProgress
|
|
376
|
+
className='dash-icon-button-color dash-icon-button-bg'
|
|
377
|
+
size={30} />
|
|
378
|
+
) : (
|
|
379
|
+
<Avatar
|
|
380
|
+
className='dash-icon-button-color dash-icon-button-bg'
|
|
381
|
+
sx={{
|
|
382
|
+
width: 30,
|
|
383
|
+
height: 30,
|
|
384
|
+
fontSize: '0.8rem',
|
|
385
|
+
}}
|
|
386
|
+
>
|
|
387
|
+
{activeTenantId ? <StoreIcon sx={{ fontSize: 18 }} /> : <BusinessIcon sx={{ fontSize: 18 }} />}
|
|
388
|
+
</Avatar>
|
|
389
|
+
)}
|
|
390
|
+
</div>
|
|
391
|
+
|
|
392
|
+
{open && ReactDOM.createPortal(
|
|
393
|
+
<div
|
|
394
|
+
ref={menuRef}
|
|
395
|
+
className="dash-tenant-switcher-portal"
|
|
396
|
+
onMouseEnter={!webView ? handleMenuMouseEnter : undefined}
|
|
397
|
+
onMouseLeave={!webView ? handleMenuMouseLeave : undefined}
|
|
398
|
+
>
|
|
399
|
+
<div
|
|
400
|
+
className="dash-tenant-switcher-menu"
|
|
401
|
+
style={{
|
|
402
|
+
top: menuPosition.top,
|
|
403
|
+
left: menuPosition.left,
|
|
404
|
+
zIndex: 10000,
|
|
405
|
+
position: 'absolute',
|
|
406
|
+
}}
|
|
407
|
+
>
|
|
408
|
+
{/* Header */}
|
|
409
|
+
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid rgba(255,255,255,0.08)' }}>
|
|
410
|
+
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
|
411
|
+
<SwapHorizIcon sx={{ fontSize: 14, mr: 0.5, verticalAlign: 'middle' }} />
|
|
412
|
+
Switch Environment
|
|
413
|
+
</Typography>
|
|
414
|
+
</Box>
|
|
415
|
+
|
|
416
|
+
{/* Tenancy Account option */}
|
|
417
|
+
<div
|
|
418
|
+
onClick={() => handleSwitch(null)}
|
|
419
|
+
style={{
|
|
420
|
+
padding: '10px 16px',
|
|
421
|
+
cursor: 'pointer',
|
|
422
|
+
display: 'flex',
|
|
423
|
+
alignItems: 'center',
|
|
424
|
+
gap: 10,
|
|
425
|
+
backgroundColor: isSelected(null) ? 'rgba(var(--dash-primary-rgb, 25, 118, 210), 0.15)' : 'transparent',
|
|
426
|
+
borderLeft: isSelected(null) ? '3px solid var(--dash-primary, #1976d2)' : '3px solid transparent',
|
|
427
|
+
transition: 'background-color 0.15s ease',
|
|
428
|
+
}}
|
|
429
|
+
onMouseOver={(e) => { if (!isSelected(null)) (e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.05)'); }}
|
|
430
|
+
onMouseOut={(e) => { if (!isSelected(null)) (e.currentTarget.style.backgroundColor = 'transparent'); }}
|
|
431
|
+
>
|
|
432
|
+
<BusinessIcon sx={{ fontSize: 20, color: isSelected(null) ? 'primary.main' : 'text.secondary' }} />
|
|
433
|
+
<Box>
|
|
434
|
+
<Typography variant="body2" sx={{ fontWeight: isSelected(null) ? 600 : 400, color: isSelected(null) ? 'primary.main' : 'text.primary' }}>
|
|
435
|
+
{tenancy?.public_name || 'Tenancy Account'}
|
|
436
|
+
</Typography>
|
|
437
|
+
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
438
|
+
Manager Account
|
|
439
|
+
</Typography>
|
|
440
|
+
</Box>
|
|
441
|
+
</div>
|
|
442
|
+
|
|
443
|
+
{/* Divider */}
|
|
444
|
+
<Box sx={{ borderTop: '1px solid rgba(255,255,255,0.06)', mx: 1 }} />
|
|
445
|
+
|
|
446
|
+
{/* Tenant options */}
|
|
447
|
+
{tenants.map((tenant) => (
|
|
448
|
+
<div
|
|
449
|
+
key={tenant.id}
|
|
450
|
+
onClick={() => handleSwitch(tenant.id)}
|
|
451
|
+
style={{
|
|
452
|
+
padding: '10px 16px',
|
|
453
|
+
cursor: 'pointer',
|
|
454
|
+
display: 'flex',
|
|
455
|
+
alignItems: 'center',
|
|
456
|
+
gap: 10,
|
|
457
|
+
backgroundColor: isSelected(tenant.id) ? 'rgba(var(--dash-primary-rgb, 25, 118, 210), 0.15)' : 'transparent',
|
|
458
|
+
borderLeft: isSelected(tenant.id) ? '3px solid var(--dash-primary, #1976d2)' : '3px solid transparent',
|
|
459
|
+
transition: 'background-color 0.15s ease',
|
|
460
|
+
}}
|
|
461
|
+
onMouseOver={(e) => { if (!isSelected(tenant.id)) (e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.05)'); }}
|
|
462
|
+
onMouseOut={(e) => { if (!isSelected(tenant.id)) (e.currentTarget.style.backgroundColor = 'transparent'); }}
|
|
463
|
+
>
|
|
464
|
+
<StoreIcon sx={{ fontSize: 20, color: isSelected(tenant.id) ? 'primary.main' : 'text.secondary' }} />
|
|
465
|
+
<Box>
|
|
466
|
+
<Typography variant="body2" sx={{ fontWeight: isSelected(tenant.id) ? 600 : 400, color: isSelected(tenant.id) ? 'primary.main' : 'text.primary' }}>
|
|
467
|
+
{tenant.name}
|
|
468
|
+
</Typography>
|
|
469
|
+
{tenant.slug && (
|
|
470
|
+
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
471
|
+
{tenant.slug}
|
|
472
|
+
</Typography>
|
|
473
|
+
)}
|
|
474
|
+
</Box>
|
|
475
|
+
</div>
|
|
476
|
+
))}
|
|
477
|
+
</div>
|
|
478
|
+
</div>,
|
|
479
|
+
document.body
|
|
480
|
+
)}
|
|
481
|
+
</>
|
|
482
|
+
);
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
export default TenantSwitcher;
|