@djangocfg/layouts 2.1.426 → 2.1.427
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 +15 -17
- package/src/layouts/AppLayout/AppLayout.tsx +0 -7
- package/src/layouts/AppLayout/BaseApp.tsx +29 -52
- package/src/layouts/AuthLayout/components/steps/SetupStep/SetupLoading.tsx +6 -4
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +7 -3
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +5 -1
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +105 -70
- package/src/layouts/PrivateLayout/types.ts +8 -0
- package/src/layouts/PublicLayout/components/UserMenu.tsx +68 -113
- package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalNavbar.tsx +0 -6
- package/src/layouts/PublicLayout/navbars/MinimalNavbar/index.ts +1 -1
- package/src/layouts/SettingsLayout/README.md +258 -0
- package/src/layouts/SettingsLayout/SettingsDialog.tsx +101 -0
- package/src/layouts/SettingsLayout/SettingsForm.tsx +100 -0
- package/src/layouts/SettingsLayout/components/ApiKeySection/ApiKeySection.tsx +189 -0
- package/src/layouts/SettingsLayout/components/SettingsNav.tsx +71 -0
- package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +57 -0
- package/src/layouts/SettingsLayout/components/SettingsPanel.tsx +48 -0
- package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +50 -0
- package/src/layouts/SettingsLayout/components/SettingsShell.tsx +77 -0
- package/src/layouts/SettingsLayout/components/SettingsTabs.tsx +56 -0
- package/src/layouts/{ProfileLayout → SettingsLayout}/components/TwoFactorSection/TwoFactorSection.tsx +84 -130
- package/src/layouts/SettingsLayout/components/index.ts +6 -0
- package/src/layouts/SettingsLayout/context/SettingsContext.tsx +122 -0
- package/src/layouts/SettingsLayout/context/index.ts +2 -0
- package/src/layouts/SettingsLayout/hooks/index.ts +12 -0
- package/src/layouts/SettingsLayout/hooks/useProfileSave.ts +95 -0
- package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +52 -0
- package/src/layouts/SettingsLayout/hooks/useSettingsSections.ts +123 -0
- package/src/layouts/SettingsLayout/hooks/useSettingsUrl.ts +140 -0
- package/src/layouts/SettingsLayout/index.ts +67 -0
- package/src/layouts/SettingsLayout/sections/AccountSection.tsx +100 -0
- package/src/layouts/SettingsLayout/sections/ApiKeysSection.tsx +15 -0
- package/src/layouts/SettingsLayout/sections/DeleteAccountRow.tsx +57 -0
- package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +43 -0
- package/src/layouts/SettingsLayout/sections/SecuritySection.tsx +15 -0
- package/src/layouts/SettingsLayout/sections/builtins.tsx +77 -0
- package/src/layouts/SettingsLayout/sections/index.ts +8 -0
- package/src/layouts/SettingsLayout/store.ts +47 -0
- package/src/layouts/SettingsLayout/types.ts +107 -0
- package/src/layouts/index.ts +1 -1
- package/src/layouts/types/index.ts +0 -1
- package/src/layouts/types/layout.types.ts +0 -4
- package/src/layouts/ProfileLayout/ProfileDialog/ProfileDialog.tsx +0 -56
- package/src/layouts/ProfileLayout/ProfileDialog/index.ts +0 -4
- package/src/layouts/ProfileLayout/ProfileDialog/store.ts +0 -51
- package/src/layouts/ProfileLayout/ProfileForm/context.tsx +0 -123
- package/src/layouts/ProfileLayout/ProfileForm/index.tsx +0 -147
- package/src/layouts/ProfileLayout/README.md +0 -150
- package/src/layouts/ProfileLayout/components/ActionButton.tsx +0 -38
- package/src/layouts/ProfileLayout/components/ApiKeySection/ApiKeySection.tsx +0 -197
- package/src/layouts/ProfileLayout/components/DeleteAccountSection.tsx +0 -44
- package/src/layouts/ProfileLayout/components/EditableField.tsx +0 -128
- package/src/layouts/ProfileLayout/components/PreferencesSection.tsx +0 -56
- package/src/layouts/ProfileLayout/components/ProfileHeader.tsx +0 -110
- package/src/layouts/ProfileLayout/components/ProfileTab.tsx +0 -35
- package/src/layouts/ProfileLayout/components/Section.tsx +0 -22
- package/src/layouts/ProfileLayout/components/index.ts +0 -11
- package/src/layouts/ProfileLayout/hooks/index.ts +0 -2
- package/src/layouts/ProfileLayout/hooks/useProfileTabs.ts +0 -56
- package/src/layouts/ProfileLayout/index.ts +0 -8
- package/src/layouts/ProfileLayout/types.ts +0 -48
- /package/src/layouts/{ProfileLayout → SettingsLayout}/components/ApiKeySection/context.tsx +0 -0
- /package/src/layouts/{ProfileLayout → SettingsLayout}/components/ApiKeySection/index.ts +0 -0
- /package/src/layouts/{ProfileLayout → SettingsLayout}/components/AvatarSection.tsx +0 -0
- /package/src/layouts/{ProfileLayout → SettingsLayout}/components/TwoFactorSection/index.ts +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { Loader2, Shield,
|
|
3
|
+
import { Loader2, Shield, ShieldOff } from 'lucide-react';
|
|
4
4
|
import React, { useEffect, useState } from 'react';
|
|
5
5
|
|
|
6
6
|
import { useTwoFactorSetup, useTwoFactorStatus } from '@djangocfg/api/auth';
|
|
@@ -9,11 +9,6 @@ import {
|
|
|
9
9
|
AlertDescription,
|
|
10
10
|
Badge,
|
|
11
11
|
Button,
|
|
12
|
-
Card,
|
|
13
|
-
CardContent,
|
|
14
|
-
CardDescription,
|
|
15
|
-
CardHeader,
|
|
16
|
-
CardTitle,
|
|
17
12
|
Dialog,
|
|
18
13
|
DialogContent,
|
|
19
14
|
DialogDescription,
|
|
@@ -21,7 +16,9 @@ import {
|
|
|
21
16
|
DialogHeader,
|
|
22
17
|
DialogTitle,
|
|
23
18
|
OTPInput,
|
|
24
|
-
|
|
19
|
+
Preloader,
|
|
20
|
+
SettingRow,
|
|
21
|
+
SettingsBlock,
|
|
25
22
|
} from '@djangocfg/ui-core/components';
|
|
26
23
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
27
24
|
|
|
@@ -43,7 +40,7 @@ function StatusBadge({ enabled }: { enabled: boolean }) {
|
|
|
43
40
|
variant={enabled ? 'default' : 'secondary'}
|
|
44
41
|
className={cn(
|
|
45
42
|
'text-xs font-medium',
|
|
46
|
-
enabled && 'bg-
|
|
43
|
+
enabled && 'bg-success-background text-success-foreground border-success-border',
|
|
47
44
|
)}
|
|
48
45
|
>
|
|
49
46
|
{enabled ? 'Enabled' : 'Disabled'}
|
|
@@ -131,28 +128,12 @@ function DisableDialog({
|
|
|
131
128
|
}
|
|
132
129
|
|
|
133
130
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
134
|
-
//
|
|
131
|
+
// Helpers
|
|
135
132
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
136
133
|
|
|
137
|
-
function
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
isPrimary: boolean;
|
|
141
|
-
}) {
|
|
142
|
-
return (
|
|
143
|
-
<div className="flex items-center gap-3 py-3">
|
|
144
|
-
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-muted flex items-center justify-center">
|
|
145
|
-
<Smartphone className="w-4 h-4 text-muted-foreground" />
|
|
146
|
-
</div>
|
|
147
|
-
<div className="flex-1 min-w-0">
|
|
148
|
-
<p className="text-sm font-medium truncate">{name}</p>
|
|
149
|
-
<p className="text-xs text-muted-foreground">
|
|
150
|
-
Added {new Date(createdAt).toLocaleDateString()}
|
|
151
|
-
{isPrimary && ' · Primary'}
|
|
152
|
-
</p>
|
|
153
|
-
</div>
|
|
154
|
-
</div>
|
|
155
|
-
);
|
|
134
|
+
function deviceDescription(createdAt: string, isPrimary: boolean): string {
|
|
135
|
+
const added = `Added ${new Date(createdAt).toLocaleDateString()}`;
|
|
136
|
+
return isPrimary ? `${added} · Primary` : added;
|
|
156
137
|
}
|
|
157
138
|
|
|
158
139
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -198,133 +179,106 @@ export const TwoFactorSection: React.FC = () => {
|
|
|
198
179
|
};
|
|
199
180
|
|
|
200
181
|
// ── Setup flow ──────────────────────────────────────────────────────────────
|
|
182
|
+
// Lighter, card-free container. The standalone setup flow brings its own
|
|
183
|
+
// visible (Preloader-based) loading state.
|
|
201
184
|
if (view === 'setup') {
|
|
202
185
|
return (
|
|
203
|
-
<
|
|
204
|
-
<
|
|
205
|
-
<
|
|
206
|
-
<CardTitle className="flex items-center gap-2 text-base">
|
|
207
|
-
<Shield className="w-4 h-4" />
|
|
208
|
-
Set up Two-Factor Authentication
|
|
209
|
-
</CardTitle>
|
|
210
|
-
<Button variant="ghost" size="sm" onClick={() => setView('status')}>
|
|
211
|
-
Cancel
|
|
212
|
-
</Button>
|
|
213
|
-
</div>
|
|
214
|
-
<CardDescription>
|
|
186
|
+
<SettingsBlock title="Set up Two-Factor Authentication">
|
|
187
|
+
<div className="flex items-center justify-between pb-2">
|
|
188
|
+
<p className="text-[13px] leading-relaxed text-muted-foreground">
|
|
215
189
|
Scan the QR code with your authenticator app (Google Authenticator, Authy, etc.)
|
|
216
|
-
</
|
|
217
|
-
|
|
218
|
-
|
|
190
|
+
</p>
|
|
191
|
+
<Button variant="ghost" size="sm" onClick={() => setView('status')}>
|
|
192
|
+
Cancel
|
|
193
|
+
</Button>
|
|
194
|
+
</div>
|
|
195
|
+
<div className="flex justify-center py-2">
|
|
219
196
|
<SetupStepStandalone
|
|
220
197
|
onComplete={handleSetupDone}
|
|
221
198
|
onSkip={() => setView('status')}
|
|
222
199
|
/>
|
|
223
|
-
</
|
|
224
|
-
</
|
|
200
|
+
</div>
|
|
201
|
+
</SettingsBlock>
|
|
225
202
|
);
|
|
226
203
|
}
|
|
227
204
|
|
|
228
205
|
// ── Loading skeleton ────────────────────────────────────────────────────────
|
|
229
206
|
if (isLoading && has2FAEnabled === null) {
|
|
230
|
-
return
|
|
231
|
-
<Card>
|
|
232
|
-
<CardContent className="flex items-center justify-center py-10">
|
|
233
|
-
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
|
234
|
-
</CardContent>
|
|
235
|
-
</Card>
|
|
236
|
-
);
|
|
207
|
+
return <Preloader variant="inline" className="py-10" />;
|
|
237
208
|
}
|
|
238
209
|
|
|
239
210
|
// ── Status view ─────────────────────────────────────────────────────────────
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
<
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
) : (
|
|
251
|
-
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
|
|
252
|
-
<ShieldOff className="w-5 h-5 text-muted-foreground" />
|
|
253
|
-
</div>
|
|
254
|
-
)}
|
|
255
|
-
<div>
|
|
256
|
-
<div className="flex items-center gap-2">
|
|
257
|
-
<CardTitle className="text-base">Two-Factor Authentication</CardTitle>
|
|
258
|
-
<StatusBadge enabled={!!has2FAEnabled} />
|
|
259
|
-
</div>
|
|
260
|
-
<CardDescription className="mt-0.5">
|
|
261
|
-
{has2FAEnabled
|
|
262
|
-
? `${devices.length} authenticator device${devices.length !== 1 ? 's' : ''} connected`
|
|
263
|
-
: 'Add an extra layer of security to your account'}
|
|
264
|
-
</CardDescription>
|
|
265
|
-
</div>
|
|
266
|
-
</div>
|
|
211
|
+
const statusDescription = (
|
|
212
|
+
<span className="inline-flex items-center gap-2">
|
|
213
|
+
<StatusBadge enabled={!!has2FAEnabled} />
|
|
214
|
+
<span>
|
|
215
|
+
{has2FAEnabled
|
|
216
|
+
? `${devices.length} authenticator device${devices.length !== 1 ? 's' : ''} connected`
|
|
217
|
+
: 'Add an extra layer of security to your account'}
|
|
218
|
+
</span>
|
|
219
|
+
</span>
|
|
220
|
+
);
|
|
267
221
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
</div>
|
|
284
|
-
</CardHeader>
|
|
222
|
+
const statusAction = has2FAEnabled ? (
|
|
223
|
+
<Button
|
|
224
|
+
variant="outline"
|
|
225
|
+
size="sm"
|
|
226
|
+
onClick={() => { clearError(); setShowDisable(true); }}
|
|
227
|
+
disabled={isLoading}
|
|
228
|
+
className="text-destructive hover:text-destructive hover:bg-destructive/10 border-destructive/30"
|
|
229
|
+
>
|
|
230
|
+
Disable
|
|
231
|
+
</Button>
|
|
232
|
+
) : (
|
|
233
|
+
<Button size="sm" onClick={handleEnableClick} disabled={isLoading}>
|
|
234
|
+
Enable 2FA
|
|
235
|
+
</Button>
|
|
236
|
+
);
|
|
285
237
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
238
|
+
return (
|
|
239
|
+
<>
|
|
240
|
+
<div className="space-y-6">
|
|
241
|
+
<SettingsBlock>
|
|
242
|
+
<SettingRow
|
|
243
|
+
label="Two-Factor Authentication"
|
|
244
|
+
description={statusDescription}
|
|
245
|
+
action={statusAction}
|
|
246
|
+
/>
|
|
294
247
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
</p>
|
|
302
|
-
<div className="divide-y">
|
|
303
|
-
{devices.map((device) => (
|
|
304
|
-
<DeviceRow
|
|
305
|
-
key={device.id}
|
|
306
|
-
name={device.name}
|
|
307
|
-
createdAt={device.createdAt}
|
|
308
|
-
isPrimary={device.isPrimary}
|
|
309
|
-
/>
|
|
310
|
-
))}
|
|
248
|
+
{/* Fetch error */}
|
|
249
|
+
{error && !showDisable && (
|
|
250
|
+
<div className="py-3">
|
|
251
|
+
<Alert variant="destructive">
|
|
252
|
+
<AlertDescription>{error}</AlertDescription>
|
|
253
|
+
</Alert>
|
|
311
254
|
</div>
|
|
312
|
-
|
|
313
|
-
)}
|
|
255
|
+
)}
|
|
314
256
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
<Shield className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
|
257
|
+
{/* Not enabled — subtle muted note */}
|
|
258
|
+
{!has2FAEnabled && (
|
|
259
|
+
<p className="flex gap-2 pt-3 text-[13px] leading-relaxed text-muted-foreground">
|
|
260
|
+
<Shield className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
|
320
261
|
<span>
|
|
321
262
|
Two-factor authentication adds a second verification step when signing in,
|
|
322
263
|
protecting your account even if your password is compromised.
|
|
323
264
|
</span>
|
|
324
|
-
</
|
|
325
|
-
|
|
265
|
+
</p>
|
|
266
|
+
)}
|
|
267
|
+
</SettingsBlock>
|
|
268
|
+
|
|
269
|
+
{/* Device list */}
|
|
270
|
+
{has2FAEnabled && devices.length > 0 && (
|
|
271
|
+
<SettingsBlock title="Connected devices">
|
|
272
|
+
{devices.map((device) => (
|
|
273
|
+
<SettingRow
|
|
274
|
+
key={device.id}
|
|
275
|
+
label={device.name}
|
|
276
|
+
description={deviceDescription(device.createdAt, device.isPrimary)}
|
|
277
|
+
/>
|
|
278
|
+
))}
|
|
279
|
+
</SettingsBlock>
|
|
326
280
|
)}
|
|
327
|
-
</
|
|
281
|
+
</div>
|
|
328
282
|
|
|
329
283
|
<DisableDialog
|
|
330
284
|
open={showDisable}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { SettingsShell } from './SettingsShell';
|
|
2
|
+
export { SettingsNav } from './SettingsNav';
|
|
3
|
+
export { SettingsTabs } from './SettingsTabs';
|
|
4
|
+
export { SettingsNavItem } from './SettingsNavItem';
|
|
5
|
+
export { SettingsPanel } from './SettingsPanel';
|
|
6
|
+
export { SettingsSearch } from './SettingsSearch';
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SettingsContext — the resolved runtime state shared by the shell parts
|
|
5
|
+
* (nav rail, search box, detail panel). Holds the derived section structure,
|
|
6
|
+
* the active section, and the search query. Selection can be driven by the
|
|
7
|
+
* zustand store (dialog mode) or local state (inline mode); the provider
|
|
8
|
+
* abstracts that difference so child components never care which.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import React, {
|
|
12
|
+
createContext,
|
|
13
|
+
useCallback,
|
|
14
|
+
useContext,
|
|
15
|
+
useMemo,
|
|
16
|
+
useState,
|
|
17
|
+
} from 'react';
|
|
18
|
+
|
|
19
|
+
import { useSettingsSections } from '../hooks/useSettingsSections';
|
|
20
|
+
import type { ResolvedGroup } from '../hooks/useSettingsSections';
|
|
21
|
+
import type { SettingsGroup, SettingsSection } from '../types';
|
|
22
|
+
|
|
23
|
+
interface SettingsContextValue {
|
|
24
|
+
/** Title for the surface header. */
|
|
25
|
+
title: React.ReactNode;
|
|
26
|
+
/** Groups (with sections) after ordering + search filtering. */
|
|
27
|
+
groups: ResolvedGroup[];
|
|
28
|
+
/** Flat visible sections in render order. */
|
|
29
|
+
visible: SettingsSection[];
|
|
30
|
+
/** The currently active section object (or null if none resolvable). */
|
|
31
|
+
active: SettingsSection | null;
|
|
32
|
+
/** Active section id. */
|
|
33
|
+
activeId: string | null;
|
|
34
|
+
/** Switch active section. */
|
|
35
|
+
setActive: (id: string) => void;
|
|
36
|
+
/** Search query + setter. */
|
|
37
|
+
query: string;
|
|
38
|
+
setQuery: (q: string) => void;
|
|
39
|
+
/** Whether the search box should render. */
|
|
40
|
+
searchable: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SettingsContext = createContext<SettingsContextValue | null>(null);
|
|
44
|
+
|
|
45
|
+
export const useSettingsContext = (): SettingsContextValue => {
|
|
46
|
+
const ctx = useContext(SettingsContext);
|
|
47
|
+
if (!ctx) throw new Error('useSettingsContext must be used within SettingsProvider');
|
|
48
|
+
return ctx;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export interface SettingsProviderProps {
|
|
52
|
+
children: React.ReactNode;
|
|
53
|
+
title: React.ReactNode;
|
|
54
|
+
/** Fully-merged sections (built-ins + app), pre-concatenated by the caller. */
|
|
55
|
+
sections: SettingsSection[];
|
|
56
|
+
groups?: SettingsGroup[];
|
|
57
|
+
searchable: boolean;
|
|
58
|
+
/** Controlled active id (dialog mode passes the store value). */
|
|
59
|
+
activeId: string | null;
|
|
60
|
+
/** Called when active section changes (dialog mode writes the store). */
|
|
61
|
+
onActiveChange: (id: string) => void;
|
|
62
|
+
/** Section to fall back to when the controlled id is null/invalid. */
|
|
63
|
+
initialSection?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const SettingsProvider: React.FC<SettingsProviderProps> = ({
|
|
67
|
+
children,
|
|
68
|
+
title,
|
|
69
|
+
sections,
|
|
70
|
+
groups,
|
|
71
|
+
searchable,
|
|
72
|
+
activeId,
|
|
73
|
+
onActiveChange,
|
|
74
|
+
initialSection,
|
|
75
|
+
}) => {
|
|
76
|
+
const [query, setQuery] = useState('');
|
|
77
|
+
|
|
78
|
+
// Filtered/ordered structure. Search filters the rail but never the lookup map.
|
|
79
|
+
const { groups: resolvedGroups, visible, byId, ids } = useSettingsSections({
|
|
80
|
+
sections,
|
|
81
|
+
groups,
|
|
82
|
+
query,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Resolve the active section. Precedence: controlled id (if known) →
|
|
86
|
+
// initialSection (if known) → first visible section → null.
|
|
87
|
+
const fallbackId = useMemo(() => {
|
|
88
|
+
if (initialSection && ids.has(initialSection)) return initialSection;
|
|
89
|
+
return visible[0]?.id ?? sections.find((s) => !s.hidden)?.id ?? null;
|
|
90
|
+
}, [initialSection, ids, visible, sections]);
|
|
91
|
+
|
|
92
|
+
const resolvedActiveId =
|
|
93
|
+
activeId && ids.has(activeId) ? activeId : fallbackId;
|
|
94
|
+
|
|
95
|
+
const active = resolvedActiveId ? byId.get(resolvedActiveId) ?? null : null;
|
|
96
|
+
|
|
97
|
+
const setActive = useCallback(
|
|
98
|
+
(id: string) => {
|
|
99
|
+
// Picking a section clears any search so the panel is reachable.
|
|
100
|
+
setQuery('');
|
|
101
|
+
onActiveChange(id);
|
|
102
|
+
},
|
|
103
|
+
[onActiveChange],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const value = useMemo<SettingsContextValue>(
|
|
107
|
+
() => ({
|
|
108
|
+
title,
|
|
109
|
+
groups: resolvedGroups,
|
|
110
|
+
visible,
|
|
111
|
+
active,
|
|
112
|
+
activeId: resolvedActiveId,
|
|
113
|
+
setActive,
|
|
114
|
+
query,
|
|
115
|
+
setQuery,
|
|
116
|
+
searchable,
|
|
117
|
+
}),
|
|
118
|
+
[title, resolvedGroups, visible, active, resolvedActiveId, setActive, query, searchable],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
|
|
122
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { useSettingsUrl } from './useSettingsUrl';
|
|
2
|
+
export type { UseSettingsUrlOptions } from './useSettingsUrl';
|
|
3
|
+
|
|
4
|
+
export { useSettingsDialog } from './useSettingsDialog';
|
|
5
|
+
export type { UseSettingsDialogReturn } from './useSettingsDialog';
|
|
6
|
+
|
|
7
|
+
export { useSettingsSections } from './useSettingsSections';
|
|
8
|
+
export type {
|
|
9
|
+
ResolvedGroup,
|
|
10
|
+
UseSettingsSectionsArgs,
|
|
11
|
+
UseSettingsSectionsResult,
|
|
12
|
+
} from './useSettingsSections';
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useProfileSave — the account-section data layer, as a plain hook (no context).
|
|
5
|
+
*
|
|
6
|
+
* Replaces the old ProfileProvider/useProfileContext plumbing: the labels are
|
|
7
|
+
* just i18n, `save` wraps `updateProfile` + toast, and `logout` is the shared
|
|
8
|
+
* logout hook. Every value here is already reachable via global hooks, so a
|
|
9
|
+
* React context added nothing but a wrapper — sections call this directly.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useCallback, useMemo } from 'react';
|
|
13
|
+
|
|
14
|
+
import { useAuth } from '@djangocfg/api/auth';
|
|
15
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
16
|
+
import { toast } from '@djangocfg/ui-core/hooks';
|
|
17
|
+
|
|
18
|
+
import { profileLogger } from '../../../utils/logger';
|
|
19
|
+
import { useLogout } from '../../../hooks';
|
|
20
|
+
|
|
21
|
+
export interface ProfileLabels {
|
|
22
|
+
title: string;
|
|
23
|
+
personalInfo: string;
|
|
24
|
+
work: string;
|
|
25
|
+
preferences: string;
|
|
26
|
+
firstName: string;
|
|
27
|
+
lastName: string;
|
|
28
|
+
phone: string;
|
|
29
|
+
company: string;
|
|
30
|
+
position: string;
|
|
31
|
+
addFirstName: string;
|
|
32
|
+
addLastName: string;
|
|
33
|
+
addPhone: string;
|
|
34
|
+
addCompany: string;
|
|
35
|
+
addPosition: string;
|
|
36
|
+
deleteAccount: string;
|
|
37
|
+
profileUpdated: string;
|
|
38
|
+
failedToUpdate: string;
|
|
39
|
+
loading: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface UseProfileSaveReturn {
|
|
43
|
+
labels: ProfileLabels;
|
|
44
|
+
/** Persist a single profile field; shows a toast on success/error. */
|
|
45
|
+
save: (field: string, value: string) => Promise<void>;
|
|
46
|
+
/** Sign the user out. */
|
|
47
|
+
logout: () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function useProfileSave(title?: string): UseProfileSaveReturn {
|
|
51
|
+
const { updateProfile } = useAuth();
|
|
52
|
+
const t = useAppT();
|
|
53
|
+
const logout = useLogout();
|
|
54
|
+
|
|
55
|
+
const labels = useMemo<ProfileLabels>(
|
|
56
|
+
() => ({
|
|
57
|
+
title: title || t('layouts.profilePage.title'),
|
|
58
|
+
personalInfo: t('layouts.profilePage.personalInfo'),
|
|
59
|
+
work: t('layouts.profilePage.work'),
|
|
60
|
+
preferences: 'Preferences',
|
|
61
|
+
firstName: t('layouts.profilePage.firstName'),
|
|
62
|
+
lastName: t('layouts.profilePage.lastName'),
|
|
63
|
+
phone: t('layouts.profilePage.phone'),
|
|
64
|
+
company: t('layouts.profilePage.company'),
|
|
65
|
+
position: t('layouts.profilePage.position'),
|
|
66
|
+
addFirstName: t('layouts.profilePage.addFirstName') || 'Add first name',
|
|
67
|
+
addLastName: t('layouts.profilePage.addLastName') || 'Add last name',
|
|
68
|
+
addPhone: t('layouts.profilePage.addPhone') || 'Add phone number',
|
|
69
|
+
addCompany: t('layouts.profilePage.addCompany') || 'Add company',
|
|
70
|
+
addPosition: t('layouts.profilePage.addPosition') || 'Add position',
|
|
71
|
+
deleteAccount: t('layouts.profilePage.deleteAccount'),
|
|
72
|
+
profileUpdated: t('layouts.profilePage.profileUpdated'),
|
|
73
|
+
failedToUpdate: t('layouts.profilePage.failedToUpdate'),
|
|
74
|
+
loading: t('ui.states.loading'),
|
|
75
|
+
}),
|
|
76
|
+
[t, title],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const save = useCallback(
|
|
80
|
+
async (field: string, value: string) => {
|
|
81
|
+
try {
|
|
82
|
+
await updateProfile({ [field]: value });
|
|
83
|
+
toast.success(labels.profileUpdated);
|
|
84
|
+
} catch (error: unknown) {
|
|
85
|
+
profileLogger.error('Profile update error:', error);
|
|
86
|
+
const apiErr = error as { response?: Record<string, string[]> };
|
|
87
|
+
toast.error(apiErr?.response?.[field]?.[0] || labels.failedToUpdate);
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
[updateProfile, labels],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return { labels, save, logout };
|
|
95
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useSettingsDialog — the public API external consumers use to drive the
|
|
5
|
+
* globally-mounted <SettingsDialog />.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const settings = useSettingsDialog();
|
|
9
|
+
* <Button onClick={() => settings.open()}>Settings</Button>
|
|
10
|
+
* <MenuItem onClick={() => settings.open('billing')}>Billing</MenuItem>
|
|
11
|
+
*
|
|
12
|
+
* Returns stable callbacks, safe in deps arrays. The dialog itself must be
|
|
13
|
+
* mounted once in the tree (PrivateLayout does this).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useCallback, useMemo } from 'react';
|
|
17
|
+
|
|
18
|
+
import { useSettingsDialogStore } from '../store';
|
|
19
|
+
|
|
20
|
+
export interface UseSettingsDialogReturn {
|
|
21
|
+
/** Whether the dialog is currently open. */
|
|
22
|
+
isOpen: boolean;
|
|
23
|
+
/** Currently active section id (null = fall back to first). */
|
|
24
|
+
activeSection: string | null;
|
|
25
|
+
/** Open the dialog, optionally on a specific section. */
|
|
26
|
+
open: (sectionId?: string) => void;
|
|
27
|
+
/** Close the dialog. */
|
|
28
|
+
close: () => void;
|
|
29
|
+
/** Toggle open/closed. */
|
|
30
|
+
toggle: () => void;
|
|
31
|
+
/** Switch the active section. */
|
|
32
|
+
setSection: (sectionId: string) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useSettingsDialog(): UseSettingsDialogReturn {
|
|
36
|
+
const isOpen = useSettingsDialogStore((s) => s.isOpen);
|
|
37
|
+
const activeSection = useSettingsDialogStore((s) => s.activeSection);
|
|
38
|
+
const storeOpen = useSettingsDialogStore((s) => s.open);
|
|
39
|
+
const storeClose = useSettingsDialogStore((s) => s.close);
|
|
40
|
+
const storeToggle = useSettingsDialogStore((s) => s.toggle);
|
|
41
|
+
const storeSetSection = useSettingsDialogStore((s) => s.setSection);
|
|
42
|
+
|
|
43
|
+
const open = useCallback((sectionId?: string) => storeOpen(sectionId), [storeOpen]);
|
|
44
|
+
const close = useCallback(() => storeClose(), [storeClose]);
|
|
45
|
+
const toggle = useCallback(() => storeToggle(), [storeToggle]);
|
|
46
|
+
const setSection = useCallback((id: string) => storeSetSection(id), [storeSetSection]);
|
|
47
|
+
|
|
48
|
+
return useMemo(
|
|
49
|
+
() => ({ isOpen, activeSection, open, close, toggle, setSection }),
|
|
50
|
+
[isOpen, activeSection, open, close, toggle, setSection],
|
|
51
|
+
);
|
|
52
|
+
}
|