@crombolo/backoffice-host 0.14.1 → 0.15.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/assets/device.js +180 -0
- package/assets/remote-approval.js +134 -0
- package/package.json +2 -2
- package/plugin/assistant/umbraco-package.json +1 -1
- package/plugin/branding/localization-en.js +3 -0
- package/plugin/branding/theme-dark.css +61 -0
- package/plugin/branding/theme-system.css +6 -0
- package/plugin/localizations/umbraco-package.json +1 -1
- package/plugin/seo/document-view.js +2 -1
- package/plugin/seo/umbraco-package.json +1 -1
- package/plugin/sign-in/umbraco-package.json +1 -1
- package/plugin/signed-in-devices.js +90 -0
- package/plugin/theme-switch.js +134 -0
- package/plugin/two-factor-setup.js +1 -0
- package/plugin/umbraco-package.json +81 -2
- package/plugin/welcome-dashboard.js +2 -2
- package/src/index.ts +1 -0
- package/src/login-assets.ts +5 -2
- package/src/shell.ts +66 -1
- package/src/theme-early.ts +50 -0
package/assets/device.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.15.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.
|
|
34
|
+
"@crombolo/core": "0.15.0",
|
|
35
35
|
"@crombolo/backoffice-dist": "18.2.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -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',
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Crombolo's dark mode (story 049): the vendored `dark.css` for every token,
|
|
3
|
+
* warmed to the brand — cocoa surfaces, cream text, terracotta accents.
|
|
4
|
+
*
|
|
5
|
+
* The theme context appends this after `theme.css` when Dark is chosen, so it
|
|
6
|
+
* overrides the light palette. `/umbraco/backoffice/…` is served wherever the
|
|
7
|
+
* back office is mounted, which is why the import can name it outright.
|
|
8
|
+
* `tests/theme.test.ts` checks the contrast of every pair below.
|
|
9
|
+
*/
|
|
10
|
+
@import url("/umbraco/backoffice/css/dark.css");
|
|
11
|
+
|
|
12
|
+
:root {
|
|
13
|
+
--crombolo-orange: #da987c;
|
|
14
|
+
--crombolo-orange-light: #e2ae98;
|
|
15
|
+
--crombolo-ink: #f6ede4;
|
|
16
|
+
--crombolo-ink-dimmed: #c9b5a5;
|
|
17
|
+
--crombolo-night: #1f1813;
|
|
18
|
+
--crombolo-night-surface: #2a211b;
|
|
19
|
+
--crombolo-night-alt: #342922;
|
|
20
|
+
--crombolo-night-header: #2b1d15;
|
|
21
|
+
--crombolo-cocoa-deep: #25160e;
|
|
22
|
+
|
|
23
|
+
--uui-color-background: var(--crombolo-night);
|
|
24
|
+
--uui-color-surface: var(--crombolo-night-surface);
|
|
25
|
+
--uui-color-surface-alt: var(--crombolo-night-alt);
|
|
26
|
+
--uui-color-surface-emphasis: #30261f;
|
|
27
|
+
--uui-color-text: var(--crombolo-ink);
|
|
28
|
+
--uui-color-text-alt: var(--crombolo-ink-dimmed);
|
|
29
|
+
--uui-color-interactive: var(--crombolo-ink);
|
|
30
|
+
--uui-color-interactive-emphasis: var(--crombolo-orange-light);
|
|
31
|
+
|
|
32
|
+
/* The accent is light, so what sits on it is dark, as in the light mode. */
|
|
33
|
+
--uui-color-default: var(--crombolo-orange);
|
|
34
|
+
--uui-color-default-emphasis: var(--crombolo-orange-light);
|
|
35
|
+
--uui-color-default-standalone: var(--crombolo-orange);
|
|
36
|
+
--uui-color-default-contrast: var(--crombolo-cocoa-deep);
|
|
37
|
+
--uui-color-selected: var(--crombolo-orange);
|
|
38
|
+
--uui-color-selected-emphasis: var(--crombolo-orange-light);
|
|
39
|
+
--uui-color-selected-standalone: var(--crombolo-orange);
|
|
40
|
+
--uui-color-selected-contrast: var(--crombolo-cocoa-deep);
|
|
41
|
+
--uui-color-current: var(--crombolo-orange);
|
|
42
|
+
--uui-color-current-emphasis: var(--crombolo-orange-light);
|
|
43
|
+
--uui-color-current-contrast: var(--crombolo-cocoa-deep);
|
|
44
|
+
--uui-color-focus: var(--crombolo-orange);
|
|
45
|
+
|
|
46
|
+
/* A dark header with cream text; the active section's marker is the accent. */
|
|
47
|
+
--uui-color-header-surface: var(--crombolo-night-header);
|
|
48
|
+
--uui-color-header-contrast: rgb(246 237 228 / 80%);
|
|
49
|
+
--uui-color-header-contrast-emphasis: var(--crombolo-ink);
|
|
50
|
+
--crombolo-header-current: var(--crombolo-orange);
|
|
51
|
+
|
|
52
|
+
/* The SEO view's search-result preview, in Google's dark-mode link blue. */
|
|
53
|
+
--crombolo-snippet-title: #8ab4f8;
|
|
54
|
+
|
|
55
|
+
--uui-color-border: #4a3b31;
|
|
56
|
+
--uui-color-border-standalone: #8b7564;
|
|
57
|
+
--uui-color-border-emphasis: #a38c7a;
|
|
58
|
+
--uui-color-divider: #3a2e26;
|
|
59
|
+
--uui-color-divider-standalone: #4a3b31;
|
|
60
|
+
--uui-color-divider-emphasis: #5a4638;
|
|
61
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* "Match my system" (story 049): the dark mode while the computer asks for
|
|
3
|
+
* dark, and the light one otherwise — including a change made while the back
|
|
4
|
+
* office is open, which the media query follows by itself.
|
|
5
|
+
*/
|
|
6
|
+
@import url("./theme-dark.css") (prefers-color-scheme: dark);
|
|
@@ -122,7 +122,8 @@ export default class CromboloSeoDocumentViewElement extends UmbLitElement {
|
|
|
122
122
|
overflow-wrap: anywhere;
|
|
123
123
|
}
|
|
124
124
|
.snippet .title {
|
|
125
|
-
|
|
125
|
+
/* A search result's link blue: Google's own, light and dark. */
|
|
126
|
+
color: var(--crombolo-snippet-title, #1a0dab);
|
|
126
127
|
font-size: 20px;
|
|
127
128
|
margin: 4px 0;
|
|
128
129
|
}
|
|
@@ -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)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Light, dark or the computer's choice, from the header (story 049).
|
|
3
|
+
*
|
|
4
|
+
* The choice is Umbraco's theme context's — it links the stylesheet and keeps
|
|
5
|
+
* the alias in `localStorage` for the shell's early script to apply before the
|
|
6
|
+
* next paint. This adds the menu, and keeps the choice for the person in user
|
|
7
|
+
* data, so it follows them to another browser.
|
|
8
|
+
*/
|
|
9
|
+
import { css, html } from '@umbraco-cms/backoffice/external/lit'
|
|
10
|
+
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'
|
|
11
|
+
import { UMB_THEME_CONTEXT } from '@umbraco-cms/backoffice/themes'
|
|
12
|
+
|
|
13
|
+
const USER_DATA = '/umbraco/management/api/v1/user-data'
|
|
14
|
+
const GROUP = 'crombolo'
|
|
15
|
+
const IDENTIFIER = 'theme'
|
|
16
|
+
const EARLY = 'crombolo-early-theme'
|
|
17
|
+
|
|
18
|
+
const CHOICES = [
|
|
19
|
+
{ alias: 'umb-light-theme', label: 'Light', icon: 'icon-sunny' },
|
|
20
|
+
{ alias: 'Crombolo.Theme.Dark', label: 'Dark', icon: 'icon-contrast' },
|
|
21
|
+
{ alias: 'Crombolo.Theme.System', label: 'Match my system', icon: 'icon-lightbulb' },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
export default class CromboloThemeSwitchElement extends UmbLitElement {
|
|
25
|
+
static properties = { _alias: { state: true } }
|
|
26
|
+
|
|
27
|
+
#context
|
|
28
|
+
#savedKey
|
|
29
|
+
|
|
30
|
+
constructor() {
|
|
31
|
+
super()
|
|
32
|
+
this._alias = 'umb-light-theme'
|
|
33
|
+
this.consumeContext(UMB_THEME_CONTEXT, (context) => {
|
|
34
|
+
this.#context = context
|
|
35
|
+
this.observe(context.theme, (alias) => {
|
|
36
|
+
this._alias = alias || 'umb-light-theme'
|
|
37
|
+
// The shell's early stylesheet stood in until the context took over;
|
|
38
|
+
// once the choice differs from it, it would only fight the new one.
|
|
39
|
+
const early = document.getElementById(EARLY)
|
|
40
|
+
if (early && early.dataset.theme !== this._alias) early.remove()
|
|
41
|
+
})
|
|
42
|
+
this.#restore()
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The person's saved choice, when this browser's differs from it. */
|
|
47
|
+
async #restore() {
|
|
48
|
+
try {
|
|
49
|
+
const query = new URLSearchParams({ groups: GROUP, identifiers: IDENTIFIER, take: '1' })
|
|
50
|
+
const response = await fetch(`${USER_DATA}?${query}`, { credentials: 'include' })
|
|
51
|
+
if (!response.ok) return
|
|
52
|
+
const found = (await response.json()).items?.[0]
|
|
53
|
+
if (!found) return
|
|
54
|
+
this.#savedKey = found.key
|
|
55
|
+
if (found.value && found.value !== this._alias) this.#context?.setThemeByAlias(found.value)
|
|
56
|
+
} catch {
|
|
57
|
+
// This browser's choice stands.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async #save(alias) {
|
|
62
|
+
const body = { group: GROUP, identifier: IDENTIFIER, value: alias }
|
|
63
|
+
try {
|
|
64
|
+
if (this.#savedKey) {
|
|
65
|
+
await fetch(USER_DATA, {
|
|
66
|
+
method: 'PUT',
|
|
67
|
+
credentials: 'include',
|
|
68
|
+
headers: { 'content-type': 'application/json' },
|
|
69
|
+
body: JSON.stringify({ ...body, key: this.#savedKey }),
|
|
70
|
+
})
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
const response = await fetch(USER_DATA, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
credentials: 'include',
|
|
76
|
+
headers: { 'content-type': 'application/json' },
|
|
77
|
+
body: JSON.stringify(body),
|
|
78
|
+
})
|
|
79
|
+
this.#savedKey = response.headers.get('umb-generated-resource') ?? undefined
|
|
80
|
+
} catch {
|
|
81
|
+
// Kept in this browser regardless; another will start from its own.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#choose(alias) {
|
|
86
|
+
this.#context?.setThemeByAlias(alias)
|
|
87
|
+
this.#save(alias)
|
|
88
|
+
this.renderRoot.querySelector('#crombolo-theme-menu')?.hidePopover?.()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
render() {
|
|
92
|
+
const current = CHOICES.find((choice) => choice.alias === this._alias) ?? CHOICES[0]
|
|
93
|
+
return html`
|
|
94
|
+
<uui-button
|
|
95
|
+
compact
|
|
96
|
+
look="primary"
|
|
97
|
+
label="Light or dark: ${current.label}"
|
|
98
|
+
popovertarget="crombolo-theme-menu">
|
|
99
|
+
<umb-icon name=${current.icon}></umb-icon>
|
|
100
|
+
</uui-button>
|
|
101
|
+
<uui-popover-container id="crombolo-theme-menu" placement="bottom-end">
|
|
102
|
+
<umb-popover-layout>
|
|
103
|
+
${CHOICES.map(
|
|
104
|
+
(choice) => html`<uui-menu-item
|
|
105
|
+
label=${choice.label}
|
|
106
|
+
?active=${choice.alias === this._alias}
|
|
107
|
+
@click-label=${() => this.#choose(choice.alias)}>
|
|
108
|
+
<umb-icon slot="icon" name=${choice.icon}></umb-icon>
|
|
109
|
+
</uui-menu-item>`,
|
|
110
|
+
)}
|
|
111
|
+
</umb-popover-layout>
|
|
112
|
+
</uui-popover-container>
|
|
113
|
+
`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static styles = css`
|
|
117
|
+
uui-button {
|
|
118
|
+
font-size: 18px;
|
|
119
|
+
--uui-button-background-color: var(--umb-header-app-button-background-color, transparent);
|
|
120
|
+
--uui-button-background-color-hover: var(
|
|
121
|
+
--umb-header-app-button-background-color-hover,
|
|
122
|
+
var(--uui-color-emphasis)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
:host {
|
|
126
|
+
--uui-menu-item-flat-structure: 1;
|
|
127
|
+
}
|
|
128
|
+
umb-popover-layout {
|
|
129
|
+
min-width: 12rem;
|
|
130
|
+
}
|
|
131
|
+
`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
customElements.define('crombolo-theme-switch', CromboloThemeSwitchElement)
|
|
@@ -124,6 +124,7 @@ export default class CromboloTwoFactorSetupElement extends UmbLitElement {
|
|
|
124
124
|
width: 200px;
|
|
125
125
|
height: 200px;
|
|
126
126
|
margin: var(--uui-size-space-4) auto;
|
|
127
|
+
/* White in every mode: a scanner reads dark modules on a light ground. */
|
|
127
128
|
background: #fff;
|
|
128
129
|
}
|
|
129
130
|
.secret code,
|
|
@@ -2,16 +2,95 @@
|
|
|
2
2
|
"$schema": "../../backoffice-dist/dist/umbraco-package-schema.json",
|
|
3
3
|
"name": "Crombolo",
|
|
4
4
|
"id": "Crombolo.Core",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.15.0",
|
|
6
6
|
"allowPublicAccess": false,
|
|
7
7
|
"extensions": [
|
|
8
|
+
{
|
|
9
|
+
"type": "theme",
|
|
10
|
+
"alias": "Crombolo.Theme.Dark",
|
|
11
|
+
"name": "Dark",
|
|
12
|
+
"css": "/crombolo-plugin/branding/theme-dark.css",
|
|
13
|
+
"weight": 200,
|
|
14
|
+
"overwrites": "umb-dark-theme"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"type": "theme",
|
|
18
|
+
"alias": "Crombolo.Theme.System",
|
|
19
|
+
"name": "Match my system",
|
|
20
|
+
"css": "/crombolo-plugin/branding/theme-system.css",
|
|
21
|
+
"weight": 250
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"type": "headerApp",
|
|
25
|
+
"alias": "Crombolo.HeaderApp.Theme",
|
|
26
|
+
"name": "Light or dark",
|
|
27
|
+
"element": "/crombolo-plugin/theme-switch.js",
|
|
28
|
+
"weight": 850
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"type": "entityUserPermission",
|
|
32
|
+
"alias": "Crombolo.UserPermission.Remote.Pull",
|
|
33
|
+
"name": "Crombolo Remote Pull Permission",
|
|
34
|
+
"forEntityTypes": ["crombolo-remote"],
|
|
35
|
+
"meta": {
|
|
36
|
+
"verbs": ["Crombolo.Remote.Pull"],
|
|
37
|
+
"label": "Pull a copy",
|
|
38
|
+
"description": "Download a copy of this site to work on, with members and users anonymised"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"type": "entityUserPermission",
|
|
43
|
+
"alias": "Crombolo.UserPermission.Remote.PullFull",
|
|
44
|
+
"name": "Crombolo Remote PullFull Permission",
|
|
45
|
+
"forEntityTypes": ["crombolo-remote"],
|
|
46
|
+
"meta": {
|
|
47
|
+
"verbs": ["Crombolo.Remote.PullFull"],
|
|
48
|
+
"label": "Pull a full copy",
|
|
49
|
+
"description": "Download a copy that includes members and users, sealed with a passphrase"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"type": "entityUserPermission",
|
|
54
|
+
"alias": "Crombolo.UserPermission.Remote.Push",
|
|
55
|
+
"name": "Crombolo Remote Push Permission",
|
|
56
|
+
"forEntityTypes": ["crombolo-remote"],
|
|
57
|
+
"meta": {
|
|
58
|
+
"verbs": ["Crombolo.Remote.Push"],
|
|
59
|
+
"label": "Push",
|
|
60
|
+
"description": "Replace this site with a copy sent from a terminal"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"type": "entityUserPermission",
|
|
65
|
+
"alias": "Crombolo.UserPermission.Remote.PushProtected",
|
|
66
|
+
"name": "Crombolo Remote PushProtected Permission",
|
|
67
|
+
"forEntityTypes": ["crombolo-remote"],
|
|
68
|
+
"meta": {
|
|
69
|
+
"verbs": ["Crombolo.Remote.PushProtected"],
|
|
70
|
+
"label": "Push to a protected site",
|
|
71
|
+
"description": "Replace a site people rely on, and approve or decline other people's pushes to it"
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"type": "userProfileApp",
|
|
76
|
+
"alias": "Crombolo.UserProfileApp.SignedInDevices",
|
|
77
|
+
"name": "Crombolo Signed-in Devices",
|
|
78
|
+
"element": "/crombolo-plugin/signed-in-devices.js",
|
|
79
|
+
"weight": 100,
|
|
80
|
+
"meta": {
|
|
81
|
+
"label": "Signed-in devices",
|
|
82
|
+
"pathname": "devices"
|
|
83
|
+
}
|
|
84
|
+
},
|
|
8
85
|
{
|
|
9
86
|
"type": "mfaLoginProvider",
|
|
10
87
|
"alias": "Crombolo.MfaLoginProvider.Authenticator",
|
|
11
88
|
"name": "Crombolo Authenticator App",
|
|
12
89
|
"forProviderName": "UmbracoUserAppAuthenticator",
|
|
13
90
|
"element": "/crombolo-plugin/two-factor-setup.js",
|
|
14
|
-
"meta": {
|
|
91
|
+
"meta": {
|
|
92
|
+
"label": "Authenticator app"
|
|
93
|
+
}
|
|
15
94
|
},
|
|
16
95
|
{
|
|
17
96
|
"type": "sectionView",
|
|
@@ -31,8 +31,8 @@ const STEPS = [
|
|
|
31
31
|
{
|
|
32
32
|
title: 'Keep the schema in the repository',
|
|
33
33
|
body: html`Content types, data types and templates are also files:
|
|
34
|
-
<code>schema/*.toml</code>. <code>crombolo
|
|
35
|
-
the backoffice back out, and <code>crombolo
|
|
34
|
+
<code>schema/*.toml</code>. <code>crombolo types sync</code> writes changes made in
|
|
35
|
+
the backoffice back out, and <code>crombolo types check</code> tells you whether a
|
|
36
36
|
database matches. Schema travels through Git, not a database export.`,
|
|
37
37
|
},
|
|
38
38
|
]
|
package/src/index.ts
CHANGED
package/src/login-assets.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { MANAGEMENT_API_PATH } from '@crombolo/core'
|
|
10
10
|
import type { PackageManifestImportmap } from './manifests.ts'
|
|
11
11
|
import type { BackOfficePaths } from './paths.ts'
|
|
12
|
+
import { earlyThemeScript } from './theme-early.ts'
|
|
12
13
|
|
|
13
14
|
export interface ShellSettings {
|
|
14
15
|
/** GlobalSettings.DefaultUILanguage — the initial UI language. */
|
|
@@ -81,6 +82,7 @@ export function renderBackOfficeShell(
|
|
|
81
82
|
<link rel="stylesheet" href="${assets}/css/umb-css.css" />
|
|
82
83
|
<link rel="stylesheet" href="${assets}/css/light.css" />
|
|
83
84
|
<link rel="stylesheet" href="${paths.pluginPath}/branding/theme.css" />
|
|
85
|
+
<script>${earlyThemeScript(paths)}</script>
|
|
84
86
|
<script type="importmap">
|
|
85
87
|
${JSON.stringify(importmap, null, 2)}
|
|
86
88
|
</script>
|
|
@@ -117,7 +119,8 @@ export function renderLoginShell(
|
|
|
117
119
|
<title>${escapeHtml(settings.title)}</title>
|
|
118
120
|
<link rel="stylesheet" href="${assets}/css/light.css" />
|
|
119
121
|
<link rel="stylesheet" href="${paths.pluginPath}/branding/theme.css" />
|
|
120
|
-
<
|
|
122
|
+
<script>${earlyThemeScript(paths)}</script>
|
|
123
|
+
<style>body { margin: 0; padding: 0; background-color: var(--uui-color-background, #fff7ea); }</style>
|
|
121
124
|
<script type="importmap">
|
|
122
125
|
${JSON.stringify(importmap, null, 2)}
|
|
123
126
|
</script>
|
|
@@ -140,3 +143,65 @@ ${noscript(`${graphics}/login-logo-alternative`)}
|
|
|
140
143
|
</html>
|
|
141
144
|
`
|
|
142
145
|
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A page of its own beside the backoffice, for a signed-in person: approving a
|
|
149
|
+
* command-line sign-in or a protected push (story 107). `element` is the custom
|
|
150
|
+
* element's opening and closing tags, attributes already escaped.
|
|
151
|
+
*/
|
|
152
|
+
export function renderStandaloneShell(
|
|
153
|
+
paths: BackOfficePaths,
|
|
154
|
+
importmap: PackageManifestImportmap,
|
|
155
|
+
settings: { title: string; script: string; element: string },
|
|
156
|
+
): string {
|
|
157
|
+
const assets = paths.assetsPath
|
|
158
|
+
return `<!doctype html>
|
|
159
|
+
<html lang="en">
|
|
160
|
+
<head>
|
|
161
|
+
<meta charset="UTF-8" />
|
|
162
|
+
<base href="${paths.backOfficePath}/" />
|
|
163
|
+
<link rel="icon" type="image/svg+xml" href="${assets}/assets/favicon.svg" />
|
|
164
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
165
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
166
|
+
<title>${escapeHtml(settings.title)}</title>
|
|
167
|
+
<link rel="stylesheet" href="${assets}/css/light.css" />
|
|
168
|
+
<link rel="stylesheet" href="${paths.pluginPath}/branding/theme.css" />
|
|
169
|
+
<script>${earlyThemeScript(paths)}</script>
|
|
170
|
+
<style>body { margin: 0; padding: 0; background-color: var(--uui-color-background, #fff7ea); }</style>
|
|
171
|
+
<script type="importmap">
|
|
172
|
+
${JSON.stringify(importmap, null, 2)}
|
|
173
|
+
</script>
|
|
174
|
+
<script type="module" src="${paths.backOfficePath}/login/${settings.script}"></script>
|
|
175
|
+
</head>
|
|
176
|
+
<body class="uui-font uui-text" style="margin: 0; padding: 0; overflow: hidden">
|
|
177
|
+
${settings.element}
|
|
178
|
+
</body>
|
|
179
|
+
</html>
|
|
180
|
+
`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The page a command-line sign-in is approved on: the person is signed in already. */
|
|
184
|
+
export function renderDeviceShell(
|
|
185
|
+
paths: BackOfficePaths,
|
|
186
|
+
importmap: PackageManifestImportmap,
|
|
187
|
+
settings: { title: string; userCode?: string },
|
|
188
|
+
): string {
|
|
189
|
+
return renderStandaloneShell(paths, importmap, {
|
|
190
|
+
title: settings.title,
|
|
191
|
+
script: 'device.js',
|
|
192
|
+
element: `<crombolo-device user-code="${escapeHtml(settings.userCode ?? '')}" logo-image="${MANAGEMENT_API_PATH}/security/back-office/graphics/login-logo-alternative"></crombolo-device>`,
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The page a push to a protected environment is approved or declined on. */
|
|
197
|
+
export function renderRemoteApprovalShell(
|
|
198
|
+
paths: BackOfficePaths,
|
|
199
|
+
importmap: PackageManifestImportmap,
|
|
200
|
+
settings: { title: string; job: string },
|
|
201
|
+
): string {
|
|
202
|
+
return renderStandaloneShell(paths, importmap, {
|
|
203
|
+
title: settings.title,
|
|
204
|
+
script: 'remote-approval.js',
|
|
205
|
+
element: `<crombolo-remote-approval job="${escapeHtml(settings.job)}" logo-image="${MANAGEMENT_API_PATH}/security/back-office/graphics/login-logo-alternative"></crombolo-remote-approval>`,
|
|
206
|
+
})
|
|
207
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The back office's light or dark mode, applied before the first paint
|
|
3
|
+
* (story 049). Umbraco's theme context keeps the choice in `localStorage` and
|
|
4
|
+
* applies it once the client has booted, so a reload painted light first; and
|
|
5
|
+
* the login shell boots no theme context at all. This inline script reads the
|
|
6
|
+
* same key and links the same stylesheet at once.
|
|
7
|
+
*/
|
|
8
|
+
import type { BackOfficePaths } from './paths.ts'
|
|
9
|
+
|
|
10
|
+
/** The key Umbraco's theme context keeps the choice under. */
|
|
11
|
+
export const THEME_STORAGE_KEY = 'umb-theme-alias'
|
|
12
|
+
|
|
13
|
+
export const THEME_DARK = 'Crombolo.Theme.Dark'
|
|
14
|
+
export const THEME_SYSTEM = 'Crombolo.Theme.System'
|
|
15
|
+
|
|
16
|
+
/** Umbraco's own dark, which crombolo's replaces: a browser that chose it gets the new one. */
|
|
17
|
+
const RENAMED: Record<string, string> = { 'umb-dark-theme': THEME_DARK }
|
|
18
|
+
|
|
19
|
+
export const EARLY_THEME_ID = 'crombolo-early-theme'
|
|
20
|
+
|
|
21
|
+
/** Each choice's stylesheet; light, or nothing chosen, needs none beyond the shell's own. */
|
|
22
|
+
export function themeStylesheets(paths: BackOfficePaths): Record<string, string> {
|
|
23
|
+
return {
|
|
24
|
+
[THEME_DARK]: `${paths.pluginPath}/branding/theme-dark.css`,
|
|
25
|
+
[THEME_SYSTEM]: `${paths.pluginPath}/branding/theme-system.css`,
|
|
26
|
+
'umb-high-contrast-theme': `${paths.assetsPath}/css/high-contrast.css`,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The inline script, as the shells embed it. It fails quietly: light is a fine fallback. */
|
|
31
|
+
export function earlyThemeScript(paths: BackOfficePaths): string {
|
|
32
|
+
const sheets = JSON.stringify(themeStylesheets(paths))
|
|
33
|
+
const renamed = JSON.stringify(RENAMED)
|
|
34
|
+
return `(function () {
|
|
35
|
+
try {
|
|
36
|
+
var key = ${JSON.stringify(THEME_STORAGE_KEY)};
|
|
37
|
+
var alias = localStorage.getItem(key);
|
|
38
|
+
var renamed = ${renamed};
|
|
39
|
+
if (alias && renamed[alias]) { alias = renamed[alias]; localStorage.setItem(key, alias); }
|
|
40
|
+
var href = alias ? ${sheets}[alias] : undefined;
|
|
41
|
+
if (!href) return;
|
|
42
|
+
var link = document.createElement('link');
|
|
43
|
+
link.id = ${JSON.stringify(EARLY_THEME_ID)};
|
|
44
|
+
link.rel = 'stylesheet';
|
|
45
|
+
link.href = href;
|
|
46
|
+
link.setAttribute('data-theme', alias);
|
|
47
|
+
document.head.appendChild(link);
|
|
48
|
+
} catch (error) {}
|
|
49
|
+
})();`
|
|
50
|
+
}
|