@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.
- package/README.md +202 -14
- package/package.json +21 -29
- package/src/domains/analytics/components/AnalyticsCard.tsx +23 -21
- package/src/domains/analytics/components/AnalyticsChart.tsx +345 -149
- package/src/domains/analytics/components/AnalyticsLayout.tsx +93 -112
- package/src/domains/analytics/components/MetricCard.tsx +42 -64
- package/src/domains/analytics/hooks/index.ts +7 -1
- package/src/domains/analytics/hooks/useAnalytics.ts +144 -139
- package/src/domains/analytics/index.ts +11 -0
- package/src/domains/analytics/services/AnalyticsEngineService.ts +103 -55
- package/src/domains/analytics/services/PerformanceService.ts +117 -69
- package/src/domains/analytics/types/analytics.ts +20 -6
- package/src/domains/analytics/types/index.ts +3 -0
- package/src/domains/analytics/utils/analytics.test.ts +183 -0
- package/src/domains/analytics/utils/analytics.ts +26 -9
- package/src/domains/analytics/utils/i18nKeys.ts +40 -0
- package/src/domains/analytics/utils/index.ts +2 -0
- package/src/domains/auth/components/AuthLayout.tsx +63 -54
- package/src/domains/auth/components/ForgotPasswordForm.tsx +96 -98
- package/src/domains/auth/components/LoginForm.tsx +167 -226
- package/src/domains/auth/components/RegisterForm.tsx +202 -272
- package/src/domains/auth/components/ResetPasswordForm.tsx +105 -151
- package/src/domains/auth/components/shared/AuthErrorBanner.tsx +35 -0
- package/src/domains/auth/components/shared/EmailInput.tsx +91 -0
- package/src/domains/auth/components/shared/PasswordInput.tsx +119 -0
- package/src/domains/auth/components/shared/PasswordStrengthIndicator.tsx +75 -0
- package/src/domains/auth/components/shared/SocialLoginButtons.tsx +58 -0
- package/src/domains/auth/components/shared/SocialProviderIcons.tsx +48 -0
- package/src/domains/auth/components/shared/index.ts +20 -0
- package/src/domains/auth/hooks/index.ts +1 -0
- package/src/domains/auth/hooks/useAuth.ts +151 -232
- package/src/domains/auth/index.ts +8 -4
- package/src/domains/auth/types/auth.ts +26 -5
- package/src/domains/auth/types/index.ts +1 -0
- package/src/domains/auth/utils/auth.test.ts +182 -0
- package/src/domains/auth/utils/auth.ts +71 -130
- package/src/domains/auth/utils/i18nKeys.ts +127 -0
- package/src/domains/auth/utils/index.ts +7 -1
- package/src/domains/auth/utils/secureToken.ts +33 -0
- package/src/domains/auth/utils/validationKeys.ts +23 -0
- package/src/domains/calendar/hooks/useCalendar.ts +145 -151
- package/src/domains/calendar/index.ts +0 -1
- package/src/domains/calendar/services/CalendarService.ts +122 -179
- package/src/domains/calendar/utils/contentItemMapper.test.ts +108 -0
- package/src/domains/calendar/utils/contentItemMapper.ts +99 -0
- package/src/domains/calendar/utils/index.ts +33 -30
- package/src/domains/layouts/components/BrandLogo.tsx +12 -9
- package/src/domains/layouts/components/DashboardHeader.tsx +78 -80
- package/src/domains/layouts/components/DashboardLayout.tsx +45 -21
- package/src/domains/layouts/components/DashboardSidebar.tsx +48 -51
- package/src/domains/layouts/types/layout.ts +7 -1
- package/src/domains/layouts/types/user.ts +3 -1
- package/src/domains/modules/build.test.tsx +97 -0
- package/src/domains/modules/build.tsx +71 -0
- package/src/domains/modules/index.ts +2 -0
- package/src/domains/modules/types.ts +29 -0
- package/src/domains/onboarding/components/AppFocusStep.tsx +99 -68
- package/src/domains/onboarding/components/OnboardingWizard.tsx +109 -183
- package/src/domains/onboarding/components/PlanStep.tsx +156 -123
- package/src/domains/onboarding/components/PlatformsStep.tsx +74 -54
- package/src/domains/onboarding/components/StepNavigation.tsx +108 -0
- package/src/domains/onboarding/components/StepProgress.tsx +62 -0
- package/src/domains/onboarding/components/UserTypeStep.tsx +59 -80
- package/src/domains/onboarding/components/index.ts +4 -0
- package/src/domains/onboarding/hooks/useOnboarding.ts +76 -66
- package/src/domains/onboarding/index.ts +1 -0
- package/src/domains/onboarding/types/onboarding.ts +57 -17
- package/src/domains/onboarding/utils/i18nKeys.ts +72 -0
- package/src/domains/onboarding/utils/index.ts +2 -0
- package/src/domains/permissions/PermissionsContext.tsx +103 -0
- package/src/domains/permissions/hasPermission.test.ts +50 -0
- package/src/domains/permissions/hasPermission.ts +38 -0
- package/src/domains/permissions/index.ts +19 -0
- package/src/domains/permissions/types.ts +35 -0
- package/src/domains/permissions/utils/i18nKeys.ts +11 -0
- package/src/domains/session/DemoLogin.tsx +50 -0
- package/src/domains/session/OrgSwitcher.tsx +110 -0
- package/src/domains/session/SessionContext.tsx +150 -0
- package/src/domains/session/inMemoryAuthAdapter.ts +53 -0
- package/src/domains/session/index.ts +18 -0
- package/src/domains/session/types.ts +47 -0
- package/src/domains/session/utils/i18nKeys.ts +12 -0
- package/src/domains/settings/components/AuditLogList.tsx +59 -0
- package/src/domains/settings/components/PermissionMatrixScreen.tsx +80 -0
- package/src/domains/settings/components/SettingsLayout.tsx +75 -60
- package/src/domains/settings/components/SettingsSection.tsx +35 -22
- package/src/domains/settings/components/UserManagementTable.tsx +87 -0
- package/src/domains/settings/components/index.ts +10 -0
- package/src/domains/settings/hooks/useSettings.ts +77 -52
- package/src/domains/settings/types/settings.ts +2 -0
- package/src/domains/settings/utils/i18nKeys.ts +18 -0
- package/src/domains/settings/utils/settings.ts +11 -4
- package/src/i18n/I18nContext.tsx +33 -0
- package/src/i18n/defaultT.test.ts +43 -0
- package/src/i18n/defaultT.ts +34 -0
- package/src/i18n/index.ts +7 -0
- package/src/i18n/types.ts +13 -0
- package/src/index.ts +1 -7
- package/src/primitives/Button.tsx +30 -0
- package/src/primitives/Checkbox.tsx +40 -0
- package/src/primitives/Input.tsx +19 -0
- package/src/primitives/Skeleton.tsx +26 -0
- package/src/primitives/cn.ts +15 -0
- package/src/primitives/index.ts +23 -0
- package/src/styles/dashboard.css +3045 -0
- package/src/styles/tokens.contract.test.ts +66 -0
- package/src/styles/tokens.css +124 -0
- package/src/domains/billing/components/BillingLayout.tsx +0 -63
- package/src/domains/billing/components/BillingPage.tsx +0 -49
- package/src/domains/billing/components/BillingPortal.tsx +0 -198
- package/src/domains/billing/components/InvoiceCard.tsx +0 -143
- package/src/domains/billing/components/PaymentMethodsList.tsx +0 -116
- package/src/domains/billing/components/PlanComparison.tsx +0 -199
- package/src/domains/billing/components/UsageCard.tsx +0 -103
- package/src/domains/billing/components/index.ts +0 -13
- package/src/domains/billing/constants/billing.ts +0 -69
- package/src/domains/billing/hooks/index.ts +0 -7
- package/src/domains/billing/hooks/useBilling.ts +0 -385
- package/src/domains/billing/index.ts +0 -73
- package/src/domains/billing/types/billing.ts +0 -342
- package/src/domains/billing/types/index.ts +0 -30
- package/src/domains/billing/utils/billing.ts +0 -360
- package/src/domains/billing/utils/index.ts +0 -31
- package/src/domains/billing/utils/polar.ts +0 -63
- 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;
|
|
@@ -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
|
+
};
|