@byline/host-tanstack-start 3.10.0 → 3.11.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/dist/admin-shell/admin-activity/list.d.ts +10 -0
- package/dist/admin-shell/admin-activity/list.js +228 -0
- package/dist/admin-shell/admin-activity/list.module.js +12 -0
- package/dist/admin-shell/admin-activity/list_module.css +45 -0
- package/dist/admin-shell/chrome/menu-drawer.js +23 -6
- package/dist/admin-shell/chrome/preview-toggle.js +11 -6
- package/dist/routes/create-admin-activity-route.d.ts +8 -0
- package/dist/routes/create-admin-activity-route.js +71 -0
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +1 -0
- package/dist/server-fns/admin-account/change-password.js +1 -1
- package/dist/server-fns/admin-account/get.js +1 -1
- package/dist/server-fns/admin-account/update.js +1 -1
- package/dist/server-fns/admin-activity/get.d.ts +50 -0
- package/dist/server-fns/admin-activity/get.js +71 -0
- package/dist/server-fns/admin-activity/index.d.ts +15 -0
- package/dist/server-fns/admin-activity/index.js +1 -0
- package/dist/server-fns/admin-permissions/get-role-abilities.js +1 -1
- package/dist/server-fns/admin-permissions/list-registered.d.ts +2 -2
- package/dist/server-fns/admin-permissions/set-role-abilities.js +1 -1
- package/dist/server-fns/admin-permissions/who-has.js +1 -1
- package/dist/server-fns/admin-roles/create.js +1 -1
- package/dist/server-fns/admin-roles/delete.js +1 -1
- package/dist/server-fns/admin-roles/get.js +1 -1
- package/dist/server-fns/admin-roles/reorder.js +1 -1
- package/dist/server-fns/admin-roles/update.js +1 -1
- package/dist/server-fns/admin-users/create.js +1 -1
- package/dist/server-fns/admin-users/delete.js +1 -1
- package/dist/server-fns/admin-users/disable.js +1 -1
- package/dist/server-fns/admin-users/enable.js +1 -1
- package/dist/server-fns/admin-users/get-user-roles.js +1 -1
- package/dist/server-fns/admin-users/get.js +1 -1
- package/dist/server-fns/admin-users/list.d.ts +1 -1
- package/dist/server-fns/admin-users/list.js +1 -1
- package/dist/server-fns/admin-users/set-password.js +1 -1
- package/dist/server-fns/admin-users/set-user-roles.js +1 -1
- package/dist/server-fns/admin-users/update.js +1 -1
- package/dist/server-fns/ai/execute.js +1 -1
- package/dist/server-fns/auth/sign-in.js +1 -1
- package/dist/server-fns/collections/audit.js +1 -1
- package/dist/server-fns/collections/copy-to-locale.js +1 -1
- package/dist/server-fns/collections/create.js +1 -1
- package/dist/server-fns/collections/delete-locale.js +1 -1
- package/dist/server-fns/collections/delete.js +1 -1
- package/dist/server-fns/collections/duplicate.js +1 -1
- package/dist/server-fns/collections/get.js +2 -2
- package/dist/server-fns/collections/history.js +1 -1
- package/dist/server-fns/collections/list.js +1 -1
- package/dist/server-fns/collections/reorder.js +1 -1
- package/dist/server-fns/collections/restore-version.js +1 -1
- package/dist/server-fns/collections/stats.js +1 -1
- package/dist/server-fns/collections/status.js +2 -2
- package/dist/server-fns/collections/update.js +2 -2
- package/dist/server-fns/collections/upload.js +1 -1
- package/dist/server-fns/i18n/set-locale.js +1 -1
- package/package.json +14 -10
- package/src/admin-shell/admin-activity/list.module.css +69 -0
- package/src/admin-shell/admin-activity/list.tsx +242 -0
- package/src/admin-shell/chrome/menu-drawer.tsx +34 -3
- package/src/admin-shell/chrome/preview-toggle.tsx +14 -3
- package/src/routes/create-admin-activity-route.tsx +76 -0
- package/src/routes/index.ts +1 -0
- package/src/server-fns/admin-account/change-password.ts +1 -1
- package/src/server-fns/admin-account/get.ts +1 -1
- package/src/server-fns/admin-account/update.ts +1 -1
- package/src/server-fns/admin-activity/get.ts +139 -0
- package/src/server-fns/admin-activity/index.ts +23 -0
- package/src/server-fns/admin-permissions/get-role-abilities.ts +1 -1
- package/src/server-fns/admin-permissions/set-role-abilities.ts +1 -1
- package/src/server-fns/admin-permissions/who-has.ts +1 -1
- package/src/server-fns/admin-roles/create.ts +1 -1
- package/src/server-fns/admin-roles/delete.ts +1 -1
- package/src/server-fns/admin-roles/get.ts +1 -1
- package/src/server-fns/admin-roles/reorder.ts +1 -1
- package/src/server-fns/admin-roles/update.ts +1 -1
- package/src/server-fns/admin-users/create.ts +1 -1
- package/src/server-fns/admin-users/delete.ts +1 -1
- package/src/server-fns/admin-users/disable.ts +1 -1
- package/src/server-fns/admin-users/enable.ts +1 -1
- package/src/server-fns/admin-users/get-user-roles.ts +1 -1
- package/src/server-fns/admin-users/get.ts +1 -1
- package/src/server-fns/admin-users/list.ts +1 -1
- package/src/server-fns/admin-users/set-password.ts +1 -1
- package/src/server-fns/admin-users/set-user-roles.ts +1 -1
- package/src/server-fns/admin-users/update.ts +1 -1
- package/src/server-fns/ai/execute.ts +1 -1
- package/src/server-fns/auth/sign-in.ts +1 -1
- package/src/server-fns/collections/audit.ts +1 -3
- package/src/server-fns/collections/copy-to-locale.ts +1 -1
- package/src/server-fns/collections/create.ts +1 -1
- package/src/server-fns/collections/delete-locale.ts +1 -1
- package/src/server-fns/collections/delete.ts +1 -1
- package/src/server-fns/collections/duplicate.ts +1 -1
- package/src/server-fns/collections/get.ts +2 -2
- package/src/server-fns/collections/history.ts +1 -1
- package/src/server-fns/collections/list.ts +1 -1
- package/src/server-fns/collections/reorder.ts +1 -1
- package/src/server-fns/collections/restore-version.ts +1 -1
- package/src/server-fns/collections/stats.ts +1 -1
- package/src/server-fns/collections/status.ts +2 -2
- package/src/server-fns/collections/update.ts +2 -2
- package/src/server-fns/collections/upload.ts +1 -1
- package/src/server-fns/i18n/set-locale.ts +1 -1
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
5
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
7
|
+
*
|
|
8
|
+
* Copyright (c) Infonomic Company Limited
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useRouterState } from '@tanstack/react-router'
|
|
12
|
+
|
|
13
|
+
import { getClientConfig } from '@byline/core'
|
|
14
|
+
import { useTranslation } from '@byline/i18n/react'
|
|
15
|
+
import { Button, Container, Section, Select, Table } from '@byline/ui/react'
|
|
16
|
+
import cx from 'classnames'
|
|
17
|
+
|
|
18
|
+
import { Link, useNavigate } from '../chrome/loose-router.js'
|
|
19
|
+
import { RouterPager } from '../chrome/router-pager.js'
|
|
20
|
+
import styles from './list.module.css'
|
|
21
|
+
import type { SystemActivityResponse } from '../../server-fns/admin-activity/index.js'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Namespaced audit `action` → i18n label key. Covers the version-stream's
|
|
25
|
+
* synthesised content-save actions (`document.created` / `document.updated`)
|
|
26
|
+
* as well as the audit-log actions surfaced on the per-document history tab.
|
|
27
|
+
* Unknown actions fall back to the raw value rather than a missing-key warning.
|
|
28
|
+
*/
|
|
29
|
+
const ACTION_KEYS: Record<string, string> = {
|
|
30
|
+
'document.created': 'activity.actions.created',
|
|
31
|
+
'document.updated': 'activity.actions.updated',
|
|
32
|
+
'document.path.changed': 'activity.actions.pathChanged',
|
|
33
|
+
'document.locales.changed': 'activity.actions.localesChanged',
|
|
34
|
+
'document.status.changed': 'activity.actions.statusChanged',
|
|
35
|
+
'document.deleted': 'activity.actions.deleted',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The selectable action types, in display order. `_all` clears the filter. */
|
|
39
|
+
const ACTION_FILTER_VALUES = [
|
|
40
|
+
'document.created',
|
|
41
|
+
'document.updated',
|
|
42
|
+
'document.status.changed',
|
|
43
|
+
'document.path.changed',
|
|
44
|
+
'document.locales.changed',
|
|
45
|
+
'document.deleted',
|
|
46
|
+
] as const
|
|
47
|
+
|
|
48
|
+
/** Render an audit before/after value inline: arrays comma-join, nullish → em-dash. */
|
|
49
|
+
function formatAuditValue(value: unknown): string {
|
|
50
|
+
if (value == null) return '—'
|
|
51
|
+
if (Array.isArray(value)) return value.length > 0 ? value.join(', ') : '—'
|
|
52
|
+
return String(value)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The system-wide activity report (docs/AUDIT.md — Workstream 4): a paged,
|
|
57
|
+
* filterable feed over the union of the version stream (content saves) and the
|
|
58
|
+
* audit log (status / path / locale changes, deletions, and future admin-realm
|
|
59
|
+
* events). Read-only; gated by `admin.activity.read`.
|
|
60
|
+
*/
|
|
61
|
+
export const ActivitySystemView = ({ data }: { data: SystemActivityResponse }) => {
|
|
62
|
+
const { t } = useTranslation('byline-admin')
|
|
63
|
+
const navigate = useNavigate()
|
|
64
|
+
const location = useRouterState({ select: (s) => s.location })
|
|
65
|
+
const search = location.search as {
|
|
66
|
+
collection?: string
|
|
67
|
+
action?: string
|
|
68
|
+
page?: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const entries = data?.entries ?? []
|
|
72
|
+
const collections = getClientConfig().collections
|
|
73
|
+
|
|
74
|
+
// Navigate with the changed filter merged into the URL search; clearing the
|
|
75
|
+
// page so a filter change always lands on page 1. `_all` removes the filter.
|
|
76
|
+
const applyFilter = (key: 'collection' | 'action', value: string) => {
|
|
77
|
+
const params = structuredClone(location.search) as Record<string, unknown>
|
|
78
|
+
params.page = undefined
|
|
79
|
+
if (value === '_all') {
|
|
80
|
+
params[key] = undefined
|
|
81
|
+
} else {
|
|
82
|
+
params[key] = value
|
|
83
|
+
}
|
|
84
|
+
navigate({ to: '/admin/activity' as never, search: params })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const collectionItems = [
|
|
88
|
+
{ value: '_all', label: t('activity.filters.allCollections') },
|
|
89
|
+
...collections.map((c) => ({
|
|
90
|
+
value: c.path,
|
|
91
|
+
label: (c.labels?.plural as string) ?? c.path,
|
|
92
|
+
})),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
const actionItems = [
|
|
96
|
+
{ value: '_all', label: t('activity.filters.allActions') },
|
|
97
|
+
...ACTION_FILTER_VALUES.map((a) => ({
|
|
98
|
+
value: a,
|
|
99
|
+
label: ACTION_KEYS[a] ? t(ACTION_KEYS[a]) : a,
|
|
100
|
+
})),
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
const hasFilters = search.collection != null || search.action != null
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<Section>
|
|
107
|
+
<Container>
|
|
108
|
+
<div className={cx('byline-activity-head', styles.head)}>
|
|
109
|
+
<h1 className={cx('byline-activity-title', styles.title)}>{t('activity.title')}</h1>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div className={cx('byline-activity-options', styles.options)}>
|
|
113
|
+
<Select<string>
|
|
114
|
+
id="activity_collection_filter"
|
|
115
|
+
name="activity_collection_filter"
|
|
116
|
+
size="sm"
|
|
117
|
+
value={search.collection ?? '_all'}
|
|
118
|
+
items={collectionItems}
|
|
119
|
+
onValueChange={(v) => {
|
|
120
|
+
if (typeof v === 'string') applyFilter('collection', v)
|
|
121
|
+
}}
|
|
122
|
+
/>
|
|
123
|
+
<Select<string>
|
|
124
|
+
id="activity_action_filter"
|
|
125
|
+
name="activity_action_filter"
|
|
126
|
+
size="sm"
|
|
127
|
+
value={search.action ?? '_all'}
|
|
128
|
+
items={actionItems}
|
|
129
|
+
onValueChange={(v) => {
|
|
130
|
+
if (typeof v === 'string') applyFilter('action', v)
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
{hasFilters && (
|
|
134
|
+
<Button
|
|
135
|
+
size="sm"
|
|
136
|
+
variant="text"
|
|
137
|
+
onClick={() => navigate({ to: '/admin/activity' as never, search: {} })}
|
|
138
|
+
>
|
|
139
|
+
{t('activity.filters.clear')}
|
|
140
|
+
</Button>
|
|
141
|
+
)}
|
|
142
|
+
<RouterPager
|
|
143
|
+
page={data.meta.page}
|
|
144
|
+
count={data.meta.totalPages}
|
|
145
|
+
showFirstButton
|
|
146
|
+
showLastButton
|
|
147
|
+
componentName="pagerTop"
|
|
148
|
+
aria-label={t('activity.pagerAriaLabel')}
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
{entries.length === 0 ? (
|
|
153
|
+
<p className={cx('byline-activity-empty', styles.empty)}>{t('activity.empty')}</p>
|
|
154
|
+
) : (
|
|
155
|
+
<Table.Container className={cx('byline-activity-table-wrap', styles.tableWrap)}>
|
|
156
|
+
<Table>
|
|
157
|
+
<Table.Header>
|
|
158
|
+
<Table.Row>
|
|
159
|
+
<Table.HeadingCell scope="col">{t('activity.columns.when')}</Table.HeadingCell>
|
|
160
|
+
<Table.HeadingCell scope="col">
|
|
161
|
+
{t('activity.columns.collection')}
|
|
162
|
+
</Table.HeadingCell>
|
|
163
|
+
<Table.HeadingCell scope="col">{t('activity.columns.action')}</Table.HeadingCell>
|
|
164
|
+
<Table.HeadingCell scope="col">{t('activity.columns.actor')}</Table.HeadingCell>
|
|
165
|
+
<Table.HeadingCell scope="col">{t('activity.columns.change')}</Table.HeadingCell>
|
|
166
|
+
</Table.Row>
|
|
167
|
+
</Table.Header>
|
|
168
|
+
<Table.Body>
|
|
169
|
+
{entries.map((entry) => {
|
|
170
|
+
const actionKey = ACTION_KEYS[entry.action]
|
|
171
|
+
const actionLabel = actionKey ? t(actionKey) : entry.action
|
|
172
|
+
// System/tooling write (NULL actor or 'system' realm) → the
|
|
173
|
+
// system label; an unresolved id is a deleted user; otherwise
|
|
174
|
+
// the admin-resolved label.
|
|
175
|
+
const actorLabel =
|
|
176
|
+
entry.actorId == null || entry.actorRealm === 'system'
|
|
177
|
+
? t('activity.systemActor')
|
|
178
|
+
: (data.actors?.[entry.actorId]?.label ?? t('activity.formerUser'))
|
|
179
|
+
|
|
180
|
+
const col = entry.collectionId ? data.collections[entry.collectionId] : undefined
|
|
181
|
+
const collectionLabel = col?.plural ?? col?.path ?? '—'
|
|
182
|
+
|
|
183
|
+
const hasChange = entry.before != null || entry.after != null
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<Table.Row key={entry.id}>
|
|
187
|
+
<Table.Cell className={cx('byline-activity-when', styles.when)}>
|
|
188
|
+
{new Date(entry.occurredAt).toLocaleString()}
|
|
189
|
+
</Table.Cell>
|
|
190
|
+
<Table.Cell>
|
|
191
|
+
{col != null && entry.documentId != null ? (
|
|
192
|
+
<Link
|
|
193
|
+
to={'/admin/collections/$collection/$id' as never}
|
|
194
|
+
params={{ collection: col.path, id: entry.documentId }}
|
|
195
|
+
>
|
|
196
|
+
{collectionLabel}
|
|
197
|
+
</Link>
|
|
198
|
+
) : (
|
|
199
|
+
collectionLabel
|
|
200
|
+
)}
|
|
201
|
+
</Table.Cell>
|
|
202
|
+
<Table.Cell>{actionLabel}</Table.Cell>
|
|
203
|
+
<Table.Cell>{actorLabel}</Table.Cell>
|
|
204
|
+
<Table.Cell className={cx('byline-activity-change', styles.change)}>
|
|
205
|
+
{hasChange ? (
|
|
206
|
+
<>
|
|
207
|
+
<span className={cx('byline-activity-before', styles.before)}>
|
|
208
|
+
{formatAuditValue(entry.before)}
|
|
209
|
+
</span>
|
|
210
|
+
<span className={cx('byline-activity-arrow', styles.arrow)}>
|
|
211
|
+
{' → '}
|
|
212
|
+
</span>
|
|
213
|
+
<span className={cx('byline-activity-after', styles.after)}>
|
|
214
|
+
{formatAuditValue(entry.after)}
|
|
215
|
+
</span>
|
|
216
|
+
</>
|
|
217
|
+
) : (
|
|
218
|
+
'—'
|
|
219
|
+
)}
|
|
220
|
+
</Table.Cell>
|
|
221
|
+
</Table.Row>
|
|
222
|
+
)
|
|
223
|
+
})}
|
|
224
|
+
</Table.Body>
|
|
225
|
+
</Table>
|
|
226
|
+
</Table.Container>
|
|
227
|
+
)}
|
|
228
|
+
|
|
229
|
+
<div className={cx('byline-activity-options', styles.options)}>
|
|
230
|
+
<RouterPager
|
|
231
|
+
page={data.meta.page}
|
|
232
|
+
count={data.meta.totalPages}
|
|
233
|
+
showFirstButton
|
|
234
|
+
showLastButton
|
|
235
|
+
componentName="pagerBottom"
|
|
236
|
+
aria-label={t('activity.pagerAriaLabel')}
|
|
237
|
+
/>
|
|
238
|
+
</div>
|
|
239
|
+
</Container>
|
|
240
|
+
</Section>
|
|
241
|
+
)
|
|
242
|
+
}
|
|
@@ -9,11 +9,20 @@
|
|
|
9
9
|
import type React from 'react'
|
|
10
10
|
import { useRouterState } from '@tanstack/react-router'
|
|
11
11
|
|
|
12
|
+
import { ADMIN_ACTIVITY_ABILITIES } from '@byline/admin/admin-activity'
|
|
12
13
|
import { ADMIN_PERMISSIONS_ABILITIES } from '@byline/admin/admin-permissions'
|
|
13
14
|
import { ADMIN_ROLES_ABILITIES } from '@byline/admin/admin-roles'
|
|
14
15
|
import { ADMIN_USERS_ABILITIES } from '@byline/admin/admin-users'
|
|
15
16
|
import { useTranslation } from '@byline/i18n/react'
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
ActivityIcon,
|
|
19
|
+
HomeIcon,
|
|
20
|
+
RolesIcon,
|
|
21
|
+
SettingsSlidersIcon,
|
|
22
|
+
Tooltip,
|
|
23
|
+
UserIcon,
|
|
24
|
+
UsersIcon,
|
|
25
|
+
} from '@byline/ui/react'
|
|
17
26
|
import cx from 'classnames'
|
|
18
27
|
import { useSwipeable } from 'react-swipeable'
|
|
19
28
|
|
|
@@ -39,10 +48,22 @@ interface MenuItemProps {
|
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
function MenuItem({ to, label, icon, pathname, compact }: MenuItemProps) {
|
|
51
|
+
// In the compact (icon-only) state the label text is hidden, so a tooltip on
|
|
52
|
+
// the icon is the only way to discover what the item is. When labels are
|
|
53
|
+
// visible the tooltip would be redundant, so it's gated on `compact`. The
|
|
54
|
+
// span is the trigger (not the bare icon) so the ref / hover handlers Base UI
|
|
55
|
+
// merges always land on a DOM node regardless of how each icon forwards props.
|
|
56
|
+
const iconSpan = <span className="icon">{icon}</span>
|
|
42
57
|
return (
|
|
43
58
|
<li className={cx('menu-item', { active: isActive(pathname, to), compact })}>
|
|
44
59
|
<Link to={to}>
|
|
45
|
-
|
|
60
|
+
{compact ? (
|
|
61
|
+
<Tooltip text={label} side="right">
|
|
62
|
+
{iconSpan}
|
|
63
|
+
</Tooltip>
|
|
64
|
+
) : (
|
|
65
|
+
iconSpan
|
|
66
|
+
)}
|
|
46
67
|
<span className="label">{label}</span>
|
|
47
68
|
</Link>
|
|
48
69
|
</li>
|
|
@@ -63,7 +84,8 @@ export function AdminMenuDrawer(): React.JSX.Element | null {
|
|
|
63
84
|
const canReadUsers = has(ADMIN_USERS_ABILITIES.read)
|
|
64
85
|
const canReadRoles = has(ADMIN_ROLES_ABILITIES.read)
|
|
65
86
|
const canReadPermissions = has(ADMIN_PERMISSIONS_ABILITIES.read)
|
|
66
|
-
const
|
|
87
|
+
const canReadActivity = has(ADMIN_ACTIVITY_ABILITIES.read)
|
|
88
|
+
const showAdminSection = canReadUsers || canReadRoles || canReadPermissions || canReadActivity
|
|
67
89
|
|
|
68
90
|
const handlers = useSwipeable({
|
|
69
91
|
onSwipedLeft: () => {
|
|
@@ -133,6 +155,15 @@ export function AdminMenuDrawer(): React.JSX.Element | null {
|
|
|
133
155
|
compact={compact}
|
|
134
156
|
/>
|
|
135
157
|
)}
|
|
158
|
+
{canReadActivity && (
|
|
159
|
+
<MenuItem
|
|
160
|
+
to="/admin/activity"
|
|
161
|
+
label={t('chrome.menu.activity')}
|
|
162
|
+
icon={<ActivityIcon width="20px" height="20px" />}
|
|
163
|
+
pathname={pathname}
|
|
164
|
+
compact={compact}
|
|
165
|
+
/>
|
|
166
|
+
)}
|
|
136
167
|
</>
|
|
137
168
|
)}
|
|
138
169
|
<li className="menu-separator" />
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
import { useEffect, useState } from 'react'
|
|
25
25
|
|
|
26
26
|
import { useTranslation } from '@byline/i18n/react'
|
|
27
|
-
import { EyeClosedIcon, EyeOpenIcon } from '@byline/ui/react'
|
|
27
|
+
import { EyeClosedIcon, EyeOpenIcon, Tooltip } from '@byline/ui/react'
|
|
28
28
|
import cx from 'classnames'
|
|
29
29
|
|
|
30
30
|
import {
|
|
@@ -89,6 +89,7 @@ export function PreviewToggle({ compact }: PreviewToggleProps) {
|
|
|
89
89
|
) : (
|
|
90
90
|
<EyeClosedIcon width="20px" height="20px" />
|
|
91
91
|
)
|
|
92
|
+
const iconSpan = <span className="icon">{icon}</span>
|
|
92
93
|
|
|
93
94
|
return (
|
|
94
95
|
<li className={cx('menu-item byline-preview-toggle', { compact, active: preview })}>
|
|
@@ -99,9 +100,19 @@ export function PreviewToggle({ compact }: PreviewToggleProps) {
|
|
|
99
100
|
aria-label={
|
|
100
101
|
preview ? t('chrome.preview.disableAriaLabel') : t('chrome.preview.enableAriaLabel')
|
|
101
102
|
}
|
|
102
|
-
title
|
|
103
|
+
// Native title for the expanded state; in compact mode the styled
|
|
104
|
+
// Tooltip below takes over so the two don't fire at once.
|
|
105
|
+
title={
|
|
106
|
+
compact ? undefined : preview ? t('chrome.preview.onTitle') : t('chrome.preview.offTitle')
|
|
107
|
+
}
|
|
103
108
|
>
|
|
104
|
-
|
|
109
|
+
{compact ? (
|
|
110
|
+
<Tooltip text={label} side="right">
|
|
111
|
+
{iconSpan}
|
|
112
|
+
</Tooltip>
|
|
113
|
+
) : (
|
|
114
|
+
iconSpan
|
|
115
|
+
)}
|
|
105
116
|
<span className="label">{label}</span>
|
|
106
117
|
</button>
|
|
107
118
|
</li>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
10
|
+
|
|
11
|
+
import { useTranslation } from '@byline/i18n/react'
|
|
12
|
+
import { z } from 'zod'
|
|
13
|
+
|
|
14
|
+
import { ActivitySystemView } from '../admin-shell/admin-activity/list.js'
|
|
15
|
+
import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js'
|
|
16
|
+
import { getSystemActivityLog } from '../server-fns/admin-activity/index.js'
|
|
17
|
+
import type { SystemActivityResponse } from '../server-fns/admin-activity/index.js'
|
|
18
|
+
|
|
19
|
+
const searchSchema = z.object({
|
|
20
|
+
page: z.coerce.number().int().min(1).optional(),
|
|
21
|
+
page_size: z.coerce.number().int().min(1).max(100).optional(),
|
|
22
|
+
collection: z.string().optional(),
|
|
23
|
+
action: z.string().optional(),
|
|
24
|
+
actorId: z.string().optional(),
|
|
25
|
+
from: z.string().optional(),
|
|
26
|
+
to: z.string().optional(),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
type ActivitySearch = z.infer<typeof searchSchema>
|
|
30
|
+
|
|
31
|
+
export function createAdminActivityRoute(path: string) {
|
|
32
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic path bypasses route-tree typing
|
|
33
|
+
const Route: any = createFileRoute(path as never)({
|
|
34
|
+
validateSearch: searchSchema,
|
|
35
|
+
loaderDeps: ({ search }: { search: ActivitySearch }) => ({
|
|
36
|
+
page: search.page,
|
|
37
|
+
page_size: search.page_size,
|
|
38
|
+
collection: search.collection,
|
|
39
|
+
action: search.action,
|
|
40
|
+
actorId: search.actorId,
|
|
41
|
+
from: search.from,
|
|
42
|
+
to: search.to,
|
|
43
|
+
}),
|
|
44
|
+
loader: async ({ deps }: { deps: ActivitySearch }) => {
|
|
45
|
+
const data = await getSystemActivityLog({
|
|
46
|
+
data: {
|
|
47
|
+
page: deps.page,
|
|
48
|
+
page_size: deps.page_size,
|
|
49
|
+
collection: deps.collection,
|
|
50
|
+
action: deps.action,
|
|
51
|
+
actorId: deps.actorId,
|
|
52
|
+
from: deps.from,
|
|
53
|
+
to: deps.to,
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
return { data }
|
|
57
|
+
},
|
|
58
|
+
component: function AdminActivityComponent() {
|
|
59
|
+
const { data } = Route.useLoaderData() as { data: SystemActivityResponse }
|
|
60
|
+
const { t } = useTranslation('byline-admin')
|
|
61
|
+
return (
|
|
62
|
+
<>
|
|
63
|
+
<BreadcrumbsClient
|
|
64
|
+
breadcrumbs={[
|
|
65
|
+
{ label: t('chrome.menu.dashboard'), href: '/admin' },
|
|
66
|
+
{ label: t('activity.title'), href: '/admin/activity' },
|
|
67
|
+
]}
|
|
68
|
+
/>
|
|
69
|
+
<ActivitySystemView data={data} />
|
|
70
|
+
</>
|
|
71
|
+
)
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
return Route
|
|
76
|
+
}
|
package/src/routes/index.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
export { createAdminAccountRoute } from './create-admin-account-route.js'
|
|
24
|
+
export { createAdminActivityRoute } from './create-admin-activity-route.js'
|
|
24
25
|
export { createAdminDashboardRoute } from './create-admin-dashboard-route.js'
|
|
25
26
|
export { createAdminLayoutRoute } from './create-admin-layout-route.js'
|
|
26
27
|
export { createAdminPermissionsRoute } from './create-admin-permissions-route.js'
|
|
@@ -20,7 +20,7 @@ export interface ChangeAccountPasswordInput {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export const changeAccountPassword = createServerFn({ method: 'POST' })
|
|
23
|
-
.
|
|
23
|
+
.validator((input: ChangeAccountPasswordInput) => input)
|
|
24
24
|
.handler(async ({ data }): Promise<AccountResponse> => {
|
|
25
25
|
const context = await getAdminRequestContext()
|
|
26
26
|
return changeAccountPasswordCommand(context, data, { store: bylineCore().adminStore! })
|
|
@@ -20,7 +20,7 @@ import { bylineCore } from '../../integrations/byline-core.js'
|
|
|
20
20
|
* doesn't carry.
|
|
21
21
|
*/
|
|
22
22
|
export const getAccount = createServerFn({ method: 'GET' })
|
|
23
|
-
.
|
|
23
|
+
.validator((input?: Record<string, never>) => input ?? {})
|
|
24
24
|
.handler(async ({ data }): Promise<AccountResponse> => {
|
|
25
25
|
const context = await getAdminRequestContext()
|
|
26
26
|
return getAccountCommand(context, data, { store: bylineCore().adminStore! })
|
|
@@ -24,7 +24,7 @@ export interface UpdateAccountInput {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export const updateAccount = createServerFn({ method: 'POST' })
|
|
27
|
-
.
|
|
27
|
+
.validator((input: UpdateAccountInput) => input)
|
|
28
28
|
.handler(async ({ data }): Promise<AccountResponse> => {
|
|
29
29
|
const context = await getAdminRequestContext()
|
|
30
30
|
return updateAccountCommand(context, data, { store: bylineCore().adminStore! })
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
10
|
+
|
|
11
|
+
import { assertAdminActor } from '@byline/admin'
|
|
12
|
+
import { ADMIN_ACTIVITY_ABILITIES } from '@byline/admin/admin-activity'
|
|
13
|
+
import { ERR_AUDIT_UNSUPPORTED, getLogger, getServerConfig } from '@byline/core'
|
|
14
|
+
|
|
15
|
+
import { getAdminRequestContext } from '../../auth/auth-context.js'
|
|
16
|
+
import { type ActorLabelMap, resolveActorLabels } from '../collections/actors.js'
|
|
17
|
+
import type { AuditLogEntryDto } from '../collections/audit.js'
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// System-wide activity report (docs/AUDIT.md — Workstream 4)
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/** Filters for the activity feed. `from` / `to` are ISO strings over the wire. */
|
|
24
|
+
export interface SystemActivitySearchParams {
|
|
25
|
+
actorId?: string
|
|
26
|
+
/** Collection **path** (the admin works in paths; the handler resolves it to the stored collection id). */
|
|
27
|
+
collection?: string
|
|
28
|
+
action?: string
|
|
29
|
+
from?: string
|
|
30
|
+
to?: string
|
|
31
|
+
page?: number
|
|
32
|
+
page_size?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Collection display info, captured at query time (a row may name a collection that was later renamed/removed). */
|
|
36
|
+
export interface ActivityCollectionInfo {
|
|
37
|
+
path: string
|
|
38
|
+
singular: string
|
|
39
|
+
plural: string
|
|
40
|
+
}
|
|
41
|
+
export type ActivityCollectionMap = Record<string, ActivityCollectionInfo>
|
|
42
|
+
|
|
43
|
+
export interface SystemActivityResponse {
|
|
44
|
+
entries: AuditLogEntryDto[]
|
|
45
|
+
meta: { total: number; page: number; pageSize: number; totalPages: number }
|
|
46
|
+
/** Acting-user id → display label. Absent ids (system rows, deleted users) render a tombstone. */
|
|
47
|
+
actors: ActorLabelMap
|
|
48
|
+
/** Collection id → display info. Absent ids name a removed collection. */
|
|
49
|
+
collections: ActivityCollectionMap
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The system-wide activity feed: the union of the version stream (content
|
|
54
|
+
* saves) and the audit log (status / path / locale changes, deletions, and
|
|
55
|
+
* future admin-realm events). Unlike the per-document audit fn, this is NOT
|
|
56
|
+
* routed through `CollectionHandle` — it is cross-collection and includes
|
|
57
|
+
* admin-realm rows with no `document_id`, so it reads the adapter's audit
|
|
58
|
+
* queries directly and is gated system-wide by `admin.activity.read` rather
|
|
59
|
+
* than by any document's own read gate.
|
|
60
|
+
*/
|
|
61
|
+
export const getSystemActivityLog = createServerFn({ method: 'GET' })
|
|
62
|
+
.validator((input: SystemActivitySearchParams) => input ?? {})
|
|
63
|
+
.handler(async ({ data }): Promise<SystemActivityResponse> => {
|
|
64
|
+
const context = await getAdminRequestContext()
|
|
65
|
+
// System-wide gate — independent of any collection ability. An auditor
|
|
66
|
+
// role holds this without holding content read/write.
|
|
67
|
+
assertAdminActor(context, ADMIN_ACTIVITY_ABILITIES.read)
|
|
68
|
+
|
|
69
|
+
const queries = getServerConfig().db.queries
|
|
70
|
+
if (queries.audit == null) {
|
|
71
|
+
throw ERR_AUDIT_UNSUPPORTED({
|
|
72
|
+
message: 'the configured db adapter does not support audit-log reads (queries.audit)',
|
|
73
|
+
}).log(getLogger())
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The admin works in collection paths; the audit rows store the collection
|
|
77
|
+
// id. Resolve path → id here. An unknown path yields no id, so the filter
|
|
78
|
+
// simply matches nothing rather than erroring.
|
|
79
|
+
let collectionId: string | undefined
|
|
80
|
+
if (data.collection) {
|
|
81
|
+
const col = await queries.collections.getCollectionByPath(data.collection)
|
|
82
|
+
collectionId = col?.id
|
|
83
|
+
if (collectionId == null) {
|
|
84
|
+
return {
|
|
85
|
+
entries: [],
|
|
86
|
+
meta: { total: 0, page: 1, pageSize: 0, totalPages: 0 },
|
|
87
|
+
actors: {},
|
|
88
|
+
collections: {},
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const result = await queries.audit.findAuditLog({
|
|
94
|
+
actorId: data.actorId,
|
|
95
|
+
collectionId,
|
|
96
|
+
action: data.action,
|
|
97
|
+
from: data.from ? new Date(data.from) : undefined,
|
|
98
|
+
to: data.to ? new Date(data.to) : undefined,
|
|
99
|
+
page: data.page,
|
|
100
|
+
page_size: data.page_size,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Acting-user labels (admin realm), resolved here from each row's raw
|
|
104
|
+
// actorId — same helper the per-document audit view uses. System/tooling
|
|
105
|
+
// rows (NULL actorId) and deleted users are absent from the map; the UI
|
|
106
|
+
// renders the tombstone label.
|
|
107
|
+
const actors: ActorLabelMap = await resolveActorLabels(result.entries.map((e) => e.actorId))
|
|
108
|
+
|
|
109
|
+
// Collection display info, keyed by the raw collectionId carried on each
|
|
110
|
+
// row. Resolved once from the current collection set; a row whose
|
|
111
|
+
// collection was since removed is simply absent (the log outlives it).
|
|
112
|
+
const collections: ActivityCollectionMap = {}
|
|
113
|
+
const collectionIds = new Set(
|
|
114
|
+
result.entries.map((e) => e.collectionId).filter((id): id is string => id != null)
|
|
115
|
+
)
|
|
116
|
+
if (collectionIds.size > 0) {
|
|
117
|
+
const all = await queries.collections.getAllCollections()
|
|
118
|
+
for (const c of all) {
|
|
119
|
+
if (collectionIds.has(c.id)) {
|
|
120
|
+
collections[c.id] = { path: c.path, singular: c.singular, plural: c.plural }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const entries: AuditLogEntryDto[] = result.entries.map((e) => ({
|
|
126
|
+
id: e.id,
|
|
127
|
+
documentId: e.documentId,
|
|
128
|
+
collectionId: e.collectionId,
|
|
129
|
+
actorId: e.actorId,
|
|
130
|
+
actorRealm: e.actorRealm,
|
|
131
|
+
action: e.action,
|
|
132
|
+
field: e.field,
|
|
133
|
+
before: e.before as string | string[] | null,
|
|
134
|
+
after: e.after as string | string[] | null,
|
|
135
|
+
occurredAt: e.occurredAt instanceof Date ? e.occurredAt.toISOString() : String(e.occurredAt),
|
|
136
|
+
}))
|
|
137
|
+
|
|
138
|
+
return { entries, meta: result.meta, actors, collections }
|
|
139
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Admin-activity server fns (docs/AUDIT.md — Workstream 4) — the
|
|
11
|
+
* system-wide activity report. Reads the adapter's audit queries directly
|
|
12
|
+
* (cross-collection, includes admin-realm rows) behind the
|
|
13
|
+
* `admin.activity.read` gate, rather than routing through a per-document
|
|
14
|
+
* read like the collections audit fn.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
type ActivityCollectionInfo,
|
|
19
|
+
type ActivityCollectionMap,
|
|
20
|
+
getSystemActivityLog,
|
|
21
|
+
type SystemActivityResponse,
|
|
22
|
+
type SystemActivitySearchParams,
|
|
23
|
+
} from './get.js'
|
|
@@ -17,7 +17,7 @@ import { getAdminRequestContext } from '../../auth/auth-context.js'
|
|
|
17
17
|
import { bylineCore } from '../../integrations/byline-core.js'
|
|
18
18
|
|
|
19
19
|
export const getRoleAbilities = createServerFn({ method: 'GET' })
|
|
20
|
-
.
|
|
20
|
+
.validator((input: { id: string }) => input)
|
|
21
21
|
.handler(async ({ data }): Promise<GetRoleAbilitiesResponse> => {
|
|
22
22
|
const context = await getAdminRequestContext()
|
|
23
23
|
return getRoleAbilitiesCommand(context, data, {
|
|
@@ -22,7 +22,7 @@ export interface SetRoleAbilitiesInput {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export const setRoleAbilities = createServerFn({ method: 'POST' })
|
|
25
|
-
.
|
|
25
|
+
.validator((input: SetRoleAbilitiesInput) => input)
|
|
26
26
|
.handler(async ({ data }): Promise<SetRoleAbilitiesResponse> => {
|
|
27
27
|
const context = await getAdminRequestContext()
|
|
28
28
|
return setRoleAbilitiesCommand(context, data, {
|
|
@@ -14,7 +14,7 @@ import { getAdminRequestContext } from '../../auth/auth-context.js'
|
|
|
14
14
|
import { bylineCore } from '../../integrations/byline-core.js'
|
|
15
15
|
|
|
16
16
|
export const whoHasAbility = createServerFn({ method: 'GET' })
|
|
17
|
-
.
|
|
17
|
+
.validator((input: { ability: string }) => input)
|
|
18
18
|
.handler(async ({ data }): Promise<WhoHasAbilityResponse> => {
|
|
19
19
|
const context = await getAdminRequestContext()
|
|
20
20
|
return whoHasAbilityCommand(context, data, {
|
|
@@ -21,7 +21,7 @@ export interface CreateAdminRoleInput {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export const createAdminRole = createServerFn({ method: 'POST' })
|
|
24
|
-
.
|
|
24
|
+
.validator((input: CreateAdminRoleInput) => input)
|
|
25
25
|
.handler(async ({ data }): Promise<AdminRoleResponse> => {
|
|
26
26
|
const context = await getAdminRequestContext()
|
|
27
27
|
return createAdminRoleCommand(context, data, { store: bylineCore().adminStore! })
|