@djangocfg/layouts 2.1.547 → 2.1.548

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.548",
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.548",
94
+ "@djangocfg/api": "^2.1.548",
95
+ "@djangocfg/devtools": "^2.1.548",
96
+ "@djangocfg/i18n": "^2.1.548",
97
+ "@djangocfg/ui-core": "^2.1.548",
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.548",
128
+ "@djangocfg/api": "^2.1.548",
129
+ "@djangocfg/devtools": "^2.1.548",
130
+ "@djangocfg/i18n": "^2.1.548",
131
+ "@djangocfg/typescript-config": "^2.1.548",
132
+ "@djangocfg/ui-core": "^2.1.548",
133
133
  "@storybook/react-vite": "^10.5.0",
134
134
  "@types/node": "^25.9.5",
135
135
  "@types/react": "19.2.15",
@@ -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