@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.
Files changed (100) hide show
  1. package/package.json +12 -12
  2. package/src/components/errors/ErrorLayout.tsx +1 -164
  3. package/src/components/errors/ErrorsTracker/providers/ErrorTrackingProvider.tsx +1 -100
  4. package/src/components/errors/ErrorsTracker/providers/errorDedupe.ts +101 -0
  5. package/src/components/errors/errorGlyphs.tsx +167 -0
  6. package/src/layouts/AppLayout/BaseApp.tsx +1 -1
  7. package/src/layouts/AppLayout/index.ts +2 -2
  8. package/src/layouts/AuthLayout/AuthLayout.tsx +3 -23
  9. package/src/layouts/AuthLayout/components/shared/AuthTopBar.tsx +1 -1
  10. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +1 -1
  11. package/src/layouts/AuthLayout/layoutContext.ts +25 -0
  12. package/src/layouts/AuthLayout/styles/split-shell.css +0 -1
  13. package/src/layouts/PrivateLayout/PrivateLayout.tsx +37 -26
  14. package/src/layouts/PrivateLayout/README.md +20 -12
  15. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +29 -42
  16. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +23 -92
  17. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +41 -112
  18. package/src/layouts/PrivateLayout/components/SidebarBody.tsx +45 -0
  19. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +64 -152
  20. package/src/layouts/PrivateLayout/components/SidebarBrandSwitcher.tsx +2 -30
  21. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +55 -115
  22. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +37 -100
  23. package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +34 -0
  24. package/src/layouts/PrivateLayout/components/SidebarResizeHandle.tsx +76 -0
  25. package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +14 -53
  26. package/src/layouts/PrivateLayout/components/index.ts +0 -4
  27. package/src/layouts/PrivateLayout/context.tsx +10 -115
  28. package/src/layouts/PrivateLayout/geometry.ts +17 -0
  29. package/src/layouts/PrivateLayout/hooks/index.ts +2 -1
  30. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +4 -4
  31. package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +33 -93
  32. package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +151 -0
  33. package/src/layouts/PrivateLayout/hooks/useSidebarWidth.ts +83 -0
  34. package/src/layouts/PrivateLayout/types.ts +13 -20
  35. package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +3 -89
  36. package/src/layouts/PublicLayout/footers/DefaultFooter/ThemeModeControl.tsx +90 -0
  37. package/src/layouts/PublicLayout/primitives/DesktopDropdownPortal.tsx +47 -0
  38. package/src/layouts/PublicLayout/primitives/NavControls.tsx +1 -1
  39. package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +2 -83
  40. package/src/layouts/PublicLayout/primitives/navDesktopStyles.ts +42 -0
  41. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +4 -171
  42. package/src/layouts/PublicLayout/shared/index.ts +1 -1
  43. package/src/layouts/PublicLayout/shared/navbarShell.types.ts +109 -0
  44. package/src/layouts/PublicLayout/shared/navbarShellStyles.ts +69 -0
  45. package/src/layouts/SettingsLayout/README.md +23 -10
  46. package/src/layouts/SettingsLayout/SettingsDialog.tsx +27 -11
  47. package/src/layouts/SettingsLayout/SettingsForm.tsx +13 -9
  48. package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +83 -0
  49. package/src/layouts/SettingsLayout/components/ApiKeySection/ApiKeySection.tsx +9 -7
  50. package/src/layouts/SettingsLayout/components/ApiKeySection/context.tsx +6 -41
  51. package/src/layouts/SettingsLayout/components/AvatarSection.tsx +11 -3
  52. package/src/layouts/SettingsLayout/components/SettingsNav.tsx +5 -9
  53. package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +5 -7
  54. package/src/layouts/SettingsLayout/components/SettingsPanel.tsx +5 -10
  55. package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +5 -3
  56. package/src/layouts/SettingsLayout/components/SettingsShell.tsx +14 -21
  57. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +63 -131
  58. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSetupDialog.tsx +35 -0
  59. package/src/layouts/SettingsLayout/components/index.ts +0 -1
  60. package/src/layouts/SettingsLayout/context/SettingsContext.tsx +5 -2
  61. package/src/layouts/SettingsLayout/hooks/index.ts +3 -0
  62. package/src/layouts/SettingsLayout/hooks/useAccountMenuItems.ts +69 -0
  63. package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +6 -3
  64. package/src/layouts/SettingsLayout/i18n/index.ts +39 -0
  65. package/src/layouts/SettingsLayout/i18n/locales/ar.ts +66 -0
  66. package/src/layouts/SettingsLayout/i18n/locales/da.ts +66 -0
  67. package/src/layouts/SettingsLayout/i18n/locales/de.ts +66 -0
  68. package/src/layouts/SettingsLayout/i18n/locales/en.ts +66 -0
  69. package/src/layouts/SettingsLayout/i18n/locales/es.ts +66 -0
  70. package/src/layouts/SettingsLayout/i18n/locales/fr.ts +66 -0
  71. package/src/layouts/SettingsLayout/i18n/locales/it.ts +66 -0
  72. package/src/layouts/SettingsLayout/i18n/locales/ja.ts +66 -0
  73. package/src/layouts/SettingsLayout/i18n/locales/ko.ts +66 -0
  74. package/src/layouts/SettingsLayout/i18n/locales/nl.ts +66 -0
  75. package/src/layouts/SettingsLayout/i18n/locales/no.ts +66 -0
  76. package/src/layouts/SettingsLayout/i18n/locales/pl.ts +66 -0
  77. package/src/layouts/SettingsLayout/i18n/locales/ptBR.ts +66 -0
  78. package/src/layouts/SettingsLayout/i18n/locales/ru.ts +66 -0
  79. package/src/layouts/SettingsLayout/i18n/locales/sv.ts +66 -0
  80. package/src/layouts/SettingsLayout/i18n/locales/tr.ts +66 -0
  81. package/src/layouts/SettingsLayout/i18n/locales/zh.ts +66 -0
  82. package/src/layouts/SettingsLayout/i18n/types.ts +65 -0
  83. package/src/layouts/SettingsLayout/index.ts +3 -1
  84. package/src/layouts/SettingsLayout/sections/AccountSection.tsx +9 -4
  85. package/src/layouts/SettingsLayout/sections/DeleteAccountRow.tsx +4 -1
  86. package/src/layouts/SettingsLayout/sections/GeneralSection.tsx +25 -0
  87. package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +13 -5
  88. package/src/layouts/SettingsLayout/sections/builtins.tsx +34 -29
  89. package/src/layouts/SettingsLayout/sections/index.ts +2 -2
  90. package/src/layouts/SettingsLayout/store.ts +12 -0
  91. package/src/layouts/SettingsLayout/types.ts +12 -4
  92. package/src/layouts/_components/locale-switcher/LocaleSwitcher.tsx +1 -1
  93. package/src/layouts/types/layout.types.ts +5 -6
  94. package/src/{layouts/AppLayout → shared}/LayoutI18nProvider.tsx +1 -1
  95. package/src/styles/dashboard.css +10 -0
  96. package/src/layouts/PrivateLayout/density.ts +0 -48
  97. package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +0 -110
  98. package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +0 -130
  99. package/src/layouts/SettingsLayout/components/SettingsRailFooter.tsx +0 -74
  100. 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 className="flex flex-col items-center gap-3 py-2">
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="Change avatar"
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 uppercase header; the default (unlabelled)
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 { title, groups, searchable, activeId, setActive, query } = useSettingsContext();
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-3"
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 text-[11px] font-medium uppercase tracking-wider',
51
- 'text-muted-foreground/70',
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-control px-2.5 py-2 text-left text-sm',
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
- // Solid raised surface (popover-level) — pops off the muted rail in
39
- // BOTH themes (on light the rail-vs-accent gap is only ~2% and
40
- // invisible; an elevated surface fixes that). No shadow — flat fill.
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
- active ? 'text-foreground' : 'text-muted-foreground group-hover:text-foreground',
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. Renders a header (title + optional
11
- * description) pinned above an independently scrolling body. The header falls
12
- * back to the section's nav label when no explicit `title` is given.
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
- <header className="shrink-0 border-b border-border px-6 py-4 md:px-8 md:py-5">
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-6 md:px-8">
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
- 'rounded-control h-9 w-full border border-border bg-muted/40 pl-8 pr-8 text-sm',
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:bg-background focus-visible:ring-1 focus-visible:ring-ring',
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="Clear search"
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-muted">
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-border px-1.5 pb-2">
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={cn(frame, 'flex-row')}>
72
- {/* Nav rail — recessed surface, one notch below the content panel.
73
- Column layout so the section list scrolls in the middle and the
74
- global-preferences footer (theme + language) pins to the bottom. */}
75
- <aside className="flex shrink-0 flex-col border-r border-border bg-muted p-3 md:w-52">
76
- <div className="min-h-0 flex-1">
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
- </div>
79
- <SettingsRailFooter className="mt-3" />
80
- </aside>
81
-
82
- {/* Detail panel — the lighter, primary surface. */}
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, Shield, ShieldOff } from 'lucide-react';
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 { TwoFactorSetup } from '../../../../snippets/TwoFactor';
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">Disable two-factor authentication?</p>
51
+ <p className="text-sm font-medium">{copy.disableTitle}</p>
72
52
  <p className="text-[13px] leading-relaxed text-muted-foreground">
73
- Enter the 6-digit code from your authenticator app to confirm.
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
- Cancel
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" />Disabling…</>
85
+ <><Loader2 className="mr-2 h-4 w-4 animate-spin" />{copy.disabling}</>
107
86
  ) : (
108
- 'Disable 2FA'
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 = `Added ${new Date(createdAt).toLocaleDateString()}`;
123
- return isPrimary ? `${added} · Primary` : 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 [view, setView] = useState<View>('status');
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
- setView('setup');
130
+ setSetupOpen(true);
151
131
  };
152
132
 
153
133
  const handleSetupDone = () => {
154
- setView('status');
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
- const statusDescription = (
199
- <span className="inline-flex items-center gap-2">
200
- <StatusBadge enabled={!!has2FAEnabled} />
201
- <span>
202
- {has2FAEnabled
203
- ? `${devices.length} authenticator device${devices.length !== 1 ? 's' : ''} connected`
204
- : 'Add an extra layer of security to your account'}
205
- </span>
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
- Disable
165
+ {copy.turnOff}
218
166
  </Button>
219
167
  ) : (
220
- <Button size="sm" onClick={handleEnableClick} disabled={isLoading}>
221
- Enable 2FA
168
+ <Button size="sm" onClick={handleEnableClick} disabled={isLoading || unknown}>
169
+ {copy.turnOn}
222
170
  </Button>
223
171
  );
224
172
 
225
173
  return (
226
- <>
227
- <div className="space-y-6">
228
- <SettingsBlock>
229
- <SettingRow
230
- label="Two-Factor Authentication"
231
- description={statusDescription}
232
- action={statusAction}
233
- />
234
-
235
- {/* Inline destructive confirmation (no nested dialog) */}
236
- {showDisable && (
237
- <DisableConfirm
238
- isLoading={isLoading}
239
- error={error}
240
- onConfirm={handleDisableConfirm}
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
- {/* Not enabled — subtle muted note */}
255
- {!has2FAEnabled && (
256
- <p className="flex gap-2 pt-3 text-[13px] leading-relaxed text-muted-foreground">
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
- {/* Device list */}
267
- {has2FAEnabled && devices.length > 0 && (
268
- <SettingsBlock title="Connected devices">
269
- {devices.map((device) => (
270
- <SettingRow
271
- key={device.id}
272
- label={device.name}
273
- description={deviceDescription(device.createdAt, device.isPrimary)}
274
- />
275
- ))}
276
- </SettingsBlock>
277
- )}
278
- </div>
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
+ };
@@ -4,4 +4,3 @@ export { SettingsTabs } from './SettingsTabs';
4
4
  export { SettingsNavItem } from './SettingsNavItem';
5
5
  export { SettingsPanel } from './SettingsPanel';
6
6
  export { SettingsSearch } from './SettingsSearch';
7
- export { SettingsRailFooter } from './SettingsRailFooter';
@@ -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
  );
@@ -10,3 +10,6 @@ export type {
10
10
  UseSettingsSectionsArgs,
11
11
  UseSettingsSectionsResult,
12
12
  } from './useSettingsSections';
13
+
14
+ export { useAccountMenuItems } from './useAccountMenuItems';
15
+ export type { UseAccountMenuItemsOptions } from './useAccountMenuItems';