@idp.global/catalog 2.1.1 → 3.0.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.
@@ -0,0 +1,574 @@
1
+ import { DeesElement, html, property, state, customElement, css, type TemplateResult } from '@design.estate/dees-element';
2
+ import { idpElementStyles } from './tokens.js';
3
+ import './idp-badge.js';
4
+ import './idp-button.js';
5
+ import './idp-icon.js';
6
+ import './idp-input.js';
7
+
8
+ export interface IIdpSsoConnectionItem {
9
+ id: string;
10
+ displayName: string;
11
+ status: 'active' | 'disabled';
12
+ emailDomains: string[];
13
+ entityId: string;
14
+ ssoUrl: string;
15
+ certificateCount: number;
16
+ provisioningMode: 'jit' | 'scim-only' | 'jit-and-scim';
17
+ defaultOrgRoles: string[];
18
+ groupMappings: { idpGroup: string; orgRoles: string[] }[];
19
+ }
20
+
21
+ export interface IIdpSsoSpDetails {
22
+ spEntityId: string;
23
+ acsUrl: string;
24
+ metadataUrl: string;
25
+ scimBaseUrl: string;
26
+ }
27
+
28
+ export interface IIdpSsoCreateEventDetail {
29
+ displayName: string;
30
+ emailDomains: string[];
31
+ metadataXml?: string;
32
+ idp?: { entityId: string; ssoUrl: string; signingCertificates: string[] };
33
+ provisioningMode: 'jit' | 'scim-only' | 'jit-and-scim';
34
+ defaultOrgRoles: string[];
35
+ groupMappings: { idpGroup: string; orgRoles: string[] }[];
36
+ }
37
+
38
+ export interface IIdpSsoStatusEventDetail {
39
+ connectionId: string;
40
+ status: 'active' | 'disabled';
41
+ }
42
+
43
+ export interface IIdpSsoDeleteEventDetail {
44
+ connectionId: string;
45
+ }
46
+
47
+ declare global {
48
+ interface HTMLElementTagNameMap {
49
+ 'idp-sso-connection-form': IdpSsoConnectionForm;
50
+ }
51
+ }
52
+
53
+ @customElement('idp-sso-connection-form')
54
+ export class IdpSsoConnectionForm extends DeesElement {
55
+ public static demo = () => html`
56
+ <div style="max-width: 720px; display: flex; flex-direction: column; gap: 16px;">
57
+ <idp-sso-connection-form
58
+ .spDetails=${{
59
+ spEntityId: 'https://idp.global/saml/sp/metadata/conn-1',
60
+ acsUrl: 'https://idp.global/saml/acs/conn-1',
61
+ metadataUrl: 'https://idp.global/saml/sp/metadata/conn-1',
62
+ scimBaseUrl: 'https://idp.global/scim/v2',
63
+ } as IIdpSsoSpDetails}
64
+ .connections=${[
65
+ {
66
+ id: 'conn-1',
67
+ displayName: 'Okta (acme.com)',
68
+ status: 'active',
69
+ emailDomains: ['acme.com', 'acme.io'],
70
+ entityId: 'http://www.okta.com/exk9examples',
71
+ ssoUrl: 'https://acme.okta.com/app/xyz/sso/saml',
72
+ certificateCount: 1,
73
+ provisioningMode: 'jit-and-scim',
74
+ defaultOrgRoles: ['member'],
75
+ groupMappings: [{ idpGroup: 'Engineering', orgRoles: ['developer'] }],
76
+ },
77
+ ] as IIdpSsoConnectionItem[]}
78
+ ></idp-sso-connection-form>
79
+ </div>
80
+ `;
81
+ public static demoGroups = ['idp.global v5 admin console'];
82
+
83
+ @property({ type: Array })
84
+ public accessor connections: IIdpSsoConnectionItem[] = [];
85
+
86
+ @property({ type: Object })
87
+ public accessor spDetails: IIdpSsoSpDetails | null = null;
88
+
89
+ @state()
90
+ private accessor formOpen = false;
91
+
92
+ @state()
93
+ private accessor manualIdp = false;
94
+
95
+ @state()
96
+ private accessor formDisplayName = '';
97
+
98
+ @state()
99
+ private accessor formEmailDomains = '';
100
+
101
+ @state()
102
+ private accessor formMetadataXml = '';
103
+
104
+ @state()
105
+ private accessor formEntityId = '';
106
+
107
+ @state()
108
+ private accessor formSsoUrl = '';
109
+
110
+ @state()
111
+ private accessor formCertificate = '';
112
+
113
+ @state()
114
+ private accessor formProvisioningMode: 'jit' | 'scim-only' | 'jit-and-scim' = 'jit';
115
+
116
+ @state()
117
+ private accessor formDefaultRoles = 'member';
118
+
119
+ @state()
120
+ private accessor formGroupMappings = '';
121
+
122
+ @state()
123
+ private accessor formError = '';
124
+
125
+ public static styles = [
126
+ ...idpElementStyles,
127
+ css`
128
+ :host {
129
+ display: block;
130
+ font-family: var(--idp-font);
131
+ color: var(--idp-fg);
132
+ }
133
+ .section {
134
+ background: var(--idp-card);
135
+ border: 1px solid var(--idp-border);
136
+ border-radius: var(--idp-radius);
137
+ padding: 20px;
138
+ }
139
+ .headline {
140
+ display: flex;
141
+ align-items: flex-start;
142
+ justify-content: space-between;
143
+ gap: 16px;
144
+ }
145
+ .title {
146
+ font-size: 14px;
147
+ font-weight: 600;
148
+ }
149
+ .muted {
150
+ font-size: 12.5px;
151
+ color: var(--idp-muted-fg);
152
+ line-height: 1.5;
153
+ margin-top: 3px;
154
+ }
155
+ .sp-grid {
156
+ display: grid;
157
+ grid-template-columns: minmax(90px, auto) 1fr;
158
+ gap: 6px 14px;
159
+ margin-top: 14px;
160
+ padding: 12px;
161
+ background: var(--idp-muted);
162
+ border: 1px solid var(--idp-border);
163
+ border-radius: 10px;
164
+ font-size: 12px;
165
+ }
166
+ .sp-key {
167
+ color: var(--idp-muted-fg);
168
+ white-space: nowrap;
169
+ }
170
+ .sp-value {
171
+ font-family: var(--idp-mono, ui-monospace, monospace);
172
+ overflow-wrap: anywhere;
173
+ }
174
+ .row {
175
+ padding: 14px 0;
176
+ border-bottom: 1px solid var(--idp-border);
177
+ }
178
+ .row:last-of-type {
179
+ border-bottom: none;
180
+ }
181
+ .row-head {
182
+ display: flex;
183
+ align-items: center;
184
+ gap: 10px;
185
+ }
186
+ .row-name {
187
+ font-size: 13px;
188
+ font-weight: 500;
189
+ flex: 1;
190
+ display: flex;
191
+ align-items: center;
192
+ gap: 8px;
193
+ min-width: 0;
194
+ }
195
+ .row-meta {
196
+ font-size: 11px;
197
+ color: var(--idp-muted-fg);
198
+ margin-top: 4px;
199
+ overflow-wrap: anywhere;
200
+ }
201
+ .chip-row {
202
+ display: flex;
203
+ flex-wrap: wrap;
204
+ gap: 6px;
205
+ margin-top: 8px;
206
+ }
207
+ .row-actions {
208
+ display: flex;
209
+ gap: 6px;
210
+ flex: 0 0 auto;
211
+ }
212
+ .table-action {
213
+ background: none;
214
+ border: 1px solid var(--idp-border);
215
+ border-radius: 7px;
216
+ cursor: pointer;
217
+ font-family: inherit;
218
+ font-size: 11px;
219
+ font-weight: 500;
220
+ padding: 4px 10px;
221
+ color: var(--idp-fg);
222
+ }
223
+ .table-action:hover {
224
+ background: var(--idp-muted);
225
+ }
226
+ .table-action.destructive {
227
+ color: var(--idp-destructive);
228
+ border-color: transparent;
229
+ }
230
+ .table-action.destructive:hover {
231
+ background: var(--idp-error-bg);
232
+ }
233
+ .empty {
234
+ padding: 18px 0;
235
+ font-size: 12.5px;
236
+ color: var(--idp-muted-fg);
237
+ }
238
+ .form {
239
+ margin-top: 16px;
240
+ padding-top: 16px;
241
+ border-top: 1px solid var(--idp-border);
242
+ display: flex;
243
+ flex-direction: column;
244
+ gap: 12px;
245
+ }
246
+ .form-grid {
247
+ display: grid;
248
+ grid-template-columns: 1fr 1fr;
249
+ gap: 12px;
250
+ }
251
+ @media (max-width: 720px) {
252
+ .form-grid {
253
+ grid-template-columns: 1fr;
254
+ }
255
+ }
256
+ label.field {
257
+ display: flex;
258
+ flex-direction: column;
259
+ gap: 5px;
260
+ font-size: 12px;
261
+ font-weight: 500;
262
+ }
263
+ textarea,
264
+ select {
265
+ font-family: inherit;
266
+ font-size: 12.5px;
267
+ color: var(--idp-fg);
268
+ background: var(--idp-card);
269
+ border: 1px solid var(--idp-border);
270
+ border-radius: 8px;
271
+ padding: 8px 10px;
272
+ }
273
+ textarea {
274
+ font-family: var(--idp-mono, ui-monospace, monospace);
275
+ min-height: 96px;
276
+ resize: vertical;
277
+ }
278
+ textarea:focus-visible,
279
+ select:focus-visible {
280
+ outline: 2px solid color-mix(in srgb, var(--idp-accent), transparent 68%);
281
+ outline-offset: 1px;
282
+ }
283
+ .hint {
284
+ font-size: 11px;
285
+ font-weight: 400;
286
+ color: var(--idp-muted-fg);
287
+ }
288
+ .toggle-link {
289
+ background: none;
290
+ border: none;
291
+ padding: 0;
292
+ cursor: pointer;
293
+ font-family: inherit;
294
+ font-size: 11.5px;
295
+ color: var(--idp-accent);
296
+ text-align: left;
297
+ }
298
+ .error {
299
+ font-size: 12px;
300
+ color: var(--idp-destructive);
301
+ }
302
+ .form-actions {
303
+ display: flex;
304
+ gap: 8px;
305
+ justify-content: flex-end;
306
+ }
307
+ `,
308
+ ];
309
+
310
+ private parseGroupMappings(rawArg: string): { idpGroup: string; orgRoles: string[] }[] {
311
+ return rawArg
312
+ .split('\n')
313
+ .map((lineArg) => lineArg.trim())
314
+ .filter(Boolean)
315
+ .map((lineArg) => {
316
+ const [groupPart, rolesPart] = lineArg.split('=');
317
+ return {
318
+ idpGroup: (groupPart || '').trim(),
319
+ orgRoles: (rolesPart || '')
320
+ .split(',')
321
+ .map((roleArg) => roleArg.trim())
322
+ .filter(Boolean),
323
+ };
324
+ })
325
+ .filter((mappingArg) => mappingArg.idpGroup && mappingArg.orgRoles.length > 0);
326
+ }
327
+
328
+ private parseList(rawArg: string): string[] {
329
+ return rawArg
330
+ .split(',')
331
+ .map((entryArg) => entryArg.trim())
332
+ .filter(Boolean);
333
+ }
334
+
335
+ private submitCreate() {
336
+ this.formError = '';
337
+ const displayName = this.formDisplayName.trim();
338
+ const emailDomains = this.parseList(this.formEmailDomains).map((domainArg) => domainArg.toLowerCase());
339
+ if (!displayName || emailDomains.length === 0) {
340
+ this.formError = 'Display name and at least one email domain are required.';
341
+ return;
342
+ }
343
+ const detail: IIdpSsoCreateEventDetail = {
344
+ displayName,
345
+ emailDomains,
346
+ provisioningMode: this.formProvisioningMode,
347
+ defaultOrgRoles: this.parseList(this.formDefaultRoles),
348
+ groupMappings: this.parseGroupMappings(this.formGroupMappings),
349
+ };
350
+ if (this.manualIdp) {
351
+ const entityId = this.formEntityId.trim();
352
+ const ssoUrl = this.formSsoUrl.trim();
353
+ const certificate = this.formCertificate.trim();
354
+ if (!entityId || !ssoUrl || !certificate) {
355
+ this.formError = 'Entity ID, SSO URL and a signing certificate are required for manual setup.';
356
+ return;
357
+ }
358
+ detail.idp = { entityId, ssoUrl, signingCertificates: [certificate] };
359
+ } else {
360
+ const metadataXml = this.formMetadataXml.trim();
361
+ if (!metadataXml) {
362
+ this.formError = 'Paste the IdP metadata XML or switch to manual setup.';
363
+ return;
364
+ }
365
+ detail.metadataXml = metadataXml;
366
+ }
367
+ this.dispatchEvent(
368
+ new CustomEvent<IIdpSsoCreateEventDetail>('idp-sso-create', {
369
+ detail,
370
+ bubbles: true,
371
+ composed: true,
372
+ }),
373
+ );
374
+ this.resetForm();
375
+ }
376
+
377
+ private resetForm() {
378
+ this.formOpen = false;
379
+ this.manualIdp = false;
380
+ this.formDisplayName = '';
381
+ this.formEmailDomains = '';
382
+ this.formMetadataXml = '';
383
+ this.formEntityId = '';
384
+ this.formSsoUrl = '';
385
+ this.formCertificate = '';
386
+ this.formProvisioningMode = 'jit';
387
+ this.formDefaultRoles = 'member';
388
+ this.formGroupMappings = '';
389
+ this.formError = '';
390
+ }
391
+
392
+ private readInput(eventArg: Event): string {
393
+ const target = eventArg.composedPath()[0] as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
394
+ return target?.value ?? '';
395
+ }
396
+
397
+ private renderConnectionRow(connectionArg: IIdpSsoConnectionItem): TemplateResult {
398
+ return html`
399
+ <div class="row">
400
+ <div class="row-head">
401
+ <div class="row-name">
402
+ ${connectionArg.displayName}
403
+ <idp-badge variant=${connectionArg.status === 'active' ? 'ok' : 'outline'}>${connectionArg.status}</idp-badge>
404
+ <idp-badge variant="outline">${connectionArg.provisioningMode}</idp-badge>
405
+ </div>
406
+ <div class="row-actions">
407
+ <button
408
+ class="table-action"
409
+ @click=${() =>
410
+ this.dispatchEvent(
411
+ new CustomEvent<IIdpSsoStatusEventDetail>('idp-sso-status', {
412
+ detail: {
413
+ connectionId: connectionArg.id,
414
+ status: connectionArg.status === 'active' ? 'disabled' : 'active',
415
+ },
416
+ bubbles: true,
417
+ composed: true,
418
+ }),
419
+ )}
420
+ >
421
+ ${connectionArg.status === 'active' ? 'Disable' : 'Enable'}
422
+ </button>
423
+ <button
424
+ class="table-action destructive"
425
+ @click=${() =>
426
+ this.dispatchEvent(
427
+ new CustomEvent<IIdpSsoDeleteEventDetail>('idp-sso-delete', {
428
+ detail: { connectionId: connectionArg.id },
429
+ bubbles: true,
430
+ composed: true,
431
+ }),
432
+ )}
433
+ >
434
+ Delete
435
+ </button>
436
+ </div>
437
+ </div>
438
+ <div class="row-meta">${connectionArg.entityId} · ${connectionArg.ssoUrl} · ${connectionArg.certificateCount} cert${connectionArg.certificateCount === 1 ? '' : 's'}</div>
439
+ <div class="chip-row">
440
+ ${connectionArg.emailDomains.map((domainArg) => html`<idp-badge variant="accent">@${domainArg}</idp-badge>`)}
441
+ ${connectionArg.groupMappings.map(
442
+ (mappingArg) => html`<idp-badge variant="outline">${mappingArg.idpGroup} → ${mappingArg.orgRoles.join(', ')}</idp-badge>`,
443
+ )}
444
+ </div>
445
+ </div>
446
+ `;
447
+ }
448
+
449
+ private renderForm(): TemplateResult {
450
+ return html`
451
+ <div class="form">
452
+ <div class="form-grid">
453
+ <idp-input
454
+ label="Display name"
455
+ placeholder="e.g. Okta (acme.com)"
456
+ .value=${this.formDisplayName}
457
+ @input=${(eventArg: Event) => (this.formDisplayName = this.readInput(eventArg))}
458
+ ></idp-input>
459
+ <idp-input
460
+ label="Email domains (comma-separated)"
461
+ placeholder="acme.com, acme.io"
462
+ .value=${this.formEmailDomains}
463
+ @input=${(eventArg: Event) => (this.formEmailDomains = this.readInput(eventArg))}
464
+ ></idp-input>
465
+ </div>
466
+ ${this.manualIdp
467
+ ? html`
468
+ <div class="form-grid">
469
+ <idp-input
470
+ label="IdP entity ID"
471
+ placeholder="http://www.okta.com/exk..."
472
+ .value=${this.formEntityId}
473
+ @input=${(eventArg: Event) => (this.formEntityId = this.readInput(eventArg))}
474
+ ></idp-input>
475
+ <idp-input
476
+ label="IdP SSO URL"
477
+ placeholder="https://acme.okta.com/app/.../sso/saml"
478
+ .value=${this.formSsoUrl}
479
+ @input=${(eventArg: Event) => (this.formSsoUrl = this.readInput(eventArg))}
480
+ ></idp-input>
481
+ </div>
482
+ <label class="field">
483
+ IdP signing certificate (PEM)
484
+ <textarea
485
+ placeholder="-----BEGIN CERTIFICATE-----"
486
+ .value=${this.formCertificate}
487
+ @input=${(eventArg: Event) => (this.formCertificate = this.readInput(eventArg))}
488
+ ></textarea>
489
+ </label>
490
+ `
491
+ : html`
492
+ <label class="field">
493
+ IdP metadata XML
494
+ <span class="hint">Download it from your IdP (Okta: Sign On → SAML metadata; Entra: Federation metadata XML)</span>
495
+ <textarea
496
+ placeholder="<EntityDescriptor ...>"
497
+ .value=${this.formMetadataXml}
498
+ @input=${(eventArg: Event) => (this.formMetadataXml = this.readInput(eventArg))}
499
+ ></textarea>
500
+ </label>
501
+ `}
502
+ <button class="toggle-link" @click=${() => (this.manualIdp = !this.manualIdp)}>
503
+ ${this.manualIdp ? 'Use metadata XML instead' : 'Enter entity ID, SSO URL and certificate manually'}
504
+ </button>
505
+ <div class="form-grid">
506
+ <label class="field">
507
+ Provisioning
508
+ <select
509
+ .value=${this.formProvisioningMode}
510
+ @change=${(eventArg: Event) => (this.formProvisioningMode = this.readInput(eventArg) as typeof this.formProvisioningMode)}
511
+ >
512
+ <option value="jit">Just-in-time (create accounts on first login)</option>
513
+ <option value="scim-only">SCIM only (require provisioning before login)</option>
514
+ <option value="jit-and-scim">JIT + SCIM</option>
515
+ </select>
516
+ </label>
517
+ <idp-input
518
+ label="Default org roles (comma-separated)"
519
+ placeholder="member"
520
+ .value=${this.formDefaultRoles}
521
+ @input=${(eventArg: Event) => (this.formDefaultRoles = this.readInput(eventArg))}
522
+ ></idp-input>
523
+ </div>
524
+ <label class="field">
525
+ Group → role mappings
526
+ <span class="hint">One per line: IdP group = role1, role2</span>
527
+ <textarea
528
+ style="min-height: 64px"
529
+ placeholder="Engineering = developer"
530
+ .value=${this.formGroupMappings}
531
+ @input=${(eventArg: Event) => (this.formGroupMappings = this.readInput(eventArg))}
532
+ ></textarea>
533
+ </label>
534
+ ${this.formError ? html`<div class="error">${this.formError}</div>` : html``}
535
+ <div class="form-actions">
536
+ <idp-button variant="ghost" size="sm" @click=${() => this.resetForm()}>Cancel</idp-button>
537
+ <idp-button variant="accent" size="sm" @click=${() => this.submitCreate()}>Create connection</idp-button>
538
+ </div>
539
+ </div>
540
+ `;
541
+ }
542
+
543
+ public render(): TemplateResult {
544
+ return html`
545
+ <div class="section">
546
+ <div class="headline">
547
+ <div>
548
+ <div class="title">SAML single sign-on</div>
549
+ <div class="muted">
550
+ Let members sign in through your organisation's identity provider. Users entering a
551
+ matching email domain are routed to your IdP automatically.
552
+ </div>
553
+ </div>
554
+ ${!this.formOpen
555
+ ? html`<idp-button variant="accent" size="sm" @click=${() => (this.formOpen = true)}>Add connection</idp-button>`
556
+ : html``}
557
+ </div>
558
+ ${this.spDetails
559
+ ? html`
560
+ <div class="sp-grid">
561
+ <span class="sp-key">SP entity ID</span><span class="sp-value">${this.spDetails.spEntityId}</span>
562
+ <span class="sp-key">ACS URL</span><span class="sp-value">${this.spDetails.acsUrl}</span>
563
+ <span class="sp-key">SP metadata</span><span class="sp-value">${this.spDetails.metadataUrl}</span>
564
+ </div>
565
+ `
566
+ : html``}
567
+ ${this.connections.length
568
+ ? this.connections.map((connectionArg) => this.renderConnectionRow(connectionArg))
569
+ : html`<div class="empty">No SSO connections yet.</div>`}
570
+ ${this.formOpen ? this.renderForm() : html``}
571
+ </div>
572
+ `;
573
+ }
574
+ }
@@ -34,3 +34,5 @@ export * from './idp-admin-shell.js';
34
34
  export * from './idp-landing-hero.js';
35
35
  export * from './idp-landing-page.js';
36
36
  export * from './idp-mobile-showcase.js';
37
+ export * from './idp-sso-connection-form.js';
38
+ export * from './idp-scim-token-panel.js';