diff --git a/ghost/admin/app/controllers/settings/audit-log.js b/ghost/admin/app/controllers/settings/audit-log.js new file mode 100644 index 0000000000..8a3f944f26 --- /dev/null +++ b/ghost/admin/app/controllers/settings/audit-log.js @@ -0,0 +1,4 @@ +import Controller from '@ember/controller'; + +export default class AuditLogController extends Controller { +} diff --git a/ghost/admin/app/helpers/audit-log-event-fetcher.js b/ghost/admin/app/helpers/audit-log-event-fetcher.js new file mode 100644 index 0000000000..e6b73dfcca --- /dev/null +++ b/ghost/admin/app/helpers/audit-log-event-fetcher.js @@ -0,0 +1,92 @@ +import moment from 'moment'; +import {Resource} from 'ember-could-get-used-to-this'; +import {TrackedArray} from 'tracked-built-ins'; +import {action} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; +import {tracked} from '@glimmer/tracking'; + +export default class AuditLogEventFetcher extends Resource { + @service ajax; + @service ghostPaths; + @service store; + + @tracked data = new TrackedArray([]); + @tracked isLoading = false; + @tracked isError = false; + @tracked errorMessage = null; + @tracked hasReachedEnd = false; + + cursor = null; + + get value() { + return { + isLoading: this.isLoading, + isError: this.isError, + errorMessage: this.errorMessage, + data: this.data, + loadNextPage: this.loadNextPage, + hasReachedEnd: this.hasReachedEnd + }; + } + + async setup() { + this.cursor = moment.utc().format('YYYY-MM-DD HH:mm:ss'); + let filter = `created_at:<'${this.cursor}'`; + + // Can't get this working with Promise.all, somehow results in an infinite loop + await this.loadEventsTask.perform({filter}); + } + + @action + loadNextPage() { + // NOTE: assumes data is always ordered by created_at desc + const lastEvent = this.data[this.data.length - 1]; + + if (!lastEvent?.data?.created_at) { + this.hasReachedEnd = true; + return; + } + + const cursor = moment.utc(lastEvent.data.created_at).format('YYYY-MM-DD HH:mm:ss'); + + if (cursor === this.cursor) { + this.hasReachedEnd = true; + return; + } + + this.cursor = cursor; + let filter = `created_at:<'${this.cursor}'`; + + this.loadEventsTask.perform({filter}); + } + + @task + *loadEventsTask(queryParams) { + try { + this.isLoading = true; + + const url = this.ghostPaths.url.api('actions'); + const data = Object.assign({}, queryParams, {limit: this.args.named.pageSize}); + const {actions} = yield this.ajax.request(url, {data}); + + if (actions.length < data.limit) { + this.hasReachedEnd = true; + } + + this.data.push(...actions); + } catch (e) { + this.isError = true; + + const errorMessage = e.payload?.errors?.[0]?.message; + if (errorMessage) { + this.errorMessage = errorMessage; + } + + // TODO: log to Sentry + console.error(e); // eslint-disable-line + } finally { + this.isLoading = false; + } + } +} diff --git a/ghost/admin/app/helpers/parse-audit-log-event.js b/ghost/admin/app/helpers/parse-audit-log-event.js new file mode 100644 index 0000000000..3fd946a821 --- /dev/null +++ b/ghost/admin/app/helpers/parse-audit-log-event.js @@ -0,0 +1,31 @@ +export default function parseAuditLogEvent(ev) { + const actorName = getActorName(ev); + const action = getAction(ev); + const actionIcon = getActionIcon(ev); + + return { + actorName, + actionIcon, + action, + timestamp: ev.created_at + }; +} + +function getActionIcon(ev) { + switch (ev.event) { + case 'added': + return 'add-stroke'; + case 'edited': + return 'content'; + case 'deleted': + return 'cross-circle'; + } +} + +function getActorName(ev) { + return ev.actor_id; +} + +function getAction(ev) { + return `${ev.event} ${ev.resource_type}`; +} diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js index 6075cf7a0a..2035065e78 100644 --- a/ghost/admin/app/router.js +++ b/ghost/admin/app/router.js @@ -49,6 +49,7 @@ Router.map(function () { this.route('settings.general', {path: '/settings/general'}); this.route('settings.membership', {path: '/settings/members'}); this.route('settings.code-injection', {path: '/settings/code-injection'}); + this.route('settings.audit-log', {path: '/settings/audit-log'}); // redirect from old /settings/members-email to /settings/newsletters this.route('settings.members-email', {path: '/settings/members-email'}); diff --git a/ghost/admin/app/routes/settings/audit-log.js b/ghost/admin/app/routes/settings/audit-log.js new file mode 100644 index 0000000000..b676d7b823 --- /dev/null +++ b/ghost/admin/app/routes/settings/audit-log.js @@ -0,0 +1,9 @@ +import AdminRoute from 'ghost-admin/routes/admin'; + +export default class AuditLogRoute extends AdminRoute { + buildRouteInfoMetadata() { + return { + titleToken: 'Settings - Audit log' + }; + } +} diff --git a/ghost/admin/app/templates/settings.hbs b/ghost/admin/app/templates/settings.hbs index d7ec205582..34b1abc178 100644 --- a/ghost/admin/app/templates/settings.hbs +++ b/ghost/admin/app/templates/settings.hbs @@ -82,6 +82,16 @@
Testing ground for new features
+ + {{#if (feature 'auditLog')}} +View staff user actions
+User | +Action | +Time | +
---|---|---|