@things-factory/auth-ui 10.1.194 → 10.1.202

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 (65) hide show
  1. package/dist-client/bootstrap.js +4 -1
  2. package/dist-client/bootstrap.js.map +1 -1
  3. package/dist-client/components/access-decision-popup.js +7 -5
  4. package/dist-client/components/access-decision-popup.js.map +1 -1
  5. package/dist-client/components/access-request-popup.d.ts +9 -1
  6. package/dist-client/components/access-request-popup.js +55 -4
  7. package/dist-client/components/access-request-popup.js.map +1 -1
  8. package/dist-client/components/my-login-history.js +14 -10
  9. package/dist-client/components/my-login-history.js.map +1 -1
  10. package/dist-client/components/role-task-edit.d.ts +22 -0
  11. package/dist-client/components/role-task-edit.js +17 -0
  12. package/dist-client/components/role-task-edit.js.map +1 -0
  13. package/dist-client/components/role-task-edit.test.d.ts +1 -0
  14. package/dist-client/components/role-task-edit.test.js +34 -0
  15. package/dist-client/components/role-task-edit.test.js.map +1 -0
  16. package/dist-client/components/role-template-picker.d.ts +5 -0
  17. package/dist-client/components/role-template-picker.js +41 -5
  18. package/dist-client/components/role-template-picker.js.map +1 -1
  19. package/dist-client/components/user-status-renderer.d.ts +9 -0
  20. package/dist-client/components/user-status-renderer.js +36 -0
  21. package/dist-client/components/user-status-renderer.js.map +1 -0
  22. package/dist-client/entity360/person-360.d.ts +13 -0
  23. package/dist-client/entity360/person-360.js +96 -0
  24. package/dist-client/entity360/person-360.js.map +1 -1
  25. package/dist-client/index.d.ts +1 -0
  26. package/dist-client/index.js +14 -1
  27. package/dist-client/index.js.map +1 -1
  28. package/dist-client/pages/access-request/access-requests.js +4 -2
  29. package/dist-client/pages/access-request/access-requests.js.map +1 -1
  30. package/dist-client/pages/access-request/my-access-requests.js +3 -2
  31. package/dist-client/pages/access-request/my-access-requests.js.map +1 -1
  32. package/dist-client/pages/audit/audit-page.d.ts +46 -0
  33. package/dist-client/pages/audit/audit-page.js +417 -0
  34. package/dist-client/pages/audit/audit-page.js.map +1 -0
  35. package/dist-client/pages/role/role-privilege-page.d.ts +46 -0
  36. package/dist-client/pages/role/role-privilege-page.js +500 -5
  37. package/dist-client/pages/role/role-privilege-page.js.map +1 -1
  38. package/dist-client/pages/role/role-task-review.test.d.ts +1 -0
  39. package/dist-client/pages/role/role-task-review.test.js +60 -0
  40. package/dist-client/pages/role/role-task-review.test.js.map +1 -0
  41. package/dist-client/pages/user/user-list-page.js +1 -31
  42. package/dist-client/pages/user/user-list-page.js.map +1 -1
  43. package/dist-client/route.js +3 -0
  44. package/dist-client/route.js.map +1 -1
  45. package/dist-client/tsconfig.tsbuildinfo +1 -1
  46. package/dist-client/utils/access-request.d.ts +15 -1
  47. package/dist-client/utils/access-request.js +19 -2
  48. package/dist-client/utils/access-request.js.map +1 -1
  49. package/dist-client/utils/access-request.test.js +26 -1
  50. package/dist-client/utils/access-request.test.js.map +1 -1
  51. package/dist-client/utils/audit-words.d.ts +30 -0
  52. package/dist-client/utils/audit-words.js +57 -0
  53. package/dist-client/utils/audit-words.js.map +1 -0
  54. package/dist-client/utils/audit-words.test.d.ts +1 -0
  55. package/dist-client/utils/audit-words.test.js +47 -0
  56. package/dist-client/utils/audit-words.test.js.map +1 -0
  57. package/dist-server/tsconfig.tsbuildinfo +1 -1
  58. package/package.json +4 -4
  59. package/things-factory.config.js +1 -0
  60. package/translations/en.json +78 -1
  61. package/translations/es.json +78 -1
  62. package/translations/ja.json +78 -1
  63. package/translations/ko.json +78 -1
  64. package/translations/ms.json +78 -1
  65. package/translations/zh.json +78 -1
@@ -9,10 +9,13 @@ import { client, gqlContext } from '@operato/graphql';
9
9
  import { i18next, localize } from '@operato/i18n';
10
10
  import { KNOWN_AXES, axisLabel } from '../../utils/privilege-axis.js';
11
11
  import { openPopup } from '@operato/layout';
12
+ import { OxPrompt } from '@operato/popup/ox-prompt.js';
12
13
  import { PageView } from '@operato/shell';
13
14
  import { CommonHeaderStyles, ScrollbarStyles, WorkSurfacePageStyles } from '@operato/styles';
14
15
  import { getDomain } from '@things-factory/auth-base/dist-client';
15
16
  import { filterPrivilegeModules, selectedVisibleModule } from '../../components/role-privilege-picker-state.js';
17
+ import { pairOf, pairsToAdd, pairsToRemove } from '../../components/role-task-edit.js';
18
+ const GRANT_FIELDS = 'category axes';
16
19
  /**
17
20
  * 한 역할이 무엇을 할 수 있나 — **켜진 것이 문장으로 본문에 선다.**
18
21
  *
@@ -61,6 +64,14 @@ let RolePrivilegePage = class RolePrivilegePage extends localize(i18next)(PageVi
61
64
  this.loadError = null;
62
65
  this.saving = false;
63
66
  this.dirty = false;
67
+ this.view = 'privileges';
68
+ /** The role laid against every task - computed by the server from the saved privileges (`Role.taskCoverage`). */
69
+ this.coverage = { tasks: [], unclassified: [] };
70
+ this.showUnheldTasks = false;
71
+ /** null until asked for; [] when this person may not read templates (`roleTemplates` asks user:mutation). */
72
+ this.templates = null;
73
+ this.templateId = '';
74
+ this.gap = null;
64
75
  }
65
76
  static { this.styles = [
66
77
  /* The frame owns the page shell; the two panes are the content it sizes (ADR-0060 decision 8). */
@@ -447,6 +458,157 @@ let RolePrivilegePage = class RolePrivilegePage extends localize(i18next)(PageVi
447
458
  color: var(--md-sys-color-on-surface-variant);
448
459
  font-size: 0.85rem;
449
460
  }
461
+
462
+ /*
463
+ The three readings of one role - quiet text buttons in the panel's header line, the chosen one underlined.
464
+ They change what the panel shows, not what the role holds, so they are not boxed like an action.
465
+ */
466
+ [granted] > header nav {
467
+ margin-left: auto;
468
+ display: flex;
469
+ gap: 2px;
470
+ }
471
+
472
+ [granted] > header nav button {
473
+ padding: 2px 8px;
474
+ border: none;
475
+ border-bottom: 2px solid transparent;
476
+ background: none;
477
+ font: inherit;
478
+ font-size: 0.8rem;
479
+ color: var(--md-sys-color-on-surface-variant);
480
+ cursor: pointer;
481
+ }
482
+
483
+ [granted] > header nav button[selected] {
484
+ border-bottom-color: var(--md-sys-color-primary);
485
+ color: var(--md-sys-color-on-surface);
486
+ font-weight: 600;
487
+ }
488
+
489
+ [review] {
490
+ flex: 1;
491
+ min-height: 0;
492
+ overflow: auto;
493
+ padding: 0 12px 12px;
494
+ }
495
+
496
+ [review] h4 {
497
+ display: flex;
498
+ align-items: baseline;
499
+ gap: 8px;
500
+ margin: 14px 0 4px;
501
+ font-size: 0.85rem;
502
+ font-weight: 600;
503
+ color: var(--md-sys-color-on-surface-variant);
504
+ }
505
+
506
+ [review] h4 [count] {
507
+ font-weight: 400;
508
+ color: var(--md-sys-color-outline);
509
+ }
510
+
511
+ ul[tasks] {
512
+ list-style: none;
513
+ margin: 0;
514
+ padding: 0;
515
+ }
516
+
517
+ ul[tasks] > li {
518
+ padding: 9px 2px;
519
+ border-bottom: 1px solid var(--md-sys-color-surface-variant);
520
+ }
521
+
522
+ ul[tasks] > li:last-child {
523
+ border-bottom: none;
524
+ }
525
+
526
+ ul[tasks] [task-name] {
527
+ font-size: 0.95rem;
528
+ font-weight: 600;
529
+ }
530
+
531
+ ul[tasks] [task-says],
532
+ ul[tasks] [task-missing] {
533
+ margin-top: 2px;
534
+ font-size: 0.85rem;
535
+ line-height: 1.55;
536
+ color: var(--md-sys-color-on-surface-variant);
537
+ }
538
+
539
+ ul[tasks] [task-missing] {
540
+ color: var(--md-sys-color-outline);
541
+ }
542
+
543
+ ul[tasks][quiet] > li {
544
+ padding: 5px 2px;
545
+ font-size: 0.88rem;
546
+ color: var(--md-sys-color-on-surface-variant);
547
+ }
548
+
549
+ [state] {
550
+ margin-left: 8px;
551
+ padding: 0 6px;
552
+ border-radius: var(--md-sys-shape-corner-full, 999px);
553
+ font-size: 0.75rem;
554
+ font-weight: 400;
555
+ background-color: var(--md-sys-color-surface-container-highest);
556
+ color: var(--md-sys-color-on-surface-variant);
557
+ }
558
+
559
+ [state][all] {
560
+ background-color: var(--md-sys-color-secondary-container);
561
+ color: var(--md-sys-color-on-secondary-container);
562
+ }
563
+
564
+ [state][broken] {
565
+ background-color: var(--md-sys-color-error-container);
566
+ color: var(--md-sys-color-on-error-container);
567
+ }
568
+
569
+ ul[sentences] {
570
+ list-style: none;
571
+ margin: 0;
572
+ padding: 0;
573
+ }
574
+
575
+ ul[sentences] li {
576
+ padding: 5px 2px;
577
+ font-size: 0.9rem;
578
+ line-height: 1.5;
579
+ }
580
+
581
+ button[quiet] {
582
+ padding: 4px 0;
583
+ border: none;
584
+ background: none;
585
+ font: inherit;
586
+ font-size: 0.85rem;
587
+ color: var(--md-sys-color-primary);
588
+ cursor: pointer;
589
+ }
590
+
591
+ button[quiet]:disabled {
592
+ color: var(--md-sys-color-outline);
593
+ cursor: default;
594
+ }
595
+
596
+ [review] select {
597
+ margin-top: 10px;
598
+ padding: 4px 6px;
599
+ font: inherit;
600
+ font-size: 0.88rem;
601
+ color: var(--md-sys-color-on-surface);
602
+ background-color: var(--md-sys-color-surface);
603
+ border: 1px solid var(--md-sys-color-outline-variant);
604
+ border-radius: var(--md-sys-shape-corner-small, 6px);
605
+ }
606
+
607
+ [review] [stale] {
608
+ margin-top: 10px;
609
+ font-size: 0.82rem;
610
+ color: var(--md-sys-color-outline);
611
+ }
450
612
  `
451
613
  ]; }
452
614
  /**
@@ -600,17 +762,304 @@ let RolePrivilegePage = class RolePrivilegePage extends localize(i18next)(PageVi
600
762
  <header>
601
763
  ${i18next.t('field.granted privileges')}
602
764
  <span count>${granted.length}</span>
765
+ <nav>
766
+ ${this.viewButton('privileges', i18next.t('label.privileges view'))} ${this.viewButton('tasks', i18next.t('label.tasks view'))}
767
+ ${this.viewButton('template', i18next.t('label.template view'))}
768
+ </nav>
603
769
  </header>
604
770
 
605
- ${granted.length
606
- ? html `<ul lines>
607
- ${granted.map(({ category, cell }) => this.renderGrantedLine(category, cell))}
771
+ ${this.view === 'tasks'
772
+ ? this.renderTasks()
773
+ : this.view === 'template'
774
+ ? this.renderTemplate()
775
+ : html `${granted.length
776
+ ? html `<ul lines>
777
+ ${granted.map(({ category, cell }) => this.renderGrantedLine(category, cell))}
778
+ </ul>`
779
+ : html `<div empty>${i18next.t('text.nothing is on for this role yet')}</div>`}
780
+ ${this.renderAdd()}`}
781
+ </div>
782
+ `;
783
+ }
784
+ viewButton(view, label) {
785
+ return html `<button ?selected=${this.view === view} @click=${() => this.showView(view)}>${label}</button>`;
786
+ }
787
+ async showView(view) {
788
+ this.view = view;
789
+ if (view === 'template' && this.templates === null) {
790
+ await this.fetchTemplates();
791
+ }
792
+ }
793
+ /**
794
+ * **The role by task** (ADR-0139 decision 11) — which of each task's privileges this role holds, as the server
795
+ * computes it from the saved privileges.
796
+ *
797
+ * It says what the role holds, nothing more: 「전부」 is not "this person can do the work" (another role, the
798
+ * data's own scope or an approval line decide that too), and 「일부」 is not a fault — a privilege several tasks
799
+ * share makes each of them 「일부」.
800
+ *
801
+ * A task can be added to the role or taken off it (the user, 2026-10-20). The role stores privileges, never tasks,
802
+ * so either is a set of privileges, shown before it is saved. Taking a task off keeps what another task the role
803
+ * holds whole still uses (`role-task-edit.ts`). Both save at once and read the coverage again, so they are not
804
+ * offered over unsaved changes - the coverage is the saved role's.
805
+ */
806
+ renderTasks() {
807
+ const tasks = this.coverage.tasks;
808
+ const held = tasks.filter(task => task.status !== 'none').sort((a, b) => statusOrder(a.status) - statusOrder(b.status));
809
+ const unheld = tasks.filter(task => task.status === 'none');
810
+ return html `
811
+ <div review>
812
+ ${this.dirty ? html `<div stale>${i18next.t('text.counted from the saved privileges')}</div>` : nothing}
813
+ ${held.length
814
+ ? html `<ul tasks>
815
+ ${held.map(task => this.renderTask(task))}
608
816
  </ul>`
609
- : html `<div empty>${i18next.t('text.nothing is on for this role yet')}</div>`}
610
- ${this.renderAdd()}
817
+ : html `<div empty>${i18next.t('text.this role holds no task')}</div>`}
818
+ ${unheld.length
819
+ ? html `<h4>
820
+ ${i18next.t('label.tasks this role does not hold')}
821
+ <span count>${unheld.length}</span>
822
+ <button quiet @click=${() => (this.showUnheldTasks = !this.showUnheldTasks)}>
823
+ ${this.showUnheldTasks ? i18next.t('button.fold') : i18next.t('button.unfold')}
824
+ </button>
825
+ </h4>
826
+ ${this.showUnheldTasks
827
+ ? html `<ul tasks quiet>
828
+ ${unheld.map(task => html `<li>${taskName(task.taskId)} ${this.taskEditButton(task)}</li>`)}
829
+ </ul>`
830
+ : nothing}`
831
+ : nothing}
832
+ ${this.coverage.unclassified.length
833
+ ? html `<h4>
834
+ ${i18next.t('label.privileges no task names')}
835
+ <span count>${countOf(this.coverage.unclassified)}</span>
836
+ </h4>
837
+ ${this.renderSentences(this.coverage.unclassified)}`
838
+ : nothing}
839
+ </div>
840
+ `;
841
+ }
842
+ /** 더하기 for a task the role lacks some or all of, 빼기 for one it holds some or all of. */
843
+ taskEditButton(task) {
844
+ if (this.inherited || task.status === 'broken')
845
+ return nothing;
846
+ const disabled = this.dirty || this.saving;
847
+ const why = this.dirty ? i18next.t('text.save before editing by task') : '';
848
+ return html `
849
+ ${task.status !== 'all'
850
+ ? html `<button quiet ?disabled=${disabled} title=${why} @click=${() => this.editTask(task, 'add')}>${i18next.t('button.add task')}</button>`
851
+ : nothing}
852
+ ${task.status !== 'none'
853
+ ? html `<button quiet ?disabled=${disabled} title=${why} @click=${() => this.editTask(task, 'remove')}>${i18next.t('button.remove task')}</button>`
854
+ : nothing}
855
+ `;
856
+ }
857
+ /** Adds a task's missing privileges to the role, or takes its own off - after showing which - and saves. */
858
+ async editTask(task, mode) {
859
+ if (this.dirty || this.saving || this.inherited)
860
+ return;
861
+ const name = taskName(task.taskId);
862
+ const { remove, kept } = mode === 'remove' ? pairsToRemove(task, this.coverage.tasks) : { remove: [], kept: [] };
863
+ const pairs = mode === 'add' ? pairsToAdd(task) : remove;
864
+ const words = (list) => list.map(pair => this.sentenceOf(pair.split(':')[0], pair.split(':')[1])).join(', ');
865
+ const keptLine = kept.length ? ` ${i18next.t('text.kept for other tasks', { list: words(kept) })}` : '';
866
+ if (!pairs.length) {
867
+ await OxPrompt.open({
868
+ type: 'info',
869
+ title: i18next.t('title.remove task'),
870
+ text: i18next.t('text.nothing to remove for task', { task: name }) + keptLine,
871
+ confirmButton: { text: i18next.t('button.confirm') }
872
+ });
873
+ return;
874
+ }
875
+ const confirmed = await OxPrompt.open({
876
+ type: 'info',
877
+ title: i18next.t(mode === 'add' ? 'title.add task' : 'title.remove task'),
878
+ text: i18next.t(mode === 'add' ? 'text.add task privileges' : 'text.remove task privileges', { task: name, count: pairs.length, list: words(pairs) }) + keptLine,
879
+ confirmButton: { text: i18next.t(mode === 'add' ? 'button.add task' : 'button.remove task') },
880
+ cancelButton: { text: i18next.t('button.cancel') }
881
+ });
882
+ if (!confirmed)
883
+ return;
884
+ const idOf = new Map(this.modules.flatMap(module => module.cells.map(cell => [pairOf(module.category, cell.axis), cell.id])));
885
+ const next = new Set(this.assigned);
886
+ for (const pair of pairs) {
887
+ const id = idOf.get(pair);
888
+ if (id)
889
+ mode === 'add' ? next.add(id) : next.delete(id);
890
+ }
891
+ this.assigned = next;
892
+ this.dirty = true;
893
+ await this.onSave();
894
+ }
895
+ renderTask(task) {
896
+ const held = countOf(task.held);
897
+ const total = held + countOf(task.missing);
898
+ const says = i18next.t(`task.${task.taskId}.description`, { defaultValue: '' });
899
+ return html `
900
+ <li>
901
+ <div>
902
+ <span task-name>${taskName(task.taskId)}</span>
903
+ ${task.status === 'broken'
904
+ ? html `<span state broken>${i18next.t('text.task names an undeclared privilege')}</span>`
905
+ : task.status === 'all'
906
+ ? html `<span state all>${i18next.t('text.task held whole')}</span>`
907
+ : html `<span state>${i18next.t('text.task held in part', { held, total })}</span>`}
908
+ ${this.taskEditButton(task)}
909
+ </div>
910
+ ${says ? html `<div task-says>${says}</div>` : nothing}
911
+ ${task.status === 'some'
912
+ ? html `<div task-missing>${i18next.t('text.not held x', { x: task.missing.flatMap(grant => grant.axes.map(axis => this.sentenceOf(grant.category, axis))).join(' · ') })}</div>`
913
+ : nothing}
914
+ </li>
915
+ `;
916
+ }
917
+ /**
918
+ * **The role against a template the administrator picks** (ADR-0139 decision 11) — whatever the role is called.
919
+ *
920
+ * The template of the role's name is picked to begin with; a role named in the site's own words (경영지원, 인사급여)
921
+ * has no such pair, and the administrator picks one. The role is never renamed to make a pair: an approval line
922
+ * holds the role. What the role holds beyond the template is shown as wider, not wrong — the site may have widened it
923
+ * on purpose — and nothing takes it away. What it lacks can be added; adding is the same top-up the template button
924
+ * on the role list runs (`createRoleFromTemplate` onto this role's name).
925
+ */
926
+ renderTemplate() {
927
+ if (this.templates === null) {
928
+ return html `<div review><div empty>${i18next.t('text.loading')}</div></div>`;
929
+ }
930
+ if (!this.templates.length) {
931
+ return html `<div review><div empty>${i18next.t('text.no template to compare with')}</div></div>`;
932
+ }
933
+ const template = this.templates.find(one => one.id === this.templateId);
934
+ const gap = this.gap;
935
+ return html `
936
+ <div review>
937
+ <select @change=${(event) => this.pickTemplate(event.target.value)}>
938
+ <option value="" ?selected=${!this.templateId}>${i18next.t('text.pick a template to compare with')}</option>
939
+ ${this.templates.map(one => html `<option value=${one.id} ?selected=${one.id === this.templateId}>${one.name}</option>`)}
940
+ </select>
941
+ ${this.dirty ? html `<div stale>${i18next.t('text.counted from the saved privileges')}</div>` : nothing}
942
+ ${template?.definitionErrors.length
943
+ ? html `<div note attention>${i18next.t('text.template cannot be applied because', { list: template.definitionErrors.join(' · ') })}</div>`
944
+ : nothing}
945
+ ${gap
946
+ ? html `
947
+ <h4>
948
+ ${i18next.t('label.missing against the template')}
949
+ <span count>${gap.missingCount}</span>
950
+ ${gap.missingCount && !this.inherited
951
+ ? html `<button quiet ?disabled=${this.dirty || this.saving || Boolean(template?.definitionErrors.length)} @click=${() => this.addMissing()}>
952
+ ${i18next.t('button.add')}
953
+ </button>`
954
+ : nothing}
955
+ </h4>
956
+ ${gap.missingCount ? this.renderSentences(gap.missing) : html `<div empty>${i18next.t('text.nothing missing against the template')}</div>`}
957
+ <h4>
958
+ ${i18next.t('label.wider than the template')}
959
+ <span count>${gap.extraCount}</span>
960
+ </h4>
961
+ ${gap.extraCount ? this.renderSentences(gap.extra) : nothing}
962
+ `
963
+ : nothing}
611
964
  </div>
612
965
  `;
613
966
  }
967
+ renderSentences(grants) {
968
+ return html `<ul sentences>
969
+ ${grants.flatMap(grant => grant.axes.map(axis => html `<li>${this.sentenceOf(grant.category, axis)}</li>`))}
970
+ </ul>`;
971
+ }
972
+ /** A privilege in words — the sentence the privileges view draws, or its module and axis when there is none. */
973
+ sentenceOf(category, axis) {
974
+ const cell = this.modules.find(module => module.category === category)?.cells.find(one => one.axis === axis);
975
+ return (cell && axisText(category, cell)) || `${moduleLabel(category)} · ${axisLabel(axis)}`;
976
+ }
977
+ async fetchTemplates() {
978
+ const response = await client.query({
979
+ query: gql `
980
+ query {
981
+ roleTemplates {
982
+ id
983
+ name
984
+ definitionErrors
985
+ }
986
+ }
987
+ `,
988
+ context: gqlContext()
989
+ });
990
+ /* Reading templates asks the same door creating a role does; without it there is nothing to compare with. */
991
+ const templates = response.errors?.length ? [] : response.data?.roleTemplates || [];
992
+ this.templates = templates;
993
+ const paired = templates.find(one => one.name === this.subject?.name);
994
+ await this.pickTemplate(paired?.id || '');
995
+ }
996
+ async pickTemplate(templateId) {
997
+ this.templateId = templateId;
998
+ this.gap = null;
999
+ if (!templateId)
1000
+ return;
1001
+ const response = await client.query({
1002
+ query: gql `
1003
+ query ($filters: [Filter!], $templateId: String) {
1004
+ roles(filters: $filters) {
1005
+ items {
1006
+ id
1007
+ templateGap(templateId: $templateId) {
1008
+ templateId
1009
+ templateName
1010
+ missing { ${GRANT_FIELDS} }
1011
+ missingCount
1012
+ extra { ${GRANT_FIELDS} }
1013
+ extraCount
1014
+ }
1015
+ }
1016
+ }
1017
+ }
1018
+ `,
1019
+ variables: { filters: [{ name: 'id', operator: 'eq', value: this.subject.id }], templateId },
1020
+ context: gqlContext()
1021
+ });
1022
+ if (response.errors?.length) {
1023
+ document.dispatchEvent(new CustomEvent('notify', { detail: { level: 'error', message: response.errors[0]?.message } }));
1024
+ return;
1025
+ }
1026
+ if (this.templateId === templateId)
1027
+ this.gap = response.data?.roles?.items?.[0]?.templateGap || null;
1028
+ }
1029
+ /** Adds what the role lacks against the picked template — after showing what it adds. */
1030
+ async addMissing() {
1031
+ const gap = this.gap;
1032
+ if (!gap?.missingCount || this.dirty || this.inherited)
1033
+ return;
1034
+ const list = gap.missing.flatMap(grant => grant.axes.map(axis => this.sentenceOf(grant.category, axis))).join(', ');
1035
+ const confirmed = await OxPrompt.open({
1036
+ type: 'info',
1037
+ title: i18next.t('title.add template privileges'),
1038
+ text: i18next.t('text.add template privileges', { template: gap.templateName, count: gap.missingCount, list }),
1039
+ confirmButton: { text: i18next.t('button.add') },
1040
+ cancelButton: { text: i18next.t('button.cancel') }
1041
+ });
1042
+ if (!confirmed)
1043
+ return;
1044
+ const response = await client.mutate({
1045
+ mutation: gql `
1046
+ mutation ($templateId: String!, $name: String) {
1047
+ createRoleFromTemplate(templateId: $templateId, name: $name) {
1048
+ id
1049
+ }
1050
+ }
1051
+ `,
1052
+ variables: { templateId: gap.templateId, name: this.subject.name },
1053
+ context: gqlContext()
1054
+ });
1055
+ if (response.errors?.length) {
1056
+ document.dispatchEvent(new CustomEvent('notify', { detail: { level: 'error', message: response.errors[0]?.message } }));
1057
+ return;
1058
+ }
1059
+ document.dispatchEvent(new CustomEvent('notify', { detail: { message: i18next.t('text.data_updated_successfully') } }));
1060
+ await this.fetch();
1061
+ await this.pickTemplate(gap.templateId);
1062
+ }
614
1063
  /**
615
1064
  * 한 줄 — 문장과 [떼기].
616
1065
  *
@@ -861,6 +1310,17 @@ let RolePrivilegePage = class RolePrivilegePage extends localize(i18next)(PageVi
861
1310
  name
862
1311
  email
863
1312
  }
1313
+ taskCoverage {
1314
+ tasks {
1315
+ taskId
1316
+ module
1317
+ status
1318
+ held { ${GRANT_FIELDS} }
1319
+ missing { ${GRANT_FIELDS} }
1320
+ undeclared { ${GRANT_FIELDS} }
1321
+ }
1322
+ unclassified { ${GRANT_FIELDS} }
1323
+ }
864
1324
  }
865
1325
  }
866
1326
  }
@@ -887,6 +1347,7 @@ let RolePrivilegePage = class RolePrivilegePage extends localize(i18next)(PageVi
887
1347
  }
888
1348
  this.subject = role;
889
1349
  this.holders = this.subject.users || [];
1350
+ this.coverage = role.taskCoverage || { tasks: [], unclassified: [] };
890
1351
  this.build(response.data?.rolePrivileges || []);
891
1352
  this.fetchState = 'loaded';
892
1353
  /*
@@ -1047,6 +1508,30 @@ __decorate([
1047
1508
  state(),
1048
1509
  __metadata("design:type", Boolean)
1049
1510
  ], RolePrivilegePage.prototype, "dirty", void 0);
1511
+ __decorate([
1512
+ state(),
1513
+ __metadata("design:type", String)
1514
+ ], RolePrivilegePage.prototype, "view", void 0);
1515
+ __decorate([
1516
+ state(),
1517
+ __metadata("design:type", Object)
1518
+ ], RolePrivilegePage.prototype, "coverage", void 0);
1519
+ __decorate([
1520
+ state(),
1521
+ __metadata("design:type", Boolean)
1522
+ ], RolePrivilegePage.prototype, "showUnheldTasks", void 0);
1523
+ __decorate([
1524
+ state(),
1525
+ __metadata("design:type", Object)
1526
+ ], RolePrivilegePage.prototype, "templates", void 0);
1527
+ __decorate([
1528
+ state(),
1529
+ __metadata("design:type", String)
1530
+ ], RolePrivilegePage.prototype, "templateId", void 0);
1531
+ __decorate([
1532
+ state(),
1533
+ __metadata("design:type", Object)
1534
+ ], RolePrivilegePage.prototype, "gap", void 0);
1050
1535
  RolePrivilegePage = __decorate([
1051
1536
  customElement('role-privilege-page')
1052
1537
  ], RolePrivilegePage);
@@ -1096,4 +1581,14 @@ function axisOrder(axis) {
1096
1581
  const index = KNOWN_AXES.indexOf(axis);
1097
1582
  return index < 0 ? KNOWN_AXES.length : index;
1098
1583
  }
1584
+ /** A task's name in the words of the module that declares it (`task.<id>.name`); the id when there are none. */
1585
+ function taskName(id) {
1586
+ return i18next.t(`task.${id}.name`, { defaultValue: id });
1587
+ }
1588
+ function statusOrder(status) {
1589
+ return ['broken', 'all', 'some', 'none'].indexOf(status);
1590
+ }
1591
+ function countOf(grants) {
1592
+ return grants.reduce((count, grant) => count + grant.axes.length, 0);
1593
+ }
1099
1594
  //# sourceMappingURL=role-privilege-page.js.map