@djangocfg/layouts 2.1.356 → 2.1.358
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 +21 -19
- package/src/configurator/private/schema.ts +12 -0
- 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 +45 -248
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +113 -430
- package/src/layouts/{_components → PrivateLayout/components}/PrivateSidebarAccount.tsx +82 -105
- package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +168 -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 +14 -0
- package/src/layouts/PrivateLayout/hooks/useAuthGuard.ts +54 -0
- package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +110 -0
- package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +113 -0
- package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +207 -0
- package/src/layouts/PrivateLayout/hooks/useSidebarDefaultOpen.ts +21 -0
- package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +115 -0
- package/src/layouts/PrivateLayout/index.ts +2 -2
- package/src/layouts/PrivateLayout/types.ts +193 -0
- package/src/layouts/ProfileLayout/ProfileDialog/ProfileDialog.tsx +32 -0
- package/src/layouts/ProfileLayout/ProfileDialog/index.ts +2 -0
- package/src/layouts/ProfileLayout/ProfileDialog/store.ts +19 -0
- package/src/layouts/ProfileLayout/{context.tsx → ProfileForm/context.tsx} +8 -8
- package/src/layouts/ProfileLayout/ProfileForm/index.tsx +148 -0
- package/src/layouts/ProfileLayout/README.md +118 -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/EditableField.tsx +1 -1
- package/src/layouts/ProfileLayout/components/PreferencesSection.tsx +56 -0
- package/src/layouts/ProfileLayout/components/ProfileHeader.tsx +110 -0
- package/src/layouts/ProfileLayout/components/ProfileTab.tsx +35 -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 +5 -2
- package/src/layouts/ProfileLayout/hooks/index.ts +2 -0
- package/src/layouts/ProfileLayout/hooks/useProfileTabs.ts +48 -0
- package/src/layouts/ProfileLayout/index.ts +7 -3
- package/src/layouts/ProfileLayout/types.ts +47 -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 -6
- package/src/layouts/index.ts +9 -4
- package/src/layouts/ProfileLayout/ProfileLayout.tsx +0 -284
- 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
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { useApiKey } from '@djangocfg/api/hooks';
|
|
6
|
+
import { toast } from '@djangocfg/ui-core/hooks';
|
|
7
|
+
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
// Labels (hardcoded English — same pattern as TwoFactorSection)
|
|
10
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface ApiKeyLabels {
|
|
13
|
+
title: string;
|
|
14
|
+
description: string;
|
|
15
|
+
created: string;
|
|
16
|
+
reissued: string;
|
|
17
|
+
neverReissued: string;
|
|
18
|
+
regenerate: string;
|
|
19
|
+
confirmRegenerate: string;
|
|
20
|
+
regenerating: string;
|
|
21
|
+
regenerated: string;
|
|
22
|
+
failedToRegenerate: string;
|
|
23
|
+
done: string;
|
|
24
|
+
test: string;
|
|
25
|
+
testing: string;
|
|
26
|
+
testSuccess: string;
|
|
27
|
+
testFailed: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const LABELS: ApiKeyLabels = {
|
|
31
|
+
title: 'API Key',
|
|
32
|
+
description: 'Your personal API key for programmatic access',
|
|
33
|
+
created: 'Created',
|
|
34
|
+
reissued: 'Last reissued',
|
|
35
|
+
neverReissued: 'Never reissued',
|
|
36
|
+
regenerate: 'Regenerate API Key',
|
|
37
|
+
confirmRegenerate: 'Click to confirm',
|
|
38
|
+
regenerating: 'Regenerating…',
|
|
39
|
+
regenerated: 'API key regenerated',
|
|
40
|
+
failedToRegenerate: 'Failed to regenerate API key',
|
|
41
|
+
done: 'Done',
|
|
42
|
+
test: 'Test API Key',
|
|
43
|
+
testing: 'Testing…',
|
|
44
|
+
testSuccess: 'API key is valid',
|
|
45
|
+
testFailed: 'API key test failed',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
// Context
|
|
50
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
interface ApiKeyContextValue {
|
|
53
|
+
labels: ApiKeyLabels;
|
|
54
|
+
apiKey: string | null;
|
|
55
|
+
reissuedAt: string | null;
|
|
56
|
+
createdAt: string | null;
|
|
57
|
+
isLoading: boolean;
|
|
58
|
+
error: string | null;
|
|
59
|
+
isArmed: boolean;
|
|
60
|
+
arm: () => void;
|
|
61
|
+
disarm: () => void;
|
|
62
|
+
regenerate: () => Promise<void>;
|
|
63
|
+
isRegenerating: boolean;
|
|
64
|
+
isFresh: boolean;
|
|
65
|
+
dismissFresh: () => void;
|
|
66
|
+
testKey: () => Promise<void>;
|
|
67
|
+
isTesting: boolean;
|
|
68
|
+
testResult: boolean | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const ApiKeyContext = createContext<ApiKeyContextValue | null>(null);
|
|
72
|
+
|
|
73
|
+
export const useApiKeyContext = (): ApiKeyContextValue => {
|
|
74
|
+
const ctx = useContext(ApiKeyContext);
|
|
75
|
+
if (!ctx) throw new Error('useApiKeyContext must be used within ApiKeyProvider');
|
|
76
|
+
return ctx;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
// Provider
|
|
81
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
export const ApiKeyProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
84
|
+
const { apiKey, reissuedAt, createdAt, isLoading, isRegenerating, isTesting, regenerate, testKey, refresh } = useApiKey();
|
|
85
|
+
|
|
86
|
+
const [isArmed, setIsArmed] = useState(false);
|
|
87
|
+
const [error, setError] = useState<string | null>(null);
|
|
88
|
+
const [testResult, setTestResult] = useState<boolean | null>(null);
|
|
89
|
+
|
|
90
|
+
const arm = useCallback(() => { setError(null); setIsArmed(true); }, []);
|
|
91
|
+
const disarm = useCallback(() => setIsArmed(false), []);
|
|
92
|
+
|
|
93
|
+
// Detect when the key is "fresh" (full key shown once after regenerate).
|
|
94
|
+
// Backend GET returns masked keys (contain •). If no •, it's the fresh full key.
|
|
95
|
+
const isFresh = apiKey ? !apiKey.includes('•') : false;
|
|
96
|
+
|
|
97
|
+
const dismissFresh = useCallback(() => {
|
|
98
|
+
refresh();
|
|
99
|
+
}, [refresh]);
|
|
100
|
+
|
|
101
|
+
const handleRegenerate = useCallback(async () => {
|
|
102
|
+
try {
|
|
103
|
+
setError(null);
|
|
104
|
+
await regenerate();
|
|
105
|
+
setIsArmed(false);
|
|
106
|
+
setTestResult(null);
|
|
107
|
+
toast.success(LABELS.regenerated);
|
|
108
|
+
} catch {
|
|
109
|
+
setError(LABELS.failedToRegenerate);
|
|
110
|
+
toast.error(LABELS.failedToRegenerate);
|
|
111
|
+
}
|
|
112
|
+
}, [regenerate]);
|
|
113
|
+
|
|
114
|
+
const handleTest = useCallback(async () => {
|
|
115
|
+
if (!apiKey) return;
|
|
116
|
+
setTestResult(null);
|
|
117
|
+
try {
|
|
118
|
+
const result = await testKey(apiKey);
|
|
119
|
+
setTestResult(result.valid);
|
|
120
|
+
if (result.valid) {
|
|
121
|
+
toast.success(LABELS.testSuccess);
|
|
122
|
+
} else {
|
|
123
|
+
toast.error(LABELS.testFailed);
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
setTestResult(false);
|
|
127
|
+
toast.error(LABELS.testFailed);
|
|
128
|
+
}
|
|
129
|
+
}, [apiKey, testKey]);
|
|
130
|
+
|
|
131
|
+
const value = useMemo<ApiKeyContextValue>(() => ({
|
|
132
|
+
labels: LABELS,
|
|
133
|
+
apiKey,
|
|
134
|
+
reissuedAt,
|
|
135
|
+
createdAt,
|
|
136
|
+
isLoading,
|
|
137
|
+
error,
|
|
138
|
+
isArmed,
|
|
139
|
+
arm,
|
|
140
|
+
disarm,
|
|
141
|
+
regenerate: handleRegenerate,
|
|
142
|
+
isRegenerating,
|
|
143
|
+
isFresh,
|
|
144
|
+
dismissFresh,
|
|
145
|
+
testKey: handleTest,
|
|
146
|
+
isTesting,
|
|
147
|
+
testResult,
|
|
148
|
+
}), [
|
|
149
|
+
apiKey, reissuedAt, createdAt, isLoading, error,
|
|
150
|
+
isArmed, arm, disarm, handleRegenerate, isRegenerating, isFresh, dismissFresh,
|
|
151
|
+
handleTest, isTesting, testResult,
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<ApiKeyContext.Provider value={value}>
|
|
156
|
+
{children}
|
|
157
|
+
</ApiKeyContext.Provider>
|
|
158
|
+
);
|
|
159
|
+
};
|
|
@@ -6,7 +6,7 @@ import { parsePhoneNumberFromString } from 'libphonenumber-js';
|
|
|
6
6
|
import { Button, Input, PhoneInput } from '@djangocfg/ui-core/components';
|
|
7
7
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
8
8
|
|
|
9
|
-
import { useProfileContext } from '../context';
|
|
9
|
+
import { useProfileContext } from '../ProfileForm/context';
|
|
10
10
|
|
|
11
11
|
function formatPhone(raw: string): string {
|
|
12
12
|
if (!raw) return '';
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preferences Section
|
|
3
|
+
*
|
|
4
|
+
* Language + theme controls for ProfileForm.
|
|
5
|
+
* Uses ThemeToggle from ui-nextjs and LocaleSwitcherDropdown from _components.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use client';
|
|
9
|
+
|
|
10
|
+
import React from 'react';
|
|
11
|
+
|
|
12
|
+
import { ThemeToggle } from '@djangocfg/ui-nextjs/theme';
|
|
13
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
14
|
+
|
|
15
|
+
import { LocaleSwitcherDropdown } from '../../_components/locale-switcher';
|
|
16
|
+
import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
|
|
17
|
+
|
|
18
|
+
interface PreferencesSectionProps {
|
|
19
|
+
/** Extra className for the root. */
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const PreferencesSection: React.FC<PreferencesSectionProps> = ({
|
|
24
|
+
className,
|
|
25
|
+
}) => {
|
|
26
|
+
const layoutI18n = useLayoutI18nOptional();
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className={cn('py-0', className)}>
|
|
30
|
+
{layoutI18n && (
|
|
31
|
+
<>
|
|
32
|
+
<div className="flex items-center justify-between py-3">
|
|
33
|
+
<span className="text-sm">Language</span>
|
|
34
|
+
<LocaleSwitcherDropdown
|
|
35
|
+
locale={layoutI18n.locale}
|
|
36
|
+
locales={layoutI18n.locales}
|
|
37
|
+
onChange={layoutI18n.onLocaleChange}
|
|
38
|
+
variant="outline"
|
|
39
|
+
size="sm"
|
|
40
|
+
showCode
|
|
41
|
+
showIcon={false}
|
|
42
|
+
showFlag
|
|
43
|
+
showTriggerLabel
|
|
44
|
+
/>
|
|
45
|
+
</div>
|
|
46
|
+
<div className="h-px bg-border/60" />
|
|
47
|
+
</>
|
|
48
|
+
)}
|
|
49
|
+
|
|
50
|
+
<div className="flex items-center justify-between py-3">
|
|
51
|
+
<span className="text-sm">Theme</span>
|
|
52
|
+
<ThemeToggle size="default" />
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { LogOut, MoreHorizontal, Trash2 } from 'lucide-react';
|
|
4
|
+
import moment from 'moment';
|
|
5
|
+
import React, { useCallback } from 'react';
|
|
6
|
+
|
|
7
|
+
import { useAuth, useDeleteAccount } from '@djangocfg/api/auth';
|
|
8
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
9
|
+
import {
|
|
10
|
+
Button,
|
|
11
|
+
DropdownMenu,
|
|
12
|
+
DropdownMenuContent,
|
|
13
|
+
DropdownMenuItem,
|
|
14
|
+
DropdownMenuSeparator,
|
|
15
|
+
DropdownMenuTrigger,
|
|
16
|
+
} from '@djangocfg/ui-core/components';
|
|
17
|
+
|
|
18
|
+
import { AvatarSection } from './AvatarSection';
|
|
19
|
+
import { useProfileContext } from '../ProfileForm/context';
|
|
20
|
+
import type { ProfileSlots } from '../types';
|
|
21
|
+
|
|
22
|
+
interface ProfileHeaderProps {
|
|
23
|
+
slots?: ProfileSlots;
|
|
24
|
+
enableDeleteAccount?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const ProfileHeader: React.FC<ProfileHeaderProps> = ({ slots, enableDeleteAccount }) => {
|
|
28
|
+
const { labels, onLogout } = useProfileContext();
|
|
29
|
+
const { user, logout } = useAuth();
|
|
30
|
+
const { deleteAccount } = useDeleteAccount();
|
|
31
|
+
const t = useAppT();
|
|
32
|
+
|
|
33
|
+
const handleDeleteAccount = useCallback(async () => {
|
|
34
|
+
const confirmationWord = t('layouts.profilePage.confirmationWord');
|
|
35
|
+
const value = await window.dialog.prompt({
|
|
36
|
+
title: t('layouts.profilePage.deleteAccountTitle'),
|
|
37
|
+
message: t('layouts.profilePage.deleteAccountDesc'),
|
|
38
|
+
placeholder: confirmationWord,
|
|
39
|
+
confirmText: t('layouts.profilePage.deleteAccount'),
|
|
40
|
+
cancelText: t('layouts.profilePage.cancel'),
|
|
41
|
+
variant: 'destructive',
|
|
42
|
+
});
|
|
43
|
+
if (value?.toUpperCase() !== confirmationWord.toUpperCase()) return;
|
|
44
|
+
const result = await deleteAccount();
|
|
45
|
+
if (result.success) logout();
|
|
46
|
+
}, [t, deleteAccount, logout]);
|
|
47
|
+
|
|
48
|
+
if (!user) return null;
|
|
49
|
+
|
|
50
|
+
const displayName = user.full_name || user.display_username || user.email;
|
|
51
|
+
const memberSince = user.date_joined
|
|
52
|
+
? moment.utc(user.date_joined).local().format('MMMM YYYY')
|
|
53
|
+
: null;
|
|
54
|
+
|
|
55
|
+
const badge = slots?.headerBadge ?? null;
|
|
56
|
+
const menuItems = slots?.headerMenuItems ?? null;
|
|
57
|
+
const headerAfter = slots?.headerAfter ?? null;
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="pb-4 md:pb-6 border-b mb-2">
|
|
61
|
+
<div className="flex items-center gap-3 md:gap-4">
|
|
62
|
+
<AvatarSection />
|
|
63
|
+
|
|
64
|
+
<div className="flex-1 min-w-0">
|
|
65
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
66
|
+
<h1 className="text-lg md:text-xl font-semibold truncate">{displayName}</h1>
|
|
67
|
+
{badge}
|
|
68
|
+
</div>
|
|
69
|
+
<p className="text-sm text-muted-foreground truncate">{user.email}</p>
|
|
70
|
+
{memberSince && (
|
|
71
|
+
<p className="text-xs text-muted-foreground/60 mt-0.5">
|
|
72
|
+
Member since {memberSince}
|
|
73
|
+
</p>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<DropdownMenu>
|
|
78
|
+
<DropdownMenuTrigger asChild>
|
|
79
|
+
<Button variant="ghost" size="icon" className="flex-shrink-0 rounded-full">
|
|
80
|
+
<MoreHorizontal className="w-4 h-4" />
|
|
81
|
+
</Button>
|
|
82
|
+
</DropdownMenuTrigger>
|
|
83
|
+
<DropdownMenuContent align="end" className="w-48">
|
|
84
|
+
<DropdownMenuItem onClick={onLogout} className="gap-2">
|
|
85
|
+
<LogOut className="w-4 h-4" />
|
|
86
|
+
{labels.signOut}
|
|
87
|
+
</DropdownMenuItem>
|
|
88
|
+
|
|
89
|
+
{menuItems && <><DropdownMenuSeparator />{menuItems}</>}
|
|
90
|
+
|
|
91
|
+
{enableDeleteAccount && (
|
|
92
|
+
<>
|
|
93
|
+
<DropdownMenuSeparator />
|
|
94
|
+
<DropdownMenuItem
|
|
95
|
+
onClick={handleDeleteAccount}
|
|
96
|
+
className="gap-2 text-destructive focus:text-destructive"
|
|
97
|
+
>
|
|
98
|
+
<Trash2 className="w-4 h-4" />
|
|
99
|
+
{labels.deleteAccount}
|
|
100
|
+
</DropdownMenuItem>
|
|
101
|
+
</>
|
|
102
|
+
)}
|
|
103
|
+
</DropdownMenuContent>
|
|
104
|
+
</DropdownMenu>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{headerAfter && <div className="mt-4">{headerAfter}</div>}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { useAuth } from '@djangocfg/api/auth';
|
|
6
|
+
|
|
7
|
+
import { EditableField, PreferencesSection, Section } from '.';
|
|
8
|
+
import { useProfileContext } from '../ProfileForm/context';
|
|
9
|
+
|
|
10
|
+
export const ProfileTab: React.FC = () => {
|
|
11
|
+
const { labels, onFieldSave } = useProfileContext();
|
|
12
|
+
const { user } = useAuth();
|
|
13
|
+
if (!user) return null;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div className="space-y-6 pt-4">
|
|
17
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-6">
|
|
18
|
+
<Section title={labels.personalInfo}>
|
|
19
|
+
<EditableField label={labels.firstName} value={user.first_name || ''} placeholder={labels.addFirstName} onSave={(v) => onFieldSave('first_name', v)} />
|
|
20
|
+
<EditableField label={labels.lastName} value={user.last_name || ''} placeholder={labels.addLastName} onSave={(v) => onFieldSave('last_name', v)} />
|
|
21
|
+
<EditableField label={labels.phone} value={user.phone || ''} placeholder={labels.addPhone} onSave={(v) => onFieldSave('phone', v)} type="phone" />
|
|
22
|
+
</Section>
|
|
23
|
+
|
|
24
|
+
<Section title={labels.work}>
|
|
25
|
+
<EditableField label={labels.company} value={user.company || ''} placeholder={labels.addCompany} onSave={(v) => onFieldSave('company', v)} />
|
|
26
|
+
<EditableField label={labels.position} value={user.position || ''} placeholder={labels.addPosition} onSave={(v) => onFieldSave('position', v)} />
|
|
27
|
+
</Section>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<Section title={labels.preferences ?? 'Preferences'}>
|
|
31
|
+
<PreferencesSection />
|
|
32
|
+
</Section>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
};
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
} from '@djangocfg/ui-core/components';
|
|
26
26
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
27
27
|
|
|
28
|
-
import { SetupStepStandalone } from '
|
|
28
|
+
import { SetupStepStandalone } from '../../../AuthLayout/components/steps/SetupStep';
|
|
29
29
|
|
|
30
30
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
31
|
// Types
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { TwoFactorSection } from './TwoFactorSection';
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
export { ActionButton } from './ActionButton';
|
|
2
|
+
export { ApiKeySection, ApiKeyProvider, useApiKeyContext } from './ApiKeySection';
|
|
3
|
+
export type { ApiKeyLabels } from './ApiKeySection';
|
|
2
4
|
export { AvatarSection } from './AvatarSection';
|
|
3
5
|
export { DeleteAccountSection, DeleteAccountScreen } from './DeleteAccountSection';
|
|
4
6
|
export { EditableField } from './EditableField';
|
|
5
|
-
export {
|
|
7
|
+
export { PreferencesSection } from './PreferencesSection';
|
|
8
|
+
export { ProfileHeader } from './ProfileHeader';
|
|
9
|
+
export { ProfileTab } from './ProfileTab';
|
|
6
10
|
export { Section } from './Section';
|
|
7
11
|
export { TwoFactorSection } from './TwoFactorSection';
|
|
8
|
-
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
export type ProfileTabValue = 'profile' | 'security' | 'api-keys';
|
|
6
|
+
|
|
7
|
+
export interface UseProfileTabsOptions {
|
|
8
|
+
enable2FA?: boolean;
|
|
9
|
+
enableAPIKeys?: boolean;
|
|
10
|
+
extraTabValues?: string[];
|
|
11
|
+
/** Initial active tab. Defaults to `'profile'`. */
|
|
12
|
+
defaultTab?: ProfileTabValue;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Manages the active profile tab with local React state.
|
|
17
|
+
*
|
|
18
|
+
* Falls back to `'profile'` when `defaultTab` is missing or not allowed.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* const { tab, setTab, allowed } = useProfileTabs({ enable2FA: true, defaultTab: 'security' });
|
|
22
|
+
* <Tabs value={tab} onValueChange={setTab}>...</Tabs>
|
|
23
|
+
*/
|
|
24
|
+
export function useProfileTabs(options: UseProfileTabsOptions = {}) {
|
|
25
|
+
const { enable2FA, enableAPIKeys, extraTabValues = [], defaultTab } = options;
|
|
26
|
+
|
|
27
|
+
const allowed = useMemo(() => {
|
|
28
|
+
const base: ProfileTabValue[] = ['profile'];
|
|
29
|
+
if (enable2FA) base.push('security');
|
|
30
|
+
if (enableAPIKeys) base.push('api-keys');
|
|
31
|
+
return [...base, ...extraTabValues] as ProfileTabValue[];
|
|
32
|
+
}, [enable2FA, enableAPIKeys, extraTabValues]);
|
|
33
|
+
|
|
34
|
+
const [tab, setTabState] = useState<ProfileTabValue>(
|
|
35
|
+
defaultTab && allowed.includes(defaultTab) ? defaultTab : 'profile',
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const setTab = useCallback(
|
|
39
|
+
(value: ProfileTabValue) => {
|
|
40
|
+
if (allowed.includes(value)) {
|
|
41
|
+
setTabState(value);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
[allowed],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return { tab, setTab, allowed };
|
|
48
|
+
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export
|
|
3
|
-
|
|
1
|
+
export { ProfileForm, useProfileContext } from './ProfileForm';
|
|
2
|
+
export { ProfileProvider } from './ProfileForm/context';
|
|
3
|
+
export { useProfileTabs } from './hooks';
|
|
4
|
+
export { ProfileDialog, useProfileDialogStore } from './ProfileDialog';
|
|
5
|
+
export type { ProfileLabels } from './ProfileForm/context';
|
|
6
|
+
export type { ProfileFormProps, ProfileLayoutProps, ProfileTab, ProfileSlots } from './types';
|
|
7
|
+
export type { ProfileTabValue, UseProfileTabsOptions } from './hooks';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import type { ProfileTabValue } from './hooks/useProfileTabs';
|
|
4
|
+
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
// Slot + Tab types
|
|
7
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export interface ProfileTab {
|
|
10
|
+
/** Unique key, used as Tabs value */
|
|
11
|
+
value: string;
|
|
12
|
+
/** Trigger label */
|
|
13
|
+
label: React.ReactNode;
|
|
14
|
+
/** Tab panel content */
|
|
15
|
+
content: React.ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ProfileSlots {
|
|
19
|
+
/** Extra items inside the ⋯ dropdown, above the separator before Delete */
|
|
20
|
+
headerMenuItems?: React.ReactNode;
|
|
21
|
+
/** Rendered next to the user name (e.g. plan badge, role chip) */
|
|
22
|
+
headerBadge?: React.ReactNode;
|
|
23
|
+
/** Rendered below the avatar row, above the tabs */
|
|
24
|
+
headerAfter?: React.ReactNode;
|
|
25
|
+
/** Rendered below all tab content */
|
|
26
|
+
footer?: React.ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ProfileFormProps {
|
|
30
|
+
onUnauthenticated?: () => void;
|
|
31
|
+
title?: string;
|
|
32
|
+
enable2FA?: boolean;
|
|
33
|
+
enableAPIKeys?: boolean;
|
|
34
|
+
enableDeleteAccount?: boolean;
|
|
35
|
+
/** Extra tabs appended after built-in Profile / Security / API Keys tabs */
|
|
36
|
+
tabs?: ProfileTab[];
|
|
37
|
+
/** Named slots for additional content */
|
|
38
|
+
slots?: ProfileSlots;
|
|
39
|
+
/**
|
|
40
|
+
* When provided, the active tab is controlled locally (no URL sync).
|
|
41
|
+
* Useful for dialogs where query-string pollution is undesirable.
|
|
42
|
+
*/
|
|
43
|
+
defaultTab?: ProfileTabValue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @deprecated Use ProfileFormProps instead */
|
|
47
|
+
export type ProfileLayoutProps = ProfileFormProps;
|
|
@@ -37,7 +37,7 @@ import React, { useMemo } from 'react';
|
|
|
37
37
|
import { useAuth } from '@djangocfg/api/auth';
|
|
38
38
|
import { useAppT } from '@djangocfg/i18n';
|
|
39
39
|
|
|
40
|
-
import { useLogout } from '
|
|
40
|
+
import { useLogout } from '../../../hooks';
|
|
41
41
|
import {
|
|
42
42
|
Button,
|
|
43
43
|
DropdownMenu,
|
|
@@ -53,10 +53,10 @@ import {
|
|
|
53
53
|
LanguageFlag,
|
|
54
54
|
} from '@djangocfg/ui-core/components';
|
|
55
55
|
|
|
56
|
-
import { LOCALE_LABELS } from '
|
|
56
|
+
import { LOCALE_LABELS } from '../../_components/LocaleSwitcher';
|
|
57
57
|
import { UserAvatar } from './UserAvatar';
|
|
58
58
|
|
|
59
|
-
import type { UserMenuGroup, UserMenuLocaleConfig } from '
|
|
59
|
+
import type { UserMenuGroup, UserMenuLocaleConfig } from '../../types';
|
|
60
60
|
|
|
61
61
|
export interface UserMenuProps {
|
|
62
62
|
/** Display variant */
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
'use client';
|
|
8
8
|
|
|
9
|
-
import React, { useEffect, useState } from 'react';
|
|
9
|
+
import React, { memo, useEffect, useMemo, useState } from 'react';
|
|
10
10
|
import { Laptop, Moon, Sun } from 'lucide-react';
|
|
11
11
|
|
|
12
12
|
import { Button } from '@djangocfg/ui-core/components';
|
|
@@ -104,7 +104,7 @@ function ThemeModeControl({
|
|
|
104
104
|
);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
function DefaultFooterRaw({ config }: DefaultFooterProps) {
|
|
108
108
|
const variant = config.variant ?? 'full';
|
|
109
109
|
const shellClass = config.shell?.className;
|
|
110
110
|
const brandSlot = config.brand?.slot;
|
|
@@ -361,3 +361,13 @@ export function DefaultFooter({ config }: DefaultFooterProps) {
|
|
|
361
361
|
</>
|
|
362
362
|
);
|
|
363
363
|
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Memoised footer. Uses reference equality on `config` — the consumer is
|
|
367
|
+
* expected to stabilise the config object (useMemo / useState). This avoids
|
|
368
|
+
* deep-comparing large nested config trees while still preventing re-renders
|
|
369
|
+
* when the same config object is passed through successive parent renders.
|
|
370
|
+
*/
|
|
371
|
+
export const DefaultFooter = memo(DefaultFooterRaw, (prev, next) => {
|
|
372
|
+
return prev.config === next.config;
|
|
373
|
+
});
|
|
@@ -16,7 +16,7 @@ import React, { type ReactNode } from 'react';
|
|
|
16
16
|
import { Button } from '@djangocfg/ui-core/components';
|
|
17
17
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
18
18
|
|
|
19
|
-
import { UserMenu } from '
|
|
19
|
+
import { UserMenu } from '../../components/UserMenu';
|
|
20
20
|
import { NavActionItem, type NavAction } from '../../primitives/NavActionItem';
|
|
21
21
|
import { NavbarShell, type NavbarActionsContext } from '../../shared';
|
|
22
22
|
import type {
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { Menu, X } from 'lucide-react';
|
|
4
|
-
import React, { type ReactNode } from 'react';
|
|
4
|
+
import React, { memo, type ReactNode } from 'react';
|
|
5
5
|
|
|
6
6
|
import { Button } from '@djangocfg/ui-core/components';
|
|
7
7
|
|
|
8
|
-
import { UserMenu } from '
|
|
8
|
+
import { UserMenu } from '../components/UserMenu';
|
|
9
9
|
import type { UserMenuConfig } from '../../types';
|
|
10
10
|
|
|
11
11
|
import { NavActionItem, type NavAction } from './NavActionItem';
|
|
@@ -30,7 +30,7 @@ interface NavActionsProps {
|
|
|
30
30
|
controlsSlot?: ReactNode;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
function NavActionsRaw({
|
|
34
34
|
userMenu,
|
|
35
35
|
mobileMenuOpen,
|
|
36
36
|
onMobileMenuToggle,
|
|
@@ -81,3 +81,44 @@ export function NavActions({
|
|
|
81
81
|
</div>
|
|
82
82
|
);
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Shallow equality for NavAction arrays. Compares label, href, variant and
|
|
87
|
+
* external flag so that CTA pills re-render when their content changes, but
|
|
88
|
+
* skip on reference churn from parent re-renders.
|
|
89
|
+
*/
|
|
90
|
+
function actionsEqual(a?: NavAction[], b?: NavAction[]): boolean {
|
|
91
|
+
if (a === b) return true;
|
|
92
|
+
if (!a || !b) return false;
|
|
93
|
+
if (a.length !== b.length) return false;
|
|
94
|
+
return a.every((ai, i) => {
|
|
95
|
+
const bi = b[i];
|
|
96
|
+
return (
|
|
97
|
+
ai.label === bi.label &&
|
|
98
|
+
ai.href === bi.href &&
|
|
99
|
+
ai.variant === bi.variant &&
|
|
100
|
+
ai.external === bi.external
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Memoised actions column. Re-renders when:
|
|
107
|
+
* - mobile menu open state changes,
|
|
108
|
+
* - actions array content changes (via actionsEqual),
|
|
109
|
+
* - any slot reference changes.
|
|
110
|
+
* Pure parent re-renders with identical props are skipped.
|
|
111
|
+
*/
|
|
112
|
+
export const NavActions = memo(NavActionsRaw, (prev, next) => {
|
|
113
|
+
return (
|
|
114
|
+
prev.mobileMenuOpen === next.mobileMenuOpen &&
|
|
115
|
+
prev.onMobileMenuToggle === next.onMobileMenuToggle &&
|
|
116
|
+
prev.toggleMobileLabel === next.toggleMobileLabel &&
|
|
117
|
+
prev.forceShowMobileTrigger === next.forceShowMobileTrigger &&
|
|
118
|
+
prev.userMenu === next.userMenu &&
|
|
119
|
+
prev.leadingSlot === next.leadingSlot &&
|
|
120
|
+
prev.trailingSlot === next.trailingSlot &&
|
|
121
|
+
prev.controlsSlot === next.controlsSlot &&
|
|
122
|
+
actionsEqual(prev.actions, next.actions)
|
|
123
|
+
);
|
|
124
|
+
});
|