@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,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,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(
|
|
34
|
+
const [loading, setLoading] = useState(routeTransitionMs > 0);
|
|
30
35
|
|
|
31
|
-
//
|
|
32
|
-
|
|
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),
|
|
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
|
-
|
|
44
|
-
<div className="
|
|
45
|
-
{
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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="
|
|
99
|
+
<div className="udb-settings__mobile">
|
|
83
100
|
<div
|
|
84
|
-
className="
|
|
101
|
+
className="udb-backdrop"
|
|
85
102
|
onClick={() => setMobileOpen(false)}
|
|
103
|
+
role="presentation"
|
|
86
104
|
/>
|
|
87
|
-
<aside className="
|
|
88
|
-
|
|
89
|
-
|
|
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="
|
|
122
|
+
<div className="udb-settings__main">
|
|
113
123
|
{/* Mobile Header */}
|
|
114
|
-
<header className="
|
|
115
|
-
<div className="
|
|
116
|
-
<Button
|
|
117
|
-
|
|
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="
|
|
120
|
-
{config.brandName
|
|
134
|
+
<h2 className="udb-settings__mobile-title">
|
|
135
|
+
{config.brandName}
|
|
121
136
|
</h2>
|
|
122
137
|
</div>
|
|
123
138
|
</header>
|
|
124
139
|
|
|
125
|
-
<main className="
|
|
140
|
+
<main className="udb-settings__content">
|
|
126
141
|
{loading ? (
|
|
127
|
-
<div className="
|
|
128
|
-
<Skeleton className="
|
|
129
|
-
<div className="
|
|
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="
|
|
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 "
|
|
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
|
-
*
|
|
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 =
|
|
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="
|
|
39
|
+
<div className="udb-settings-nav">
|
|
36
40
|
{!collapsed && (
|
|
37
|
-
<h3 className="
|
|
41
|
+
<h3 className="udb-settings-nav__title">
|
|
38
42
|
{section.title}
|
|
39
43
|
</h3>
|
|
40
44
|
)}
|
|
41
45
|
|
|
42
|
-
<div className="
|
|
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="
|
|
63
|
+
<span className="udb-settings-nav__item-label">{item.label}</span>
|
|
59
64
|
{item.badge !== undefined && item.badge > 0 && (
|
|
60
|
-
<span
|
|
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 &&
|
|
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
|
-
"
|
|
72
|
-
"
|
|
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;
|