diff --git a/ghost/admin/app/components/settings/audit-log/event-filter.hbs b/ghost/admin/app/components/settings/audit-log/event-filter.hbs new file mode 100644 index 0000000000..5b4eb98592 --- /dev/null +++ b/ghost/admin/app/components/settings/audit-log/event-filter.hbs @@ -0,0 +1,29 @@ + + + + {{svg-jar "filter"}} + Filter events + + + + + {{!-- NOTE: re-using ember-power-select-options styles --}} + + + \ No newline at end of file diff --git a/ghost/admin/app/components/settings/audit-log/event-filter.js b/ghost/admin/app/components/settings/audit-log/event-filter.js new file mode 100644 index 0000000000..e45e5a8368 --- /dev/null +++ b/ghost/admin/app/components/settings/audit-log/event-filter.js @@ -0,0 +1,56 @@ +import Component from '@glimmer/component'; +import {action} from '@ember/object'; +import {inject as service} from '@ember/service'; + +const ALL_EVENT_TYPES = [ + {event: 'created', icon: 'event-filter-signup', name: 'Created'}, + {event: 'edited', icon: 'event-filter-login', name: 'Edited'}, + {event: 'deleted', icon: 'event-filter-subscription', name: 'Deleted'}, + {event: 'post', icon: 'event-filter-payment', name: 'Posts'}, + {event: 'pages', icon: 'event-filter-payment', name: 'Pages'}, + {event: 'tag', icon: 'event-filter-newsletter', name: 'Tags'}, + {event: 'integration', icon: 'event-filter-newsletter', name: 'Integrations'}, + {event: 'api_key', icon: 'event-filter-newsletter', name: 'API keys'}, + {event: 'label', icon: 'event-filter-newsletter', name: 'Labels'} +]; + +export default class AuditLogEventFilter extends Component { + @service settings; + @service feature; + + get availableEventTypes() { + const extended = [...ALL_EVENT_TYPES]; + + if (this.args.hiddenEvents?.length) { + return extended.filter(t => !this.args.hiddenEvents.includes(t.event)); + } else { + return extended; + } + } + + get eventTypes() { + const excludedEvents = (this.args.excludedEvents || '').split(','); + + return this.availableEventTypes.map(type => ({ + event: type.event, + icon: type.icon, + name: type.name, + isSelected: !excludedEvents.includes(type.event) + })); + } + + @action + toggleEventType(eventType) { + const excludedEvents = new Set(this.eventTypes.reject(type => type.isSelected).map(type => type.event)); + + if (excludedEvents.has(eventType)) { + excludedEvents.delete(eventType); + } else { + excludedEvents.add(eventType); + } + + const excludeString = Array.from(excludedEvents).join(','); + + this.args.onChange(excludeString || null); + } +} diff --git a/ghost/admin/app/components/settings/audit-log/no-events.hbs b/ghost/admin/app/components/settings/audit-log/no-events.hbs new file mode 100644 index 0000000000..8ef96b7b17 --- /dev/null +++ b/ghost/admin/app/components/settings/audit-log/no-events.hbs @@ -0,0 +1,13 @@ +
+
+ {{svg-jar "activity-placeholder" class="gh-members-placeholder"}} + {{#if @filter}} +

No actions match the current filter

+ + Show all actions + + {{else}} +

No actions yet

+ {{/if}} +
+
\ No newline at end of file diff --git a/ghost/admin/app/components/settings/audit-log/search-trigger.hbs b/ghost/admin/app/components/settings/audit-log/search-trigger.hbs new file mode 100644 index 0000000000..0c7005fb90 --- /dev/null +++ b/ghost/admin/app/components/settings/audit-log/search-trigger.hbs @@ -0,0 +1 @@ +
Filter member {{svg-jar "arrow-down-small"}}
diff --git a/ghost/admin/app/components/settings/audit-log/search.hbs b/ghost/admin/app/components/settings/audit-log/search.hbs new file mode 100644 index 0000000000..984887f6d6 --- /dev/null +++ b/ghost/admin/app/components/settings/audit-log/search.hbs @@ -0,0 +1,23 @@ +{{#if @selected}} + +{{else}} + + {{#if member.name}}{{member.name}}{{/if}} + {{member.email}} + +{{/if}} diff --git a/ghost/admin/app/components/settings/audit-log/search.js b/ghost/admin/app/components/settings/audit-log/search.js new file mode 100644 index 0000000000..97acb8b445 --- /dev/null +++ b/ghost/admin/app/components/settings/audit-log/search.js @@ -0,0 +1,20 @@ +import Component from '@glimmer/component'; +import {action} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {task, timeout} from 'ember-concurrency'; + +export default class AuditLogSearch extends Component { + @service store; + + @action + clear() { + this.args.onChange(null); + } + + @task + *searchUsersTask(term) { + yield timeout(300); // debounce + + return yield this.store.query('user', {search: term, limit: 20}); + } +} diff --git a/ghost/admin/app/components/settings/audit-log/table.hbs b/ghost/admin/app/components/settings/audit-log/table.hbs new file mode 100644 index 0000000000..e408ed0891 --- /dev/null +++ b/ghost/admin/app/components/settings/audit-log/table.hbs @@ -0,0 +1,41 @@ + + + + + + + + + + {{#each @events as |event|}} + {{#let (parse-audit-log-event event) as |ev|}} + +
+
+ + + +

{{ev.actor.name}}

+
+
+
+
+
{{svg-jar ev.actionIcon}}
+
+ + {{capitalize-first-letter ev.action}} + {{#if (or ev.resource.title ev.resource.name)}} + + {{if ev.resource.title ev.resource.title ev.resource.name}} + + {{/if}} + +
+
+
+
{{moment-format ev.original.created_at "DD MMM YYYY HH:mm:ss"}}
+ + {{/let}} + {{/each}} + +
UserActionWhen
diff --git a/ghost/admin/app/controllers/settings/audit-log.js b/ghost/admin/app/controllers/settings/audit-log.js index 8a3f944f26..400e38b193 100644 --- a/ghost/admin/app/controllers/settings/audit-log.js +++ b/ghost/admin/app/controllers/settings/audit-log.js @@ -1,4 +1,39 @@ import Controller from '@ember/controller'; +import {action} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {tracked} from '@glimmer/tracking'; export default class AuditLogController extends Controller { + @service router; + @service settings; + @service store; + + queryParams = ['excludedResources', 'user']; + + @tracked excludedResources = null; + @tracked user = null; + + get fullExcludedResources() { + return (this.excludedResources || '').split(','); + } + + get userRecord() { + if (!this.user) { + return null; + } + + // TODO: {reload: true} here shouldn't be needed but without it + // the template renders nothing if the record is already in the store + return this.store.findRecord('user', this.user, {reload: true}); + } + + @action + changeExcludedResources(newList) { + this.router.transitionTo({queryParams: {excludedResources: newList}}); + } + + @action + changeUser(user) { + this.router.transitionTo({queryParams: {user: user?.id}}); + } } diff --git a/ghost/admin/app/helpers/audit-log-event-fetcher.js b/ghost/admin/app/helpers/audit-log-event-fetcher.js index e6b73dfcca..8a0af9f53e 100644 --- a/ghost/admin/app/helpers/audit-log-event-fetcher.js +++ b/ghost/admin/app/helpers/audit-log-event-fetcher.js @@ -34,6 +34,10 @@ export default class AuditLogEventFetcher extends Resource { this.cursor = moment.utc().format('YYYY-MM-DD HH:mm:ss'); let filter = `created_at:<'${this.cursor}'`; + if (this.args.named.filter) { + filter += `+${this.args.named.filter}`; + } + // Can't get this working with Promise.all, somehow results in an infinite loop await this.loadEventsTask.perform({filter}); } @@ -58,6 +62,10 @@ export default class AuditLogEventFetcher extends Resource { this.cursor = cursor; let filter = `created_at:<'${this.cursor}'`; + if (this.args.named.filter) { + filter += `+${this.args.named.filter}`; + } + this.loadEventsTask.perform({filter}); } diff --git a/ghost/admin/app/helpers/audit-log-event-filter.js b/ghost/admin/app/helpers/audit-log-event-filter.js new file mode 100644 index 0000000000..e37a895776 --- /dev/null +++ b/ghost/admin/app/helpers/audit-log-event-filter.js @@ -0,0 +1,34 @@ +import Helper from '@ember/component/helper'; +import classic from 'ember-classic-decorator'; +import {isBlank} from '@ember/utils'; +import {inject as service} from '@ember/service'; + +@classic +export default class AuditLogEventFilter extends Helper { + @service settings; + @service feature; + + compute( + positionalParams, + {excludedEvents = [], user = ''} + ) { + const excludedEventsSet = new Set(); + + if (excludedEvents.length) { + excludedEvents.forEach(type => excludedEventsSet.add(type)); + } + + let filterParts = []; + + const excludedEventsArray = Array.from(excludedEventsSet).reject(isBlank); + if (excludedEventsArray.length > 0) { + filterParts.push(`resource_type:-[${excludedEventsArray.join(',')}]`); + } + + if (user) { + filterParts.push(`actor_id:${user}`); + } + + return filterParts.join('+'); + } +} diff --git a/ghost/admin/app/templates/settings/audit-log.hbs b/ghost/admin/app/templates/settings/audit-log.hbs index 9331d45a2c..c112b73cf3 100644 --- a/ghost/admin/app/templates/settings/audit-log.hbs +++ b/ghost/admin/app/templates/settings/audit-log.hbs @@ -4,74 +4,41 @@ Settings {{svg-jar "arrow-right"}} Audit log + {{#if this.userRecord}} + {{svg-jar "arrow-right"}} + {{or this.userRecord.name this.userRecord.email}} + {{/if}} +
+ + + +
- {{#let (audit-log-event-fetcher pageSize=50) as |eventsFetcher|}} - {{#if eventsFetcher.data}} -
- - - - - - - - - - {{#each eventsFetcher.data as |event|}} - {{#let (parse-audit-log-event event) as |ev|}} - -
-
- - - -

{{ev.actor.name}}

-
-
-
-
-
{{svg-jar ev.actionIcon}}
-
- - {{capitalize-first-letter ev.action}} - {{#if (or ev.resource.title ev.resource.name)}} - - {{if ev.resource.title ev.resource.title ev.resource.name}} - - {{else}} -
{{ev.original.resource_id}} - {{/if}} -
-
-
-
-
{{moment-format ev.original.created_at "DD MMM YYYY HH:mm:ss"}}
- - {{/let}} - {{/each}} - -
UserActionWhen
+ {{#let (audit-log-event-fetcher filter=(audit-log-event-filter excludedEvents=this.fullExcludedResources user=this.user) pageSize=50) as |eventsFetcher|}} + {{#if eventsFetcher.data}} +
+ - {{#if (not (or eventsFetcher.isLoading eventsFetcher.hasReachedEnd))}} - - {{/if}} -
- {{else}} - {{#unless eventsFetcher.isLoading}} -
-
- {{svg-jar "activity-placeholder" class="gh-members-placeholder"}} -

No staff activity yet

+ {{#if (not (or eventsFetcher.isLoading eventsFetcher.hasReachedEnd))}} + + {{/if}}
-
- {{/unless}} - {{/if}} + {{else}} + {{#unless eventsFetcher.isLoading}} + + {{/unless}} + {{/if}} - {{#if eventsFetcher.isLoading}} -
- {{/if}} + {{#if eventsFetcher.isLoading}} +
+ {{/if}} {{/let}}