@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
|
@@ -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
|
+
};
|
|
@@ -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 '../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,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { useAuth } from '@djangocfg/api/auth';
|
|
6
|
+
|
|
7
|
+
import { EditableField, Section } from '.';
|
|
8
|
+
import { useProfileContext } from '../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="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-6 pt-4">
|
|
17
|
+
<Section title={labels.personalInfo}>
|
|
18
|
+
<EditableField label={labels.firstName} value={user.first_name || ''} placeholder={labels.addFirstName} onSave={(v) => onFieldSave('first_name', v)} />
|
|
19
|
+
<EditableField label={labels.lastName} value={user.last_name || ''} placeholder={labels.addLastName} onSave={(v) => onFieldSave('last_name', v)} />
|
|
20
|
+
<EditableField label={labels.phone} value={user.phone || ''} placeholder={labels.addPhone} onSave={(v) => onFieldSave('phone', v)} type="phone" />
|
|
21
|
+
</Section>
|
|
22
|
+
|
|
23
|
+
<Section title={labels.work}>
|
|
24
|
+
<EditableField label={labels.company} value={user.company || ''} placeholder={labels.addCompany} onSave={(v) => onFieldSave('company', v)} />
|
|
25
|
+
<EditableField label={labels.position} value={user.position || ''} placeholder={labels.addPosition} onSave={(v) => onFieldSave('position', v)} />
|
|
26
|
+
</Section>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
};
|
|
@@ -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,10 @@
|
|
|
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 { ProfileHeader } from './ProfileHeader';
|
|
8
|
+
export { ProfileTab } from './ProfileTab';
|
|
6
9
|
export { Section } from './Section';
|
|
7
10
|
export { TwoFactorSection } from './TwoFactorSection';
|
|
8
|
-
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { createContext, useCallback, useContext, useMemo } from 'react';
|
|
4
4
|
|
|
5
|
+
import { useAuth } from '@djangocfg/api/auth';
|
|
5
6
|
import { useAppT } from '@djangocfg/i18n';
|
|
6
7
|
import { toast } from '@djangocfg/ui-core/hooks';
|
|
7
|
-
import { useAuth } from '@djangocfg/api/auth';
|
|
8
8
|
|
|
9
9
|
import { profileLogger } from '../../utils/logger';
|
|
10
10
|
import { useLogout } from '../../hooks';
|
|
@@ -18,6 +18,7 @@ export interface ProfileLabels {
|
|
|
18
18
|
personalInfo: string;
|
|
19
19
|
work: string;
|
|
20
20
|
security: string;
|
|
21
|
+
apiKeys: string;
|
|
21
22
|
firstName: string;
|
|
22
23
|
lastName: string;
|
|
23
24
|
phone: string;
|
|
@@ -65,13 +66,9 @@ export const useProfileContext = (): ProfileContextValue => {
|
|
|
65
66
|
interface ProfileProviderProps {
|
|
66
67
|
children: React.ReactNode;
|
|
67
68
|
title?: string;
|
|
68
|
-
onUnauthenticated?: () => void;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
export const ProfileProvider: React.FC<ProfileProviderProps> = ({
|
|
72
|
-
children,
|
|
73
|
-
title,
|
|
74
|
-
}) => {
|
|
71
|
+
export const ProfileProvider: React.FC<ProfileProviderProps> = ({ children, title }) => {
|
|
75
72
|
const { updateProfile } = useAuth();
|
|
76
73
|
const t = useAppT();
|
|
77
74
|
const onLogout = useLogout();
|
|
@@ -81,6 +78,7 @@ export const ProfileProvider: React.FC<ProfileProviderProps> = ({
|
|
|
81
78
|
personalInfo: t('layouts.profilePage.personalInfo'),
|
|
82
79
|
work: t('layouts.profilePage.work'),
|
|
83
80
|
security: t('layouts.profilePage.security'),
|
|
81
|
+
apiKeys: 'API Keys',
|
|
84
82
|
firstName: t('layouts.profilePage.firstName'),
|
|
85
83
|
lastName: t('layouts.profilePage.lastName'),
|
|
86
84
|
phone: t('layouts.profilePage.phone'),
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
|
|
5
|
+
import { useQueryState, parseAsStringEnum } from '@djangocfg/ui-core/hooks';
|
|
6
|
+
|
|
7
|
+
export type ProfileTabValue = 'profile' | 'security' | 'api-keys';
|
|
8
|
+
|
|
9
|
+
export interface UseProfileTabsOptions {
|
|
10
|
+
enable2FA?: boolean;
|
|
11
|
+
enableAPIKeys?: boolean;
|
|
12
|
+
extraTabValues?: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Syncs the active profile tab with the URL query param `?tab=`.
|
|
17
|
+
*
|
|
18
|
+
* Falls back to `'profile'` when the param is missing or not allowed.
|
|
19
|
+
* Setting the default value clears the key from the URL (no `?tab=profile`).
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* const { tab, setTab, allowed } = useProfileTabs({ enable2FA: true });
|
|
23
|
+
* <Tabs value={tab} onValueChange={setTab}>...</Tabs>
|
|
24
|
+
*/
|
|
25
|
+
export function useProfileTabs(options: UseProfileTabsOptions = {}) {
|
|
26
|
+
const { enable2FA, enableAPIKeys, extraTabValues = [] } = options;
|
|
27
|
+
|
|
28
|
+
const allowed = useMemo(() => {
|
|
29
|
+
const base: ProfileTabValue[] = ['profile'];
|
|
30
|
+
if (enable2FA) base.push('security');
|
|
31
|
+
if (enableAPIKeys) base.push('api-keys');
|
|
32
|
+
return [...base, ...extraTabValues] as ProfileTabValue[];
|
|
33
|
+
}, [enable2FA, enableAPIKeys, extraTabValues]);
|
|
34
|
+
|
|
35
|
+
const parser = useMemo(
|
|
36
|
+
() => parseAsStringEnum(allowed).withDefault('profile'),
|
|
37
|
+
[allowed],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const [tab, setTab] = useQueryState('tab', parser, { replace: true });
|
|
41
|
+
|
|
42
|
+
return { tab, setTab, allowed };
|
|
43
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
-
export { ProfileLayout } from './ProfileLayout';
|
|
2
|
-
export
|
|
3
|
-
|
|
1
|
+
export { ProfileLayout, useProfileContext } from './ProfileLayout';
|
|
2
|
+
export { ProfileProvider } from './context';
|
|
3
|
+
export { useProfileTabs } from './hooks';
|
|
4
|
+
export type { ProfileLabels } from './context';
|
|
5
|
+
export type { ProfileLayoutProps, ProfileTab, ProfileSlots } from './types';
|
|
6
|
+
export type { ProfileTabValue, UseProfileTabsOptions } from './hooks';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
// Slot + Tab types
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export interface ProfileTab {
|
|
8
|
+
/** Unique key, used as Tabs value */
|
|
9
|
+
value: string;
|
|
10
|
+
/** Trigger label */
|
|
11
|
+
label: React.ReactNode;
|
|
12
|
+
/** Tab panel content */
|
|
13
|
+
content: React.ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ProfileSlots {
|
|
17
|
+
/** Extra items inside the ⋯ dropdown, above the separator before Delete */
|
|
18
|
+
headerMenuItems?: React.ReactNode;
|
|
19
|
+
/** Rendered next to the user name (e.g. plan badge, role chip) */
|
|
20
|
+
headerBadge?: React.ReactNode;
|
|
21
|
+
/** Rendered below the avatar row, above the tabs */
|
|
22
|
+
headerAfter?: React.ReactNode;
|
|
23
|
+
/** Rendered below all tab content */
|
|
24
|
+
footer?: React.ReactNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ProfileLayoutProps {
|
|
28
|
+
onUnauthenticated?: () => void;
|
|
29
|
+
title?: string;
|
|
30
|
+
enable2FA?: boolean;
|
|
31
|
+
enableAPIKeys?: boolean;
|
|
32
|
+
enableDeleteAccount?: boolean;
|
|
33
|
+
/** Extra tabs appended after built-in Profile / Security / API Keys tabs */
|
|
34
|
+
tabs?: ProfileTab[];
|
|
35
|
+
/** Named slots for additional content */
|
|
36
|
+
slots?: ProfileSlots;
|
|
37
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { type ReactNode } from 'react';
|
|
3
|
+
import React, { memo, type ReactNode } from 'react';
|
|
4
4
|
|
|
5
5
|
import { Link } from '@djangocfg/ui-core/components';
|
|
6
6
|
|
|
@@ -9,7 +9,7 @@ interface NavBrandProps {
|
|
|
9
9
|
brandHref?: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
function NavBrandRaw({ brand, brandHref = '/' }: NavBrandProps) {
|
|
13
13
|
if (brand == null || brand === '' || brand === false) return null;
|
|
14
14
|
|
|
15
15
|
if (typeof brand === 'string') {
|
|
@@ -25,3 +25,5 @@ export function NavBrand({ brand, brandHref = '/' }: NavBrandProps) {
|
|
|
25
25
|
|
|
26
26
|
return <>{brand}</>;
|
|
27
27
|
}
|
|
28
|
+
|
|
29
|
+
export const NavBrand = memo(NavBrandRaw);
|