@djangocfg/layouts 2.1.547 → 2.1.549

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/README.md CHANGED
@@ -66,6 +66,22 @@ provider surface and the Next.js vs Wails wiring, and the demo app
66
66
  > **Admin** is not a separate layout — use `PrivateLayout` with an admin `sidebar`
67
67
  > config in your `app/.../admin/layout.tsx`.
68
68
 
69
+ ### Staff-only route groups
70
+
71
+ `PrivateLayout`'s guard checks the session. For a surface that also requires
72
+ staff, add `requireStaff` (resolved from `useAuth().isAdminUser`, the
73
+ `is_staff || is_superuser` fold):
74
+
75
+ ```tsx
76
+ <PrivateLayout sidebar={sidebar} header={header} requireStaff>{children}</PrivateLayout>
77
+ ```
78
+
79
+ A signed-in non-staff user gets `ForbiddenState` instead of the shell — **not a
80
+ redirect to the auth form**: they are already signed in, so signing in again
81
+ cannot grant the privilege, and since the guard saves the current URL first, a
82
+ redirect would loop back here. Pass `forbiddenFallback` to supply your own
83
+ screen. Details: [PrivateLayout README](./src/layouts/PrivateLayout/README.md).
84
+
69
85
  ### `AuthLayout` shells
70
86
 
71
87
  Two visual variants, switchable via the `variant` prop:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.547",
3
+ "version": "2.1.549",
4
4
  "description": "Simple, straightforward layout components for Next.js - import and use with props",
5
5
  "keywords": [
6
6
  "layouts",
@@ -90,11 +90,11 @@
90
90
  "check": "tsc --noEmit"
91
91
  },
92
92
  "peerDependencies": {
93
- "@djangocfg/analytics": "^2.1.547",
94
- "@djangocfg/api": "^2.1.547",
95
- "@djangocfg/devtools": "^2.1.547",
96
- "@djangocfg/i18n": "^2.1.547",
97
- "@djangocfg/ui-core": "^2.1.547",
93
+ "@djangocfg/analytics": "^2.1.549",
94
+ "@djangocfg/api": "^2.1.549",
95
+ "@djangocfg/devtools": "^2.1.549",
96
+ "@djangocfg/i18n": "^2.1.549",
97
+ "@djangocfg/ui-core": "^2.1.549",
98
98
  "@hookform/resolvers": "^5.2.2",
99
99
  "consola": "^3.4.2",
100
100
  "lucide-react": "^0.545.0",
@@ -124,12 +124,12 @@
124
124
  "uuid": "^11.1.1"
125
125
  },
126
126
  "devDependencies": {
127
- "@djangocfg/analytics": "^2.1.547",
128
- "@djangocfg/api": "^2.1.547",
129
- "@djangocfg/devtools": "^2.1.547",
130
- "@djangocfg/i18n": "^2.1.547",
131
- "@djangocfg/typescript-config": "^2.1.547",
132
- "@djangocfg/ui-core": "^2.1.547",
127
+ "@djangocfg/analytics": "^2.1.549",
128
+ "@djangocfg/api": "^2.1.549",
129
+ "@djangocfg/devtools": "^2.1.549",
130
+ "@djangocfg/i18n": "^2.1.549",
131
+ "@djangocfg/typescript-config": "^2.1.549",
132
+ "@djangocfg/ui-core": "^2.1.549",
133
133
  "@storybook/react-vite": "^10.5.0",
134
134
  "@types/node": "^25.9.5",
135
135
  "@types/react": "19.2.15",
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import React, { memo, useCallback, useMemo, useState } from 'react';
3
+ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
 
5
5
  import { useAppT } from '@djangocfg/i18n';
6
6
 
@@ -78,6 +78,15 @@ function OTPStepRaw() {
78
78
  return `${local[0]}${'*'.repeat(Math.min(local.length - 1, 5))}@${domain}`;
79
79
  }, [identifier]);
80
80
 
81
+ const autoSubmitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
82
+
83
+ useEffect(
84
+ () => () => {
85
+ if (autoSubmitTimerRef.current) clearTimeout(autoSubmitTimerRef.current);
86
+ },
87
+ []
88
+ );
89
+
81
90
  // Auto-submit on complete (state machine approach)
82
91
  const handleOTPComplete = useCallback(
83
92
  async (completedValue: string) => {
@@ -87,7 +96,11 @@ function OTPStepRaw() {
87
96
  setSubmitState('submitting');
88
97
  const fakeEvent = { preventDefault: () => {} } as React.FormEvent;
89
98
 
90
- setTimeout(async () => {
99
+ // Tracked so leaving the step cancels it. This component unmounts when
100
+ // the user goes back to the identifier, and an uncancelled timer would
101
+ // submit the code they just abandoned.
102
+ autoSubmitTimerRef.current = setTimeout(async () => {
103
+ autoSubmitTimerRef.current = null;
91
104
  try {
92
105
  await handleOTPSubmit(fakeEvent);
93
106
  } finally {
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useState } from 'react';
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
4
 
5
5
  import { AUTH } from '../constants';
6
6
 
@@ -18,13 +18,27 @@ export const useCopyToClipboard = (
18
18
  duration = AUTH.COPY_FEEDBACK_DURATION
19
19
  ): UseCopyToClipboardResult => {
20
20
  const [copied, setCopied] = useState(false);
21
+ // One timer, cleared on unmount. Copying twice in quick succession stacked
22
+ // two, so the first cleared the flag while the second copy was still fresh.
23
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
24
+
25
+ useEffect(
26
+ () => () => {
27
+ if (timerRef.current) clearTimeout(timerRef.current);
28
+ },
29
+ []
30
+ );
21
31
 
22
32
  const copy = useCallback(
23
33
  async (text: string) => {
24
34
  try {
25
35
  await navigator.clipboard.writeText(text);
26
36
  setCopied(true);
27
- setTimeout(() => setCopied(false), duration);
37
+ if (timerRef.current) clearTimeout(timerRef.current);
38
+ timerRef.current = setTimeout(() => {
39
+ timerRef.current = null;
40
+ setCopied(false);
41
+ }, duration);
28
42
  } catch {
29
43
  // Clipboard API not available
30
44
  console.warn('Clipboard API not available');
@@ -4,6 +4,10 @@
4
4
  * Authenticated shell: collapsible sidebar (icon rail vs expanded) + scrollable content.
5
5
  * Toggle lives in the sidebar header on desktop; on narrow viewports a `SidebarTrigger` sits in `PrivateContent` (opens the mobile Sheet).
6
6
  * Ctrl/Cmd + B still toggles the sidebar width.
7
+ *
8
+ * Guarded: no session redirects to `header.authPath`. With `requireStaff`, a
9
+ * signed-in non-staff user gets `ForbiddenState` instead — never a redirect,
10
+ * since signing in again cannot grant the privilege.
7
11
  */
8
12
 
9
13
  'use client';
@@ -13,7 +17,7 @@ import React from 'react';
13
17
  import { Preloader } from '@djangocfg/ui-core/components';
14
18
  import { SidebarInset, SidebarProvider } from '@djangocfg/ui-core/components';
15
19
 
16
- import { PrivateContent, PrivateSidebar } from './components';
20
+ import { ForbiddenState, PrivateContent, PrivateSidebar } from './components';
17
21
  import { useAuthGuard } from './hooks';
18
22
  import { useLayoutVisual } from './hooks';
19
23
  import { useSidebarDefaultOpen } from './hooks';
@@ -49,10 +53,13 @@ export function PrivateLayout({
49
53
  contentScroll = 'auto',
50
54
  visual,
51
55
  requireAuth = true,
56
+ requireStaff = false,
57
+ forbiddenFallback,
52
58
  settings,
53
59
  }: PrivateLayoutProps) {
54
- const { isLoading, loadingText } = useAuthGuard({
60
+ const { isLoading, isForbidden, loadingText } = useAuthGuard({
55
61
  requireAuth,
62
+ requireStaff,
56
63
  authPath: header?.authPath,
57
64
  });
58
65
 
@@ -73,6 +80,13 @@ export function PrivateLayout({
73
80
  );
74
81
  }
75
82
 
83
+ // Before the shell: a forbidden user must never reach the sidebar or the
84
+ // children, or every request they trigger returns 403 and the screen cannot
85
+ // be told apart from a backend outage.
86
+ if (isForbidden) {
87
+ return <>{forbiddenFallback ?? <ForbiddenState />}</>;
88
+ }
89
+
76
90
  return (
77
91
  <SidebarProvider
78
92
  defaultOpen={defaultOpen}
@@ -17,6 +17,38 @@ The auth guard redirects to `header.authPath` when there's no session. Pass `req
17
17
 
18
18
  ---
19
19
 
20
+ ## Staff-only surfaces
21
+
22
+ Add `requireStaff` when the whole route group is staff-only:
23
+
24
+ ```tsx
25
+ <PrivateLayout sidebar={sidebar} header={header} requireStaff>{children}</PrivateLayout>
26
+ ```
27
+
28
+ It resolves from `useAuth().isAdminUser` — the `is_staff || is_superuser` fold,
29
+ never one flag on its own.
30
+
31
+ | Prop | Type | Default | Role |
32
+ |---|---|---|---|
33
+ | `requireStaff` | `boolean` | `false` | Require staff/admin on top of a session. |
34
+ | `forbiddenFallback` | `ReactNode` | `<ForbiddenState />` | Replaces the built-in screen. |
35
+
36
+ **On failure the forbidden state renders instead of the shell — there is no
37
+ redirect to the auth form.** The user is already signed in, so the sign-in form
38
+ cannot fix anything; and because the guard saves the current URL before
39
+ redirecting, a re-login on the same account would land straight back here, in a
40
+ loop. The default `ForbiddenState` names the signed-in account and offers sign
41
+ out, which is the one action that helps: it lets them switch accounts.
42
+
43
+ Gate the shell rather than each control inside it, so the wall renders **before
44
+ any request fires**. Without it a non-staff operator reaches the surface, every
45
+ call returns `403`, and the screen is indistinguishable from a backend outage.
46
+
47
+ Presentation, not protection: the endpoints behind the surface must withhold the
48
+ data themselves. The gate decides what is *offered*, never what is *permitted*.
49
+
50
+ ---
51
+
20
52
  ## Wiring (canonical)
21
53
 
22
54
  Mount it in the **route-group `layout.tsx`** that owns the authenticated section
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Forbidden State
3
+ *
4
+ * Shown when a signed-in user lacks the privilege a route requires
5
+ * (`requireStaff`). Deliberately NOT a redirect to the sign-in form: the
6
+ * session is valid, so signing in again changes nothing — and since the guard
7
+ * saves the current URL first, a redirect would loop straight back here.
8
+ *
9
+ * It renders before any request fires, so the operator sees "wrong account"
10
+ * instead of a screen full of 403s indistinguishable from a backend outage.
11
+ * Sign out is the only action that helps: it lets them switch accounts.
12
+ */
13
+
14
+ 'use client';
15
+
16
+ import React from 'react';
17
+
18
+ import { ShieldAlert } from 'lucide-react';
19
+
20
+ import { useAuth } from '@djangocfg/api/auth';
21
+ import { Button } from '@djangocfg/ui-core/components';
22
+
23
+ export interface ForbiddenStateProps {
24
+ /** Heading. Defaults to a staff-access message. */
25
+ title?: string;
26
+ /** Explanation under the heading. */
27
+ description?: string;
28
+ }
29
+
30
+ export function ForbiddenState({
31
+ title = 'Staff access required',
32
+ description = 'Your account is signed in but does not have staff permissions for this area. Sign out and use a staff account, or ask an administrator to grant access.',
33
+ }: ForbiddenStateProps) {
34
+ const { user, logout } = useAuth();
35
+
36
+ return (
37
+ <div
38
+ role="alert"
39
+ className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center"
40
+ >
41
+ <div className="flex size-12 items-center justify-center rounded-full bg-destructive/10">
42
+ <ShieldAlert className="size-6 text-destructive" />
43
+ </div>
44
+
45
+ <div className="space-y-2">
46
+ <h1 className="text-lg font-semibold">{title}</h1>
47
+ <p className="max-w-md text-sm text-muted-foreground">{description}</p>
48
+ {/* Naming the account is the fastest way to spot the usual cause:
49
+ signed in on the right site with the wrong account. */}
50
+ {user?.email && (
51
+ <p className="text-xs text-muted-foreground">
52
+ Signed in as <span className="font-medium">{user.email}</span>
53
+ </p>
54
+ )}
55
+ </div>
56
+
57
+ <Button variant="outline" size="sm" onClick={logout}>
58
+ Sign out
59
+ </Button>
60
+ </div>
61
+ );
62
+ }
@@ -4,6 +4,8 @@
4
4
 
5
5
  export { PrivateSidebar } from './PrivateSidebar';
6
6
  export { PrivateContent } from './PrivateContent';
7
+ export { ForbiddenState } from './ForbiddenState';
8
+ export type { ForbiddenStateProps } from './ForbiddenState';
7
9
  export { SidebarBrand } from './SidebarBrand';
8
10
  export { SidebarBrandSwitcher } from './SidebarBrandSwitcher';
9
11
  export { SidebarNavGroup } from './SidebarNavGroup';
@@ -14,10 +14,16 @@ import {
14
14
  resolveGuardIsLoading,
15
15
  resolveGuardIsAuthenticated,
16
16
  shouldRedirectToAuth,
17
+ isForbidden,
17
18
  } from '@djangocfg/api/auth';
18
19
 
19
20
  interface UseAuthGuardOptions {
20
21
  requireAuth?: boolean;
22
+ /**
23
+ * Require staff/admin (`is_staff || is_superuser`) on top of a session.
24
+ * A signed-in non-staff user gets the forbidden state, not a redirect.
25
+ */
26
+ requireStaff?: boolean;
21
27
  /** Explicit override; defaults to the app's resolved `useAuth().routes.auth`. */
22
28
  authPath?: string;
23
29
  /**
@@ -34,16 +40,19 @@ interface UseAuthGuardResult {
34
40
  isLoading: boolean;
35
41
  /** Whether the user is authenticated (or auth is not required) */
36
42
  isAuthenticated: boolean;
43
+ /** Signed in, but lacking the privilege this route requires. */
44
+ isForbidden: boolean;
37
45
  /** Loading text for the preloader */
38
46
  loadingText: string;
39
47
  }
40
48
 
41
49
  export function useAuthGuard({
42
50
  requireAuth = true,
51
+ requireStaff = false,
43
52
  authPath,
44
53
  loadingTimeoutMs = 8000,
45
54
  }: UseAuthGuardOptions): UseAuthGuardResult {
46
- const { isAuthenticated, isLoading: authLoading, saveRedirectUrl, routes } = useAuth();
55
+ const { isAuthenticated, isLoading: authLoading, isAdminUser, saveRedirectUrl, routes } = useAuth();
47
56
  const resolvedAuthPath = authPath ?? routes.auth;
48
57
  const router = useRouter();
49
58
  const [isRedirecting, setIsRedirecting] = useState(false);
@@ -78,6 +87,8 @@ export function useAuthGuard({
78
87
  authLoading: effectiveAuthLoading,
79
88
  isRedirecting,
80
89
  isAuthenticated,
90
+ requireStaff,
91
+ isAdminUser,
81
92
  };
82
93
 
83
94
  useEffect(() => {
@@ -96,6 +107,9 @@ export function useAuthGuard({
96
107
  return {
97
108
  isLoading,
98
109
  isAuthenticated: resolveGuardIsAuthenticated(guardState),
110
+ // Never redirects: shouldRedirectToAuth requires !isAuthenticated, so a
111
+ // forbidden user is structurally excluded from the redirect effect above.
112
+ isForbidden: isForbidden(guardState),
99
113
  loadingText,
100
114
  };
101
115
  }
@@ -4,7 +4,8 @@
4
4
 
5
5
  export { PrivateLayout } from './PrivateLayout';
6
6
  export { PrivateLayoutProvider, usePrivateLayoutContext } from './context';
7
- export { SidebarBrandSwitcher } from './components';
7
+ export { SidebarBrandSwitcher, ForbiddenState } from './components';
8
+ export type { ForbiddenStateProps } from './components';
8
9
  export type {
9
10
  PrivateLayoutProps,
10
11
  SidebarItem,
@@ -223,6 +223,21 @@ export interface PrivateLayoutProps {
223
223
  * embeds where there's no real session. Default `true` (guard on).
224
224
  */
225
225
  requireAuth?: boolean;
226
+ /**
227
+ * Require staff/admin (`is_staff || is_superuser`) on top of a session.
228
+ * A signed-in non-staff user gets the forbidden state instead of the shell —
229
+ * not a redirect, which would loop since signing in again cannot grant the
230
+ * privilege. Default `false`.
231
+ *
232
+ * Presentation only: the endpoints behind this surface must withhold the
233
+ * data themselves. The gate decides what is offered, never what is permitted.
234
+ */
235
+ requireStaff?: boolean;
236
+ /**
237
+ * Replaces the built-in "not authorised" screen shown when `requireStaff`
238
+ * fails. Omit for the default `ForbiddenState`.
239
+ */
240
+ forbiddenFallback?: ReactNode;
226
241
  /**
227
242
  * Mount the global SettingsDialog (Claude-style master/detail settings modal,
228
243
  * hash-URL driven, openable via `useSettingsDialog()`). Pass a config object
@@ -77,6 +77,7 @@ export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = ({
77
77
  }) => {
78
78
  const [step, setStep] = useState<AuthStep>(initialStep);
79
79
  const [identifier, setIdentifier] = useState(initialIdentifier);
80
+ const [password, setPassword] = useState('');
80
81
  const [otp, setOtp] = useState(initialOtp);
81
82
  const [isLoading, setIsLoading] = useState(initialLoading);
82
83
  const [acceptedTerms, setAcceptedTerms] = useState(initialAcceptedTerms);
@@ -100,6 +101,7 @@ export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = ({
100
101
  return {
101
102
  // State
102
103
  identifier,
104
+ password,
103
105
  otp,
104
106
  isLoading,
105
107
  acceptedTerms,
@@ -118,6 +120,7 @@ export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = ({
118
120
 
119
121
  // State handlers
120
122
  setIdentifier,
123
+ setPassword,
121
124
  setOtp,
122
125
  setAcceptedTerms,
123
126
  setRememberMe,
@@ -137,6 +140,7 @@ export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = ({
137
140
  // step never changes from under the storyteller. To preview a
138
141
  // different step, render a new story with `step="..."`.
139
142
  handleIdentifierSubmit: async (e) => stop(e),
143
+ handlePasswordSubmit: async (e) => stop(e),
140
144
  handleOTPSubmit: async (e) => stop(e),
141
145
  handleResendOTP: async () => {},
142
146
  handleBackToIdentifier: () => setStep('identifier'),