@umituz/web-dashboard 3.2.2 → 4.0.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.
Files changed (125) hide show
  1. package/README.md +202 -14
  2. package/package.json +21 -29
  3. package/src/domains/analytics/components/AnalyticsCard.tsx +23 -21
  4. package/src/domains/analytics/components/AnalyticsChart.tsx +345 -149
  5. package/src/domains/analytics/components/AnalyticsLayout.tsx +93 -112
  6. package/src/domains/analytics/components/MetricCard.tsx +42 -64
  7. package/src/domains/analytics/hooks/index.ts +7 -1
  8. package/src/domains/analytics/hooks/useAnalytics.ts +144 -139
  9. package/src/domains/analytics/index.ts +11 -0
  10. package/src/domains/analytics/services/AnalyticsEngineService.ts +103 -55
  11. package/src/domains/analytics/services/PerformanceService.ts +117 -69
  12. package/src/domains/analytics/types/analytics.ts +20 -6
  13. package/src/domains/analytics/types/index.ts +3 -0
  14. package/src/domains/analytics/utils/analytics.test.ts +183 -0
  15. package/src/domains/analytics/utils/analytics.ts +26 -9
  16. package/src/domains/analytics/utils/i18nKeys.ts +40 -0
  17. package/src/domains/analytics/utils/index.ts +2 -0
  18. package/src/domains/auth/components/AuthLayout.tsx +63 -54
  19. package/src/domains/auth/components/ForgotPasswordForm.tsx +96 -98
  20. package/src/domains/auth/components/LoginForm.tsx +167 -226
  21. package/src/domains/auth/components/RegisterForm.tsx +202 -272
  22. package/src/domains/auth/components/ResetPasswordForm.tsx +105 -151
  23. package/src/domains/auth/components/shared/AuthErrorBanner.tsx +35 -0
  24. package/src/domains/auth/components/shared/EmailInput.tsx +91 -0
  25. package/src/domains/auth/components/shared/PasswordInput.tsx +119 -0
  26. package/src/domains/auth/components/shared/PasswordStrengthIndicator.tsx +75 -0
  27. package/src/domains/auth/components/shared/SocialLoginButtons.tsx +58 -0
  28. package/src/domains/auth/components/shared/SocialProviderIcons.tsx +48 -0
  29. package/src/domains/auth/components/shared/index.ts +20 -0
  30. package/src/domains/auth/hooks/index.ts +1 -0
  31. package/src/domains/auth/hooks/useAuth.ts +151 -232
  32. package/src/domains/auth/index.ts +8 -4
  33. package/src/domains/auth/types/auth.ts +26 -5
  34. package/src/domains/auth/types/index.ts +1 -0
  35. package/src/domains/auth/utils/auth.test.ts +182 -0
  36. package/src/domains/auth/utils/auth.ts +71 -130
  37. package/src/domains/auth/utils/i18nKeys.ts +127 -0
  38. package/src/domains/auth/utils/index.ts +7 -1
  39. package/src/domains/auth/utils/secureToken.ts +33 -0
  40. package/src/domains/auth/utils/validationKeys.ts +23 -0
  41. package/src/domains/calendar/hooks/useCalendar.ts +145 -151
  42. package/src/domains/calendar/index.ts +0 -1
  43. package/src/domains/calendar/services/CalendarService.ts +122 -179
  44. package/src/domains/calendar/utils/contentItemMapper.test.ts +108 -0
  45. package/src/domains/calendar/utils/contentItemMapper.ts +99 -0
  46. package/src/domains/calendar/utils/index.ts +33 -30
  47. package/src/domains/layouts/components/BrandLogo.tsx +12 -9
  48. package/src/domains/layouts/components/DashboardHeader.tsx +78 -80
  49. package/src/domains/layouts/components/DashboardLayout.tsx +45 -21
  50. package/src/domains/layouts/components/DashboardSidebar.tsx +48 -51
  51. package/src/domains/layouts/types/layout.ts +7 -1
  52. package/src/domains/layouts/types/user.ts +3 -1
  53. package/src/domains/modules/build.test.tsx +97 -0
  54. package/src/domains/modules/build.tsx +71 -0
  55. package/src/domains/modules/index.ts +2 -0
  56. package/src/domains/modules/types.ts +29 -0
  57. package/src/domains/onboarding/components/AppFocusStep.tsx +99 -68
  58. package/src/domains/onboarding/components/OnboardingWizard.tsx +109 -183
  59. package/src/domains/onboarding/components/PlanStep.tsx +156 -123
  60. package/src/domains/onboarding/components/PlatformsStep.tsx +74 -54
  61. package/src/domains/onboarding/components/StepNavigation.tsx +108 -0
  62. package/src/domains/onboarding/components/StepProgress.tsx +62 -0
  63. package/src/domains/onboarding/components/UserTypeStep.tsx +59 -80
  64. package/src/domains/onboarding/components/index.ts +4 -0
  65. package/src/domains/onboarding/hooks/useOnboarding.ts +76 -66
  66. package/src/domains/onboarding/index.ts +1 -0
  67. package/src/domains/onboarding/types/onboarding.ts +57 -17
  68. package/src/domains/onboarding/utils/i18nKeys.ts +72 -0
  69. package/src/domains/onboarding/utils/index.ts +2 -0
  70. package/src/domains/permissions/PermissionsContext.tsx +103 -0
  71. package/src/domains/permissions/hasPermission.test.ts +50 -0
  72. package/src/domains/permissions/hasPermission.ts +38 -0
  73. package/src/domains/permissions/index.ts +19 -0
  74. package/src/domains/permissions/types.ts +35 -0
  75. package/src/domains/permissions/utils/i18nKeys.ts +11 -0
  76. package/src/domains/session/DemoLogin.tsx +50 -0
  77. package/src/domains/session/OrgSwitcher.tsx +110 -0
  78. package/src/domains/session/SessionContext.tsx +150 -0
  79. package/src/domains/session/inMemoryAuthAdapter.ts +53 -0
  80. package/src/domains/session/index.ts +18 -0
  81. package/src/domains/session/types.ts +47 -0
  82. package/src/domains/session/utils/i18nKeys.ts +12 -0
  83. package/src/domains/settings/components/AuditLogList.tsx +59 -0
  84. package/src/domains/settings/components/PermissionMatrixScreen.tsx +80 -0
  85. package/src/domains/settings/components/SettingsLayout.tsx +75 -60
  86. package/src/domains/settings/components/SettingsSection.tsx +35 -22
  87. package/src/domains/settings/components/UserManagementTable.tsx +87 -0
  88. package/src/domains/settings/components/index.ts +10 -0
  89. package/src/domains/settings/hooks/useSettings.ts +77 -52
  90. package/src/domains/settings/types/settings.ts +2 -0
  91. package/src/domains/settings/utils/i18nKeys.ts +18 -0
  92. package/src/domains/settings/utils/settings.ts +11 -4
  93. package/src/i18n/I18nContext.tsx +33 -0
  94. package/src/i18n/defaultT.test.ts +43 -0
  95. package/src/i18n/defaultT.ts +34 -0
  96. package/src/i18n/index.ts +7 -0
  97. package/src/i18n/types.ts +13 -0
  98. package/src/index.ts +1 -7
  99. package/src/primitives/Button.tsx +30 -0
  100. package/src/primitives/Checkbox.tsx +40 -0
  101. package/src/primitives/Input.tsx +19 -0
  102. package/src/primitives/Skeleton.tsx +26 -0
  103. package/src/primitives/cn.ts +15 -0
  104. package/src/primitives/index.ts +23 -0
  105. package/src/styles/dashboard.css +3045 -0
  106. package/src/styles/tokens.contract.test.ts +66 -0
  107. package/src/styles/tokens.css +124 -0
  108. package/src/domains/billing/components/BillingLayout.tsx +0 -63
  109. package/src/domains/billing/components/BillingPage.tsx +0 -49
  110. package/src/domains/billing/components/BillingPortal.tsx +0 -198
  111. package/src/domains/billing/components/InvoiceCard.tsx +0 -143
  112. package/src/domains/billing/components/PaymentMethodsList.tsx +0 -116
  113. package/src/domains/billing/components/PlanComparison.tsx +0 -199
  114. package/src/domains/billing/components/UsageCard.tsx +0 -103
  115. package/src/domains/billing/components/index.ts +0 -13
  116. package/src/domains/billing/constants/billing.ts +0 -69
  117. package/src/domains/billing/hooks/index.ts +0 -7
  118. package/src/domains/billing/hooks/useBilling.ts +0 -385
  119. package/src/domains/billing/index.ts +0 -73
  120. package/src/domains/billing/types/billing.ts +0 -342
  121. package/src/domains/billing/types/index.ts +0 -30
  122. package/src/domains/billing/utils/billing.ts +0 -360
  123. package/src/domains/billing/utils/index.ts +0 -31
  124. package/src/domains/billing/utils/polar.ts +0 -63
  125. package/src/domains/calendar/components/index.ts +0 -7
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Onboarding Domain i18n Keys
3
+ */
4
+
5
+ export const ONBOARDING_KEYS = {
6
+ // Wizard
7
+ wizard: {
8
+ cancel: 'onboarding.buttons.cancel',
9
+ stepNotConfigured: 'onboarding.errors.stepNotConfigured',
10
+ errors: {
11
+ completionFailed: 'onboarding.errors.completionFailed',
12
+ },
13
+ },
14
+
15
+ // User type step
16
+ userType: {
17
+ title: 'onboarding.userType.title',
18
+ description: 'onboarding.userType.description',
19
+ },
20
+
21
+ // App focus step
22
+ appFocus: {
23
+ title: 'onboarding.appFocus.title',
24
+ description: 'onboarding.appFocus.description',
25
+ success: 'onboarding.appFocus.success',
26
+ },
27
+
28
+ // Platforms step
29
+ platforms: {
30
+ title: 'onboarding.platforms.title',
31
+ description: 'onboarding.platforms.description',
32
+ connect: 'onboarding.platforms.connect',
33
+ connected: 'onboarding.platforms.connected',
34
+ selected: 'onboarding.platforms.selected',
35
+ },
36
+
37
+ // Plan step
38
+ plan: {
39
+ title: 'onboarding.plan.title',
40
+ description: 'onboarding.plan.description',
41
+ monthly: 'onboarding.plan.monthly',
42
+ yearly: 'onboarding.plan.yearly',
43
+ monthlyShort: 'onboarding.plan.monthlyShort',
44
+ yearlyShort: 'onboarding.plan.yearlyShort',
45
+ save: 'onboarding.plan.save',
46
+ recommended: 'onboarding.plan.recommended',
47
+ selectedPlan: 'onboarding.plan.selectedPlan',
48
+ mostPopular: 'onboarding.plan.mostPopular',
49
+ bestValue: 'onboarding.plan.bestValue',
50
+ custom: 'onboarding.plan.custom',
51
+ },
52
+
53
+ // Steps (default data — keys can be overridden per consumer)
54
+ defaults: {
55
+ founder: 'onboarding.defaults.userType.founder',
56
+ founderDescription: 'onboarding.defaults.userType.founderDescription',
57
+ contentCreator: 'onboarding.defaults.userType.contentCreator',
58
+ contentCreatorDescription: 'onboarding.defaults.userType.contentCreatorDescription',
59
+ smallBusiness: 'onboarding.defaults.userType.smallBusiness',
60
+ smallBusinessDescription: 'onboarding.defaults.userType.smallBusinessDescription',
61
+ enterprise: 'onboarding.defaults.userType.enterprise',
62
+ enterpriseDescription: 'onboarding.defaults.userType.enterpriseDescription',
63
+ agency: 'onboarding.defaults.userType.agency',
64
+ agencyDescription: 'onboarding.defaults.userType.agencyDescription',
65
+ personal: 'onboarding.defaults.userType.personal',
66
+ personalDescription: 'onboarding.defaults.userType.personalDescription',
67
+ mobileApp: 'onboarding.defaults.appType.mobileApp',
68
+ mobileAppDescription: 'onboarding.defaults.appType.mobileAppDescription',
69
+ webApp: 'onboarding.defaults.appType.webApp',
70
+ webAppDescription: 'onboarding.defaults.appType.webAppDescription',
71
+ },
72
+ } as const;
@@ -13,3 +13,5 @@ export {
13
13
  isValidEmail,
14
14
  isValidPassword,
15
15
  } from './onboarding';
16
+
17
+ export { ONBOARDING_KEYS } from './i18nKeys';
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Permissions context — provides the matrix + active role and a
3
+ * `can(permission)` helper bound to them. Guard components
4
+ * (<RequirePermission>, <Can>) consume this context.
5
+ */
6
+
7
+ import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
8
+ import { Lock } from "lucide-react";
9
+ import { useT } from "../../i18n";
10
+ import { hasPermission } from "./hasPermission";
11
+ import { PERMISSIONS_KEYS } from "./utils/i18nKeys";
12
+ import type { Permission, PermissionMatrix, RoleKey } from "./types";
13
+
14
+ export interface PermissionsProviderProps {
15
+ /** Role x permission matrix. */
16
+ matrix: PermissionMatrix;
17
+ /** Active user's role. */
18
+ role: RoleKey | null | undefined;
19
+ children: ReactNode;
20
+ }
21
+
22
+ interface PermissionsContextValue {
23
+ matrix: PermissionMatrix;
24
+ role: RoleKey | null;
25
+ can: (permission: Permission) => boolean;
26
+ }
27
+
28
+ const PermissionsContext = createContext<PermissionsContextValue | null>(null);
29
+
30
+ export const PermissionsProvider = ({
31
+ matrix,
32
+ role,
33
+ children,
34
+ }: PermissionsProviderProps) => {
35
+ const can = useCallback(
36
+ (permission: Permission) => hasPermission(matrix, role, permission),
37
+ [matrix, role],
38
+ );
39
+
40
+ const value = useMemo<PermissionsContextValue>(
41
+ () => ({ matrix, role: role ?? null, can }),
42
+ [matrix, role, can],
43
+ );
44
+
45
+ return (
46
+ <PermissionsContext.Provider value={value}>
47
+ {children}
48
+ </PermissionsContext.Provider>
49
+ );
50
+ };
51
+
52
+ export const usePermissions = (): PermissionsContextValue => {
53
+ const context = useContext(PermissionsContext);
54
+ if (!context) {
55
+ throw new Error("usePermissions requires a <PermissionsProvider> ancestor");
56
+ }
57
+ return context;
58
+ };
59
+
60
+ /** Default lock screen rendered by RequirePermission when denied. */
61
+ const ForbiddenFallback = () => {
62
+ const t = useT();
63
+ return (
64
+ <div className="udb-empty" role="alert">
65
+ <div className="udb-empty__icon">
66
+ <Lock className="udb-empty__glyph" aria-hidden="true" />
67
+ </div>
68
+ <p className="udb-empty__text">{t(PERMISSIONS_KEYS.forbidden.title)}</p>
69
+ <p className="udb-empty__hint">{t(PERMISSIONS_KEYS.forbidden.hint)}</p>
70
+ </div>
71
+ );
72
+ };
73
+
74
+ export interface RequirePermissionProps {
75
+ /** Permission required to render the children. */
76
+ permission: Permission;
77
+ children: ReactNode;
78
+ /** Rendered instead of the default lock screen when denied. */
79
+ fallback?: ReactNode;
80
+ }
81
+
82
+ /**
83
+ * Page-level permission gate — renders the fallback (lock screen by
84
+ * default) instead of the children when `can(permission)` is false.
85
+ */
86
+ export const RequirePermission = ({
87
+ permission,
88
+ children,
89
+ fallback,
90
+ }: RequirePermissionProps) => {
91
+ const { can } = usePermissions();
92
+ if (can(permission)) return <>{children}</>;
93
+ return <>{fallback ?? <ForbiddenFallback />}</>;
94
+ };
95
+
96
+ export interface CanProps extends RequirePermissionProps {}
97
+
98
+ /** Declarative twin of RequirePermission for inline use. */
99
+ export const Can = ({ permission, children, fallback }: CanProps) => (
100
+ <RequirePermission permission={permission} fallback={fallback}>
101
+ {children}
102
+ </RequirePermission>
103
+ );
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { hasPermission } from "./hasPermission";
3
+ import type { PermissionMatrix } from "./types";
4
+
5
+ const MATRIX: PermissionMatrix = {
6
+ owner: "*",
7
+ admin: ["dashboard.view", "sales.*", "reports.view"],
8
+ viewer: ["dashboard.view"],
9
+ nobody: [],
10
+ };
11
+
12
+ describe("hasPermission", () => {
13
+ it("grants exact permissions", () => {
14
+ expect(hasPermission(MATRIX, "admin", "dashboard.view")).toBe(true);
15
+ expect(hasPermission(MATRIX, "viewer", "dashboard.view")).toBe(true);
16
+ });
17
+
18
+ it("denies permissions not granted to the role", () => {
19
+ expect(hasPermission(MATRIX, "viewer", "sales.view")).toBe(false);
20
+ expect(hasPermission(MATRIX, "admin", "settings.manage")).toBe(false);
21
+ });
22
+
23
+ it("honors the full wildcard", () => {
24
+ expect(hasPermission(MATRIX, "owner", "anything.at.all")).toBe(true);
25
+ });
26
+
27
+ it("honors module wildcards without over-granting", () => {
28
+ expect(hasPermission(MATRIX, "admin", "sales.view")).toBe(true);
29
+ expect(hasPermission(MATRIX, "admin", "sales.manage")).toBe(true);
30
+ expect(hasPermission(MATRIX, "admin", "salesforce.view")).toBe(false);
31
+ });
32
+
33
+ it("denies unknown roles", () => {
34
+ expect(hasPermission(MATRIX, "intruder", "dashboard.view")).toBe(false);
35
+ });
36
+
37
+ it("denies unknown modules", () => {
38
+ expect(hasPermission(MATRIX, "viewer", "nuclear.launch")).toBe(false);
39
+ });
40
+
41
+ it("denies roles with empty grants", () => {
42
+ expect(hasPermission(MATRIX, "nobody", "dashboard.view")).toBe(false);
43
+ });
44
+
45
+ it("denies when matrix or role is missing", () => {
46
+ expect(hasPermission(null, "admin", "dashboard.view")).toBe(false);
47
+ expect(hasPermission(MATRIX, null, "dashboard.view")).toBe(false);
48
+ expect(hasPermission(undefined, undefined, "x")).toBe(false);
49
+ });
50
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * hasPermission — pure permission lookup against a matrix.
3
+ *
4
+ * Deny-by-default: unknown roles, unknown permissions and empty
5
+ * grants all resolve to `false`. Only an exact entry, a module
6
+ * wildcard ("sales.*") or the full wildcard ("*") grants access.
7
+ */
8
+
9
+ import {
10
+ PERMISSION_WILDCARD,
11
+ type Permission,
12
+ type PermissionMatrix,
13
+ type RoleGrant,
14
+ type RoleKey,
15
+ } from "./types";
16
+
17
+ const MODULE_WILDCARD_SUFFIX = ".*";
18
+
19
+ const grantIncludes = (grant: RoleGrant, permission: Permission): boolean => {
20
+ if (grant === PERMISSION_WILDCARD) return true;
21
+ return grant.some(
22
+ (entry) =>
23
+ entry === permission ||
24
+ (entry.endsWith(MODULE_WILDCARD_SUFFIX) &&
25
+ permission.startsWith(entry.slice(0, -1))),
26
+ );
27
+ };
28
+
29
+ export const hasPermission = (
30
+ matrix: PermissionMatrix | null | undefined,
31
+ role: RoleKey | null | undefined,
32
+ permission: Permission,
33
+ ): boolean => {
34
+ if (!matrix || !role) return false;
35
+ const grant = matrix[role];
36
+ if (grant === undefined) return false;
37
+ return grantIncludes(grant, permission);
38
+ };
@@ -0,0 +1,19 @@
1
+ export {
2
+ PermissionsProvider,
3
+ usePermissions,
4
+ RequirePermission,
5
+ Can,
6
+ type PermissionsProviderProps,
7
+ type RequirePermissionProps,
8
+ type CanProps,
9
+ } from "./PermissionsContext";
10
+ export { hasPermission } from "./hasPermission";
11
+ export {
12
+ PERMISSION_WILDCARD,
13
+ type Permission,
14
+ type PermissionMatrix,
15
+ type RoleGrant,
16
+ type RoleKey,
17
+ type RoleInfo,
18
+ } from "./types";
19
+ export { PERMISSIONS_KEYS } from "./utils/i18nKeys";
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Permission model — generalized from the texora/visora fleet pattern.
3
+ *
4
+ * Hosts keep their own role and permission vocabularies (string unions
5
+ * in the app); the package treats them as opaque strings and stays
6
+ * camp-agnostic. A matrix maps each role to either the full-grant
7
+ * wildcard `"*"` or a list of permission strings, which may themselves
8
+ * end in `.*` to grant every permission of a module
9
+ * (e.g. `"sales.*"` grants `"sales.view"`).
10
+ */
11
+
12
+ /** A single permission, e.g. "sales.view". Opaque to the package. */
13
+ export type Permission = string;
14
+
15
+ /** A role key, e.g. "owner", "operator". Opaque to the package. */
16
+ export type RoleKey = string;
17
+
18
+ /** Full-grant wildcard — grants every permission. */
19
+ export const PERMISSION_WILDCARD = "*" as const;
20
+
21
+ /**
22
+ * What a role grants: the wildcard or an explicit permission list
23
+ * (module wildcards like `"sales.*"` allowed).
24
+ */
25
+ export type RoleGrant = typeof PERMISSION_WILDCARD | readonly string[];
26
+
27
+ /** Role x permission matrix. */
28
+ export type PermissionMatrix = Readonly<Record<RoleKey, RoleGrant>>;
29
+
30
+ /** Display metadata for a role (matrix screens). */
31
+ export interface RoleInfo {
32
+ key: RoleKey;
33
+ label: string;
34
+ description?: string;
35
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Permission domain i18n keys — the documented string contract.
3
+ * Hosts translate these via <DashboardI18nProvider t={...}>.
4
+ */
5
+
6
+ export const PERMISSIONS_KEYS = {
7
+ forbidden: {
8
+ title: "permissions.forbidden.title",
9
+ hint: "permissions.forbidden.hint",
10
+ },
11
+ } as const;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * DemoLogin — one-click demo entry (fleet pattern: log in as a demo
3
+ * persona without a password). Controlled: the click delegates to
4
+ * `onDemo` (typically `session.demoLogin`); the label defaults to
5
+ * the i18n catalog.
6
+ */
7
+
8
+ import { UserRound } from "lucide-react";
9
+ import { Button } from "../../primitives";
10
+ import { useT } from "../../i18n";
11
+ import { SESSION_KEYS } from "./utils/i18nKeys";
12
+
13
+ export interface DemoLoginProps {
14
+ onDemo: (userId: string) => void | Promise<void>;
15
+ /** Demo user id to log in as. */
16
+ userId: string;
17
+ label?: string;
18
+ variant?: "default" | "outline" | "ghost";
19
+ size?: "default" | "sm";
20
+ disabled?: boolean;
21
+ className?: string;
22
+ }
23
+
24
+ export const DemoLogin = ({
25
+ onDemo,
26
+ userId,
27
+ label,
28
+ variant = "outline",
29
+ size = "default",
30
+ disabled = false,
31
+ className,
32
+ }: DemoLoginProps) => {
33
+ const t = useT();
34
+
35
+ return (
36
+ <Button
37
+ type="button"
38
+ variant={variant}
39
+ size={size}
40
+ disabled={disabled}
41
+ className={className}
42
+ onClick={() => void onDemo(userId)}
43
+ >
44
+ <UserRound aria-hidden="true" />
45
+ {label ?? t(SESSION_KEYS.demoLogin.action)}
46
+ </Button>
47
+ );
48
+ };
49
+
50
+ export default DemoLogin;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * OrgSwitcher — tenant switcher trigger + dropdown.
3
+ *
4
+ * Controlled component: tenants and the active id come in as props,
5
+ * switches are delegated via onSwitch. Styled entirely with existing
6
+ * udb-* classes (profile button, avatar, menu).
7
+ */
8
+
9
+ import { useState } from "react";
10
+ import { Building2, Check, ChevronDown } from "lucide-react";
11
+ import { cn } from "../../primitives";
12
+ import { useT } from "../../i18n";
13
+ import { SESSION_KEYS } from "./utils/i18nKeys";
14
+ import type { Tenant } from "./types";
15
+
16
+ export interface OrgSwitcherProps {
17
+ tenants: Tenant[];
18
+ activeTenantId?: string | null;
19
+ onSwitch: (tenantId: string) => void;
20
+ disabled?: boolean;
21
+ className?: string;
22
+ }
23
+
24
+ const initialsOf = (tenant: Tenant): string =>
25
+ tenant.initials ??
26
+ tenant.name
27
+ .split(/\s+/)
28
+ .slice(0, 2)
29
+ .map((word) => word.charAt(0).toUpperCase())
30
+ .join("");
31
+
32
+ export const OrgSwitcher = ({
33
+ tenants,
34
+ activeTenantId,
35
+ onSwitch,
36
+ disabled = false,
37
+ className,
38
+ }: OrgSwitcherProps) => {
39
+ const t = useT();
40
+ const [open, setOpen] = useState(false);
41
+
42
+ if (tenants.length === 0) return null;
43
+
44
+ const active = tenants.find((tenant) => tenant.id === activeTenantId) ?? tenants[0];
45
+
46
+ return (
47
+ <div className={cn("udb-header__anchor", className)}>
48
+ <button
49
+ type="button"
50
+ className="udb-profile-btn"
51
+ disabled={disabled}
52
+ aria-haspopup="menu"
53
+ aria-expanded={open}
54
+ aria-label={t(SESSION_KEYS.orgSwitcher.label)}
55
+ onClick={() => setOpen((prev) => !prev)}
56
+ >
57
+ <span className="udb-avatar">{initialsOf(active)}</span>
58
+ <span className="udb-org">
59
+ <span className="udb-org__name">{active.name}</span>
60
+ {active.plan && <span className="udb-org__meta">{active.plan}</span>}
61
+ </span>
62
+ <ChevronDown
63
+ className={cn(
64
+ "udb-profile-btn__chevron",
65
+ open && "udb-profile-btn__chevron--open",
66
+ )}
67
+ aria-hidden="true"
68
+ />
69
+ </button>
70
+
71
+ {open && (
72
+ <>
73
+ <button
74
+ type="button"
75
+ className="udb-overlay"
76
+ aria-label="close tenant menu"
77
+ tabIndex={-1}
78
+ onClick={() => setOpen(false)}
79
+ />
80
+ <div className="udb-menu udb-org-menu" role="menu">
81
+ {tenants.map((tenant) => {
82
+ const isActive = tenant.id === active.id;
83
+ return (
84
+ <button
85
+ key={tenant.id}
86
+ type="button"
87
+ role="menuitem"
88
+ className={cn(
89
+ "udb-menu__item",
90
+ isActive && "udb-menu__item--active",
91
+ )}
92
+ onClick={() => {
93
+ onSwitch(tenant.id);
94
+ setOpen(false);
95
+ }}
96
+ >
97
+ <Building2 className="udb-menu__item-icon" aria-hidden="true" />
98
+ <span className="udb-org-menu__label">{tenant.name}</span>
99
+ {isActive && <Check className="udb-org-menu__check" aria-hidden="true" />}
100
+ </button>
101
+ );
102
+ })}
103
+ </div>
104
+ </>
105
+ )}
106
+ </div>
107
+ );
108
+ };
109
+
110
+ export default OrgSwitcher;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Session context — user + active tenant state over an injected
3
+ * AuthAdapter. Restores the current user on mount, exposes
4
+ * login/logout/demoLogin, loads tenants for the user and keeps an
5
+ * `activeTenant` the OrgSwitcher drives via `switchTenant`.
6
+ */
7
+
8
+ import {
9
+ createContext,
10
+ useCallback,
11
+ useContext,
12
+ useEffect,
13
+ useMemo,
14
+ useState,
15
+ type ReactNode,
16
+ } from "react";
17
+ import type {
18
+ AuthAdapter,
19
+ LoginCredentials,
20
+ SessionStatus,
21
+ SessionUser,
22
+ Tenant,
23
+ } from "./types";
24
+
25
+ export interface SessionProviderProps {
26
+ adapter: AuthAdapter;
27
+ children: ReactNode;
28
+ }
29
+
30
+ interface SessionContextValue {
31
+ user: SessionUser | null;
32
+ tenants: Tenant[];
33
+ activeTenant: Tenant | null;
34
+ status: SessionStatus;
35
+ login: (credentials: LoginCredentials) => Promise<SessionUser>;
36
+ logout: () => Promise<void>;
37
+ demoLogin: (userId: string) => Promise<SessionUser>;
38
+ switchTenant: (tenantId: string) => void;
39
+ }
40
+
41
+ const SessionContext = createContext<SessionContextValue | null>(null);
42
+
43
+ export const SessionProvider = ({ adapter, children }: SessionProviderProps) => {
44
+ const [user, setUser] = useState<SessionUser | null>(null);
45
+ const [tenants, setTenants] = useState<Tenant[]>([]);
46
+ const [activeTenantId, setActiveTenantId] = useState<string | null>(null);
47
+ const [status, setStatus] = useState<SessionStatus>("loading");
48
+
49
+ // Restore the session once on mount.
50
+ useEffect(() => {
51
+ let cancelled = false;
52
+ adapter
53
+ .current()
54
+ .then((current) => {
55
+ if (cancelled) return;
56
+ setUser(current);
57
+ setStatus(current ? "authenticated" : "anonymous");
58
+ })
59
+ .catch(() => {
60
+ if (!cancelled) setStatus("anonymous");
61
+ });
62
+ return () => {
63
+ cancelled = true;
64
+ };
65
+ }, [adapter]);
66
+
67
+ // Load tenants whenever the authenticated user changes.
68
+ useEffect(() => {
69
+ if (!user || !adapter.listTenants) {
70
+ setTenants([]);
71
+ setActiveTenantId(null);
72
+ return;
73
+ }
74
+ let cancelled = false;
75
+ adapter
76
+ .listTenants(user.id)
77
+ .then((list) => {
78
+ if (cancelled) return;
79
+ setTenants(list);
80
+ setActiveTenantId((prev) =>
81
+ prev && list.some((tenant) => tenant.id === prev) ? prev : (list[0]?.id ?? null),
82
+ );
83
+ })
84
+ .catch(() => {
85
+ if (!cancelled) setTenants([]);
86
+ });
87
+ return () => {
88
+ cancelled = true;
89
+ };
90
+ }, [adapter, user]);
91
+
92
+ const login = useCallback(
93
+ async (credentials: LoginCredentials) => {
94
+ const next = await adapter.login(credentials);
95
+ setUser(next);
96
+ setStatus("authenticated");
97
+ return next;
98
+ },
99
+ [adapter],
100
+ );
101
+
102
+ const logout = useCallback(async () => {
103
+ await adapter.logout();
104
+ setUser(null);
105
+ setStatus("anonymous");
106
+ }, [adapter]);
107
+
108
+ const demoLogin = useCallback(
109
+ async (userId: string) => {
110
+ if (!adapter.demoLogin) {
111
+ throw new Error("AuthAdapter does not support demoLogin");
112
+ }
113
+ const next = await adapter.demoLogin(userId);
114
+ setUser(next);
115
+ setStatus("authenticated");
116
+ return next;
117
+ },
118
+ [adapter],
119
+ );
120
+
121
+ const switchTenant = useCallback((tenantId: string) => {
122
+ setActiveTenantId(tenantId);
123
+ }, []);
124
+
125
+ const value = useMemo<SessionContextValue>(
126
+ () => ({
127
+ user,
128
+ tenants,
129
+ activeTenant: tenants.find((tenant) => tenant.id === activeTenantId) ?? null,
130
+ status,
131
+ login,
132
+ logout,
133
+ demoLogin,
134
+ switchTenant,
135
+ }),
136
+ [user, tenants, activeTenantId, status, login, logout, demoLogin, switchTenant],
137
+ );
138
+
139
+ return (
140
+ <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
141
+ );
142
+ };
143
+
144
+ export const useSession = (): SessionContextValue => {
145
+ const context = useContext(SessionContext);
146
+ if (!context) {
147
+ throw new Error("useSession requires a <SessionProvider> ancestor");
148
+ }
149
+ return context;
150
+ };