@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.
Files changed (53) hide show
  1. package/dist/components/i18n/BridgedLocalesMenuButton.js +1 -2
  2. package/dist/components/menu/DarkToggleMode.js +1 -1
  3. package/dist/components/serviceAccount/ApiKeyRevealDialog.js +110 -0
  4. package/dist/components/serviceAccount/ServiceAccountStatusSwitch.js +94 -0
  5. package/dist/components/subscription/PlanPricesSettings.js +4 -2
  6. package/dist/components/tenancy/GlobalTenantWrapper.js +64 -0
  7. package/dist/components/tenancy/TenantSwitcher.js +389 -0
  8. package/dist/contexts/com/components/EchoNotificationsCenter.js +40 -0
  9. package/dist/default-theme/menu/AppMenuComponents/expanded/SidebarItem.js +1 -0
  10. package/dist/index.js +60 -40
  11. package/dist/pages/Profile.js +32 -38
  12. package/dist/providers/i18n/en.js +108 -0
  13. package/dist/providers/i18n/es.js +108 -0
  14. package/dist/providers/i18n/languages.js +3 -1
  15. package/dist/providers/i18n/mergeTranslations.js +18 -0
  16. package/dist/resources/User/components/AvatarUploadField.js +61 -0
  17. package/dist/resources/User/components/PasswordFields.js +67 -0
  18. package/dist/resources/User/getProfileMenu.js +36 -0
  19. package/dist/resources/User/profileResource.js +33 -0
  20. package/dist/resources/User/userLayout.js +58 -0
  21. package/dist/resources/User/userResource.js +90 -0
  22. package/dist/resources/User/userSchema.js +206 -0
  23. package/dist/resources/User/usersFilters.js +22 -0
  24. package/dist/systemResources.js +30 -19
  25. package/dist/templates/ResourceTemplateCreate.js +5 -2
  26. package/package.json +4 -1
  27. package/src/components/i18n/BridgedLocalesMenuButton.tsx +1 -2
  28. package/src/components/menu/DarkToggleMode.tsx +1 -1
  29. package/src/components/serviceAccount/ApiKeyRevealDialog.tsx +129 -0
  30. package/src/components/serviceAccount/ServiceAccountStatusSwitch.tsx +99 -0
  31. package/src/components/subscription/PlanPricesSettings.tsx +2 -2
  32. package/src/components/tenancy/GlobalTenantWrapper.tsx +84 -0
  33. package/src/components/tenancy/TenantSwitcher.tsx +485 -0
  34. package/src/contexts/com/components/EchoNotificationsCenter.tsx +65 -0
  35. package/src/default-theme/menu/AppMaterialMenu.tsx +1 -1
  36. package/src/default-theme/menu/AppMenuComponents/expanded/SidebarItem.tsx +1 -0
  37. package/src/default-theme/menu/SidebarActions.tsx +1 -1
  38. package/src/index.tsx +15 -0
  39. package/src/pages/Profile.tsx +69 -107
  40. package/src/providers/i18n/en.ts +113 -0
  41. package/src/providers/i18n/es.ts +113 -0
  42. package/src/providers/i18n/languages.tsx +3 -0
  43. package/src/providers/i18n/mergeTranslations.ts +53 -0
  44. package/src/resources/User/components/AvatarUploadField.tsx +57 -0
  45. package/src/resources/User/components/PasswordFields.tsx +73 -0
  46. package/src/resources/User/getProfileMenu.ts +57 -0
  47. package/src/resources/User/profileResource.tsx +51 -0
  48. package/src/resources/User/userLayout.tsx +62 -0
  49. package/src/resources/User/userResource.tsx +111 -0
  50. package/src/resources/User/userSchema.tsx +213 -0
  51. package/src/resources/User/usersFilters.tsx +21 -0
  52. package/src/systemResources.tsx +31 -20
  53. 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
- 'ecommerce/currency',
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
- 'ecommerce/currency',
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;