@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.
Files changed (83) hide show
  1. package/package.json +21 -19
  2. package/src/configurator/private/schema.ts +12 -0
  3. package/src/layouts/AdminLayout/AdminLayout.tsx +2 -1
  4. package/src/layouts/AppLayout/AppLayout.tsx +35 -15
  5. package/src/layouts/AppLayout/BaseApp.tsx +2 -2
  6. package/src/layouts/AuthLayout/AuthLayout.tsx +26 -19
  7. package/src/layouts/AuthLayout/components/oauth/OAuthCallback.tsx +10 -4
  8. package/src/layouts/AuthLayout/components/shared/AuthButton.tsx +11 -5
  9. package/src/layouts/AuthLayout/components/shared/AuthContainer.tsx +10 -10
  10. package/src/layouts/AuthLayout/components/shared/AuthDivider.tsx +11 -5
  11. package/src/layouts/AuthLayout/components/shared/AuthError.tsx +10 -5
  12. package/src/layouts/AuthLayout/components/shared/AuthFooter.tsx +11 -5
  13. package/src/layouts/AuthLayout/components/shared/AuthHeader.tsx +10 -10
  14. package/src/layouts/AuthLayout/components/shared/AuthLink.tsx +11 -5
  15. package/src/layouts/AuthLayout/components/shared/AuthOTPInput.tsx +28 -20
  16. package/src/layouts/AuthLayout/components/shared/TermsCheckbox.tsx +11 -5
  17. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +12 -4
  18. package/src/layouts/AuthLayout/components/steps/OTPStep.tsx +9 -4
  19. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupComplete.tsx +12 -5
  20. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupLoading.tsx +9 -4
  21. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupQRCode.tsx +11 -5
  22. package/src/layouts/AuthLayout/components/steps/SetupStep/index.tsx +15 -5
  23. package/src/layouts/AuthLayout/components/steps/TwoFactorStep.tsx +9 -4
  24. package/src/layouts/AuthLayout/context.tsx +35 -13
  25. package/src/layouts/AuthLayout/shells/AuthShell.tsx +11 -4
  26. package/src/layouts/AuthLayout/shells/CenteredShell.tsx +10 -4
  27. package/src/layouts/AuthLayout/shells/SplitShell.tsx +10 -4
  28. package/src/layouts/AuthLayout/shells/context.tsx +16 -5
  29. package/src/layouts/PrivateLayout/PrivateLayout.tsx +45 -248
  30. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +113 -430
  31. package/src/layouts/{_components → PrivateLayout/components}/PrivateSidebarAccount.tsx +82 -105
  32. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +168 -0
  33. package/src/layouts/{_components → PrivateLayout/components}/SidebarFeatured.tsx +2 -2
  34. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +189 -0
  35. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +137 -0
  36. package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +71 -0
  37. package/src/layouts/PrivateLayout/components/index.ts +4 -0
  38. package/src/layouts/PrivateLayout/context.tsx +211 -0
  39. package/src/layouts/PrivateLayout/density.ts +48 -0
  40. package/src/layouts/PrivateLayout/hooks/index.ts +14 -0
  41. package/src/layouts/PrivateLayout/hooks/useAuthGuard.ts +54 -0
  42. package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +110 -0
  43. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +113 -0
  44. package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +207 -0
  45. package/src/layouts/PrivateLayout/hooks/useSidebarDefaultOpen.ts +21 -0
  46. package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +115 -0
  47. package/src/layouts/PrivateLayout/index.ts +2 -2
  48. package/src/layouts/PrivateLayout/types.ts +193 -0
  49. package/src/layouts/ProfileLayout/ProfileDialog/ProfileDialog.tsx +32 -0
  50. package/src/layouts/ProfileLayout/ProfileDialog/index.ts +2 -0
  51. package/src/layouts/ProfileLayout/ProfileDialog/store.ts +19 -0
  52. package/src/layouts/ProfileLayout/{context.tsx → ProfileForm/context.tsx} +8 -8
  53. package/src/layouts/ProfileLayout/ProfileForm/index.tsx +148 -0
  54. package/src/layouts/ProfileLayout/README.md +118 -0
  55. package/src/layouts/ProfileLayout/components/ApiKeySection/ApiKeySection.tsx +197 -0
  56. package/src/layouts/ProfileLayout/components/ApiKeySection/context.tsx +159 -0
  57. package/src/layouts/ProfileLayout/components/ApiKeySection/index.ts +3 -0
  58. package/src/layouts/ProfileLayout/components/EditableField.tsx +1 -1
  59. package/src/layouts/ProfileLayout/components/PreferencesSection.tsx +56 -0
  60. package/src/layouts/ProfileLayout/components/ProfileHeader.tsx +110 -0
  61. package/src/layouts/ProfileLayout/components/ProfileTab.tsx +35 -0
  62. package/src/layouts/ProfileLayout/components/{TwoFactorSection.tsx → TwoFactorSection/TwoFactorSection.tsx} +1 -1
  63. package/src/layouts/ProfileLayout/components/TwoFactorSection/index.ts +1 -0
  64. package/src/layouts/ProfileLayout/components/index.ts +5 -2
  65. package/src/layouts/ProfileLayout/hooks/index.ts +2 -0
  66. package/src/layouts/ProfileLayout/hooks/useProfileTabs.ts +48 -0
  67. package/src/layouts/ProfileLayout/index.ts +7 -3
  68. package/src/layouts/ProfileLayout/types.ts +47 -0
  69. package/src/layouts/{_components → PublicLayout/components}/UserMenu.tsx +3 -3
  70. package/src/layouts/PublicLayout/components/index.ts +4 -0
  71. package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +12 -2
  72. package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalNavbar.tsx +1 -1
  73. package/src/layouts/PublicLayout/primitives/NavActions.tsx +44 -3
  74. package/src/layouts/PublicLayout/primitives/NavBrand.tsx +4 -2
  75. package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +42 -2
  76. package/src/layouts/PublicLayout/shared/MobileDrawerShell.tsx +1 -1
  77. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +60 -1
  78. package/src/layouts/_components/index.ts +2 -6
  79. package/src/layouts/index.ts +9 -4
  80. package/src/layouts/ProfileLayout/ProfileLayout.tsx +0 -284
  81. package/src/layouts/ProfileLayout/__tests__/TwoFactorSection.test.tsx +0 -234
  82. package/src/layouts/ProfileLayout/components/ProfileForm.tsx +0 -198
  83. /package/src/layouts/{_components → PublicLayout/components}/UserAvatar.tsx +0 -0
@@ -0,0 +1,32 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from '@djangocfg/ui-core/components';
11
+
12
+ import { ProfileForm } from '../ProfileForm';
13
+ import { useProfileDialogStore } from './store';
14
+
15
+ export interface ProfileDialogProps {
16
+ title?: string;
17
+ }
18
+
19
+ export const ProfileDialog: React.FC<ProfileDialogProps> = ({ title }) => {
20
+ const { isOpen, close, initialTab } = useProfileDialogStore();
21
+
22
+ return (
23
+ <Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
24
+ <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto p-0">
25
+ <DialogHeader className="sr-only">
26
+ <DialogTitle>Profile</DialogTitle>
27
+ </DialogHeader>
28
+ <ProfileForm title={title} defaultTab={initialTab} />
29
+ </DialogContent>
30
+ </Dialog>
31
+ );
32
+ };
@@ -0,0 +1,2 @@
1
+ export { ProfileDialog } from './ProfileDialog';
2
+ export { useProfileDialogStore } from './store';
@@ -0,0 +1,19 @@
1
+ import { create } from 'zustand';
2
+
3
+ import type { ProfileTabValue } from '../hooks/useProfileTabs';
4
+
5
+ interface ProfileDialogState {
6
+ isOpen: boolean;
7
+ initialTab: ProfileTabValue | undefined;
8
+ open: (options?: { initialTab?: ProfileTabValue }) => void;
9
+ close: () => void;
10
+ toggle: () => void;
11
+ }
12
+
13
+ export const useProfileDialogStore = create<ProfileDialogState>((set) => ({
14
+ isOpen: false,
15
+ initialTab: undefined,
16
+ open: (options) => set({ isOpen: true, initialTab: options?.initialTab }),
17
+ close: () => set({ isOpen: false, initialTab: undefined }),
18
+ toggle: () => set((state) => ({ isOpen: !state.isOpen })),
19
+ }));
@@ -2,12 +2,12 @@
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
- import { profileLogger } from '../../utils/logger';
10
- import { useLogout } from '../../hooks';
9
+ import { profileLogger } from '../../../utils/logger';
10
+ import { useLogout } from '../../../hooks';
11
11
 
12
12
  // ─────────────────────────────────────────────────────────────────────────────
13
13
  // Types
@@ -18,6 +18,8 @@ export interface ProfileLabels {
18
18
  personalInfo: string;
19
19
  work: string;
20
20
  security: string;
21
+ apiKeys: string;
22
+ preferences: string;
21
23
  firstName: string;
22
24
  lastName: string;
23
25
  phone: string;
@@ -65,13 +67,9 @@ export const useProfileContext = (): ProfileContextValue => {
65
67
  interface ProfileProviderProps {
66
68
  children: React.ReactNode;
67
69
  title?: string;
68
- onUnauthenticated?: () => void;
69
70
  }
70
71
 
71
- export const ProfileProvider: React.FC<ProfileProviderProps> = ({
72
- children,
73
- title,
74
- }) => {
72
+ export const ProfileProvider: React.FC<ProfileProviderProps> = ({ children, title }) => {
75
73
  const { updateProfile } = useAuth();
76
74
  const t = useAppT();
77
75
  const onLogout = useLogout();
@@ -81,6 +79,8 @@ export const ProfileProvider: React.FC<ProfileProviderProps> = ({
81
79
  personalInfo: t('layouts.profilePage.personalInfo'),
82
80
  work: t('layouts.profilePage.work'),
83
81
  security: t('layouts.profilePage.security'),
82
+ apiKeys: 'API Keys',
83
+ preferences: 'Preferences',
84
84
  firstName: t('layouts.profilePage.firstName'),
85
85
  lastName: t('layouts.profilePage.lastName'),
86
86
  phone: t('layouts.profilePage.phone'),
@@ -0,0 +1,148 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect } from 'react';
4
+
5
+ import { useAuth } from '@djangocfg/api/auth';
6
+ import { useAppT } from '@djangocfg/i18n';
7
+ import {
8
+ Preloader,
9
+ Tabs,
10
+ TabsContent,
11
+ TabsList,
12
+ TabsTrigger,
13
+ } from '@djangocfg/ui-core/components';
14
+
15
+ import { ApiKeySection, ProfileHeader, ProfileTab, TwoFactorSection } from '../components';
16
+ import { ProfileProvider, useProfileContext } from './context';
17
+ import { useProfileTabs } from '../hooks/useProfileTabs';
18
+ import type { ProfileFormProps } from '../types';
19
+ import type { ProfileTabValue } from '../hooks/useProfileTabs';
20
+
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+ // Built-in tab panels
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+
25
+ function TabSecurity() {
26
+ return (
27
+ <div className="pt-4 space-y-4">
28
+ <TwoFactorSection />
29
+ </div>
30
+ );
31
+ }
32
+
33
+ function TabApiKeys() {
34
+ return (
35
+ <div className="pt-4 space-y-4">
36
+ <ApiKeySection />
37
+ </div>
38
+ );
39
+ }
40
+
41
+ // ─────────────────────────────────────────────────────────────────────────────
42
+ // Content (inside ProfileProvider)
43
+ // ─────────────────────────────────────────────────────────────────────────────
44
+
45
+ function ProfileContent({
46
+ onUnauthenticated,
47
+ enable2FA,
48
+ enableAPIKeys = true,
49
+ enableDeleteAccount = true,
50
+ tabs = [],
51
+ slots,
52
+ defaultTab,
53
+ }: ProfileFormProps) {
54
+ const { labels } = useProfileContext();
55
+ const { user, isLoading } = useAuth();
56
+
57
+ const extraTabValues = React.useMemo(() => tabs.map((t) => t.value), [tabs]);
58
+
59
+ const { tab, setTab, allowed } = useProfileTabs({
60
+ enable2FA,
61
+ enableAPIKeys,
62
+ extraTabValues,
63
+ defaultTab,
64
+ });
65
+
66
+ const handleTabChange = React.useCallback(
67
+ (value: string) => setTab(value as ProfileTabValue),
68
+ [setTab],
69
+ );
70
+
71
+ useEffect(() => {
72
+ if (onUnauthenticated && !user && !isLoading) onUnauthenticated();
73
+ }, [onUnauthenticated, user, isLoading]);
74
+
75
+ if (isLoading) {
76
+ return <Preloader variant="fullscreen" text={labels.loading} size="lg" backdrop backdropOpacity={80} />;
77
+ }
78
+
79
+ if (!user) {
80
+ return (
81
+ <div className="flex items-center justify-center min-h-screen">
82
+ <div className="text-center">
83
+ <h1 className="text-2xl font-bold mb-4">{labels.notAuthenticated}</h1>
84
+ <p className="text-muted-foreground">{labels.pleaseLogIn}</p>
85
+ </div>
86
+ </div>
87
+ );
88
+ }
89
+
90
+ // ── Prepare data before render ──
91
+
92
+ const extraTriggers = tabs.map((t) => (
93
+ <TabsTrigger key={t.value} value={t.value}>{t.label}</TabsTrigger>
94
+ ));
95
+ const extraPanels = tabs.map((t) => (
96
+ <TabsContent key={t.value} value={t.value}>{t.content}</TabsContent>
97
+ ));
98
+ const footer = slots?.footer ?? null;
99
+
100
+ // ── Render ──
101
+
102
+ return (
103
+ <div className="container mx-auto px-4 py-6 md:py-10 max-w-3xl">
104
+ <ProfileHeader slots={slots} enableDeleteAccount={enableDeleteAccount} />
105
+
106
+ <Tabs value={tab} onValueChange={handleTabChange} className="mt-2">
107
+ <TabsList variant="underline" scrollable>
108
+ <TabsTrigger value="profile">{labels.personalInfo}</TabsTrigger>
109
+ {enable2FA && <TabsTrigger value="security">{labels.security}</TabsTrigger>}
110
+ {enableAPIKeys && <TabsTrigger value="api-keys">{labels.apiKeys}</TabsTrigger>}
111
+ {extraTriggers}
112
+ </TabsList>
113
+
114
+ <TabsContent value="profile">
115
+ <ProfileTab />
116
+ </TabsContent>
117
+
118
+ {enable2FA && (
119
+ <TabsContent value="security">
120
+ <TabSecurity />
121
+ </TabsContent>
122
+ )}
123
+
124
+ {enableAPIKeys && (
125
+ <TabsContent value="api-keys">
126
+ <TabApiKeys />
127
+ </TabsContent>
128
+ )}
129
+
130
+ {extraPanels}
131
+ </Tabs>
132
+
133
+ {footer && <div className="mt-8">{footer}</div>}
134
+ </div>
135
+ );
136
+ }
137
+
138
+ // ─────────────────────────────────────────────────────────────────────────────
139
+ // Export
140
+ // ─────────────────────────────────────────────────────────────────────────────
141
+
142
+ export const ProfileForm: React.FC<ProfileFormProps> = ({ title, ...props }) => (
143
+ <ProfileProvider title={title}>
144
+ <ProfileContent title={title} {...props} />
145
+ </ProfileProvider>
146
+ );
147
+
148
+ export { useProfileContext } from './context';
@@ -0,0 +1,118 @@
1
+ # ProfileLayout
2
+
3
+ User profile page with tabbed interface: **Profile** | **Security** | **API Keys**.
4
+
5
+ ## Usage
6
+
7
+ ### Standalone page
8
+
9
+ ```tsx
10
+ import { ProfileForm } from '@djangocfg/layouts';
11
+
12
+ <ProfileForm
13
+ enable2FA
14
+ enableAPIKeys
15
+ enableDeleteAccount
16
+ onUnauthenticated={() => router.push('/login')}
17
+ slots={{ headerBadge: <PlanBadge /> }}
18
+ tabs={[
19
+ { value: 'billing', label: 'Billing', content: <BillingPanel /> },
20
+ ]}
21
+ />
22
+ ```
23
+
24
+ ### Dialog
25
+
26
+ ```tsx
27
+ import { ProfileDialog, useProfileDialogStore } from '@djangocfg/layouts';
28
+
29
+ // Open on a specific tab
30
+ useProfileDialogStore.getState().open({ initialTab: 'security' });
31
+
32
+ // In your layout (lazy-loaded)
33
+ <ProfileDialog />
34
+ ```
35
+
36
+ ## Props
37
+
38
+ | Prop | Default | Description |
39
+ |------|---------|-------------|
40
+ | `enable2FA` | `false` | Show Security tab with 2FA management |
41
+ | `enableAPIKeys` | `true` | Show API Keys tab |
42
+ | `enableDeleteAccount` | `true` | Show Delete Account in header dropdown |
43
+ | `onUnauthenticated` | — | Callback when user is not authenticated |
44
+ | `tabs` | `[]` | Extra `ProfileTab[]` appended after built-in tabs |
45
+ | `slots` | — | Named slots: `headerMenuItems`, `headerBadge`, `headerAfter`, `footer` |
46
+ | `title` | i18n | Page title |
47
+ | `defaultTab` | — | Initial active tab. When provided (e.g. by `ProfileDialog`), tabs start at this value. |
48
+
49
+ ## Global Profile Dialog
50
+
51
+ `ProfileDialog` is a Zustand-driven dialog that can be opened from anywhere:
52
+
53
+ ```tsx
54
+ import { useProfileDialogStore } from '@djangocfg/layouts';
55
+
56
+ const { open, close } = useProfileDialogStore();
57
+
58
+ // Open on Profile tab
59
+ open();
60
+
61
+ // Open on Security tab
62
+ open({ initialTab: 'security' });
63
+ ```
64
+
65
+ Wire it into `PrivateLayout` for global access:
66
+
67
+ ```tsx
68
+ import { PrivateLayout } from '@djangocfg/layouts';
69
+
70
+ <PrivateLayout sidebar={...} header={...}>
71
+ {children}
72
+ </PrivateLayout>
73
+ // ProfileDialog is rendered automatically inside PrivateLayout
74
+ ```
75
+
76
+ ## Sidebar Account Button Modes
77
+
78
+ The footer account button in `PrivateLayout` supports two modes via `HeaderConfig`:
79
+
80
+ ```tsx
81
+ const header: HeaderConfig = {
82
+ // ...
83
+ accountAction: 'menu', // Default — opens DropdownMenu with links, theme, logout
84
+ accountAction: 'dialog', // Opens ProfileDialog instead
85
+ };
86
+ ```
87
+
88
+ ## Architecture
89
+
90
+ ```
91
+ ProfileLayout/
92
+ ├── ProfileForm/
93
+ │ ├── index.tsx Shell: ProfileProvider → header + Tabs
94
+ │ └── context.tsx Root context (labels, onLogout, onFieldSave)
95
+ ├── ProfileDialog/
96
+ │ ├── ProfileDialog.tsx Dialog wrapper around ProfileForm
97
+ │ └── store.ts Zustand store (isOpen, initialTab, open/close)
98
+ ├── hooks/
99
+ │ └── useProfileTabs.ts Local tab state (useState)
100
+ ├── types.ts ProfileFormProps, ProfileTab, ProfileSlots
101
+ └── components/
102
+ ├── ProfileHeader Avatar + name + dropdown menu
103
+ ├── ProfileTab Editable fields grid (first_name, last_name, phone, company, position)
104
+ ├── TwoFactorSection/ Own mini-context (2FA status, setup, disable)
105
+ ├── ApiKeySection/ Own mini-context (useApiKey, reveal/arm state)
106
+ ├── EditableField Inline-editable text/phone field
107
+ ├── Section Card-like section wrapper
108
+ └── DeleteAccount Confirmation dialog + delete action
109
+ ```
110
+
111
+ Each section (`TwoFactorSection`, `ApiKeySection`) owns a **mini-context** — isolated state, labels, and API logic. Root context stays minimal.
112
+
113
+ ## Dependencies
114
+
115
+ - `@djangocfg/api/hooks` — `useApiKey` (retrieve + regenerate)
116
+ - `@djangocfg/api/auth` — `useAuth`, `useDeleteAccount`, `useTwoFactorSetup`, `useTwoFactorStatus`
117
+ - `@djangocfg/ui-core/components` — UI primitives + `CopyButton`
118
+ - `@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
+ );