@things-factory/auth-ui 10.1.226 → 10.1.227

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 (37) hide show
  1. package/dist-client/components/role-template-picker.d.ts +5 -0
  2. package/dist-client/components/role-template-picker.js +151 -42
  3. package/dist-client/components/role-template-picker.js.map +1 -1
  4. package/dist-client/components/role-template-picker.test.d.ts +1 -0
  5. package/dist-client/components/role-template-picker.test.js +41 -0
  6. package/dist-client/components/role-template-picker.test.js.map +1 -0
  7. package/dist-client/pages/role/role-add-delete.test.d.ts +1 -0
  8. package/dist-client/pages/role/role-add-delete.test.js +61 -0
  9. package/dist-client/pages/role/role-add-delete.test.js.map +1 -0
  10. package/dist-client/pages/role/role-inheritance-shown.test.d.ts +1 -0
  11. package/dist-client/pages/role/role-inheritance-shown.test.js +31 -0
  12. package/dist-client/pages/role/role-inheritance-shown.test.js.map +1 -0
  13. package/dist-client/pages/role/role-list-page.d.ts +15 -11
  14. package/dist-client/pages/role/role-list-page.js +112 -53
  15. package/dist-client/pages/role/role-list-page.js.map +1 -1
  16. package/dist-client/pages/role/role-privilege-inherited.test.d.ts +1 -0
  17. package/dist-client/pages/role/role-privilege-inherited.test.js +23 -0
  18. package/dist-client/pages/role/role-privilege-inherited.test.js.map +1 -0
  19. package/dist-client/pages/role/role-privilege-page.d.ts +8 -5
  20. package/dist-client/pages/role/role-privilege-page.js +23 -13
  21. package/dist-client/pages/role/role-privilege-page.js.map +1 -1
  22. package/dist-client/pages/role/role-privilege-views.test.d.ts +1 -0
  23. package/dist-client/pages/role/role-privilege-views.test.js +19 -0
  24. package/dist-client/pages/role/role-privilege-views.test.js.map +1 -0
  25. package/dist-client/tsconfig.tsbuildinfo +1 -1
  26. package/helps/auth/application.ko.md +1 -1
  27. package/helps/auth/auth-provider.ko.md +1 -1
  28. package/helps/auth/domain-owners.ko.md +1 -1
  29. package/helps/operato-hub/partners.ko.md +1 -1
  30. package/helps/system/attribute.ko.md +1 -1
  31. package/package.json +6 -6
  32. package/translations/en.json +17 -10
  33. package/translations/es.json +17 -10
  34. package/translations/ja.json +17 -10
  35. package/translations/ko.json +18 -11
  36. package/translations/ms.json +17 -10
  37. package/translations/zh.json +17 -10
@@ -40,6 +40,11 @@ export declare class RoleTemplatePicker extends RoleTemplatePicker_base {
40
40
  connectedCallback(): Promise<void>;
41
41
  private fetchTemplates;
42
42
  render(): import("lit-html").TemplateResult<1>;
43
+ /**
44
+ * 「추가」가 무엇을 하는지 단추 줄에서 말한다. 이 창에서는 업무 · 권한을 고칠 수 없어 사람이 의아해한다 — 템플릿과
45
+ * 같은 상태로 시작하고, 고치는 것은 목록의 「권한 편집」에서 따로 한다(2026-10-28 사용자).
46
+ */
47
+ private renderHowItStarts;
43
48
  /**
44
49
  * What the seat opens, in one line — **counted, not interpreted.**
45
50
  *
@@ -80,41 +80,51 @@ let RoleTemplatePicker = class RoleTemplatePicker extends localize(i18next)(LitE
80
80
  /* The popup footer already has a convention — plain buttons, pill on pointer devices. */
81
81
  CommonHeaderStyles,
82
82
  css `
83
+ /*
84
+ * 글 크기를 이 창이 정한다(2026-10-28 사용자: 「추가 팝업도 스타일이 벙벙하다」). 팝업 몸통의 기본
85
+ * 글 크기를 그대로 받아 자리 이름이 제목만 하게 컸고, 줄 사이도 그만큼 벌어졌다.
86
+ */
83
87
  :host {
84
88
  display: flex;
85
89
  flex-direction: column;
86
90
  background-color: var(--md-sys-color-surface-container-lowest);
87
91
  color: var(--md-sys-color-on-surface);
92
+ font-size: var(--fontsize-default, 14px);
88
93
  height: 100%;
89
94
  overflow: hidden;
90
95
  }
91
96
 
97
+ /*
98
+ * 두 칸은 상자가 아니라 한 면을 가르는 선 하나로 나뉜다(2026-10-28 사용자: 「팝업 내부에서 어설픈 라운드 섹션
99
+ * 쓰지마라」). 둥근 상자 둘이 팝업 안에 또 떠 있었다.
100
+ */
92
101
  [body] {
93
102
  display: grid;
94
103
  grid-template-columns: minmax(240px, 320px) 1fr;
95
- gap: var(--spacing-medium, 8px);
96
- padding: var(--spacing-medium, 8px);
97
104
  flex: 1;
98
105
  min-height: 0;
106
+ border-top: 1px solid var(--md-sys-color-outline-variant);
99
107
  }
100
108
 
101
109
  [panel] {
102
110
  display: flex;
103
111
  flex-direction: column;
104
112
  min-height: 0;
105
- background-color: var(--md-sys-color-surface);
106
- border-radius: var(--md-sys-shape-corner-medium, 8px);
107
113
  overflow: hidden;
108
114
  }
109
115
 
116
+ [panel] + [panel] {
117
+ border-left: 1px solid var(--md-sys-color-outline-variant);
118
+ }
119
+
110
120
  [panel] > header {
111
121
  display: flex;
112
122
  align-items: center;
113
123
  gap: 6px;
114
- padding: var(--spacing-small, 6px) var(--spacing-medium, 8px);
124
+ padding: var(--spacing-small, 6px) var(--spacing-medium, 10px);
115
125
  font-size: var(--fontsize-small, 13px);
116
126
  color: var(--md-sys-color-on-surface-variant);
117
- background-color: var(--md-sys-color-surface-container);
127
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
118
128
  }
119
129
 
120
130
  [count] {
@@ -133,8 +143,8 @@ let RoleTemplatePicker = class RoleTemplatePicker extends localize(i18next)(LitE
133
143
  li[seat] {
134
144
  display: flex;
135
145
  flex-direction: column;
136
- gap: 2px;
137
- padding: var(--spacing-small, 6px) var(--spacing-medium, 8px);
146
+ gap: 1px;
147
+ padding: 6px var(--spacing-medium, 10px);
138
148
  border-bottom: 1px solid var(--md-sys-color-outline-variant);
139
149
  cursor: pointer;
140
150
  }
@@ -155,9 +165,14 @@ let RoleTemplatePicker = class RoleTemplatePicker extends localize(i18next)(LitE
155
165
  }
156
166
 
157
167
  li[seat] [name] {
168
+ font-size: var(--fontsize-default, 14px);
158
169
  font-weight: 600;
159
170
  }
160
171
 
172
+ li[seat] md-icon {
173
+ --md-icon-size: 16px;
174
+ }
175
+
161
176
  li[seat] small {
162
177
  color: var(--md-sys-color-on-surface-variant);
163
178
  font-size: var(--fontsize-small, 12px);
@@ -183,15 +198,67 @@ let RoleTemplatePicker = class RoleTemplatePicker extends localize(i18next)(LitE
183
198
  [detail] {
184
199
  display: flex;
185
200
  flex-direction: column;
186
- gap: var(--spacing-large, 14px);
187
- padding: var(--spacing-large, 14px);
201
+ gap: var(--spacing-medium, 10px);
202
+ padding: var(--spacing-medium, 10px) var(--spacing-large, 14px);
188
203
  overflow-y: auto;
189
204
  flex: 1;
190
205
  }
191
206
 
207
+ /*
208
+ * 이름 · 설명은 한 줄에 하나 — 이름표가 왼쪽 한 열에 서고 칸이 나머지를 채운다(2026-10-28 사용자: 「이름 수정
209
+ * 부분이 있는데, 그런것은 왜 두줄로 만드냐」). 이름표 열은 가장 긴 이름표만큼이라 두 줄의 칸 머리가 맞는다.
210
+ */
211
+ [form] {
212
+ display: grid;
213
+ grid-template-columns: max-content 1fr;
214
+ align-items: center;
215
+ gap: 6px 12px;
216
+ }
217
+
218
+ /*
219
+ * 첫 줄이 무엇에서 시작하는지 말한다 — 창 이름이 「역할 추가」뿐이라 고르는 것이 템플릿이라는 것을 알 수
220
+ * 없었다(2026-10-28 사용자). 이름을 고쳐도 어느 템플릿인지는 이 줄에 남는다.
221
+ */
222
+ [form] label,
223
+ [form] [label] {
224
+ font-size: var(--fontsize-small, 13px);
225
+ color: var(--md-sys-color-on-surface-variant);
226
+ }
227
+
228
+ [form] [value] {
229
+ font-weight: 600;
230
+ }
231
+
232
+ [form] input {
233
+ width: 100%;
234
+ box-sizing: border-box;
235
+ }
236
+
237
+ [form] [note] {
238
+ grid-column: 2;
239
+ }
240
+
241
+ /* 업무 · 권한 — 머리말 한 줄이 두 덩어리를 가른다. */
242
+ section {
243
+ display: flex;
244
+ flex-direction: column;
245
+ gap: 8px;
246
+ padding-top: var(--spacing-medium, 10px);
247
+ border-top: 1px solid var(--md-sys-color-outline-variant);
248
+ }
249
+
250
+ section > h4 {
251
+ display: flex;
252
+ align-items: baseline;
253
+ gap: 6px;
254
+ margin: 0;
255
+ font-size: var(--fontsize-small, 13px);
256
+ font-weight: 600;
257
+ color: var(--md-sys-color-on-surface-variant);
258
+ }
259
+
192
260
  /* 곁말 — 필드 밑에 붙거나 혼자 선다. 본문과 같은 무게로 두면 읽는 순서가 흐려진다. */
193
- [note],
194
- label[field] small {
261
+ [note] {
195
262
  margin: 0;
196
263
  font-size: var(--fontsize-small, 12px);
197
264
  color: var(--md-sys-color-on-surface-variant);
@@ -202,12 +269,20 @@ let RoleTemplatePicker = class RoleTemplatePicker extends localize(i18next)(LitE
202
269
  color: var(--md-sys-color-error);
203
270
  }
204
271
 
205
- /* The seat's work, one per line - the words a person reads first; the modules below are the detail. */
272
+ /* The seat's work - the words a person reads first, as chips that wrap; the modules below are the detail. */
206
273
  ul[tasks] {
207
- margin: 0;
208
- padding: 0 0 0 1.1em;
209
- font-size: 0.92rem;
210
- line-height: 1.6;
274
+ flex: none;
275
+ overflow: visible;
276
+ display: flex;
277
+ flex-wrap: wrap;
278
+ gap: 4px 6px;
279
+ }
280
+
281
+ ul[tasks] li {
282
+ padding: 2px 10px;
283
+ border-radius: var(--md-sys-shape-corner-full, 999px);
284
+ background-color: var(--md-sys-color-surface-container-high);
285
+ font-size: var(--fontsize-small, 13px);
211
286
  }
212
287
 
213
288
  dl[grants] {
@@ -268,6 +343,12 @@ let RoleTemplatePicker = class RoleTemplatePicker extends localize(i18next)(LitE
268
343
  .footer {
269
344
  justify-content: flex-end;
270
345
  }
346
+
347
+ .footer [how] {
348
+ margin: 0 auto 0 0;
349
+ font-size: var(--fontsize-small, 13px);
350
+ color: var(--md-sys-color-on-surface-variant);
351
+ }
271
352
  `
272
353
  ]; }
273
354
  async connectedCallback() {
@@ -307,13 +388,17 @@ let RoleTemplatePicker = class RoleTemplatePicker extends localize(i18next)(LitE
307
388
  return;
308
389
  }
309
390
  this.templates = response.data?.roleTemplates || [];
391
+ /* 처음부터 하나를 보인다 — 아무것도 안 고른 창은 오른쪽 절반이 빈 채로 열렸다. 만드는 것은 여전히 「추가」를 눌러야 한다. */
392
+ const first = this.templates.find(template => !template.definitionErrors.length);
393
+ if (first && !this.selected && !this.scratch)
394
+ this.pick(first);
310
395
  }
311
396
  render() {
312
397
  return html `
313
398
  <div body>
314
399
  <div panel>
315
400
  <header>
316
- ${i18next.t('field.seat')}
401
+ ${i18next.t('field.role templates')}
317
402
  <span count>${this.templates.length}</span>
318
403
  </header>
319
404
  <ul>
@@ -336,9 +421,9 @@ let RoleTemplatePicker = class RoleTemplatePicker extends localize(i18next)(LitE
336
421
  <li seat ?selected=${this.scratch} @click=${() => this.pickScratch()}>
337
422
  <div title-row>
338
423
  <md-icon>edit</md-icon>
339
- <span name>${i18next.t('button.name it yourself')}</span>
424
+ <span name>${i18next.t('label.no template')}</span>
340
425
  </div>
341
- <small>${i18next.t('text.start from a blank seat')}</small>
426
+ <small>${i18next.t('text.an empty role with no privileges')}</small>
342
427
  </li>
343
428
  </ul>
344
429
  </div>
@@ -347,11 +432,26 @@ let RoleTemplatePicker = class RoleTemplatePicker extends localize(i18next)(LitE
347
432
  </div>
348
433
 
349
434
  <div class="footer">
435
+ ${this.renderHowItStarts()}
350
436
  <button @click=${() => this.dispatchEvent(new CustomEvent('close'))}>${i18next.t('button.cancel')}</button>
351
- <button ok ?disabled=${!this.canCreate()} @click=${() => this.create()}>${i18next.t('button.create')}</button>
437
+ <button ok ?disabled=${!this.canCreate()} @click=${() => this.create()}>${i18next.t('button.add')}</button>
352
438
  </div>
353
439
  `;
354
440
  }
441
+ /**
442
+ * 「추가」가 무엇을 하는지 단추 줄에서 말한다. 이 창에서는 업무 · 권한을 고칠 수 없어 사람이 의아해한다 — 템플릿과
443
+ * 같은 상태로 시작하고, 고치는 것은 목록의 「권한 편집」에서 따로 한다(2026-10-28 사용자).
444
+ */
445
+ renderHowItStarts() {
446
+ const words = { add: i18next.t('button.add'), edit: i18next.t('button.edit privileges') };
447
+ if (this.scratch) {
448
+ return html `<p how>${i18next.t('text.add starts an empty role', words)}</p>`;
449
+ }
450
+ if (!this.selected) {
451
+ return nothing;
452
+ }
453
+ return html `<p how>${i18next.t('text.add starts the role as the template is', words)}</p>`;
454
+ }
355
455
  /**
356
456
  * What the seat opens, in one line — **counted, not interpreted.**
357
457
  *
@@ -368,6 +468,10 @@ let RoleTemplatePicker = class RoleTemplatePicker extends localize(i18next)(LitE
368
468
  if (template.grantsNothingDeliberately) {
369
469
  return i18next.t('text.opens nothing, receives approvals');
370
470
  }
471
+ /* 업무로 지은 자리는 업무 수로 — 오른쪽이 먼저 보이는 것이 업무다. */
472
+ if (template.tasks.length) {
473
+ return i18next.t('text.x tasks y privileges', { x: template.tasks.length, y: template.privilegeCount });
474
+ }
371
475
  return i18next.t('text.x modules y privileges', {
372
476
  x: template.grants.filter(grant => grant.axes.length).length,
373
477
  y: template.privilegeCount
@@ -376,17 +480,15 @@ let RoleTemplatePicker = class RoleTemplatePicker extends localize(i18next)(LitE
376
480
  renderDetail() {
377
481
  if (this.scratch) {
378
482
  return html `
379
- <header>${i18next.t('button.name it yourself')}</header>
380
483
  <div detail>
381
- <label field>
382
- ${i18next.t('field.name')}
383
- <input .value=${this.name} @input=${(e) => (this.name = e.target.value)} />
384
- </label>
385
- <label field>
386
- ${i18next.t('field.description')}
387
- <input .value=${this.description} @input=${(e) => (this.description = e.target.value)} />
388
- </label>
389
- <small>${i18next.t('text.a blank seat opens nothing until you give it privileges')}</small>
484
+ <div form>
485
+ <span label>${i18next.t('field.role template')}</span>
486
+ <span value>${i18next.t('label.no template')}</span>
487
+ <label for="name">${i18next.t('field.new role name')}</label>
488
+ <input id="name" .value=${this.name} @input=${(e) => (this.name = e.target.value)} />
489
+ <label for="description">${i18next.t('field.description')}</label>
490
+ <input id="description" .value=${this.description} @input=${(e) => (this.description = e.target.value)} />
491
+ </div>
390
492
  </div>
391
493
  `;
392
494
  }
@@ -395,17 +497,21 @@ let RoleTemplatePicker = class RoleTemplatePicker extends localize(i18next)(LitE
395
497
  }
396
498
  const template = this.selected;
397
499
  return html `
398
- <header>${template.name}</header>
399
500
  <div detail>
400
- <label field>
401
- ${i18next.t('field.name')}
402
- <input .value=${this.name} @input=${(e) => (this.name = e.target.value)} />
403
- <small>${i18next.t('text.this plant may call the seat what it likes')}</small>
404
- </label>
405
- ${template.defaultNameTaken && this.name === template.name
501
+ <div form>
502
+ <span label>${i18next.t('field.role template')}</span>
503
+ <span value>${template.name}</span>
504
+ <label for="name">${i18next.t('field.new role name')}</label>
505
+ <input id="name" .value=${this.name} @input=${(e) => (this.name = e.target.value)} />
506
+ ${template.defaultNameTaken && this.name === template.name
406
507
  ? html `<p note>${i18next.t('text.a role of this name exists and will be added to')}</p>`
407
508
  : nothing}
408
- ${this.renderTasks(template)} ${this.renderOpens(template)}
509
+ </div>
510
+ ${this.renderTasks(template)}
511
+ <section>
512
+ <h4>${i18next.t('field.privileges')} <span count>${template.privilegeCount}</span></h4>
513
+ ${this.renderOpens(template)}
514
+ </section>
409
515
  </div>
410
516
  `;
411
517
  }
@@ -458,9 +564,12 @@ let RoleTemplatePicker = class RoleTemplatePicker extends localize(i18next)(LitE
458
564
  ? html `<p note attention>${i18next.t('text.template cannot be applied because', { list: template.definitionErrors.join(' · ') })}</p>`
459
565
  : nothing}
460
566
  ${template.tasks.length
461
- ? html `<ul tasks>
462
- ${template.tasks.map(id => html `<li>${taskName(id)}</li>`)}
463
- </ul>`
567
+ ? html `<section>
568
+ <h4>${i18next.t('label.tasks view')} <span count>${template.tasks.length}</span></h4>
569
+ <ul tasks>
570
+ ${template.tasks.map(id => html `<li>${taskName(id)}</li>`)}
571
+ </ul>
572
+ </section>`
464
573
  : nothing}
465
574
  `;
466
575
  }
@@ -1 +1 @@
1
- {"version":3,"file":"role-template-picker.js","sourceRoot":"","sources":["../../client/components/role-template-picker.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/icon/icon.js'\n\nimport gql from 'graphql-tag'\nimport { css, html, LitElement, nothing } from 'lit'\nimport { customElement, property, state } from 'lit/decorators.js'\n\nimport type { CSSResultGroup } from 'lit'\n\nimport { client, gqlContext } from '@operato/graphql'\nimport { i18next, localize } from '@operato/i18n'\n\nimport { axisLabel } from '../utils/privilege-axis.js'\nimport { CommonHeaderStyles, FormFieldStyles, ScrollbarStyles } from '@operato/styles'\n\ntype GrantView = { category: string; axes: string[]; missingAxes: string[] }\ntype TemplateView = {\n id: string\n name: string\n description?: string\n grants: GrantView[]\n privilegeCount: number\n missingCount: number\n defaultNameTaken: boolean\n grantsNothingDeliberately: boolean\n tasks: string[]\n definitionErrors: string[]\n}\n\n/*\n * The axis vocabulary lives in `utils/privilege-axis.ts` now — one table, not the copy that\n * used to sit here beside an identical one in `role-privilege-page.ts`. It also stopped\n * translating `query`/`mutation` into 「읽기」/「쓰기」; that file says why.\n */\n\n/**\n * The module, in words people use.\n *\n * Same rule as the role privilege page (`moduleLabel` there): use\n * `privilege.category.<category>` when it exists and **fall back to the code word**. Not\n * inventing one is the point — a made-up name would read as product vocabulary and hide\n * from the UX lane that the sentence is still missing.\n */\nfunction moduleLabel(category: string): string {\n return i18next.t(`privilege.category.${category}`, { defaultValue: category })\n}\n\n/** Stable order inside a group, by the name a person actually reads. */\nfunction byName(a: GrantView, b: GrantView): number {\n return moduleLabel(a.category).localeCompare(moduleLabel(b.category))\n}\n\n/**\n * Starting a role.\n *\n * ── Why this stands where the blank row used to ──────────────────────────────\n * A new plant has no roles, and the old answer to \"make one\" was an empty line waiting for\n * a name. Naming it is the easy half; the hard half is which of 84 privileges belong to a\n * quality inspector, and that is a judgement the product has made and the plant has not.\n *\n * miratek is the case: two roles (Administrator, TWIN-CONNECTOR), no MES seats, and eleven\n * approval doors standing empty because there is nothing to route them to. Read from\n * inside, that looks like a plant that has not decided. It is a plant with nothing to\n * decide from.\n *\n * ── Both doors, in one place ─────────────────────────────────────────────────\n * The templates come first and the blank name last, because the order is what teaches: a\n * plant that sees \"quality inspector, 7 privileges\" learns what a seat looks like before\n * it is asked to invent one. Hiding the blank door would be worse, though — the seats a\n * factory actually has (\"night shift lead\", \"outside inspector\") are its own, and the\n * product does not know them.\n *\n * ── Why the name is editable ─────────────────────────────────────────────────\n * The template knows the seat exists; it does not know what the people there call it. And\n * because a role is also what an approval line points at, the same seat is sometimes\n * needed twice — a day and a night inspector hold the same privileges and take different\n * approvals. A fixed name would allow each template exactly once per domain.\n */\n@customElement('role-template-picker')\nexport class RoleTemplatePicker extends localize(i18next)(LitElement) {\n static styles: CSSResultGroup = [\n ScrollbarStyles,\n /*\n * Native controls, styled by the shared block.\n *\n * This used `md-filled-text-field` and the Material buttons until the user looked at it\n * (2026-09-11): \"too big, and the style does not fit.\" They are right — a filled field\n * with a floating label is a form control from a different density than the pills,\n * chips and 30px rows this product uses everywhere else.\n *\n * `FormFieldStyles` exists precisely for this: a component draws its input inside its\n * own shadow root, where the theme stylesheet cannot reach, so the token wiring has to\n * be declared per component. It goes first so local rules below can override it.\n */\n FormFieldStyles,\n /* The popup footer already has a convention — plain buttons, pill on pointer devices. */\n CommonHeaderStyles,\n css`\n :host {\n display: flex;\n flex-direction: column;\n background-color: var(--md-sys-color-surface-container-lowest);\n color: var(--md-sys-color-on-surface);\n height: 100%;\n overflow: hidden;\n }\n\n [body] {\n display: grid;\n grid-template-columns: minmax(240px, 320px) 1fr;\n gap: var(--spacing-medium, 8px);\n padding: var(--spacing-medium, 8px);\n flex: 1;\n min-height: 0;\n }\n\n [panel] {\n display: flex;\n flex-direction: column;\n min-height: 0;\n background-color: var(--md-sys-color-surface);\n border-radius: var(--md-sys-shape-corner-medium, 8px);\n overflow: hidden;\n }\n\n [panel] > header {\n display: flex;\n align-items: center;\n gap: 6px;\n padding: var(--spacing-small, 6px) var(--spacing-medium, 8px);\n font-size: var(--fontsize-small, 13px);\n color: var(--md-sys-color-on-surface-variant);\n background-color: var(--md-sys-color-surface-container);\n }\n\n [count] {\n color: var(--md-sys-color-primary);\n font-weight: 600;\n }\n\n ul {\n list-style: none;\n margin: 0;\n padding: 0;\n overflow-y: auto;\n flex: 1;\n }\n\n li[seat] {\n display: flex;\n flex-direction: column;\n gap: 2px;\n padding: var(--spacing-small, 6px) var(--spacing-medium, 8px);\n border-bottom: 1px solid var(--md-sys-color-outline-variant);\n cursor: pointer;\n }\n\n li[seat]:hover {\n background-color: var(--md-sys-color-surface-container-high);\n }\n\n li[seat][selected] {\n background-color: var(--md-sys-color-secondary-container);\n color: var(--md-sys-color-on-secondary-container);\n }\n\n li[seat] [title-row] {\n display: flex;\n align-items: center;\n gap: 6px;\n }\n\n li[seat] [name] {\n font-weight: 600;\n }\n\n li[seat] small {\n color: var(--md-sys-color-on-surface-variant);\n font-size: var(--fontsize-small, 12px);\n }\n\n li[seat][selected] small {\n color: inherit;\n }\n\n [tag] {\n font-size: var(--fontsize-smaller, 11px);\n padding: 0 6px;\n border-radius: var(--md-sys-shape-corner-full, 999px);\n background-color: var(--md-sys-color-surface-container-highest);\n color: var(--md-sys-color-on-surface-variant);\n }\n\n [tag][warn] {\n background-color: var(--md-sys-color-error-container);\n color: var(--md-sys-color-on-error-container);\n }\n\n [detail] {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-large, 14px);\n padding: var(--spacing-large, 14px);\n overflow-y: auto;\n flex: 1;\n }\n\n /* 곁말 — 필드 밑에 붙거나 혼자 선다. 본문과 같은 무게로 두면 읽는 순서가 흐려진다. */\n [note],\n label[field] small {\n margin: 0;\n font-size: var(--fontsize-small, 12px);\n color: var(--md-sys-color-on-surface-variant);\n line-height: 1.5;\n }\n\n [note][attention] {\n color: var(--md-sys-color-error);\n }\n\n /* The seat's work, one per line - the words a person reads first; the modules below are the detail. */\n ul[tasks] {\n margin: 0;\n padding: 0 0 0 1.1em;\n font-size: 0.92rem;\n line-height: 1.6;\n }\n\n dl[grants] {\n display: grid;\n grid-template-columns: minmax(8em, max-content) 1fr;\n /* 줄 사이가 2px 이었다. 이름이 위아래로 붙어 한 덩어리로 읽혔다. */\n gap: 6px var(--spacing-large, 14px);\n margin: 0;\n align-items: baseline;\n }\n\n /*\n * 모듈 이름이 **기준선**이다 — 굵기로 두 칸이 저절로 갈린다.\n *\n * 처음엔 거꾸로였다: 이름을 흐리게 두고 「쓰기」를 굵게 했더니, 눈이 먼저 닿는 것이\n * 오른쪽 칸이 되어 왼쪽이 뭘 말하는지 훑어지지 않았다. 사람이 이 표에서 찾는 것은\n * 「이 자리가 **어느 모듈**을 여나」이고, 읽기냐 쓰기냐는 그 다음이다.\n */\n dl[grants] dt {\n color: var(--md-sys-color-on-surface);\n font-size: var(--fontsize-small, 13px);\n font-weight: 600;\n }\n\n dl[grants] dd {\n margin: 0;\n display: flex;\n flex-wrap: wrap;\n /* 낱말 사이 가운뎃점 — 「읽기 쓰기」가 한 낱말로 붙어 보이던 것을 가른다. */\n gap: 0 6px;\n }\n\n dl[grants] dd [axis] + [axis]::before {\n content: '·';\n margin-right: 6px;\n color: var(--md-sys-color-outline);\n }\n\n [axis] {\n font-size: var(--fontsize-small, 12px);\n color: var(--md-sys-color-on-surface-variant);\n }\n\n [axis][missing] {\n text-decoration: line-through;\n color: var(--md-sys-color-outline);\n }\n\n [empty] {\n margin: auto;\n text-align: center;\n color: var(--md-sys-color-on-surface-variant);\n font-size: var(--fontsize-small, 13px);\n padding: var(--spacing-large, 16px);\n }\n\n /* 자리 배치만 더한다 — 모양은 CommonHeaderStyles 의 .footer 가 갖는다. */\n .footer {\n justify-content: flex-end;\n }\n `\n ]\n\n @property({ type: Object }) page?: any\n\n @state() private templates: TemplateView[] = []\n @state() private selected?: TemplateView\n /** `undefined` means \"the blank door\" — name the seat yourself. */\n @state() private scratch = false\n @state() private name = ''\n @state() private description = ''\n\n async connectedCallback() {\n super.connectedCallback()\n await this.fetchTemplates()\n }\n\n private async fetchTemplates() {\n const response = await client.query({\n query: gql`\n query {\n roleTemplates {\n id\n name\n description\n privilegeCount\n missingCount\n defaultNameTaken\n grantsNothingDeliberately\n tasks\n definitionErrors\n grants {\n category\n axes\n missingAxes\n }\n }\n }\n `,\n context: gqlContext()\n })\n\n /*\n * A GraphQL error arrives as `data: null` with `errors` set, and `?? []` would draw\n * that as \"this installation offers nothing\" — a refusal made to look like an answer.\n */\n if (response.errors?.length) {\n document.dispatchEvent(\n new CustomEvent('notify', { detail: { level: 'error', message: response.errors[0]?.message } })\n )\n return\n }\n\n this.templates = response.data?.roleTemplates || []\n }\n\n render() {\n return html`\n <div body>\n <div panel>\n <header>\n ${i18next.t('field.seat')}\n <span count>${this.templates.length}</span>\n </header>\n <ul>\n ${this.templates.map(\n template => html`\n <li seat ?selected=${!this.scratch && this.selected?.id === template.id} @click=${() => this.pick(template)}>\n <div title-row>\n <span name>${template.name}</span>\n ${template.defaultNameTaken\n ? html`<span tag>${i18next.t('text.already in this domain')}</span>`\n : nothing}\n ${template.definitionErrors.length\n ? html`<span tag warn>${i18next.t('text.template cannot be applied')}</span>`\n : template.missingCount\n ? html`<span tag warn>${i18next.t('text.x not here', { x: template.missingCount })}</span>`\n : nothing}\n </div>\n <small>${this.summarize(template)}</small>\n </li>\n `\n )}\n <li seat ?selected=${this.scratch} @click=${() => this.pickScratch()}>\n <div title-row>\n <md-icon>edit</md-icon>\n <span name>${i18next.t('button.name it yourself')}</span>\n </div>\n <small>${i18next.t('text.start from a blank seat')}</small>\n </li>\n </ul>\n </div>\n\n <div panel>${this.renderDetail()}</div>\n </div>\n\n <div class=\"footer\">\n <button @click=${() => this.dispatchEvent(new CustomEvent('close'))}>${i18next.t('button.cancel')}</button>\n <button ok ?disabled=${!this.canCreate()} @click=${() => this.create()}>${i18next.t('button.create')}</button>\n </div>\n `\n }\n\n /**\n * What the seat opens, in one line — **counted, not interpreted.**\n *\n * This briefly said \"changes 6 · only sees 8\", which read well and was not something the\n * data supports: the privilege axis is an unconstrained string and its name does not\n * determine what the resolver behind it does. Counting is safe; grouping by meaning was\n * not. See `renderOpens`.\n *\n * \"0 privileges\" is still two different facts and must not be merged: granting nothing\n * on purpose is an approval seat, and a template whose grants are all missing here is a\n * defect (`missingCount`).\n */\n private summarize(template: TemplateView) {\n if (template.grantsNothingDeliberately) {\n return i18next.t('text.opens nothing, receives approvals')\n }\n\n return i18next.t('text.x modules y privileges', {\n x: template.grants.filter(grant => grant.axes.length).length,\n y: template.privilegeCount\n })\n }\n\n private renderDetail() {\n if (this.scratch) {\n return html`\n <header>${i18next.t('button.name it yourself')}</header>\n <div detail>\n <label field>\n ${i18next.t('field.name')}\n <input .value=${this.name} @input=${(e: any) => (this.name = e.target.value)} />\n </label>\n <label field>\n ${i18next.t('field.description')}\n <input .value=${this.description} @input=${(e: any) => (this.description = e.target.value)} />\n </label>\n <small>${i18next.t('text.a blank seat opens nothing until you give it privileges')}</small>\n </div>\n `\n }\n\n if (!this.selected) {\n return html`<div empty>${i18next.t('text.pick a seat to see what it opens')}</div>`\n }\n\n const template = this.selected\n\n return html`\n <header>${template.name}</header>\n <div detail>\n <label field>\n ${i18next.t('field.name')}\n <input .value=${this.name} @input=${(e: any) => (this.name = e.target.value)} />\n <small>${i18next.t('text.this plant may call the seat what it likes')}</small>\n </label>\n ${template.defaultNameTaken && this.name === template.name\n ? html`<p note>${i18next.t('text.a role of this name exists and will be added to')}</p>`\n : nothing}\n ${this.renderTasks(template)} ${this.renderOpens(template)}\n </div>\n `\n }\n\n /**\n * 이 자리가 무엇을 여나 — **선언된 것만.**\n *\n * ⚠ 여기 「바꿀 수 있는 것 / 볼 수만 있는 것」 두 묶음이 있었다. 사용자가 물었다(2026-09-11):\n * 「바꿀 수 있고, 볼 수만 있는 것을 너가 어떻게 알아? 논리적이지 않은 것을 우겨넣지 말고.」\n *\n * 몰랐다. 지어낸 것이다. 근거가 없는 이유가 둘이다.\n *\n * ① `@privilege(privilege:)` 는 **제약 없는 문자열**이다(지시자 선언이 `privilege: String`\n * 이고 enum 이 아니다). 지금 쓰인 값이 mutation 484 · query 314 로 둘뿐인 것은 **오늘\n * 그렇게 쓴 것**이지 둘로 한정된 것이 아니다. 내 코드는 `axis !== 'query'` 로 갈랐으므로,\n * 처음 보는 값은 **알아서가 아니라 query 가 아니라서** 「바꿀 수 있는 것」으로 떨어졌다.\n * ② 이름이 뜻을 보장하지 않는다. 축 이름과 실제 GraphQL 연산이 어긋나는 자리가 6곳이고,\n * 그중 둘은 이 기능의 내 파일이다(`role-template-query.ts` — 템플릿을 **읽는** 질의에\n * `privilege: \"mutation\"` 을 달았다. 역할을 만드는 문이라서 그게 맞다). 축은 연산 종류가\n * 아니라 **어느 문을 지나야 하나**의 이름이다.\n *\n * 그래서 화면은 선언된 것을 그대로 낸다 — 어느 모듈에, 어느 권한이. 읽히게 만드는 것\n * (굵기·줄 간격·가운뎃점)은 아무것도 주장하지 않으므로 그대로 둔다.\n */\n private renderOpens(template: TemplateView) {\n if (template.grantsNothingDeliberately) {\n return html`<p note>${i18next.t('text.opens nothing, receives approvals')}</p>`\n }\n\n return html`\n <dl grants>\n ${[...template.grants].sort(byName).map(\n grant => html`\n <dt>${moduleLabel(grant.category)}</dt>\n <dd>\n ${grant.axes.map(axis => html`<span axis>${axisLabel(axis)}</span>`)}\n ${grant.missingAxes.map(axis => html`<span axis missing>${axisLabel(axis)}</span>`)}\n </dd>\n `\n )}\n </dl>\n ${template.missingCount\n ? html`<p note>${i18next.t('text.struck through are not installed here')}</p>`\n : nothing}\n `\n }\n\n /**\n * The work this seat does, by task (ADR-0139 decision 11) — named in the words of the module that declares each.\n * A template the framework refuses to apply says why here, before anyone presses create.\n */\n private renderTasks(template: TemplateView) {\n return html`\n ${template.definitionErrors.length\n ? html`<p note attention>${i18next.t('text.template cannot be applied because', { list: template.definitionErrors.join(' · ') })}</p>`\n : nothing}\n ${template.tasks.length\n ? html`<ul tasks>\n ${template.tasks.map(id => html`<li>${taskName(id)}</li>`)}\n </ul>`\n : nothing}\n `\n }\n\n private pick(template: TemplateView) {\n this.scratch = false\n this.selected = template\n this.name = template.name\n }\n\n private pickScratch() {\n this.scratch = true\n this.selected = undefined\n this.name = ''\n }\n\n private canCreate() {\n return Boolean(this.name.trim()) && (this.scratch || (Boolean(this.selected) && !this.selected!.definitionErrors.length))\n }\n\n private async create() {\n const created = this.scratch\n ? await this.page?.createBlankRole?.(this.name.trim(), this.description.trim())\n : await this.page?.createFromTemplate?.(this.selected!.id, this.name.trim())\n\n if (created) {\n this.dispatchEvent(new CustomEvent('close'))\n }\n }\n}\n\n/** A task's name in the words of the module that declares it; the id when there are none. */\nfunction taskName(id: string): string {\n return i18next.t(`task.${id}.name`, { defaultValue: id })\n}\n"]}
1
+ {"version":3,"file":"role-template-picker.js","sourceRoot":"","sources":["../../client/components/role-template-picker.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/icon/icon.js'\n\nimport gql from 'graphql-tag'\nimport { css, html, LitElement, nothing } from 'lit'\nimport { customElement, property, state } from 'lit/decorators.js'\n\nimport type { CSSResultGroup } from 'lit'\n\nimport { client, gqlContext } from '@operato/graphql'\nimport { i18next, localize } from '@operato/i18n'\n\nimport { axisLabel } from '../utils/privilege-axis.js'\nimport { CommonHeaderStyles, FormFieldStyles, ScrollbarStyles } from '@operato/styles'\n\ntype GrantView = { category: string; axes: string[]; missingAxes: string[] }\ntype TemplateView = {\n id: string\n name: string\n description?: string\n grants: GrantView[]\n privilegeCount: number\n missingCount: number\n defaultNameTaken: boolean\n grantsNothingDeliberately: boolean\n tasks: string[]\n definitionErrors: string[]\n}\n\n/*\n * The axis vocabulary lives in `utils/privilege-axis.ts` now — one table, not the copy that\n * used to sit here beside an identical one in `role-privilege-page.ts`. It also stopped\n * translating `query`/`mutation` into 「읽기」/「쓰기」; that file says why.\n */\n\n/**\n * The module, in words people use.\n *\n * Same rule as the role privilege page (`moduleLabel` there): use\n * `privilege.category.<category>` when it exists and **fall back to the code word**. Not\n * inventing one is the point — a made-up name would read as product vocabulary and hide\n * from the UX lane that the sentence is still missing.\n */\nfunction moduleLabel(category: string): string {\n return i18next.t(`privilege.category.${category}`, { defaultValue: category })\n}\n\n/** Stable order inside a group, by the name a person actually reads. */\nfunction byName(a: GrantView, b: GrantView): number {\n return moduleLabel(a.category).localeCompare(moduleLabel(b.category))\n}\n\n/**\n * Starting a role.\n *\n * ── Why this stands where the blank row used to ──────────────────────────────\n * A new plant has no roles, and the old answer to \"make one\" was an empty line waiting for\n * a name. Naming it is the easy half; the hard half is which of 84 privileges belong to a\n * quality inspector, and that is a judgement the product has made and the plant has not.\n *\n * miratek is the case: two roles (Administrator, TWIN-CONNECTOR), no MES seats, and eleven\n * approval doors standing empty because there is nothing to route them to. Read from\n * inside, that looks like a plant that has not decided. It is a plant with nothing to\n * decide from.\n *\n * ── Both doors, in one place ─────────────────────────────────────────────────\n * The templates come first and the blank name last, because the order is what teaches: a\n * plant that sees \"quality inspector, 7 privileges\" learns what a seat looks like before\n * it is asked to invent one. Hiding the blank door would be worse, though — the seats a\n * factory actually has (\"night shift lead\", \"outside inspector\") are its own, and the\n * product does not know them.\n *\n * ── Why the name is editable ─────────────────────────────────────────────────\n * The template knows the seat exists; it does not know what the people there call it. And\n * because a role is also what an approval line points at, the same seat is sometimes\n * needed twice — a day and a night inspector hold the same privileges and take different\n * approvals. A fixed name would allow each template exactly once per domain.\n */\n@customElement('role-template-picker')\nexport class RoleTemplatePicker extends localize(i18next)(LitElement) {\n static styles: CSSResultGroup = [\n ScrollbarStyles,\n /*\n * Native controls, styled by the shared block.\n *\n * This used `md-filled-text-field` and the Material buttons until the user looked at it\n * (2026-09-11): \"too big, and the style does not fit.\" They are right — a filled field\n * with a floating label is a form control from a different density than the pills,\n * chips and 30px rows this product uses everywhere else.\n *\n * `FormFieldStyles` exists precisely for this: a component draws its input inside its\n * own shadow root, where the theme stylesheet cannot reach, so the token wiring has to\n * be declared per component. It goes first so local rules below can override it.\n */\n FormFieldStyles,\n /* The popup footer already has a convention — plain buttons, pill on pointer devices. */\n CommonHeaderStyles,\n css`\n /*\n * 글 크기를 이 창이 정한다(2026-10-28 사용자: 「추가 팝업도 스타일이 벙벙하다」). 팝업 몸통의 기본\n * 글 크기를 그대로 받아 자리 이름이 제목만 하게 컸고, 줄 사이도 그만큼 벌어졌다.\n */\n :host {\n display: flex;\n flex-direction: column;\n background-color: var(--md-sys-color-surface-container-lowest);\n color: var(--md-sys-color-on-surface);\n font-size: var(--fontsize-default, 14px);\n height: 100%;\n overflow: hidden;\n }\n\n /*\n * 두 칸은 상자가 아니라 한 면을 가르는 선 하나로 나뉜다(2026-10-28 사용자: 「팝업 내부에서 어설픈 라운드 섹션\n * 쓰지마라」). 둥근 상자 둘이 팝업 안에 또 떠 있었다.\n */\n [body] {\n display: grid;\n grid-template-columns: minmax(240px, 320px) 1fr;\n flex: 1;\n min-height: 0;\n border-top: 1px solid var(--md-sys-color-outline-variant);\n }\n\n [panel] {\n display: flex;\n flex-direction: column;\n min-height: 0;\n overflow: hidden;\n }\n\n [panel] + [panel] {\n border-left: 1px solid var(--md-sys-color-outline-variant);\n }\n\n [panel] > header {\n display: flex;\n align-items: center;\n gap: 6px;\n padding: var(--spacing-small, 6px) var(--spacing-medium, 10px);\n font-size: var(--fontsize-small, 13px);\n color: var(--md-sys-color-on-surface-variant);\n border-bottom: 1px solid var(--md-sys-color-outline-variant);\n }\n\n [count] {\n color: var(--md-sys-color-primary);\n font-weight: 600;\n }\n\n ul {\n list-style: none;\n margin: 0;\n padding: 0;\n overflow-y: auto;\n flex: 1;\n }\n\n li[seat] {\n display: flex;\n flex-direction: column;\n gap: 1px;\n padding: 6px var(--spacing-medium, 10px);\n border-bottom: 1px solid var(--md-sys-color-outline-variant);\n cursor: pointer;\n }\n\n li[seat]:hover {\n background-color: var(--md-sys-color-surface-container-high);\n }\n\n li[seat][selected] {\n background-color: var(--md-sys-color-secondary-container);\n color: var(--md-sys-color-on-secondary-container);\n }\n\n li[seat] [title-row] {\n display: flex;\n align-items: center;\n gap: 6px;\n }\n\n li[seat] [name] {\n font-size: var(--fontsize-default, 14px);\n font-weight: 600;\n }\n\n li[seat] md-icon {\n --md-icon-size: 16px;\n }\n\n li[seat] small {\n color: var(--md-sys-color-on-surface-variant);\n font-size: var(--fontsize-small, 12px);\n }\n\n li[seat][selected] small {\n color: inherit;\n }\n\n [tag] {\n font-size: var(--fontsize-smaller, 11px);\n padding: 0 6px;\n border-radius: var(--md-sys-shape-corner-full, 999px);\n background-color: var(--md-sys-color-surface-container-highest);\n color: var(--md-sys-color-on-surface-variant);\n }\n\n [tag][warn] {\n background-color: var(--md-sys-color-error-container);\n color: var(--md-sys-color-on-error-container);\n }\n\n [detail] {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-medium, 10px);\n padding: var(--spacing-medium, 10px) var(--spacing-large, 14px);\n overflow-y: auto;\n flex: 1;\n }\n\n /*\n * 이름 · 설명은 한 줄에 하나 — 이름표가 왼쪽 한 열에 서고 칸이 나머지를 채운다(2026-10-28 사용자: 「이름 수정\n * 부분이 있는데, 그런것은 왜 두줄로 만드냐」). 이름표 열은 가장 긴 이름표만큼이라 두 줄의 칸 머리가 맞는다.\n */\n [form] {\n display: grid;\n grid-template-columns: max-content 1fr;\n align-items: center;\n gap: 6px 12px;\n }\n\n /*\n * 첫 줄이 무엇에서 시작하는지 말한다 — 창 이름이 「역할 추가」뿐이라 고르는 것이 템플릿이라는 것을 알 수\n * 없었다(2026-10-28 사용자). 이름을 고쳐도 어느 템플릿인지는 이 줄에 남는다.\n */\n [form] label,\n [form] [label] {\n font-size: var(--fontsize-small, 13px);\n color: var(--md-sys-color-on-surface-variant);\n }\n\n [form] [value] {\n font-weight: 600;\n }\n\n [form] input {\n width: 100%;\n box-sizing: border-box;\n }\n\n [form] [note] {\n grid-column: 2;\n }\n\n /* 업무 · 권한 — 머리말 한 줄이 두 덩어리를 가른다. */\n section {\n display: flex;\n flex-direction: column;\n gap: 8px;\n padding-top: var(--spacing-medium, 10px);\n border-top: 1px solid var(--md-sys-color-outline-variant);\n }\n\n section > h4 {\n display: flex;\n align-items: baseline;\n gap: 6px;\n margin: 0;\n font-size: var(--fontsize-small, 13px);\n font-weight: 600;\n color: var(--md-sys-color-on-surface-variant);\n }\n\n /* 곁말 — 필드 밑에 붙거나 혼자 선다. 본문과 같은 무게로 두면 읽는 순서가 흐려진다. */\n [note] {\n margin: 0;\n font-size: var(--fontsize-small, 12px);\n color: var(--md-sys-color-on-surface-variant);\n line-height: 1.5;\n }\n\n [note][attention] {\n color: var(--md-sys-color-error);\n }\n\n /* The seat's work - the words a person reads first, as chips that wrap; the modules below are the detail. */\n ul[tasks] {\n flex: none;\n overflow: visible;\n display: flex;\n flex-wrap: wrap;\n gap: 4px 6px;\n }\n\n ul[tasks] li {\n padding: 2px 10px;\n border-radius: var(--md-sys-shape-corner-full, 999px);\n background-color: var(--md-sys-color-surface-container-high);\n font-size: var(--fontsize-small, 13px);\n }\n\n dl[grants] {\n display: grid;\n grid-template-columns: minmax(8em, max-content) 1fr;\n /* 줄 사이가 2px 이었다. 이름이 위아래로 붙어 한 덩어리로 읽혔다. */\n gap: 6px var(--spacing-large, 14px);\n margin: 0;\n align-items: baseline;\n }\n\n /*\n * 모듈 이름이 **기준선**이다 — 굵기로 두 칸이 저절로 갈린다.\n *\n * 처음엔 거꾸로였다: 이름을 흐리게 두고 「쓰기」를 굵게 했더니, 눈이 먼저 닿는 것이\n * 오른쪽 칸이 되어 왼쪽이 뭘 말하는지 훑어지지 않았다. 사람이 이 표에서 찾는 것은\n * 「이 자리가 **어느 모듈**을 여나」이고, 읽기냐 쓰기냐는 그 다음이다.\n */\n dl[grants] dt {\n color: var(--md-sys-color-on-surface);\n font-size: var(--fontsize-small, 13px);\n font-weight: 600;\n }\n\n dl[grants] dd {\n margin: 0;\n display: flex;\n flex-wrap: wrap;\n /* 낱말 사이 가운뎃점 — 「읽기 쓰기」가 한 낱말로 붙어 보이던 것을 가른다. */\n gap: 0 6px;\n }\n\n dl[grants] dd [axis] + [axis]::before {\n content: '·';\n margin-right: 6px;\n color: var(--md-sys-color-outline);\n }\n\n [axis] {\n font-size: var(--fontsize-small, 12px);\n color: var(--md-sys-color-on-surface-variant);\n }\n\n [axis][missing] {\n text-decoration: line-through;\n color: var(--md-sys-color-outline);\n }\n\n [empty] {\n margin: auto;\n text-align: center;\n color: var(--md-sys-color-on-surface-variant);\n font-size: var(--fontsize-small, 13px);\n padding: var(--spacing-large, 16px);\n }\n\n /* 자리 배치만 더한다 — 모양은 CommonHeaderStyles 의 .footer 가 갖는다. */\n .footer {\n justify-content: flex-end;\n }\n\n .footer [how] {\n margin: 0 auto 0 0;\n font-size: var(--fontsize-small, 13px);\n color: var(--md-sys-color-on-surface-variant);\n }\n `\n ]\n\n @property({ type: Object }) page?: any\n\n @state() private templates: TemplateView[] = []\n @state() private selected?: TemplateView\n /** `undefined` means \"the blank door\" — name the seat yourself. */\n @state() private scratch = false\n @state() private name = ''\n @state() private description = ''\n\n async connectedCallback() {\n super.connectedCallback()\n await this.fetchTemplates()\n }\n\n private async fetchTemplates() {\n const response = await client.query({\n query: gql`\n query {\n roleTemplates {\n id\n name\n description\n privilegeCount\n missingCount\n defaultNameTaken\n grantsNothingDeliberately\n tasks\n definitionErrors\n grants {\n category\n axes\n missingAxes\n }\n }\n }\n `,\n context: gqlContext()\n })\n\n /*\n * A GraphQL error arrives as `data: null` with `errors` set, and `?? []` would draw\n * that as \"this installation offers nothing\" — a refusal made to look like an answer.\n */\n if (response.errors?.length) {\n document.dispatchEvent(\n new CustomEvent('notify', { detail: { level: 'error', message: response.errors[0]?.message } })\n )\n return\n }\n\n this.templates = response.data?.roleTemplates || []\n\n /* 처음부터 하나를 보인다 — 아무것도 안 고른 창은 오른쪽 절반이 빈 채로 열렸다. 만드는 것은 여전히 「추가」를 눌러야 한다. */\n const first = this.templates.find(template => !template.definitionErrors.length)\n if (first && !this.selected && !this.scratch) this.pick(first)\n }\n\n render() {\n return html`\n <div body>\n <div panel>\n <header>\n ${i18next.t('field.role templates')}\n <span count>${this.templates.length}</span>\n </header>\n <ul>\n ${this.templates.map(\n template => html`\n <li seat ?selected=${!this.scratch && this.selected?.id === template.id} @click=${() => this.pick(template)}>\n <div title-row>\n <span name>${template.name}</span>\n ${template.defaultNameTaken\n ? html`<span tag>${i18next.t('text.already in this domain')}</span>`\n : nothing}\n ${template.definitionErrors.length\n ? html`<span tag warn>${i18next.t('text.template cannot be applied')}</span>`\n : template.missingCount\n ? html`<span tag warn>${i18next.t('text.x not here', { x: template.missingCount })}</span>`\n : nothing}\n </div>\n <small>${this.summarize(template)}</small>\n </li>\n `\n )}\n <li seat ?selected=${this.scratch} @click=${() => this.pickScratch()}>\n <div title-row>\n <md-icon>edit</md-icon>\n <span name>${i18next.t('label.no template')}</span>\n </div>\n <small>${i18next.t('text.an empty role with no privileges')}</small>\n </li>\n </ul>\n </div>\n\n <div panel>${this.renderDetail()}</div>\n </div>\n\n <div class=\"footer\">\n ${this.renderHowItStarts()}\n <button @click=${() => this.dispatchEvent(new CustomEvent('close'))}>${i18next.t('button.cancel')}</button>\n <button ok ?disabled=${!this.canCreate()} @click=${() => this.create()}>${i18next.t('button.add')}</button>\n </div>\n `\n }\n\n /**\n * 「추가」가 무엇을 하는지 단추 줄에서 말한다. 이 창에서는 업무 · 권한을 고칠 수 없어 사람이 의아해한다 — 템플릿과\n * 같은 상태로 시작하고, 고치는 것은 목록의 「권한 편집」에서 따로 한다(2026-10-28 사용자).\n */\n private renderHowItStarts() {\n const words = { add: i18next.t('button.add'), edit: i18next.t('button.edit privileges') }\n\n if (this.scratch) {\n return html`<p how>${i18next.t('text.add starts an empty role', words)}</p>`\n }\n\n if (!this.selected) {\n return nothing\n }\n\n return html`<p how>${i18next.t('text.add starts the role as the template is', words)}</p>`\n }\n\n /**\n * What the seat opens, in one line — **counted, not interpreted.**\n *\n * This briefly said \"changes 6 · only sees 8\", which read well and was not something the\n * data supports: the privilege axis is an unconstrained string and its name does not\n * determine what the resolver behind it does. Counting is safe; grouping by meaning was\n * not. See `renderOpens`.\n *\n * \"0 privileges\" is still two different facts and must not be merged: granting nothing\n * on purpose is an approval seat, and a template whose grants are all missing here is a\n * defect (`missingCount`).\n */\n private summarize(template: TemplateView) {\n if (template.grantsNothingDeliberately) {\n return i18next.t('text.opens nothing, receives approvals')\n }\n\n /* 업무로 지은 자리는 업무 수로 — 오른쪽이 먼저 보이는 것이 업무다. */\n if (template.tasks.length) {\n return i18next.t('text.x tasks y privileges', { x: template.tasks.length, y: template.privilegeCount })\n }\n\n return i18next.t('text.x modules y privileges', {\n x: template.grants.filter(grant => grant.axes.length).length,\n y: template.privilegeCount\n })\n }\n\n private renderDetail() {\n if (this.scratch) {\n return html`\n <div detail>\n <div form>\n <span label>${i18next.t('field.role template')}</span>\n <span value>${i18next.t('label.no template')}</span>\n <label for=\"name\">${i18next.t('field.new role name')}</label>\n <input id=\"name\" .value=${this.name} @input=${(e: any) => (this.name = e.target.value)} />\n <label for=\"description\">${i18next.t('field.description')}</label>\n <input id=\"description\" .value=${this.description} @input=${(e: any) => (this.description = e.target.value)} />\n </div>\n </div>\n `\n }\n\n if (!this.selected) {\n return html`<div empty>${i18next.t('text.pick a seat to see what it opens')}</div>`\n }\n\n const template = this.selected\n\n return html`\n <div detail>\n <div form>\n <span label>${i18next.t('field.role template')}</span>\n <span value>${template.name}</span>\n <label for=\"name\">${i18next.t('field.new role name')}</label>\n <input id=\"name\" .value=${this.name} @input=${(e: any) => (this.name = e.target.value)} />\n ${template.defaultNameTaken && this.name === template.name\n ? html`<p note>${i18next.t('text.a role of this name exists and will be added to')}</p>`\n : nothing}\n </div>\n ${this.renderTasks(template)}\n <section>\n <h4>${i18next.t('field.privileges')} <span count>${template.privilegeCount}</span></h4>\n ${this.renderOpens(template)}\n </section>\n </div>\n `\n }\n\n /**\n * 이 자리가 무엇을 여나 — **선언된 것만.**\n *\n * ⚠ 여기 「바꿀 수 있는 것 / 볼 수만 있는 것」 두 묶음이 있었다. 사용자가 물었다(2026-09-11):\n * 「바꿀 수 있고, 볼 수만 있는 것을 너가 어떻게 알아? 논리적이지 않은 것을 우겨넣지 말고.」\n *\n * 몰랐다. 지어낸 것이다. 근거가 없는 이유가 둘이다.\n *\n * ① `@privilege(privilege:)` 는 **제약 없는 문자열**이다(지시자 선언이 `privilege: String`\n * 이고 enum 이 아니다). 지금 쓰인 값이 mutation 484 · query 314 로 둘뿐인 것은 **오늘\n * 그렇게 쓴 것**이지 둘로 한정된 것이 아니다. 내 코드는 `axis !== 'query'` 로 갈랐으므로,\n * 처음 보는 값은 **알아서가 아니라 query 가 아니라서** 「바꿀 수 있는 것」으로 떨어졌다.\n * ② 이름이 뜻을 보장하지 않는다. 축 이름과 실제 GraphQL 연산이 어긋나는 자리가 6곳이고,\n * 그중 둘은 이 기능의 내 파일이다(`role-template-query.ts` — 템플릿을 **읽는** 질의에\n * `privilege: \"mutation\"` 을 달았다. 역할을 만드는 문이라서 그게 맞다). 축은 연산 종류가\n * 아니라 **어느 문을 지나야 하나**의 이름이다.\n *\n * 그래서 화면은 선언된 것을 그대로 낸다 — 어느 모듈에, 어느 권한이. 읽히게 만드는 것\n * (굵기·줄 간격·가운뎃점)은 아무것도 주장하지 않으므로 그대로 둔다.\n */\n private renderOpens(template: TemplateView) {\n if (template.grantsNothingDeliberately) {\n return html`<p note>${i18next.t('text.opens nothing, receives approvals')}</p>`\n }\n\n return html`\n <dl grants>\n ${[...template.grants].sort(byName).map(\n grant => html`\n <dt>${moduleLabel(grant.category)}</dt>\n <dd>\n ${grant.axes.map(axis => html`<span axis>${axisLabel(axis)}</span>`)}\n ${grant.missingAxes.map(axis => html`<span axis missing>${axisLabel(axis)}</span>`)}\n </dd>\n `\n )}\n </dl>\n ${template.missingCount\n ? html`<p note>${i18next.t('text.struck through are not installed here')}</p>`\n : nothing}\n `\n }\n\n /**\n * The work this seat does, by task (ADR-0139 decision 11) — named in the words of the module that declares each.\n * A template the framework refuses to apply says why here, before anyone presses create.\n */\n private renderTasks(template: TemplateView) {\n return html`\n ${template.definitionErrors.length\n ? html`<p note attention>${i18next.t('text.template cannot be applied because', { list: template.definitionErrors.join(' · ') })}</p>`\n : nothing}\n ${template.tasks.length\n ? html`<section>\n <h4>${i18next.t('label.tasks view')} <span count>${template.tasks.length}</span></h4>\n <ul tasks>\n ${template.tasks.map(id => html`<li>${taskName(id)}</li>`)}\n </ul>\n </section>`\n : nothing}\n `\n }\n\n private pick(template: TemplateView) {\n this.scratch = false\n this.selected = template\n this.name = template.name\n }\n\n private pickScratch() {\n this.scratch = true\n this.selected = undefined\n this.name = ''\n }\n\n private canCreate() {\n return Boolean(this.name.trim()) && (this.scratch || (Boolean(this.selected) && !this.selected!.definitionErrors.length))\n }\n\n private async create() {\n const created = this.scratch\n ? await this.page?.createBlankRole?.(this.name.trim(), this.description.trim())\n : await this.page?.createFromTemplate?.(this.selected!.id, this.name.trim())\n\n if (created) {\n this.dispatchEvent(new CustomEvent('close'))\n }\n }\n}\n\n/** A task's name in the words of the module that declares it; the id when there are none. */\nfunction taskName(id: string): string {\n return i18next.t(`task.${id}.name`, { defaultValue: id })\n}\n"]}
@@ -0,0 +1,41 @@
1
+ import { readFileSync } from 'fs';
2
+ import path from 'path';
3
+ /**
4
+ * The add-role window as one composed panel (the user, 2026-10-28: 「이름 수정 부분이 있는데, 그런것은 왜 두줄로
5
+ * 만드냐」 · 「짜임새있게」). It needs a DOM, so the test reads its source with comments stripped.
6
+ */
7
+ const code = readFileSync(path.join(__dirname, 'role-template-picker.ts'), 'utf-8')
8
+ .replace(/\/\*[\s\S]*?\*\//g, '')
9
+ .replace(/^\s*\/\/.*$/gm, '');
10
+ const detail = code.slice(code.indexOf('private renderDetail('), code.indexOf('private renderOpens('));
11
+ describe('the add-role window', () => {
12
+ it('★ opens with the first template that can be applied already shown - no blank half', () => {
13
+ expect(code).toMatch(/const first = this\.templates\.find\(template => !template\.definitionErrors\.length\)\s*if \(first && !this\.selected && !this\.scratch\) this\.pick\(first\)/);
14
+ });
15
+ it('★ puts each field on one line - its label in a column beside it', () => {
16
+ expect(code).toMatch(/\[form\] \{\s*display: grid;\s*grid-template-columns: max-content 1fr;/);
17
+ expect(detail).not.toMatch(/<label field>/);
18
+ expect(detail.match(/<div form>/g)).toHaveLength(2);
19
+ });
20
+ it('★ says it starts from a template - the list is headed so, and the first row names the one picked', () => {
21
+ expect(code).toMatch(/\$\{i18next\.t\('field\.role templates'\)\}\s*<span count>/);
22
+ expect(detail).toMatch(/<span label>\$\{i18next\.t\('field\.role template'\)\}<\/span>\s*<span value>\$\{template\.name\}<\/span>\s*<label for="name">\$\{i18next\.t\('field\.new role name'\)\}<\/label>/);
23
+ expect(detail).toMatch(/<span value>\$\{i18next\.t\('label\.no template'\)\}<\/span>/);
24
+ const words = JSON.parse(readFileSync(path.join(__dirname, '../../translations/ko.json'), 'utf-8'));
25
+ expect([words['field.role templates'], words['field.role template'], words['label.no template']]).toEqual(['시작할 템플릿', '선택한 템플릿', '템플릿 없이']);
26
+ expect(words['field.new role name']).toBe('새 역할 이름');
27
+ });
28
+ it('★ the button line says 추가 starts the role as the template is, and changes are made in 권한 편집', () => {
29
+ const footer = code.slice(code.indexOf('<div class="footer">'), code.indexOf('</div>', code.indexOf('<div class="footer">')));
30
+ expect(footer).toMatch(/\$\{this\.renderHowItStarts\(\)\}/);
31
+ const how = code.slice(code.indexOf('private renderHowItStarts('), code.indexOf('private summarize('));
32
+ expect(how).toMatch(/text\.add starts an empty role/);
33
+ expect(how).toMatch(/text\.add starts the role as the template is/);
34
+ const words = JSON.parse(readFileSync(path.join(__dirname, '../../translations/ko.json'), 'utf-8'));
35
+ expect(words['text.add starts the role as the template is']).toMatch(/\{add\}[\s\S]*\{edit\}/);
36
+ });
37
+ it('does not repeat the template name in a bar above the name field', () => {
38
+ expect(detail).not.toMatch(/<header>/);
39
+ });
40
+ });
41
+ //# sourceMappingURL=role-template-picker.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"role-template-picker.test.js","sourceRoot":"","sources":["../../client/components/role-template-picker.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { readFileSync } from 'fs'\nimport path from 'path'\n\n/**\n * The add-role window as one composed panel (the user, 2026-10-28: 「이름 수정 부분이 있는데, 그런것은 왜 두줄로\n * 만드냐」 · 「짜임새있게」). It needs a DOM, so the test reads its source with comments stripped.\n */\n\nconst code = readFileSync(path.join(__dirname, 'role-template-picker.ts'), 'utf-8')\n .replace(/\\/\\*[\\s\\S]*?\\*\\//g, '')\n .replace(/^\\s*\\/\\/.*$/gm, '')\nconst detail = code.slice(code.indexOf('private renderDetail('), code.indexOf('private renderOpens('))\n\ndescribe('the add-role window', () => {\n it('★ opens with the first template that can be applied already shown - no blank half', () => {\n expect(code).toMatch(/const first = this\\.templates\\.find\\(template => !template\\.definitionErrors\\.length\\)\\s*if \\(first && !this\\.selected && !this\\.scratch\\) this\\.pick\\(first\\)/)\n })\n\n it('★ puts each field on one line - its label in a column beside it', () => {\n expect(code).toMatch(/\\[form\\] \\{\\s*display: grid;\\s*grid-template-columns: max-content 1fr;/)\n expect(detail).not.toMatch(/<label field>/)\n expect(detail.match(/<div form>/g)).toHaveLength(2)\n })\n\n it('★ says it starts from a template - the list is headed so, and the first row names the one picked', () => {\n expect(code).toMatch(/\\$\\{i18next\\.t\\('field\\.role templates'\\)\\}\\s*<span count>/)\n expect(detail).toMatch(/<span label>\\$\\{i18next\\.t\\('field\\.role template'\\)\\}<\\/span>\\s*<span value>\\$\\{template\\.name\\}<\\/span>\\s*<label for=\"name\">\\$\\{i18next\\.t\\('field\\.new role name'\\)\\}<\\/label>/)\n expect(detail).toMatch(/<span value>\\$\\{i18next\\.t\\('label\\.no template'\\)\\}<\\/span>/)\n const words = JSON.parse(readFileSync(path.join(__dirname, '../../translations/ko.json'), 'utf-8'))\n expect([words['field.role templates'], words['field.role template'], words['label.no template']]).toEqual(['시작할 템플릿', '선택한 템플릿', '템플릿 없이'])\n expect(words['field.new role name']).toBe('새 역할 이름')\n })\n\n it('★ the button line says 추가 starts the role as the template is, and changes are made in 권한 편집', () => {\n const footer = code.slice(code.indexOf('<div class=\"footer\">'), code.indexOf('</div>', code.indexOf('<div class=\"footer\">')))\n expect(footer).toMatch(/\\$\\{this\\.renderHowItStarts\\(\\)\\}/)\n const how = code.slice(code.indexOf('private renderHowItStarts('), code.indexOf('private summarize('))\n expect(how).toMatch(/text\\.add starts an empty role/)\n expect(how).toMatch(/text\\.add starts the role as the template is/)\n const words = JSON.parse(readFileSync(path.join(__dirname, '../../translations/ko.json'), 'utf-8'))\n expect(words['text.add starts the role as the template is']).toMatch(/\\{add\\}[\\s\\S]*\\{edit\\}/)\n })\n\n it('does not repeat the template name in a bar above the name field', () => {\n expect(detail).not.toMatch(/<header>/)\n })\n})\n"]}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,61 @@
1
+ import { readFileSync } from 'fs';
2
+ import path from 'path';
3
+ /**
4
+ * How a role is added and deleted on the role list (the user, 2026-10-28: 「도대체 어떻게 추가하고, 삭제한다는
5
+ * 말인가」). One way to add - the 「역할 추가」 window, no blank row in the grid - and deleting acts on picked rows,
6
+ * says first which picked roles people still hold, and deletes none of them. The page needs a DOM, so the test
7
+ * reads its source with comments stripped.
8
+ */
9
+ const read = (file) => readFileSync(path.join(__dirname, file), 'utf-8');
10
+ const strip = (source) => source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '').replace(/<!--[\s\S]*?-->/g, '');
11
+ const code = strip(read('role-list-page.ts'));
12
+ const deleting = code.slice(code.indexOf('private async onDeleteGrid('), code.indexOf('private async openCreate('));
13
+ const words = JSON.parse(readFileSync(path.join(__dirname, '../../../translations/ko.json'), 'utf-8'));
14
+ describe('adding a role', () => {
15
+ it('★ the button says what it adds', () => {
16
+ expect(code).toMatch(/<button add @click=\$\{\(\) => this\.openCreate\(\)\}>\s*<md-icon>add<\/md-icon>\$\{i18next\.t\('button\.add role'\)\}/);
17
+ expect(words['button.add role']).toBe('역할 추가');
18
+ });
19
+ it('★ the grid draws no blank row to type a role into', () => {
20
+ expect(code).toMatch(/rows: \{\s*appendable: false,/);
21
+ });
22
+ it('the window is titled the same', () => {
23
+ expect(code).toMatch(/title: i18next\.t\('button\.add role'\)/);
24
+ });
25
+ });
26
+ describe('deleting a role', () => {
27
+ it('★ rows can be picked', () => {
28
+ expect(code).toMatch(/\{ type: 'gutter', gutterName: 'row-selector', multiple: true, fixed: true \}/);
29
+ });
30
+ it('★ the list asks how many people hold each role and shows it', () => {
31
+ expect(code).toMatch(/privilegeCount\s+userCount\s+templateGap/);
32
+ expect(code).toMatch(/name: 'userCount',[\s\S]{0,120}renderer: userCountCell/);
33
+ });
34
+ it('★ names the picked roles people hold and stops before asking the server', () => {
35
+ const held = deleting.indexOf("record.userCount > 0");
36
+ expect(held).toBeGreaterThan(0);
37
+ expect(deleting.indexOf('deleteRoles(ids')).toBeGreaterThan(held);
38
+ expect(deleting.slice(held, deleting.indexOf('deleteRoles(ids'))).toMatch(/text\.roles held by users are not deleted[\s\S]*?return\s*\}/);
39
+ });
40
+ it('★ names picked roles inherited from the parent and stops - even mixed with this domain\'s own', () => {
41
+ const inherited = deleting.indexOf('this.isInherited(record)');
42
+ expect(inherited).toBeGreaterThan(0);
43
+ expect(deleting.slice(inherited, deleting.indexOf('deleteRoles(ids'))).toMatch(/if \(inherited\.length\) \{[\s\S]*?text\.inherited roles are not deleted here[\s\S]*?return\s*\}/);
44
+ expect(deleting).toMatch(/const mine = selected\n/);
45
+ });
46
+ it('says to pick rows when none are picked, instead of doing nothing', () => {
47
+ expect(deleting).toMatch(/if \(!selected\?\.length\) \{\s*await OxPrompt\.open\([\s\S]*?text\.pick the roles to delete/);
48
+ });
49
+ it('names what it is about to delete before deleting it', () => {
50
+ expect(deleting).toMatch(/text\.delete these roles', \{ names: mine\.map/);
51
+ });
52
+ it('every sentence has its words in every language', () => {
53
+ const keys = ['button.add role', 'field.role users', 'text.n people', 'text.pick the roles to delete', 'text.roles held by users are not deleted', 'text.role and its users', 'text.delete these roles', 'text.inherited roles are not deleted here'];
54
+ for (const language of ['ko', 'en', 'ja', 'zh', 'es', 'ms']) {
55
+ const all = JSON.parse(readFileSync(path.join(__dirname, `../../../translations/${language}.json`), 'utf-8'));
56
+ for (const key of keys)
57
+ expect([language, key, typeof all[key]]).toEqual([language, key, 'string']);
58
+ }
59
+ });
60
+ });
61
+ //# sourceMappingURL=role-add-delete.test.js.map