@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +31 -11
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/hooks/useIdleLogout.test.js +57 -2
  6. package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -1
  7. package/dist/__tests__/router/query-string.test.d.ts +2 -0
  8. package/dist/__tests__/router/query-string.test.d.ts.map +1 -0
  9. package/dist/__tests__/router/query-string.test.js +44 -0
  10. package/dist/__tests__/router/query-string.test.js.map +1 -0
  11. package/dist/__tests__/views/users-selected-deeplink.render.test.d.ts +2 -0
  12. package/dist/__tests__/views/users-selected-deeplink.render.test.d.ts.map +1 -0
  13. package/dist/__tests__/views/users-selected-deeplink.render.test.js +91 -0
  14. package/dist/__tests__/views/users-selected-deeplink.render.test.js.map +1 -0
  15. package/dist/hooks/useIdleLogout.d.ts +21 -6
  16. package/dist/hooks/useIdleLogout.d.ts.map +1 -1
  17. package/dist/hooks/useIdleLogout.js +41 -8
  18. package/dist/hooks/useIdleLogout.js.map +1 -1
  19. package/dist/router/index.d.ts +7 -0
  20. package/dist/router/index.d.ts.map +1 -1
  21. package/dist/router/index.js +23 -4
  22. package/dist/router/index.js.map +1 -1
  23. package/dist/views/MediaBrowser.d.ts +4 -1
  24. package/dist/views/MediaBrowser.d.ts.map +1 -1
  25. package/dist/views/MediaBrowser.js +27 -5
  26. package/dist/views/MediaBrowser.js.map +1 -1
  27. package/dist/views/SEO.d.ts.map +1 -1
  28. package/dist/views/SEO.js +2 -1
  29. package/dist/views/SEO.js.map +1 -1
  30. package/dist/views/Settings.d.ts +4 -1
  31. package/dist/views/Settings.d.ts.map +1 -1
  32. package/dist/views/Settings.js +4 -3
  33. package/dist/views/Settings.js.map +1 -1
  34. package/dist/views/Users.d.ts +4 -1
  35. package/dist/views/Users.d.ts.map +1 -1
  36. package/dist/views/Users.js +22 -2
  37. package/dist/views/Users.js.map +1 -1
  38. package/dist/views/settings/SecurityTab.d.ts.map +1 -1
  39. package/dist/views/settings/SecurityTab.js +2 -1
  40. package/dist/views/settings/SecurityTab.js.map +1 -1
  41. package/package.json +2 -2
  42. package/src/AdminRoot.tsx +53 -12
  43. package/src/__tests__/hooks/useIdleLogout.test.tsx +103 -1
  44. package/src/__tests__/router/query-string.test.ts +54 -0
  45. package/src/__tests__/views/users-selected-deeplink.render.test.tsx +102 -0
  46. package/src/hooks/useIdleLogout.ts +65 -13
  47. package/src/router/index.ts +25 -4
  48. package/src/views/MediaBrowser.tsx +28 -5
  49. package/src/views/SEO.tsx +2 -1
  50. package/src/views/Settings.tsx +7 -2
  51. package/src/views/Users.tsx +23 -2
  52. 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: boolean
22
- timeoutMs: number
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') return
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
- if (Date.now() - lastActivity >= timeoutMs) {
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
- const interval = window.setInterval(checkIdle, Math.min(CHECK_INTERVAL_MS, timeoutMs))
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
  }
@@ -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
- const browserPath = stripBase(window.location.pathname, basePath)
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
- setCurrentPath(stripBase(window.location.pathname, baseRef.current))
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
- return { currentPath, navigate, matchRoute, buildHref }
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 = new URLSearchParams(window.location.search)
66
+ const params = parseRouteQuery(window.location.search)
66
67
  return {
67
68
  tab: params.get('tab'),
68
69
  entityType: params.get('entityType'),
@@ -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 new URLSearchParams(window.location.search).get('tab')
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">
@@ -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&section=sessions|ip-allowlist|audit
57
58
  useEffect(() => {
58
59
  if (loading || typeof window === 'undefined') return
59
- const section = new URLSearchParams(window.location.search).get('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' })