@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.
- package/actions/ext.config.yaml +21 -0
- package/actions/system-config-audit-list/index.js +78 -0
- package/package.json +23 -0
- package/scripts/setup.js +168 -0
- package/src/hook.js +56 -0
- package/src/index.js +6 -0
- package/web/index.js +35 -0
- package/web/src/AuditLog.js +523 -0
|
@@ -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
|
+
}
|
package/scripts/setup.js
ADDED
|
@@ -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
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
|
+
}
|