@adobedjangir/commerce-admin-audit-log 0.1.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.
@@ -0,0 +1,21 @@
1
+ # Action fragment for the audit-log add-on. The host's app.config.yaml
2
+ # $includes this file under `application.runtimeManifest.packages.AuditLog`
3
+ # — so this file should contain ONLY the body of that package definition
4
+ # (license + actions), not the `packages:` wrapper.
5
+ license: Apache-2.0
6
+ actions:
7
+ system-config-audit-list:
8
+ function: system-config-audit-list/index.js
9
+ web: 'yes'
10
+ runtime: 'nodejs:20'
11
+ inputs:
12
+ LOG_LEVEL: debug
13
+ OAUTH_CLIENT_ID: $OAUTH_CLIENT_ID
14
+ OAUTH_CLIENT_SECRET: $OAUTH_CLIENT_SECRET
15
+ OAUTH_ORG_ID: $OAUTH_ORG_ID
16
+ OAUTH_SCOPES: $OAUTH_SCOPES
17
+ AIO_DB_REGION: $AIO_DB_REGION
18
+ annotations:
19
+ require-adobe-auth: false
20
+ include-ims-credentials: true
21
+ final: true
@@ -0,0 +1,78 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ Licensed under the Apache License, Version 2.0
4
+ */
5
+
6
+ // Paginated audit-log reader. Returns newest entries first; supports filtering
7
+ // by scope, path substring, actor, and date range. The save action writes
8
+ // the docs; this action only reads. Sensitive values are already redacted at
9
+ // write time, so this endpoint is safe to expose to read-only roles.
10
+
11
+ const { Core } = require('@adobe/aio-sdk')
12
+ const { errorResponse } = require('@adobedjangir/commerce-admin-management/actions/utils')
13
+ const { getClient } = require('@adobedjangir/commerce-admin-management/abdb')
14
+
15
+ const COLLECTION = 'system_config_audit'
16
+ const DEFAULT_LIMIT = 100
17
+ const MAX_LIMIT = 500
18
+
19
+ function buildFilter (params) {
20
+ const f = {}
21
+ if (params.scope) f.scope = String(params.scope)
22
+ if (params.scopeId) f.scope_id = String(params.scopeId)
23
+ if (params.actor) f.changedBy = String(params.actor)
24
+ if (params.action && ['create', 'update', 'delete'].includes(params.action)) {
25
+ f.action = params.action
26
+ }
27
+ if (params.since) f.changedAt = { ...(f.changedAt || {}), $gte: String(params.since) }
28
+ if (params.until) f.changedAt = { ...(f.changedAt || {}), $lte: String(params.until) }
29
+ return f
30
+ }
31
+
32
+ async function main (params) {
33
+ const logger = Core.Logger('system-config-audit-list', { level: params.LOG_LEVEL || 'info' })
34
+
35
+ const limit = Math.min(
36
+ Math.max(parseInt(params.limit, 10) || DEFAULT_LIMIT, 1),
37
+ MAX_LIMIT
38
+ )
39
+ const skip = Math.max(parseInt(params.skip, 10) || 0, 0)
40
+ const filter = buildFilter(params)
41
+ // Client-side path filter — $regex isn't reliably supported across ABDB
42
+ // driver versions, so we do a substring match in app-land after fetch.
43
+ const pathFilter = params.path ? String(params.path).toLowerCase() : null
44
+
45
+ let dbHandle
46
+ try {
47
+ dbHandle = await getClient(params)
48
+ } catch (e) {
49
+ return errorResponse(500, `ABDB connect failed: ${e.message}`, logger)
50
+ }
51
+ const { client, close } = dbHandle
52
+
53
+ try {
54
+ const col = await client.collection(COLLECTION)
55
+ // Newest first. Without an index ABDB will scan, but the cap on audit
56
+ // doc count keeps this bounded.
57
+ let cursor = col.find(filter).sort({ changedAt: -1 })
58
+ if (!pathFilter) {
59
+ cursor = cursor.skip(skip).limit(limit)
60
+ }
61
+ let docs = await cursor.toArray()
62
+ if (pathFilter) {
63
+ docs = docs.filter((d) => (d.path || '').toLowerCase().includes(pathFilter))
64
+ docs = docs.slice(skip, skip + limit)
65
+ }
66
+ return {
67
+ statusCode: 200,
68
+ body: { ok: true, items: docs, limit, skip, returned: docs.length }
69
+ }
70
+ } catch (error) {
71
+ logger.error(error)
72
+ return errorResponse(500, error.message || 'audit list failed', logger)
73
+ } finally {
74
+ try { await close() } catch (_) {}
75
+ }
76
+ }
77
+
78
+ exports.main = main
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@adobedjangir/commerce-admin-audit-log",
3
+ "version": "0.1.0",
4
+ "description": "Audit log + revert add-on for @adobedjangir/commerce-admin-management. Records every system_config change with old/new/actor and exposes a UI tab to inspect + revert.",
5
+ "license": "Apache-2.0",
6
+ "author": "Adobe Inc.",
7
+ "keywords": ["adobe-io", "aio", "app-builder", "commerce-admin", "audit-log"],
8
+ "main": "./src/index.js",
9
+ "exports": {
10
+ ".": "./src/index.js",
11
+ "./hook": "./src/hook.js",
12
+ "./web": "./web/index.js"
13
+ },
14
+ "scripts": {
15
+ "postinstall": "node ./scripts/setup.js"
16
+ },
17
+ "files": ["src", "actions", "web", "scripts", "README.md"],
18
+ "engines": { "node": ">=18" },
19
+ "peerDependencies": {
20
+ "@adobedjangir/commerce-admin-management": ">=0.3.0",
21
+ "@adobedjangir/commerce-admin-get-config": ">=0.0.7"
22
+ }
23
+ }
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ Copyright 2025 Adobe. All rights reserved.
4
+ Licensed under the Apache License, Version 2.0
5
+ */
6
+
7
+ const fs = require('fs')
8
+ const path = require('path')
9
+
10
+ // ── Per-add-on configuration ──
11
+ const PACKAGE_NAME = '@adobedjangir/commerce-admin-audit-log'
12
+ const RUNTIME_KEY = 'AuditLog'
13
+ const INCLUDE_REL = 'node_modules/@adobedjangir/commerce-admin-audit-log/actions/ext.config.yaml'
14
+ const REGISTER_IMPORT = "import registerAuditLog from '@adobedjangir/commerce-admin-audit-log/web'"
15
+ const REGISTER_CALL = 'registerAuditLog()'
16
+ const MARKER = `# ${PACKAGE_NAME} (auto-linked on npm install)`
17
+
18
+ // ── Boilerplate (identical across add-ons; small enough to inline) ──
19
+ function findProjectRoot (startDir) {
20
+ let dir = startDir
21
+ while (dir && dir !== path.dirname(dir)) {
22
+ if (fs.existsSync(path.join(dir, 'app.config.yaml'))) return dir
23
+ dir = path.dirname(dir)
24
+ }
25
+ return null
26
+ }
27
+ function resolveProjectRoot () {
28
+ return (process.env.INIT_CWD && findProjectRoot(process.env.INIT_CWD)) ||
29
+ findProjectRoot(process.cwd())
30
+ }
31
+
32
+ /**
33
+ * Add (or replace) an `application.runtimeManifest.packages.<RUNTIME_KEY>`
34
+ * block that $includes our fragment. Idempotent.
35
+ */
36
+ function patchAppConfig (content) {
37
+ // Look for our own marker to detect prior installs.
38
+ const blockRe = new RegExp(
39
+ String.raw`^[ \t]*${MARKER}\n([ \t]+${RUNTIME_KEY}:[ \t]*\n[ \t]+\$include:[^\n]*\n)`, 'm'
40
+ )
41
+ const desiredBody = ` ${MARKER}\n ${RUNTIME_KEY}:\n $include: ${INCLUDE_REL}\n`
42
+
43
+ if (blockRe.test(content)) {
44
+ // Already linked — leave it.
45
+ return { content, changed: false, reason: 'already-linked' }
46
+ }
47
+
48
+ // 1. application.runtimeManifest.packages exists → append our subkey.
49
+ if (/^application:[ \t]*\n([ \t]+[^\n]*\n)*[ \t]+runtimeManifest:[ \t]*\n([ \t]+[^\n]*\n)*[ \t]+packages:[ \t]*\n/m.test(content)) {
50
+ const next = content.replace(
51
+ /([ \t]+packages:[ \t]*\n)/m,
52
+ (m) => m + desiredBody
53
+ )
54
+ return { content: next, changed: next !== content, reason: 'appended-under-packages' }
55
+ }
56
+
57
+ // 2. application.runtimeManifest exists but no `packages:` → add one.
58
+ if (/^application:[ \t]*\n([ \t]+[^\n]*\n)*[ \t]+runtimeManifest:[ \t]*\n/m.test(content)) {
59
+ const next = content.replace(
60
+ /([ \t]+runtimeManifest:[ \t]*\n)/m,
61
+ (m) => m + ` packages:\n${desiredBody}`
62
+ )
63
+ return { content: next, changed: next !== content, reason: 'added-packages' }
64
+ }
65
+
66
+ // 3. application: exists but no runtimeManifest → add the whole tree.
67
+ if (/^application:[ \t]*\n/m.test(content)) {
68
+ const next = content.replace(
69
+ /^application:[ \t]*\n/m,
70
+ `application:\n runtimeManifest:\n packages:\n${desiredBody}`
71
+ )
72
+ return { content: next, changed: next !== content, reason: 'added-runtimeManifest' }
73
+ }
74
+
75
+ // 4. No application: → append the whole block.
76
+ const trimmed = content.replace(/\s+$/, '')
77
+ const sep = trimmed ? '\n\n' : ''
78
+ return {
79
+ content: `${trimmed}${sep}application:\n runtimeManifest:\n packages:\n${desiredBody}`,
80
+ changed: true,
81
+ reason: 'appended-application'
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Patch the host's web-src/src/index.js to import + call our register fn.
87
+ * We look for a managed `// --- COMMERCE-ADMIN ADDONS ---` block; if it's
88
+ * absent we add one. Idempotent — the registration line is only added once.
89
+ */
90
+ function patchBootstrap (content) {
91
+ const blockStart = '// --- COMMERCE-ADMIN ADDONS (auto-managed) ---'
92
+ const blockEnd = '// --- COMMERCE-ADMIN ADDONS END ---'
93
+ // Already registered?
94
+ if (content.includes(REGISTER_IMPORT) && content.includes(REGISTER_CALL)) {
95
+ return { content, changed: false, reason: 'already-registered' }
96
+ }
97
+
98
+ // Find or create the managed block.
99
+ if (content.includes(blockStart)) {
100
+ // Insert our import + call inside the block.
101
+ const next = content.replace(
102
+ blockStart + '\n',
103
+ blockStart + '\n' + REGISTER_IMPORT + '\n' + REGISTER_CALL + '\n'
104
+ )
105
+ return { content: next, changed: true, reason: 'inserted-into-existing-block' }
106
+ }
107
+
108
+ // No block yet — append one after `configureWeb({...})` call.
109
+ const cwRe = /configureWeb\s*\([^)]*\)\s*\n/
110
+ if (cwRe.test(content)) {
111
+ const next = content.replace(
112
+ cwRe,
113
+ (m) => m + '\n' + blockStart + '\n' + REGISTER_IMPORT + '\n' + REGISTER_CALL + '\n' + blockEnd + '\n'
114
+ )
115
+ return { content: next, changed: true, reason: 'created-block-after-configureWeb' }
116
+ }
117
+
118
+ // No configureWeb? Append at end as a best effort.
119
+ return {
120
+ content: content + '\n' + blockStart + '\n' + REGISTER_IMPORT + '\n' + REGISTER_CALL + '\n' + blockEnd + '\n',
121
+ changed: true,
122
+ reason: 'appended-block-at-end'
123
+ }
124
+ }
125
+
126
+ function main () {
127
+ if (process.env.CONFIGURATION_MANAGEMENT_SKIP_SETUP === '1') return
128
+ const root = resolveProjectRoot()
129
+ if (!root) {
130
+ console.log(`[${PACKAGE_NAME}] No app.config.yaml found — skip setup.`)
131
+ return
132
+ }
133
+
134
+ // 1. app.config.yaml
135
+ const cfg = path.join(root, 'app.config.yaml')
136
+ if (fs.existsSync(cfg)) {
137
+ const before = fs.readFileSync(cfg, 'utf8')
138
+ const { content, changed, reason } = patchAppConfig(before)
139
+ if (changed) {
140
+ fs.writeFileSync(cfg, content, 'utf8')
141
+ console.log(`[${PACKAGE_NAME}] app.config.yaml: ${reason}`)
142
+ } else {
143
+ console.log(`[${PACKAGE_NAME}] app.config.yaml: ${reason}`)
144
+ }
145
+ }
146
+
147
+ // 2. bootstrap (web-src/src/index.js)
148
+ const boot = path.join(root, 'web-src', 'src', 'index.js')
149
+ if (fs.existsSync(boot)) {
150
+ const before = fs.readFileSync(boot, 'utf8')
151
+ const { content, changed, reason } = patchBootstrap(before)
152
+ if (changed) {
153
+ fs.writeFileSync(boot, content, 'utf8')
154
+ console.log(`[${PACKAGE_NAME}] web-src/src/index.js: ${reason}`)
155
+ } else {
156
+ console.log(`[${PACKAGE_NAME}] web-src/src/index.js: ${reason}`)
157
+ }
158
+ }
159
+ }
160
+
161
+ if (require.main === module) {
162
+ try { main() } catch (err) {
163
+ // Never fail npm install on scaffold issues.
164
+ console.error(`[${PACKAGE_NAME}] setup error (install continues):`, err.message)
165
+ }
166
+ }
167
+
168
+ module.exports = { patchAppConfig, patchBootstrap }
package/src/hook.js ADDED
@@ -0,0 +1,56 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ Licensed under the Apache License, Version 2.0
4
+ */
5
+
6
+ // Server-side hook for core's system-config-save. Core soft-requires this
7
+ // module (`try { require('@adobedjangir/commerce-admin-audit-log/hook') } catch {}`)
8
+ // — when the add-on is not installed, audit writes silently no-op.
9
+
10
+ const AUDIT_COLLECTION = 'system_config_audit'
11
+ const AUDIT_MAX_DOCS = 10000
12
+
13
+ async function ensureCollection (client, name) {
14
+ try {
15
+ await client.createCollection(name)
16
+ } catch (err) {
17
+ const msg = (err && err.message) ? String(err.message) : String(err)
18
+ if (!/exist|already|duplicate/i.test(msg)) throw err
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Append a batch of audit entries. Best-effort; never throws.
24
+ *
25
+ * @param {object} client ABDB client from core's getClient(...)
26
+ * @param {Array} entries [{ scope, scope_id, path, action, oldValue, newValue, changedBy, changedAt }]
27
+ * @param {object} [logger] aio logger
28
+ */
29
+ async function recordAuditEntries (client, entries, logger) {
30
+ const log = logger || { warn: () => {} }
31
+ if (!client || !Array.isArray(entries) || entries.length === 0) return
32
+ try {
33
+ await ensureCollection(client, AUDIT_COLLECTION)
34
+ const col = await client.collection(AUDIT_COLLECTION)
35
+ try {
36
+ await col.insertMany(entries)
37
+ } catch (err) {
38
+ const msg = (err && err.message) ? String(err.message) : String(err)
39
+ if (!/insertMany|unsupported|not implemented/i.test(msg)) throw err
40
+ for (const e of entries) await col.insertOne(e)
41
+ }
42
+ // Best-effort cap.
43
+ try {
44
+ const total = await col.countDocuments({})
45
+ if (total > AUDIT_MAX_DOCS) {
46
+ const over = total - AUDIT_MAX_DOCS
47
+ const oldest = await col.find({}).sort({ changedAt: 1 }).limit(over).toArray()
48
+ for (const o of oldest) await col.deleteOne({ _id: o._id })
49
+ }
50
+ } catch (_) { /* compaction is best-effort */ }
51
+ } catch (err) {
52
+ log.warn(`audit-log hook: write failed (${err.message})`)
53
+ }
54
+ }
55
+
56
+ module.exports = { recordAuditEntries, AUDIT_COLLECTION }
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ Licensed under the Apache License, Version 2.0
4
+ */
5
+
6
+ module.exports = require('./hook')
package/web/index.js ADDED
@@ -0,0 +1,35 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ Licensed under the Apache License, Version 2.0
4
+ */
5
+
6
+ // Register Audit Log into the host's commerce-admin-management instance.
7
+ // Idempotent — calling twice just re-registers the same entries.
8
+ //
9
+ // Usage (host's web-src/src/index.js, scaffolded by this add-on's postinstall):
10
+ // import registerAuditLog from '@adobedjangir/commerce-admin-audit-log/web'
11
+ // registerAuditLog()
12
+ //
13
+ // configureWeb is core's own — extraNav/extraPages/actionKeys are merged
14
+ // (append + dedup by id), so every add-on can chain its own contribution
15
+ // without clobbering siblings.
16
+
17
+ import { configureWeb } from '@adobedjangir/commerce-admin-management/web'
18
+ import AuditLog from './src/AuditLog'
19
+
20
+ export default function registerAuditLog () {
21
+ configureWeb({
22
+ actionKeys: {
23
+ systemConfigAuditList: 'AuditLog/system-config-audit-list'
24
+ },
25
+ extraNav: [{
26
+ id: 'audit-log',
27
+ path: '/audit-log',
28
+ label: 'Audit Log',
29
+ icon: 'Properties'
30
+ }],
31
+ extraPages: { 'audit-log': AuditLog }
32
+ })
33
+ }
34
+
35
+ export { default as AuditLog } from './src/AuditLog'
@@ -0,0 +1,523 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ Licensed under the Apache License, Version 2.0
4
+ */
5
+
6
+ // Audit log viewer — paginated reader for the `system_config_audit` collection
7
+ // written by system-config-save. Filters narrow the result set client+server
8
+ // (server applies most filters; path is a client-side substring match).
9
+ //
10
+ // Each row also exposes a Revert action. "Revert" semantics:
11
+ // - create → there was no value before, so revert means delete the row
12
+ // (we send USE_DEFAULT_SENTINEL to system-config-save).
13
+ // - update → write `oldValue` back at the same scope:scopeId/path.
14
+ // - delete → re-insert `oldValue` at the same scope:scopeId/path.
15
+ //
16
+ // Sensitive fields are blocked from revert because audit stores
17
+ // `[ENCRYPTED]` instead of plaintext — there's nothing to restore.
18
+
19
+ import React, { useCallback, useEffect, useState } from 'react'
20
+ import {
21
+ View,
22
+ Flex,
23
+ Heading,
24
+ Text,
25
+ TextField,
26
+ Picker,
27
+ Item,
28
+ Button,
29
+ SearchField,
30
+ ProgressCircle,
31
+ Well,
32
+ StatusLight,
33
+ DialogTrigger,
34
+ Dialog,
35
+ Content,
36
+ Header,
37
+ Divider,
38
+ ButtonGroup
39
+ } from '@adobe/react-spectrum'
40
+ import { callAction, resolveActor } from '@adobedjangir/commerce-admin-management/web'
41
+ import { getActionKey } from '@adobedjangir/commerce-admin-management/web'
42
+ import { PALETTE, RADIUS, SHADOW } from '@adobedjangir/commerce-admin-management/web'
43
+
44
+ const PAGE_SIZE_OPTIONS = [
45
+ { id: '25', label: '25 / page' },
46
+ { id: '50', label: '50 / page' },
47
+ { id: '100', label: '100 / page' },
48
+ { id: '200', label: '200 / page' }
49
+ ]
50
+ const DEFAULT_PAGE_SIZE = 50
51
+ const SENSITIVE_TOKEN = '[ENCRYPTED]'
52
+ const USE_DEFAULT_SENTINEL = '__USE_DEFAULT__'
53
+ const ACTION_OPTIONS = [
54
+ { id: 'any', label: 'Any action' },
55
+ { id: 'create', label: 'Create' },
56
+ { id: 'update', label: 'Update' },
57
+ { id: 'delete', label: 'Delete' }
58
+ ]
59
+ const SCOPE_OPTIONS = [
60
+ { id: 'any', label: 'Any scope' },
61
+ { id: 'default', label: 'Default' },
62
+ { id: 'websites', label: 'Websites' },
63
+ { id: 'stores', label: 'Stores' }
64
+ ]
65
+
66
+ function actionTone (a) {
67
+ if (a === 'create') return 'positive'
68
+ if (a === 'delete') return 'negative'
69
+ if (a === 'update') return 'notice'
70
+ return 'neutral'
71
+ }
72
+
73
+ function fmtTime (iso) {
74
+ if (!iso) return ''
75
+ try { return new Date(iso).toLocaleString() } catch (_) { return iso }
76
+ }
77
+
78
+ function fmtValue (v) {
79
+ if (v == null) return '∅'
80
+ if (typeof v === 'string') return v
81
+ try { return JSON.stringify(v) } catch (_) { return String(v) }
82
+ }
83
+
84
+ /**
85
+ * Build the payload for system-config-save that reverses an audit row.
86
+ * Returns `{ ok, payload, message }` — when `ok=false`, surface the message
87
+ * to the operator instead of attempting the save.
88
+ */
89
+ function buildRevertPayload (row) {
90
+ if (!row || !row.path) {
91
+ return { ok: false, message: 'Audit row is malformed.' }
92
+ }
93
+ if (row.oldValue === SENSITIVE_TOKEN || row.newValue === SENSITIVE_TOKEN) {
94
+ return {
95
+ ok: false,
96
+ message: 'This change is for a sensitive (encrypted) field — the original value is not stored in the audit log, so it cannot be reverted from here.'
97
+ }
98
+ }
99
+ // 'create' had no prior value, so reverting means "delete the override".
100
+ if (row.action === 'create' || row.oldValue == null) {
101
+ return {
102
+ ok: true,
103
+ payload: {
104
+ values: { [row.path]: USE_DEFAULT_SENTINEL },
105
+ scope: row.scope || 'default',
106
+ scopeId: row.scope_id || '0'
107
+ }
108
+ }
109
+ }
110
+ // update / delete: write oldValue back.
111
+ return {
112
+ ok: true,
113
+ payload: {
114
+ values: { [row.path]: row.oldValue },
115
+ scope: row.scope || 'default',
116
+ scopeId: row.scope_id || '0'
117
+ }
118
+ }
119
+ }
120
+
121
+ export default function AuditLog ({ runtime, ims }) {
122
+ const [items, setItems] = useState([])
123
+ const [loading, setLoading] = useState(false)
124
+ const [error, setError] = useState(null)
125
+ const [page, setPage] = useState(0)
126
+ const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
127
+ const [returned, setReturned] = useState(0)
128
+ const [status, setStatus] = useState({ tone: 'neutral', message: '' })
129
+
130
+ // Confirmation dialog state — null while closed, the row to revert when open.
131
+ const [confirmRow, setConfirmRow] = useState(null)
132
+ const [reverting, setReverting] = useState(false)
133
+
134
+ // Filters — kept as draft state; user clicks Search to apply (no live
135
+ // re-fetch on every keystroke since the action is a real HTTP call).
136
+ const [scope, setScope] = useState('any')
137
+ const [actionFilter, setActionFilter] = useState('any')
138
+ const [pathFilter, setPathFilter] = useState('')
139
+ const [actor, setActor] = useState('')
140
+ const [since, setSince] = useState('')
141
+ const [until, setUntil] = useState('')
142
+
143
+ /**
144
+ * Each fetch replaces the current page (skip = page * pageSize). The
145
+ * audit-list action returns `{ items, returned }` — we treat
146
+ * `returned < pageSize` as "no more pages after this one".
147
+ */
148
+ const fetchPage = useCallback(async (nextPage = 0, sizeOverride = null) => {
149
+ const size = sizeOverride || pageSize
150
+ setLoading(true)
151
+ setError(null)
152
+ try {
153
+ const params = { limit: size, skip: nextPage * size }
154
+ if (scope !== 'any') params.scope = scope
155
+ if (actionFilter !== 'any') params.action = actionFilter
156
+ if (pathFilter.trim()) params.path = pathFilter.trim()
157
+ if (actor.trim()) params.actor = actor.trim()
158
+ if (since.trim()) params.since = since.trim()
159
+ if (until.trim()) params.until = until.trim()
160
+ const res = await callAction(
161
+ { runtime, ims },
162
+ getActionKey('systemConfigAuditList'),
163
+ '',
164
+ params
165
+ )
166
+ const body = res?.body || res
167
+ const next = Array.isArray(body?.items) ? body.items : []
168
+ setItems(next)
169
+ setReturned(body?.returned ?? next.length)
170
+ setPage(nextPage)
171
+ } catch (e) {
172
+ setError(e.message || 'Failed to load audit log')
173
+ } finally {
174
+ setLoading(false)
175
+ }
176
+ }, [runtime, ims, scope, actionFilter, pathFilter, actor, since, until, pageSize])
177
+
178
+ useEffect(() => {
179
+ fetchPage(0)
180
+ // initial load only — filters refresh on user action
181
+ // eslint-disable-next-line react-hooks/exhaustive-deps
182
+ }, [])
183
+
184
+ const onSearch = () => fetchPage(0)
185
+ const onPrev = () => { if (page > 0) fetchPage(page - 1) }
186
+ const onNext = () => { if (returned >= pageSize) fetchPage(page + 1) }
187
+ const onChangePageSize = (next) => {
188
+ const n = Number(next) || DEFAULT_PAGE_SIZE
189
+ setPageSize(n)
190
+ fetchPage(0, n) // reset to page 0 when page size changes
191
+ }
192
+ const hasNext = returned >= pageSize
193
+ const hasPrev = page > 0
194
+
195
+ /**
196
+ * Pagination bar — rendered above the table. Kept as a local component so
197
+ * we don't repeat the markup if we ever want one at the bottom too.
198
+ */
199
+ const Pagination = () => (
200
+ <View
201
+ paddingX="size-200"
202
+ paddingY="size-150"
203
+ UNSAFE_style={{
204
+ background: PALETTE.surfaceMuted,
205
+ borderBottom: `1px solid ${PALETTE.border}`
206
+ }}
207
+ >
208
+ <Flex gap="size-200" alignItems="center" justifyContent="space-between" wrap>
209
+ <Flex gap="size-150" alignItems="center" wrap>
210
+ <Picker
211
+ aria-label="Rows per page"
212
+ selectedKey={String(pageSize)}
213
+ onSelectionChange={onChangePageSize}
214
+ width="size-1700"
215
+ isDisabled={loading}
216
+ >
217
+ {PAGE_SIZE_OPTIONS.map((o) => <Item key={o.id}>{o.label}</Item>)}
218
+ </Picker>
219
+ <Text UNSAFE_style={{ color: PALETTE.textMuted, fontSize: 12 }}>
220
+ {items.length === 0
221
+ ? 'No rows'
222
+ : <>Page <strong>{page + 1}</strong> · showing rows {page * pageSize + 1}–{page * pageSize + returned}</>}
223
+ </Text>
224
+ </Flex>
225
+ <Flex gap="size-100">
226
+ <Button variant="secondary" onPress={onPrev} isDisabled={!hasPrev || loading}>
227
+ ← Prev
228
+ </Button>
229
+ <Button variant="secondary" onPress={onNext} isDisabled={!hasNext || loading}>
230
+ Next →
231
+ </Button>
232
+ </Flex>
233
+ </Flex>
234
+ </View>
235
+ )
236
+
237
+ /** Attempt to revert a row. Confirmation has already been given. */
238
+ const doRevert = useCallback(async (row) => {
239
+ const built = buildRevertPayload(row)
240
+ if (!built.ok) {
241
+ setStatus({ tone: 'negative', message: built.message })
242
+ setConfirmRow(null)
243
+ return
244
+ }
245
+ // Tag the revert audit row with the operator's identity AND the source
246
+ // entry — easier to trace "X reverted Y" in the audit later.
247
+ const baseActor = resolveActor(ims)
248
+ const tagged = row.changedAt
249
+ ? `${baseActor} (revert of ${row.changedAt})`
250
+ : baseActor
251
+ setReverting(true)
252
+ setStatus({ tone: 'notice', message: 'Reverting…' })
253
+ try {
254
+ const res = await callAction(
255
+ { runtime, ims },
256
+ getActionKey('systemConfigSave'),
257
+ '',
258
+ { ...built.payload, actor: tagged }
259
+ )
260
+ const body = res?.body || res
261
+ if (body && body.fieldErrors) {
262
+ const first = Object.values(body.fieldErrors)[0]
263
+ setStatus({ tone: 'negative', message: first || 'Revert rejected by validation' })
264
+ } else {
265
+ setStatus({
266
+ tone: 'positive',
267
+ message: `Reverted ${row.path} at ${row.scope}:${row.scope_id}`
268
+ })
269
+ // The new audit entry lands at the top; jump to page 0 so the user
270
+ // sees it without having to navigate back.
271
+ await fetchPage(0)
272
+ }
273
+ } catch (e) {
274
+ setStatus({ tone: 'negative', message: e.message || 'Revert failed' })
275
+ } finally {
276
+ setReverting(false)
277
+ setConfirmRow(null)
278
+ }
279
+ }, [runtime, ims, fetchPage])
280
+
281
+ const startRevert = (row) => {
282
+ // Pre-flight to surface "can't revert sensitive" before opening the dialog.
283
+ const built = buildRevertPayload(row)
284
+ if (!built.ok) {
285
+ setStatus({ tone: 'negative', message: built.message })
286
+ return
287
+ }
288
+ setStatus({ tone: 'neutral', message: '' })
289
+ setConfirmRow(row)
290
+ }
291
+
292
+ return (
293
+ <View padding="size-400" UNSAFE_style={{ background: PALETTE.bg, minHeight: '100vh' }}>
294
+ <Heading level={2} marginTop={0}>Audit Log</Heading>
295
+ <Text UNSAFE_style={{ color: PALETTE.textMuted }}>
296
+ Every save to system_config_data is recorded here with old → new
297
+ values. Sensitive fields show <code>[ENCRYPTED]</code> and cannot be
298
+ reverted from here. Newest entries first.
299
+ </Text>
300
+
301
+ {status.message && (
302
+ <View marginTop="size-150">
303
+ <StatusLight variant={status.tone}>{status.message}</StatusLight>
304
+ </View>
305
+ )}
306
+
307
+ <View
308
+ marginTop="size-200"
309
+ padding="size-200"
310
+ UNSAFE_style={{
311
+ background: PALETTE.surface,
312
+ border: `1px solid ${PALETTE.border}`,
313
+ borderRadius: RADIUS.lg,
314
+ boxShadow: SHADOW.xs
315
+ }}
316
+ >
317
+ <Flex gap="size-150" wrap alignItems="end">
318
+ <Picker label="Scope" selectedKey={scope} onSelectionChange={setScope} width="size-1700">
319
+ {SCOPE_OPTIONS.map((s) => <Item key={s.id}>{s.label}</Item>)}
320
+ </Picker>
321
+ <Picker label="Action" selectedKey={actionFilter} onSelectionChange={setActionFilter} width="size-1700">
322
+ {ACTION_OPTIONS.map((s) => <Item key={s.id}>{s.label}</Item>)}
323
+ </Picker>
324
+ <SearchField
325
+ label="Path contains"
326
+ value={pathFilter}
327
+ onChange={setPathFilter}
328
+ onSubmit={onSearch}
329
+ width="size-2400"
330
+ placeholder="section/group/field"
331
+ />
332
+ <TextField
333
+ label="Actor"
334
+ value={actor}
335
+ onChange={setActor}
336
+ width="size-2400"
337
+ placeholder="email or org id"
338
+ />
339
+ <TextField
340
+ label="Since (ISO)"
341
+ value={since}
342
+ onChange={setSince}
343
+ width="size-2400"
344
+ placeholder="2026-01-01T00:00:00Z"
345
+ />
346
+ <TextField
347
+ label="Until (ISO)"
348
+ value={until}
349
+ onChange={setUntil}
350
+ width="size-2400"
351
+ placeholder="2026-12-31T23:59:59Z"
352
+ />
353
+ <Button variant="cta" onPress={onSearch} isDisabled={loading}>
354
+ {loading ? 'Loading…' : 'Search'}
355
+ </Button>
356
+ </Flex>
357
+ </View>
358
+
359
+ {error && (
360
+ <Well marginTop="size-200" UNSAFE_style={{ borderColor: PALETTE.danger }}>
361
+ <Text UNSAFE_style={{ color: PALETTE.danger }}>{error}</Text>
362
+ </Well>
363
+ )}
364
+
365
+ <View
366
+ marginTop="size-200"
367
+ UNSAFE_style={{
368
+ background: PALETTE.surface,
369
+ border: `1px solid ${PALETTE.border}`,
370
+ borderRadius: RADIUS.lg,
371
+ boxShadow: SHADOW.xs,
372
+ overflow: 'hidden'
373
+ }}
374
+ >
375
+ <Pagination />
376
+
377
+ <div style={{
378
+ display: 'grid',
379
+ gridTemplateColumns: 'minmax(140px, 180px) minmax(120px, 200px) 110px minmax(180px, 1.2fr) minmax(180px, 1.5fr) minmax(180px, 1.5fr) 100px',
380
+ padding: '12px 16px',
381
+ gap: 12,
382
+ background: PALETTE.surfaceMuted,
383
+ fontSize: 11,
384
+ fontWeight: 700,
385
+ letterSpacing: 0.6,
386
+ textTransform: 'uppercase',
387
+ color: PALETTE.textMuted,
388
+ borderBottom: `1px solid ${PALETTE.border}`
389
+ }}>
390
+ <div>Time</div>
391
+ <div>Actor</div>
392
+ {/* StatusLight's coloured dot takes ~22px before the text, so
393
+ indent the column header to line up with the row content. */}
394
+ <div style={{ paddingLeft: 22 }}>Action</div>
395
+ <div>Path</div>
396
+ <div>Old</div>
397
+ <div>New</div>
398
+ <div>Revert</div>
399
+ </div>
400
+
401
+ {loading && items.length === 0 ? (
402
+ <Flex justifyContent="center" margin="size-400">
403
+ <ProgressCircle aria-label="Loading" isIndeterminate />
404
+ </Flex>
405
+ ) : items.length === 0 ? (
406
+ <View padding="size-400">
407
+ <Text UNSAFE_style={{ color: PALETTE.textMuted }}>
408
+ No audit entries match these filters.
409
+ </Text>
410
+ </View>
411
+ ) : (
412
+ items.map((row, i) => {
413
+ const isEncrypted = row.oldValue === SENSITIVE_TOKEN || row.newValue === SENSITIVE_TOKEN
414
+ // Common cell style — preserve whitespace + break long unbroken
415
+ // tokens (URLs, hashes, JSON blobs) so nothing is clipped.
416
+ const cell = {
417
+ whiteSpace: 'pre-wrap',
418
+ overflowWrap: 'anywhere',
419
+ wordBreak: 'break-word',
420
+ minWidth: 0
421
+ }
422
+ return (
423
+ <div
424
+ key={row._id || `${row.changedAt}-${row.path}-${i}`}
425
+ style={{
426
+ display: 'grid',
427
+ // Min/max so columns flex but never collapse below readable.
428
+ gridTemplateColumns: 'minmax(140px, 180px) minmax(120px, 200px) 110px minmax(180px, 1.2fr) minmax(180px, 1.5fr) minmax(180px, 1.5fr) 100px',
429
+ padding: '12px 16px',
430
+ gap: 12,
431
+ borderBottom: `1px solid ${PALETTE.border}`,
432
+ fontSize: 13,
433
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
434
+ background: i % 2 === 0 ? PALETTE.surface : PALETTE.surfaceSubtle,
435
+ alignItems: 'start'
436
+ }}
437
+ >
438
+ <div style={{ ...cell, color: PALETTE.text }}>{fmtTime(row.changedAt)}</div>
439
+ <div style={{ ...cell, color: PALETTE.textMuted }}>
440
+ {row.changedBy || 'system'}
441
+ </div>
442
+ <div>
443
+ <StatusLight variant={actionTone(row.action)}>
444
+ {row.action || '?'}
445
+ </StatusLight>
446
+ </div>
447
+ <div style={cell}>
448
+ <div>{row.path}</div>
449
+ <div style={{ color: PALETTE.textMuted, fontSize: 11 }}>{row.scope}:{row.scope_id}</div>
450
+ </div>
451
+ <div style={{ ...cell, color: PALETTE.danger }}>
452
+ {fmtValue(row.oldValue)}
453
+ </div>
454
+ <div style={{ ...cell, color: PALETTE.success }}>
455
+ {fmtValue(row.newValue)}
456
+ </div>
457
+ <div>
458
+ <Button
459
+ variant="secondary"
460
+ onPress={() => startRevert(row)}
461
+ isDisabled={reverting || isEncrypted}
462
+ UNSAFE_style={{ fontFamily: 'inherit' }}
463
+ >
464
+ {isEncrypted ? 'N/A' : 'Revert'}
465
+ </Button>
466
+ </div>
467
+ </div>
468
+ )
469
+ })
470
+ )}
471
+
472
+ </View>
473
+
474
+ {/* Revert confirmation dialog. */}
475
+ <DialogTrigger isOpen={!!confirmRow} onOpenChange={(o) => { if (!o) setConfirmRow(null) }}>
476
+ <div style={{ display: 'none' }} aria-hidden="true">trigger</div>
477
+ <Dialog>
478
+ <Heading>Revert this change?</Heading>
479
+ <Header>
480
+ <Text>{confirmRow?.path} at {confirmRow?.scope}:{confirmRow?.scope_id}</Text>
481
+ </Header>
482
+ <Divider />
483
+ <Content>
484
+ <Text>
485
+ {confirmRow?.action === 'create'
486
+ ? <>This will <strong>delete</strong> the scope-level override and fall back to the inherited default.</>
487
+ : confirmRow?.action === 'delete'
488
+ ? <>This will <strong>re-insert</strong> the previous value.</>
489
+ : <>This will replace the current value with the previous value.</>}
490
+ </Text>
491
+ <div style={{
492
+ marginTop: 12,
493
+ padding: 12,
494
+ background: PALETTE.surfaceMuted,
495
+ border: `1px solid ${PALETTE.border}`,
496
+ borderRadius: RADIUS.md,
497
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
498
+ fontSize: 12,
499
+ wordBreak: 'break-all'
500
+ }}>
501
+ <div style={{ color: PALETTE.textMuted, fontSize: 11, fontWeight: 700, letterSpacing: 0.4, textTransform: 'uppercase', marginBottom: 4 }}>
502
+ will be set to
503
+ </div>
504
+ <div style={{ color: PALETTE.success }}>
505
+ {confirmRow?.action === 'create'
506
+ ? '(inherit from default)'
507
+ : fmtValue(confirmRow?.oldValue)}
508
+ </div>
509
+ </div>
510
+ </Content>
511
+ <ButtonGroup>
512
+ <Button variant="secondary" onPress={() => setConfirmRow(null)} isDisabled={reverting}>
513
+ Cancel
514
+ </Button>
515
+ <Button variant="cta" onPress={() => doRevert(confirmRow)} isDisabled={reverting}>
516
+ {reverting ? 'Reverting…' : 'Revert'}
517
+ </Button>
518
+ </ButtonGroup>
519
+ </Dialog>
520
+ </DialogTrigger>
521
+ </View>
522
+ )
523
+ }