@crombolo/backoffice-host 0.14.1 → 0.14.4

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,180 @@
1
+ /**
2
+ * The page a person approves a command-line sign-in on (story 107, RFC 8628).
3
+ * The `crombolo` command shows a code and opens this page; the person, already
4
+ * signed in here, checks the code matches and allows it — or declines.
5
+ */
6
+ import { css, html, LitElement, nothing } from '@umbraco-cms/backoffice/external/lit'
7
+ import '@umbraco-cms/backoffice/external/uui'
8
+
9
+ const API = '/umbraco/management/api/v1/security/back-office'
10
+
11
+ class CromboloDeviceElement extends LitElement {
12
+ static properties = {
13
+ userCode: { type: String, attribute: 'user-code' },
14
+ logoImage: { type: String, attribute: 'logo-image' },
15
+ _pending: { state: true },
16
+ _error: { state: true },
17
+ _done: { state: true },
18
+ _busy: { state: true },
19
+ }
20
+
21
+ constructor() {
22
+ super()
23
+ this.userCode = ''
24
+ this.logoImage = ''
25
+ this._pending = undefined
26
+ this._error = ''
27
+ this._done = ''
28
+ this._busy = false
29
+ }
30
+
31
+ connectedCallback() {
32
+ super.connectedCallback()
33
+ if (this.userCode) this.#look(this.userCode)
34
+ }
35
+
36
+ async #look(code) {
37
+ this._error = ''
38
+ this._busy = true
39
+ try {
40
+ const response = await fetch(`${API}/device/pending?user_code=${encodeURIComponent(code)}`, {
41
+ credentials: 'include',
42
+ })
43
+ if (response.ok) {
44
+ this._pending = await response.json()
45
+ return
46
+ }
47
+ const problem = await response.json().catch(() => ({}))
48
+ this._error = problem.detail ?? problem.title ?? 'That code is not waiting.'
49
+ } catch {
50
+ this._error = 'Could not reach the server.'
51
+ } finally {
52
+ this._busy = false
53
+ }
54
+ }
55
+
56
+ async #decide(approve) {
57
+ this._busy = true
58
+ try {
59
+ const response = await fetch(`${API}/device/decide`, {
60
+ method: 'POST',
61
+ credentials: 'include',
62
+ headers: { 'content-type': 'application/json' },
63
+ body: JSON.stringify({ userCode: this._pending.userCode, approve }),
64
+ })
65
+ if (!response.ok) {
66
+ this._error = 'That code is no longer waiting. Start the sign-in again.'
67
+ this._pending = undefined
68
+ return
69
+ }
70
+ this._done = approve
71
+ ? 'Signed in. You can close this window and go back to your terminal.'
72
+ : 'Declined. Nothing was signed in.'
73
+ } catch {
74
+ this._error = 'Could not reach the server.'
75
+ } finally {
76
+ this._busy = false
77
+ }
78
+ }
79
+
80
+ #submitCode(event) {
81
+ event.preventDefault()
82
+ const code = event.target.elements.code.value.trim()
83
+ if (code) this.#look(code)
84
+ }
85
+
86
+ #body() {
87
+ if (this._done) return html`<p id="notice" role="status">${this._done}</p>`
88
+ if (this._pending)
89
+ return html`
90
+ <p>
91
+ ${this._pending.clientName ?? 'A command line'} is asking to sign in as you. Check that this
92
+ code matches the one it shows:
93
+ </p>
94
+ <p id="code">${this._pending.userCode}</p>
95
+ ${this._error ? html`<p id="error" role="alert">${this._error}</p>` : nothing}
96
+ <div id="actions">
97
+ <uui-button look="primary" color="positive" label="Allow" .state=${this._busy ? 'waiting' : undefined}
98
+ @click=${() => this.#decide(true)}></uui-button>
99
+ <uui-button look="outline" label="Decline" @click=${() => this.#decide(false)}></uui-button>
100
+ </div>`
101
+ return html`
102
+ <form @submit=${this.#submitCode}>
103
+ <uui-form-layout-item>
104
+ <uui-label for="code" slot="label" required>The code your terminal shows</uui-label>
105
+ <uui-input id="code" name="code" autocomplete="off" required .value=${this.userCode ?? ''}></uui-input>
106
+ </uui-form-layout-item>
107
+ ${this._error ? html`<p id="error" role="alert">${this._error}</p>` : nothing}
108
+ <uui-button type="submit" look="primary" label="Continue" .state=${this._busy ? 'waiting' : undefined}></uui-button>
109
+ </form>`
110
+ }
111
+
112
+ render() {
113
+ return html`
114
+ <div id="layout">
115
+ <uui-box id="card">
116
+ ${this.logoImage ? html`<img id="logo" src=${this.logoImage} alt="" />` : nothing}
117
+ <h1 class="uui-h3">Sign in from your terminal</h1>
118
+ ${this.#body()}
119
+ </uui-box>
120
+ </div>
121
+ `
122
+ }
123
+
124
+ static styles = css`
125
+ :host {
126
+ display: block;
127
+ min-height: 100vh;
128
+ background: var(--uui-color-surface-alt, #f4f4f4);
129
+ }
130
+ #layout {
131
+ display: flex;
132
+ align-items: center;
133
+ justify-content: center;
134
+ min-height: 100vh;
135
+ padding: 16px;
136
+ box-sizing: border-box;
137
+ }
138
+ #card {
139
+ width: 100%;
140
+ max-width: 420px;
141
+ }
142
+ #logo {
143
+ display: block;
144
+ height: 48px;
145
+ margin-bottom: var(--uui-size-space-4, 12px);
146
+ }
147
+ h1 {
148
+ margin: 0 0 var(--uui-size-space-5, 18px);
149
+ }
150
+ form {
151
+ display: flex;
152
+ flex-direction: column;
153
+ gap: var(--uui-size-space-4, 12px);
154
+ }
155
+ uui-input {
156
+ width: 100%;
157
+ }
158
+ #code {
159
+ font-family: monospace;
160
+ font-size: 1.75rem;
161
+ letter-spacing: 0.15em;
162
+ text-align: center;
163
+ margin: var(--uui-size-space-4, 12px) 0;
164
+ }
165
+ #actions {
166
+ display: flex;
167
+ gap: var(--uui-size-space-3, 9px);
168
+ }
169
+ #error {
170
+ margin: 0;
171
+ color: var(--uui-color-danger, #d42054);
172
+ }
173
+ #notice {
174
+ margin: 0;
175
+ color: var(--uui-color-positive, #0b8152);
176
+ }
177
+ `
178
+ }
179
+
180
+ customElements.define('crombolo-device', CromboloDeviceElement)
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The page a push to a protected environment is approved on (story 107). The
3
+ * person who pushed shares its link; someone else allowed to push here looks at
4
+ * who sent it and when, and approves or declines it.
5
+ */
6
+ import { css, html, LitElement, nothing } from '@umbraco-cms/backoffice/external/lit'
7
+ import '@umbraco-cms/backoffice/external/uui'
8
+
9
+ const API = '/umbraco/remote/v1/jobs'
10
+
11
+ class CromboloRemoteApprovalElement extends LitElement {
12
+ static properties = {
13
+ job: { type: String },
14
+ logoImage: { type: String, attribute: 'logo-image' },
15
+ _job: { state: true },
16
+ _error: { state: true },
17
+ _busy: { state: true },
18
+ }
19
+
20
+ constructor() {
21
+ super()
22
+ this.job = ''
23
+ this.logoImage = ''
24
+ this._job = undefined
25
+ this._error = ''
26
+ this._busy = false
27
+ }
28
+
29
+ connectedCallback() {
30
+ super.connectedCallback()
31
+ this.#load()
32
+ }
33
+
34
+ async #load() {
35
+ const response = await fetch(`${API}/${encodeURIComponent(this.job)}`, {
36
+ credentials: 'include',
37
+ })
38
+ const body = await response.json().catch(() => ({}))
39
+ if (!response.ok) {
40
+ this._error = body.title ?? 'There is no such push.'
41
+ return
42
+ }
43
+ this._job = body
44
+ }
45
+
46
+ async #decide(approve) {
47
+ this._busy = true
48
+ this._error = ''
49
+ const response = await fetch(
50
+ `${API}/${encodeURIComponent(this.job)}/${approve ? 'approve' : 'decline'}`,
51
+ {
52
+ method: 'POST',
53
+ credentials: 'include',
54
+ },
55
+ )
56
+ const body = await response.json().catch(() => ({}))
57
+ this._busy = false
58
+ if (!response.ok) {
59
+ this._error = body.title ?? 'That could not be done.'
60
+ return
61
+ }
62
+ this._job = body
63
+ }
64
+
65
+ #body() {
66
+ const job = this._job
67
+ if (!job)
68
+ return this._error ? html`<p id="error">${this._error}</p>` : html`<uui-loader></uui-loader>`
69
+ const when = new Date(job.createdAt).toLocaleString()
70
+ if (job.state !== 'approval-required')
71
+ return html`<p>
72
+ ${job.state === 'refused' ? (job.problem?.title ?? 'This push was declined.') : 'This push has been decided.'}
73
+ </p>`
74
+ return html`
75
+ <p>
76
+ <strong>${job.requestedBy}</strong> wants to replace <strong>${job.environment}</strong> with a copy of their
77
+ site, sent ${when}. Everything on ${job.environment} that is not in it goes to the recycle bin, and a backup is
78
+ taken first.
79
+ </p>
80
+ ${this._error ? html`<p id="error" role="alert">${this._error}</p>` : nothing}
81
+ <div id="actions">
82
+ <uui-button look="primary" color="positive" label="Approve" .state=${this._busy ? 'waiting' : undefined}
83
+ @click=${() => this.#decide(true)}></uui-button>
84
+ <uui-button look="outline" color="danger" label="Decline" @click=${() => this.#decide(false)}></uui-button>
85
+ </div>`
86
+ }
87
+
88
+ render() {
89
+ return html`<div id="layout">
90
+ <uui-box id="card">
91
+ ${this.logoImage ? html`<img id="logo" src=${this.logoImage} alt="" />` : nothing}
92
+ <h1 class="uui-h3">Approve a push</h1>
93
+ ${this.#body()}
94
+ </uui-box>
95
+ </div>`
96
+ }
97
+
98
+ static styles = css`
99
+ :host {
100
+ display: block;
101
+ min-height: 100vh;
102
+ background: var(--uui-color-surface-alt, #f4f4f4);
103
+ }
104
+ #layout {
105
+ display: flex;
106
+ align-items: center;
107
+ justify-content: center;
108
+ min-height: 100vh;
109
+ padding: 16px;
110
+ box-sizing: border-box;
111
+ }
112
+ #card {
113
+ width: 100%;
114
+ max-width: 480px;
115
+ }
116
+ #logo {
117
+ display: block;
118
+ height: 48px;
119
+ margin-bottom: var(--uui-size-space-4, 12px);
120
+ }
121
+ h1 {
122
+ margin: 0 0 var(--uui-size-space-5, 18px);
123
+ }
124
+ #actions {
125
+ display: flex;
126
+ gap: var(--uui-size-space-3, 9px);
127
+ }
128
+ #error {
129
+ color: var(--uui-color-danger, #d42054);
130
+ }
131
+ `
132
+ }
133
+
134
+ customElements.define('crombolo-remote-approval', CromboloRemoteApprovalElement)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/backoffice-host",
3
- "version": "0.14.1",
3
+ "version": "0.14.4",
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.14.1",
34
+ "@crombolo/core": "0.14.4",
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.14.1",
5
+ "version": "0.14.4",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -46,6 +46,9 @@ export default {
46
46
  "Hello there and welcome to Crombolo! In just 1 minute you'll be good to go, we just need you to setup a password and add a picture for your avatar.",
47
47
  userinviteExpiredMessage:
48
48
  'Welcome to Crombolo! Unfortunately your invite has expired. Please contact your administrator to get a new one.',
49
+
50
+ // The heading over the remote permissions (story 107) in a user group.
51
+ 'permissionsEntityGroup_crombolo-remote': 'Moving whole sites',
49
52
  },
50
53
  general: {
51
54
  umbracoInfo: 'Crombolo info',
@@ -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.14.1",
5
+ "version": "0.14.4",
6
6
  "allowPublicAccess": true,
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.14.1",
5
+ "version": "0.14.4",
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 Sign-in Dialog",
4
4
  "id": "Crombolo.SignInDialog",
5
- "version": "0.14.1",
5
+ "version": "0.14.4",
6
6
  "allowPublicAccess": true,
7
7
  "extensions": [
8
8
  {
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The terminals signed in as the current person (story 107), in their profile:
3
+ * each one named, when it signed in and was last used, and signed out from here.
4
+ */
5
+ import { css, html, nothing } from '@umbraco-cms/backoffice/external/lit'
6
+ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'
7
+
8
+ const API = '/umbraco/management/api/v1/security/back-office/devices'
9
+
10
+ const when = (iso) => (iso ? new Date(iso).toLocaleString() : '—')
11
+
12
+ export default class CromboloSignedInDevicesElement extends UmbLitElement {
13
+ static properties = { _devices: { state: true }, _error: { state: true } }
14
+
15
+ constructor() {
16
+ super()
17
+ this._devices = undefined
18
+ this._error = ''
19
+ }
20
+
21
+ connectedCallback() {
22
+ super.connectedCallback()
23
+ this.#load()
24
+ }
25
+
26
+ async #load() {
27
+ try {
28
+ const response = await fetch(API, { credentials: 'include' })
29
+ this._devices = response.ok ? await response.json() : []
30
+ } catch {
31
+ this._error = 'Could not load your signed-in devices.'
32
+ }
33
+ }
34
+
35
+ async #signOut(id) {
36
+ const response = await fetch(`${API}/${id}`, { method: 'DELETE', credentials: 'include' })
37
+ if (!response.ok) this._error = 'That device could not be signed out.'
38
+ await this.#load()
39
+ }
40
+
41
+ render() {
42
+ if (!this._devices) return nothing
43
+ return html`<uui-box headline="Signed-in devices">
44
+ ${this._error ? html`<p class="error">${this._error}</p>` : nothing}
45
+ ${
46
+ this._devices.length === 0
47
+ ? html`<p>No terminals are signed in as you.</p>`
48
+ : html`<ul>
49
+ ${this._devices.map(
50
+ (device) => html`<li>
51
+ <div>
52
+ <strong>${device.name ?? 'A terminal'}</strong>
53
+ <small>signed in ${when(device.signedInAt)}, last used ${when(device.lastUsedAt)}</small>
54
+ </div>
55
+ <uui-button look="secondary" label="Sign out" @click=${() => this.#signOut(device.id)}></uui-button>
56
+ </li>`,
57
+ )}
58
+ </ul>`
59
+ }
60
+ </uui-box>`
61
+ }
62
+
63
+ static styles = css`
64
+ ul {
65
+ list-style: none;
66
+ margin: 0;
67
+ padding: 0;
68
+ }
69
+ li {
70
+ display: flex;
71
+ justify-content: space-between;
72
+ align-items: center;
73
+ gap: var(--uui-size-space-4);
74
+ padding: var(--uui-size-space-3) 0;
75
+ border-bottom: 1px solid var(--uui-color-divider);
76
+ }
77
+ li div {
78
+ display: flex;
79
+ flex-direction: column;
80
+ }
81
+ small {
82
+ color: var(--uui-color-text-alt);
83
+ }
84
+ .error {
85
+ color: var(--uui-color-danger);
86
+ }
87
+ `
88
+ }
89
+
90
+ customElements.define('crombolo-signed-in-devices', CromboloSignedInDevicesElement)
@@ -2,9 +2,61 @@
2
2
  "$schema": "../../backoffice-dist/dist/umbraco-package-schema.json",
3
3
  "name": "Crombolo",
4
4
  "id": "Crombolo.Core",
5
- "version": "0.14.1",
5
+ "version": "0.14.4",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
+ {
9
+ "type": "entityUserPermission",
10
+ "alias": "Crombolo.UserPermission.Remote.Pull",
11
+ "name": "Crombolo Remote Pull Permission",
12
+ "forEntityTypes": ["crombolo-remote"],
13
+ "meta": {
14
+ "verbs": ["Crombolo.Remote.Pull"],
15
+ "label": "Pull a copy",
16
+ "description": "Download a copy of this site to work on, with members and users anonymised"
17
+ }
18
+ },
19
+ {
20
+ "type": "entityUserPermission",
21
+ "alias": "Crombolo.UserPermission.Remote.PullFull",
22
+ "name": "Crombolo Remote PullFull Permission",
23
+ "forEntityTypes": ["crombolo-remote"],
24
+ "meta": {
25
+ "verbs": ["Crombolo.Remote.PullFull"],
26
+ "label": "Pull a full copy",
27
+ "description": "Download a copy that includes members and users, sealed with a passphrase"
28
+ }
29
+ },
30
+ {
31
+ "type": "entityUserPermission",
32
+ "alias": "Crombolo.UserPermission.Remote.Push",
33
+ "name": "Crombolo Remote Push Permission",
34
+ "forEntityTypes": ["crombolo-remote"],
35
+ "meta": {
36
+ "verbs": ["Crombolo.Remote.Push"],
37
+ "label": "Push",
38
+ "description": "Replace this site with a copy sent from a terminal"
39
+ }
40
+ },
41
+ {
42
+ "type": "entityUserPermission",
43
+ "alias": "Crombolo.UserPermission.Remote.PushProtected",
44
+ "name": "Crombolo Remote PushProtected Permission",
45
+ "forEntityTypes": ["crombolo-remote"],
46
+ "meta": {
47
+ "verbs": ["Crombolo.Remote.PushProtected"],
48
+ "label": "Push to a protected site",
49
+ "description": "Replace a site people rely on, and approve or decline other people's pushes to it"
50
+ }
51
+ },
52
+ {
53
+ "type": "userProfileApp",
54
+ "alias": "Crombolo.UserProfileApp.SignedInDevices",
55
+ "name": "Crombolo Signed-in Devices",
56
+ "element": "/crombolo-plugin/signed-in-devices.js",
57
+ "weight": 100,
58
+ "meta": { "label": "Signed-in devices", "pathname": "devices" }
59
+ },
8
60
  {
9
61
  "type": "mfaLoginProvider",
10
62
  "alias": "Crombolo.MfaLoginProvider.Authenticator",
@@ -13,6 +13,9 @@ export function resolveLoginAsset(backOfficePath: string, pathname: string): str
13
13
  const prefix = `${backOfficePath}/login/`
14
14
  if (!pathname.startsWith(prefix)) return undefined
15
15
  const name = pathname.slice(prefix.length)
16
- // Only the files we ship; no traversal, no directory listing.
17
- return name === 'login.js' ? join(LOGIN_ASSET_DIR, 'login.js') : undefined
16
+ // Only the files we ship; no traversal, no directory listing. `device.js` and
17
+ // `remote-approval.js` are the pages a sign-in and a push are approved on (story 107).
18
+ return ['login.js', 'device.js', 'remote-approval.js'].includes(name)
19
+ ? join(LOGIN_ASSET_DIR, name)
20
+ : undefined
18
21
  }
package/src/shell.ts CHANGED
@@ -140,3 +140,64 @@ ${noscript(`${graphics}/login-logo-alternative`)}
140
140
  </html>
141
141
  `
142
142
  }
143
+
144
+ /**
145
+ * A page of its own beside the backoffice, for a signed-in person: approving a
146
+ * command-line sign-in or a protected push (story 107). `element` is the custom
147
+ * element's opening and closing tags, attributes already escaped.
148
+ */
149
+ export function renderStandaloneShell(
150
+ paths: BackOfficePaths,
151
+ importmap: PackageManifestImportmap,
152
+ settings: { title: string; script: string; element: string },
153
+ ): string {
154
+ const assets = paths.assetsPath
155
+ return `<!doctype html>
156
+ <html lang="en">
157
+ <head>
158
+ <meta charset="UTF-8" />
159
+ <base href="${paths.backOfficePath}/" />
160
+ <link rel="icon" type="image/svg+xml" href="${assets}/assets/favicon.svg" />
161
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
162
+ <meta name="robots" content="noindex, nofollow" />
163
+ <title>${escapeHtml(settings.title)}</title>
164
+ <link rel="stylesheet" href="${assets}/css/light.css" />
165
+ <link rel="stylesheet" href="${paths.pluginPath}/branding/theme.css" />
166
+ <style>body { margin: 0; padding: 0; background-color: #fff7ea; }</style>
167
+ <script type="importmap">
168
+ ${JSON.stringify(importmap, null, 2)}
169
+ </script>
170
+ <script type="module" src="${paths.backOfficePath}/login/${settings.script}"></script>
171
+ </head>
172
+ <body class="uui-font uui-text" style="margin: 0; padding: 0; overflow: hidden">
173
+ ${settings.element}
174
+ </body>
175
+ </html>
176
+ `
177
+ }
178
+
179
+ /** The page a command-line sign-in is approved on: the person is signed in already. */
180
+ export function renderDeviceShell(
181
+ paths: BackOfficePaths,
182
+ importmap: PackageManifestImportmap,
183
+ settings: { title: string; userCode?: string },
184
+ ): string {
185
+ return renderStandaloneShell(paths, importmap, {
186
+ title: settings.title,
187
+ script: 'device.js',
188
+ element: `<crombolo-device user-code="${escapeHtml(settings.userCode ?? '')}" logo-image="${MANAGEMENT_API_PATH}/security/back-office/graphics/login-logo-alternative"></crombolo-device>`,
189
+ })
190
+ }
191
+
192
+ /** The page a push to a protected environment is approved or declined on. */
193
+ export function renderRemoteApprovalShell(
194
+ paths: BackOfficePaths,
195
+ importmap: PackageManifestImportmap,
196
+ settings: { title: string; job: string },
197
+ ): string {
198
+ return renderStandaloneShell(paths, importmap, {
199
+ title: settings.title,
200
+ script: 'remote-approval.js',
201
+ element: `<crombolo-remote-approval job="${escapeHtml(settings.job)}" logo-image="${MANAGEMENT_API_PATH}/security/back-office/graphics/login-logo-alternative"></crombolo-remote-approval>`,
202
+ })
203
+ }