@things-factory/auth-ui 10.1.225 → 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.
- package/dist-client/components/role-template-picker.d.ts +5 -0
- package/dist-client/components/role-template-picker.js +151 -42
- package/dist-client/components/role-template-picker.js.map +1 -1
- package/dist-client/components/role-template-picker.test.d.ts +1 -0
- package/dist-client/components/role-template-picker.test.js +41 -0
- package/dist-client/components/role-template-picker.test.js.map +1 -0
- package/dist-client/identity-confirmation/identity-confirm-popup.d.ts +23 -0
- package/dist-client/identity-confirmation/identity-confirm-popup.js +121 -0
- package/dist-client/identity-confirmation/identity-confirm-popup.js.map +1 -0
- package/dist-client/identity-confirmation/identity-confirmation.d.ts +52 -0
- package/dist-client/identity-confirmation/identity-confirmation.js +148 -0
- package/dist-client/identity-confirmation/identity-confirmation.js.map +1 -0
- package/dist-client/index.d.ts +1 -0
- package/dist-client/index.js +2 -0
- package/dist-client/index.js.map +1 -1
- package/dist-client/pages/role/role-add-delete.test.d.ts +1 -0
- package/dist-client/pages/role/role-add-delete.test.js +61 -0
- package/dist-client/pages/role/role-add-delete.test.js.map +1 -0
- package/dist-client/pages/role/role-inheritance-shown.test.d.ts +1 -0
- package/dist-client/pages/role/role-inheritance-shown.test.js +31 -0
- package/dist-client/pages/role/role-inheritance-shown.test.js.map +1 -0
- package/dist-client/pages/role/role-list-page.d.ts +15 -11
- package/dist-client/pages/role/role-list-page.js +112 -53
- package/dist-client/pages/role/role-list-page.js.map +1 -1
- package/dist-client/pages/role/role-privilege-inherited.test.d.ts +1 -0
- package/dist-client/pages/role/role-privilege-inherited.test.js +23 -0
- package/dist-client/pages/role/role-privilege-inherited.test.js.map +1 -0
- package/dist-client/pages/role/role-privilege-page.d.ts +8 -5
- package/dist-client/pages/role/role-privilege-page.js +23 -13
- package/dist-client/pages/role/role-privilege-page.js.map +1 -1
- package/dist-client/pages/role/role-privilege-views.test.d.ts +1 -0
- package/dist-client/pages/role/role-privilege-views.test.js +19 -0
- package/dist-client/pages/role/role-privilege-views.test.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/helps/auth/application.ko.md +1 -1
- package/helps/auth/auth-provider.ko.md +1 -1
- package/helps/auth/domain-owners.ko.md +1 -1
- package/helps/operato-hub/partners.ko.md +1 -1
- package/helps/system/attribute.ko.md +1 -1
- package/package.json +6 -6
- package/translations/en.json +25 -10
- package/translations/es.json +25 -10
- package/translations/ja.json +25 -10
- package/translations/ko.json +26 -11
- package/translations/ms.json +25 -10
- package/translations/zh.json +25 -10
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { __decorate, __metadata } from "tslib";
|
|
2
|
+
import '@material/web/button/text-button.js';
|
|
3
|
+
import '@material/web/button/filled-button.js';
|
|
4
|
+
import '@material/web/button/outlined-button.js';
|
|
5
|
+
import '@operato/popup/ox-popup-actions.js';
|
|
6
|
+
import { css, html, LitElement, nothing } from 'lit';
|
|
7
|
+
import { customElement, state } from 'lit/decorators.js';
|
|
8
|
+
import { i18next, localize } from '@operato/i18n';
|
|
9
|
+
import { closePopup } from '@operato/popup';
|
|
10
|
+
import { CommonPopupStyles, FormFieldStyles, PopupBodyStyles, PopupFieldStyles } from '@operato/styles';
|
|
11
|
+
import { verifyBiometric } from '@things-factory/auth-base/dist-client';
|
|
12
|
+
/**
|
|
13
|
+
* Confirming it is still the signed-in person before a door that asks so (ADR-0168): the password, or a passkey
|
|
14
|
+
* where the browser has them. The popup closes with `true` once the server took the confirmation; the server's own
|
|
15
|
+
* sentence says why a password was not taken - a miss and how many, a lock.
|
|
16
|
+
*/
|
|
17
|
+
let IdentityConfirmPopup = class IdentityConfirmPopup extends localize(i18next)(LitElement) {
|
|
18
|
+
constructor() {
|
|
19
|
+
super(...arguments);
|
|
20
|
+
this.password = '';
|
|
21
|
+
this.refused = '';
|
|
22
|
+
this.busy = false;
|
|
23
|
+
}
|
|
24
|
+
static { this.styles = [
|
|
25
|
+
CommonPopupStyles,
|
|
26
|
+
PopupBodyStyles,
|
|
27
|
+
FormFieldStyles,
|
|
28
|
+
PopupFieldStyles,
|
|
29
|
+
css `
|
|
30
|
+
.note {
|
|
31
|
+
color: var(--ui-muted);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.failure {
|
|
35
|
+
color: var(--md-sys-color-error);
|
|
36
|
+
}
|
|
37
|
+
`
|
|
38
|
+
]; }
|
|
39
|
+
render() {
|
|
40
|
+
const passkeys = typeof window !== 'undefined' && 'PublicKeyCredential' in window;
|
|
41
|
+
return html `
|
|
42
|
+
<form class="body" @submit=${(e) => (e.preventDefault(), this.byPassword())}>
|
|
43
|
+
<div class="note">${i18next.t('text.identity confirmation why')}</div>
|
|
44
|
+
<label class="field">
|
|
45
|
+
<span>${i18next.t('field.password')}</span>
|
|
46
|
+
<input
|
|
47
|
+
type="password"
|
|
48
|
+
name="password"
|
|
49
|
+
autocomplete="current-password"
|
|
50
|
+
.value=${this.password}
|
|
51
|
+
@input=${(e) => ((this.password = e.target.value), (this.refused = ''))}
|
|
52
|
+
/>
|
|
53
|
+
</label>
|
|
54
|
+
${this.refused ? html `<div class="failure" role="alert">${this.refused}</div>` : nothing}
|
|
55
|
+
</form>
|
|
56
|
+
<ox-popup-actions>
|
|
57
|
+
${passkeys
|
|
58
|
+
? html `<md-outlined-button slot="start" ?disabled=${this.busy} @click=${() => this.byPasskey()}>
|
|
59
|
+
${i18next.t('button.confirm with passkey')}
|
|
60
|
+
</md-outlined-button>`
|
|
61
|
+
: nothing}
|
|
62
|
+
<md-text-button slot="cancel" @click=${() => closePopup(this, false)}>${i18next.t('button.cancel')}</md-text-button>
|
|
63
|
+
<md-filled-button slot="confirm" ?disabled=${this.busy || !this.password} @click=${() => this.byPassword()}>
|
|
64
|
+
${i18next.t('button.confirm')}
|
|
65
|
+
</md-filled-button>
|
|
66
|
+
</ox-popup-actions>
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
firstUpdated() {
|
|
70
|
+
;
|
|
71
|
+
this.renderRoot.querySelector('input[name=password]')?.focus();
|
|
72
|
+
}
|
|
73
|
+
async byPassword() {
|
|
74
|
+
if (!this.password || this.busy)
|
|
75
|
+
return;
|
|
76
|
+
this.busy = true;
|
|
77
|
+
try {
|
|
78
|
+
const response = await fetch('/auth/confirm-identity', {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
81
|
+
body: JSON.stringify({ password: this.password })
|
|
82
|
+
});
|
|
83
|
+
if (response.ok)
|
|
84
|
+
return closePopup(this, true);
|
|
85
|
+
this.password = '';
|
|
86
|
+
this.refused = (await response.text()) || i18next.t('text.identity not confirmed');
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
this.busy = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async byPasskey() {
|
|
93
|
+
this.busy = true;
|
|
94
|
+
try {
|
|
95
|
+
const result = await verifyBiometric();
|
|
96
|
+
if (result.verified)
|
|
97
|
+
return closePopup(this, true);
|
|
98
|
+
this.refused = i18next.t(result.needsRegistration ? 'text.no passkey to confirm with' : 'text.identity not confirmed');
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
this.busy = false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
__decorate([
|
|
106
|
+
state(),
|
|
107
|
+
__metadata("design:type", Object)
|
|
108
|
+
], IdentityConfirmPopup.prototype, "password", void 0);
|
|
109
|
+
__decorate([
|
|
110
|
+
state(),
|
|
111
|
+
__metadata("design:type", Object)
|
|
112
|
+
], IdentityConfirmPopup.prototype, "refused", void 0);
|
|
113
|
+
__decorate([
|
|
114
|
+
state(),
|
|
115
|
+
__metadata("design:type", Object)
|
|
116
|
+
], IdentityConfirmPopup.prototype, "busy", void 0);
|
|
117
|
+
IdentityConfirmPopup = __decorate([
|
|
118
|
+
customElement('identity-confirm-popup')
|
|
119
|
+
], IdentityConfirmPopup);
|
|
120
|
+
export { IdentityConfirmPopup };
|
|
121
|
+
//# sourceMappingURL=identity-confirm-popup.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity-confirm-popup.js","sourceRoot":"","sources":["../../client/identity-confirmation/identity-confirm-popup.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/button/text-button.js'\nimport '@material/web/button/filled-button.js'\nimport '@material/web/button/outlined-button.js'\nimport '@operato/popup/ox-popup-actions.js'\n\nimport { css, html, LitElement, nothing } from 'lit'\nimport { customElement, state } from 'lit/decorators.js'\n\nimport type { CSSResultGroup } from 'lit'\n\nimport { i18next, localize } from '@operato/i18n'\nimport { closePopup } from '@operato/popup'\nimport { CommonPopupStyles, FormFieldStyles, PopupBodyStyles, PopupFieldStyles } from '@operato/styles'\nimport { verifyBiometric } from '@things-factory/auth-base/dist-client'\n\n/**\n * Confirming it is still the signed-in person before a door that asks so (ADR-0168): the password, or a passkey\n * where the browser has them. The popup closes with `true` once the server took the confirmation; the server's own\n * sentence says why a password was not taken - a miss and how many, a lock.\n */\n@customElement('identity-confirm-popup')\nexport class IdentityConfirmPopup extends localize(i18next)(LitElement) {\n static styles: CSSResultGroup = [\n CommonPopupStyles,\n PopupBodyStyles,\n FormFieldStyles,\n PopupFieldStyles,\n css`\n .note {\n color: var(--ui-muted);\n }\n\n .failure {\n color: var(--md-sys-color-error);\n }\n `\n ]\n\n @state() private password = ''\n @state() private refused = ''\n @state() private busy = false\n\n render() {\n const passkeys = typeof window !== 'undefined' && 'PublicKeyCredential' in window\n\n return html`\n <form class=\"body\" @submit=${(e: Event) => (e.preventDefault(), this.byPassword())}>\n <div class=\"note\">${i18next.t('text.identity confirmation why')}</div>\n <label class=\"field\">\n <span>${i18next.t('field.password')}</span>\n <input\n type=\"password\"\n name=\"password\"\n autocomplete=\"current-password\"\n .value=${this.password}\n @input=${(e: InputEvent) => ((this.password = (e.target as HTMLInputElement).value), (this.refused = ''))}\n />\n </label>\n ${this.refused ? html`<div class=\"failure\" role=\"alert\">${this.refused}</div>` : nothing}\n </form>\n <ox-popup-actions>\n ${passkeys\n ? html`<md-outlined-button slot=\"start\" ?disabled=${this.busy} @click=${() => this.byPasskey()}>\n ${i18next.t('button.confirm with passkey')}\n </md-outlined-button>`\n : nothing}\n <md-text-button slot=\"cancel\" @click=${() => closePopup(this, false)}>${i18next.t('button.cancel')}</md-text-button>\n <md-filled-button slot=\"confirm\" ?disabled=${this.busy || !this.password} @click=${() => this.byPassword()}>\n ${i18next.t('button.confirm')}\n </md-filled-button>\n </ox-popup-actions>\n `\n }\n\n firstUpdated() {\n ;(this.renderRoot.querySelector('input[name=password]') as HTMLInputElement | null)?.focus()\n }\n\n private async byPassword() {\n if (!this.password || this.busy) return\n this.busy = true\n try {\n const response = await fetch('/auth/confirm-identity', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Accept: 'application/json' },\n body: JSON.stringify({ password: this.password })\n })\n if (response.ok) return closePopup(this, true)\n\n this.password = ''\n this.refused = (await response.text()) || i18next.t('text.identity not confirmed')\n } finally {\n this.busy = false\n }\n }\n\n private async byPasskey() {\n this.busy = true\n try {\n const result = await verifyBiometric()\n if (result.verified) return closePopup(this, true)\n\n this.refused = i18next.t(result.needsRegistration ? 'text.no passkey to confirm with' : 'text.identity not confirmed')\n } finally {\n this.busy = false\n }\n }\n}\n"]}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import './identity-confirm-popup.js';
|
|
2
|
+
import '@material/web/button/outlined-button.js';
|
|
3
|
+
import { LitElement } from 'lit';
|
|
4
|
+
import type { ReactiveController, ReactiveControllerHost, TemplateResult } from 'lit';
|
|
5
|
+
/** The server's refusal code for a door that asks for a recent confirmation. */
|
|
6
|
+
export declare const IDENTITY_CONFIRMATION_REFUSAL = "identity-confirmation-required";
|
|
7
|
+
/** The query context for such a door: the screen asks for the confirmation itself, so no error snackbar. */
|
|
8
|
+
export declare const CONFIRMED_IDENTITY_CONTEXT: {
|
|
9
|
+
handledRefusals: string[];
|
|
10
|
+
};
|
|
11
|
+
/** Whether `error` - an Apollo error, or a response with `errors` - is that refusal. */
|
|
12
|
+
export declare function needsIdentityConfirmation(error: any): boolean;
|
|
13
|
+
/** Ask the person to confirm it is them - true once the server took it. Two screens asking at once share one popup. */
|
|
14
|
+
export declare function confirmIdentity(): Promise<boolean>;
|
|
15
|
+
/**
|
|
16
|
+
* Run `read`; when the door refuses for want of a recent confirmation, ask for it and read once more. Undefined when
|
|
17
|
+
* the person did not confirm - the screen then says so (`identity-confirmation-notice`) rather than an error.
|
|
18
|
+
*/
|
|
19
|
+
export declare function withConfirmedIdentity<T>(read: () => Promise<T>): Promise<T | undefined>;
|
|
20
|
+
/** Until when the confirmation in this browser holds - null when there is none (ADR-0168 decision 3). */
|
|
21
|
+
export declare function confirmedUntil(): Promise<Date | null>;
|
|
22
|
+
declare const IdentityConfirmationNotice_base: (new (...args: any[]) => LitElement) & typeof LitElement;
|
|
23
|
+
/**
|
|
24
|
+
* What a screen shows in place of what it would not read: the confirmation was declined, or it ran out and what the
|
|
25
|
+
* screen showed was put away. Its button asks again and fires `confirmed` once the person did.
|
|
26
|
+
*/
|
|
27
|
+
export declare class IdentityConfirmationNotice extends IdentityConfirmationNotice_base {
|
|
28
|
+
static styles: import("lit").CSSResult;
|
|
29
|
+
/** The confirmation ran out and what the screen showed was put away - rather than a confirmation not yet given. */
|
|
30
|
+
expired: boolean;
|
|
31
|
+
render(): TemplateResult<1>;
|
|
32
|
+
private ask;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A screen's hold on what a confirmation opened: it reads through `read`, which asks when the door does, and puts
|
|
36
|
+
* what it read away (`onPutAway` drops the screen's data) the moment the confirmation ends - the screen does not
|
|
37
|
+
* keep showing it past ten minutes. `unconfirmed` says why nothing is shown - declined, or ran out - and `notice`
|
|
38
|
+
* draws that, with the way to ask again. A screen that goes away drops its data by calling `putAway()` itself.
|
|
39
|
+
*/
|
|
40
|
+
export declare class ConfirmedIdentityController implements ReactiveController {
|
|
41
|
+
private host;
|
|
42
|
+
private onPutAway;
|
|
43
|
+
unconfirmed: 'declined' | 'expired' | null;
|
|
44
|
+
private timer?;
|
|
45
|
+
constructor(host: ReactiveControllerHost, onPutAway: () => void);
|
|
46
|
+
read<T>(run: () => Promise<T>): Promise<T | undefined>;
|
|
47
|
+
/** Drop what the screen shows; `reason` is said in its place, none when the screen only went away. */
|
|
48
|
+
putAway(reason?: 'declined' | 'expired' | null): void;
|
|
49
|
+
notice(again: () => void): TemplateResult;
|
|
50
|
+
hostDisconnected(): void;
|
|
51
|
+
}
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { __decorate, __metadata } from "tslib";
|
|
2
|
+
import './identity-confirm-popup.js';
|
|
3
|
+
import '@material/web/button/outlined-button.js';
|
|
4
|
+
import { css, html, LitElement } from 'lit';
|
|
5
|
+
import { customElement, property } from 'lit/decorators.js';
|
|
6
|
+
import { i18next, localize } from '@operato/i18n';
|
|
7
|
+
import { openPopup } from '@operato/layout';
|
|
8
|
+
/*
|
|
9
|
+
* ── Screens of a door that asks for a recent confirmation (ADR-0168) ─────────────────────
|
|
10
|
+
* The server refuses such a door until the person confirmed it is them in this browser, within the last ten minutes.
|
|
11
|
+
* A screen reads through `ConfirmedIdentityController`, which asks once and reads again, and puts away what it shows
|
|
12
|
+
* the moment the confirmation ends - a screen kept open past ten minutes asks again (the user, 2026-10-28). One way
|
|
13
|
+
* for every app - none draws its own password box.
|
|
14
|
+
*/
|
|
15
|
+
/** The server's refusal code for a door that asks for a recent confirmation. */
|
|
16
|
+
export const IDENTITY_CONFIRMATION_REFUSAL = 'identity-confirmation-required';
|
|
17
|
+
/** The query context for such a door: the screen asks for the confirmation itself, so no error snackbar. */
|
|
18
|
+
export const CONFIRMED_IDENTITY_CONTEXT = { handledRefusals: [IDENTITY_CONFIRMATION_REFUSAL] };
|
|
19
|
+
/** Whether `error` - an Apollo error, or a response with `errors` - is that refusal. */
|
|
20
|
+
export function needsIdentityConfirmation(error) {
|
|
21
|
+
const errors = error?.graphQLErrors?.length ? error.graphQLErrors : (error?.errors ?? []);
|
|
22
|
+
return errors.some((one) => one?.extensions?.refusal?.code === IDENTITY_CONFIRMATION_REFUSAL);
|
|
23
|
+
}
|
|
24
|
+
let asking = null;
|
|
25
|
+
/** Ask the person to confirm it is them - true once the server took it. Two screens asking at once share one popup. */
|
|
26
|
+
export function confirmIdentity() {
|
|
27
|
+
if (!asking) {
|
|
28
|
+
const popup = openPopup(html `<identity-confirm-popup></identity-confirm-popup>`, {
|
|
29
|
+
backdrop: true,
|
|
30
|
+
/* A form of one field stands at its own height - a fraction of the screen clipped it and left half the card empty. */
|
|
31
|
+
size: 'fit',
|
|
32
|
+
title: i18next.t('title.confirm it is you')
|
|
33
|
+
});
|
|
34
|
+
asking = popup.result.then(result => result === true).finally(() => (asking = null));
|
|
35
|
+
}
|
|
36
|
+
return asking;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Run `read`; when the door refuses for want of a recent confirmation, ask for it and read once more. Undefined when
|
|
40
|
+
* the person did not confirm - the screen then says so (`identity-confirmation-notice`) rather than an error.
|
|
41
|
+
*/
|
|
42
|
+
export async function withConfirmedIdentity(read) {
|
|
43
|
+
try {
|
|
44
|
+
return await read();
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (!needsIdentityConfirmation(error))
|
|
48
|
+
throw error;
|
|
49
|
+
if (!(await confirmIdentity()))
|
|
50
|
+
return undefined;
|
|
51
|
+
return await read();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Until when the confirmation in this browser holds - null when there is none (ADR-0168 decision 3). */
|
|
55
|
+
export async function confirmedUntil() {
|
|
56
|
+
try {
|
|
57
|
+
const response = await fetch('/auth/confirm-identity', { headers: { Accept: 'application/json' } });
|
|
58
|
+
const { confirmedUntil } = response.ok ? await response.json() : { confirmedUntil: null };
|
|
59
|
+
return confirmedUntil ? new Date(confirmedUntil) : null;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* What a screen shows in place of what it would not read: the confirmation was declined, or it ran out and what the
|
|
67
|
+
* screen showed was put away. Its button asks again and fires `confirmed` once the person did.
|
|
68
|
+
*/
|
|
69
|
+
let IdentityConfirmationNotice = class IdentityConfirmationNotice extends localize(i18next)(LitElement) {
|
|
70
|
+
constructor() {
|
|
71
|
+
super(...arguments);
|
|
72
|
+
/** The confirmation ran out and what the screen showed was put away - rather than a confirmation not yet given. */
|
|
73
|
+
this.expired = false;
|
|
74
|
+
}
|
|
75
|
+
static { this.styles = css `
|
|
76
|
+
:host {
|
|
77
|
+
display: flex;
|
|
78
|
+
flex-direction: column;
|
|
79
|
+
align-items: flex-start;
|
|
80
|
+
gap: var(--spacing-medium, 8px);
|
|
81
|
+
padding: var(--spacing-large, 12px) 0;
|
|
82
|
+
color: var(--ui-muted);
|
|
83
|
+
}
|
|
84
|
+
`; }
|
|
85
|
+
render() {
|
|
86
|
+
return html `
|
|
87
|
+
<span>${i18next.t(this.expired ? 'text.identity confirmation expired' : 'text.identity confirmation needed')}</span>
|
|
88
|
+
<md-outlined-button @click=${() => this.ask()}>${i18next.t('button.confirm it is you')}</md-outlined-button>
|
|
89
|
+
`;
|
|
90
|
+
}
|
|
91
|
+
async ask() {
|
|
92
|
+
if (await confirmIdentity())
|
|
93
|
+
this.dispatchEvent(new CustomEvent('confirmed', { bubbles: true, composed: true }));
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
__decorate([
|
|
97
|
+
property({ type: Boolean }),
|
|
98
|
+
__metadata("design:type", Object)
|
|
99
|
+
], IdentityConfirmationNotice.prototype, "expired", void 0);
|
|
100
|
+
IdentityConfirmationNotice = __decorate([
|
|
101
|
+
customElement('identity-confirmation-notice')
|
|
102
|
+
], IdentityConfirmationNotice);
|
|
103
|
+
export { IdentityConfirmationNotice };
|
|
104
|
+
/**
|
|
105
|
+
* A screen's hold on what a confirmation opened: it reads through `read`, which asks when the door does, and puts
|
|
106
|
+
* what it read away (`onPutAway` drops the screen's data) the moment the confirmation ends - the screen does not
|
|
107
|
+
* keep showing it past ten minutes. `unconfirmed` says why nothing is shown - declined, or ran out - and `notice`
|
|
108
|
+
* draws that, with the way to ask again. A screen that goes away drops its data by calling `putAway()` itself.
|
|
109
|
+
*/
|
|
110
|
+
export class ConfirmedIdentityController {
|
|
111
|
+
constructor(host, onPutAway) {
|
|
112
|
+
this.host = host;
|
|
113
|
+
this.onPutAway = onPutAway;
|
|
114
|
+
this.unconfirmed = null;
|
|
115
|
+
host.addController(this);
|
|
116
|
+
}
|
|
117
|
+
async read(run) {
|
|
118
|
+
const answer = await withConfirmedIdentity(run);
|
|
119
|
+
if (answer === undefined) {
|
|
120
|
+
this.putAway('declined');
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
this.unconfirmed = null;
|
|
124
|
+
clearTimeout(this.timer);
|
|
125
|
+
const until = await confirmedUntil();
|
|
126
|
+
/* The read just passed the door, so a confirmation is there; not knowing until when, the screen does not keep it. */
|
|
127
|
+
const left = until ? until.getTime() - Date.now() : 0;
|
|
128
|
+
this.timer = setTimeout(() => this.putAway('expired'), Math.max(left, 0));
|
|
129
|
+
this.host.requestUpdate();
|
|
130
|
+
return answer;
|
|
131
|
+
}
|
|
132
|
+
/** Drop what the screen shows; `reason` is said in its place, none when the screen only went away. */
|
|
133
|
+
putAway(reason = null) {
|
|
134
|
+
clearTimeout(this.timer);
|
|
135
|
+
this.timer = undefined;
|
|
136
|
+
this.unconfirmed = reason;
|
|
137
|
+
this.onPutAway();
|
|
138
|
+
this.host.requestUpdate();
|
|
139
|
+
}
|
|
140
|
+
notice(again) {
|
|
141
|
+
return html `<identity-confirmation-notice ?expired=${this.unconfirmed === 'expired'} @confirmed=${again}></identity-confirmation-notice>`;
|
|
142
|
+
}
|
|
143
|
+
hostDisconnected() {
|
|
144
|
+
clearTimeout(this.timer);
|
|
145
|
+
this.timer = undefined;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=identity-confirmation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity-confirmation.js","sourceRoot":"","sources":["../../client/identity-confirmation/identity-confirmation.ts"],"names":[],"mappings":"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","sourcesContent":["import './identity-confirm-popup.js'\nimport '@material/web/button/outlined-button.js'\n\nimport { css, html, LitElement } from 'lit'\nimport type { ReactiveController, ReactiveControllerHost, TemplateResult } from 'lit'\nimport { customElement, property } from 'lit/decorators.js'\n\nimport { i18next, localize } from '@operato/i18n'\nimport { openPopup } from '@operato/layout'\n\n/*\n * ── Screens of a door that asks for a recent confirmation (ADR-0168) ─────────────────────\n * The server refuses such a door until the person confirmed it is them in this browser, within the last ten minutes.\n * A screen reads through `ConfirmedIdentityController`, which asks once and reads again, and puts away what it shows\n * the moment the confirmation ends - a screen kept open past ten minutes asks again (the user, 2026-10-28). One way\n * for every app - none draws its own password box.\n */\n\n/** The server's refusal code for a door that asks for a recent confirmation. */\nexport const IDENTITY_CONFIRMATION_REFUSAL = 'identity-confirmation-required'\n\n/** The query context for such a door: the screen asks for the confirmation itself, so no error snackbar. */\nexport const CONFIRMED_IDENTITY_CONTEXT = { handledRefusals: [IDENTITY_CONFIRMATION_REFUSAL] }\n\n/** Whether `error` - an Apollo error, or a response with `errors` - is that refusal. */\nexport function needsIdentityConfirmation(error: any): boolean {\n const errors = error?.graphQLErrors?.length ? error.graphQLErrors : (error?.errors ?? [])\n return errors.some((one: any) => one?.extensions?.refusal?.code === IDENTITY_CONFIRMATION_REFUSAL)\n}\n\nlet asking: Promise<boolean> | null = null\n\n/** Ask the person to confirm it is them - true once the server took it. Two screens asking at once share one popup. */\nexport function confirmIdentity(): Promise<boolean> {\n if (!asking) {\n const popup = openPopup<boolean>(html`<identity-confirm-popup></identity-confirm-popup>`, {\n backdrop: true,\n /* A form of one field stands at its own height - a fraction of the screen clipped it and left half the card empty. */\n size: 'fit',\n title: i18next.t('title.confirm it is you')\n })\n asking = popup.result.then(result => result === true).finally(() => (asking = null))\n }\n return asking\n}\n\n/**\n * Run `read`; when the door refuses for want of a recent confirmation, ask for it and read once more. Undefined when\n * the person did not confirm - the screen then says so (`identity-confirmation-notice`) rather than an error.\n */\nexport async function withConfirmedIdentity<T>(read: () => Promise<T>): Promise<T | undefined> {\n try {\n return await read()\n } catch (error) {\n if (!needsIdentityConfirmation(error)) throw error\n if (!(await confirmIdentity())) return undefined\n return await read()\n }\n}\n\n/** Until when the confirmation in this browser holds - null when there is none (ADR-0168 decision 3). */\nexport async function confirmedUntil(): Promise<Date | null> {\n try {\n const response = await fetch('/auth/confirm-identity', { headers: { Accept: 'application/json' } })\n const { confirmedUntil } = response.ok ? await response.json() : { confirmedUntil: null }\n return confirmedUntil ? new Date(confirmedUntil) : null\n } catch {\n return null\n }\n}\n\n/**\n * What a screen shows in place of what it would not read: the confirmation was declined, or it ran out and what the\n * screen showed was put away. Its button asks again and fires `confirmed` once the person did.\n */\n@customElement('identity-confirmation-notice')\nexport class IdentityConfirmationNotice extends localize(i18next)(LitElement) {\n static styles = css`\n :host {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: var(--spacing-medium, 8px);\n padding: var(--spacing-large, 12px) 0;\n color: var(--ui-muted);\n }\n `\n\n /** The confirmation ran out and what the screen showed was put away - rather than a confirmation not yet given. */\n @property({ type: Boolean }) expired = false\n\n render() {\n return html`\n <span>${i18next.t(this.expired ? 'text.identity confirmation expired' : 'text.identity confirmation needed')}</span>\n <md-outlined-button @click=${() => this.ask()}>${i18next.t('button.confirm it is you')}</md-outlined-button>\n `\n }\n\n private async ask() {\n if (await confirmIdentity()) this.dispatchEvent(new CustomEvent('confirmed', { bubbles: true, composed: true }))\n }\n}\n\n/**\n * A screen's hold on what a confirmation opened: it reads through `read`, which asks when the door does, and puts\n * what it read away (`onPutAway` drops the screen's data) the moment the confirmation ends - the screen does not\n * keep showing it past ten minutes. `unconfirmed` says why nothing is shown - declined, or ran out - and `notice`\n * draws that, with the way to ask again. A screen that goes away drops its data by calling `putAway()` itself.\n */\nexport class ConfirmedIdentityController implements ReactiveController {\n unconfirmed: 'declined' | 'expired' | null = null\n\n private timer?: ReturnType<typeof setTimeout>\n\n constructor(\n private host: ReactiveControllerHost,\n private onPutAway: () => void\n ) {\n host.addController(this)\n }\n\n async read<T>(run: () => Promise<T>): Promise<T | undefined> {\n const answer = await withConfirmedIdentity(run)\n if (answer === undefined) {\n this.putAway('declined')\n return undefined\n }\n\n this.unconfirmed = null\n clearTimeout(this.timer)\n const until = await confirmedUntil()\n /* The read just passed the door, so a confirmation is there; not knowing until when, the screen does not keep it. */\n const left = until ? until.getTime() - Date.now() : 0\n this.timer = setTimeout(() => this.putAway('expired'), Math.max(left, 0))\n this.host.requestUpdate()\n return answer\n }\n\n /** Drop what the screen shows; `reason` is said in its place, none when the screen only went away. */\n putAway(reason: 'declined' | 'expired' | null = null) {\n clearTimeout(this.timer)\n this.timer = undefined\n this.unconfirmed = reason\n this.onPutAway()\n this.host.requestUpdate()\n }\n\n notice(again: () => void): TemplateResult {\n return html`<identity-confirmation-notice ?expired=${this.unconfirmed === 'expired'} @confirmed=${again}></identity-confirmation-notice>`\n }\n\n hostDisconnected() {\n clearTimeout(this.timer)\n this.timer = undefined\n }\n}\n"]}
|
package/dist-client/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import '@operato/i18n/ox-i18n.js';
|
|
|
3
3
|
export * from './components/role-selector.js';
|
|
4
4
|
export * from './components/domain-switch.js';
|
|
5
5
|
export * from './components/invite-user.js';
|
|
6
|
+
export * from './identity-confirmation/identity-confirmation.js';
|
|
6
7
|
export type AuthManagementMenuOptions = {
|
|
7
8
|
role?: boolean;
|
|
8
9
|
appliance?: boolean;
|
package/dist-client/index.js
CHANGED
|
@@ -7,6 +7,8 @@ import { hasPrivilege } from '@things-factory/auth-base/dist-client';
|
|
|
7
7
|
export * from './components/role-selector.js';
|
|
8
8
|
export * from './components/domain-switch.js';
|
|
9
9
|
export * from './components/invite-user.js';
|
|
10
|
+
/* A door that asks for a recent confirmation that it is still the person (ADR-0168) - one way for every screen. */
|
|
11
|
+
export * from './identity-confirmation/identity-confirmation.js';
|
|
10
12
|
export async function setAuthManagementMenus(options) {
|
|
11
13
|
const { role = true, appliance = true, application = true, user = true, authProvider = true, domain = true, attribute = true, audit = true } = options || {};
|
|
12
14
|
if (await hasPrivilege({ privilege: 'mutation', category: 'user', domainOwnerGranted: true, superUserGranted: true })) {
|
package/dist-client/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"AAAA,OAAO,4BAA4B,CAAA;AACnC,OAAO,0BAA0B,CAAA;AAEjC,OAAO,EAAE,IAAI,EAAE,MAAM,UAAU,CAAA;AAE/B,OAAO,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAA;AACtC,OAAO,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AAC9D,OAAO,EAAE,YAAY,EAAE,MAAM,uCAAuC,CAAA;AAEpE,cAAc,+BAA+B,CAAA;AAC7C,cAAc,+BAA+B,CAAA;AAC7C,cAAc,6BAA6B,CAAA;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/icon/icon.js'\nimport '@operato/i18n/ox-i18n.js'\n\nimport { html } from 'lit-html'\n\nimport { store } from '@operato/shell'\nimport { ADD_MORENDA } from '@things-factory/more-base/client'\nimport { hasPrivilege } from '@things-factory/auth-base/dist-client'\n\nexport * from './components/role-selector.js'\nexport * from './components/domain-switch.js'\nexport * from './components/invite-user.js'\n/* A door that asks for a recent confirmation that it is still the person (ADR-0168) - one way for every screen. */\nexport * from './identity-confirmation/identity-confirmation.js'\n\nexport type AuthManagementMenuOptions = {\n role?: boolean\n appliance?: boolean\n application?: boolean\n user?: boolean\n authProvider?: boolean\n domain?: boolean\n attribute?: boolean\n audit?: boolean\n}\n\nexport async function setAuthManagementMenus(options?: any) {\n const {\n role = true,\n appliance = true,\n application = true,\n user = true,\n authProvider = true,\n domain = true,\n attribute = true,\n audit = true\n } = options || {}\n\n if (\n await hasPrivilege({ privilege: 'mutation', category: 'user', domainOwnerGranted: true, superUserGranted: true })\n ) {\n role &&\n store.dispatch({\n type: ADD_MORENDA,\n morenda: {\n icon: html` <md-icon>how_to_reg</md-icon> `,\n name: html` <ox-i18n msgid=\"text.role_management\"></ox-i18n> `,\n page: 'roles'\n }\n })\n\n appliance &&\n store.dispatch({\n type: ADD_MORENDA,\n morenda: {\n icon: html` <md-icon>devices</md-icon> `,\n name: html` <ox-i18n msgid=\"text.appliance\"></ox-i18n> `,\n page: 'appliance-home'\n }\n })\n\n application &&\n store.dispatch({\n type: ADD_MORENDA,\n morenda: {\n icon: html` <md-icon>apps</md-icon> `,\n name: html` <ox-i18n msgid=\"text.application management\"></ox-i18n> `,\n page: 'applications'\n }\n })\n\n user &&\n store.dispatch({\n type: ADD_MORENDA,\n morenda: {\n icon: html` <md-icon>people</md-icon> `,\n name: html` <ox-i18n msgid=\"text.user management\"></ox-i18n> `,\n page: 'users'\n }\n })\n\n authProvider &&\n store.dispatch({\n type: ADD_MORENDA,\n morenda: {\n icon: html` <md-icon>id_card</md-icon> `,\n name: html` <ox-i18n msgid=\"text.auth-provider management\"></ox-i18n> `,\n page: 'auth-providers'\n }\n })\n }\n\n /* The audit asks its own privilege - the domain owner does not pass on ownership (ADR-0141 §5). */\n if (audit && (await hasPrivilege({ privilege: 'query', category: 'audit', superUserGranted: true }))) {\n store.dispatch({\n type: ADD_MORENDA,\n morenda: {\n icon: html` <md-icon>policy</md-icon> `,\n name: html` <ox-i18n msgid=\"audit.title\"></ox-i18n> `,\n page: 'audit'\n }\n })\n }\n\n if (await hasPrivilege({ domainOwnerGranted: true, superUserGranted: true })) {\n domain &&\n store.dispatch({\n type: ADD_MORENDA,\n morenda: {\n icon: html` <md-icon>supervised_user_circle</md-icon> `,\n name: html` <ox-i18n msgid=\"text.domain owners\"></ox-i18n> `,\n page: 'domain-owners'\n }\n })\n }\n\n if (await hasPrivilege({ superUserGranted: true })) {\n domain &&\n store.dispatch({\n type: ADD_MORENDA,\n morenda: {\n icon: html` <md-icon>business</md-icon> `,\n name: html` <ox-i18n msgid=\"text.domain management\"></ox-i18n> `,\n page: 'domains'\n }\n })\n\n attribute &&\n store.dispatch({\n type: ADD_MORENDA,\n morenda: {\n icon: html` <md-icon>dataset</md-icon> `,\n name: html` <ox-i18n msgid=\"text.attribute management\"></ox-i18n> `,\n page: 'attributes'\n }\n })\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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"role-add-delete.test.js","sourceRoot":"","sources":["../../../client/pages/role/role-add-delete.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { readFileSync } from 'fs'\nimport path from 'path'\n\n/**\n * How a role is added and deleted on the role list (the user, 2026-10-28: 「도대체 어떻게 추가하고, 삭제한다는\n * 말인가」). One way to add - the 「역할 추가」 window, no blank row in the grid - and deleting acts on picked rows,\n * says first which picked roles people still hold, and deletes none of them. The page needs a DOM, so the test\n * reads its source with comments stripped.\n */\n\nconst read = (file: string) => readFileSync(path.join(__dirname, file), 'utf-8')\nconst strip = (source: string) => source.replace(/\\/\\*[\\s\\S]*?\\*\\//g, '').replace(/^\\s*\\/\\/.*$/gm, '').replace(/<!--[\\s\\S]*?-->/g, '')\n\nconst code = strip(read('role-list-page.ts'))\nconst deleting = code.slice(code.indexOf('private async onDeleteGrid('), code.indexOf('private async openCreate('))\nconst words = JSON.parse(readFileSync(path.join(__dirname, '../../../translations/ko.json'), 'utf-8'))\n\ndescribe('adding a role', () => {\n it('★ the button says what it adds', () => {\n expect(code).toMatch(/<button add @click=\\$\\{\\(\\) => this\\.openCreate\\(\\)\\}>\\s*<md-icon>add<\\/md-icon>\\$\\{i18next\\.t\\('button\\.add role'\\)\\}/)\n expect(words['button.add role']).toBe('역할 추가')\n })\n\n it('★ the grid draws no blank row to type a role into', () => {\n expect(code).toMatch(/rows: \\{\\s*appendable: false,/)\n })\n\n it('the window is titled the same', () => {\n expect(code).toMatch(/title: i18next\\.t\\('button\\.add role'\\)/)\n })\n})\n\ndescribe('deleting a role', () => {\n it('★ rows can be picked', () => {\n expect(code).toMatch(/\\{ type: 'gutter', gutterName: 'row-selector', multiple: true, fixed: true \\}/)\n })\n\n it('★ the list asks how many people hold each role and shows it', () => {\n expect(code).toMatch(/privilegeCount\\s+userCount\\s+templateGap/)\n expect(code).toMatch(/name: 'userCount',[\\s\\S]{0,120}renderer: userCountCell/)\n })\n\n it('★ names the picked roles people hold and stops before asking the server', () => {\n const held = deleting.indexOf(\"record.userCount > 0\")\n expect(held).toBeGreaterThan(0)\n expect(deleting.indexOf('deleteRoles(ids')).toBeGreaterThan(held)\n expect(deleting.slice(held, deleting.indexOf('deleteRoles(ids'))).toMatch(/text\\.roles held by users are not deleted[\\s\\S]*?return\\s*\\}/)\n })\n\n it('★ names picked roles inherited from the parent and stops - even mixed with this domain\\'s own', () => {\n const inherited = deleting.indexOf('this.isInherited(record)')\n expect(inherited).toBeGreaterThan(0)\n 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*\\}/)\n expect(deleting).toMatch(/const mine = selected\\n/)\n })\n\n it('says to pick rows when none are picked, instead of doing nothing', () => {\n expect(deleting).toMatch(/if \\(!selected\\?\\.length\\) \\{\\s*await OxPrompt\\.open\\([\\s\\S]*?text\\.pick the roles to delete/)\n })\n\n it('names what it is about to delete before deleting it', () => {\n expect(deleting).toMatch(/text\\.delete these roles', \\{ names: mine\\.map/)\n })\n\n it('every sentence has its words in every language', () => {\n 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']\n for (const language of ['ko', 'en', 'ja', 'zh', 'es', 'ms']) {\n const all = JSON.parse(readFileSync(path.join(__dirname, `../../../translations/${language}.json`), 'utf-8'))\n for (const key of keys) expect([language, key, typeof all[key]]).toEqual([language, key, 'string'])\n }\n })\n})\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
/**
|
|
4
|
+
* A domain that inherits from nowhere shows nothing about inheritance - no 「우리 역할 · 상속 · 전체」
|
|
5
|
+
* buttons, no line about where roles come from, no source column (the user, 2026-10-28). The page
|
|
6
|
+
* needs a DOM, so the test reads its source with comments stripped.
|
|
7
|
+
*/
|
|
8
|
+
const source = readFileSync(path.join(__dirname, 'role-list-page.ts'), 'utf-8');
|
|
9
|
+
const code = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
|
10
|
+
const scopes = code.slice(code.indexOf('private renderScopes('), code.indexOf('private renderLineage('));
|
|
11
|
+
const initialized = code.slice(code.indexOf('async pageInitialized('), code.indexOf('async pageUpdated('));
|
|
12
|
+
describe('the role list in a domain with no parent', () => {
|
|
13
|
+
it('★ inherits only when the profile names a parent - not knowing is not having none', () => {
|
|
14
|
+
expect(code).toMatch(/private hasParent\(\): boolean \{\s*return this\.parentDomain !== null\s*\}/);
|
|
15
|
+
});
|
|
16
|
+
it('★ draws no scope buttons, and so no lineage line', () => {
|
|
17
|
+
expect(scopes).toMatch(/^private renderScopes\(\) \{\s*if \(!this\.hasParent\(\)\) \{\s*return nothing\s*\}/);
|
|
18
|
+
expect(code.match(/this\.renderLineage\(\)/g)).toHaveLength(1);
|
|
19
|
+
expect(scopes).toMatch(/this\.renderLineage\(\)/);
|
|
20
|
+
});
|
|
21
|
+
it('★ leaves the source column and its list detail out', () => {
|
|
22
|
+
expect(initialized).toMatch(/const inheriting = this\.hasParent\(\)/);
|
|
23
|
+
expect(initialized).toMatch(/details: inheriting \? \['privilegeCount', 'userCount', 'source'\] : \['privilegeCount', 'userCount'\]/);
|
|
24
|
+
expect(initialized).toMatch(/\.\.\.\(inheriting\s*\?\s*\[\s*\{\s*type: 'string',\s*name: 'source',/);
|
|
25
|
+
expect(initialized.match(/name: 'source'/g)).toHaveLength(1);
|
|
26
|
+
});
|
|
27
|
+
it('counts the scopes only where there are scopes to count', () => {
|
|
28
|
+
expect(initialized).toMatch(/if \(this\.hasParent\(\)\) \{\s*this\.countScopes\(\)\s*\}/);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
//# sourceMappingURL=role-inheritance-shown.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"role-inheritance-shown.test.js","sourceRoot":"","sources":["../../../client/pages/role/role-inheritance-shown.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,IAAI,CAAA;AACjC,OAAO,IAAI,MAAM,MAAM,CAAA;AAEvB;;;;GAIG;AAEH,MAAM,MAAM,GAAG,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,mBAAmB,CAAC,EAAE,OAAO,CAAC,CAAA;AAC/E,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,mBAAmB,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,eAAe,EAAE,EAAE,CAAC,CAAA;AAEjF,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,uBAAuB,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,wBAAwB,CAAC,CAAC,CAAA;AACxG,MAAM,WAAW,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,wBAAwB,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,oBAAoB,CAAC,CAAC,CAAA;AAE1G,QAAQ,CAAC,0CAA0C,EAAE,GAAG,EAAE;IACxD,EAAE,CAAC,kFAAkF,EAAE,GAAG,EAAE;QAC1F,MAAM,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,6EAA6E,CAAC,CAAA;IACrG,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,kDAAkD,EAAE,GAAG,EAAE;QAC1D,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,qFAAqF,CAAC,CAAA;QAC7G,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,0BAA0B,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;QAC9D,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,yBAAyB,CAAC,CAAA;IACnD,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,oDAAoD,EAAE,GAAG,EAAE;QAC5D,MAAM,CAAC,WAAW,CAAC,CAAC,OAAO,CAAC,wCAAwC,CAAC,CAAA;QACrE,MAAM,CAAC,WAAW,CAAC,CAAC,OAAO,CAAC,wGAAwG,CAAC,CAAA;QACrI,MAAM,CAAC,WAAW,CAAC,CAAC,OAAO,CAAC,uEAAuE,CAAC,CAAA;QACpG,MAAM,CAAC,WAAW,CAAC,KAAK,CAAC,iBAAiB,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;IAC9D,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,wDAAwD,EAAE,GAAG,EAAE;QAChE,MAAM,CAAC,WAAW,CAAC,CAAC,OAAO,CAAC,4DAA4D,CAAC,CAAA;IAC3F,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA","sourcesContent":["import { readFileSync } from 'fs'\nimport path from 'path'\n\n/**\n * A domain that inherits from nowhere shows nothing about inheritance - no 「우리 역할 · 상속 · 전체」\n * buttons, no line about where roles come from, no source column (the user, 2026-10-28). The page\n * needs a DOM, so the test reads its source with comments stripped.\n */\n\nconst source = readFileSync(path.join(__dirname, 'role-list-page.ts'), 'utf-8')\nconst code = source.replace(/\\/\\*[\\s\\S]*?\\*\\//g, '').replace(/^\\s*\\/\\/.*$/gm, '')\n\nconst scopes = code.slice(code.indexOf('private renderScopes('), code.indexOf('private renderLineage('))\nconst initialized = code.slice(code.indexOf('async pageInitialized('), code.indexOf('async pageUpdated('))\n\ndescribe('the role list in a domain with no parent', () => {\n it('★ inherits only when the profile names a parent - not knowing is not having none', () => {\n expect(code).toMatch(/private hasParent\\(\\): boolean \\{\\s*return this\\.parentDomain !== null\\s*\\}/)\n })\n\n it('★ draws no scope buttons, and so no lineage line', () => {\n expect(scopes).toMatch(/^private renderScopes\\(\\) \\{\\s*if \\(!this\\.hasParent\\(\\)\\) \\{\\s*return nothing\\s*\\}/)\n expect(code.match(/this\\.renderLineage\\(\\)/g)).toHaveLength(1)\n expect(scopes).toMatch(/this\\.renderLineage\\(\\)/)\n })\n\n it('★ leaves the source column and its list detail out', () => {\n expect(initialized).toMatch(/const inheriting = this\\.hasParent\\(\\)/)\n expect(initialized).toMatch(/details: inheriting \\? \\['privilegeCount', 'userCount', 'source'\\] : \\['privilegeCount', 'userCount'\\]/)\n expect(initialized).toMatch(/\\.\\.\\.\\(inheriting\\s*\\?\\s*\\[\\s*\\{\\s*type: 'string',\\s*name: 'source',/)\n expect(initialized.match(/name: 'source'/g)).toHaveLength(1)\n })\n\n it('counts the scopes only where there are scopes to count', () => {\n expect(initialized).toMatch(/if \\(this\\.hasParent\\(\\)\\) \\{\\s*this\\.countScopes\\(\\)\\s*\\}/)\n })\n})\n"]}
|
|
@@ -105,19 +105,22 @@ export declare class RoleListPage extends RoleListPageBase {
|
|
|
105
105
|
*/
|
|
106
106
|
private renderScopes;
|
|
107
107
|
/**
|
|
108
|
-
* 어디에서 물려받나 —
|
|
108
|
+
* 어디에서 물려받나 — **어느 도메인인지**를 적는다. 그 역할을 고치러 가야 할 곳이다.
|
|
109
109
|
*
|
|
110
|
-
*
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
110
|
+
* 묶음 단추와 함께 그려지므로 부모가 있을 때만 온다(`hasParent`).
|
|
111
|
+
*/
|
|
112
|
+
private renderLineage;
|
|
113
|
+
/**
|
|
114
|
+
* 물려받는 도메인인가 — 상속을 말하는 것(묶음 단추 · 물려받는 곳 · 출처 칸)이 모두 이것을 본다.
|
|
114
115
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
116
|
+
* 최상위 도메인에는 상속이 없다. 「우리 역할 9 · 상속 0 · 전체 9」와 줄마다 「우리」는 같은 말을
|
|
117
|
+
* 되풀이할 뿐이라 그리지 않는다(사용자 2026-10-28: 「이 도메인이 상속을 받는 도메인이 아니라면,
|
|
118
|
+
* 굳이 상속과 관련된 정보를 보일 필요가 없을 것이다」).
|
|
117
119
|
*
|
|
118
|
-
* 아직
|
|
120
|
+
* 아직 모르면(`undefined`, 옛 서버가 `parent` 칸을 안 보낼 때) 그대로 그린다 — 모르는 것을
|
|
121
|
+
* 「없다」로 접지 않는다.
|
|
119
122
|
*/
|
|
120
|
-
private
|
|
123
|
+
private hasParent;
|
|
121
124
|
private onScope;
|
|
122
125
|
/**
|
|
123
126
|
* 세 묶음의 건수.
|
|
@@ -188,8 +191,9 @@ export declare class RoleListPage extends RoleListPageBase {
|
|
|
188
191
|
/**
|
|
189
192
|
* 고른 역할을 지운다.
|
|
190
193
|
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
194
|
+
* 가진 사람이 있는 역할은 서버가 거절한다(`role-in-use`) — 하나라도 있으면 아무것도 안 지운다. 화면은
|
|
195
|
+
* 보내기 전에 같은 것을 사람 수 칸으로 알고 있으므로 먼저 그 이름을 말하고 멈춘다. 서버의 거절은 그 사이
|
|
196
|
+
* 누군가 부여했을 때를 위한 것이다.
|
|
193
197
|
*/
|
|
194
198
|
private onDeleteGrid;
|
|
195
199
|
/**
|