@djangocfg/layouts 2.1.596 → 2.1.598
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/layouts/SettingsLayout/SettingsDialog.tsx +2 -0
- package/src/layouts/SettingsLayout/hooks/useGuestIntentRedirect.ts +31 -0
- package/src/layouts/SettingsLayout/hooks/useSettingsUrl.ts +5 -5
- package/src/layouts/SettingsLayout/intent/parseSettingsHash.test.ts +25 -1
- package/src/layouts/SettingsLayout/intent/parseSettingsHash.ts +17 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.598",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -91,11 +91,11 @@
|
|
|
91
91
|
"test": "vitest run"
|
|
92
92
|
},
|
|
93
93
|
"peerDependencies": {
|
|
94
|
-
"@djangocfg/analytics": "^2.1.
|
|
95
|
-
"@djangocfg/api": "^2.1.
|
|
96
|
-
"@djangocfg/devtools": "^2.1.
|
|
97
|
-
"@djangocfg/i18n": "^2.1.
|
|
98
|
-
"@djangocfg/ui-core": "^2.1.
|
|
94
|
+
"@djangocfg/analytics": "^2.1.598",
|
|
95
|
+
"@djangocfg/api": "^2.1.598",
|
|
96
|
+
"@djangocfg/devtools": "^2.1.598",
|
|
97
|
+
"@djangocfg/i18n": "^2.1.598",
|
|
98
|
+
"@djangocfg/ui-core": "^2.1.598",
|
|
99
99
|
"@hookform/resolvers": "^5.2.2",
|
|
100
100
|
"consola": "^3.4.2",
|
|
101
101
|
"lucide-react": "^0.545.0",
|
|
@@ -125,12 +125,12 @@
|
|
|
125
125
|
"uuid": "^11.1.1"
|
|
126
126
|
},
|
|
127
127
|
"devDependencies": {
|
|
128
|
-
"@djangocfg/analytics": "^2.1.
|
|
129
|
-
"@djangocfg/api": "^2.1.
|
|
130
|
-
"@djangocfg/devtools": "^2.1.
|
|
131
|
-
"@djangocfg/i18n": "^2.1.
|
|
132
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
133
|
-
"@djangocfg/ui-core": "^2.1.
|
|
128
|
+
"@djangocfg/analytics": "^2.1.598",
|
|
129
|
+
"@djangocfg/api": "^2.1.598",
|
|
130
|
+
"@djangocfg/devtools": "^2.1.598",
|
|
131
|
+
"@djangocfg/i18n": "^2.1.598",
|
|
132
|
+
"@djangocfg/typescript-config": "^2.1.598",
|
|
133
|
+
"@djangocfg/ui-core": "^2.1.598",
|
|
134
134
|
"@storybook/react-vite": "^10.5.0",
|
|
135
135
|
"@types/node": "^25.9.5",
|
|
136
136
|
"@types/react": "19.2.15",
|
|
@@ -13,6 +13,7 @@ import { useIsMobile } from '@djangocfg/ui-core/hooks';
|
|
|
13
13
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
14
14
|
|
|
15
15
|
import { SettingsForm } from './SettingsForm';
|
|
16
|
+
import { useGuestIntentRedirect } from './hooks/useGuestIntentRedirect';
|
|
16
17
|
import { useSettingsUrl } from './hooks/useSettingsUrl';
|
|
17
18
|
import { useSettingsCopy } from './i18n';
|
|
18
19
|
import { buildBuiltinSections } from './sections';
|
|
@@ -77,6 +78,7 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({
|
|
|
77
78
|
);
|
|
78
79
|
|
|
79
80
|
useSettingsUrl({ urlKey, enabled: syncUrl && isAuthenticated, resolveSection });
|
|
81
|
+
useGuestIntentRedirect(urlKey ?? 'settings', syncUrl !== false);
|
|
80
82
|
|
|
81
83
|
const resolvedTitle = title ?? copy.nav.title;
|
|
82
84
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useGuestIntentRedirect — a guest who arrives on `#settings/<section>?<intent>` is sent to sign-in and brought back.
|
|
5
|
+
*
|
|
6
|
+
* The dialog only opens for a signed-in user, so without this an assistant's consent link (`#settings/mcp?request=…`)
|
|
7
|
+
* lands on a page where nothing happens. The full address, fragment included, is saved as the post-sign-in
|
|
8
|
+
* destination, so the dialog reads the same intent once the session exists. A plain `#settings` (no intent) is left
|
|
9
|
+
* alone: a guest opening it has nothing pending.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useEffect, useRef } from 'react';
|
|
13
|
+
|
|
14
|
+
import { useAuth, useCfgRouter } from '@djangocfg/api/auth';
|
|
15
|
+
|
|
16
|
+
import { carriesIntent } from '../intent/parseSettingsHash';
|
|
17
|
+
|
|
18
|
+
export function useGuestIntentRedirect(urlKey: string, enabled: boolean): void {
|
|
19
|
+
const { isAuthenticated, isLoading, saveRedirectUrl, routes } = useAuth();
|
|
20
|
+
const router = useCfgRouter();
|
|
21
|
+
const sent = useRef(false);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!enabled || isLoading || isAuthenticated || sent.current) return;
|
|
25
|
+
if (!carriesIntent(window.location.hash.slice(1), urlKey)) return;
|
|
26
|
+
sent.current = true;
|
|
27
|
+
const { pathname, search, hash } = window.location;
|
|
28
|
+
saveRedirectUrl(`${pathname}${search}${hash}`);
|
|
29
|
+
router.push(routes.auth);
|
|
30
|
+
}, [enabled, isLoading, isAuthenticated, saveRedirectUrl, router, routes.auth, urlKey]);
|
|
31
|
+
}
|
|
@@ -27,7 +27,7 @@ import { useEffect, useRef } from 'react';
|
|
|
27
27
|
|
|
28
28
|
import { useHashState } from '@djangocfg/ui-core/hooks';
|
|
29
29
|
|
|
30
|
-
import { buildSettingsHash as buildHash, parseSettingsHash as parseHash } from '../intent/parseSettingsHash';
|
|
30
|
+
import { buildSettingsHash as buildHash, parseSettingsHash as parseHash, sectionToOpen } from '../intent/parseSettingsHash';
|
|
31
31
|
import { useSettingsDialogStore } from '../store';
|
|
32
32
|
|
|
33
33
|
export interface UseSettingsUrlOptions {
|
|
@@ -37,7 +37,8 @@ export interface UseSettingsUrlOptions {
|
|
|
37
37
|
enabled?: boolean;
|
|
38
38
|
/**
|
|
39
39
|
* Validate / normalize a section id read from the URL. Return null to reject
|
|
40
|
-
* an unknown id (dialog still opens, on the fallback section
|
|
40
|
+
* an unknown id (dialog still opens, on the fallback section; the id stays in
|
|
41
|
+
* the address so a section registered a moment later can claim it). The shell
|
|
41
42
|
* passes the set of known section ids here.
|
|
42
43
|
*/
|
|
43
44
|
resolveSection?: (id: string | null) => string | null;
|
|
@@ -73,9 +74,8 @@ export function useSettingsUrl(options: UseSettingsUrlOptions = {}): void {
|
|
|
73
74
|
const parsed = parseHash(fragment, urlKey);
|
|
74
75
|
|
|
75
76
|
if (parsed.open) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
: parsed.section;
|
|
77
|
+
// A section the host does not know *yet* stays in the address (see `sectionToOpen`).
|
|
78
|
+
const resolved = sectionToOpen(parsed.section, resolveSection ? resolveSection(parsed.section) : parsed.section);
|
|
79
79
|
// open() preserves the current section when called without one, so only
|
|
80
80
|
// pass a section when the URL actually named a (valid) one.
|
|
81
81
|
open(resolved ?? undefined);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
|
|
3
|
-
import { buildSettingsHash, parseSettingsHash } from './parseSettingsHash';
|
|
3
|
+
import { buildSettingsHash, carriesIntent, parseSettingsHash, sectionToOpen } from './parseSettingsHash';
|
|
4
4
|
|
|
5
5
|
describe('parseSettingsHash', () => {
|
|
6
6
|
it('is closed for an empty or foreign fragment', () => {
|
|
@@ -38,3 +38,27 @@ describe('buildSettingsHash', () => {
|
|
|
38
38
|
expect(buildSettingsHash('settings', null)).toBe('settings');
|
|
39
39
|
});
|
|
40
40
|
});
|
|
41
|
+
|
|
42
|
+
describe('sectionToOpen', () => {
|
|
43
|
+
it('uses the host-resolved id when there is one', () => {
|
|
44
|
+
expect(sectionToOpen('mcp', 'mcp')).toBe('mcp');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('keeps the requested id while the section is not registered yet (cold deep link)', () => {
|
|
48
|
+
expect(sectionToOpen('mcp', null)).toBe('mcp');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('is null for a bare #settings', () => {
|
|
52
|
+
expect(sectionToOpen(null, null)).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('carriesIntent', () => {
|
|
57
|
+
it('is true only for a settings link with a query', () => {
|
|
58
|
+
expect(carriesIntent('settings/mcp?request=abc&client=Claude', 'settings')).toBe(true);
|
|
59
|
+
expect(carriesIntent('settings/mcp', 'settings')).toBe(false);
|
|
60
|
+
expect(carriesIntent('settings', 'settings')).toBe(false);
|
|
61
|
+
expect(carriesIntent('other/mcp?request=abc', 'settings')).toBe(false);
|
|
62
|
+
expect(carriesIntent('', 'settings')).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -42,3 +42,20 @@ export function parseSettingsHash(fragment: string, urlKey: string): ParsedSetti
|
|
|
42
42
|
export function buildSettingsHash(urlKey: string, section: string | null): string {
|
|
43
43
|
return section ? `${urlKey}/${section}` : urlKey;
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The section to open for a parsed fragment. `resolved` is the host's check against the sections it knows *right now*;
|
|
48
|
+
* it is null both for a typo and for a section that has not registered yet (a host adds its MCP section once a
|
|
49
|
+
* backend answer arrives, after the dialog has already read a deep link on a cold load). Dropping the id in the
|
|
50
|
+
* second case made the dialog write `#settings` over the link and open on the first section, so the id is kept: an
|
|
51
|
+
* unknown one only shows the fallback section, a late one is picked up as soon as it registers.
|
|
52
|
+
*/
|
|
53
|
+
export function sectionToOpen(requested: string | null, resolved: string | null): string | null {
|
|
54
|
+
return resolved ?? requested;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** True when the fragment is a Settings deep link that carries an arrival intent (`#settings/mcp?request=…`). */
|
|
58
|
+
export function carriesIntent(fragment: string, urlKey: string): boolean {
|
|
59
|
+
const parsed = parseSettingsHash(fragment, urlKey);
|
|
60
|
+
return parsed.open && Object.keys(parsed.params).length > 0;
|
|
61
|
+
}
|