@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,53 @@
1
+ /**
2
+ * In-memory auth adapter — zero-dependency default for demos,
3
+ * Storybook-style setups and tests. Credentials are NOT checked
4
+ * (any password logs in); demo accounts log in by user id.
5
+ */
6
+
7
+ import type {
8
+ AuthAdapter,
9
+ LoginCredentials,
10
+ SessionUser,
11
+ } from "./types";
12
+
13
+ export interface InMemoryAuthAdapterOptions {
14
+ /** Users available for login/demo. */
15
+ users: readonly SessionUser[];
16
+ }
17
+
18
+ export const createInMemoryAuthAdapter = ({
19
+ users,
20
+ }: InMemoryAuthAdapterOptions): AuthAdapter => {
21
+ let currentUser: SessionUser | null = null;
22
+
23
+ const findByEmail = (email: string): SessionUser | undefined =>
24
+ users.find((user) => user.email === email);
25
+
26
+ return {
27
+ async login({ email }: LoginCredentials): Promise<SessionUser> {
28
+ const user = findByEmail(email);
29
+ if (!user) {
30
+ throw new Error(`No user with email ${email}`);
31
+ }
32
+ currentUser = user;
33
+ return user;
34
+ },
35
+
36
+ async logout(): Promise<void> {
37
+ currentUser = null;
38
+ },
39
+
40
+ async current(): Promise<SessionUser | null> {
41
+ return currentUser;
42
+ },
43
+
44
+ async demoLogin(userId: string): Promise<SessionUser> {
45
+ const user = users.find((candidate) => candidate.id === userId);
46
+ if (!user) {
47
+ throw new Error(`No demo user with id ${userId}`);
48
+ }
49
+ currentUser = user;
50
+ return user;
51
+ },
52
+ };
53
+ };
@@ -0,0 +1,18 @@
1
+ export {
2
+ SessionProvider,
3
+ useSession,
4
+ type SessionProviderProps,
5
+ } from "./SessionContext";
6
+ export { OrgSwitcher, type OrgSwitcherProps } from "./OrgSwitcher";
7
+ export { DemoLogin, type DemoLoginProps } from "./DemoLogin";
8
+ export { createInMemoryAuthAdapter, type InMemoryAuthAdapterOptions } from "./inMemoryAuthAdapter";
9
+ export {
10
+ SESSION_KEYS,
11
+ } from "./utils/i18nKeys";
12
+ export type {
13
+ AuthAdapter,
14
+ LoginCredentials,
15
+ SessionStatus,
16
+ SessionUser,
17
+ Tenant,
18
+ } from "./types";
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Session model — the host-facing auth contract.
3
+ *
4
+ * The package performs no authentication itself; hosts adapt their
5
+ * provider (Firebase, Supabase, NEON-backed REST, mock...) to this
6
+ * interface and hand it to <SessionProvider>.
7
+ */
8
+
9
+ /** Authenticated user as the dashboard presents it. */
10
+ export interface SessionUser {
11
+ id: string;
12
+ name: string;
13
+ email: string;
14
+ /** Host role key — pairs with the permissions matrix. */
15
+ role: string;
16
+ avatarUrl?: string;
17
+ }
18
+
19
+ /** Tenant/organization the user may switch between. */
20
+ export interface Tenant {
21
+ id: string;
22
+ name: string;
23
+ /** Short initials for avatar-style rendering. */
24
+ initials?: string;
25
+ /** Optional plan badge (e.g. "starter" | "growth" | "enterprise"). */
26
+ plan?: string;
27
+ }
28
+
29
+ export interface LoginCredentials {
30
+ email: string;
31
+ password: string;
32
+ }
33
+
34
+ /**
35
+ * Auth adapter seam. `login`/`logout`/`current` are required;
36
+ * `listTenants` and `demoLogin` power the multi-tenant switcher and
37
+ * one-click demo entry when the host supports them.
38
+ */
39
+ export interface AuthAdapter {
40
+ login(credentials: LoginCredentials): Promise<SessionUser>;
41
+ logout(): Promise<void>;
42
+ current(): Promise<SessionUser | null>;
43
+ listTenants?(userId: string): Promise<Tenant[]>;
44
+ demoLogin?(userId: string): Promise<SessionUser>;
45
+ }
46
+
47
+ export type SessionStatus = "loading" | "authenticated" | "anonymous";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Session domain i18n keys — the documented string contract.
3
+ */
4
+
5
+ export const SESSION_KEYS = {
6
+ orgSwitcher: {
7
+ label: "session.orgSwitcher.label",
8
+ },
9
+ demoLogin: {
10
+ action: "session.demoLogin.action",
11
+ },
12
+ } as const;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * AuditLogList — admin skeleton for the audit trail screen.
3
+ * Entries arrive as props; the list is display-only.
4
+ */
5
+
6
+ import { ScrollText } from "lucide-react";
7
+ import { useT } from "../../../i18n";
8
+ import { SETTINGS_ADMIN_KEYS } from "../utils/i18nKeys";
9
+
10
+ export interface AuditEntry {
11
+ id: string;
12
+ /** Who performed the action. */
13
+ actor: string;
14
+ /** What happened, e.g. "role.changed". */
15
+ action: string;
16
+ /** Optional target of the action (user, invoice...). */
17
+ target?: string;
18
+ /** ISO timestamp. */
19
+ at: string;
20
+ }
21
+
22
+ export interface AuditLogListProps {
23
+ entries: AuditEntry[];
24
+ }
25
+
26
+ const formatAt = (iso: string): string => {
27
+ const date = new Date(iso);
28
+ return Number.isNaN(date.getTime()) ? iso : date.toISOString().slice(0, 16).replace("T", " ");
29
+ };
30
+
31
+ export const AuditLogList = ({ entries }: AuditLogListProps) => {
32
+ const t = useT();
33
+
34
+ if (entries.length === 0) {
35
+ return (
36
+ <div className="udb-empty">
37
+ <div className="udb-empty__icon">
38
+ <ScrollText className="udb-empty__glyph" aria-hidden="true" />
39
+ </div>
40
+ <p className="udb-empty__text">{t(SETTINGS_ADMIN_KEYS.audit.empty)}</p>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ return (
46
+ <ul className="udb-audit">
47
+ {entries.map((entry) => (
48
+ <li key={entry.id} className="udb-audit__item">
49
+ <span className="udb-audit__at">{formatAt(entry.at)}</span>
50
+ <span className="udb-audit__actor">{entry.actor}</span>
51
+ <span className="udb-audit__action">{entry.action}</span>
52
+ {entry.target && <span className="udb-audit__target">{entry.target}</span>}
53
+ </li>
54
+ ))}
55
+ </ul>
56
+ );
57
+ };
58
+
59
+ export default AuditLogList;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * PermissionMatrixScreen — admin skeleton rendering the role x
3
+ * permission matrix. Read-only by default; pass `onToggle` to make
4
+ * cells interactive (delegated — persistence is the host's job).
5
+ */
6
+
7
+ import { Check, Minus } from "lucide-react";
8
+ import { cn } from "../../../primitives";
9
+ import { hasPermission } from "../../permissions/hasPermission";
10
+ import type {
11
+ Permission,
12
+ PermissionMatrix,
13
+ RoleInfo,
14
+ } from "../../permissions/types";
15
+
16
+ export interface PermissionMatrixScreenProps {
17
+ matrix: PermissionMatrix;
18
+ roles: RoleInfo[];
19
+ /** Permission column keys, in display order. */
20
+ permissions: Permission[];
21
+ onToggle?: (role: string, permission: Permission, granted: boolean) => void;
22
+ }
23
+
24
+ export const PermissionMatrixScreen = ({
25
+ matrix,
26
+ roles,
27
+ permissions,
28
+ onToggle,
29
+ }: PermissionMatrixScreenProps) => (
30
+ <div className="udb-table__wrap">
31
+ <table className="udb-table">
32
+ <thead>
33
+ <tr>
34
+ <th className="udb-table__th" />
35
+ {permissions.map((permission) => (
36
+ <th key={permission} className="udb-table__th udb-table__th--code">
37
+ {permission}
38
+ </th>
39
+ ))}
40
+ </tr>
41
+ </thead>
42
+ <tbody>
43
+ {roles.map((role) => (
44
+ <tr key={role.key} className="udb-table__row">
45
+ <td className="udb-table__td udb-table__td--strong">{role.label}</td>
46
+ {permissions.map((permission) => {
47
+ const granted = hasPermission(matrix, role.key, permission);
48
+ const cell = granted ? (
49
+ <Check className="udb-matrix__check" aria-hidden="true" />
50
+ ) : (
51
+ <Minus className="udb-matrix__deny" aria-hidden="true" />
52
+ );
53
+ return (
54
+ <td key={permission} className="udb-table__td udb-table__td--center">
55
+ {onToggle ? (
56
+ <button
57
+ type="button"
58
+ className={cn(
59
+ "udb-matrix__cell",
60
+ granted && "udb-matrix__cell--on",
61
+ )}
62
+ aria-pressed={granted}
63
+ onClick={() => onToggle(role.key, permission, granted)}
64
+ >
65
+ {cell}
66
+ </button>
67
+ ) : (
68
+ cell
69
+ )}
70
+ </td>
71
+ );
72
+ })}
73
+ </tr>
74
+ ))}
75
+ </tbody>
76
+ </table>
77
+ </div>
78
+ );
79
+
80
+ export default PermissionMatrixScreen;
@@ -1,70 +1,87 @@
1
- import { useState } from "react";
1
+ import { useEffect, useState } from "react";
2
2
  import { Outlet, useLocation, useNavigate } from "react-router-dom";
3
+ import { Skeleton, Button } from "../../../primitives";
4
+ import { ChevronLeft, Menu } from "lucide-react";
3
5
  import { SettingsSection } from "./SettingsSection";
4
6
  import type { SettingsConfig } from "../types/settings";
5
- import { Skeleton } from "@umituz/web-design-system/atoms";
6
- import { ChevronLeft, Menu } from "lucide-react";
7
- import { Button } from "@umituz/web-design-system/atoms";
8
7
 
9
8
  interface SettingsLayoutProps {
10
9
  /** Settings configuration */
11
10
  config: SettingsConfig;
11
+ /**
12
+ * Artificial route-transition skeleton duration in ms (default: 0 = off).
13
+ * Content renders immediately by default; set > 0 only if you want
14
+ * a skeleton to mask route swaps.
15
+ */
16
+ routeTransitionMs?: number;
12
17
  }
13
18
 
14
19
  /**
15
20
  * Settings Layout Component
16
21
  *
17
22
  * Main layout wrapper for settings pages.
18
- * Provides sidebar navigation and content area.
19
- *
20
- * @param props - Settings layout props
23
+ * Provides sidebar navigation and content area. Styling is entirely
24
+ * `udb-settings*` classes from the package stylesheet.
21
25
  */
22
26
  export const SettingsLayout = ({
23
27
  config,
28
+ routeTransitionMs = 0,
24
29
  }: SettingsLayoutProps) => {
25
30
  const location = useLocation();
26
31
  const navigate = useNavigate();
27
32
  const [collapsed, setCollapsed] = useState(false);
28
33
  const [mobileOpen, setMobileOpen] = useState(false);
29
- const [loading, setLoading] = useState(true);
34
+ const [loading, setLoading] = useState(routeTransitionMs > 0);
30
35
 
31
- // Simulate loading on route change
32
- useState(() => {
36
+ // Optional route-change skeleton. Cleanup is essential: setTimeout must be
37
+ // cancelled on unmount/route change. With routeTransitionMs = 0 (default)
38
+ // no artificial delay is applied at all.
39
+ useEffect(() => {
40
+ if (routeTransitionMs <= 0) {
41
+ setLoading(false);
42
+ return;
43
+ }
33
44
  setLoading(true);
34
- const timer = setTimeout(() => setLoading(false), 200);
45
+ const timer = setTimeout(() => setLoading(false), routeTransitionMs);
35
46
  return () => clearTimeout(timer);
36
- });
47
+ }, [location.pathname, routeTransitionMs]);
37
48
 
38
49
  const handleNavigate = (path: string) => {
39
50
  navigate(path);
40
51
  setMobileOpen(false);
41
52
  };
42
53
 
43
- return (
44
- <div className="flex h-screen w-full bg-background font-sans">
45
- {/* Desktop Sidebar */}
46
- <aside
47
- className={`hidden md:flex flex-col shrink-0 border-r border-border bg-card transition-all duration-300 ${
48
- collapsed ? "w-16" : "w-64"
49
- }`}
54
+ const renderSidebarHeader = (onMenuClick: () => void, showMenuButton: boolean) => (
55
+ <div className="udb-settings__sidebar-header">
56
+ {!collapsed && (
57
+ <h2 className="udb-settings__sidebar-title">
58
+ {config.brandName}
59
+ </h2>
60
+ )}
61
+ <Button
62
+ variant="ghost"
63
+ size="icon"
64
+ onClick={onMenuClick}
65
+ className="udb-settings__sidebar-toggle"
66
+ aria-label={showMenuButton ? "Open sidebar" : "Toggle sidebar"}
50
67
  >
51
- <div className="flex h-14 items-center justify-between border-b border-border px-4">
52
- {!collapsed && (
53
- <h2 className="text-lg font-semibold text-foreground">
54
- {config.brandName || "Settings"}
55
- </h2>
56
- )}
57
- <Button
58
- variant="ghost"
59
- size="icon"
60
- onClick={() => setCollapsed(!collapsed)}
61
- className="ml-auto"
62
- >
63
- {collapsed ? <Menu className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
64
- </Button>
65
- </div>
68
+ {showMenuButton ? (
69
+ <Menu className="udb-settings__toggle-icon" />
70
+ ) : collapsed ? (
71
+ <Menu className="udb-settings__toggle-icon" />
72
+ ) : (
73
+ <ChevronLeft className="udb-settings__toggle-icon" />
74
+ )}
75
+ </Button>
76
+ </div>
77
+ );
66
78
 
67
- <nav className="flex-1 overflow-y-auto p-2">
79
+ return (
80
+ <div className="udb-root udb-settings">
81
+ {/* Desktop Sidebar */}
82
+ <aside className={`udb-settings__sidebar${collapsed ? " udb-settings__sidebar--collapsed" : ""}`}>
83
+ {renderSidebarHeader(() => setCollapsed((prev) => !prev), false)}
84
+ <nav className="udb-settings__nav">
68
85
  {config.sections.map((section) => (
69
86
  <SettingsSection
70
87
  key={section.key}
@@ -79,22 +96,15 @@ export const SettingsLayout = ({
79
96
 
80
97
  {/* Mobile Menu Overlay */}
81
98
  {mobileOpen && (
82
- <div className="fixed inset-0 z-50 md:hidden">
99
+ <div className="udb-settings__mobile">
83
100
  <div
84
- className="absolute inset-0 bg-background/80 backdrop-blur-sm"
101
+ className="udb-backdrop"
85
102
  onClick={() => setMobileOpen(false)}
103
+ role="presentation"
86
104
  />
87
- <aside className="absolute left-0 top-0 h-full w-64 border-r border-border bg-card shadow-xl">
88
- <div className="flex h-14 items-center justify-between border-b border-border px-4">
89
- <h2 className="text-lg font-semibold text-foreground">
90
- {config.brandName || "Settings"}
91
- </h2>
92
- <Button variant="ghost" size="icon" onClick={() => setMobileOpen(false)}>
93
- <Menu className="h-4 w-4" />
94
- </Button>
95
- </div>
96
-
97
- <nav className="flex-1 overflow-y-auto p-2">
105
+ <aside className="udb-settings__mobile-sidebar">
106
+ {renderSidebarHeader(() => setMobileOpen(false), true)}
107
+ <nav className="udb-settings__nav">
98
108
  {config.sections.map((section) => (
99
109
  <SettingsSection
100
110
  key={section.key}
@@ -109,26 +119,31 @@ export const SettingsLayout = ({
109
119
  )}
110
120
 
111
121
  {/* Main Content Area */}
112
- <div className="flex flex-1 flex-col overflow-hidden min-w-0">
122
+ <div className="udb-settings__main">
113
123
  {/* Mobile Header */}
114
- <header className="flex h-14 items-center justify-between border-b border-border bg-card/50 backdrop-blur-md px-4 shrink-0 md:hidden">
115
- <div className="flex items-center gap-3">
116
- <Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
117
- <Menu className="h-5 w-5" />
124
+ <header className="udb-settings__mobile-header">
125
+ <div className="udb-settings__mobile-lead">
126
+ <Button
127
+ variant="ghost"
128
+ size="icon"
129
+ onClick={() => setMobileOpen(true)}
130
+ aria-label="Open mobile menu"
131
+ >
132
+ <Menu className="udb-settings__toggle-icon-lg" />
118
133
  </Button>
119
- <h2 className="text-sm font-semibold text-foreground">
120
- {config.brandName || "Settings"}
134
+ <h2 className="udb-settings__mobile-title">
135
+ {config.brandName}
121
136
  </h2>
122
137
  </div>
123
138
  </header>
124
139
 
125
- <main className="flex-1 overflow-y-auto p-4 md:p-8">
140
+ <main className="udb-settings__content">
126
141
  {loading ? (
127
- <div className="mx-auto w-full max-w-4xl space-y-6">
128
- <Skeleton className="h-8 w-1/3 rounded-xl" />
129
- <div className="grid gap-4">
142
+ <div className="udb-settings__skeleton">
143
+ <Skeleton className="udb-skeleton--title" />
144
+ <div className="udb-settings__skeleton-list">
130
145
  {Array.from({ length: 3 }).map((_, i) => (
131
- <Skeleton key={i} className="h-24 rounded-xl" />
146
+ <Skeleton key={i} className="udb-settings__skeleton-row" />
132
147
  ))}
133
148
  </div>
134
149
  </div>
@@ -1,6 +1,7 @@
1
+ import { useMemo } from "react";
1
2
  import { Link } from "react-router-dom";
2
3
  import { ChevronRight } from "lucide-react";
3
- import { cn } from "@umituz/web-design-system/utils";
4
+ import { cn } from "../../../primitives";
4
5
  import type { SettingsSection as SettingsSectionType } from "../types/settings";
5
6
 
6
7
  interface SettingsSectionProps {
@@ -18,8 +19,8 @@ interface SettingsSectionProps {
18
19
  * Settings Section Component
19
20
  *
20
21
  * Displays a section of settings items.
21
- *
22
- * @param props - Settings section props
22
+ * Items without a path and without an onClick handler are hidden
23
+ * (an empty interactive element violates a11y and is dead code).
23
24
  */
24
25
  export const SettingsSection = ({
25
26
  section,
@@ -27,51 +28,61 @@ export const SettingsSection = ({
27
28
  onNavigate,
28
29
  collapsed = false,
29
30
  }: SettingsSectionProps) => {
30
- const filteredItems = section.items.filter((item) => item.enabled !== false);
31
+ const filteredItems = useMemo(
32
+ () => section.items.filter((item) => item.enabled !== false),
33
+ [section.items],
34
+ );
31
35
 
32
36
  if (filteredItems.length === 0) return null;
33
37
 
34
38
  return (
35
- <div className="mb-6 last:mb-0">
39
+ <div className="udb-settings-nav">
36
40
  {!collapsed && (
37
- <h3 className="px-2 mb-2 text-xs font-bold uppercase tracking-wider text-muted-foreground">
41
+ <h3 className="udb-settings-nav__title">
38
42
  {section.title}
39
43
  </h3>
40
44
  )}
41
45
 
42
- <div className="space-y-1">
46
+ <div className="udb-settings-nav__items">
43
47
  {filteredItems.map((item) => {
44
48
  const isActive = currentPath === item.path;
49
+ const hasNavigation = Boolean(item.path) || Boolean(item.onClick);
50
+
51
+ // Skip rendering items that have neither a path nor a click handler.
52
+ if (!hasNavigation) {
53
+ return null;
54
+ }
45
55
 
46
56
  const itemContent = (
47
57
  <>
48
58
  {item.icon && (
49
- <item.icon
50
- className={cn(
51
- "h-4 w-4 shrink-0",
52
- isActive && "scale-110"
53
- )}
54
- />
59
+ <item.icon className="udb-settings-nav__item-icon" />
55
60
  )}
56
61
  {!collapsed && (
57
62
  <>
58
- <span className="flex-1 text-left">{item.label}</span>
63
+ <span className="udb-settings-nav__item-label">{item.label}</span>
59
64
  {item.badge !== undefined && item.badge > 0 && (
60
- <span className="ml-auto flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-destructive-foreground">
65
+ <span
66
+ className="udb-settings-nav__badge"
67
+ aria-label={`${item.badge} notifications`}
68
+ >
61
69
  {item.badge > 99 ? "99+" : item.badge}
62
70
  </span>
63
71
  )}
64
- {item.path && <ChevronRight className="h-4 w-4 text-muted-foreground" />}
72
+ {item.path && (
73
+ <ChevronRight
74
+ className="udb-settings-nav__chevron"
75
+ aria-hidden="true"
76
+ />
77
+ )}
65
78
  </>
66
79
  )}
67
80
  </>
68
81
  );
69
82
 
70
83
  const itemClassName = cn(
71
- "flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200 w-full",
72
- "hover:bg-accent hover:text-accent-foreground",
73
- isActive && "bg-accent text-accent-foreground",
74
- collapsed ? "justify-center" : "justify-start"
84
+ "udb-settings-nav__item",
85
+ isActive && "udb-settings-nav__item--active",
75
86
  );
76
87
 
77
88
  if (item.path) {
@@ -79,9 +90,10 @@ export const SettingsSection = ({
79
90
  <Link
80
91
  key={item.key}
81
92
  to={item.path}
82
- onClick={() => onNavigate?.(item.path!)}
93
+ onClick={() => onNavigate?.(item.path as string)}
83
94
  className={itemClassName}
84
95
  title={collapsed ? item.label : undefined}
96
+ aria-current={isActive ? "page" : undefined}
85
97
  >
86
98
  {itemContent}
87
99
  </Link>
@@ -92,9 +104,10 @@ export const SettingsSection = ({
92
104
  <button
93
105
  key={item.key}
94
106
  type="button"
95
- onClick={() => {}}
107
+ onClick={item.onClick}
96
108
  className={itemClassName}
97
109
  title={collapsed ? item.label : undefined}
110
+ aria-current={isActive ? "page" : undefined}
98
111
  >
99
112
  {itemContent}
100
113
  </button>
@@ -0,0 +1,87 @@
1
+ /**
2
+ * UserManagementTable — admin skeleton for the users screen.
3
+ * Config-driven: rows and role catalog come in as props, role
4
+ * changes delegate out. No fetching, no package-side copy beyond
5
+ * the empty state.
6
+ */
7
+
8
+ import { Users } from "lucide-react";
9
+ import { useT } from "../../../i18n";
10
+ import { SETTINGS_ADMIN_KEYS } from "../utils/i18nKeys";
11
+ import type { RoleKey, RoleInfo } from "../../permissions/types";
12
+
13
+ export interface ManagedUser {
14
+ id: string;
15
+ name: string;
16
+ email: string;
17
+ role: RoleKey;
18
+ status?: string;
19
+ }
20
+
21
+ export interface UserManagementTableProps {
22
+ users: ManagedUser[];
23
+ roles: RoleInfo[];
24
+ /** Provide to render an editable role select per row. */
25
+ onRoleChange?: (userId: string, role: RoleKey) => void;
26
+ }
27
+
28
+ export const UserManagementTable = ({
29
+ users,
30
+ roles,
31
+ onRoleChange,
32
+ }: UserManagementTableProps) => {
33
+ const t = useT();
34
+
35
+ if (users.length === 0) {
36
+ return (
37
+ <div className="udb-empty">
38
+ <div className="udb-empty__icon">
39
+ <Users className="udb-empty__glyph" aria-hidden="true" />
40
+ </div>
41
+ <p className="udb-empty__text">{t(SETTINGS_ADMIN_KEYS.users.empty)}</p>
42
+ </div>
43
+ );
44
+ }
45
+
46
+ return (
47
+ <div className="udb-table__wrap">
48
+ <table className="udb-table">
49
+ <thead>
50
+ <tr>
51
+ <th className="udb-table__th">{t("settings.users.columns.name")}</th>
52
+ <th className="udb-table__th">{t("settings.users.columns.email")}</th>
53
+ <th className="udb-table__th">{t("settings.users.columns.role")}</th>
54
+ </tr>
55
+ </thead>
56
+ <tbody>
57
+ {users.map((user) => (
58
+ <tr key={user.id} className="udb-table__row">
59
+ <td className="udb-table__td">{user.name}</td>
60
+ <td className="udb-table__td">{user.email}</td>
61
+ <td className="udb-table__td">
62
+ {onRoleChange ? (
63
+ <select
64
+ className="udb-period-select__select"
65
+ value={user.role}
66
+ aria-label={t("settings.users.columns.role")}
67
+ onChange={(e) => onRoleChange(user.id, e.target.value)}
68
+ >
69
+ {roles.map((role) => (
70
+ <option key={role.key} value={role.key}>
71
+ {role.label}
72
+ </option>
73
+ ))}
74
+ </select>
75
+ ) : (
76
+ roles.find((role) => role.key === user.role)?.label ?? user.role
77
+ )}
78
+ </td>
79
+ </tr>
80
+ ))}
81
+ </tbody>
82
+ </table>
83
+ </div>
84
+ );
85
+ };
86
+
87
+ export default UserManagementTable;