@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.
Files changed (75) hide show
  1. package/package.json +17 -17
  2. package/src/layouts/AdminLayout/AdminLayout.tsx +2 -1
  3. package/src/layouts/AppLayout/AppLayout.tsx +35 -15
  4. package/src/layouts/AppLayout/BaseApp.tsx +2 -2
  5. package/src/layouts/AuthLayout/AuthLayout.tsx +26 -19
  6. package/src/layouts/AuthLayout/components/oauth/OAuthCallback.tsx +10 -4
  7. package/src/layouts/AuthLayout/components/shared/AuthButton.tsx +11 -5
  8. package/src/layouts/AuthLayout/components/shared/AuthContainer.tsx +10 -10
  9. package/src/layouts/AuthLayout/components/shared/AuthDivider.tsx +11 -5
  10. package/src/layouts/AuthLayout/components/shared/AuthError.tsx +10 -5
  11. package/src/layouts/AuthLayout/components/shared/AuthFooter.tsx +11 -5
  12. package/src/layouts/AuthLayout/components/shared/AuthHeader.tsx +10 -10
  13. package/src/layouts/AuthLayout/components/shared/AuthLink.tsx +11 -5
  14. package/src/layouts/AuthLayout/components/shared/AuthOTPInput.tsx +28 -20
  15. package/src/layouts/AuthLayout/components/shared/TermsCheckbox.tsx +11 -5
  16. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +12 -4
  17. package/src/layouts/AuthLayout/components/steps/OTPStep.tsx +9 -4
  18. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupComplete.tsx +12 -5
  19. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupLoading.tsx +9 -4
  20. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupQRCode.tsx +11 -5
  21. package/src/layouts/AuthLayout/components/steps/SetupStep/index.tsx +15 -5
  22. package/src/layouts/AuthLayout/components/steps/TwoFactorStep.tsx +9 -4
  23. package/src/layouts/AuthLayout/context.tsx +35 -13
  24. package/src/layouts/AuthLayout/shells/AuthShell.tsx +11 -4
  25. package/src/layouts/AuthLayout/shells/CenteredShell.tsx +10 -4
  26. package/src/layouts/AuthLayout/shells/SplitShell.tsx +10 -4
  27. package/src/layouts/AuthLayout/shells/context.tsx +16 -5
  28. package/src/layouts/PrivateLayout/PrivateLayout.tsx +32 -247
  29. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +115 -426
  30. package/src/layouts/{_components → PrivateLayout/components}/PrivateSidebarAccount.tsx +40 -19
  31. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +165 -0
  32. package/src/layouts/{_components → PrivateLayout/components}/SidebarFeatured.tsx +2 -2
  33. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +189 -0
  34. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +137 -0
  35. package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +71 -0
  36. package/src/layouts/PrivateLayout/components/index.ts +4 -0
  37. package/src/layouts/PrivateLayout/context.tsx +211 -0
  38. package/src/layouts/PrivateLayout/density.ts +48 -0
  39. package/src/layouts/PrivateLayout/hooks/index.ts +13 -0
  40. package/src/layouts/PrivateLayout/hooks/useAuthGuard.ts +54 -0
  41. package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +103 -0
  42. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +113 -0
  43. package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +207 -0
  44. package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +115 -0
  45. package/src/layouts/PrivateLayout/index.ts +2 -2
  46. package/src/layouts/PrivateLayout/types.ts +187 -0
  47. package/src/layouts/ProfileLayout/ProfileLayout.tsx +44 -183
  48. package/src/layouts/ProfileLayout/README.md +58 -0
  49. package/src/layouts/ProfileLayout/components/ApiKeySection/ApiKeySection.tsx +197 -0
  50. package/src/layouts/ProfileLayout/components/ApiKeySection/context.tsx +159 -0
  51. package/src/layouts/ProfileLayout/components/ApiKeySection/index.ts +3 -0
  52. package/src/layouts/ProfileLayout/components/ProfileHeader.tsx +110 -0
  53. package/src/layouts/ProfileLayout/components/ProfileTab.tsx +29 -0
  54. package/src/layouts/ProfileLayout/components/{TwoFactorSection.tsx → TwoFactorSection/TwoFactorSection.tsx} +1 -1
  55. package/src/layouts/ProfileLayout/components/TwoFactorSection/index.ts +1 -0
  56. package/src/layouts/ProfileLayout/components/index.ts +4 -2
  57. package/src/layouts/ProfileLayout/context.tsx +4 -6
  58. package/src/layouts/ProfileLayout/hooks/index.ts +2 -0
  59. package/src/layouts/ProfileLayout/hooks/useProfileTabs.ts +43 -0
  60. package/src/layouts/ProfileLayout/index.ts +6 -3
  61. package/src/layouts/ProfileLayout/types.ts +37 -0
  62. package/src/layouts/{_components → PublicLayout/components}/UserMenu.tsx +3 -3
  63. package/src/layouts/PublicLayout/components/index.ts +4 -0
  64. package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +12 -2
  65. package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalNavbar.tsx +1 -1
  66. package/src/layouts/PublicLayout/primitives/NavActions.tsx +44 -3
  67. package/src/layouts/PublicLayout/primitives/NavBrand.tsx +4 -2
  68. package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +42 -2
  69. package/src/layouts/PublicLayout/shared/MobileDrawerShell.tsx +1 -1
  70. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +60 -1
  71. package/src/layouts/_components/index.ts +2 -7
  72. package/src/layouts/index.ts +9 -4
  73. package/src/layouts/ProfileLayout/__tests__/TwoFactorSection.test.tsx +0 -234
  74. package/src/layouts/ProfileLayout/components/ProfileForm.tsx +0 -198
  75. /package/src/layouts/{_components → PublicLayout/components}/UserAvatar.tsx +0 -0
@@ -1,18 +1,10 @@
1
1
  'use client';
2
2
 
3
- import { LogOut, MoreHorizontal, Trash2 } from 'lucide-react';
4
- import moment from 'moment';
5
- import React, { useCallback, useEffect } from 'react';
3
+ import React, { useEffect } from 'react';
6
4
 
7
- import { useAuth, useDeleteAccount } from '@djangocfg/api/auth';
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
- // Slot + Tab types (public API)
22
+ // Built-in tab panels
33
23
  // ─────────────────────────────────────────────────────────────────────────────
34
24
 
35
- export interface ProfileTab {
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="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-6 pt-4">
169
- <Section title={labels.personalInfo}>
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
- <TwoFactorSection />
36
+ <ApiKeySection />
191
37
  </div>
192
38
  );
193
39
  }
194
40
 
195
41
  // ─────────────────────────────────────────────────────────────────────────────
196
- // Main content
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((tab) => (
231
- <TabsTrigger key={tab.value} value={tab.value}>
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
- const extraPanels = tabs.map((tab) => (
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 defaultValue="profile" className="mt-2">
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
- <TabProfile />
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
- // Router + Export
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
+ );