@djangocfg/layouts 2.1.355 → 2.1.357
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/package.json +17 -17
- package/src/layouts/AdminLayout/AdminLayout.tsx +2 -1
- package/src/layouts/AppLayout/AppLayout.tsx +35 -15
- package/src/layouts/AppLayout/BaseApp.tsx +2 -2
- package/src/layouts/AuthLayout/AuthLayout.tsx +26 -19
- package/src/layouts/AuthLayout/components/oauth/OAuthCallback.tsx +10 -4
- package/src/layouts/AuthLayout/components/shared/AuthButton.tsx +11 -5
- package/src/layouts/AuthLayout/components/shared/AuthContainer.tsx +10 -10
- package/src/layouts/AuthLayout/components/shared/AuthDivider.tsx +11 -5
- package/src/layouts/AuthLayout/components/shared/AuthError.tsx +10 -5
- package/src/layouts/AuthLayout/components/shared/AuthFooter.tsx +11 -5
- package/src/layouts/AuthLayout/components/shared/AuthHeader.tsx +10 -10
- package/src/layouts/AuthLayout/components/shared/AuthLink.tsx +11 -5
- package/src/layouts/AuthLayout/components/shared/AuthOTPInput.tsx +28 -20
- package/src/layouts/AuthLayout/components/shared/TermsCheckbox.tsx +11 -5
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +12 -4
- package/src/layouts/AuthLayout/components/steps/OTPStep.tsx +9 -4
- package/src/layouts/AuthLayout/components/steps/SetupStep/SetupComplete.tsx +12 -5
- package/src/layouts/AuthLayout/components/steps/SetupStep/SetupLoading.tsx +9 -4
- package/src/layouts/AuthLayout/components/steps/SetupStep/SetupQRCode.tsx +11 -5
- package/src/layouts/AuthLayout/components/steps/SetupStep/index.tsx +15 -5
- package/src/layouts/AuthLayout/components/steps/TwoFactorStep.tsx +9 -4
- package/src/layouts/AuthLayout/context.tsx +35 -13
- package/src/layouts/AuthLayout/shells/AuthShell.tsx +11 -4
- package/src/layouts/AuthLayout/shells/CenteredShell.tsx +10 -4
- package/src/layouts/AuthLayout/shells/SplitShell.tsx +10 -4
- package/src/layouts/AuthLayout/shells/context.tsx +16 -5
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +32 -247
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +115 -426
- package/src/layouts/{_components → PrivateLayout/components}/PrivateSidebarAccount.tsx +40 -19
- package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +165 -0
- package/src/layouts/{_components → PrivateLayout/components}/SidebarFeatured.tsx +2 -2
- package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +189 -0
- package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +137 -0
- package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +71 -0
- package/src/layouts/PrivateLayout/components/index.ts +4 -0
- package/src/layouts/PrivateLayout/context.tsx +211 -0
- package/src/layouts/PrivateLayout/density.ts +48 -0
- package/src/layouts/PrivateLayout/hooks/index.ts +13 -0
- package/src/layouts/PrivateLayout/hooks/useAuthGuard.ts +54 -0
- package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +103 -0
- package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +113 -0
- package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +207 -0
- package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +115 -0
- package/src/layouts/PrivateLayout/index.ts +2 -2
- package/src/layouts/PrivateLayout/types.ts +187 -0
- package/src/layouts/ProfileLayout/ProfileLayout.tsx +44 -183
- package/src/layouts/ProfileLayout/README.md +58 -0
- package/src/layouts/ProfileLayout/components/ApiKeySection/ApiKeySection.tsx +197 -0
- package/src/layouts/ProfileLayout/components/ApiKeySection/context.tsx +159 -0
- package/src/layouts/ProfileLayout/components/ApiKeySection/index.ts +3 -0
- package/src/layouts/ProfileLayout/components/ProfileHeader.tsx +110 -0
- package/src/layouts/ProfileLayout/components/ProfileTab.tsx +29 -0
- package/src/layouts/ProfileLayout/components/{TwoFactorSection.tsx → TwoFactorSection/TwoFactorSection.tsx} +1 -1
- package/src/layouts/ProfileLayout/components/TwoFactorSection/index.ts +1 -0
- package/src/layouts/ProfileLayout/components/index.ts +4 -2
- package/src/layouts/ProfileLayout/context.tsx +4 -6
- package/src/layouts/ProfileLayout/hooks/index.ts +2 -0
- package/src/layouts/ProfileLayout/hooks/useProfileTabs.ts +43 -0
- package/src/layouts/ProfileLayout/index.ts +6 -3
- package/src/layouts/ProfileLayout/types.ts +37 -0
- package/src/layouts/{_components → PublicLayout/components}/UserMenu.tsx +3 -3
- package/src/layouts/PublicLayout/components/index.ts +4 -0
- package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +12 -2
- package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalNavbar.tsx +1 -1
- package/src/layouts/PublicLayout/primitives/NavActions.tsx +44 -3
- package/src/layouts/PublicLayout/primitives/NavBrand.tsx +4 -2
- package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +42 -2
- package/src/layouts/PublicLayout/shared/MobileDrawerShell.tsx +1 -1
- package/src/layouts/PublicLayout/shared/NavbarShell.tsx +60 -1
- package/src/layouts/_components/index.ts +2 -7
- package/src/layouts/index.ts +9 -4
- package/src/layouts/ProfileLayout/__tests__/TwoFactorSection.test.tsx +0 -234
- package/src/layouts/ProfileLayout/components/ProfileForm.tsx +0 -198
- /package/src/layouts/{_components → PublicLayout/components}/UserAvatar.tsx +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { ChevronDown } from 'lucide-react';
|
|
4
|
-
import React from 'react';
|
|
4
|
+
import React, { memo } from 'react';
|
|
5
5
|
|
|
6
6
|
import { Button, Link } from '@djangocfg/ui-core/components';
|
|
7
7
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
@@ -48,7 +48,7 @@ function subMenuLinkCls(active: boolean) {
|
|
|
48
48
|
const popoverCls =
|
|
49
49
|
'absolute left-0 top-full mt-1 z-[1200] min-w-[14.5rem] rounded-xl border border-border/70 bg-background/95 backdrop-blur-sm p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_6px_18px_rgba(0,0,0,0.035)] dark:shadow-[0_6px_20px_rgba(0,0,0,0.12)]';
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
function NavDesktopItemsRaw({
|
|
52
52
|
items,
|
|
53
53
|
maxVisible,
|
|
54
54
|
isActivePath,
|
|
@@ -245,3 +245,43 @@ export function NavDesktopItems({
|
|
|
245
245
|
</div>
|
|
246
246
|
);
|
|
247
247
|
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Shallow equality for NavigationItem arrays. Compares href, label, external
|
|
251
|
+
* and one-level-deep sub-items so that nav label/badge changes re-render,
|
|
252
|
+
* but object-reference churn from parent is ignored.
|
|
253
|
+
*/
|
|
254
|
+
function navItemsShallowEqual(a: NavigationItem[], b: NavigationItem[]): boolean {
|
|
255
|
+
if (a === b) return true;
|
|
256
|
+
if (a.length !== b.length) return false;
|
|
257
|
+
return a.every((ai, i) => {
|
|
258
|
+
const bi = b[i];
|
|
259
|
+
if (ai.href !== bi.href || ai.label !== bi.label || ai.external !== bi.external) return false;
|
|
260
|
+
if ((ai.items?.length ?? 0) !== (bi.items?.length ?? 0)) return false;
|
|
261
|
+
if (ai.items) {
|
|
262
|
+
return ai.items.every((sub, j) => {
|
|
263
|
+
const bsub = bi.items![j];
|
|
264
|
+
return sub.href === bsub.href && sub.label === bsub.label && sub.external === bsub.external;
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return true;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Memoised desktop navigation row. Re-renders only when:
|
|
273
|
+
* - the items array content changes (via navItemsShallowEqual),
|
|
274
|
+
* - dropdown state object changes,
|
|
275
|
+
* - maxVisible or renderDesktopDropdown changes.
|
|
276
|
+
* Parent re-renders with identical navigation data are skipped entirely.
|
|
277
|
+
*/
|
|
278
|
+
export const NavDesktopItems = memo(NavDesktopItemsRaw, (prev, next) => {
|
|
279
|
+
return (
|
|
280
|
+
prev.maxVisible === next.maxVisible &&
|
|
281
|
+
prev.isActivePath === next.isActivePath &&
|
|
282
|
+
prev.isGroupActive === next.isGroupActive &&
|
|
283
|
+
prev.dropdown === next.dropdown &&
|
|
284
|
+
prev.renderDesktopDropdown === next.renderDesktopDropdown &&
|
|
285
|
+
navItemsShallowEqual(prev.items, next.items)
|
|
286
|
+
);
|
|
287
|
+
});
|
|
@@ -17,7 +17,7 @@ import { useBodyScrollLock, useIsTabletOrBelow } from '@djangocfg/ui-core/hooks'
|
|
|
17
17
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
18
18
|
|
|
19
19
|
import { usePathnameWithoutLocale } from '../../../hooks';
|
|
20
|
-
import { UserMenu } from '
|
|
20
|
+
import { UserMenu } from '../components/UserMenu';
|
|
21
21
|
import { usePublicLayoutOptional } from '../context';
|
|
22
22
|
import { useMobileNavPanel } from '../hooks';
|
|
23
23
|
import { NavControls } from '../primitives/NavControls';
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import React, {
|
|
19
19
|
type ReactNode,
|
|
20
|
+
memo,
|
|
20
21
|
useEffect,
|
|
21
22
|
useLayoutEffect,
|
|
22
23
|
useMemo,
|
|
@@ -160,7 +161,7 @@ export interface NavbarActionsContext {
|
|
|
160
161
|
controls: ReactNode | null;
|
|
161
162
|
}
|
|
162
163
|
|
|
163
|
-
|
|
164
|
+
function NavbarShellRaw(props: NavbarShellProps) {
|
|
164
165
|
const context = usePublicLayoutOptional();
|
|
165
166
|
|
|
166
167
|
const {
|
|
@@ -348,3 +349,61 @@ export function NavbarShell(props: NavbarShellProps) {
|
|
|
348
349
|
</div>
|
|
349
350
|
);
|
|
350
351
|
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Shallow equality for NavigationItem arrays. Compares href, label, external
|
|
355
|
+
* and one-level-deep sub-items so that nav label/badge changes re-render,
|
|
356
|
+
* but object-reference churn from parent is ignored.
|
|
357
|
+
*/
|
|
358
|
+
function navItemsShallowEqual(a: NavigationItem[], b: NavigationItem[]): boolean {
|
|
359
|
+
if (a === b) return true;
|
|
360
|
+
if (a.length !== b.length) return false;
|
|
361
|
+
return a.every((ai, i) => {
|
|
362
|
+
const bi = b[i];
|
|
363
|
+
if (ai.href !== bi.href || ai.label !== bi.label || ai.external !== bi.external) return false;
|
|
364
|
+
if ((ai.items?.length ?? 0) !== (bi.items?.length ?? 0)) return false;
|
|
365
|
+
if (ai.items) {
|
|
366
|
+
return ai.items.every((sub, j) => {
|
|
367
|
+
const bsub = bi.items![j];
|
|
368
|
+
return sub.href === bsub.href && sub.label === bsub.label && sub.external === bsub.external;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return true;
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Memoised navbar orchestrator. Re-renders only when any config prop changes
|
|
377
|
+
* (variant, position, nav items, actions, slots, etc.). The heavy inner JSX
|
|
378
|
+
* (brand, desktop nav, actions) is preserved across renders when props are
|
|
379
|
+
* stable. Navigation items are compared via navItemsShallowEqual so that
|
|
380
|
+
* reference churn from parent does not cause a re-render.
|
|
381
|
+
*/
|
|
382
|
+
export const NavbarShell = memo(NavbarShellRaw, (prev, next) => {
|
|
383
|
+
return (
|
|
384
|
+
prev.variant === next.variant &&
|
|
385
|
+
prev.position === next.position &&
|
|
386
|
+
prev.brand === next.brand &&
|
|
387
|
+
prev.brandHref === next.brandHref &&
|
|
388
|
+
prev.userMenu === next.userMenu &&
|
|
389
|
+
prev.desktopMaxPrimaryItems === next.desktopMaxPrimaryItems &&
|
|
390
|
+
prev.navLayout === next.navLayout &&
|
|
391
|
+
prev.navbarHeight === next.navbarHeight &&
|
|
392
|
+
prev.insetX === next.insetX &&
|
|
393
|
+
prev.innerPadding === next.innerPadding &&
|
|
394
|
+
prev.hideNavOnScroll === next.hideNavOnScroll &&
|
|
395
|
+
prev.transparent === next.transparent &&
|
|
396
|
+
prev.transparentThreshold === next.transparentThreshold &&
|
|
397
|
+
prev.outerClassName === next.outerClassName &&
|
|
398
|
+
prev.shapeClassName === next.shapeClassName &&
|
|
399
|
+
prev.shapeForState === next.shapeForState &&
|
|
400
|
+
prev.renderActions === next.renderActions &&
|
|
401
|
+
prev.actions === next.actions &&
|
|
402
|
+
prev.actionsLeadingSlot === next.actionsLeadingSlot &&
|
|
403
|
+
prev.actionsTrailingSlot === next.actionsTrailingSlot &&
|
|
404
|
+
prev.controls === next.controls &&
|
|
405
|
+
prev.mobileMenuOpen === next.mobileMenuOpen &&
|
|
406
|
+
prev.onMobileMenuToggle === next.onMobileMenuToggle &&
|
|
407
|
+
navItemsShallowEqual(prev.navigation ?? [], next.navigation ?? [])
|
|
408
|
+
);
|
|
409
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared Layout Components
|
|
3
|
+
*
|
|
4
|
+
* Components used across multiple layout types.
|
|
3
5
|
*/
|
|
4
6
|
|
|
5
7
|
export {
|
|
@@ -25,10 +27,3 @@ export type {
|
|
|
25
27
|
LocaleSwitcherSharedProps,
|
|
26
28
|
LocaleSwitcherVariant,
|
|
27
29
|
} from './LocaleSwitcher';
|
|
28
|
-
|
|
29
|
-
export { UserMenu } from './UserMenu';
|
|
30
|
-
export type { UserMenuProps } from './UserMenu';
|
|
31
|
-
|
|
32
|
-
export { UserAvatar } from './UserAvatar';
|
|
33
|
-
export type { UserAvatarProps } from './UserAvatar';
|
|
34
|
-
|
package/src/layouts/index.ts
CHANGED
|
@@ -10,9 +10,15 @@
|
|
|
10
10
|
// Shared types (universal type system)
|
|
11
11
|
export * from './types';
|
|
12
12
|
|
|
13
|
-
// Shared components
|
|
14
|
-
export { LocaleSwitcher, LOCALE_LABELS
|
|
15
|
-
export type { LocaleSwitcherProps
|
|
13
|
+
// Shared components (used by multiple layouts)
|
|
14
|
+
export { LocaleSwitcher, LOCALE_LABELS } from './_components';
|
|
15
|
+
export type { LocaleSwitcherProps } from './_components';
|
|
16
|
+
|
|
17
|
+
// PublicLayout components (re-exported for convenience)
|
|
18
|
+
export { UserMenu } from './PublicLayout/components';
|
|
19
|
+
export type { UserMenuProps } from './PublicLayout/components';
|
|
20
|
+
export { UserAvatar } from './PublicLayout/components';
|
|
21
|
+
export type { UserAvatarProps } from './PublicLayout/components';
|
|
16
22
|
|
|
17
23
|
// Smart layout router
|
|
18
24
|
export * from './AppLayout';
|
|
@@ -25,4 +31,3 @@ export * from './AdminLayout';
|
|
|
25
31
|
|
|
26
32
|
// Additional layouts
|
|
27
33
|
export * from './ProfileLayout';
|
|
28
|
-
|
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for TwoFactorSection component
|
|
3
|
-
*
|
|
4
|
-
* Tests the 2FA management section in ProfileLayout.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
8
|
-
import React from 'react';
|
|
9
|
-
|
|
10
|
-
import { TwoFactorSection } from '../components/TwoFactorSection';
|
|
11
|
-
|
|
12
|
-
// Mock hooks
|
|
13
|
-
const mockFetchStatus = jest.fn();
|
|
14
|
-
const mockDisable2FA = jest.fn();
|
|
15
|
-
const mockResetSetup = jest.fn();
|
|
16
|
-
const mockClearError = jest.fn();
|
|
17
|
-
|
|
18
|
-
jest.mock('@djangocfg/api/auth', () => ({
|
|
19
|
-
useTwoFactorStatus: () => ({
|
|
20
|
-
isLoading: false,
|
|
21
|
-
error: null,
|
|
22
|
-
has2FAEnabled: false,
|
|
23
|
-
devices: [],
|
|
24
|
-
fetchStatus: mockFetchStatus,
|
|
25
|
-
disable2FA: mockDisable2FA,
|
|
26
|
-
clearError: mockClearError,
|
|
27
|
-
}),
|
|
28
|
-
useTwoFactorSetup: () => ({
|
|
29
|
-
resetSetup: mockResetSetup,
|
|
30
|
-
}),
|
|
31
|
-
}));
|
|
32
|
-
|
|
33
|
-
// Mock TwoFactorSetup component
|
|
34
|
-
jest.mock('../../AuthLayout/components/TwoFactorSetup', () => ({
|
|
35
|
-
TwoFactorSetup: ({ onComplete, onSkip }: any) => (
|
|
36
|
-
<div data-testid="two-factor-setup">
|
|
37
|
-
<button onClick={onComplete}>Complete Setup</button>
|
|
38
|
-
<button onClick={onSkip}>Skip Setup</button>
|
|
39
|
-
</div>
|
|
40
|
-
),
|
|
41
|
-
}));
|
|
42
|
-
|
|
43
|
-
// Mock UI components
|
|
44
|
-
jest.mock('@djangocfg/ui-core/components', () => ({
|
|
45
|
-
Alert: ({ children, variant }: any) => (
|
|
46
|
-
<div data-testid="alert" data-variant={variant}>
|
|
47
|
-
{children}
|
|
48
|
-
</div>
|
|
49
|
-
),
|
|
50
|
-
AlertDescription: ({ children }: any) => <span>{children}</span>,
|
|
51
|
-
Button: ({ children, onClick, disabled, variant }: any) => (
|
|
52
|
-
<button onClick={onClick} disabled={disabled} data-variant={variant}>
|
|
53
|
-
{children}
|
|
54
|
-
</button>
|
|
55
|
-
),
|
|
56
|
-
Card: ({ children, className }: any) => (
|
|
57
|
-
<div data-testid="card" className={className}>
|
|
58
|
-
{children}
|
|
59
|
-
</div>
|
|
60
|
-
),
|
|
61
|
-
CardContent: ({ children }: any) => <div>{children}</div>,
|
|
62
|
-
CardDescription: ({ children }: any) => <p>{children}</p>,
|
|
63
|
-
CardHeader: ({ children }: any) => <div>{children}</div>,
|
|
64
|
-
CardTitle: ({ children }: any) => <h3>{children}</h3>,
|
|
65
|
-
Dialog: ({ children, open }: any) => (open ? <div data-testid="dialog">{children}</div> : null),
|
|
66
|
-
DialogContent: ({ children }: any) => <div>{children}</div>,
|
|
67
|
-
DialogDescription: ({ children }: any) => <p>{children}</p>,
|
|
68
|
-
DialogFooter: ({ children }: any) => <div>{children}</div>,
|
|
69
|
-
DialogHeader: ({ children }: any) => <div>{children}</div>,
|
|
70
|
-
DialogTitle: ({ children }: any) => <h4>{children}</h4>,
|
|
71
|
-
OTPInput: ({ value, onChange, disabled }: any) => (
|
|
72
|
-
<input
|
|
73
|
-
data-testid="otp-input"
|
|
74
|
-
value={value}
|
|
75
|
-
onChange={(e) => onChange(e.target.value)}
|
|
76
|
-
disabled={disabled}
|
|
77
|
-
/>
|
|
78
|
-
),
|
|
79
|
-
}));
|
|
80
|
-
|
|
81
|
-
describe('TwoFactorSection', () => {
|
|
82
|
-
beforeEach(() => {
|
|
83
|
-
jest.clearAllMocks();
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
describe('when 2FA is disabled', () => {
|
|
87
|
-
it('should render disabled state', () => {
|
|
88
|
-
render(<TwoFactorSection />);
|
|
89
|
-
|
|
90
|
-
expect(screen.getByText('Two-Factor Authentication')).toBeInTheDocument();
|
|
91
|
-
expect(screen.getByText('2FA is not enabled')).toBeInTheDocument();
|
|
92
|
-
expect(screen.getByText('Enable 2FA')).toBeInTheDocument();
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('should fetch status on mount', () => {
|
|
96
|
-
render(<TwoFactorSection />);
|
|
97
|
-
|
|
98
|
-
expect(mockFetchStatus).toHaveBeenCalledTimes(1);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('should show security recommendation', () => {
|
|
102
|
-
render(<TwoFactorSection />);
|
|
103
|
-
|
|
104
|
-
expect(
|
|
105
|
-
screen.getByText(/Two-factor authentication adds an extra layer of security/)
|
|
106
|
-
).toBeInTheDocument();
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
describe('when 2FA is enabled', () => {
|
|
111
|
-
beforeEach(() => {
|
|
112
|
-
jest.doMock('@djangocfg/api/auth', () => ({
|
|
113
|
-
useTwoFactorStatus: () => ({
|
|
114
|
-
isLoading: false,
|
|
115
|
-
error: null,
|
|
116
|
-
has2FAEnabled: true,
|
|
117
|
-
devices: [
|
|
118
|
-
{
|
|
119
|
-
id: '123',
|
|
120
|
-
name: 'My Authenticator',
|
|
121
|
-
createdAt: '2024-01-01T00:00:00Z',
|
|
122
|
-
lastUsedAt: null,
|
|
123
|
-
isPrimary: true,
|
|
124
|
-
},
|
|
125
|
-
],
|
|
126
|
-
fetchStatus: mockFetchStatus,
|
|
127
|
-
disable2FA: mockDisable2FA,
|
|
128
|
-
clearError: mockClearError,
|
|
129
|
-
}),
|
|
130
|
-
useTwoFactorSetup: () => ({
|
|
131
|
-
resetSetup: mockResetSetup,
|
|
132
|
-
}),
|
|
133
|
-
}));
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('should render enabled state with device info', async () => {
|
|
137
|
-
// Note: Due to jest module caching, this test shows expected behavior
|
|
138
|
-
// In actual implementation, mock would need to be reset properly
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
describe('enable 2FA flow', () => {
|
|
143
|
-
it('should show setup view when Enable 2FA is clicked', async () => {
|
|
144
|
-
render(<TwoFactorSection />);
|
|
145
|
-
|
|
146
|
-
fireEvent.click(screen.getByText('Enable 2FA'));
|
|
147
|
-
|
|
148
|
-
await waitFor(() => {
|
|
149
|
-
expect(screen.getByTestId('two-factor-setup')).toBeInTheDocument();
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
expect(mockResetSetup).toHaveBeenCalled();
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('should return to status view when setup is completed', async () => {
|
|
156
|
-
render(<TwoFactorSection />);
|
|
157
|
-
|
|
158
|
-
// Click Enable 2FA
|
|
159
|
-
fireEvent.click(screen.getByText('Enable 2FA'));
|
|
160
|
-
|
|
161
|
-
await waitFor(() => {
|
|
162
|
-
expect(screen.getByTestId('two-factor-setup')).toBeInTheDocument();
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
// Complete setup
|
|
166
|
-
fireEvent.click(screen.getByText('Complete Setup'));
|
|
167
|
-
|
|
168
|
-
await waitFor(() => {
|
|
169
|
-
expect(mockFetchStatus).toHaveBeenCalled();
|
|
170
|
-
});
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it('should return to status view when setup is skipped', async () => {
|
|
174
|
-
render(<TwoFactorSection />);
|
|
175
|
-
|
|
176
|
-
// Click Enable 2FA
|
|
177
|
-
fireEvent.click(screen.getByText('Enable 2FA'));
|
|
178
|
-
|
|
179
|
-
await waitFor(() => {
|
|
180
|
-
expect(screen.getByTestId('two-factor-setup')).toBeInTheDocument();
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
// Skip setup
|
|
184
|
-
fireEvent.click(screen.getByText('Skip Setup'));
|
|
185
|
-
|
|
186
|
-
// Should go back to status view
|
|
187
|
-
await waitFor(() => {
|
|
188
|
-
expect(screen.queryByTestId('two-factor-setup')).not.toBeInTheDocument();
|
|
189
|
-
});
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
describe('error handling', () => {
|
|
194
|
-
it('should display error when present', () => {
|
|
195
|
-
jest.doMock('@djangocfg/api/auth', () => ({
|
|
196
|
-
useTwoFactorStatus: () => ({
|
|
197
|
-
isLoading: false,
|
|
198
|
-
error: 'Failed to fetch status',
|
|
199
|
-
has2FAEnabled: null,
|
|
200
|
-
devices: [],
|
|
201
|
-
fetchStatus: mockFetchStatus,
|
|
202
|
-
disable2FA: mockDisable2FA,
|
|
203
|
-
clearError: mockClearError,
|
|
204
|
-
}),
|
|
205
|
-
useTwoFactorSetup: () => ({
|
|
206
|
-
resetSetup: mockResetSetup,
|
|
207
|
-
}),
|
|
208
|
-
}));
|
|
209
|
-
|
|
210
|
-
// Note: Due to jest module caching, mock would need proper reset
|
|
211
|
-
});
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
describe('loading state', () => {
|
|
215
|
-
it('should show loading indicator when fetching initial status', () => {
|
|
216
|
-
jest.doMock('@djangocfg/api/auth', () => ({
|
|
217
|
-
useTwoFactorStatus: () => ({
|
|
218
|
-
isLoading: true,
|
|
219
|
-
error: null,
|
|
220
|
-
has2FAEnabled: null,
|
|
221
|
-
devices: [],
|
|
222
|
-
fetchStatus: mockFetchStatus,
|
|
223
|
-
disable2FA: mockDisable2FA,
|
|
224
|
-
clearError: mockClearError,
|
|
225
|
-
}),
|
|
226
|
-
useTwoFactorSetup: () => ({
|
|
227
|
-
resetSetup: mockResetSetup,
|
|
228
|
-
}),
|
|
229
|
-
}));
|
|
230
|
-
|
|
231
|
-
// Note: Due to jest module caching, mock would need proper reset
|
|
232
|
-
});
|
|
233
|
-
});
|
|
234
|
-
});
|
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
// @ts-nocheck
|
|
2
|
-
'use client';
|
|
3
|
-
|
|
4
|
-
import React, { useEffect, useState } from 'react';
|
|
5
|
-
import { useForm } from 'react-hook-form';
|
|
6
|
-
import { toast } from '@djangocfg/ui-core/hooks';
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
PatchedCfgUserUpdateRequest, PatchedCfgUserUpdateRequestSchema,
|
|
10
|
-
useAuth
|
|
11
|
-
} from '@djangocfg/api/auth';
|
|
12
|
-
import {
|
|
13
|
-
Button, Form, FormControl, FormField, FormItem, FormLabel, FormMessage, Input, Label, PhoneInput
|
|
14
|
-
} from '@djangocfg/ui-core/components';
|
|
15
|
-
import { zodResolver } from '@hookform/resolvers/zod';
|
|
16
|
-
|
|
17
|
-
import { profileLogger } from '../../../utils/logger';
|
|
18
|
-
|
|
19
|
-
export const ProfileForm = () => {
|
|
20
|
-
const { user, updateProfile } = useAuth();
|
|
21
|
-
const [isEditing, setIsEditing] = useState(false);
|
|
22
|
-
const [isSaving, setIsSaving] = useState(false);
|
|
23
|
-
|
|
24
|
-
const form = useForm<PatchedCfgUserUpdateRequest>({
|
|
25
|
-
resolver: zodResolver(PatchedCfgUserUpdateRequestSchema),
|
|
26
|
-
defaultValues: {
|
|
27
|
-
first_name: '',
|
|
28
|
-
last_name: '',
|
|
29
|
-
company: '',
|
|
30
|
-
position: '',
|
|
31
|
-
phone: '',
|
|
32
|
-
},
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
// Load user data
|
|
36
|
-
useEffect(() => {
|
|
37
|
-
if (user) {
|
|
38
|
-
form.reset({
|
|
39
|
-
first_name: user.first_name || '',
|
|
40
|
-
last_name: user.last_name || '',
|
|
41
|
-
company: user.company || '',
|
|
42
|
-
position: user.position || '',
|
|
43
|
-
phone: user.phone || '',
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
}, [user, form]);
|
|
47
|
-
|
|
48
|
-
const handleSubmit = async (data: PatchedCfgUserUpdateRequest) => {
|
|
49
|
-
setIsSaving(true);
|
|
50
|
-
try {
|
|
51
|
-
await updateProfile(data);
|
|
52
|
-
toast.success('Profile updated successfully');
|
|
53
|
-
setIsEditing(false);
|
|
54
|
-
} catch (error: any) {
|
|
55
|
-
profileLogger.error('Profile update error:', error);
|
|
56
|
-
if (error?.response?.data) {
|
|
57
|
-
const fieldErrors = error.response.data;
|
|
58
|
-
Object.entries(fieldErrors).forEach(([field, messages]) => {
|
|
59
|
-
if (Array.isArray(messages) && messages.length > 0) {
|
|
60
|
-
form.setError(field as keyof PatchedCfgUserUpdateRequest, {
|
|
61
|
-
type: 'server',
|
|
62
|
-
message: messages[0],
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
toast.error('Please fix the validation errors');
|
|
67
|
-
} else {
|
|
68
|
-
toast.error('Failed to update profile');
|
|
69
|
-
}
|
|
70
|
-
} finally {
|
|
71
|
-
setIsSaving(false);
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
const handleCancel = () => {
|
|
76
|
-
setIsEditing(false);
|
|
77
|
-
form.clearErrors();
|
|
78
|
-
if (user) {
|
|
79
|
-
form.reset({
|
|
80
|
-
first_name: user.first_name || '',
|
|
81
|
-
last_name: user.last_name || '',
|
|
82
|
-
company: user.company || '',
|
|
83
|
-
position: user.position || '',
|
|
84
|
-
phone: user.phone || '',
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const onSubmit = form.handleSubmit(handleSubmit);
|
|
90
|
-
|
|
91
|
-
return (
|
|
92
|
-
<Form {...form}>
|
|
93
|
-
<form onSubmit={onSubmit} className="space-y-4">
|
|
94
|
-
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
95
|
-
<div className="space-y-2 md:col-span-2">
|
|
96
|
-
<Label htmlFor="email">Email</Label>
|
|
97
|
-
<Input id="email" value={user?.email || ''} disabled className="bg-muted" />
|
|
98
|
-
</div>
|
|
99
|
-
|
|
100
|
-
<FormField
|
|
101
|
-
control={form.control}
|
|
102
|
-
name="first_name"
|
|
103
|
-
render={({ field }) => (
|
|
104
|
-
<FormItem>
|
|
105
|
-
<FormLabel>First Name</FormLabel>
|
|
106
|
-
<FormControl>
|
|
107
|
-
<Input {...field} disabled={!isEditing} placeholder="Enter first name" />
|
|
108
|
-
</FormControl>
|
|
109
|
-
<FormMessage />
|
|
110
|
-
</FormItem>
|
|
111
|
-
)}
|
|
112
|
-
/>
|
|
113
|
-
|
|
114
|
-
<FormField
|
|
115
|
-
control={form.control}
|
|
116
|
-
name="last_name"
|
|
117
|
-
render={({ field }) => (
|
|
118
|
-
<FormItem>
|
|
119
|
-
<FormLabel>Last Name</FormLabel>
|
|
120
|
-
<FormControl>
|
|
121
|
-
<Input {...field} disabled={!isEditing} placeholder="Enter last name" />
|
|
122
|
-
</FormControl>
|
|
123
|
-
<FormMessage />
|
|
124
|
-
</FormItem>
|
|
125
|
-
)}
|
|
126
|
-
/>
|
|
127
|
-
|
|
128
|
-
<FormField
|
|
129
|
-
control={form.control}
|
|
130
|
-
name="company"
|
|
131
|
-
render={({ field }) => (
|
|
132
|
-
<FormItem>
|
|
133
|
-
<FormLabel>Company</FormLabel>
|
|
134
|
-
<FormControl>
|
|
135
|
-
<Input {...field} disabled={!isEditing} placeholder="Enter company name" />
|
|
136
|
-
</FormControl>
|
|
137
|
-
<FormMessage />
|
|
138
|
-
</FormItem>
|
|
139
|
-
)}
|
|
140
|
-
/>
|
|
141
|
-
|
|
142
|
-
<FormField
|
|
143
|
-
control={form.control}
|
|
144
|
-
name="position"
|
|
145
|
-
render={({ field }) => (
|
|
146
|
-
<FormItem>
|
|
147
|
-
<FormLabel>Position</FormLabel>
|
|
148
|
-
<FormControl>
|
|
149
|
-
<Input {...field} disabled={!isEditing} placeholder="Enter position" />
|
|
150
|
-
</FormControl>
|
|
151
|
-
<FormMessage />
|
|
152
|
-
</FormItem>
|
|
153
|
-
)}
|
|
154
|
-
/>
|
|
155
|
-
|
|
156
|
-
<FormField
|
|
157
|
-
control={form.control}
|
|
158
|
-
name="phone"
|
|
159
|
-
render={({ field }) => (
|
|
160
|
-
<FormItem className="md:col-span-2">
|
|
161
|
-
<FormLabel>Phone</FormLabel>
|
|
162
|
-
<FormControl>
|
|
163
|
-
<PhoneInput
|
|
164
|
-
value={field.value}
|
|
165
|
-
onChange={field.onChange}
|
|
166
|
-
disabled={!isEditing}
|
|
167
|
-
placeholder="Enter phone number"
|
|
168
|
-
defaultCountry="US"
|
|
169
|
-
/>
|
|
170
|
-
</FormControl>
|
|
171
|
-
<FormMessage />
|
|
172
|
-
</FormItem>
|
|
173
|
-
)}
|
|
174
|
-
/>
|
|
175
|
-
</div>
|
|
176
|
-
|
|
177
|
-
{/* Action Buttons */}
|
|
178
|
-
<div className="flex items-center justify-between pt-4">
|
|
179
|
-
{isEditing ? (
|
|
180
|
-
<div className="flex items-center gap-2">
|
|
181
|
-
<Button type="submit" disabled={isSaving}>
|
|
182
|
-
{isSaving ? 'Saving...' : 'Save Changes'}
|
|
183
|
-
</Button>
|
|
184
|
-
<Button type="button" variant="outline" onClick={handleCancel}>
|
|
185
|
-
Cancel
|
|
186
|
-
</Button>
|
|
187
|
-
</div>
|
|
188
|
-
) : (
|
|
189
|
-
<Button type="button" onClick={() => setIsEditing(true)}>
|
|
190
|
-
Edit Profile
|
|
191
|
-
</Button>
|
|
192
|
-
)}
|
|
193
|
-
</div>
|
|
194
|
-
</form>
|
|
195
|
-
</Form>
|
|
196
|
-
);
|
|
197
|
-
};
|
|
198
|
-
|
|
File without changes
|