@actuate-media/cms-admin 0.68.0 → 0.69.0
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/CHANGELOG.md +12 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +31 -11
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/hooks/useIdleLogout.test.js +57 -2
- package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -1
- package/dist/__tests__/router/query-string.test.d.ts +2 -0
- package/dist/__tests__/router/query-string.test.d.ts.map +1 -0
- package/dist/__tests__/router/query-string.test.js +44 -0
- package/dist/__tests__/router/query-string.test.js.map +1 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.d.ts +2 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.js +91 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.js.map +1 -0
- package/dist/hooks/useIdleLogout.d.ts +21 -6
- package/dist/hooks/useIdleLogout.d.ts.map +1 -1
- package/dist/hooks/useIdleLogout.js +41 -8
- package/dist/hooks/useIdleLogout.js.map +1 -1
- package/dist/router/index.d.ts +7 -0
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +23 -4
- package/dist/router/index.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts +4 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +27 -5
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +2 -1
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/Settings.d.ts +4 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +4 -3
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/Users.d.ts +4 -1
- package/dist/views/Users.d.ts.map +1 -1
- package/dist/views/Users.js +22 -2
- package/dist/views/Users.js.map +1 -1
- package/dist/views/settings/SecurityTab.d.ts.map +1 -1
- package/dist/views/settings/SecurityTab.js +2 -1
- package/dist/views/settings/SecurityTab.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +53 -12
- package/src/__tests__/hooks/useIdleLogout.test.tsx +103 -1
- package/src/__tests__/router/query-string.test.ts +54 -0
- package/src/__tests__/views/users-selected-deeplink.render.test.tsx +102 -0
- package/src/hooks/useIdleLogout.ts +65 -13
- package/src/router/index.ts +25 -4
- package/src/views/MediaBrowser.tsx +28 -5
- package/src/views/SEO.tsx +2 -1
- package/src/views/Settings.tsx +7 -2
- package/src/views/Users.tsx +23 -2
- package/src/views/settings/SecurityTab.tsx +2 -1
|
@@ -1,13 +1,31 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef } from 'react'
|
|
3
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
4
4
|
|
|
5
5
|
/** How often the idle checker wakes up to compare against the deadline. */
|
|
6
6
|
const CHECK_INTERVAL_MS = 30_000
|
|
7
7
|
|
|
8
|
+
export interface UseIdleLogoutOptions {
|
|
9
|
+
enabled: boolean
|
|
10
|
+
timeoutMs: number
|
|
11
|
+
onIdle: () => void
|
|
12
|
+
/**
|
|
13
|
+
* How long before the idle deadline to fire `onWarn` (so the UI can show a
|
|
14
|
+
* "you're about to be signed out" prompt). Ignored unless `onWarn` is set
|
|
15
|
+
* or when it doesn't fit inside `timeoutMs`.
|
|
16
|
+
*/
|
|
17
|
+
warningMs?: number
|
|
18
|
+
/** Fired once when the session enters the warning window. */
|
|
19
|
+
onWarn?: () => void
|
|
20
|
+
/** Fired when activity resumes after `onWarn` (dismiss the prompt). */
|
|
21
|
+
onWarnCancelled?: () => void
|
|
22
|
+
}
|
|
23
|
+
|
|
8
24
|
/**
|
|
9
25
|
* Signs the user out after `timeoutMs` of no user activity, when enabled by
|
|
10
|
-
* the Settings → Security "Idle auto-logout" policy.
|
|
26
|
+
* the Settings → Security "Idle auto-logout" policy. Optionally fires a
|
|
27
|
+
* warning `warningMs` before the deadline so the UI can offer a
|
|
28
|
+
* "Stay signed in" action (the returned `stayActive` resets the clock).
|
|
11
29
|
*
|
|
12
30
|
* Deliberately lightweight: activity events only stamp a ref (no state, no
|
|
13
31
|
* re-renders), and a single coarse interval compares the last-activity stamp
|
|
@@ -17,31 +35,53 @@ const CHECK_INTERVAL_MS = 30_000
|
|
|
17
35
|
*
|
|
18
36
|
* All listeners and the interval are torn down on unmount or when disabled.
|
|
19
37
|
*/
|
|
20
|
-
export function useIdleLogout(options: {
|
|
21
|
-
enabled
|
|
22
|
-
|
|
23
|
-
onIdle: () => void
|
|
24
|
-
}): void {
|
|
25
|
-
const { enabled, timeoutMs } = options
|
|
26
|
-
// Keep the latest callback without re-subscribing listeners on every render.
|
|
38
|
+
export function useIdleLogout(options: UseIdleLogoutOptions): { stayActive: () => void } {
|
|
39
|
+
const { enabled, timeoutMs, warningMs } = options
|
|
40
|
+
// Keep the latest callbacks without re-subscribing listeners on every render.
|
|
27
41
|
const onIdleRef = useRef(options.onIdle)
|
|
28
42
|
onIdleRef.current = options.onIdle
|
|
43
|
+
const onWarnRef = useRef(options.onWarn)
|
|
44
|
+
onWarnRef.current = options.onWarn
|
|
45
|
+
const onWarnCancelledRef = useRef(options.onWarnCancelled)
|
|
46
|
+
onWarnCancelledRef.current = options.onWarnCancelled
|
|
47
|
+
|
|
48
|
+
// The effect re-assigns this so `stayActive` always resets the live timer.
|
|
49
|
+
const resetRef = useRef<() => void>(() => undefined)
|
|
29
50
|
|
|
30
51
|
useEffect(() => {
|
|
31
|
-
if (!enabled || timeoutMs <= 0 || typeof window === 'undefined')
|
|
52
|
+
if (!enabled || timeoutMs <= 0 || typeof window === 'undefined') {
|
|
53
|
+
resetRef.current = () => undefined
|
|
54
|
+
return
|
|
55
|
+
}
|
|
32
56
|
|
|
33
57
|
let lastActivity = Date.now()
|
|
34
58
|
let fired = false
|
|
59
|
+
let warned = false
|
|
60
|
+
|
|
61
|
+
// Only meaningful when a warn callback exists and the window fits.
|
|
62
|
+
const effectiveWarningMs =
|
|
63
|
+
onWarnRef.current && warningMs && warningMs > 0 && warningMs < timeoutMs ? warningMs : 0
|
|
35
64
|
|
|
36
65
|
const markActivity = () => {
|
|
37
66
|
lastActivity = Date.now()
|
|
67
|
+
if (warned && !fired) {
|
|
68
|
+
warned = false
|
|
69
|
+
onWarnCancelledRef.current?.()
|
|
70
|
+
}
|
|
38
71
|
}
|
|
72
|
+
resetRef.current = markActivity
|
|
39
73
|
|
|
40
74
|
const checkIdle = () => {
|
|
41
75
|
if (fired) return
|
|
42
|
-
|
|
76
|
+
const idleFor = Date.now() - lastActivity
|
|
77
|
+
if (idleFor >= timeoutMs) {
|
|
43
78
|
fired = true
|
|
44
79
|
onIdleRef.current()
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
if (effectiveWarningMs > 0 && !warned && idleFor >= timeoutMs - effectiveWarningMs) {
|
|
83
|
+
warned = true
|
|
84
|
+
onWarnRef.current?.()
|
|
45
85
|
}
|
|
46
86
|
}
|
|
47
87
|
|
|
@@ -64,14 +104,26 @@ export function useIdleLogout(options: {
|
|
|
64
104
|
}
|
|
65
105
|
document.addEventListener('visibilitychange', onVisibility)
|
|
66
106
|
|
|
67
|
-
|
|
107
|
+
// Tick often enough to hit the warning window, not just the deadline.
|
|
108
|
+
const tick =
|
|
109
|
+
effectiveWarningMs > 0
|
|
110
|
+
? Math.min(CHECK_INTERVAL_MS, timeoutMs, effectiveWarningMs)
|
|
111
|
+
: Math.min(CHECK_INTERVAL_MS, timeoutMs)
|
|
112
|
+
const interval = window.setInterval(checkIdle, tick)
|
|
68
113
|
|
|
69
114
|
return () => {
|
|
115
|
+
resetRef.current = () => undefined
|
|
70
116
|
for (const event of activityEvents) {
|
|
71
117
|
window.removeEventListener(event, markActivity)
|
|
72
118
|
}
|
|
73
119
|
document.removeEventListener('visibilitychange', onVisibility)
|
|
74
120
|
window.clearInterval(interval)
|
|
75
121
|
}
|
|
76
|
-
}, [enabled, timeoutMs])
|
|
122
|
+
}, [enabled, timeoutMs, warningMs])
|
|
123
|
+
|
|
124
|
+
const stayActive = useCallback(() => {
|
|
125
|
+
resetRef.current()
|
|
126
|
+
}, [])
|
|
127
|
+
|
|
128
|
+
return { stayActive }
|
|
77
129
|
}
|
package/src/router/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
3
|
+
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
|
|
4
4
|
|
|
5
5
|
interface RouteParams {
|
|
6
6
|
[key: string]: string
|
|
@@ -14,6 +14,15 @@ function stripBase(pathname: string, basePath: string): string {
|
|
|
14
14
|
return '/'
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Parse the query string out of an admin path (`/pages/new?parent=abc`) or a
|
|
19
|
+
* raw search string (`?parent=abc`) into `URLSearchParams`. Pure helper for
|
|
20
|
+
* view components that only receive `currentPath` / `onNavigate` props.
|
|
21
|
+
*/
|
|
22
|
+
export function parseRouteQuery(path: string): URLSearchParams {
|
|
23
|
+
return new URLSearchParams(path.split('#')[0]!.split('?')[1] ?? '')
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
export function useAdminRouter(basePath = '/admin', serverPath = '/') {
|
|
18
27
|
const [currentPath, setCurrentPath] = useState(serverPath)
|
|
19
28
|
const baseRef = useRef(basePath)
|
|
@@ -21,7 +30,10 @@ export function useAdminRouter(basePath = '/admin', serverPath = '/') {
|
|
|
21
30
|
|
|
22
31
|
useEffect(() => {
|
|
23
32
|
if (typeof window !== 'undefined') {
|
|
24
|
-
|
|
33
|
+
// Include the query string so state-bearing URLs (`/pages/new?parent=x`,
|
|
34
|
+
// `/settings?tab=users`) survive hard reloads. matchRoute strips the
|
|
35
|
+
// query before matching, so `/?foo=1` still resolves to `/`.
|
|
36
|
+
const browserPath = stripBase(window.location.pathname, basePath) + window.location.search
|
|
25
37
|
if (browserPath !== currentPath) {
|
|
26
38
|
setCurrentPath(browserPath)
|
|
27
39
|
}
|
|
@@ -30,7 +42,9 @@ export function useAdminRouter(basePath = '/admin', serverPath = '/') {
|
|
|
30
42
|
|
|
31
43
|
useEffect(() => {
|
|
32
44
|
function onPopState() {
|
|
33
|
-
|
|
45
|
+
// Query string included for the same reason as the mount sync above —
|
|
46
|
+
// back/forward must restore deep-link state, not just the pathname.
|
|
47
|
+
setCurrentPath(stripBase(window.location.pathname, baseRef.current) + window.location.search)
|
|
34
48
|
}
|
|
35
49
|
window.addEventListener('popstate', onPopState)
|
|
36
50
|
return () => window.removeEventListener('popstate', onPopState)
|
|
@@ -92,5 +106,12 @@ export function useAdminRouter(basePath = '/admin', serverPath = '/') {
|
|
|
92
106
|
[currentPath],
|
|
93
107
|
)
|
|
94
108
|
|
|
95
|
-
|
|
109
|
+
// Raw query string of the current route (`?tab=users`), or '' when absent.
|
|
110
|
+
// Views wanting parsed params can pass it (or currentPath) to parseRouteQuery.
|
|
111
|
+
const search = useMemo(() => {
|
|
112
|
+
const queryIndex = currentPath.indexOf('?')
|
|
113
|
+
return queryIndex === -1 ? '' : currentPath.slice(queryIndex).split('#')[0]!
|
|
114
|
+
}, [currentPath])
|
|
115
|
+
|
|
116
|
+
return { currentPath, search, navigate, matchRoute, buildHref }
|
|
96
117
|
}
|
|
@@ -24,8 +24,9 @@ import {
|
|
|
24
24
|
FolderInput,
|
|
25
25
|
GripVertical,
|
|
26
26
|
} from 'lucide-react'
|
|
27
|
-
import { useState, useMemo, useRef, useCallback } from 'react'
|
|
27
|
+
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
|
28
28
|
import { toast } from 'sonner'
|
|
29
|
+
import { parseRouteQuery } from '../router/index.js'
|
|
29
30
|
import { sortByRelevance, type SortConfig, toggleSort } from '../lib/search.js'
|
|
30
31
|
import { useApiData } from '../lib/useApiData.js'
|
|
31
32
|
import { cmsApi } from '../lib/api.js'
|
|
@@ -97,6 +98,9 @@ type MediaSortKey = 'name' | 'type' | 'size' | 'date'
|
|
|
97
98
|
|
|
98
99
|
export interface MediaBrowserProps {
|
|
99
100
|
onNavigate?: (path: string) => void
|
|
101
|
+
/** Router path including the query string. `?selected=<mediaId>` (written by
|
|
102
|
+
* the command palette) opens that item's detail panel once the list loads. */
|
|
103
|
+
currentPath?: string
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
function buildMediaApiUrl(folderSel: FolderSelection): string {
|
|
@@ -109,7 +113,7 @@ function buildMediaApiUrl(folderSel: FolderSelection): string {
|
|
|
109
113
|
return `${base}&folderId=${folderSel.folderId}`
|
|
110
114
|
}
|
|
111
115
|
|
|
112
|
-
export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
116
|
+
export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
|
|
113
117
|
const [folderSel, setFolderSel] = useState<FolderSelection>({ type: 'smart', smart: 'all' })
|
|
114
118
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
|
115
119
|
|
|
@@ -144,7 +148,7 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
|
144
148
|
const [uploading, setUploading] = useState(false)
|
|
145
149
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
146
150
|
|
|
147
|
-
const mediaItems = data?.data ?? data?.items ?? []
|
|
151
|
+
const mediaItems = useMemo(() => data?.data ?? data?.items ?? [], [data])
|
|
148
152
|
|
|
149
153
|
const filteredAndSorted = useMemo(() => {
|
|
150
154
|
let results = mediaItems.filter((item) => {
|
|
@@ -185,7 +189,7 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
|
185
189
|
return { total: mediaItems.length, images, videos, documents, bytes }
|
|
186
190
|
}, [mediaItems])
|
|
187
191
|
|
|
188
|
-
const openDetail = (item: MediaItem) => {
|
|
192
|
+
const openDetail = useCallback((item: MediaItem) => {
|
|
189
193
|
setActiveItem(item)
|
|
190
194
|
setEditAlt(item.altTag ?? '')
|
|
191
195
|
setEditTitle(item.title ?? '')
|
|
@@ -193,7 +197,26 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
|
193
197
|
setEditCaption(item.caption ?? '')
|
|
194
198
|
setEditDescription(item.description ?? '')
|
|
195
199
|
setEditFocusKeyword(item.focusKeyword ?? '')
|
|
196
|
-
}
|
|
200
|
+
}, [])
|
|
201
|
+
|
|
202
|
+
// Deep link from the command palette (`/media?selected=<id>`): open the
|
|
203
|
+
// item's detail panel once the library loads. Each query value is handled
|
|
204
|
+
// once, so closing the panel doesn't get overridden on refetch; unknown
|
|
205
|
+
// ids are ignored.
|
|
206
|
+
const selectedMediaId = useMemo(() => {
|
|
207
|
+
const source = currentPath ?? (typeof window !== 'undefined' ? window.location.search : '')
|
|
208
|
+
return parseRouteQuery(source).get('selected')
|
|
209
|
+
}, [currentPath])
|
|
210
|
+
const handledSelectedRef = useRef<string | null>(null)
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (!selectedMediaId || loading) return
|
|
213
|
+
if (handledSelectedRef.current === selectedMediaId) return
|
|
214
|
+
const item = mediaItems.find((m) => String(m.id) === selectedMediaId)
|
|
215
|
+
if (!item) return
|
|
216
|
+
handledSelectedRef.current = selectedMediaId
|
|
217
|
+
setActiveTab('library')
|
|
218
|
+
openDetail(item)
|
|
219
|
+
}, [selectedMediaId, loading, mediaItems, openDetail])
|
|
197
220
|
|
|
198
221
|
// Count of items missing alt OR title — drives the SEO Audit tab badge.
|
|
199
222
|
const auditAttentionCount = useMemo(
|
package/src/views/SEO.tsx
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from 'lucide-react'
|
|
19
19
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
20
20
|
import { toast } from 'sonner'
|
|
21
|
+
import { parseRouteQuery } from '../router/index.js'
|
|
21
22
|
import { ErrorBoundary } from '../components/ErrorBoundary.js'
|
|
22
23
|
import { fetchSeoOverview, runSeoAudit } from '../lib/seo-service.js'
|
|
23
24
|
import { fetchPlanInfo, type PlanInfo } from '../lib/plan.js'
|
|
@@ -62,7 +63,7 @@ function readUrlState(): UrlState {
|
|
|
62
63
|
if (typeof window === 'undefined') {
|
|
63
64
|
return { tab: null, entityType: null, entityId: null, issueId: null }
|
|
64
65
|
}
|
|
65
|
-
const params =
|
|
66
|
+
const params = parseRouteQuery(window.location.search)
|
|
66
67
|
return {
|
|
67
68
|
tab: params.get('tab'),
|
|
68
69
|
entityType: params.get('entityType'),
|
package/src/views/Settings.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from 'lucide-react'
|
|
14
14
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
|
15
15
|
import { toast } from 'sonner'
|
|
16
|
+
import { parseRouteQuery } from '../router/index.js'
|
|
16
17
|
import { useApiData } from '../lib/useApiData.js'
|
|
17
18
|
import { cmsApi } from '../lib/api.js'
|
|
18
19
|
import { RelationshipField } from '../fields/RelationshipField.js'
|
|
@@ -35,6 +36,9 @@ export interface SettingsProps {
|
|
|
35
36
|
/** Initial tab when arriving without a `?tab=` query param (e.g. via the
|
|
36
37
|
* legacy `/users` or `/api-keys` routes). */
|
|
37
38
|
initialTab?: string
|
|
39
|
+
/** Router path including the query string (e.g. `/users?selected=<id>`).
|
|
40
|
+
* Forwarded to embedded tab views so their deep links keep working. */
|
|
41
|
+
currentPath?: string
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
/**
|
|
@@ -48,7 +52,7 @@ const GLOBAL_SAVE_TABS = new Set(['layout'])
|
|
|
48
52
|
|
|
49
53
|
function readSettingsTabFromUrl(): string | null {
|
|
50
54
|
if (typeof window === 'undefined') return null
|
|
51
|
-
return
|
|
55
|
+
return parseRouteQuery(window.location.search).get('tab')
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
export function Settings({
|
|
@@ -56,6 +60,7 @@ export function Settings({
|
|
|
56
60
|
onNavigate,
|
|
57
61
|
session,
|
|
58
62
|
initialTab = 'general',
|
|
63
|
+
currentPath,
|
|
59
64
|
}: SettingsProps = {}) {
|
|
60
65
|
const { data, loading, error, refetch } = useApiData<any>('/globals/settings')
|
|
61
66
|
|
|
@@ -329,7 +334,7 @@ export function Settings({
|
|
|
329
334
|
</Tabs.Content>
|
|
330
335
|
|
|
331
336
|
<Tabs.Content value="users">
|
|
332
|
-
<UsersView onNavigate={onNavigate} embedded />
|
|
337
|
+
<UsersView onNavigate={onNavigate} embedded currentPath={currentPath} />
|
|
333
338
|
</Tabs.Content>
|
|
334
339
|
|
|
335
340
|
<Tabs.Content value="api-keys">
|
package/src/views/Users.tsx
CHANGED
|
@@ -22,8 +22,9 @@ import {
|
|
|
22
22
|
Check,
|
|
23
23
|
SlidersHorizontal,
|
|
24
24
|
} from 'lucide-react'
|
|
25
|
-
import { useState, useMemo, useCallback, type FormEvent } from 'react'
|
|
25
|
+
import { useState, useMemo, useCallback, useEffect, useRef, type FormEvent } from 'react'
|
|
26
26
|
import { toast } from 'sonner'
|
|
27
|
+
import { parseRouteQuery } from '../router/index.js'
|
|
27
28
|
import { useApiData } from '../lib/useApiData.js'
|
|
28
29
|
import { cmsApi } from '../lib/api.js'
|
|
29
30
|
import { Button } from '../components/ui/Button.js'
|
|
@@ -675,9 +676,12 @@ const ROLE_PILLS: Array<'All' | 'Owner' | 'Admin' | 'Editor' | 'Viewer'> = [
|
|
|
675
676
|
export interface UsersProps {
|
|
676
677
|
onNavigate?: (path: string) => void
|
|
677
678
|
embedded?: boolean
|
|
679
|
+
/** Router path including the query string. `?selected=<userId>` (written by
|
|
680
|
+
* the command palette) opens that member's detail drawer once the list loads. */
|
|
681
|
+
currentPath?: string
|
|
678
682
|
}
|
|
679
683
|
|
|
680
|
-
export function Users({ embedded = false }: UsersProps = {}) {
|
|
684
|
+
export function Users({ embedded = false, currentPath }: UsersProps = {}) {
|
|
681
685
|
const members = useApiData<ApiMember[]>('/users?pageSize=200')
|
|
682
686
|
const invites = useApiData<ApiInvite[]>('/invites')
|
|
683
687
|
const stats = useApiData<ApiStats>('/users/stats')
|
|
@@ -702,6 +706,23 @@ export function Users({ embedded = false }: UsersProps = {}) {
|
|
|
702
706
|
review.refetch()
|
|
703
707
|
}, [members, invites, stats, review])
|
|
704
708
|
|
|
709
|
+
// Deep link from the command palette (`/users?selected=<id>`): open the
|
|
710
|
+
// member's detail drawer once the list loads. Each query value is handled
|
|
711
|
+
// once, so closing the drawer isn't overridden on refetch; ids that don't
|
|
712
|
+
// match a loaded member are ignored.
|
|
713
|
+
const selectedUserId = useMemo(() => {
|
|
714
|
+
const source = currentPath ?? (typeof window !== 'undefined' ? window.location.search : '')
|
|
715
|
+
return parseRouteQuery(source).get('selected')
|
|
716
|
+
}, [currentPath])
|
|
717
|
+
const handledSelectedRef = useRef<string | null>(null)
|
|
718
|
+
useEffect(() => {
|
|
719
|
+
if (!selectedUserId || members.loading) return
|
|
720
|
+
if (handledSelectedRef.current === selectedUserId) return
|
|
721
|
+
if (!(members.data ?? []).some((m) => m.id === selectedUserId)) return
|
|
722
|
+
handledSelectedRef.current = selectedUserId
|
|
723
|
+
setDetailUserId(selectedUserId)
|
|
724
|
+
}, [selectedUserId, members.loading, members.data])
|
|
725
|
+
|
|
705
726
|
const currentUserRole = useMemo(
|
|
706
727
|
() => (members.data ?? []).find((m) => m.isCurrentUser)?.roleName ?? null,
|
|
707
728
|
[members.data],
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect } from 'react'
|
|
4
4
|
import { Loader2, ShieldAlert } from 'lucide-react'
|
|
5
|
+
import { parseRouteQuery } from '../../router/index.js'
|
|
5
6
|
import {
|
|
6
7
|
ConfirmDangerousSettingDialog,
|
|
7
8
|
SettingsSaveBar,
|
|
@@ -56,7 +57,7 @@ export function SecurityTab({
|
|
|
56
57
|
// Deep-link support: /settings?tab=security§ion=sessions|ip-allowlist|audit
|
|
57
58
|
useEffect(() => {
|
|
58
59
|
if (loading || typeof window === 'undefined') return
|
|
59
|
-
const section =
|
|
60
|
+
const section = parseRouteQuery(window.location.search).get('section')
|
|
60
61
|
if (!section) return
|
|
61
62
|
const el = document.getElementById(`security-${section}`)
|
|
62
63
|
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|