@crombolo/backoffice-host 0.10.0 → 0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/backoffice-host",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Serves the Umbraco backoffice SPA: shell HTML, import map and package manifests.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -31,7 +31,7 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "@crombolo/core": "0.10.0",
34
+ "@crombolo/core": "0.11.0",
35
35
  "@crombolo/backoffice-dist": "18.2.0"
36
36
  }
37
37
  }
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../../backoffice-dist/dist/umbraco-package-schema.json",
3
3
  "name": "Crombolo Assistant",
4
4
  "id": "Crombolo.Assistant",
5
- "version": "0.10.0",
5
+ "version": "0.11.0",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -0,0 +1,369 @@
1
+ /**
2
+ * The Audit log dashboard in Settings: who did what, when and from where,
3
+ * newest first, filtered by person, kind of change, words and dates.
4
+ * docs/audit-log.md.
5
+ */
6
+ import { css, html, LitElement, nothing } from '@umbraco-cms/backoffice/external/lit'
7
+
8
+ const PAGE_SIZE = 50
9
+
10
+ const CATEGORIES = [
11
+ ['', 'Everything'],
12
+ ['content', 'Content'],
13
+ ['media', 'Media'],
14
+ ['member', 'Members'],
15
+ ['schema', 'Types and templates'],
16
+ ['settings', 'Settings'],
17
+ ['user', 'Users'],
18
+ ['permission', 'Permissions'],
19
+ ['sign-in', 'Sign-ins'],
20
+ ['system', 'System'],
21
+ ]
22
+
23
+ const ACTIONS = {
24
+ Create: 'Created',
25
+ CreateAndPublish: 'Created and published',
26
+ Save: 'Saved',
27
+ SaveAndPublish: 'Saved and published',
28
+ Publish: 'Published',
29
+ PublishWithDescendants: 'Published with everything below it',
30
+ Unpublish: 'Unpublished',
31
+ Delete: 'Deleted',
32
+ Move: 'Moved',
33
+ Trash: 'Moved to the recycle bin',
34
+ Restore: 'Restored from the recycle bin',
35
+ EmptyRecycleBin: 'Emptied the recycle bin',
36
+ Copy: 'Copied',
37
+ Sort: 'Sorted',
38
+ Rename: 'Renamed',
39
+ Rollback: 'Rolled back',
40
+ Revert: 'Reverted',
41
+ PreventCleanup: 'Kept a version',
42
+ AssignDomains: 'Changed domains',
43
+ SetPublicAccess: 'Restricted access',
44
+ AddPublicAccess: 'Restricted access',
45
+ RemovePublicAccess: 'Removed access restrictions',
46
+ SetNotifications: 'Changed notifications',
47
+ Import: 'Imported',
48
+ CreateTemplate: 'Created a template',
49
+ ChangePassword: 'Changed password',
50
+ ResetPassword: 'Reset password',
51
+ ResetPasswordByLink: 'Reset password from an e-mailed link',
52
+ Invite: 'Invited',
53
+ ResendInvite: 'Resent the invitation',
54
+ AcceptInvite: 'Accepted the invitation',
55
+ Disable: 'Disabled',
56
+ Enable: 'Enabled',
57
+ Unlock: 'Unlocked',
58
+ SetUserGroups: 'Changed groups',
59
+ AddUsers: 'Added people',
60
+ RemoveUsers: 'Removed people',
61
+ SaveProfile: 'Updated their profile',
62
+ SetAvatar: 'Changed the picture',
63
+ RemoveAvatar: 'Removed the picture',
64
+ EnableTwoFactor: 'Turned on two-factor sign-in',
65
+ DisableTwoFactor: 'Turned off two-factor sign-in',
66
+ AddClientCredentials: 'Added an API key',
67
+ RemoveClientCredentials: 'Removed an API key',
68
+ SignIn: 'Signed in',
69
+ SignInFailed: 'Failed to sign in',
70
+ LockedOut: 'Locked out',
71
+ SignOut: 'Signed out',
72
+ Rebuild: 'Rebuilt',
73
+ Reload: 'Reloaded',
74
+ Build: 'Built',
75
+ Install: 'Installed',
76
+ Uninstall: 'Uninstalled',
77
+ Commit: 'Sent to source control',
78
+ Sync: 'Applied the schema files',
79
+ Purge: 'Purged',
80
+ Upgrade: 'Upgraded the site',
81
+ UpgradeFix: 'Prepared an upgrade',
82
+ PauseEditing: 'Paused editing',
83
+ ResumeEditing: 'Resumed editing',
84
+ RunMigration: 'Ran a package update',
85
+ }
86
+
87
+ const ITEM_TYPES = {
88
+ Document: 'Page',
89
+ DocumentBlueprint: 'Document blueprint',
90
+ DocumentType: 'Document type',
91
+ DictionaryItem: 'Dictionary item',
92
+ MediaType: 'Media type',
93
+ MemberType: 'Member type',
94
+ MemberGroup: 'Member group',
95
+ DataType: 'Data type',
96
+ PartialView: 'Partial view',
97
+ UserGroup: 'User group',
98
+ SeoSettings: 'Search and sharing settings',
99
+ }
100
+
101
+ const WORKSPACE = {
102
+ Document: (key) => `section/content/workspace/document/edit/${key}`,
103
+ Media: (key) => `section/media/workspace/media/edit/${key}`,
104
+ Member: (key) => `section/member-management/workspace/member/edit/${key}`,
105
+ User: (key) => `section/user-management/workspace/user/edit/${key}`,
106
+ }
107
+
108
+ const words = (name) =>
109
+ name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
110
+ const actionLabel = (action) => ACTIONS[action] ?? words(action)
111
+ const itemType = (type) => ITEM_TYPES[type] ?? words(type.replace(/Folder$/, ' folder'))
112
+
113
+ function apiBase() {
114
+ const base = document.querySelector('base')?.getAttribute('href') ?? '/umbraco/'
115
+ return `${base.replace(/\/$/, '')}/crombolo/api/audit`
116
+ }
117
+
118
+ async function get(path, params) {
119
+ const query = params ? `?${new URLSearchParams(params)}` : ''
120
+ const response = await fetch(`${apiBase()}${path}${query}`, {
121
+ credentials: 'include',
122
+ headers: { accept: 'application/json' },
123
+ })
124
+ if (!response.ok) throw new Error(`The audit log could not be read (${response.status}).`)
125
+ return response.json()
126
+ }
127
+
128
+ function fromWhere(entry) {
129
+ if (entry.source === 'cli') return 'Command line'
130
+ if (entry.source === 'job') return 'Scheduled'
131
+ const forwarded = entry.forwardedFor?.split(',')[0]?.trim()
132
+ return forwarded || entry.ipAddress || '—'
133
+ }
134
+
135
+ export default class CromboloAuditDashboardElement extends LitElement {
136
+ static properties = {
137
+ _page: { state: true },
138
+ _users: { state: true },
139
+ _error: { state: true },
140
+ _filter: { state: true },
141
+ _skip: { state: true },
142
+ }
143
+
144
+ constructor() {
145
+ super()
146
+ this._page = undefined
147
+ this._users = []
148
+ this._error = undefined
149
+ this._filter = { user: '', category: '', text: '', since: '', until: '' }
150
+ this._skip = 0
151
+ }
152
+
153
+ connectedCallback() {
154
+ super.connectedCallback()
155
+ get('/users')
156
+ .then((users) => {
157
+ this._users = users.items
158
+ })
159
+ .catch(() => {})
160
+ this.#load()
161
+ }
162
+
163
+ async #load() {
164
+ const params = { skip: String(this._skip), take: String(PAGE_SIZE) }
165
+ const { user, category, text, since, until } = this._filter
166
+ if (user) params.user = user
167
+ if (category) params.category = category
168
+ if (text.trim()) params.text = text.trim()
169
+ // Whole local days: "To" includes the day it names.
170
+ if (since) params.since = new Date(`${since}T00:00:00`).toISOString()
171
+ if (until)
172
+ params.until = new Date(new Date(`${until}T00:00:00`).getTime() + 86_400_000).toISOString()
173
+ try {
174
+ this._page = await get('', params)
175
+ this._error = undefined
176
+ } catch (error) {
177
+ this._error = error.message
178
+ }
179
+ }
180
+
181
+ #set(field, value) {
182
+ this._filter = { ...this._filter, [field]: value }
183
+ this._skip = 0
184
+ this.#load()
185
+ }
186
+
187
+ #go(skip) {
188
+ this._skip = Math.max(0, skip)
189
+ this.#load()
190
+ }
191
+
192
+ render() {
193
+ return html`
194
+ <uui-box headline="Audit log">
195
+ <p class="intro">
196
+ Every change made in the backoffice, every sign-in, and what the command line and
197
+ scheduled publishing changed.
198
+ ${
199
+ this._page?.retentionDays
200
+ ? html`Entries are kept for ${this._page.retentionDays} days.`
201
+ : this._page
202
+ ? html`Entries are kept until they are removed.`
203
+ : nothing
204
+ }
205
+ </p>
206
+ ${this.#renderFilters()}
207
+ ${this.#renderBody()}
208
+ </uui-box>
209
+ `
210
+ }
211
+
212
+ #renderFilters() {
213
+ const f = this._filter
214
+ return html`
215
+ <div class="filters">
216
+ <label>
217
+ Who
218
+ <select .value=${f.user} @change=${(e) => this.#set('user', e.target.value)}>
219
+ <option value="">Anyone</option>
220
+ ${this._users.map((u) => html`<option value=${u.key}>${u.name || 'Unnamed'}</option>`)}
221
+ </select>
222
+ </label>
223
+ <label>
224
+ What
225
+ <select .value=${f.category} @change=${(e) => this.#set('category', e.target.value)}>
226
+ ${CATEGORIES.map(([value, label]) => html`<option value=${value}>${label}</option>`)}
227
+ </select>
228
+ </label>
229
+ <label>
230
+ Containing
231
+ <uui-input
232
+ label="Containing"
233
+ placeholder="A name"
234
+ .value=${f.text}
235
+ @change=${(e) => this.#set('text', e.target.value)}
236
+ ></uui-input>
237
+ </label>
238
+ <label>
239
+ From
240
+ <input type="date" .value=${f.since} @change=${(e) => this.#set('since', e.target.value)} />
241
+ </label>
242
+ <label>
243
+ To
244
+ <input type="date" .value=${f.until} @change=${(e) => this.#set('until', e.target.value)} />
245
+ </label>
246
+ <uui-button look="secondary" compact label="Refresh" @click=${() => this.#load()}>Refresh</uui-button>
247
+ </div>
248
+ `
249
+ }
250
+
251
+ #renderBody() {
252
+ if (this._error) return html`<p class="error">${this._error}</p>`
253
+ if (!this._page) return html`<uui-loader></uui-loader>`
254
+ const { items, total } = this._page
255
+ if (items.length === 0) return html`<p class="empty">Nothing matches.</p>`
256
+ const last = Math.min(this._skip + items.length, total)
257
+ return html`
258
+ <uui-table>
259
+ <uui-table-head>
260
+ <uui-table-head-cell>When</uui-table-head-cell>
261
+ <uui-table-head-cell>Who</uui-table-head-cell>
262
+ <uui-table-head-cell>What</uui-table-head-cell>
263
+ <uui-table-head-cell>Item</uui-table-head-cell>
264
+ <uui-table-head-cell>From</uui-table-head-cell>
265
+ </uui-table-head>
266
+ ${items.map((entry) => this.#renderRow(entry))}
267
+ </uui-table>
268
+ <div class="pager">
269
+ <span>${this._skip + 1}–${last} of ${total}</span>
270
+ <uui-button
271
+ compact
272
+ look="secondary"
273
+ label="Newer"
274
+ ?disabled=${this._skip === 0}
275
+ @click=${() => this.#go(this._skip - PAGE_SIZE)}
276
+ >Newer</uui-button>
277
+ <uui-button
278
+ compact
279
+ look="secondary"
280
+ label="Older"
281
+ ?disabled=${last >= total}
282
+ @click=${() => this.#go(this._skip + PAGE_SIZE)}
283
+ >Older</uui-button>
284
+ </div>
285
+ `
286
+ }
287
+
288
+ #renderRow(entry) {
289
+ const when = new Date(entry.occurredAt)
290
+ const failed = entry.action === 'SignInFailed' || entry.action === 'LockedOut'
291
+ return html`<uui-table-row>
292
+ <uui-table-cell title=${when.toISOString()}>${when.toLocaleString()}</uui-table-cell>
293
+ <uui-table-cell>${entry.user?.name ?? (entry.source === 'job' ? 'Scheduled publishing' : 'Unknown')}</uui-table-cell>
294
+ <uui-table-cell>
295
+ ${failed ? html`<uui-tag color="danger">${actionLabel(entry.action)}</uui-tag>` : actionLabel(entry.action)}
296
+ ${entry.detail && entry.source !== 'cli' ? html`<small>${entry.detail}</small>` : nothing}
297
+ </uui-table-cell>
298
+ <uui-table-cell>${this.#renderItem(entry)}</uui-table-cell>
299
+ <uui-table-cell title=${entry.forwardedFor ? `Connected from ${entry.ipAddress ?? 'unknown'}` : ''}>
300
+ ${fromWhere(entry)}
301
+ </uui-table-cell>
302
+ </uui-table-row>`
303
+ }
304
+
305
+ #renderItem(entry) {
306
+ const item = entry.item
307
+ if (!item) return nothing
308
+ const label = item.name ?? item.key ?? ''
309
+ const link = item.key && WORKSPACE[item.type]?.(item.key)
310
+ const base = document.querySelector('base')?.getAttribute('href') ?? '/umbraco/'
311
+ return html`
312
+ <small>${itemType(item.type)}</small>
313
+ ${link ? html`<a href=${`${base.replace(/\/$/, '')}/${link}`}>${label}</a>` : label}
314
+ `
315
+ }
316
+
317
+ static styles = css`
318
+ :host {
319
+ display: block;
320
+ padding: var(--uui-size-layout-1);
321
+ }
322
+ .intro {
323
+ margin-top: 0;
324
+ color: var(--uui-color-text-alt);
325
+ }
326
+ .filters {
327
+ display: flex;
328
+ flex-wrap: wrap;
329
+ gap: var(--uui-size-space-4);
330
+ align-items: flex-end;
331
+ margin-bottom: var(--uui-size-space-5);
332
+ }
333
+ .filters label {
334
+ display: flex;
335
+ flex-direction: column;
336
+ gap: var(--uui-size-space-1);
337
+ font-weight: 600;
338
+ }
339
+ select,
340
+ input[type='date'] {
341
+ font: inherit;
342
+ padding: var(--uui-size-space-2);
343
+ border: 1px solid var(--uui-color-border);
344
+ border-radius: var(--uui-border-radius);
345
+ background: var(--uui-color-surface);
346
+ color: var(--uui-color-text);
347
+ }
348
+ uui-table-cell small {
349
+ display: block;
350
+ color: var(--uui-color-text-alt);
351
+ }
352
+ .pager {
353
+ display: flex;
354
+ gap: var(--uui-size-space-3);
355
+ align-items: center;
356
+ justify-content: flex-end;
357
+ margin-top: var(--uui-size-space-4);
358
+ }
359
+ .empty,
360
+ .error {
361
+ color: var(--uui-color-text-alt);
362
+ }
363
+ .error {
364
+ color: var(--uui-color-danger);
365
+ }
366
+ `
367
+ }
368
+
369
+ customElements.define('crombolo-audit-dashboard', CromboloAuditDashboardElement)
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../../backoffice-dist/dist/umbraco-package-schema.json",
3
3
  "name": "Crombolo Localizations",
4
4
  "id": "Crombolo.Localizations",
5
- "version": "0.10.0",
5
+ "version": "0.11.0",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../../backoffice-dist/dist/umbraco-package-schema.json",
3
3
  "name": "Crombolo Search & sharing",
4
4
  "id": "Crombolo.Seo",
5
- "version": "0.10.0",
5
+ "version": "0.11.0",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../backoffice-dist/dist/umbraco-package-schema.json",
3
3
  "name": "Crombolo",
4
4
  "id": "Crombolo.Core",
5
- "version": "0.10.0",
5
+ "version": "0.11.0",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -79,6 +79,23 @@
79
79
  }
80
80
  ]
81
81
  },
82
+ {
83
+ "type": "dashboard",
84
+ "alias": "Crombolo.Dashboard.AuditLog",
85
+ "name": "Crombolo Audit Log Dashboard",
86
+ "element": "/crombolo-plugin/audit-dashboard.js",
87
+ "weight": 4,
88
+ "meta": {
89
+ "label": "Audit log",
90
+ "pathname": "audit-log"
91
+ },
92
+ "conditions": [
93
+ {
94
+ "alias": "Umb.Condition.SectionAlias",
95
+ "match": "Umb.Section.Settings"
96
+ }
97
+ ]
98
+ },
82
99
  {
83
100
  "type": "dashboard",
84
101
  "alias": "Crombolo.Dashboard.Welcome",