@djangocfg/layouts 2.1.591 → 2.1.593
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 +12 -12
- package/src/components/errors/ErrorLayout.tsx +1 -164
- package/src/components/errors/ErrorsTracker/providers/ErrorTrackingProvider.tsx +1 -100
- package/src/components/errors/ErrorsTracker/providers/errorDedupe.ts +101 -0
- package/src/components/errors/errorGlyphs.tsx +167 -0
- package/src/layouts/AppLayout/BaseApp.tsx +1 -1
- package/src/layouts/AppLayout/index.ts +2 -2
- package/src/layouts/AuthLayout/AuthLayout.tsx +3 -23
- package/src/layouts/AuthLayout/components/shared/AuthTopBar.tsx +1 -1
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +1 -1
- package/src/layouts/AuthLayout/layoutContext.ts +25 -0
- package/src/layouts/AuthLayout/styles/split-shell.css +0 -1
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +37 -26
- package/src/layouts/PrivateLayout/README.md +20 -12
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +29 -42
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +23 -92
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +41 -112
- package/src/layouts/PrivateLayout/components/SidebarBody.tsx +45 -0
- package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +64 -152
- package/src/layouts/PrivateLayout/components/SidebarBrandSwitcher.tsx +2 -30
- package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +55 -115
- package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +37 -100
- package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +34 -0
- package/src/layouts/PrivateLayout/components/SidebarResizeHandle.tsx +76 -0
- package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +14 -53
- package/src/layouts/PrivateLayout/components/index.ts +0 -4
- package/src/layouts/PrivateLayout/context.tsx +10 -115
- package/src/layouts/PrivateLayout/geometry.ts +17 -0
- package/src/layouts/PrivateLayout/hooks/index.ts +2 -1
- package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +4 -4
- package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +33 -93
- package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +151 -0
- package/src/layouts/PrivateLayout/hooks/useSidebarWidth.ts +83 -0
- package/src/layouts/PrivateLayout/types.ts +13 -20
- package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +3 -89
- package/src/layouts/PublicLayout/footers/DefaultFooter/ThemeModeControl.tsx +90 -0
- package/src/layouts/PublicLayout/primitives/DesktopDropdownPortal.tsx +47 -0
- package/src/layouts/PublicLayout/primitives/NavControls.tsx +1 -1
- package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +2 -83
- package/src/layouts/PublicLayout/primitives/navDesktopStyles.ts +42 -0
- package/src/layouts/PublicLayout/shared/NavbarShell.tsx +4 -171
- package/src/layouts/PublicLayout/shared/index.ts +1 -1
- package/src/layouts/PublicLayout/shared/navbarShell.types.ts +109 -0
- package/src/layouts/PublicLayout/shared/navbarShellStyles.ts +69 -0
- package/src/layouts/SettingsLayout/README.md +23 -10
- package/src/layouts/SettingsLayout/SettingsDialog.tsx +27 -11
- package/src/layouts/SettingsLayout/SettingsForm.tsx +13 -9
- package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +83 -0
- package/src/layouts/SettingsLayout/components/ApiKeySection/ApiKeySection.tsx +9 -7
- package/src/layouts/SettingsLayout/components/ApiKeySection/context.tsx +6 -41
- package/src/layouts/SettingsLayout/components/AvatarSection.tsx +11 -3
- package/src/layouts/SettingsLayout/components/SettingsNav.tsx +5 -9
- package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +5 -7
- package/src/layouts/SettingsLayout/components/SettingsPanel.tsx +5 -10
- package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +5 -3
- package/src/layouts/SettingsLayout/components/SettingsShell.tsx +14 -21
- package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +63 -131
- package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSetupDialog.tsx +35 -0
- package/src/layouts/SettingsLayout/components/index.ts +0 -1
- package/src/layouts/SettingsLayout/context/SettingsContext.tsx +5 -2
- package/src/layouts/SettingsLayout/hooks/index.ts +3 -0
- package/src/layouts/SettingsLayout/hooks/useAccountMenuItems.ts +69 -0
- package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +6 -3
- package/src/layouts/SettingsLayout/i18n/index.ts +39 -0
- package/src/layouts/SettingsLayout/i18n/locales/ar.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/da.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/de.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/en.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/es.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/fr.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/it.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/ja.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/ko.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/nl.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/no.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/pl.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/ptBR.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/ru.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/sv.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/tr.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/locales/zh.ts +66 -0
- package/src/layouts/SettingsLayout/i18n/types.ts +65 -0
- package/src/layouts/SettingsLayout/index.ts +3 -1
- package/src/layouts/SettingsLayout/sections/AccountSection.tsx +9 -4
- package/src/layouts/SettingsLayout/sections/DeleteAccountRow.tsx +4 -1
- package/src/layouts/SettingsLayout/sections/GeneralSection.tsx +25 -0
- package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +13 -5
- package/src/layouts/SettingsLayout/sections/builtins.tsx +34 -29
- package/src/layouts/SettingsLayout/sections/index.ts +2 -2
- package/src/layouts/SettingsLayout/store.ts +12 -0
- package/src/layouts/SettingsLayout/types.ts +12 -4
- package/src/layouts/_components/locale-switcher/LocaleSwitcher.tsx +1 -1
- package/src/layouts/types/layout.types.ts +5 -6
- package/src/{layouts/AppLayout → shared}/LayoutI18nProvider.tsx +1 -1
- package/src/styles/dashboard.css +10 -0
- package/src/layouts/PrivateLayout/density.ts +0 -48
- package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +0 -110
- package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +0 -130
- package/src/layouts/SettingsLayout/components/SettingsRailFooter.tsx +0 -74
- package/src/layouts/SettingsLayout/sections/SecuritySection.tsx +0 -15
|
@@ -7,6 +7,7 @@ import { toast } from '@djangocfg/ui-core/hooks';
|
|
|
7
7
|
import { useAuth } from '@djangocfg/api/auth';
|
|
8
8
|
import { Avatar, AvatarFallback, Button } from '@djangocfg/ui-core/components';
|
|
9
9
|
|
|
10
|
+
import { useSettingsCopy } from '../i18n';
|
|
10
11
|
import { profileLogger } from '../../../utils/logger';
|
|
11
12
|
|
|
12
13
|
const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
|
|
@@ -16,7 +17,7 @@ const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
|
|
|
16
17
|
* settings rows, not inside one. Avatar isn't a form field like "First name";
|
|
17
18
|
* it's the user's identity, so it gets the canonical Apple-ID / Slack / Linear
|
|
18
19
|
* treatment: large centered avatar → name/email → a single "Change avatar"
|
|
19
|
-
* action.
|
|
20
|
+
* action, on a soft primary-tinted hero card.
|
|
20
21
|
*
|
|
21
22
|
* UX: the avatar is a preview only; "Change avatar" opens the picker and
|
|
22
23
|
* uploads immediately (no Save/Cancel step). The preview is optimistic — shown
|
|
@@ -24,6 +25,7 @@ const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
|
|
|
24
25
|
* The whole avatar is also clickable for a faster path.
|
|
25
26
|
*/
|
|
26
27
|
export const AvatarSection = () => {
|
|
28
|
+
const copy = useSettingsCopy();
|
|
27
29
|
const { user, uploadAvatar } = useAuth();
|
|
28
30
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
29
31
|
const [isUploading, setIsUploading] = useState(false);
|
|
@@ -70,13 +72,19 @@ export const AvatarSection = () => {
|
|
|
70
72
|
const initials = user?.initials || 'UN';
|
|
71
73
|
|
|
72
74
|
return (
|
|
73
|
-
<div
|
|
75
|
+
<div
|
|
76
|
+
className={
|
|
77
|
+
'flex flex-col items-center gap-3 rounded-2xl border border-foreground/5 px-6 pb-6 pt-8 ' +
|
|
78
|
+
// Soft hero wash tinted by the theme primary — works in light and dark.
|
|
79
|
+
'bg-[radial-gradient(ellipse_at_50%_0%,color-mix(in_oklab,var(--primary)_22%,transparent),transparent_70%),linear-gradient(to_bottom,color-mix(in_oklab,var(--primary)_6%,transparent),transparent)]'
|
|
80
|
+
}
|
|
81
|
+
>
|
|
74
82
|
<button
|
|
75
83
|
type="button"
|
|
76
84
|
onClick={handlePick}
|
|
77
85
|
disabled={isUploading}
|
|
78
86
|
className="group relative rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
79
|
-
aria-label=
|
|
87
|
+
aria-label={copy.account.changeAvatar}
|
|
80
88
|
>
|
|
81
89
|
<Avatar className="h-16 w-16 overflow-hidden rounded-full ring-1 ring-foreground/10 transition-transform group-hover:scale-105 md:h-20 md:w-20">
|
|
82
90
|
{displayUrl ? (
|
|
@@ -10,11 +10,11 @@ import { SettingsSearch } from './SettingsSearch';
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Left rail: optional title, optional search, then grouped section items.
|
|
13
|
-
* Groups with a label render a small
|
|
13
|
+
* Groups with a label render a small muted header; the default (unlabelled)
|
|
14
14
|
* group renders bare. Empty search results show a quiet "no matches" line.
|
|
15
15
|
*/
|
|
16
16
|
export const SettingsNav: React.FC = () => {
|
|
17
|
-
const {
|
|
17
|
+
const { groups, searchable, activeId, setActive, query } = useSettingsContext();
|
|
18
18
|
|
|
19
19
|
const hasResults = groups.some((g) => g.sections.length > 0);
|
|
20
20
|
|
|
@@ -22,12 +22,8 @@ export const SettingsNav: React.FC = () => {
|
|
|
22
22
|
<nav
|
|
23
23
|
role="tablist"
|
|
24
24
|
aria-orientation="vertical"
|
|
25
|
-
className="flex h-full flex-col gap-
|
|
25
|
+
className="flex h-full flex-col gap-2"
|
|
26
26
|
>
|
|
27
|
-
<div className="px-1">
|
|
28
|
-
<h2 className="px-2.5 text-sm font-semibold text-foreground">{title}</h2>
|
|
29
|
-
</div>
|
|
30
|
-
|
|
31
27
|
{searchable && (
|
|
32
28
|
<div className="px-1">
|
|
33
29
|
<SettingsSearch />
|
|
@@ -47,8 +43,8 @@ export const SettingsNav: React.FC = () => {
|
|
|
47
43
|
{group.label && (
|
|
48
44
|
<div
|
|
49
45
|
className={cn(
|
|
50
|
-
'px-2.5 pb-1
|
|
51
|
-
'text-muted-foreground
|
|
46
|
+
'px-2.5 pb-1 pt-1 text-xs',
|
|
47
|
+
'text-muted-foreground',
|
|
52
48
|
)}
|
|
53
49
|
>
|
|
54
50
|
{group.label}
|
|
@@ -31,22 +31,20 @@ export const SettingsNavItem: React.FC<SettingsNavItemProps> = ({
|
|
|
31
31
|
aria-selected={active}
|
|
32
32
|
onClick={() => onSelect(section.id)}
|
|
33
33
|
className={cn(
|
|
34
|
-
'group flex w-full items-center gap-2.5 rounded-
|
|
34
|
+
'group flex h-8 w-full items-center gap-2.5 rounded-lg px-2.5 text-left text-sm',
|
|
35
35
|
'outline-none transition-colors',
|
|
36
36
|
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background',
|
|
37
37
|
active
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
? 'bg-popover text-foreground font-medium'
|
|
42
|
-
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground',
|
|
38
|
+
// Neutral foreground overlay, like the app sidebar — theme-agnostic.
|
|
39
|
+
? 'bg-foreground/10 text-foreground'
|
|
40
|
+
: 'text-foreground/90 hover:bg-foreground/[0.07]',
|
|
43
41
|
)}
|
|
44
42
|
>
|
|
45
43
|
{Icon && (
|
|
46
44
|
<Icon
|
|
47
45
|
className={cn(
|
|
48
46
|
'size-4 shrink-0 transition-colors',
|
|
49
|
-
|
|
47
|
+
'text-foreground/80 group-hover:text-foreground',
|
|
50
48
|
)}
|
|
51
49
|
/>
|
|
52
50
|
)}
|
|
@@ -7,9 +7,9 @@ import { ScrollArea } from '@djangocfg/ui-core/components';
|
|
|
7
7
|
import { useSettingsContext } from '../context';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Right side: the active section's detail
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Right side: the active section's detail in an independently scrolling body.
|
|
11
|
+
* The heading is screen-reader only (the nav already names the section); the
|
|
12
|
+
* visible structure comes from each section's own blocks, like Claude.
|
|
13
13
|
*
|
|
14
14
|
* Only the active section's `content` is in the tree, so heavy panels don't
|
|
15
15
|
* render until selected (and unmount when the dialog closes — Radix default).
|
|
@@ -29,17 +29,12 @@ export const SettingsPanel: React.FC = () => {
|
|
|
29
29
|
|
|
30
30
|
return (
|
|
31
31
|
<div className="flex h-full min-h-0 flex-col">
|
|
32
|
-
<
|
|
33
|
-
<h1 className="text-lg font-semibold tracking-tight text-foreground">{heading}</h1>
|
|
34
|
-
{active.description && (
|
|
35
|
-
<p className="mt-1 text-sm text-muted-foreground">{active.description}</p>
|
|
36
|
-
)}
|
|
37
|
-
</header>
|
|
32
|
+
<h1 className="sr-only">{heading}</h1>
|
|
38
33
|
|
|
39
34
|
<ScrollArea className="min-h-0 flex-1">
|
|
40
35
|
{/* key forces a fresh subtree per section: resets scroll + transient
|
|
41
36
|
form state when switching, which is the expected settings UX. */}
|
|
42
|
-
<div key={active.id} className="px-6 py-
|
|
37
|
+
<div key={active.id} className="px-6 py-5 md:px-8 md:py-6">
|
|
43
38
|
{active.content}
|
|
44
39
|
</div>
|
|
45
40
|
</ScrollArea>
|
|
@@ -6,6 +6,7 @@ import { useAppT } from '@djangocfg/i18n';
|
|
|
6
6
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
7
|
import { Search, X } from 'lucide-react';
|
|
8
8
|
|
|
9
|
+
import { useSettingsCopy } from '../i18n';
|
|
9
10
|
import { useSettingsContext } from '../context';
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -16,6 +17,7 @@ import { useSettingsContext } from '../context';
|
|
|
16
17
|
export const SettingsSearch: React.FC = () => {
|
|
17
18
|
const { query, setQuery } = useSettingsContext();
|
|
18
19
|
const t = useAppT();
|
|
20
|
+
const copy = useSettingsCopy();
|
|
19
21
|
const placeholder = t('ui.select.search') || 'Search';
|
|
20
22
|
|
|
21
23
|
return (
|
|
@@ -28,18 +30,18 @@ export const SettingsSearch: React.FC = () => {
|
|
|
28
30
|
placeholder={placeholder}
|
|
29
31
|
aria-label={placeholder}
|
|
30
32
|
className={cn(
|
|
31
|
-
'
|
|
33
|
+
'h-8 w-full rounded-lg border border-transparent bg-foreground/[0.05] pl-8 pr-8 text-base md:text-sm',
|
|
32
34
|
'text-foreground placeholder:text-muted-foreground',
|
|
33
35
|
'outline-none transition-[border-color,background-color,box-shadow]',
|
|
34
36
|
// Crisp thin accent outline (matches ui-core Input), no blurry halo.
|
|
35
|
-
'focus-visible:border-ring focus-visible:
|
|
37
|
+
'focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring',
|
|
36
38
|
)}
|
|
37
39
|
/>
|
|
38
40
|
{query && (
|
|
39
41
|
<button
|
|
40
42
|
type="button"
|
|
41
43
|
onClick={() => setQuery('')}
|
|
42
|
-
aria-label=
|
|
44
|
+
aria-label={copy.search.clear}
|
|
43
45
|
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors hover:text-foreground"
|
|
44
46
|
>
|
|
45
47
|
<X className="size-3.5" />
|
|
@@ -8,7 +8,6 @@ import { cn } from '@djangocfg/ui-core/lib';
|
|
|
8
8
|
import { useSettingsContext } from '../context';
|
|
9
9
|
import { SettingsNav } from './SettingsNav';
|
|
10
10
|
import { SettingsPanel } from './SettingsPanel';
|
|
11
|
-
import { SettingsRailFooter } from './SettingsRailFooter';
|
|
12
11
|
import { SettingsTabs } from './SettingsTabs';
|
|
13
12
|
|
|
14
13
|
interface SettingsShellProps {
|
|
@@ -44,18 +43,15 @@ export const SettingsShell: React.FC<SettingsShellProps> = ({ bare = false, clas
|
|
|
44
43
|
if (isMobile) {
|
|
45
44
|
return (
|
|
46
45
|
<div className={frame}>
|
|
47
|
-
<div className="shrink-0 bg-
|
|
46
|
+
<div className="shrink-0 bg-background">
|
|
48
47
|
{/* Title row — `pr-12` leaves room for the dialog's corner close button. */}
|
|
49
48
|
<div className="flex items-center gap-3 px-4 pb-2 pt-3.5 pr-12">
|
|
50
49
|
<h2 className="flex-1 truncate text-base font-semibold tracking-tight text-foreground">
|
|
51
50
|
{title}
|
|
52
51
|
</h2>
|
|
53
|
-
{/* Global prefs (theme + language) mirror the desktop rail footer —
|
|
54
|
-
on phones they ride the header instead of a bottom rail. */}
|
|
55
|
-
<SettingsRailFooter horizontal className="border-t-0" />
|
|
56
52
|
</div>
|
|
57
53
|
{/* Tabs on their own row (below the header), so nothing overlaps the X. */}
|
|
58
|
-
<div className="border-b border-
|
|
54
|
+
<div className="border-b border-foreground/10 px-1.5 pb-2">
|
|
59
55
|
<SettingsTabs />
|
|
60
56
|
</div>
|
|
61
57
|
</div>
|
|
@@ -66,23 +62,20 @@ export const SettingsShell: React.FC<SettingsShellProps> = ({ bare = false, clas
|
|
|
66
62
|
);
|
|
67
63
|
}
|
|
68
64
|
|
|
69
|
-
// ── Desktop: rail + panel ──
|
|
65
|
+
// ── Desktop: header bar, then rail + panel on one surface ──
|
|
70
66
|
return (
|
|
71
|
-
<div className={
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
<
|
|
76
|
-
<
|
|
67
|
+
<div className={frame}>
|
|
68
|
+
<header className="flex h-11 shrink-0 items-center border-b border-foreground/[0.12] px-5 pr-14">
|
|
69
|
+
<h2 className="truncate text-lg font-medium text-foreground">{title}</h2>
|
|
70
|
+
</header>
|
|
71
|
+
<div className="flex min-h-0 flex-1">
|
|
72
|
+
<aside className="flex w-[13.25rem] shrink-0 flex-col border-e border-foreground/10 bg-background p-3">
|
|
77
73
|
<SettingsNav />
|
|
78
|
-
</
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
<section className="min-h-0 flex-1 bg-card">
|
|
84
|
-
<SettingsPanel />
|
|
85
|
-
</section>
|
|
74
|
+
</aside>
|
|
75
|
+
<section className="min-h-0 flex-1">
|
|
76
|
+
<SettingsPanel />
|
|
77
|
+
</section>
|
|
78
|
+
</div>
|
|
86
79
|
</div>
|
|
87
80
|
);
|
|
88
81
|
};
|
|
@@ -1,46 +1,25 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { Loader2,
|
|
3
|
+
import { Loader2, ShieldOff } from 'lucide-react';
|
|
4
4
|
import React, { useEffect, useState } from 'react';
|
|
5
5
|
|
|
6
6
|
import { useTwoFactorSetup, useTwoFactorStatus } from '@djangocfg/api/auth';
|
|
7
7
|
import {
|
|
8
8
|
Alert,
|
|
9
9
|
AlertDescription,
|
|
10
|
-
Badge,
|
|
11
10
|
Button,
|
|
12
11
|
OTPInput,
|
|
13
|
-
Preloader,
|
|
14
12
|
SettingRow,
|
|
15
13
|
SettingsBlock,
|
|
16
14
|
} from '@djangocfg/ui-core/components';
|
|
17
|
-
import { cn } from '@djangocfg/ui-core/lib';
|
|
18
15
|
|
|
19
|
-
import {
|
|
16
|
+
import { fill, useSettingsCopy, type SettingsCopy } from '../../i18n';
|
|
17
|
+
import { TwoFactorSetupDialog } from './TwoFactorSetupDialog';
|
|
20
18
|
|
|
21
19
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
20
|
// Types
|
|
23
21
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
22
|
|
|
25
|
-
type View = 'status' | 'setup';
|
|
26
|
-
|
|
27
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
-
// Status badge
|
|
29
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
-
|
|
31
|
-
function StatusBadge({ enabled }: { enabled: boolean }) {
|
|
32
|
-
return (
|
|
33
|
-
<Badge
|
|
34
|
-
variant={enabled ? 'default' : 'secondary'}
|
|
35
|
-
className={cn(
|
|
36
|
-
'text-xs font-medium',
|
|
37
|
-
enabled && 'bg-success-background text-success-foreground border-success-border',
|
|
38
|
-
)}
|
|
39
|
-
>
|
|
40
|
-
{enabled ? 'Enabled' : 'Disabled'}
|
|
41
|
-
</Badge>
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
23
|
|
|
45
24
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
46
25
|
// Disable confirmation — inline, not a dialog. Settings already lives in a
|
|
@@ -61,6 +40,7 @@ function DisableConfirm({
|
|
|
61
40
|
onCancel: () => void;
|
|
62
41
|
}) {
|
|
63
42
|
// Mounted fresh each time the confirmation opens — state starts clean.
|
|
43
|
+
const copy = useSettingsCopy().security;
|
|
64
44
|
const [code, setCode] = useState('');
|
|
65
45
|
|
|
66
46
|
return (
|
|
@@ -68,10 +48,9 @@ function DisableConfirm({
|
|
|
68
48
|
<div className="flex gap-2.5">
|
|
69
49
|
<ShieldOff className="mt-0.5 h-4 w-4 flex-shrink-0 text-destructive" />
|
|
70
50
|
<div className="space-y-1">
|
|
71
|
-
<p className="text-sm font-medium">
|
|
51
|
+
<p className="text-sm font-medium">{copy.disableTitle}</p>
|
|
72
52
|
<p className="text-[13px] leading-relaxed text-muted-foreground">
|
|
73
|
-
|
|
74
|
-
This will make your account less secure.
|
|
53
|
+
{copy.disableHint}
|
|
75
54
|
</p>
|
|
76
55
|
</div>
|
|
77
56
|
</div>
|
|
@@ -94,7 +73,7 @@ function DisableConfirm({
|
|
|
94
73
|
/>
|
|
95
74
|
<div className="flex gap-2">
|
|
96
75
|
<Button variant="outline" size="sm" onClick={onCancel} disabled={isLoading}>
|
|
97
|
-
|
|
76
|
+
{copy.cancel}
|
|
98
77
|
</Button>
|
|
99
78
|
<Button
|
|
100
79
|
variant="destructive"
|
|
@@ -103,9 +82,9 @@ function DisableConfirm({
|
|
|
103
82
|
disabled={isLoading || code.length !== 6}
|
|
104
83
|
>
|
|
105
84
|
{isLoading ? (
|
|
106
|
-
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
85
|
+
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />{copy.disabling}</>
|
|
107
86
|
) : (
|
|
108
|
-
|
|
87
|
+
copy.disable
|
|
109
88
|
)}
|
|
110
89
|
</Button>
|
|
111
90
|
</div>
|
|
@@ -118,9 +97,9 @@ function DisableConfirm({
|
|
|
118
97
|
// Helpers
|
|
119
98
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
120
99
|
|
|
121
|
-
function deviceDescription(createdAt: string, isPrimary: boolean): string {
|
|
122
|
-
const added =
|
|
123
|
-
return isPrimary ? `${added} ·
|
|
100
|
+
function deviceDescription(copy: SettingsCopy['security'], createdAt: string, isPrimary: boolean): string {
|
|
101
|
+
const added = fill(copy.deviceAdded, { date: new Date(createdAt).toLocaleDateString() });
|
|
102
|
+
return isPrimary ? `${added} · ${copy.devicePrimary}` : added;
|
|
124
103
|
}
|
|
125
104
|
|
|
126
105
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -128,7 +107,8 @@ function deviceDescription(createdAt: string, isPrimary: boolean): string {
|
|
|
128
107
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
129
108
|
|
|
130
109
|
export const TwoFactorSection: React.FC = () => {
|
|
131
|
-
const
|
|
110
|
+
const copy = useSettingsCopy().security;
|
|
111
|
+
const [setupOpen, setSetupOpen] = useState(false);
|
|
132
112
|
const [showDisable, setShowDisable] = useState(false);
|
|
133
113
|
|
|
134
114
|
const {
|
|
@@ -147,11 +127,11 @@ export const TwoFactorSection: React.FC = () => {
|
|
|
147
127
|
|
|
148
128
|
const handleEnableClick = () => {
|
|
149
129
|
resetSetup();
|
|
150
|
-
|
|
130
|
+
setSetupOpen(true);
|
|
151
131
|
};
|
|
152
132
|
|
|
153
133
|
const handleSetupDone = () => {
|
|
154
|
-
|
|
134
|
+
setSetupOpen(false);
|
|
155
135
|
fetchStatus();
|
|
156
136
|
};
|
|
157
137
|
|
|
@@ -165,46 +145,15 @@ export const TwoFactorSection: React.FC = () => {
|
|
|
165
145
|
clearError();
|
|
166
146
|
};
|
|
167
147
|
|
|
168
|
-
// ── Setup flow ──────────────────────────────────────────────────────────────
|
|
169
|
-
// Lighter, card-free container. The standalone setup flow brings its own
|
|
170
|
-
// visible (Preloader-based) loading state.
|
|
171
|
-
if (view === 'setup') {
|
|
172
|
-
return (
|
|
173
|
-
<SettingsBlock title="Set up Two-Factor Authentication">
|
|
174
|
-
<div className="flex items-center justify-between pb-2">
|
|
175
|
-
<p className="text-[13px] leading-relaxed text-muted-foreground">
|
|
176
|
-
Scan the QR code with your authenticator app (Google Authenticator, Authy, etc.)
|
|
177
|
-
</p>
|
|
178
|
-
<Button variant="ghost" size="sm" onClick={() => setView('status')}>
|
|
179
|
-
Cancel
|
|
180
|
-
</Button>
|
|
181
|
-
</div>
|
|
182
|
-
<div className="flex justify-center py-2">
|
|
183
|
-
<TwoFactorSetup
|
|
184
|
-
onComplete={handleSetupDone}
|
|
185
|
-
onSkip={() => setView('status')}
|
|
186
|
-
/>
|
|
187
|
-
</div>
|
|
188
|
-
</SettingsBlock>
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// ── Loading skeleton ────────────────────────────────────────────────────────
|
|
193
|
-
if (isLoading && has2FAEnabled === null) {
|
|
194
|
-
return <Preloader variant="inline" className="py-10" />;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
148
|
// ── Status view ─────────────────────────────────────────────────────────────
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
</span>
|
|
207
|
-
);
|
|
149
|
+
// One compact block: a single status row (state + action), the inline confirm
|
|
150
|
+
// when turning off, then the connected devices as plain rows.
|
|
151
|
+
const unknown = has2FAEnabled === null;
|
|
152
|
+
const statusDescription = unknown
|
|
153
|
+
? isLoading ? copy.checking : copy.unavailable
|
|
154
|
+
: has2FAEnabled
|
|
155
|
+
? copy.on
|
|
156
|
+
: copy.off;
|
|
208
157
|
|
|
209
158
|
const statusAction = has2FAEnabled ? (
|
|
210
159
|
<Button
|
|
@@ -212,70 +161,53 @@ export const TwoFactorSection: React.FC = () => {
|
|
|
212
161
|
size="sm"
|
|
213
162
|
onClick={() => { clearError(); setShowDisable(true); }}
|
|
214
163
|
disabled={isLoading || showDisable}
|
|
215
|
-
className="text-destructive hover:text-destructive hover:bg-destructive/10 border-destructive/30"
|
|
216
164
|
>
|
|
217
|
-
|
|
165
|
+
{copy.turnOff}
|
|
218
166
|
</Button>
|
|
219
167
|
) : (
|
|
220
|
-
<Button size="sm" onClick={handleEnableClick} disabled={isLoading}>
|
|
221
|
-
|
|
168
|
+
<Button size="sm" onClick={handleEnableClick} disabled={isLoading || unknown}>
|
|
169
|
+
{copy.turnOn}
|
|
222
170
|
</Button>
|
|
223
171
|
);
|
|
224
172
|
|
|
225
173
|
return (
|
|
226
|
-
|
|
227
|
-
<
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
{
|
|
236
|
-
{
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
onCancel={handleDisableCancel}
|
|
242
|
-
/>
|
|
243
|
-
)}
|
|
244
|
-
|
|
245
|
-
{/* Fetch error */}
|
|
246
|
-
{error && !showDisable && (
|
|
247
|
-
<div className="py-3">
|
|
248
|
-
<Alert variant="destructive">
|
|
249
|
-
<AlertDescription>{error}</AlertDescription>
|
|
250
|
-
</Alert>
|
|
251
|
-
</div>
|
|
252
|
-
)}
|
|
174
|
+
<SettingsBlock title={copy.title}>
|
|
175
|
+
<SettingRow
|
|
176
|
+
label={copy.twoFactor}
|
|
177
|
+
description={statusDescription}
|
|
178
|
+
action={statusAction}
|
|
179
|
+
/>
|
|
180
|
+
|
|
181
|
+
{showDisable && (
|
|
182
|
+
<DisableConfirm
|
|
183
|
+
isLoading={isLoading}
|
|
184
|
+
error={error}
|
|
185
|
+
onConfirm={handleDisableConfirm}
|
|
186
|
+
onCancel={handleDisableCancel}
|
|
187
|
+
/>
|
|
188
|
+
)}
|
|
253
189
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
<Shield className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
|
258
|
-
<span>
|
|
259
|
-
Two-factor authentication adds a second verification step when signing in,
|
|
260
|
-
protecting your account even if your password is compromised.
|
|
261
|
-
</span>
|
|
262
|
-
</p>
|
|
263
|
-
)}
|
|
264
|
-
</SettingsBlock>
|
|
190
|
+
{error && !showDisable && (
|
|
191
|
+
<p className="py-2 text-[13px] text-destructive">{error}</p>
|
|
192
|
+
)}
|
|
265
193
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
194
|
+
{has2FAEnabled && devices.map((device) => (
|
|
195
|
+
<SettingRow
|
|
196
|
+
key={device.id}
|
|
197
|
+
label={device.name}
|
|
198
|
+
description={deviceDescription(copy, device.createdAt, device.isPrimary)}
|
|
199
|
+
/>
|
|
200
|
+
))}
|
|
201
|
+
|
|
202
|
+
<TwoFactorSetupDialog
|
|
203
|
+
open={setupOpen}
|
|
204
|
+
onOpenChange={(open) => {
|
|
205
|
+
setSetupOpen(open);
|
|
206
|
+
// Closing mid-way or after finishing: the status row must reflect the server.
|
|
207
|
+
if (!open) fetchStatus();
|
|
208
|
+
}}
|
|
209
|
+
onComplete={handleSetupDone}
|
|
210
|
+
/>
|
|
211
|
+
</SettingsBlock>
|
|
280
212
|
);
|
|
281
213
|
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@djangocfg/ui-core/components';
|
|
6
|
+
|
|
7
|
+
import { TwoFactorSetup } from '../../../../snippets/TwoFactor';
|
|
8
|
+
import { useSettingsCopy } from '../../i18n';
|
|
9
|
+
|
|
10
|
+
export interface TwoFactorSetupDialogProps {
|
|
11
|
+
open: boolean;
|
|
12
|
+
onOpenChange: (open: boolean) => void;
|
|
13
|
+
/** Runs after the user dismisses the backup codes. */
|
|
14
|
+
onComplete: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Enabling 2FA is one focused task, so it gets its own dialog over Settings instead of
|
|
19
|
+
* replacing the Security block inline. One exit (close); no "skip" — that belongs to the
|
|
20
|
+
* sign-in flow, not to a deliberate opt-in. Content mounts only while open, so each
|
|
21
|
+
* opening starts a fresh setup and an unfinished one is dropped on close.
|
|
22
|
+
*/
|
|
23
|
+
export const TwoFactorSetupDialog: React.FC<TwoFactorSetupDialogProps> = ({ open, onOpenChange, onComplete }) => {
|
|
24
|
+
const copy = useSettingsCopy().security;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
28
|
+
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-1.5rem)] max-w-md flex-col items-center overflow-y-auto bg-card p-6">
|
|
29
|
+
<DialogTitle className="sr-only">{copy.setupTitle}</DialogTitle>
|
|
30
|
+
<DialogDescription className="sr-only">{copy.setupHint}</DialogDescription>
|
|
31
|
+
{open && <TwoFactorSetup onComplete={onComplete} />}
|
|
32
|
+
</DialogContent>
|
|
33
|
+
</Dialog>
|
|
34
|
+
);
|
|
35
|
+
};
|
|
@@ -40,6 +40,9 @@ interface SettingsContextValue {
|
|
|
40
40
|
searchable: boolean;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** `searchable: 'auto'` shows the search box only when the rail is long enough to need it. */
|
|
44
|
+
export const SEARCH_MIN_SECTIONS = 10;
|
|
45
|
+
|
|
43
46
|
const SettingsContext = createContext<SettingsContextValue | null>(null);
|
|
44
47
|
|
|
45
48
|
export const useSettingsContext = (): SettingsContextValue => {
|
|
@@ -54,7 +57,7 @@ export interface SettingsProviderProps {
|
|
|
54
57
|
/** Fully-merged sections (built-ins + app), pre-concatenated by the caller. */
|
|
55
58
|
sections: SettingsSection[];
|
|
56
59
|
groups?: SettingsGroup[];
|
|
57
|
-
searchable: boolean;
|
|
60
|
+
searchable: boolean | 'auto';
|
|
58
61
|
/** Controlled active id (dialog mode passes the store value). */
|
|
59
62
|
activeId: string | null;
|
|
60
63
|
/** Called when active section changes (dialog mode writes the store). */
|
|
@@ -113,7 +116,7 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({
|
|
|
113
116
|
setActive,
|
|
114
117
|
query,
|
|
115
118
|
setQuery,
|
|
116
|
-
searchable,
|
|
119
|
+
searchable: searchable === 'auto' ? visible.length >= SEARCH_MIN_SECTIONS : searchable,
|
|
117
120
|
}),
|
|
118
121
|
[title, resolvedGroups, visible, active, resolvedActiveId, setActive, query, searchable],
|
|
119
122
|
);
|