@djangocfg/layouts 2.1.356 → 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,18 +1,10 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import moment from 'moment';
|
|
5
|
-
import React, { useCallback, useEffect } from 'react';
|
|
3
|
+
import React, { useEffect } from 'react';
|
|
6
4
|
|
|
7
|
-
import { useAuth
|
|
5
|
+
import { useAuth } from '@djangocfg/api/auth';
|
|
8
6
|
import { useAppT } from '@djangocfg/i18n';
|
|
9
7
|
import {
|
|
10
|
-
Button,
|
|
11
|
-
DropdownMenu,
|
|
12
|
-
DropdownMenuContent,
|
|
13
|
-
DropdownMenuItem,
|
|
14
|
-
DropdownMenuSeparator,
|
|
15
|
-
DropdownMenuTrigger,
|
|
16
8
|
Preloader,
|
|
17
9
|
Tabs,
|
|
18
10
|
TabsContent,
|
|
@@ -20,185 +12,40 @@ import {
|
|
|
20
12
|
TabsTrigger,
|
|
21
13
|
} from '@djangocfg/ui-core/components';
|
|
22
14
|
|
|
23
|
-
import {
|
|
24
|
-
AvatarSection,
|
|
25
|
-
EditableField,
|
|
26
|
-
Section,
|
|
27
|
-
TwoFactorSection,
|
|
28
|
-
} from './components';
|
|
15
|
+
import { ApiKeySection, ProfileHeader, ProfileTab, TwoFactorSection } from './components';
|
|
29
16
|
import { ProfileProvider, useProfileContext } from './context';
|
|
17
|
+
import { useProfileTabs } from './hooks/useProfileTabs';
|
|
18
|
+
import type { ProfileLayoutProps } from './types';
|
|
19
|
+
import type { ProfileTabValue } from './hooks/useProfileTabs';
|
|
30
20
|
|
|
31
21
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
-
//
|
|
22
|
+
// Built-in tab panels
|
|
33
23
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
24
|
|
|
35
|
-
|
|
36
|
-
/** Unique key, used as Tabs value */
|
|
37
|
-
value: string;
|
|
38
|
-
/** Trigger label */
|
|
39
|
-
label: React.ReactNode;
|
|
40
|
-
/** Tab panel content */
|
|
41
|
-
content: React.ReactNode;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface ProfileSlots {
|
|
45
|
-
/** Extra items rendered inside the ⋯ dropdown, above the separator before Delete */
|
|
46
|
-
headerMenuItems?: React.ReactNode;
|
|
47
|
-
/** Rendered next to the user name (e.g. plan badge, role chip) */
|
|
48
|
-
headerBadge?: React.ReactNode;
|
|
49
|
-
/** Rendered below the avatar row, above the tabs */
|
|
50
|
-
headerAfter?: React.ReactNode;
|
|
51
|
-
/** Rendered below all tab content */
|
|
52
|
-
footer?: React.ReactNode;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export interface ProfileLayoutProps {
|
|
56
|
-
onUnauthenticated?: () => void;
|
|
57
|
-
title?: string;
|
|
58
|
-
enable2FA?: boolean;
|
|
59
|
-
enableDeleteAccount?: boolean;
|
|
60
|
-
/** Extra tabs appended after built-in Profile / Security tabs */
|
|
61
|
-
tabs?: ProfileTab[];
|
|
62
|
-
/** Named slots for additional content */
|
|
63
|
-
slots?: ProfileSlots;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
-
// Header
|
|
68
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
-
|
|
70
|
-
function ProfileHeader({ slots, enableDeleteAccount }: {
|
|
71
|
-
slots?: ProfileSlots;
|
|
72
|
-
enableDeleteAccount?: boolean;
|
|
73
|
-
}) {
|
|
74
|
-
const { labels, onLogout } = useProfileContext();
|
|
75
|
-
const { user, logout } = useAuth();
|
|
76
|
-
const { deleteAccount } = useDeleteAccount();
|
|
77
|
-
const t = useAppT();
|
|
78
|
-
|
|
79
|
-
const handleDeleteAccount = useCallback(async () => {
|
|
80
|
-
const confirmationWord = t('layouts.profilePage.confirmationWord');
|
|
81
|
-
const value = await window.dialog.prompt({
|
|
82
|
-
title: t('layouts.profilePage.deleteAccountTitle'),
|
|
83
|
-
message: t('layouts.profilePage.deleteAccountDesc'),
|
|
84
|
-
placeholder: confirmationWord,
|
|
85
|
-
confirmText: t('layouts.profilePage.deleteAccount'),
|
|
86
|
-
cancelText: t('layouts.profilePage.cancel'),
|
|
87
|
-
variant: 'destructive',
|
|
88
|
-
});
|
|
89
|
-
if (value?.toUpperCase() !== confirmationWord.toUpperCase()) return;
|
|
90
|
-
const result = await deleteAccount();
|
|
91
|
-
if (result.success) logout();
|
|
92
|
-
}, [t, deleteAccount, logout]);
|
|
93
|
-
|
|
94
|
-
if (!user) return null;
|
|
95
|
-
|
|
96
|
-
const displayName = user.full_name || user.display_username || user.email;
|
|
97
|
-
const memberSince = user.date_joined
|
|
98
|
-
? moment.utc(user.date_joined).local().format('MMMM YYYY')
|
|
99
|
-
: null;
|
|
100
|
-
|
|
101
|
-
const badge = slots?.headerBadge ?? null;
|
|
102
|
-
const menuItems = slots?.headerMenuItems ?? null;
|
|
103
|
-
const headerAfter = slots?.headerAfter ?? null;
|
|
104
|
-
|
|
105
|
-
return (
|
|
106
|
-
<div className="pb-4 md:pb-6 border-b mb-2">
|
|
107
|
-
<div className="flex items-center gap-3 md:gap-4">
|
|
108
|
-
<AvatarSection />
|
|
109
|
-
|
|
110
|
-
<div className="flex-1 min-w-0">
|
|
111
|
-
<div className="flex items-center gap-2 flex-wrap">
|
|
112
|
-
<h1 className="text-lg md:text-xl font-semibold truncate">{displayName}</h1>
|
|
113
|
-
{badge}
|
|
114
|
-
</div>
|
|
115
|
-
<p className="text-sm text-muted-foreground truncate">{user.email}</p>
|
|
116
|
-
{memberSince && (
|
|
117
|
-
<p className="text-xs text-muted-foreground/60 mt-0.5">
|
|
118
|
-
Member since {memberSince}
|
|
119
|
-
</p>
|
|
120
|
-
)}
|
|
121
|
-
</div>
|
|
122
|
-
|
|
123
|
-
<DropdownMenu>
|
|
124
|
-
<DropdownMenuTrigger asChild>
|
|
125
|
-
<Button variant="ghost" size="icon" className="flex-shrink-0 rounded-full">
|
|
126
|
-
<MoreHorizontal className="w-4 h-4" />
|
|
127
|
-
</Button>
|
|
128
|
-
</DropdownMenuTrigger>
|
|
129
|
-
<DropdownMenuContent align="end" className="w-48">
|
|
130
|
-
<DropdownMenuItem onClick={onLogout} className="gap-2">
|
|
131
|
-
<LogOut className="w-4 h-4" />
|
|
132
|
-
{labels.signOut}
|
|
133
|
-
</DropdownMenuItem>
|
|
134
|
-
|
|
135
|
-
{menuItems && <><DropdownMenuSeparator />{menuItems}</>}
|
|
136
|
-
|
|
137
|
-
{enableDeleteAccount && (
|
|
138
|
-
<>
|
|
139
|
-
<DropdownMenuSeparator />
|
|
140
|
-
<DropdownMenuItem
|
|
141
|
-
onClick={handleDeleteAccount}
|
|
142
|
-
className="gap-2 text-destructive focus:text-destructive"
|
|
143
|
-
>
|
|
144
|
-
<Trash2 className="w-4 h-4" />
|
|
145
|
-
{labels.deleteAccount}
|
|
146
|
-
</DropdownMenuItem>
|
|
147
|
-
</>
|
|
148
|
-
)}
|
|
149
|
-
</DropdownMenuContent>
|
|
150
|
-
</DropdownMenu>
|
|
151
|
-
</div>
|
|
152
|
-
|
|
153
|
-
{headerAfter && <div className="mt-4">{headerAfter}</div>}
|
|
154
|
-
</div>
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
159
|
-
// Built-in tab: Profile
|
|
160
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
161
|
-
|
|
162
|
-
function TabProfile() {
|
|
163
|
-
const { labels, onFieldSave } = useProfileContext();
|
|
164
|
-
const { user } = useAuth();
|
|
165
|
-
if (!user) return null;
|
|
166
|
-
|
|
25
|
+
function TabSecurity() {
|
|
167
26
|
return (
|
|
168
|
-
<div className="
|
|
169
|
-
<
|
|
170
|
-
<EditableField label={labels.firstName} value={user.first_name || ''} placeholder={labels.addFirstName} onSave={(v) => onFieldSave('first_name', v)} />
|
|
171
|
-
<EditableField label={labels.lastName} value={user.last_name || ''} placeholder={labels.addLastName} onSave={(v) => onFieldSave('last_name', v)} />
|
|
172
|
-
<EditableField label={labels.phone} value={user.phone || ''} placeholder={labels.addPhone} onSave={(v) => onFieldSave('phone', v)} type="phone" />
|
|
173
|
-
</Section>
|
|
174
|
-
|
|
175
|
-
<Section title={labels.work}>
|
|
176
|
-
<EditableField label={labels.company} value={user.company || ''} placeholder={labels.addCompany} onSave={(v) => onFieldSave('company', v)} />
|
|
177
|
-
<EditableField label={labels.position} value={user.position || ''} placeholder={labels.addPosition} onSave={(v) => onFieldSave('position', v)} />
|
|
178
|
-
</Section>
|
|
27
|
+
<div className="pt-4 space-y-4">
|
|
28
|
+
<TwoFactorSection />
|
|
179
29
|
</div>
|
|
180
30
|
);
|
|
181
31
|
}
|
|
182
32
|
|
|
183
|
-
|
|
184
|
-
// Built-in tab: Security
|
|
185
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
186
|
-
|
|
187
|
-
function TabSecurity() {
|
|
33
|
+
function TabApiKeys() {
|
|
188
34
|
return (
|
|
189
35
|
<div className="pt-4 space-y-4">
|
|
190
|
-
<
|
|
36
|
+
<ApiKeySection />
|
|
191
37
|
</div>
|
|
192
38
|
);
|
|
193
39
|
}
|
|
194
40
|
|
|
195
41
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
196
|
-
//
|
|
42
|
+
// Content (inside ProfileProvider)
|
|
197
43
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
198
44
|
|
|
199
45
|
function ProfileContent({
|
|
200
46
|
onUnauthenticated,
|
|
201
47
|
enable2FA,
|
|
48
|
+
enableAPIKeys = true,
|
|
202
49
|
enableDeleteAccount = true,
|
|
203
50
|
tabs = [],
|
|
204
51
|
slots,
|
|
@@ -206,6 +53,18 @@ function ProfileContent({
|
|
|
206
53
|
const { labels } = useProfileContext();
|
|
207
54
|
const { user, isLoading } = useAuth();
|
|
208
55
|
|
|
56
|
+
const extraTabValues = React.useMemo(() => tabs.map((t) => t.value), [tabs]);
|
|
57
|
+
const { tab, setTab } = useProfileTabs({
|
|
58
|
+
enable2FA,
|
|
59
|
+
enableAPIKeys,
|
|
60
|
+
extraTabValues,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const handleTabChange = React.useCallback(
|
|
64
|
+
(value: string) => setTab(value as ProfileTabValue),
|
|
65
|
+
[setTab],
|
|
66
|
+
);
|
|
67
|
+
|
|
209
68
|
useEffect(() => {
|
|
210
69
|
if (onUnauthenticated && !user && !isLoading) onUnauthenticated();
|
|
211
70
|
}, [onUnauthenticated, user, isLoading]);
|
|
@@ -225,38 +84,32 @@ function ProfileContent({
|
|
|
225
84
|
);
|
|
226
85
|
}
|
|
227
86
|
|
|
228
|
-
// ── Prepare data before render
|
|
87
|
+
// ── Prepare data before render ──
|
|
229
88
|
|
|
230
|
-
const extraTriggers = tabs.map((
|
|
231
|
-
<TabsTrigger key={
|
|
232
|
-
{tab.label}
|
|
233
|
-
</TabsTrigger>
|
|
89
|
+
const extraTriggers = tabs.map((t) => (
|
|
90
|
+
<TabsTrigger key={t.value} value={t.value}>{t.label}</TabsTrigger>
|
|
234
91
|
));
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
<TabsContent key={tab.value} value={tab.value}>
|
|
238
|
-
{tab.content}
|
|
239
|
-
</TabsContent>
|
|
92
|
+
const extraPanels = tabs.map((t) => (
|
|
93
|
+
<TabsContent key={t.value} value={t.value}>{t.content}</TabsContent>
|
|
240
94
|
));
|
|
241
|
-
|
|
242
95
|
const footer = slots?.footer ?? null;
|
|
243
96
|
|
|
244
|
-
// ── Render
|
|
97
|
+
// ── Render ──
|
|
245
98
|
|
|
246
99
|
return (
|
|
247
100
|
<div className="container mx-auto px-4 py-6 md:py-10 max-w-3xl">
|
|
248
101
|
<ProfileHeader slots={slots} enableDeleteAccount={enableDeleteAccount} />
|
|
249
102
|
|
|
250
|
-
<Tabs
|
|
251
|
-
{/* Underline-style scrollable tabs — mobile friendly */}
|
|
103
|
+
<Tabs value={tab} onValueChange={handleTabChange} className="mt-2">
|
|
252
104
|
<TabsList variant="underline" scrollable>
|
|
253
105
|
<TabsTrigger value="profile">{labels.personalInfo}</TabsTrigger>
|
|
254
106
|
{enable2FA && <TabsTrigger value="security">{labels.security}</TabsTrigger>}
|
|
107
|
+
{enableAPIKeys && <TabsTrigger value="api-keys">{labels.apiKeys}</TabsTrigger>}
|
|
255
108
|
{extraTriggers}
|
|
256
109
|
</TabsList>
|
|
257
110
|
|
|
258
111
|
<TabsContent value="profile">
|
|
259
|
-
<
|
|
112
|
+
<ProfileTab />
|
|
260
113
|
</TabsContent>
|
|
261
114
|
|
|
262
115
|
{enable2FA && (
|
|
@@ -265,6 +118,12 @@ function ProfileContent({
|
|
|
265
118
|
</TabsContent>
|
|
266
119
|
)}
|
|
267
120
|
|
|
121
|
+
{enableAPIKeys && (
|
|
122
|
+
<TabsContent value="api-keys">
|
|
123
|
+
<TabApiKeys />
|
|
124
|
+
</TabsContent>
|
|
125
|
+
)}
|
|
126
|
+
|
|
268
127
|
{extraPanels}
|
|
269
128
|
</Tabs>
|
|
270
129
|
|
|
@@ -274,7 +133,7 @@ function ProfileContent({
|
|
|
274
133
|
}
|
|
275
134
|
|
|
276
135
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
277
|
-
//
|
|
136
|
+
// Export
|
|
278
137
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
279
138
|
|
|
280
139
|
export const ProfileLayout: React.FC<ProfileLayoutProps> = ({ title, ...props }) => (
|
|
@@ -282,3 +141,5 @@ export const ProfileLayout: React.FC<ProfileLayoutProps> = ({ title, ...props })
|
|
|
282
141
|
<ProfileContent title={title} {...props} />
|
|
283
142
|
</ProfileProvider>
|
|
284
143
|
);
|
|
144
|
+
|
|
145
|
+
export { useProfileContext } from './context';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# ProfileLayout
|
|
2
|
+
|
|
3
|
+
User profile page with tabbed interface: **Profile** | **Security** | **API Keys**.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import { ProfileLayout } from '@djangocfg/layouts';
|
|
9
|
+
|
|
10
|
+
<ProfileLayout
|
|
11
|
+
enable2FA
|
|
12
|
+
enableAPIKeys
|
|
13
|
+
enableDeleteAccount
|
|
14
|
+
onUnauthenticated={() => router.push('/login')}
|
|
15
|
+
slots={{ headerBadge: <PlanBadge /> }}
|
|
16
|
+
tabs={[
|
|
17
|
+
{ value: 'billing', label: 'Billing', content: <BillingPanel /> },
|
|
18
|
+
]}
|
|
19
|
+
/>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Props
|
|
23
|
+
|
|
24
|
+
| Prop | Default | Description |
|
|
25
|
+
|------|---------|-------------|
|
|
26
|
+
| `enable2FA` | `false` | Show Security tab with 2FA management |
|
|
27
|
+
| `enableAPIKeys` | `true` | Show API Keys tab |
|
|
28
|
+
| `enableDeleteAccount` | `true` | Show Delete Account in header dropdown |
|
|
29
|
+
| `onUnauthenticated` | — | Callback when user is not authenticated |
|
|
30
|
+
| `tabs` | `[]` | Extra `ProfileTab[]` appended after built-in tabs |
|
|
31
|
+
| `slots` | — | Named slots: `headerMenuItems`, `headerBadge`, `headerAfter`, `footer` |
|
|
32
|
+
| `title` | i18n | Page title |
|
|
33
|
+
|
|
34
|
+
## Architecture
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
ProfileLayout/
|
|
38
|
+
├── ProfileLayout.tsx Shell: ProfileProvider → header + Tabs
|
|
39
|
+
├── context.tsx Root context (labels, onLogout, onFieldSave)
|
|
40
|
+
├── types.ts ProfileLayoutProps, ProfileTab, ProfileSlots
|
|
41
|
+
└── components/
|
|
42
|
+
├── ProfileHeader Avatar + name + dropdown menu
|
|
43
|
+
├── ProfileTab Editable fields grid (first_name, last_name, phone, company, position)
|
|
44
|
+
├── TwoFactorSection/ Own mini-context (2FA status, setup, disable)
|
|
45
|
+
├── ApiKeySection/ Own mini-context (useApiKey, reveal/arm state)
|
|
46
|
+
├── EditableField Inline-editable text/phone field
|
|
47
|
+
├── Section Card-like section wrapper
|
|
48
|
+
└── DeleteAccount Confirmation dialog + delete action
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Each section (`TwoFactorSection`, `ApiKeySection`) owns a **mini-context** — isolated state, labels, and API logic. Root context stays minimal.
|
|
52
|
+
|
|
53
|
+
## Dependencies
|
|
54
|
+
|
|
55
|
+
- `@djangocfg/api/hooks` — `useApiKey` (retrieve + regenerate)
|
|
56
|
+
- `@djangocfg/api/auth` — `useAuth`, `useDeleteAccount`, `useTwoFactorSetup`, `useTwoFactorStatus`
|
|
57
|
+
- `@djangocfg/ui-core/components` — UI primitives + `CopyButton`
|
|
58
|
+
- `@djangocfg/i18n` — `useAppT`
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Check, FlaskConical, KeyRound, Loader2, RefreshCw } from 'lucide-react';
|
|
4
|
+
import moment from 'moment';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
Badge,
|
|
9
|
+
Button,
|
|
10
|
+
Card,
|
|
11
|
+
CardContent,
|
|
12
|
+
CardDescription,
|
|
13
|
+
CardHeader,
|
|
14
|
+
CardTitle,
|
|
15
|
+
CopyButton,
|
|
16
|
+
} from '@djangocfg/ui-core/components';
|
|
17
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
18
|
+
|
|
19
|
+
import { ApiKeyProvider, useApiKeyContext } from './context';
|
|
20
|
+
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
// Helpers
|
|
23
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function formatDate(iso: string | null, fallback: string): string {
|
|
26
|
+
if (!iso) return fallback;
|
|
27
|
+
return moment.utc(iso).local().format('MMM D, YYYY');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Returns true if the backend already masked the key (contains •). */
|
|
31
|
+
function isMasked(key: string): boolean {
|
|
32
|
+
return key.includes('•');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
// Inner component (uses context)
|
|
37
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function ApiKeyCard() {
|
|
40
|
+
const {
|
|
41
|
+
labels,
|
|
42
|
+
apiKey,
|
|
43
|
+
reissuedAt,
|
|
44
|
+
createdAt,
|
|
45
|
+
isLoading,
|
|
46
|
+
error,
|
|
47
|
+
isArmed,
|
|
48
|
+
arm,
|
|
49
|
+
disarm,
|
|
50
|
+
regenerate,
|
|
51
|
+
isRegenerating,
|
|
52
|
+
isFresh,
|
|
53
|
+
dismissFresh,
|
|
54
|
+
testKey,
|
|
55
|
+
isTesting,
|
|
56
|
+
testResult,
|
|
57
|
+
} = useApiKeyContext();
|
|
58
|
+
|
|
59
|
+
if (isLoading) {
|
|
60
|
+
return (
|
|
61
|
+
<Card>
|
|
62
|
+
<CardContent className="flex items-center justify-center py-10">
|
|
63
|
+
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
|
64
|
+
</CardContent>
|
|
65
|
+
</Card>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const masked = apiKey ? isMasked(apiKey) : false;
|
|
70
|
+
const displayKey = apiKey ?? '—';
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<Card>
|
|
74
|
+
<CardHeader>
|
|
75
|
+
<div className="flex items-center gap-3">
|
|
76
|
+
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
|
77
|
+
<KeyRound className="w-5 h-5 text-primary" />
|
|
78
|
+
</div>
|
|
79
|
+
<div className="flex-1 min-w-0">
|
|
80
|
+
<div className="flex items-center gap-2">
|
|
81
|
+
<CardTitle className="text-base">{labels.title}</CardTitle>
|
|
82
|
+
{apiKey && <Badge variant="secondary" className="text-xs">Active</Badge>}
|
|
83
|
+
</div>
|
|
84
|
+
<CardDescription className="mt-0.5">{labels.description}</CardDescription>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</CardHeader>
|
|
88
|
+
|
|
89
|
+
{apiKey && (
|
|
90
|
+
<CardContent className="pt-0 space-y-3">
|
|
91
|
+
{/* Fresh-key banner */}
|
|
92
|
+
{isFresh && (
|
|
93
|
+
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900 text-amber-800 dark:text-amber-200 text-sm">
|
|
94
|
+
<KeyRound className="w-4 h-4 flex-shrink-0" />
|
|
95
|
+
<span className="flex-1">This is your new API key — copy it now. It will be masked when you leave this page.</span>
|
|
96
|
+
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={dismissFresh}>
|
|
97
|
+
<Check className="w-4 h-4 mr-1" />
|
|
98
|
+
{labels.done}
|
|
99
|
+
</Button>
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
{/* Test result banner */}
|
|
104
|
+
{testResult !== null && (
|
|
105
|
+
<div className={cn(
|
|
106
|
+
'flex items-center gap-2 px-3 py-2 rounded-md text-sm',
|
|
107
|
+
testResult
|
|
108
|
+
? 'bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900 text-green-800 dark:text-green-200'
|
|
109
|
+
: 'bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 text-red-800 dark:text-red-200',
|
|
110
|
+
)}>
|
|
111
|
+
<FlaskConical className="w-4 h-4 flex-shrink-0" />
|
|
112
|
+
<span>{testResult ? labels.testSuccess : labels.testFailed}</span>
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
<div className="flex items-center gap-2">
|
|
117
|
+
<div className="flex-1 min-w-0 px-3 py-2.5 rounded-md bg-muted font-mono text-sm select-all">
|
|
118
|
+
<span className={cn(masked && 'tracking-widest')}>
|
|
119
|
+
{displayKey}
|
|
120
|
+
</span>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{/* Copy only when the key is fresh (full key after regenerate) */}
|
|
124
|
+
{isFresh && <CopyButton value={apiKey} />}
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
128
|
+
{createdAt && <span>{labels.created}: {formatDate(createdAt, '—')}</span>}
|
|
129
|
+
<span>{labels.reissued}: {formatDate(reissuedAt, labels.neverReissued)}</span>
|
|
130
|
+
</div>
|
|
131
|
+
</CardContent>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{error && (
|
|
135
|
+
<CardContent className="pt-0">
|
|
136
|
+
<p className="text-sm text-destructive">{error}</p>
|
|
137
|
+
</CardContent>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
<CardContent className="pt-0">
|
|
141
|
+
{isArmed ? (
|
|
142
|
+
<div className="flex items-center gap-2">
|
|
143
|
+
<Button
|
|
144
|
+
variant="destructive"
|
|
145
|
+
size="sm"
|
|
146
|
+
onClick={regenerate}
|
|
147
|
+
disabled={isRegenerating}
|
|
148
|
+
>
|
|
149
|
+
{isRegenerating
|
|
150
|
+
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />{labels.regenerating}</>
|
|
151
|
+
: labels.confirmRegenerate}
|
|
152
|
+
</Button>
|
|
153
|
+
<Button variant="ghost" size="sm" onClick={disarm} disabled={isRegenerating}>
|
|
154
|
+
Cancel
|
|
155
|
+
</Button>
|
|
156
|
+
</div>
|
|
157
|
+
) : (
|
|
158
|
+
<div className="flex items-center gap-2">
|
|
159
|
+
<Button
|
|
160
|
+
variant="outline"
|
|
161
|
+
size="sm"
|
|
162
|
+
onClick={arm}
|
|
163
|
+
disabled={!apiKey || isRegenerating}
|
|
164
|
+
>
|
|
165
|
+
<RefreshCw className="mr-2 h-4 w-4" />
|
|
166
|
+
{labels.regenerate}
|
|
167
|
+
</Button>
|
|
168
|
+
|
|
169
|
+
{/* Test button only when we have a fresh (full) key */}
|
|
170
|
+
{isFresh && (
|
|
171
|
+
<Button
|
|
172
|
+
variant="secondary"
|
|
173
|
+
size="sm"
|
|
174
|
+
onClick={testKey}
|
|
175
|
+
disabled={isTesting}
|
|
176
|
+
>
|
|
177
|
+
{isTesting
|
|
178
|
+
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />{labels.testing}</>
|
|
179
|
+
: <><FlaskConical className="mr-2 h-4 w-4" />{labels.test}</>}
|
|
180
|
+
</Button>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</CardContent>
|
|
185
|
+
</Card>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
190
|
+
// Export (wraps with provider)
|
|
191
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
export const ApiKeySection: React.FC = () => (
|
|
194
|
+
<ApiKeyProvider>
|
|
195
|
+
<ApiKeyCard />
|
|
196
|
+
</ApiKeyProvider>
|
|
197
|
+
);
|