@crombolo/entra 0.0.0-stage → 0.14.1
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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +73 -4
- package/plugin/entra-client.js +40 -0
- package/plugin/entra-workspace.js +347 -0
- package/src/index.ts +161 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kineco
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
crombolo
|
|
2
|
+
Copyright (c) 2026 Kineco
|
|
3
|
+
|
|
4
|
+
This product includes third-party software. crombolo is an independent project.
|
|
5
|
+
It is not affiliated with, endorsed by, or sponsored by Umbraco A/S, which owns
|
|
6
|
+
the Umbraco trademark.
|
|
7
|
+
|
|
8
|
+
There are three places third-party material is redistributed, rather than merely
|
|
9
|
+
depended on:
|
|
10
|
+
|
|
11
|
+
@crombolo/backoffice-dist the built Umbraco backoffice and the browser
|
|
12
|
+
packages bundled into it, produced by
|
|
13
|
+
`bun run vendor:backoffice`
|
|
14
|
+
@crombolo/backoffice-dist three CSS files copied unmodified from the Umbraco
|
|
15
|
+
client source tree
|
|
16
|
+
@crombolo/contracts the Umbraco Management API OpenAPI document,
|
|
17
|
+
copied unmodified
|
|
18
|
+
|
|
19
|
+
No Umbraco trademark is redistributed. The Umbraco wordmark, the "U" roundel
|
|
20
|
+
favicon and the installer illustration are deliberately not carried in this
|
|
21
|
+
repository: MIT grants copyright permission and says nothing about trademarks, so
|
|
22
|
+
crombolo's own marks are served at those paths instead. See `BRANDED_ASSETS` in
|
|
23
|
+
`@crombolo/backoffice-host`.
|
|
24
|
+
|
|
25
|
+
The Umbraco backoffice
|
|
26
|
+
----------------------
|
|
27
|
+
@umbraco-cms/backoffice 18.2.0 — MIT — https://github.com/umbraco/Umbraco-CMS
|
|
28
|
+
@umbraco-ui/uui 2.0.2 — MIT — https://github.com/umbraco/Umbraco.UI
|
|
29
|
+
|
|
30
|
+
Static files from the Umbraco client source tree
|
|
31
|
+
------------------------------------------------
|
|
32
|
+
css/umb-css.css, css/rte-content.css, css/umbraco-blockgridlayout.css
|
|
33
|
+
— copied unmodified from Umbraco-CMS release-18.2.0 — MIT
|
|
34
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
35
|
+
Shipped in @crombolo/backoffice-dist under `upstream-static/`.
|
|
36
|
+
|
|
37
|
+
`rte-content.css` itself carries Tiptap's default editor styles, copied from
|
|
38
|
+
tiptap v2.11.7 (MIT); the file records this in its own header.
|
|
39
|
+
|
|
40
|
+
The Umbraco Management API contract
|
|
41
|
+
-----------------------------------
|
|
42
|
+
OpenApi.json — copied unmodified from Umbraco-CMS release-18.2.0
|
|
43
|
+
(src/Umbraco.Cms.Api.Management/OpenApi.json) — MIT
|
|
44
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
45
|
+
Shipped in @crombolo/contracts, which also ships the TypeScript types generated
|
|
46
|
+
from it.
|
|
47
|
+
|
|
48
|
+
Browser runtime dependencies bundled into the backoffice build
|
|
49
|
+
--------------------------------------------------------------
|
|
50
|
+
@heximal/expressions 0.1.5 — BSD-3-Clause
|
|
51
|
+
@microsoft/signalr 10.0.11 — MIT
|
|
52
|
+
@tiptap/* 3.31.3 — MIT
|
|
53
|
+
diff 9.0.0 — BSD-3-Clause
|
|
54
|
+
dompurify 3.4.16 — Apache-2.0, elected from "MPL-2.0 OR Apache-2.0"
|
|
55
|
+
element-internals-polyfill 3.0.2 — MIT
|
|
56
|
+
lit 3.3.3 — BSD-3-Clause
|
|
57
|
+
luxon 3.7.2 — MIT
|
|
58
|
+
marked 18.0.14 — MIT
|
|
59
|
+
monaco-editor 0.57.0 — MIT
|
|
60
|
+
rxjs 7.8.2 — Apache-2.0
|
|
61
|
+
uuid 14.0.2 — MIT
|
|
62
|
+
|
|
63
|
+
dompurify is offered under either licence; Apache-2.0 is elected here, so no
|
|
64
|
+
MPL-2.0 obligation attaches to the copy bundled into the backoffice build. Of the
|
|
65
|
+
Apache-2.0 packages above, none ships a NOTICE file of its own, so Apache-2.0
|
|
66
|
+
section 4(d) adds nothing to this file.
|
|
67
|
+
|
|
68
|
+
Each package's own licence text ships inside its npm distribution and applies
|
|
69
|
+
unchanged to the copies bundled here.
|
package/package.json
CHANGED
|
@@ -1,6 +1,75 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/entra",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.14.1",
|
|
4
|
+
"description": "Edit a Microsoft Entra sign-in from the Crombolo backoffice: tenant, app credentials and group mappings, over the provider the site declares in code.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Crombolo",
|
|
7
|
+
"homepage": "https://github.com/kineco-au/crombolo-cms#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kineco-au/crombolo-cms.git",
|
|
11
|
+
"directory": "packages/entra"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/kineco-au/crombolo-cms/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"crombolo",
|
|
18
|
+
"crombolo-bundle",
|
|
19
|
+
"entra",
|
|
20
|
+
"azure-ad",
|
|
21
|
+
"single-sign-on",
|
|
22
|
+
"cms"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"bun": ">=1.3.0"
|
|
26
|
+
},
|
|
27
|
+
"type": "module",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": "./src/index.ts"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"plugin",
|
|
33
|
+
"src",
|
|
34
|
+
"NOTICE"
|
|
35
|
+
],
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"crombolo": {
|
|
40
|
+
"id": "entra",
|
|
41
|
+
"name": "Microsoft Entra",
|
|
42
|
+
"server": "entraSettings",
|
|
43
|
+
"extensions": [
|
|
44
|
+
{
|
|
45
|
+
"type": "menuItem",
|
|
46
|
+
"alias": "Crombolo.MenuItem.Entra",
|
|
47
|
+
"name": "Microsoft Entra Menu Item",
|
|
48
|
+
"weight": 240,
|
|
49
|
+
"meta": {
|
|
50
|
+
"label": "Microsoft Entra",
|
|
51
|
+
"icon": "icon-key",
|
|
52
|
+
"entityType": "crombolo-entra",
|
|
53
|
+
"menus": [
|
|
54
|
+
"Umb.Menu.AdvancedSettings"
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"type": "workspace",
|
|
60
|
+
"alias": "Crombolo.Workspace.Entra",
|
|
61
|
+
"name": "Microsoft Entra Workspace",
|
|
62
|
+
"element": "plugin/entra-workspace.js",
|
|
63
|
+
"meta": {
|
|
64
|
+
"entityType": "crombolo-entra"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
"dependencies": {
|
|
70
|
+
"@crombolo/server": "0.14.1"
|
|
71
|
+
},
|
|
72
|
+
"peerDependencies": {
|
|
73
|
+
"@crombolo/crombolo": ">=0.14.1"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads and drives this bundle's endpoints at
|
|
3
|
+
* `<backoffice>/crombolo/api/bundle/entra/*`. The session cookie carries the
|
|
4
|
+
* auth, as it does for the framework's own plugin endpoints; the base comes from
|
|
5
|
+
* `<base href>`, because the backoffice path is configurable.
|
|
6
|
+
*/
|
|
7
|
+
const base = () => {
|
|
8
|
+
const href = document.querySelector('base')?.getAttribute('href') ?? '/umbraco/'
|
|
9
|
+
return `${href.replace(/\/$/, '')}/crombolo/api/bundle/entra`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const json = async (path, init) => {
|
|
13
|
+
const response = await fetch(`${base()}${path}`, {
|
|
14
|
+
credentials: 'include',
|
|
15
|
+
headers: {
|
|
16
|
+
accept: 'application/json',
|
|
17
|
+
...(init?.body ? { 'content-type': 'application/json' } : {}),
|
|
18
|
+
},
|
|
19
|
+
...init,
|
|
20
|
+
})
|
|
21
|
+
const body = await response.json().catch(() => ({}))
|
|
22
|
+
// A refusal carries a message the screen shows.
|
|
23
|
+
if (!response.ok && ![400, 403, 404].includes(response.status))
|
|
24
|
+
throw new Error(`The Entra settings endpoint answered ${response.status}`)
|
|
25
|
+
return body
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const at = (provider) =>
|
|
29
|
+
`/providers/${encodeURIComponent(provider.audience)}/${encodeURIComponent(provider.id)}`
|
|
30
|
+
|
|
31
|
+
export const listProviders = () => json('/providers')
|
|
32
|
+
|
|
33
|
+
export const listGroups = (audience) => json(`/groups/${encodeURIComponent(audience)}`)
|
|
34
|
+
|
|
35
|
+
export const saveProvider = (provider, changes) =>
|
|
36
|
+
json(at(provider), { method: 'PUT', body: JSON.stringify(changes) })
|
|
37
|
+
|
|
38
|
+
export const resetProvider = (provider) => json(at(provider), { method: 'DELETE' })
|
|
39
|
+
|
|
40
|
+
export const testProvider = (provider) => json(`${at(provider)}/test`, { method: 'POST' })
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Microsoft Entra screen, in Settings under Advanced (story 033).
|
|
3
|
+
*
|
|
4
|
+
* One card per Entra sign-in the site declares in code: for the backoffice, for
|
|
5
|
+
* members, or both. Each field shows what is in force and says when it was
|
|
6
|
+
* changed here rather than in code; saving changes only what differs, and
|
|
7
|
+
* "Use the code's settings" removes every change. The client secret is never
|
|
8
|
+
* shown: it can be replaced or removed, and the card says whether there is one.
|
|
9
|
+
*/
|
|
10
|
+
import '@umbraco-cms/backoffice/components'
|
|
11
|
+
import { css, html, nothing, repeat } from '@umbraco-cms/backoffice/external/lit'
|
|
12
|
+
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'
|
|
13
|
+
import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'
|
|
14
|
+
import {
|
|
15
|
+
listGroups,
|
|
16
|
+
listProviders,
|
|
17
|
+
resetProvider,
|
|
18
|
+
saveProvider,
|
|
19
|
+
testProvider,
|
|
20
|
+
} from './entra-client.js'
|
|
21
|
+
|
|
22
|
+
const AUDIENCE = { user: 'Backoffice users', member: 'Members' }
|
|
23
|
+
|
|
24
|
+
/** What a card edits, from a provider as the server describes it. */
|
|
25
|
+
const draftOf = (provider) => ({
|
|
26
|
+
name: provider.name,
|
|
27
|
+
tenantId: provider.tenantId ?? '',
|
|
28
|
+
clientId: provider.clientId,
|
|
29
|
+
clientSecret: '',
|
|
30
|
+
autoCreate: provider.autoCreate,
|
|
31
|
+
linkByEmail: provider.linkByEmail,
|
|
32
|
+
emailsVerified: provider.emailsVerified,
|
|
33
|
+
mappings: Object.entries(provider.groupMap).map(([claim, group]) => ({ claim, group })),
|
|
34
|
+
defaultGroups: [...provider.defaultGroups],
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
export default class CromboloEntraWorkspaceElement extends UmbLitElement {
|
|
38
|
+
static properties = {
|
|
39
|
+
_providers: { state: true },
|
|
40
|
+
_drafts: { state: true },
|
|
41
|
+
_groups: { state: true },
|
|
42
|
+
_canEdit: { state: true },
|
|
43
|
+
_loading: { state: true },
|
|
44
|
+
_problem: { state: true },
|
|
45
|
+
_results: { state: true },
|
|
46
|
+
_busy: { state: true },
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#notifications
|
|
50
|
+
|
|
51
|
+
constructor() {
|
|
52
|
+
super()
|
|
53
|
+
this._providers = []
|
|
54
|
+
this._drafts = {}
|
|
55
|
+
this._groups = { user: [], member: [] }
|
|
56
|
+
this._canEdit = false
|
|
57
|
+
this._loading = true
|
|
58
|
+
this._problem = ''
|
|
59
|
+
this._results = {}
|
|
60
|
+
this._busy = ''
|
|
61
|
+
this.consumeContext(UMB_NOTIFICATION_CONTEXT, (context) => {
|
|
62
|
+
this.#notifications = context
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
connectedCallback() {
|
|
67
|
+
super.connectedCallback()
|
|
68
|
+
this.#load()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async #load() {
|
|
72
|
+
this._loading = true
|
|
73
|
+
try {
|
|
74
|
+
const result = await listProviders()
|
|
75
|
+
this._providers = result.providers ?? []
|
|
76
|
+
this._canEdit = Boolean(result.canEdit)
|
|
77
|
+
this._drafts = Object.fromEntries(this._providers.map((p) => [this.#key(p), draftOf(p)]))
|
|
78
|
+
const audiences = [...new Set(this._providers.map((p) => p.audience))]
|
|
79
|
+
const groups = { user: [], member: [] }
|
|
80
|
+
for (const audience of audiences) groups[audience] = (await listGroups(audience)).groups ?? []
|
|
81
|
+
this._groups = groups
|
|
82
|
+
this._problem = ''
|
|
83
|
+
} catch (error) {
|
|
84
|
+
this._problem = error instanceof Error ? error.message : String(error)
|
|
85
|
+
} finally {
|
|
86
|
+
this._loading = false
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#key(provider) {
|
|
91
|
+
return `${provider.audience}:${provider.id}`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#change(provider, field, value) {
|
|
95
|
+
const key = this.#key(provider)
|
|
96
|
+
this._drafts = { ...this._drafts, [key]: { ...this._drafts[key], [field]: value } }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#setMapping(provider, index, field, value) {
|
|
100
|
+
const draft = this._drafts[this.#key(provider)]
|
|
101
|
+
const mappings = draft.mappings.map((m, i) => (i === index ? { ...m, [field]: value } : m))
|
|
102
|
+
this.#change(provider, 'mappings', mappings)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Only what differs from what is in force goes to the server. */
|
|
106
|
+
#changes(provider) {
|
|
107
|
+
const draft = this._drafts[this.#key(provider)]
|
|
108
|
+
const changes = {}
|
|
109
|
+
for (const field of [
|
|
110
|
+
'name',
|
|
111
|
+
'tenantId',
|
|
112
|
+
'clientId',
|
|
113
|
+
'autoCreate',
|
|
114
|
+
'linkByEmail',
|
|
115
|
+
'emailsVerified',
|
|
116
|
+
])
|
|
117
|
+
if (draft[field] !== (provider[field] ?? '')) changes[field] = draft[field]
|
|
118
|
+
if (draft.clientSecret) changes.clientSecret = draft.clientSecret
|
|
119
|
+
const map = Object.fromEntries(
|
|
120
|
+
draft.mappings.filter((m) => m.claim.trim() && m.group).map((m) => [m.claim.trim(), m.group]),
|
|
121
|
+
)
|
|
122
|
+
if (JSON.stringify(map) !== JSON.stringify(provider.groupMap)) changes.groupMap = map
|
|
123
|
+
if (JSON.stringify(draft.defaultGroups) !== JSON.stringify(provider.defaultGroups))
|
|
124
|
+
changes.defaultGroups = draft.defaultGroups
|
|
125
|
+
return changes
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async #run(provider, label, action) {
|
|
129
|
+
this._busy = this.#key(provider)
|
|
130
|
+
try {
|
|
131
|
+
const result = await action()
|
|
132
|
+
if (result.ok === false) {
|
|
133
|
+
this.#notifications?.peek('danger', { data: { message: result.message } })
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
this.#notifications?.peek('positive', { data: { message: label } })
|
|
137
|
+
await this.#load()
|
|
138
|
+
} catch (error) {
|
|
139
|
+
this.#notifications?.peek('danger', {
|
|
140
|
+
data: { message: error instanceof Error ? error.message : String(error) },
|
|
141
|
+
})
|
|
142
|
+
} finally {
|
|
143
|
+
this._busy = ''
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#save(provider) {
|
|
148
|
+
const changes = this.#changes(provider)
|
|
149
|
+
if (Object.keys(changes).length === 0) return
|
|
150
|
+
return this.#run(provider, 'Entra settings saved', () => saveProvider(provider, changes))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#removeSecret(provider) {
|
|
154
|
+
return this.#run(provider, 'Client secret removed', () =>
|
|
155
|
+
saveProvider(provider, { clientSecret: null }),
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#reset(provider) {
|
|
160
|
+
return this.#run(provider, "Using the code's settings again", () => resetProvider(provider))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async #test(provider) {
|
|
164
|
+
this._busy = this.#key(provider)
|
|
165
|
+
try {
|
|
166
|
+
const result = await testProvider(provider)
|
|
167
|
+
this._results = { ...this._results, [this.#key(provider)]: result }
|
|
168
|
+
} finally {
|
|
169
|
+
this._busy = ''
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
render() {
|
|
174
|
+
return html`<umb-body-layout headline="Microsoft Entra">
|
|
175
|
+
${this._problem ? html`<uui-box><p class="problem">${this._problem}</p></uui-box>` : nothing}
|
|
176
|
+
${this._loading ? html`<uui-loader></uui-loader>` : this.#body()}
|
|
177
|
+
</umb-body-layout>`
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#body() {
|
|
181
|
+
if (this._providers.length === 0)
|
|
182
|
+
return html`<uui-box headline="No Entra sign-in">
|
|
183
|
+
<p>
|
|
184
|
+
This site declares no Microsoft Entra sign-in. Add one with <code>entra()</code> in
|
|
185
|
+
<code>crombolo.config.ts</code>, under <code>backofficeIdentityProviders</code> or
|
|
186
|
+
<code>memberIdentityProviders</code>; its settings can then be changed here.
|
|
187
|
+
</p>
|
|
188
|
+
</uui-box>`
|
|
189
|
+
return html`
|
|
190
|
+
${
|
|
191
|
+
this._canEdit
|
|
192
|
+
? nothing
|
|
193
|
+
: html`<uui-box><p>Only an administrator can change how people sign in.</p></uui-box>`
|
|
194
|
+
}
|
|
195
|
+
${repeat(
|
|
196
|
+
this._providers,
|
|
197
|
+
(p) => this.#key(p),
|
|
198
|
+
(p) => this.#card(p),
|
|
199
|
+
)}
|
|
200
|
+
`
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
#label(provider, field, text) {
|
|
204
|
+
return html`${text}${
|
|
205
|
+
provider.overridden.includes(field)
|
|
206
|
+
? html` <uui-tag look="secondary" color="warning">Changed here</uui-tag>`
|
|
207
|
+
: nothing
|
|
208
|
+
}`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#card(provider) {
|
|
212
|
+
const draft = this._drafts[this.#key(provider)]
|
|
213
|
+
const groups = this._groups[provider.audience] ?? []
|
|
214
|
+
const disabled = !this._canEdit
|
|
215
|
+
const result = this._results[this.#key(provider)]
|
|
216
|
+
const busy = this._busy === this.#key(provider)
|
|
217
|
+
return html`<uui-box headline=${`${AUDIENCE[provider.audience]} — ${provider.name}`}>
|
|
218
|
+
<div slot="header-actions" class="actions">
|
|
219
|
+
<uui-button look="secondary" label="Test connection" ?disabled=${busy}
|
|
220
|
+
@click=${() => this.#test(provider)}>Test connection</uui-button>
|
|
221
|
+
${
|
|
222
|
+
this._canEdit && provider.overridden.length > 0
|
|
223
|
+
? html`<uui-button look="secondary" color="danger" label="Use the code's settings"
|
|
224
|
+
?disabled=${busy} @click=${() => this.#reset(provider)}>Use the code's settings</uui-button>`
|
|
225
|
+
: nothing
|
|
226
|
+
}
|
|
227
|
+
</div>
|
|
228
|
+
${result ? html`<p class=${result.ok ? 'ok' : 'problem'}>${result.message}</p>` : nothing}
|
|
229
|
+
<umb-property-layout label="Redirect URL" description="Register this with the app in Entra">
|
|
230
|
+
<code slot="editor">${window.location.origin}${provider.callbackPath}</code>
|
|
231
|
+
</umb-property-layout>
|
|
232
|
+
<umb-property-layout .label=${this.#label(provider, 'name', 'Button text')}>
|
|
233
|
+
<uui-input slot="editor" label="Button text" ?readonly=${disabled} .value=${draft.name}
|
|
234
|
+
@change=${(e) => this.#change(provider, 'name', e.target.value)}></uui-input>
|
|
235
|
+
</umb-property-layout>
|
|
236
|
+
<umb-property-layout .label=${this.#label(provider, 'tenantId', 'Tenant ID')}>
|
|
237
|
+
<uui-input slot="editor" label="Tenant ID" ?readonly=${disabled} .value=${draft.tenantId}
|
|
238
|
+
@change=${(e) => this.#change(provider, 'tenantId', e.target.value)}></uui-input>
|
|
239
|
+
</umb-property-layout>
|
|
240
|
+
<umb-property-layout .label=${this.#label(provider, 'clientId', 'Application (client) ID')}>
|
|
241
|
+
<uui-input slot="editor" label="Client ID" ?readonly=${disabled} .value=${draft.clientId}
|
|
242
|
+
@change=${(e) => this.#change(provider, 'clientId', e.target.value)}></uui-input>
|
|
243
|
+
</umb-property-layout>
|
|
244
|
+
<umb-property-layout .label=${this.#label(provider, 'clientSecret', 'Client secret')}
|
|
245
|
+
description=${provider.hasClientSecret ? 'A secret is set. Enter a new one to replace it.' : 'No secret is set.'}>
|
|
246
|
+
<div slot="editor" class="row">
|
|
247
|
+
<uui-input type="password" label="Client secret" autocomplete="off" ?readonly=${disabled}
|
|
248
|
+
.value=${draft.clientSecret} placeholder=${provider.hasClientSecret ? '••••••••' : ''}
|
|
249
|
+
@change=${(e) => this.#change(provider, 'clientSecret', e.target.value)}></uui-input>
|
|
250
|
+
${
|
|
251
|
+
this._canEdit && provider.overridden.includes('clientSecret')
|
|
252
|
+
? html`<uui-button look="secondary" label="Remove the secret kept here"
|
|
253
|
+
@click=${() => this.#removeSecret(provider)}>Remove</uui-button>`
|
|
254
|
+
: nothing
|
|
255
|
+
}
|
|
256
|
+
</div>
|
|
257
|
+
</umb-property-layout>
|
|
258
|
+
<umb-property-layout .label=${this.#label(provider, 'autoCreate', 'Create accounts')}
|
|
259
|
+
description="Create an account for someone signing in for the first time">
|
|
260
|
+
<uui-toggle slot="editor" label="Create accounts" ?disabled=${disabled} ?checked=${draft.autoCreate}
|
|
261
|
+
@change=${(e) => this.#change(provider, 'autoCreate', e.target.checked)}></uui-toggle>
|
|
262
|
+
</umb-property-layout>
|
|
263
|
+
<umb-property-layout .label=${this.#label(provider, 'linkByEmail', 'Link by email')}
|
|
264
|
+
description="Link a first sign-in to an existing account with the same, verified, email">
|
|
265
|
+
<uui-toggle slot="editor" label="Link by email" ?disabled=${disabled} ?checked=${draft.linkByEmail}
|
|
266
|
+
@change=${(e) => this.#change(provider, 'linkByEmail', e.target.checked)}></uui-toggle>
|
|
267
|
+
</umb-property-layout>
|
|
268
|
+
<umb-property-layout .label=${this.#label(provider, 'emailsVerified', 'Trust emails')}
|
|
269
|
+
description="Treat every email from this tenant as verified. Entra sends no verification claim.">
|
|
270
|
+
<uui-toggle slot="editor" label="Trust emails" ?disabled=${disabled} ?checked=${draft.emailsVerified}
|
|
271
|
+
@change=${(e) => this.#change(provider, 'emailsVerified', e.target.checked)}></uui-toggle>
|
|
272
|
+
</umb-property-layout>
|
|
273
|
+
<umb-property-layout .label=${this.#label(provider, 'groupMap', 'Group mappings')}
|
|
274
|
+
description="An Entra group's object ID, and the group here its members join. Made to match on every sign-in.">
|
|
275
|
+
<div slot="editor" class="mappings">
|
|
276
|
+
${draft.mappings.map(
|
|
277
|
+
(m, i) => html`<div class="row">
|
|
278
|
+
<uui-input label="Entra group object ID" placeholder="Entra group object ID" ?readonly=${disabled}
|
|
279
|
+
.value=${m.claim} @change=${(e) => this.#setMapping(provider, i, 'claim', e.target.value)}></uui-input>
|
|
280
|
+
<uui-select label="Group here" ?disabled=${disabled}
|
|
281
|
+
.options=${[{ name: 'Choose a group', value: '', selected: !m.group }, ...groups.map((g) => ({ name: g.name, value: g.value, selected: g.value === m.group }))]}
|
|
282
|
+
@change=${(e) => this.#setMapping(provider, i, 'group', e.target.value)}></uui-select>
|
|
283
|
+
${
|
|
284
|
+
disabled
|
|
285
|
+
? nothing
|
|
286
|
+
: html`<uui-button look="secondary" label="Remove mapping" compact
|
|
287
|
+
@click=${() =>
|
|
288
|
+
this.#change(
|
|
289
|
+
provider,
|
|
290
|
+
'mappings',
|
|
291
|
+
draft.mappings.filter((_, j) => j !== i),
|
|
292
|
+
)}>Remove</uui-button>`
|
|
293
|
+
}
|
|
294
|
+
</div>`,
|
|
295
|
+
)}
|
|
296
|
+
${
|
|
297
|
+
disabled
|
|
298
|
+
? nothing
|
|
299
|
+
: html`<uui-button look="secondary" label="Add a mapping" compact
|
|
300
|
+
@click=${() => this.#change(provider, 'mappings', [...draft.mappings, { claim: '', group: '' }])}>Add a mapping</uui-button>`
|
|
301
|
+
}
|
|
302
|
+
</div>
|
|
303
|
+
</umb-property-layout>
|
|
304
|
+
<umb-property-layout .label=${this.#label(provider, 'defaultGroups', 'Default groups')}
|
|
305
|
+
description="Groups a new account joins">
|
|
306
|
+
<div slot="editor" class="checks">
|
|
307
|
+
${groups.map(
|
|
308
|
+
(g) => html`<uui-checkbox label=${g.name} ?disabled=${disabled}
|
|
309
|
+
?checked=${draft.defaultGroups.includes(g.value)}
|
|
310
|
+
@change=${(e) =>
|
|
311
|
+
this.#change(
|
|
312
|
+
provider,
|
|
313
|
+
'defaultGroups',
|
|
314
|
+
e.target.checked
|
|
315
|
+
? [...draft.defaultGroups, g.value]
|
|
316
|
+
: draft.defaultGroups.filter((v) => v !== g.value),
|
|
317
|
+
)}>${g.name}</uui-checkbox>`,
|
|
318
|
+
)}
|
|
319
|
+
</div>
|
|
320
|
+
</umb-property-layout>
|
|
321
|
+
${
|
|
322
|
+
this._canEdit
|
|
323
|
+
? html`<div class="save">
|
|
324
|
+
<uui-button look="primary" color="positive" label="Save" ?disabled=${busy || Object.keys(this.#changes(provider)).length === 0}
|
|
325
|
+
@click=${() => this.#save(provider)}>Save</uui-button>
|
|
326
|
+
</div>`
|
|
327
|
+
: nothing
|
|
328
|
+
}
|
|
329
|
+
</uui-box>`
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
static styles = css`
|
|
333
|
+
:host { display: block; height: 100%; }
|
|
334
|
+
uui-box { margin-bottom: var(--uui-size-layout-1); }
|
|
335
|
+
.actions { display: flex; gap: var(--uui-size-space-3); }
|
|
336
|
+
.row { display: flex; gap: var(--uui-size-space-3); align-items: center; }
|
|
337
|
+
.row uui-input { flex: 1; }
|
|
338
|
+
.mappings, .checks { display: flex; flex-direction: column; gap: var(--uui-size-space-3); }
|
|
339
|
+
uui-input, uui-select { width: 100%; }
|
|
340
|
+
.save { display: flex; justify-content: flex-end; margin-top: var(--uui-size-space-5); }
|
|
341
|
+
.ok { color: var(--uui-color-positive); }
|
|
342
|
+
.problem { color: var(--uui-color-danger); }
|
|
343
|
+
code { word-break: break-all; }
|
|
344
|
+
`
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
customElements.define('crombolo-entra-workspace', CromboloEntraWorkspaceElement)
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Microsoft Entra bundle (story 033): a Settings screen for the Entra sign-ins
|
|
3
|
+
* a site declares in code with `entra()`. It changes their settings — tenant, app
|
|
4
|
+
* credentials, the sign-in options and group mappings — and puts them back; it
|
|
5
|
+
* never declares a provider. The overrides live in core, behind the
|
|
6
|
+
* `identityProviders` capability, and apply at the next sign-in on every node.
|
|
7
|
+
*
|
|
8
|
+
* Reading is for anyone with the Settings section. Changing is for
|
|
9
|
+
* administrators only, because a group mapping can make someone an administrator.
|
|
10
|
+
*/
|
|
11
|
+
import type {
|
|
12
|
+
BundleRequest,
|
|
13
|
+
BundleSection,
|
|
14
|
+
ProviderOverrideInput,
|
|
15
|
+
ServerBundle,
|
|
16
|
+
} from '@crombolo/server'
|
|
17
|
+
import manifest from '../package.json' with { type: 'json' }
|
|
18
|
+
|
|
19
|
+
const CAPABILITIES = ['identityProviders', 'log'] as const
|
|
20
|
+
|
|
21
|
+
type Capability = (typeof CAPABILITIES)[number]
|
|
22
|
+
|
|
23
|
+
export const ENTRA_BUNDLE_ID = manifest.crombolo.id
|
|
24
|
+
|
|
25
|
+
export interface EntraSettingsOptions {
|
|
26
|
+
/** The section a caller must have to see the screen; Settings by default. */
|
|
27
|
+
section?: BundleSection
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const json = (body: unknown, status = 200) =>
|
|
31
|
+
Response.json(body, { status, headers: { 'cache-control': 'no-store' } })
|
|
32
|
+
|
|
33
|
+
const AUDIENCES = new Set(['member', 'user'])
|
|
34
|
+
|
|
35
|
+
/** The fields the screen sends, each checked for its type; nothing else is passed on. */
|
|
36
|
+
export function readOverrides(body: Record<string, unknown>): ProviderOverrideInput {
|
|
37
|
+
const out: ProviderOverrideInput = {}
|
|
38
|
+
for (const field of ['name', 'tenantId', 'clientId'] as const)
|
|
39
|
+
if (field in body && (typeof body[field] === 'string' || body[field] === null))
|
|
40
|
+
out[field] = (body[field] as string | null)?.trim() || undefined
|
|
41
|
+
if ('clientSecret' in body) {
|
|
42
|
+
const secret = body.clientSecret
|
|
43
|
+
if (secret === null) out.clientSecret = null
|
|
44
|
+
else if (typeof secret === 'string' && secret.trim()) out.clientSecret = secret.trim()
|
|
45
|
+
}
|
|
46
|
+
for (const field of ['autoCreate', 'linkByEmail', 'emailsVerified'] as const)
|
|
47
|
+
if (field in body) out[field] = typeof body[field] === 'boolean' ? body[field] : undefined
|
|
48
|
+
if ('groupMap' in body) {
|
|
49
|
+
const map = body.groupMap
|
|
50
|
+
if (map === null) out.groupMap = undefined
|
|
51
|
+
else if (map && typeof map === 'object' && !Array.isArray(map)) {
|
|
52
|
+
const entries = Object.entries(map as Record<string, unknown>)
|
|
53
|
+
.filter(([key, value]) => key.trim() && typeof value === 'string' && value.trim())
|
|
54
|
+
.map(([key, value]) => [key.trim(), (value as string).trim()])
|
|
55
|
+
out.groupMap = Object.fromEntries(entries)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if ('defaultGroups' in body) {
|
|
59
|
+
const groups = body.defaultGroups
|
|
60
|
+
out.defaultGroups = Array.isArray(groups)
|
|
61
|
+
? groups
|
|
62
|
+
.filter((g): g is string => typeof g === 'string' && g.trim() !== '')
|
|
63
|
+
.map((g) => g.trim())
|
|
64
|
+
: undefined
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const parseBody = async (request: Request): Promise<Record<string, unknown>> => {
|
|
70
|
+
const body = await request.json().catch(() => undefined)
|
|
71
|
+
return body && typeof body === 'object' ? (body as Record<string, unknown>) : {}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type Context = BundleRequest<Capability>
|
|
75
|
+
|
|
76
|
+
/** The provider a route names, when it is an Entra one the config declares. */
|
|
77
|
+
async function entraProvider(context: Context) {
|
|
78
|
+
const { audience, id } = context.params
|
|
79
|
+
if (!audience || !AUDIENCES.has(audience)) return undefined
|
|
80
|
+
const all = await context.host.identityProviders.list()
|
|
81
|
+
return all.find((p) => p.kind === 'entra' && p.audience === audience && p.id === id)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const adminOnly = () =>
|
|
85
|
+
json({ ok: false, message: 'Only an administrator can change how people sign in.' }, 403)
|
|
86
|
+
|
|
87
|
+
/** The Entra settings screen's server half, for `bundles: [entraSettings()]` in `crombolo.config.ts`. */
|
|
88
|
+
export function entraSettings(options: EntraSettingsOptions = {}): ServerBundle<Capability> {
|
|
89
|
+
return {
|
|
90
|
+
id: ENTRA_BUNDLE_ID,
|
|
91
|
+
section: options.section ?? 'settings',
|
|
92
|
+
capabilities: CAPABILITIES,
|
|
93
|
+
routes: [
|
|
94
|
+
{
|
|
95
|
+
method: 'GET',
|
|
96
|
+
path: 'providers',
|
|
97
|
+
async handler({ host, principal }) {
|
|
98
|
+
const providers = (await host.identityProviders.list()).filter((p) => p.kind === 'entra')
|
|
99
|
+
return json({ canEdit: principal.isAdmin, providers })
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
method: 'GET',
|
|
104
|
+
path: 'groups/:audience',
|
|
105
|
+
async handler({ host, params }) {
|
|
106
|
+
if (!params.audience || !AUDIENCES.has(params.audience))
|
|
107
|
+
return json({ ok: false, message: 'Groups are for members or users.' }, 404)
|
|
108
|
+
return json({
|
|
109
|
+
groups: await host.identityProviders.groups(params.audience as 'member' | 'user'),
|
|
110
|
+
})
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
method: 'PUT',
|
|
115
|
+
path: 'providers/:audience/:id',
|
|
116
|
+
async handler(context) {
|
|
117
|
+
if (!context.principal.isAdmin) return adminOnly()
|
|
118
|
+
const provider = await entraProvider(context)
|
|
119
|
+
if (!provider) return json({ ok: false, message: 'There is no such Entra sign-in.' }, 404)
|
|
120
|
+
const outcome = await context.host.identityProviders.update(
|
|
121
|
+
provider.audience,
|
|
122
|
+
provider.id,
|
|
123
|
+
readOverrides(await parseBody(context.request)),
|
|
124
|
+
)
|
|
125
|
+
if (!outcome.ok) return json({ ok: false, message: outcome.error }, 400)
|
|
126
|
+
context.host.log.info('{user} changed the {audience} Entra sign-in {id}', {
|
|
127
|
+
user: context.principal.email,
|
|
128
|
+
audience: provider.audience,
|
|
129
|
+
id: provider.id,
|
|
130
|
+
})
|
|
131
|
+
return json({ ok: true, provider: await entraProvider(context) })
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
method: 'DELETE',
|
|
136
|
+
path: 'providers/:audience/:id',
|
|
137
|
+
async handler(context) {
|
|
138
|
+
if (!context.principal.isAdmin) return adminOnly()
|
|
139
|
+
const provider = await entraProvider(context)
|
|
140
|
+
if (!provider) return json({ ok: false, message: 'There is no such Entra sign-in.' }, 404)
|
|
141
|
+
await context.host.identityProviders.reset(provider.audience, provider.id)
|
|
142
|
+
context.host.log.info('{user} put the {audience} Entra sign-in {id} back to the code', {
|
|
143
|
+
user: context.principal.email,
|
|
144
|
+
audience: provider.audience,
|
|
145
|
+
id: provider.id,
|
|
146
|
+
})
|
|
147
|
+
return json({ ok: true, provider: await entraProvider(context) })
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
method: 'POST',
|
|
152
|
+
path: 'providers/:audience/:id/test',
|
|
153
|
+
async handler(context) {
|
|
154
|
+
const provider = await entraProvider(context)
|
|
155
|
+
if (!provider) return json({ ok: false, message: 'There is no such Entra sign-in.' }, 404)
|
|
156
|
+
return json(await context.host.identityProviders.test(provider.audience, provider.id))
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
}
|
|
161
|
+
}
|
package/README.md
DELETED