@crombolo/backoffice-host 0.13.2 → 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.
Files changed (41) hide show
  1. package/assets/login.js +89 -2
  2. package/package.json +2 -2
  3. package/plugin/assistant/umbraco-package.json +1 -1
  4. package/plugin/branding/branding.js +9 -14
  5. package/plugin/branding/localization-en.js +8 -0
  6. package/plugin/branding/theme.css +2 -0
  7. package/plugin/localizations/ar.js +3 -39
  8. package/plugin/localizations/bs.js +3 -29
  9. package/plugin/localizations/cs.js +3 -24
  10. package/plugin/localizations/cy.js +3 -32
  11. package/plugin/localizations/da.js +3 -41
  12. package/plugin/localizations/de.js +3 -27
  13. package/plugin/localizations/es.js +3 -19
  14. package/plugin/localizations/fr.js +3 -26
  15. package/plugin/localizations/hr.js +3 -29
  16. package/plugin/localizations/it.js +3 -29
  17. package/plugin/localizations/ja.js +3 -11
  18. package/plugin/localizations/ko.js +3 -11
  19. package/plugin/localizations/nb.js +3 -18
  20. package/plugin/localizations/nl.js +3 -29
  21. package/plugin/localizations/pl.js +3 -11
  22. package/plugin/localizations/pt-br.js +0 -10
  23. package/plugin/localizations/pt.js +3 -39
  24. package/plugin/localizations/ru.js +3 -9
  25. package/plugin/localizations/sv.js +3 -20
  26. package/plugin/localizations/tr.js +3 -26
  27. package/plugin/localizations/uk.js +3 -9
  28. package/plugin/localizations/umbraco-package.json +22 -2
  29. package/plugin/localizations/vi.js +3 -42
  30. package/plugin/localizations/zh-tw.js +0 -6
  31. package/plugin/localizations/zh.js +0 -6
  32. package/plugin/seo/umbraco-package.json +1 -1
  33. package/plugin/sign-in/entry.js +20 -0
  34. package/plugin/sign-in/sign-in-dialog.js +127 -0
  35. package/plugin/sign-in/umbraco-package.json +15 -0
  36. package/plugin/two-factor-setup.js +147 -0
  37. package/plugin/umbraco-package.json +27 -27
  38. package/plugin/welcome-dashboard.js +2 -5
  39. package/plugin/workflow-designer.js +245 -170
  40. package/src/shell.ts +4 -1
  41. package/plugin/branding/sysinfo.js +0 -89
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The workflow designer (story 028): stages drawn on a canvas and connected by
3
3
  * arrows, each stage's approvers and send-back beside it, and the workflow's
4
- * own settings above. Saving writes `schema/workflows/<alias>.toml`; before it
4
+ * own settings on a tab of their own. Saving writes `schema/workflows/<alias>.toml`; before it
5
5
  * does, the requests in flight that the change affects are named.
6
6
  *
7
7
  * An arrow that would close a loop is refused as it is drawn. The server holds
@@ -60,13 +60,24 @@ const blank = () => ({
60
60
  ],
61
61
  })
62
62
 
63
- const select = (
64
- value,
65
- options,
66
- onChange,
67
- ) => html`<select @change=${(e) => onChange(e.target.value)}>
68
- ${Object.entries(options).map(([key, label]) => html`<option value=${key} ?selected=${key === value}>${label}</option>`)}
69
- </select>`
63
+ const select = (label, value, options, onChange, disabled) => html`<uui-select
64
+ label=${label}
65
+ ?disabled=${disabled}
66
+ .options=${Object.entries(options).map(([key, name]) => ({ name, value: key, selected: key === value }))}
67
+ @change=${(e) => onChange(e.target.value)}
68
+ ></uui-select>`
69
+
70
+ /** A labelled row, as every backoffice editor lays out a field. */
71
+ const property = (
72
+ label,
73
+ description,
74
+ editor,
75
+ orientation = 'horizontal',
76
+ ) => html`<umb-property-layout
77
+ orientation=${orientation}
78
+ label=${label}
79
+ description=${description ?? ''}
80
+ ><div slot="editor">${editor}</div></umb-property-layout>`
70
81
 
71
82
  export default class CromboloWorkflowDesignerElement extends UmbLitElement {
72
83
  static properties = {
@@ -78,6 +89,7 @@ export default class CromboloWorkflowDesignerElement extends UmbLitElement {
78
89
  _problems: { state: true },
79
90
  _affected: { state: true },
80
91
  _message: { state: true },
92
+ _tab: { state: true },
81
93
  }
82
94
 
83
95
  #drag
@@ -115,6 +127,7 @@ export default class CromboloWorkflowDesignerElement extends UmbLitElement {
115
127
  this._problems = undefined
116
128
  this._affected = undefined
117
129
  this._message = undefined
130
+ this._tab = 'settings'
118
131
  }
119
132
 
120
133
  #touch() {
@@ -309,7 +322,7 @@ export default class CromboloWorkflowDesignerElement extends UmbLitElement {
309
322
  <uui-table-head-cell>Workflow</uui-table-head-cell>
310
323
  <uui-table-head-cell>Covers</uui-table-head-cell>
311
324
  <uui-table-head-cell>Stages</uui-table-head-cell>
312
- <uui-table-head-cell></uui-table-head-cell>
325
+ <uui-table-head-cell class="actions"></uui-table-head-cell>
313
326
  </uui-table-head>
314
327
  ${items.map(
315
328
  (w) => html`<uui-table-row>
@@ -321,7 +334,7 @@ export default class CromboloWorkflowDesignerElement extends UmbLitElement {
321
334
  .filter(Boolean)
322
335
  .join(', ')}</uui-table-cell>
323
336
  <uui-table-cell>${w.stages.length}</uui-table-cell>
324
- <uui-table-cell>
337
+ <uui-table-cell class="actions">
325
338
  <uui-button look="secondary" label=${editable ? 'Edit' : 'View'} @click=${() => this.#edit(w)}></uui-button>
326
339
  ${
327
340
  editable
@@ -340,53 +353,41 @@ export default class CromboloWorkflowDesignerElement extends UmbLitElement {
340
353
  #renderEditor() {
341
354
  const w = this._editing
342
355
  const editable = this._list.editable
343
- const stage = this.#stage(this._selected)
344
356
  const leave = () => {
345
357
  this._editing = undefined
346
358
  this._message = undefined
347
359
  this._problems = undefined
348
360
  }
361
+ const tab = (id, label, icon) => html`<uui-tab
362
+ label=${label}
363
+ ?active=${this._tab === id}
364
+ @click=${() => {
365
+ this._tab = id
366
+ }}
367
+ ><umb-icon slot="icon" name=${icon}></umb-icon>${label}</uui-tab>`
349
368
  // Umbraco's own layout, so Save sits bottom right in the footer as it does
350
- // in every workspace, with Back beside it.
369
+ // in every workspace, with Back beside it, and the tabs top right.
351
370
  return html`<umb-body-layout headline=${w.name || 'Workflow'}>
352
- <uui-box>
353
- ${
354
- editable
355
- ? html`<div class="toolbar">
356
- <uui-button look="secondary" label="Add a stage" @click=${() => this.#addStage()}>Add a stage</uui-button>
357
- <uui-button look=${this._connecting ? 'primary' : 'secondary'} label="Connect stages"
358
- @click=${() => {
359
- this._connecting = this._connecting ? undefined : true
360
- }}
361
- >${this._connecting === true ? 'Click the stage to draw from' : this._connecting ? 'Now click the stage it leads to' : 'Connect stages'}</uui-button>
362
- </div>`
363
- : nothing
364
- }
365
- ${this._message ? html`<p class="message">${this._message}</p>` : nothing}
366
- <p class="hint">Arrows run left to right: the stages an arrow leads to wait for every stage with an arrow into them, and run side by side. Click an arrow to remove it.</p>
367
- <div class="canvas" @pointermove=${(e) => editable && this.#pointerMove(e)} @pointerup=${() => this.#pointerUp()} @pointerleave=${() => this.#pointerUp()}>
368
- ${drawGraph(w.stages, {
369
- selected: this._selected,
370
- onStage: (alias) => this.#onStage(alias),
371
- onArrow: (from, to) => editable && this.#onArrow(from, to),
372
- onPointerDown: (alias, event) => editable && this.#pointerDown(alias, event),
373
- })}
374
- </div>
375
- </uui-box>
376
- <div class="panels">
377
- ${stage ? this.#renderStage(stage, editable) : nothing}
378
- ${this.#renderSettings(w, editable)}
379
- </div>
380
- ${this._problems ? html`<ul class="problems">${this._problems.map((p) => html`<li>${p.path ? html`<code>${p.path}</code> ` : nothing}${p.message}</li>`)}</ul>` : nothing}
371
+ <uui-tab-group slot="navigation">
372
+ ${tab('settings', 'Settings', 'icon-settings')}
373
+ ${tab('stages', 'Stages', 'icon-mindmap')}
374
+ </uui-tab-group>
375
+ ${this._message ? html`<uui-box class="notice"><p class="message">${this._message}</p></uui-box>` : nothing}
376
+ ${
377
+ this._problems
378
+ ? html`<uui-box class="notice" headline="Not saved"><ul class="problems">${this._problems.map((p) => html`<li>${p.path ? html`<code>${p.path}</code> ` : nothing}${p.message}</li>`)}</ul></uui-box>`
379
+ : nothing
380
+ }
381
381
  ${
382
382
  this._affected
383
- ? html`<div class="affected">
383
+ ? html`<uui-box class="notice affected" headline="Pages waiting for approval">
384
384
  <p>Saving changes the rules for ${this._affected.length} page(s) waiting for approval:</p>
385
385
  <ul>${this._affected.map((a) => html`<li>${a.page}: ${ON_CHANGE[a.outcome]}</li>`)}</ul>
386
386
  <uui-button look="primary" label="Save anyway" @click=${() => this.#save(true)}>Save anyway</uui-button>
387
- </div>`
387
+ </uui-box>`
388
388
  : nothing
389
389
  }
390
+ ${this._tab === 'stages' ? this.#renderStages(w, editable) : this.#renderSettings(w, editable)}
390
391
  ${
391
392
  this._problems
392
393
  ? html`<span slot="footer-info" class="not-saved">Not saved: ${this._problems.length} problem(s), listed above</span>`
@@ -401,163 +402,237 @@ export default class CromboloWorkflowDesignerElement extends UmbLitElement {
401
402
  </umb-body-layout>`
402
403
  }
403
404
 
405
+ #renderStages(w, editable) {
406
+ const stage = this.#stage(this._selected)
407
+ // One box, two columns: the graph, and the stage selected in it.
408
+ return html`<uui-box headline="Stages">
409
+ ${
410
+ editable
411
+ ? html`<div slot="header-actions" class="toolbar">
412
+ <uui-button look="secondary" compact label="Add a stage" @click=${() => this.#addStage()}>Add a stage</uui-button>
413
+ <uui-button look=${this._connecting ? 'primary' : 'secondary'} compact label="Connect stages"
414
+ @click=${() => {
415
+ this._connecting = this._connecting ? undefined : true
416
+ }}
417
+ >${this._connecting === true ? 'Click the stage to draw from' : this._connecting ? 'Now click the stage it leads to' : 'Connect stages'}</uui-button>
418
+ </div>`
419
+ : nothing
420
+ }
421
+ <div class="stages">
422
+ <div class="graph">
423
+ <p class="hint">Arrows run left to right: the stages an arrow leads to wait for every stage with an arrow into them, and run side by side. Click an arrow to remove it.</p>
424
+ <div class="canvas" @pointermove=${(e) => editable && this.#pointerMove(e)} @pointerup=${() => this.#pointerUp()} @pointerleave=${() => this.#pointerUp()}>
425
+ ${drawGraph(w.stages, {
426
+ selected: this._selected,
427
+ onStage: (alias) => this.#onStage(alias),
428
+ onArrow: (from, to) => editable && this.#onArrow(from, to),
429
+ onPointerDown: (alias, event) => editable && this.#pointerDown(alias, event),
430
+ })}
431
+ </div>
432
+ </div>
433
+ <div class="stage">
434
+ ${stage ? this.#renderStage(stage, editable) : html`<p class="hint">Select a stage to change it.</p>`}
435
+ </div>
436
+ </div>
437
+ </uui-box>`
438
+ }
439
+
404
440
  #renderStage(stage, editable) {
405
441
  const earlier = [...ancestors(this._editing.stages, stage.alias)]
406
442
  const requiredKind = typeof stage.required === 'number' ? 'number' : stage.required
407
- return html`<uui-box headline=${`Stage: ${stage.name || stage.alias}`}>
408
- <fieldset ?disabled=${!editable}>
409
- <label>Name <uui-input label="Name" .value=${stage.name ?? ''} @change=${(e) => {
443
+ return html`<div class="stage-header">
444
+ <h4>${stage.name || stage.alias}</h4>
445
+ ${
446
+ editable && this._editing.stages.length > 1
447
+ ? html`<uui-button look="secondary" color="danger" compact label="Remove this stage" @click=${() => this.#removeStage(stage.alias)}>Remove</uui-button>`
448
+ : nothing
449
+ }
450
+ </div>
451
+ ${property(
452
+ 'Name',
453
+ undefined,
454
+ html`<uui-input label="Name" ?readonly=${!editable} .value=${stage.name ?? ''} @change=${(
455
+ e,
456
+ ) => {
410
457
  stage.name = e.target.value
411
458
  this.#touch()
412
- }}></uui-input></label>
413
- <label>Alias <uui-input label="Alias" .value=${stage.alias} @change=${(e) => this.#renameStage(stage, e.target.value)}></uui-input></label>
414
- <div class="field"><span>Approved by</span>
415
- ${this._list.groups.map(
416
- (g) =>
417
- html`<label class="check"><input type="checkbox" .checked=${stage.approvers.includes(g.alias)} @change=${(
459
+ }}></uui-input>`,
460
+ 'vertical',
461
+ )}
462
+ ${property(
463
+ 'Alias',
464
+ 'How the stage is named in the file and in other stages',
465
+ html`<uui-input label="Alias" ?readonly=${!editable} .value=${stage.alias} @change=${(e) => this.#renameStage(stage, e.target.value)}></uui-input>`,
466
+ 'vertical',
467
+ )}
468
+ ${property(
469
+ 'Approved by',
470
+ 'The user groups whose members decide this stage',
471
+ html`<div class="checks">${this._list.groups.map(
472
+ (g) =>
473
+ html`<uui-checkbox label=${g.name} ?disabled=${!editable} ?checked=${stage.approvers.includes(g.alias)} @change=${(
474
+ e,
475
+ ) => {
476
+ stage.approvers = e.target.checked
477
+ ? [...stage.approvers, g.alias]
478
+ : stage.approvers.filter((a) => a !== g.alias)
479
+ this.#touch()
480
+ }}>${g.name}</uui-checkbox>`,
481
+ )}</div>`,
482
+ 'vertical',
483
+ )}
484
+ ${property(
485
+ 'How many must approve',
486
+ undefined,
487
+ html`${select(
488
+ 'How many must approve',
489
+ requiredKind,
490
+ {
491
+ one: 'Any one of them',
492
+ majority: 'A majority',
493
+ all: 'All of them',
494
+ number: 'A number of them',
495
+ },
496
+ (value) => {
497
+ stage.required = value === 'number' ? 2 : value
498
+ this.#touch()
499
+ },
500
+ !editable,
501
+ )}
502
+ ${
503
+ typeof stage.required === 'number'
504
+ ? html`<uui-input class="count" type="number" min="1" label="How many" ?readonly=${!editable} .value=${String(stage.required)} @change=${(
418
505
  e,
419
506
  ) => {
420
- stage.approvers = e.target.checked
421
- ? [...stage.approvers, g.alias]
422
- : stage.approvers.filter((a) => a !== g.alias)
507
+ stage.required = Math.max(1, Number(e.target.value) || 1)
423
508
  this.#touch()
424
- }} /> ${g.name}</label>`,
425
- )}
426
- </div>
427
- <label>How many must approve
428
- ${select(
429
- requiredKind,
430
- {
431
- one: 'Any one of them',
432
- majority: 'A majority',
433
- all: 'All of them',
434
- number: 'A number of them',
435
- },
436
- (value) => {
437
- stage.required = value === 'number' ? 2 : value
438
- this.#touch()
439
- },
440
- )}
441
- ${
442
- typeof stage.required === 'number'
443
- ? html`<input type="number" min="1" .value=${String(stage.required)} @change=${(
444
- e,
445
- ) => {
446
- stage.required = Math.max(1, Number(e.target.value) || 1)
447
- this.#touch()
448
- }} />`
449
- : nothing
450
- }
451
- </label>
452
- <label>Sending back goes to
453
- ${select(
454
- stage.sendBack,
455
- {
456
- editor: 'The editor (the request ends)',
457
- ...Object.fromEntries(earlier.map((a) => [a, `The ${a} stage`])),
458
- },
459
- (value) => {
460
- stage.sendBack = value
461
- this.#touch()
462
- },
463
- )}
464
- </label>
465
- ${
466
- editable && this._editing.stages.length > 1
467
- ? html`<uui-button look="secondary" color="danger" label="Remove stage" @click=${() => this.#removeStage(stage.alias)}>Remove this stage</uui-button>`
509
+ }}></uui-input>`
468
510
  : nothing
469
- }
470
- </fieldset>
471
- </uui-box>`
511
+ }`,
512
+ 'vertical',
513
+ )}
514
+ ${property(
515
+ 'Sending back goes to',
516
+ undefined,
517
+ select(
518
+ 'Sending back goes to',
519
+ stage.sendBack,
520
+ {
521
+ editor: 'The editor (the request ends)',
522
+ ...Object.fromEntries(
523
+ earlier.map((a) => [a, `The ${this.#stage(a)?.name || a} stage`]),
524
+ ),
525
+ },
526
+ (value) => {
527
+ stage.sendBack = value
528
+ this.#touch()
529
+ },
530
+ !editable,
531
+ ),
532
+ 'vertical',
533
+ )}`
472
534
  }
473
535
 
474
536
  #renderSettings(w, editable) {
475
- return html`<uui-box headline="Settings">
476
- <fieldset ?disabled=${!editable}>
477
- <label>Name <uui-input label="Name" .value=${w.name} @change=${(e) => {
478
- w.name = e.target.value
479
- this.#touch()
480
- }}></uui-input></label>
481
- <label>Alias <uui-input label="Alias" .value=${w.alias} @change=${(e) => {
482
- w.alias = e.target.value.trim()
483
- this.#touch()
484
- }}></uui-input></label>
485
- <div class="field"><span>Covers the pages under</span>
486
- <umb-input-document
537
+ const set = (key) => (value) => {
538
+ w[key] = value
539
+ this.#touch()
540
+ }
541
+ return html`<div class="settings">
542
+ ${editable ? nothing : html`<uui-box class="notice"><p>Workflows are kept with the site's files, which cannot be changed on this server.</p></uui-box>`}
543
+ <uui-box headline="Workflow">
544
+ ${property(
545
+ 'Name',
546
+ undefined,
547
+ html`<uui-input label="Name" ?readonly=${!editable} .value=${w.name} @change=${(e) => set('name')(e.target.value)}></uui-input>`,
548
+ )}
549
+ ${property(
550
+ 'Alias',
551
+ 'The name of its file',
552
+ html`<uui-input label="Alias" ?readonly=${!editable} .value=${w.alias} @change=${(e) => set('alias')(e.target.value.trim())}></uui-input>`,
553
+ )}
554
+ ${property(
555
+ 'Covers the pages under',
556
+ 'Each page and everything below it',
557
+ html`<umb-input-document
558
+ ?readonly=${!editable}
487
559
  .selection=${w.appliesTo.branches}
488
560
  @change=${(e) => {
489
561
  w.appliesTo.branches = [...(e.target.selection ?? [])]
490
562
  this.#touch()
491
563
  }}
492
- ></umb-input-document>
493
- </div>
494
- <div class="field"><span>And every page of these types</span>
495
- ${this._list.contentTypes.map(
564
+ ></umb-input-document>`,
565
+ )}
566
+ ${property(
567
+ 'And every page of these types',
568
+ 'Wherever they are, unless a branch above is covered by another workflow',
569
+ html`<div class="checks">${this._list.contentTypes.map(
496
570
  (t) =>
497
- html`<label class="check"><input type="checkbox" .checked=${w.appliesTo.contentTypes.includes(t.alias)} @change=${(
571
+ html`<uui-checkbox label=${t.name} ?disabled=${!editable} ?checked=${w.appliesTo.contentTypes.includes(t.alias)} @change=${(
498
572
  e,
499
573
  ) => {
500
574
  w.appliesTo.contentTypes = e.target.checked
501
575
  ? [...w.appliesTo.contentTypes, t.alias]
502
576
  : w.appliesTo.contentTypes.filter((a) => a !== t.alias)
503
577
  this.#touch()
504
- }} /> ${t.name}</label>`,
505
- )}
506
- </div>
507
- <label class="check"><input type="checkbox" .checked=${w.publishersMaySkip} @change=${(
508
- e,
509
- ) => {
510
- w.publishersMaySkip = e.target.checked
511
- this.#touch()
512
- }} /> People who may publish can publish directly</label>
513
- <label class="check"><input type="checkbox" .checked=${w.unpublish} @change=${(e) => {
514
- w.unpublish = e.target.checked
515
- this.#touch()
516
- }} /> Taking a page down needs approval too</label>
517
- <label>When a waiting page is changed ${select(w.onEdit, ON_EDIT, (v) => {
518
- w.onEdit = v
519
- this.#touch()
520
- })}</label>
521
- <label>When the person who made the change could approve it ${select(
522
- w.selfApproval,
523
- SELF,
524
- (v) => {
525
- w.selfApproval = v
526
- this.#touch()
527
- },
528
- )}</label>
529
- <label>Comments ${select(w.comments, COMMENTS, (v) => {
530
- w.comments = v
531
- this.#touch()
532
- })}</label>
533
- <label>When this workflow changes, pages waiting on it ${select(
534
- w.onChange,
535
- ON_CHANGE,
536
- (v) => {
537
- w.onChange = v
538
- this.#touch()
539
- },
540
- )}</label>
541
- </fieldset>
542
- </uui-box>`
578
+ }}>${t.name}</uui-checkbox>`,
579
+ )}</div>`,
580
+ )}
581
+ </uui-box>
582
+ <uui-box headline="Rules">
583
+ ${property(
584
+ 'Publishers may skip it',
585
+ 'People who may publish a page publish it directly',
586
+ html`<uui-toggle label="Publishers may skip it" ?disabled=${!editable} ?checked=${w.publishersMaySkip} @change=${(e) => set('publishersMaySkip')(e.target.checked)}></uui-toggle>`,
587
+ )}
588
+ ${property(
589
+ 'Unpublishing needs approval',
590
+ 'Taking a page down goes through the workflow too',
591
+ html`<uui-toggle label="Unpublishing needs approval" ?disabled=${!editable} ?checked=${w.unpublish} @change=${(e) => set('unpublish')(e.target.checked)}></uui-toggle>`,
592
+ )}
593
+ ${property('When a waiting page is changed', undefined, select('When a waiting page is changed', w.onEdit, ON_EDIT, set('onEdit'), !editable))}
594
+ ${property(
595
+ 'When the person who made the change could approve it',
596
+ undefined,
597
+ select('Self-approval', w.selfApproval, SELF, set('selfApproval'), !editable),
598
+ )}
599
+ ${property('Comments on a decision', undefined, select('Comments', w.comments, COMMENTS, set('comments'), !editable))}
600
+ ${property(
601
+ 'When this workflow changes',
602
+ 'What happens to pages already waiting on it',
603
+ select('When this workflow changes', w.onChange, ON_CHANGE, set('onChange'), !editable),
604
+ )}
605
+ </uui-box>
606
+ </div>`
543
607
  }
544
608
 
545
609
  static styles = css`
546
- :host { display: block; height: 100%; }
610
+ :host { display: block; height: 100%; container-type: inline-size; }
547
611
  .list { padding: var(--uui-size-layout-1); }
548
612
  .create { margin-bottom: var(--uui-size-space-5); }
549
- .toolbar { display: flex; flex-wrap: wrap; gap: var(--uui-size-space-3); margin-bottom: var(--uui-size-space-4); }
550
- .canvas { overflow: auto; border: 1px dashed var(--uui-color-border); border-radius: 6px; padding: var(--uui-size-space-5); min-height: 200px; touch-action: none; }
551
- .panels { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: var(--uui-size-space-5); margin-top: var(--uui-size-space-5); }
552
- fieldset { border: 0; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--uui-size-space-4); }
553
- label, .field { display: flex; flex-direction: column; gap: var(--uui-size-space-2); }
554
- label.check { flex-direction: row; align-items: center; }
555
- .field > span { font-weight: 700; }
556
- .hint { opacity: 0.8; font-size: 0.9em; }
557
- .message { color: var(--uui-color-warning-emphasis, #9e7300); }
558
- .problems { color: var(--uui-color-danger); margin-top: var(--uui-size-space-5); }
613
+ .actions { text-align: right; white-space: nowrap; }
614
+ uui-tab-group { --uui-tab-divider: var(--uui-color-border); border-left: 1px solid var(--uui-color-border); border-right: 1px solid var(--uui-color-border); }
615
+ .notice { margin-bottom: var(--uui-size-layout-1); }
616
+ .toolbar { display: flex; flex-wrap: wrap; gap: var(--uui-size-space-3); }
617
+ .stages { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 380px); gap: var(--uui-size-layout-1); }
618
+ .stage { border-left: 1px solid var(--uui-color-border); padding-left: var(--uui-size-layout-1); }
619
+ .stage-header { display: flex; align-items: center; justify-content: space-between; gap: var(--uui-size-space-3); margin-bottom: var(--uui-size-space-3); }
620
+ .stage-header h4 { margin: 0; }
621
+ @container (max-width: 640px) {
622
+ .stages { grid-template-columns: minmax(0, 1fr); }
623
+ .stage { border-left: 0; padding-left: 0; border-top: 1px solid var(--uui-color-border); padding-top: var(--uui-size-layout-1); }
624
+ }
625
+ .settings { display: flex; flex-direction: column; gap: var(--uui-size-layout-1); }
626
+ .canvas { overflow: auto; border: 1px dashed var(--uui-color-border); border-radius: var(--uui-border-radius); padding: var(--uui-size-space-5); min-height: 240px; touch-action: none; }
627
+ umb-property-layout:first-of-type { padding-top: 0; }
628
+ uui-input, uui-select { width: 100%; }
629
+ uui-input.count { margin-top: var(--uui-size-space-3); max-width: 8em; }
630
+ .checks { display: flex; flex-direction: column; gap: var(--uui-size-space-2); }
631
+ .hint { margin-top: 0; color: var(--uui-color-text-alt); }
632
+ .message { margin: 0; color: var(--uui-color-warning-emphasis, #9e7300); }
633
+ .problems { color: var(--uui-color-danger); margin: 0; }
559
634
  .not-saved { color: var(--uui-color-danger); }
560
- .affected { margin-top: var(--uui-size-space-5); border: 1px solid var(--uui-color-warning); padding: var(--uui-size-space-4); border-radius: 6px; margin-bottom: var(--uui-size-space-4); }
635
+ .affected { border: 1px solid var(--uui-color-warning); }
561
636
  ${unsafeCSS(GRAPH_STYLES)}
562
637
  `
563
638
  }
package/src/shell.ts CHANGED
@@ -34,6 +34,8 @@ export interface LoginShellSettings {
34
34
  * the browser resumes the OAuth flow rather than landing on a blank editor.
35
35
  */
36
36
  returnUrl?: string
37
+ /** "Sign in with …" buttons, one per backoffice identity provider (story 031). */
38
+ externalProviders?: ReadonlyArray<{ name: string; url: string }>
37
39
  }
38
40
 
39
41
  const escapeHtml = (value: string): string =>
@@ -131,7 +133,8 @@ ${noscript(`${graphics}/login-logo-alternative`)}
131
133
  ${settings.usernameIsEmail ? 'username-is-email' : ''}
132
134
  ${settings.allowUserInvite ? 'allow-user-invite' : ''}
133
135
  ${settings.allowPasswordReset ? 'allow-password-reset' : ''}
134
- ${settings.disableLocalLogin ? 'disable-local-login' : ''}>
136
+ ${settings.disableLocalLogin ? 'disable-local-login' : ''}
137
+ external-providers="${escapeHtml(JSON.stringify(settings.externalProviders ?? []))}">
135
138
  </crombolo-login>
136
139
  </body>
137
140
  </html>
@@ -1,89 +0,0 @@
1
- /**
2
- * The System information modal, reached from the header logo popover.
3
- *
4
- * It names the product in four places that no localisation key covers: three
5
- * row labels built as plain strings in `sysinfo.element.js`, and the heading it
6
- * puts above the text it copies to the clipboard.
7
- *
8
- * The rows are corrected at their source rather than in the DOM. The element
9
- * assembles every row into one string and assigns it to `_systemInformation`, a
10
- * lit reactive property, so wrapping that property's setter corrects the rows
11
- * before they are ever rendered — no observer, and no reaching into a shadow
12
- * root. The clipboard heading is prepended inside a private method that nothing
13
- * can reach, so that one is taken on the way out to the clipboard instead.
14
- */
15
-
16
- /**
17
- * The exact phrases to correct. Spelled out rather than replacing the product
18
- * name wherever it appears, because the same text carries the browser's user
19
- * agent and the current URL — a blanket replacement would rewrite whatever a
20
- * site happens to have in its address.
21
- */
22
- const PHRASES = [
23
- 'Umbraco build version',
24
- 'Umbraco assembly version',
25
- 'Umbraco client version',
26
- 'Umbraco system information',
27
- ]
28
-
29
- /** A fixed point: its own output contains none of the phrases, so re-applying it changes nothing. */
30
- export function rebrandSysinfo(text) {
31
- if (typeof text !== 'string') return text
32
- let corrected = text
33
- for (const phrase of PHRASES) {
34
- corrected = corrected.replaceAll(phrase, phrase.replace('Umbraco', 'Crombolo'))
35
- }
36
- return corrected
37
- }
38
-
39
- /** True when the string is the modal's clipboard payload rather than someone's own copy. */
40
- export function isSysinfoClipboardText(text) {
41
- return typeof text === 'string' && text.includes('Umbraco system information')
42
- }
43
-
44
- /**
45
- * Wraps the reactive property's setter. Does nothing at all if the accessor is
46
- * not where lit puts it, so an upstream change costs the correction and never
47
- * an error.
48
- */
49
- function patchRows(element) {
50
- const accessor = Object.getOwnPropertyDescriptor(element?.prototype ?? {}, '_systemInformation')
51
- if (!accessor?.get || !accessor?.set) return false
52
- Object.defineProperty(element.prototype, '_systemInformation', {
53
- ...accessor,
54
- get() {
55
- return accessor.get.call(this)
56
- },
57
- set(value) {
58
- accessor.set.call(this, rebrandSysinfo(value))
59
- },
60
- })
61
- return true
62
- }
63
-
64
- /**
65
- * Corrects the heading on its way to the clipboard. Installed only once the
66
- * modal's element exists — so a session that never opens System information
67
- * never patches anything — and it passes every other string through untouched.
68
- */
69
- function patchClipboard() {
70
- const clipboard = navigator.clipboard
71
- if (typeof clipboard?.writeText !== 'function') return
72
- const write = clipboard.writeText.bind(clipboard)
73
- try {
74
- clipboard.writeText = (text) =>
75
- write(isSysinfoClipboardText(text) ? rebrandSysinfo(text) : text)
76
- } catch {
77
- // A read-only `writeText` leaves the copied heading stale, which is the
78
- // one thing here worth less than a working copy button.
79
- }
80
- }
81
-
82
- export const onInit = () => {
83
- customElements
84
- .whenDefined('umb-sysinfo')
85
- .then((element) => {
86
- if (patchRows(element)) patchClipboard()
87
- })
88
- .catch(() => {})
89
- }