@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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useSettingsSections — resolve raw section/group config into the ordered,
|
|
5
|
+
* grouped, search-filtered structure the nav rail renders.
|
|
6
|
+
*
|
|
7
|
+
* Pure derivation (no side effects): built-ins + app sections are merged,
|
|
8
|
+
* hidden ones dropped from the rail, sorted by (group order, section order,
|
|
9
|
+
* declaration index), then partitioned into groups. A search query filters by
|
|
10
|
+
* label text + keywords.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useMemo } from 'react';
|
|
14
|
+
|
|
15
|
+
import type { SettingsGroup, SettingsSection } from '../types';
|
|
16
|
+
|
|
17
|
+
/** A group paired with its resolved, ordered sections. */
|
|
18
|
+
export interface ResolvedGroup {
|
|
19
|
+
id: string;
|
|
20
|
+
label?: React.ReactNode;
|
|
21
|
+
sections: SettingsSection[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UseSettingsSectionsArgs {
|
|
25
|
+
/** Built-in + app sections, already concatenated by the caller. */
|
|
26
|
+
sections: SettingsSection[];
|
|
27
|
+
/** Group definitions for ordering/labelling. */
|
|
28
|
+
groups?: SettingsGroup[];
|
|
29
|
+
/** Live search query (case-insensitive). Empty = no filtering. */
|
|
30
|
+
query?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UseSettingsSectionsResult {
|
|
34
|
+
/** Groups (with sections) for the nav rail, in render order. */
|
|
35
|
+
groups: ResolvedGroup[];
|
|
36
|
+
/** Flat list of all visible sections, in render order. */
|
|
37
|
+
visible: SettingsSection[];
|
|
38
|
+
/** Lookup by id across ALL sections (incl. hidden) — for resolving active. */
|
|
39
|
+
byId: Map<string, SettingsSection>;
|
|
40
|
+
/** All known section ids (incl. hidden) — handed to the URL resolver. */
|
|
41
|
+
ids: Set<string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DEFAULT_GROUP_ID = '__default__';
|
|
45
|
+
|
|
46
|
+
/** Extract searchable text from a label that may be a string or JSX. */
|
|
47
|
+
function labelText(label: React.ReactNode): string {
|
|
48
|
+
return typeof label === 'string' ? label : '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function matchesQuery(section: SettingsSection, q: string): boolean {
|
|
52
|
+
if (!q) return true;
|
|
53
|
+
const haystack = [
|
|
54
|
+
labelText(section.label),
|
|
55
|
+
labelText(section.title),
|
|
56
|
+
...(section.keywords ?? []),
|
|
57
|
+
]
|
|
58
|
+
.join(' ')
|
|
59
|
+
.toLowerCase();
|
|
60
|
+
return haystack.includes(q);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function useSettingsSections({
|
|
64
|
+
sections,
|
|
65
|
+
groups,
|
|
66
|
+
query = '',
|
|
67
|
+
}: UseSettingsSectionsArgs): UseSettingsSectionsResult {
|
|
68
|
+
return useMemo(() => {
|
|
69
|
+
const q = query.trim().toLowerCase();
|
|
70
|
+
|
|
71
|
+
const byId = new Map<string, SettingsSection>();
|
|
72
|
+
const ids = new Set<string>();
|
|
73
|
+
for (const s of sections) {
|
|
74
|
+
byId.set(s.id, s);
|
|
75
|
+
ids.add(s.id);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Group order: explicit `order`, else declaration order; default group first.
|
|
79
|
+
const groupOrder = new Map<string, number>();
|
|
80
|
+
groupOrder.set(DEFAULT_GROUP_ID, -Infinity);
|
|
81
|
+
(groups ?? []).forEach((g, i) => {
|
|
82
|
+
groupOrder.set(g.id, g.order ?? i);
|
|
83
|
+
});
|
|
84
|
+
const groupLabel = new Map<string, React.ReactNode>();
|
|
85
|
+
(groups ?? []).forEach((g) => g.label && groupLabel.set(g.id, g.label));
|
|
86
|
+
|
|
87
|
+
// Bucket visible (not hidden, passes search) sections by group.
|
|
88
|
+
const buckets = new Map<string, Array<{ section: SettingsSection; index: number }>>();
|
|
89
|
+
const visible: SettingsSection[] = [];
|
|
90
|
+
|
|
91
|
+
sections.forEach((section, index) => {
|
|
92
|
+
if (section.hidden) return;
|
|
93
|
+
if (!matchesQuery(section, q)) return;
|
|
94
|
+
const gid = section.group && groupOrder.has(section.group) ? section.group
|
|
95
|
+
: section.group ?? DEFAULT_GROUP_ID;
|
|
96
|
+
// Register ad-hoc groups (named on a section but not declared) so they sort stably.
|
|
97
|
+
if (!groupOrder.has(gid)) groupOrder.set(gid, buckets.size);
|
|
98
|
+
if (!buckets.has(gid)) buckets.set(gid, []);
|
|
99
|
+
buckets.get(gid)!.push({ section, index });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Sort sections within each bucket, then emit groups in group order.
|
|
103
|
+
const resolved: ResolvedGroup[] = [...buckets.entries()]
|
|
104
|
+
.sort((a, b) => (groupOrder.get(a[0])! - groupOrder.get(b[0])!))
|
|
105
|
+
.map(([gid, entries]) => {
|
|
106
|
+
const ordered = entries
|
|
107
|
+
.sort((a, b) => {
|
|
108
|
+
const oa = a.section.order ?? a.index;
|
|
109
|
+
const ob = b.section.order ?? b.index;
|
|
110
|
+
return oa - ob;
|
|
111
|
+
})
|
|
112
|
+
.map((e) => e.section);
|
|
113
|
+
ordered.forEach((s) => visible.push(s));
|
|
114
|
+
return {
|
|
115
|
+
id: gid,
|
|
116
|
+
label: gid === DEFAULT_GROUP_ID ? undefined : groupLabel.get(gid),
|
|
117
|
+
sections: ordered,
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return { groups: resolved, visible, byId, ids };
|
|
122
|
+
}, [sections, groups, query]);
|
|
123
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useSettingsUrl — two-way bind the SettingsDialog store to the URL hash.
|
|
5
|
+
*
|
|
6
|
+
* Shape: `#settings/<section>` (Claude.ai style). The leading key is
|
|
7
|
+
* configurable (`urlKey`). Examples:
|
|
8
|
+
* #settings → open, first/last-active section
|
|
9
|
+
* #settings/usage → open, "usage" section active
|
|
10
|
+
* (no/other hash) → closed
|
|
11
|
+
*
|
|
12
|
+
* Direction of truth:
|
|
13
|
+
* URL → store : on mount and on every hashchange/popstate. This is what makes
|
|
14
|
+
* deep-links, refresh, and the back button "just work" — the
|
|
15
|
+
* browser owns history, we mirror it into state.
|
|
16
|
+
* store → URL : when the user opens/closes/switches section via the UI, we
|
|
17
|
+
* push or replace the hash so it stays shareable.
|
|
18
|
+
*
|
|
19
|
+
* We lean on ui-core's `useLocation` (a `useSyncExternalStore` over a patched
|
|
20
|
+
* history + `hashchange`) so this is framework-agnostic — no next/router import.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { useEffect, useRef } from 'react';
|
|
24
|
+
|
|
25
|
+
import { useLocation, useNavigate } from '@djangocfg/ui-core/hooks';
|
|
26
|
+
|
|
27
|
+
import { useSettingsDialogStore } from '../store';
|
|
28
|
+
|
|
29
|
+
export interface UseSettingsUrlOptions {
|
|
30
|
+
/** Hash namespace before the section id. Default: "settings". */
|
|
31
|
+
urlKey?: string;
|
|
32
|
+
/** Disable the binding entirely (imperative-only mode). Default: false. */
|
|
33
|
+
enabled?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Validate / normalize a section id read from the URL. Return null to reject
|
|
36
|
+
* an unknown id (dialog still opens, on the fallback section). The shell
|
|
37
|
+
* passes the set of known section ids here.
|
|
38
|
+
*/
|
|
39
|
+
resolveSection?: (id: string | null) => string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ParsedHash {
|
|
43
|
+
open: boolean;
|
|
44
|
+
section: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Parse `#settings/usage` → { open: true, section: 'usage' }. */
|
|
48
|
+
function parseHash(hash: string, urlKey: string): ParsedHash {
|
|
49
|
+
// hash includes the leading '#': "#settings/usage"
|
|
50
|
+
const raw = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
51
|
+
if (!raw) return { open: false, section: null };
|
|
52
|
+
|
|
53
|
+
const [key, ...rest] = raw.split('/');
|
|
54
|
+
if (key !== urlKey) return { open: false, section: null };
|
|
55
|
+
|
|
56
|
+
const section = rest.join('/') || null;
|
|
57
|
+
return { open: true, section };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Build `#settings/usage` (or `#settings` when no section). */
|
|
61
|
+
function buildHash(urlKey: string, section: string | null): string {
|
|
62
|
+
return section ? `#${urlKey}/${section}` : `#${urlKey}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Strip a trailing hash, keeping pathname + search intact. */
|
|
66
|
+
function urlWithoutHash(): string {
|
|
67
|
+
if (typeof window === 'undefined') return '/';
|
|
68
|
+
const { pathname, search } = window.location;
|
|
69
|
+
return `${pathname}${search}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function useSettingsUrl(options: UseSettingsUrlOptions = {}): void {
|
|
73
|
+
const { urlKey = 'settings', enabled = true, resolveSection } = options;
|
|
74
|
+
|
|
75
|
+
const { hash } = useLocation();
|
|
76
|
+
const { navigate } = useNavigate();
|
|
77
|
+
|
|
78
|
+
const isOpen = useSettingsDialogStore((s) => s.isOpen);
|
|
79
|
+
const activeSection = useSettingsDialogStore((s) => s.activeSection);
|
|
80
|
+
const open = useSettingsDialogStore((s) => s.open);
|
|
81
|
+
const close = useSettingsDialogStore((s) => s.close);
|
|
82
|
+
|
|
83
|
+
// Guard against the URL→store and store→URL effects fighting each other:
|
|
84
|
+
// when we write the hash ourselves we tag the value we wrote and skip the
|
|
85
|
+
// next URL→store pass for that exact value.
|
|
86
|
+
const selfWrittenHash = useRef<string | null>(null);
|
|
87
|
+
|
|
88
|
+
// ── URL → store ──────────────────────────────────────────────────────────
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!enabled) return;
|
|
91
|
+
|
|
92
|
+
// Ignore the echo of a hash we just wrote ourselves.
|
|
93
|
+
if (selfWrittenHash.current === hash) {
|
|
94
|
+
selfWrittenHash.current = null;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const parsed = parseHash(hash, urlKey);
|
|
99
|
+
|
|
100
|
+
if (parsed.open) {
|
|
101
|
+
const resolved = resolveSection
|
|
102
|
+
? resolveSection(parsed.section)
|
|
103
|
+
: parsed.section;
|
|
104
|
+
// open() preserves the current section when called without one, so only
|
|
105
|
+
// pass a section when the URL actually named a (valid) one.
|
|
106
|
+
open(resolved ?? undefined);
|
|
107
|
+
} else if (isOpen) {
|
|
108
|
+
// Hash no longer points at us (back button, manual edit) → close.
|
|
109
|
+
close();
|
|
110
|
+
}
|
|
111
|
+
// We intentionally depend on `hash` only; isOpen is read fresh via the
|
|
112
|
+
// store getter semantics above. Re-running on isOpen would double-fire.
|
|
113
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
114
|
+
}, [hash, enabled, urlKey]);
|
|
115
|
+
|
|
116
|
+
// ── store → URL ──────────────────────────────────────────────────────────
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!enabled) return;
|
|
119
|
+
if (typeof window === 'undefined') return;
|
|
120
|
+
|
|
121
|
+
const current = window.location.hash;
|
|
122
|
+
|
|
123
|
+
if (isOpen) {
|
|
124
|
+
const next = buildHash(urlKey, activeSection);
|
|
125
|
+
if (current !== next) {
|
|
126
|
+
selfWrittenHash.current = next;
|
|
127
|
+
// push so the back button can close; refresh keeps the section.
|
|
128
|
+
navigate(`${urlWithoutHash()}${next}`);
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
// Only strip if the hash is *ours*; never clobber an unrelated hash.
|
|
132
|
+
const parsed = parseHash(current, urlKey);
|
|
133
|
+
if (parsed.open) {
|
|
134
|
+
selfWrittenHash.current = '';
|
|
135
|
+
navigate(urlWithoutHash(), { replace: true });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
139
|
+
}, [isOpen, activeSection, enabled, urlKey]);
|
|
140
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SettingsLayout
|
|
3
|
+
*
|
|
4
|
+
* A Claude.ai-style settings surface: a searchable, grouped section rail on the
|
|
5
|
+
* left and a scrollable detail panel on the right, available as a global dialog
|
|
6
|
+
* (URL-hash driven, zustand-backed) or an inline form.
|
|
7
|
+
*
|
|
8
|
+
* Designed to eventually supersede ProfileLayout. Built in parallel — both
|
|
9
|
+
* coexist for now.
|
|
10
|
+
*
|
|
11
|
+
* // Mount once (PrivateLayout does this):
|
|
12
|
+
* <SettingsDialog sections={appSections} builtins={{ security: true }} />
|
|
13
|
+
*
|
|
14
|
+
* // Open from anywhere:
|
|
15
|
+
* const settings = useSettingsDialog();
|
|
16
|
+
* settings.open('billing'); // or navigate to #settings/billing
|
|
17
|
+
*
|
|
18
|
+
* // Inline page variant:
|
|
19
|
+
* <SettingsForm sections={appSections} />
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use client';
|
|
23
|
+
|
|
24
|
+
// Top-level components
|
|
25
|
+
export { SettingsDialog } from './SettingsDialog';
|
|
26
|
+
export { SettingsForm } from './SettingsForm';
|
|
27
|
+
|
|
28
|
+
// Store + public hooks
|
|
29
|
+
export { useSettingsDialogStore } from './store';
|
|
30
|
+
export {
|
|
31
|
+
useSettingsDialog,
|
|
32
|
+
useSettingsUrl,
|
|
33
|
+
useSettingsSections,
|
|
34
|
+
} from './hooks';
|
|
35
|
+
export type {
|
|
36
|
+
UseSettingsDialogReturn,
|
|
37
|
+
UseSettingsUrlOptions,
|
|
38
|
+
ResolvedGroup,
|
|
39
|
+
} from './hooks';
|
|
40
|
+
|
|
41
|
+
// Context (for advanced custom shells)
|
|
42
|
+
export { SettingsProvider, useSettingsContext } from './context';
|
|
43
|
+
export type { SettingsProviderProps } from './context';
|
|
44
|
+
|
|
45
|
+
// Shell parts (compose a bespoke layout if needed)
|
|
46
|
+
export { SettingsShell, SettingsNav, SettingsNavItem, SettingsPanel, SettingsSearch, SettingsTabs } from './components';
|
|
47
|
+
|
|
48
|
+
// Built-in sections + helpers (extend/replace the defaults)
|
|
49
|
+
export {
|
|
50
|
+
buildBuiltinSections,
|
|
51
|
+
BUILTIN_GROUP,
|
|
52
|
+
AccountSection,
|
|
53
|
+
SecuritySection,
|
|
54
|
+
ApiKeysSection,
|
|
55
|
+
SettingRow,
|
|
56
|
+
SettingsBlock,
|
|
57
|
+
} from './sections';
|
|
58
|
+
|
|
59
|
+
// Types
|
|
60
|
+
export type {
|
|
61
|
+
SettingsSection,
|
|
62
|
+
SettingsGroup,
|
|
63
|
+
SettingsBuiltins,
|
|
64
|
+
SettingsContent,
|
|
65
|
+
SettingsFormProps,
|
|
66
|
+
SettingsDialogProps,
|
|
67
|
+
} from './types';
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { useAuth } from '@djangocfg/api/auth';
|
|
6
|
+
import { SettingRow, SettingsBlock } from '@djangocfg/ui-core/components';
|
|
7
|
+
|
|
8
|
+
import { AvatarSection } from '../components/AvatarSection';
|
|
9
|
+
import { useProfileSave } from '../hooks/useProfileSave';
|
|
10
|
+
import { DeleteAccountRow } from './DeleteAccountRow';
|
|
11
|
+
import { PreferencesRows } from './PreferencesRows';
|
|
12
|
+
|
|
13
|
+
interface AccountSectionProps {
|
|
14
|
+
/** Render the "Danger zone" delete-account control. Default: true. */
|
|
15
|
+
enableDeleteAccount?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Built-in "Account" section — macOS / Claude-settings layout: flat blocks of
|
|
20
|
+
* label↔control rows divided by hairlines, no card frames.
|
|
21
|
+
*
|
|
22
|
+
* Editable fields are declared inline via SettingRow's props API
|
|
23
|
+
* (`value` + `editable` + `onSave`); the chip/input styling lives in SettingRow.
|
|
24
|
+
* Save logic + labels come from `useProfileSave` (no context wrapper needed).
|
|
25
|
+
*/
|
|
26
|
+
export const AccountSection: React.FC<AccountSectionProps> = ({
|
|
27
|
+
enableDeleteAccount = true,
|
|
28
|
+
}) => {
|
|
29
|
+
const { labels, save: onFieldSave } = useProfileSave();
|
|
30
|
+
const { user } = useAuth();
|
|
31
|
+
|
|
32
|
+
if (!user) return null;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="mx-auto max-w-2xl space-y-7">
|
|
36
|
+
<SettingsBlock title={labels.personalInfo}>
|
|
37
|
+
<SettingRow label="Avatar">
|
|
38
|
+
{/* AvatarSection ships a 56–80px avatar; constrain to ~40px without
|
|
39
|
+
forking it (the fixed box drives row height, inner scale shrinks). */}
|
|
40
|
+
<div className="flex h-10 w-10 items-center justify-center">
|
|
41
|
+
<div className="origin-center scale-[0.5]">
|
|
42
|
+
<AvatarSection />
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</SettingRow>
|
|
46
|
+
<SettingRow
|
|
47
|
+
label={labels.firstName}
|
|
48
|
+
value={user.first_name || ''}
|
|
49
|
+
placeholder={labels.addFirstName}
|
|
50
|
+
editable
|
|
51
|
+
onSave={(v) => onFieldSave('first_name', v)}
|
|
52
|
+
/>
|
|
53
|
+
<SettingRow
|
|
54
|
+
label={labels.lastName}
|
|
55
|
+
value={user.last_name || ''}
|
|
56
|
+
placeholder={labels.addLastName}
|
|
57
|
+
editable
|
|
58
|
+
onSave={(v) => onFieldSave('last_name', v)}
|
|
59
|
+
/>
|
|
60
|
+
<SettingRow
|
|
61
|
+
label={labels.phone}
|
|
62
|
+
value={user.phone || ''}
|
|
63
|
+
placeholder={labels.addPhone}
|
|
64
|
+
type="phone"
|
|
65
|
+
editable
|
|
66
|
+
onSave={(v) => onFieldSave('phone', v)}
|
|
67
|
+
/>
|
|
68
|
+
</SettingsBlock>
|
|
69
|
+
|
|
70
|
+
<SettingsBlock title={labels.work}>
|
|
71
|
+
<SettingRow
|
|
72
|
+
label={labels.company}
|
|
73
|
+
value={user.company || ''}
|
|
74
|
+
placeholder={labels.addCompany}
|
|
75
|
+
editable
|
|
76
|
+
onSave={(v) => onFieldSave('company', v)}
|
|
77
|
+
/>
|
|
78
|
+
<SettingRow
|
|
79
|
+
label={labels.position}
|
|
80
|
+
value={user.position || ''}
|
|
81
|
+
placeholder={labels.addPosition}
|
|
82
|
+
editable
|
|
83
|
+
onSave={(v) => onFieldSave('position', v)}
|
|
84
|
+
/>
|
|
85
|
+
</SettingsBlock>
|
|
86
|
+
|
|
87
|
+
<SettingsBlock title={labels.preferences ?? 'Preferences'}>
|
|
88
|
+
<PreferencesRows />
|
|
89
|
+
</SettingsBlock>
|
|
90
|
+
|
|
91
|
+
{enableDeleteAccount && (
|
|
92
|
+
// The whole Danger zone is collapsible (closed by default) so
|
|
93
|
+
// destructive actions stay tucked away until deliberately revealed.
|
|
94
|
+
<SettingsBlock title="Danger zone" collapsible defaultOpen={false}>
|
|
95
|
+
<DeleteAccountRow />
|
|
96
|
+
</SettingsBlock>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { ApiKeySection } from '../components/ApiKeySection';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Built-in "API keys" section body. Wraps the existing ApiKeySection (display,
|
|
9
|
+
* regenerate, test). The panel header supplies the title.
|
|
10
|
+
*/
|
|
11
|
+
export const ApiKeysSection: React.FC = () => (
|
|
12
|
+
<div className="mx-auto max-w-2xl space-y-6">
|
|
13
|
+
<ApiKeySection />
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { Loader2, Trash2 } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
import { useAuth, useDeleteAccount } from '@djangocfg/api/auth';
|
|
7
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
8
|
+
import { Button, SettingRow } from '@djangocfg/ui-core/components';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Compact "Delete account" row — a flat row with a small destructive button on
|
|
12
|
+
* the right. Lives inside the collapsible "Danger zone" block, so the section
|
|
13
|
+
* itself is what's hidden by default; the typed-confirmation prompt is the
|
|
14
|
+
* final safety gate.
|
|
15
|
+
*/
|
|
16
|
+
export const DeleteAccountRow: React.FC = () => {
|
|
17
|
+
const { logout } = useAuth();
|
|
18
|
+
const { deleteAccount, isLoading } = useDeleteAccount();
|
|
19
|
+
const t = useAppT();
|
|
20
|
+
|
|
21
|
+
const confirmationWord = t('layouts.profilePage.confirmationWord');
|
|
22
|
+
|
|
23
|
+
const handleDelete = async () => {
|
|
24
|
+
const value = await window.dialog.prompt({
|
|
25
|
+
title: t('layouts.profilePage.deleteAccountTitle'),
|
|
26
|
+
message: t('layouts.profilePage.deleteAccountDesc'),
|
|
27
|
+
placeholder: confirmationWord,
|
|
28
|
+
confirmText: t('layouts.profilePage.deleteAccount'),
|
|
29
|
+
cancelText: t('layouts.profilePage.cancel'),
|
|
30
|
+
variant: 'destructive',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (value?.toUpperCase() !== confirmationWord.toUpperCase()) return;
|
|
34
|
+
|
|
35
|
+
const result = await deleteAccount();
|
|
36
|
+
if (result.success) logout();
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<SettingRow
|
|
41
|
+
label={t('layouts.profilePage.deleteAccount')}
|
|
42
|
+
description="Permanently remove your account and all associated data."
|
|
43
|
+
action={
|
|
44
|
+
<Button
|
|
45
|
+
variant="destructive"
|
|
46
|
+
size="sm"
|
|
47
|
+
onClick={handleDelete}
|
|
48
|
+
disabled={isLoading}
|
|
49
|
+
className="shrink-0"
|
|
50
|
+
>
|
|
51
|
+
{isLoading ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
|
52
|
+
{t('layouts.profilePage.deleteAccount')}
|
|
53
|
+
</Button>
|
|
54
|
+
}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { SettingRow } from '@djangocfg/ui-core/components';
|
|
6
|
+
import { ThemeSegmented } from '@djangocfg/ui-core/theme';
|
|
7
|
+
|
|
8
|
+
import { LocaleSwitcherDropdown } from '../../_components/locale-switcher';
|
|
9
|
+
import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Preferences as native SettingRows (Language + Theme), so they share the exact
|
|
13
|
+
* row/divider/spacing of the rest of the settings panel. Mirrors the controls
|
|
14
|
+
* from ProfileLayout's PreferencesSection but in the lighter Claude-style row
|
|
15
|
+
* layout. Language only renders when a layout i18n context is present.
|
|
16
|
+
*/
|
|
17
|
+
export const PreferencesRows: React.FC = () => {
|
|
18
|
+
const layoutI18n = useLayoutI18nOptional();
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
{layoutI18n && (
|
|
23
|
+
<SettingRow
|
|
24
|
+
label="Language"
|
|
25
|
+
action={
|
|
26
|
+
<LocaleSwitcherDropdown
|
|
27
|
+
locale={layoutI18n.locale}
|
|
28
|
+
locales={layoutI18n.locales}
|
|
29
|
+
onChange={layoutI18n.onLocaleChange}
|
|
30
|
+
variant="outline"
|
|
31
|
+
size="sm"
|
|
32
|
+
showCode
|
|
33
|
+
showIcon={false}
|
|
34
|
+
showFlag
|
|
35
|
+
showTriggerLabel
|
|
36
|
+
/>
|
|
37
|
+
}
|
|
38
|
+
/>
|
|
39
|
+
)}
|
|
40
|
+
<SettingRow label="Theme" action={<ThemeSegmented />} />
|
|
41
|
+
</>
|
|
42
|
+
);
|
|
43
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { TwoFactorSection } from '../components/TwoFactorSection';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Built-in "Security" section body — two-factor auth setup/disable.
|
|
9
|
+
* Wraps the existing TwoFactorSection; the panel header supplies the title.
|
|
10
|
+
*/
|
|
11
|
+
export const SecuritySection: React.FC = () => (
|
|
12
|
+
<div className="mx-auto max-w-2xl space-y-6">
|
|
13
|
+
<TwoFactorSection />
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { KeyRound, ShieldCheck, UserRound } from 'lucide-react';
|
|
6
|
+
|
|
7
|
+
import type { SettingsBuiltins, SettingsGroup, SettingsSection } from '../types';
|
|
8
|
+
import { AccountSection } from './AccountSection';
|
|
9
|
+
import { ApiKeysSection } from './ApiKeysSection';
|
|
10
|
+
import { SecuritySection } from './SecuritySection';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The group every built-in section belongs to. App sections can reuse this id
|
|
14
|
+
* to sit alongside the built-ins, or define their own groups.
|
|
15
|
+
*/
|
|
16
|
+
export const BUILTIN_GROUP: SettingsGroup = { id: 'general', label: 'General', order: 0 };
|
|
17
|
+
|
|
18
|
+
interface BuildBuiltinArgs {
|
|
19
|
+
builtins: SettingsBuiltins;
|
|
20
|
+
enableDeleteAccount: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Construct the enabled built-in sections. Pure function of the flags — labels
|
|
25
|
+
* are left as plain strings here (the inner section bodies pull localized copy
|
|
26
|
+
* from ProfileProvider); only the nav-level label/title live at this layer.
|
|
27
|
+
*
|
|
28
|
+
* Defaults: account on, security off (needs API support), apiKeys on.
|
|
29
|
+
*/
|
|
30
|
+
export function buildBuiltinSections({
|
|
31
|
+
builtins,
|
|
32
|
+
enableDeleteAccount,
|
|
33
|
+
}: BuildBuiltinArgs): SettingsSection[] {
|
|
34
|
+
const { account = true, security = false, apiKeys = true } = builtins;
|
|
35
|
+
const sections: SettingsSection[] = [];
|
|
36
|
+
|
|
37
|
+
if (account) {
|
|
38
|
+
sections.push({
|
|
39
|
+
id: 'account',
|
|
40
|
+
label: 'Account',
|
|
41
|
+
icon: UserRound,
|
|
42
|
+
group: BUILTIN_GROUP.id,
|
|
43
|
+
title: 'Account',
|
|
44
|
+
description: 'Manage your profile, preferences, and account.',
|
|
45
|
+
keywords: ['profile', 'name', 'email', 'avatar', 'language', 'theme', 'preferences'],
|
|
46
|
+
content: <AccountSection enableDeleteAccount={enableDeleteAccount} />,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (security) {
|
|
51
|
+
sections.push({
|
|
52
|
+
id: 'security',
|
|
53
|
+
label: 'Security',
|
|
54
|
+
icon: ShieldCheck,
|
|
55
|
+
group: BUILTIN_GROUP.id,
|
|
56
|
+
title: 'Security',
|
|
57
|
+
description: 'Two-factor authentication and active sessions.',
|
|
58
|
+
keywords: ['2fa', 'two-factor', 'mfa', 'authenticator', 'password', 'devices'],
|
|
59
|
+
content: <SecuritySection />,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (apiKeys) {
|
|
64
|
+
sections.push({
|
|
65
|
+
id: 'api-keys',
|
|
66
|
+
label: 'API keys',
|
|
67
|
+
icon: KeyRound,
|
|
68
|
+
group: BUILTIN_GROUP.id,
|
|
69
|
+
title: 'API keys',
|
|
70
|
+
description: 'Create and manage keys for programmatic access.',
|
|
71
|
+
keywords: ['api', 'token', 'key', 'secret', 'developer'],
|
|
72
|
+
content: <ApiKeysSection />,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return sections;
|
|
77
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { buildBuiltinSections, BUILTIN_GROUP } from './builtins';
|
|
2
|
+
export { AccountSection } from './AccountSection';
|
|
3
|
+
export { SecuritySection } from './SecuritySection';
|
|
4
|
+
export { ApiKeysSection } from './ApiKeysSection';
|
|
5
|
+
// SettingRow/SettingsBlock now live in ui-core (universal). Re-export for
|
|
6
|
+
// backward compatibility with consumers importing them from @djangocfg/layouts.
|
|
7
|
+
export { SettingRow, SettingsBlock } from '@djangocfg/ui-core/components';
|
|
8
|
+
export { PreferencesRows } from './PreferencesRows';
|