@things-factory/auth-ui 10.1.29 → 10.1.33

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 (57) hide show
  1. package/dist-client/components/accept-invitation.d.ts +48 -0
  2. package/dist-client/components/accept-invitation.js +265 -0
  3. package/dist-client/components/accept-invitation.js.map +1 -0
  4. package/dist-client/components/appliance-editor.d.ts +3 -0
  5. package/dist-client/components/appliance-editor.js +102 -1
  6. package/dist-client/components/appliance-editor.js.map +1 -1
  7. package/dist-client/components/role-selector.js +23 -1
  8. package/dist-client/components/role-selector.js.map +1 -1
  9. package/dist-client/components/user-invite.d.ts +8 -0
  10. package/dist-client/components/user-invite.js +53 -6
  11. package/dist-client/components/user-invite.js.map +1 -1
  12. package/dist-client/entries/auth/accept-invitation.d.ts +3 -0
  13. package/dist-client/entries/auth/accept-invitation.js +10 -0
  14. package/dist-client/entries/auth/accept-invitation.js.map +1 -0
  15. package/dist-client/pages/invitation/invitation-list-query.test.d.ts +1 -0
  16. package/dist-client/pages/invitation/invitation-list-query.test.js +69 -0
  17. package/dist-client/pages/invitation/invitation-list-query.test.js.map +1 -0
  18. package/dist-client/pages/invitation/invitations.d.ts +63 -0
  19. package/dist-client/pages/invitation/invitations.js +282 -0
  20. package/dist-client/pages/invitation/invitations.js.map +1 -0
  21. package/dist-client/pages/role/role-list-page.d.ts +3 -2
  22. package/dist-client/pages/role/role-list-page.js +39 -6
  23. package/dist-client/pages/role/role-list-page.js.map +1 -1
  24. package/dist-client/route.js +3 -0
  25. package/dist-client/route.js.map +1 -1
  26. package/dist-client/tsconfig.tsbuildinfo +1 -1
  27. package/dist-client/utils/credential-panel-state.d.ts +51 -0
  28. package/dist-client/utils/credential-panel-state.js +49 -0
  29. package/dist-client/utils/credential-panel-state.js.map +1 -0
  30. package/dist-client/utils/credential-panel-state.test.d.ts +1 -0
  31. package/dist-client/utils/credential-panel-state.test.js +88 -0
  32. package/dist-client/utils/credential-panel-state.test.js.map +1 -0
  33. package/dist-client/utils/inherited-name-refusal.d.ts +44 -0
  34. package/dist-client/utils/inherited-name-refusal.js +46 -0
  35. package/dist-client/utils/inherited-name-refusal.js.map +1 -0
  36. package/dist-client/utils/inherited-name-refusal.test.d.ts +1 -0
  37. package/dist-client/utils/inherited-name-refusal.test.js +60 -0
  38. package/dist-client/utils/inherited-name-refusal.test.js.map +1 -0
  39. package/dist-client/utils/invitation-token.d.ts +28 -0
  40. package/dist-client/utils/invitation-token.js +40 -0
  41. package/dist-client/utils/invitation-token.js.map +1 -0
  42. package/dist-client/utils/invitation-token.test.d.ts +1 -0
  43. package/dist-client/utils/invitation-token.test.js +60 -0
  44. package/dist-client/utils/invitation-token.test.js.map +1 -0
  45. package/dist-client/utils/role-origin.d.ts +44 -0
  46. package/dist-client/utils/role-origin.js +47 -0
  47. package/dist-client/utils/role-origin.js.map +1 -0
  48. package/dist-client/utils/role-origin.test.d.ts +1 -0
  49. package/dist-client/utils/role-origin.test.js +60 -0
  50. package/dist-client/utils/role-origin.test.js.map +1 -0
  51. package/package.json +5 -5
  52. package/things-factory.config.js +1 -0
  53. package/translations/en.json +28 -0
  54. package/translations/ja.json +28 -0
  55. package/translations/ko.json +28 -0
  56. package/translations/ms.json +28 -0
  57. package/translations/zh.json +28 -0
@@ -0,0 +1,48 @@
1
+ import '@material/web/icon/icon.js';
2
+ import '@material/web/button/elevated-button.js';
3
+ import '@material/web/textfield/filled-text-field.js';
4
+ import '@operato/i18n/ox-i18n.js';
5
+ import '@operato/i18n/ox-i18n-selector.js';
6
+ import '@operato/layout/ox-snack-bar.js';
7
+ import { AbstractAuthPage } from './abstract-auth-page.js';
8
+ /**
9
+ * Accepting an invitation.
10
+ *
11
+ * ── Why the token is not in the address ─────────────────────────────────────
12
+ * The link is `…/accept-invitation#<token>`, and a browser does not send what follows the `#`.
13
+ * So the request that loads this page carries nothing, and the token stays out of server access
14
+ * logs, proxy logs and `Referer`. This page reads it from the fragment and then clears it from
15
+ * the address bar, so it does not sit in the browser's history either.
16
+ *
17
+ * The one place it can still travel is the mail being forwarded, and nothing about where the
18
+ * token sits can prevent that. So the address it was sent to is asked for as well: holding the
19
+ * link is not enough on its own.
20
+ *
21
+ * ── Why the password fields only appear after a try ─────────────────────────
22
+ * Whether an address already has an account is not this page's to announce. It sends what it
23
+ * has, and the server answers `invitation-needs-a-password` when it needs one — so the extra
24
+ * fields appear for the person who is actually creating an account, and nobody learns anything
25
+ * by loading the page.
26
+ */
27
+ export declare class AcceptInvitation extends AbstractAuthPage {
28
+ static styles: import("lit").CSSResult[];
29
+ get pageName(): string;
30
+ get actionUrl(): string;
31
+ get title(): string;
32
+ /** Read from the fragment on connect. Empty when the link was opened without one. */
33
+ token?: string;
34
+ /** The server asked for a password, so this person is creating an account. */
35
+ needsPassword?: boolean;
36
+ /** Joined. The page stops being a form and says where to go. */
37
+ joined?: boolean;
38
+ busy?: boolean;
39
+ connectedCallback(): void;
40
+ render(): import("lit-html").TemplateResult<1>;
41
+ private renderDone;
42
+ private renderForm;
43
+ private renderNewAccount;
44
+ private fieldValue;
45
+ submit(): Promise<void>;
46
+ private accept;
47
+ updated(changed: any): void;
48
+ }
@@ -0,0 +1,265 @@
1
+ import { __decorate, __metadata } from "tslib";
2
+ import '@material/web/icon/icon.js';
3
+ import '@material/web/button/elevated-button.js';
4
+ import '@material/web/textfield/filled-text-field.js';
5
+ import '@operato/i18n/ox-i18n.js';
6
+ import '@operato/i18n/ox-i18n-selector.js';
7
+ import '@operato/layout/ox-snack-bar.js';
8
+ import { css, html, nothing } from 'lit';
9
+ import { property } from 'lit/decorators.js';
10
+ import { i18next } from '@operato/i18n';
11
+ import { AUTH_STYLE_SIGN } from '../auth-style-sign.js';
12
+ import { EntryControlSizeStyles } from '../entry-control-size-styles.js';
13
+ import { AbstractAuthPage } from './abstract-auth-page.js';
14
+ import { readInvitationToken } from '../utils/invitation-token.js';
15
+ /**
16
+ * Accepting an invitation.
17
+ *
18
+ * ── Why the token is not in the address ─────────────────────────────────────
19
+ * The link is `…/accept-invitation#<token>`, and a browser does not send what follows the `#`.
20
+ * So the request that loads this page carries nothing, and the token stays out of server access
21
+ * logs, proxy logs and `Referer`. This page reads it from the fragment and then clears it from
22
+ * the address bar, so it does not sit in the browser's history either.
23
+ *
24
+ * The one place it can still travel is the mail being forwarded, and nothing about where the
25
+ * token sits can prevent that. So the address it was sent to is asked for as well: holding the
26
+ * link is not enough on its own.
27
+ *
28
+ * ── Why the password fields only appear after a try ─────────────────────────
29
+ * Whether an address already has an account is not this page's to announce. It sends what it
30
+ * has, and the server answers `invitation-needs-a-password` when it needs one — so the extra
31
+ * fields appear for the person who is actually creating an account, and nobody learns anything
32
+ * by loading the page.
33
+ */
34
+ export class AcceptInvitation extends AbstractAuthPage {
35
+ static { this.styles = [
36
+ EntryControlSizeStyles,
37
+ css `
38
+ :host {
39
+ position: relative;
40
+ overflow: hidden;
41
+
42
+ display: flex;
43
+ flex-direction: row;
44
+
45
+ width: 100vw;
46
+ height: 100vh;
47
+ height: 100dvh;
48
+ }
49
+
50
+ .note {
51
+ margin: 0 0 var(--spacing-medium) 0;
52
+ font-size: 0.85rem;
53
+ line-height: 1.5;
54
+ color: var(--md-sys-color-on-surface-variant);
55
+ }
56
+ `,
57
+ AUTH_STYLE_SIGN
58
+ ]; }
59
+ get pageName() {
60
+ return 'accept invitation';
61
+ }
62
+ get actionUrl() {
63
+ return '/accept-invitation';
64
+ }
65
+ get title() {
66
+ return 'accept invitation';
67
+ }
68
+ connectedCallback() {
69
+ super.connectedCallback();
70
+ const { token, clean } = readInvitationToken(window.location.href);
71
+ this.token = token;
72
+ /*
73
+ * Taken out of the address bar and out of this history entry. `replaceState` rather than
74
+ * `pushState`: going back should not put the token back on screen.
75
+ */
76
+ if (clean && clean !== window.location.href) {
77
+ window.history.replaceState(null, '', clean);
78
+ }
79
+ }
80
+ render() {
81
+ const { icon, title, description } = this.applicationMeta;
82
+ const { disableUserFavoredLanguage, languages } = this.data || {};
83
+ return html `
84
+ <div class="wrap">
85
+ <div class="auth-brand">
86
+ <img src=${icon} />
87
+ <strong class="name">${title}</strong>
88
+ <span class="welcome-msg">${description}</span>
89
+ </div>
90
+
91
+ <div class="auth-form">
92
+ <h3><ox-i18n msgid="title.accept invitation"></ox-i18n></h3>
93
+
94
+ ${this.joined ? this.renderDone() : this.renderForm(disableUserFavoredLanguage, languages)}
95
+ </div>
96
+ </div>
97
+
98
+ <ox-snack-bar id="snackbar" level="error" .message=${this.message}></ox-snack-bar>
99
+ `;
100
+ }
101
+ renderDone() {
102
+ return html `
103
+ <p class="note">${i18next.t('text.you have joined. sign in to continue')}</p>
104
+ <md-elevated-button @click=${() => (window.location.href = '/auth/signin')}>
105
+ <ox-i18n msgid="button.sign in"></ox-i18n>
106
+ </md-elevated-button>
107
+ `;
108
+ }
109
+ renderForm(disableUserFavoredLanguage, languages) {
110
+ return html `
111
+ <form id="form" @keypress=${(e) => e.key === 'Enter' && this.accept()}>
112
+ <p class="note">${i18next.t('text.confirm the address this invitation was sent to')}</p>
113
+
114
+ <div class="field">
115
+ <md-filled-text-field
116
+ name="email"
117
+ type="email"
118
+ label=${String(i18next.t('field.email'))}
119
+ autocomplete="email"
120
+ required
121
+ ><md-icon slot="leading-icon">mail</md-icon></md-filled-text-field
122
+ >
123
+ </div>
124
+
125
+ ${this.token
126
+ ? nothing
127
+ : html `
128
+ <div class="field">
129
+ <md-filled-text-field
130
+ name="token"
131
+ label=${String(i18next.t('label.invitation code'))}
132
+ supporting-text=${String(i18next.t('text.the code is printed in the invitation email'))}
133
+ autocomplete="off"
134
+ required
135
+ ><md-icon slot="leading-icon">key</md-icon></md-filled-text-field
136
+ >
137
+ </div>
138
+ `}
139
+ ${this.needsPassword ? this.renderNewAccount() : nothing}
140
+
141
+ <md-elevated-button id="submit-button" type="button" ?disabled=${this.busy} @click=${() => this.accept()}>
142
+ <ox-i18n msgid="button.accept invitation"></ox-i18n>
143
+ </md-elevated-button>
144
+
145
+ ${!disableUserFavoredLanguage
146
+ ? html `<div id="locale-area">
147
+ <label for="locale-selector"><md-icon>language</md-icon></label>
148
+ <ox-i18n-selector
149
+ id="locale-selector"
150
+ value=${i18next.language || 'en-US'}
151
+ .languages=${languages}
152
+ @change=${(e) => e.detail && i18next.changeLanguage(e.detail)}
153
+ ></ox-i18n-selector>
154
+ </div>`
155
+ : nothing}
156
+ </form>
157
+ `;
158
+ }
159
+ renderNewAccount() {
160
+ return html `
161
+ <p class="note">${i18next.t('text.choose a password to finish joining')}</p>
162
+
163
+ <div class="field">
164
+ <md-filled-text-field name="name" label=${String(i18next.t('field.name'))} autocomplete="name"
165
+ ><md-icon slot="leading-icon">person</md-icon></md-filled-text-field
166
+ >
167
+ </div>
168
+
169
+ <div class="field">
170
+ <md-filled-text-field
171
+ name="password"
172
+ type="password"
173
+ label=${String(i18next.t('label.password'))}
174
+ autocomplete="new-password"
175
+ required
176
+ ><md-icon slot="leading-icon">password</md-icon></md-filled-text-field
177
+ >
178
+ </div>
179
+ `;
180
+ }
181
+ /*
182
+ * ⚠ Not called `valueOf`. Overriding `Object.prototype.valueOf` with a different signature
183
+ * makes the class type unassignable to `Object`, and every `@property` decorator on it then
184
+ * fails to resolve — with an error that talks about decorators and never mentions the method.
185
+ */
186
+ fieldValue(name) {
187
+ return this.renderRoot.querySelector(`[name="${name}"]`)?.value?.trim() || '';
188
+ }
189
+ /*
190
+ * The base class calls this to commit the page. It does not submit the form element: the
191
+ * answer here is read rather than navigated to, because a refusal can mean "now ask for a
192
+ * password" instead of "stop".
193
+ */
194
+ async submit() {
195
+ await this.accept();
196
+ }
197
+ async accept() {
198
+ if (this.busy) {
199
+ return;
200
+ }
201
+ this.busy = true;
202
+ this.message = '';
203
+ try {
204
+ const response = await fetch('/accept-invitation', {
205
+ method: 'POST',
206
+ headers: { 'Content-Type': 'application/json' },
207
+ body: JSON.stringify({
208
+ token: this.token || this.fieldValue('token'),
209
+ email: this.fieldValue('email'),
210
+ name: this.fieldValue('name'),
211
+ password: this.fieldValue('password')
212
+ })
213
+ });
214
+ if (response.ok) {
215
+ this.joined = true;
216
+ return;
217
+ }
218
+ const refusal = await response.json().catch(() => ({}));
219
+ /*
220
+ * The one refusal that is not the end of the attempt — it means this person has no account
221
+ * yet, which is exactly who this page is for.
222
+ */
223
+ if (refusal?.refusal?.code === 'invitation-needs-a-password' || refusal?.code === 'invitation-needs-a-password') {
224
+ this.needsPassword = true;
225
+ this.message = String(i18next.t('text.choose a password to finish joining'));
226
+ return;
227
+ }
228
+ this.message = refusal?.message || String(i18next.t('text.this invitation cannot be used'));
229
+ }
230
+ catch (e) {
231
+ this.message = String(i18next.t('text.this invitation cannot be used'));
232
+ }
233
+ finally {
234
+ this.busy = false;
235
+ }
236
+ }
237
+ updated(changed) {
238
+ super.updated(changed);
239
+ if (changed.has('message')) {
240
+ if (!this.message) {
241
+ this.hideSnackbar();
242
+ }
243
+ else {
244
+ this.showSnackbar({ timer: -1 });
245
+ }
246
+ }
247
+ }
248
+ }
249
+ __decorate([
250
+ property({ type: String }),
251
+ __metadata("design:type", String)
252
+ ], AcceptInvitation.prototype, "token", void 0);
253
+ __decorate([
254
+ property({ type: Boolean }),
255
+ __metadata("design:type", Boolean)
256
+ ], AcceptInvitation.prototype, "needsPassword", void 0);
257
+ __decorate([
258
+ property({ type: Boolean }),
259
+ __metadata("design:type", Boolean)
260
+ ], AcceptInvitation.prototype, "joined", void 0);
261
+ __decorate([
262
+ property({ type: Boolean }),
263
+ __metadata("design:type", Boolean)
264
+ ], AcceptInvitation.prototype, "busy", void 0);
265
+ //# sourceMappingURL=accept-invitation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"accept-invitation.js","sourceRoot":"","sources":["../../client/components/accept-invitation.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/icon/icon.js'\nimport '@material/web/button/elevated-button.js'\nimport '@material/web/textfield/filled-text-field.js'\n\nimport '@operato/i18n/ox-i18n.js'\nimport '@operato/i18n/ox-i18n-selector.js'\nimport '@operato/layout/ox-snack-bar.js'\n\nimport { css, html, nothing } from 'lit'\nimport { property } from 'lit/decorators.js'\n\nimport { i18next } from '@operato/i18n'\n\nimport { AUTH_STYLE_SIGN } from '../auth-style-sign.js'\nimport { EntryControlSizeStyles } from '../entry-control-size-styles.js'\nimport { AbstractAuthPage } from './abstract-auth-page.js'\nimport { readInvitationToken } from '../utils/invitation-token.js'\n\n/**\n * Accepting an invitation.\n *\n * ── Why the token is not in the address ─────────────────────────────────────\n * The link is `…/accept-invitation#<token>`, and a browser does not send what follows the `#`.\n * So the request that loads this page carries nothing, and the token stays out of server access\n * logs, proxy logs and `Referer`. This page reads it from the fragment and then clears it from\n * the address bar, so it does not sit in the browser's history either.\n *\n * The one place it can still travel is the mail being forwarded, and nothing about where the\n * token sits can prevent that. So the address it was sent to is asked for as well: holding the\n * link is not enough on its own.\n *\n * ── Why the password fields only appear after a try ─────────────────────────\n * Whether an address already has an account is not this page's to announce. It sends what it\n * has, and the server answers `invitation-needs-a-password` when it needs one — so the extra\n * fields appear for the person who is actually creating an account, and nobody learns anything\n * by loading the page.\n */\nexport class AcceptInvitation extends AbstractAuthPage {\n static styles = [\n EntryControlSizeStyles,\n css`\n :host {\n position: relative;\n overflow: hidden;\n\n display: flex;\n flex-direction: row;\n\n width: 100vw;\n height: 100vh;\n height: 100dvh;\n }\n\n .note {\n margin: 0 0 var(--spacing-medium) 0;\n font-size: 0.85rem;\n line-height: 1.5;\n color: var(--md-sys-color-on-surface-variant);\n }\n `,\n AUTH_STYLE_SIGN\n ]\n\n get pageName() {\n return 'accept invitation'\n }\n\n get actionUrl() {\n return '/accept-invitation'\n }\n\n get title() {\n return 'accept invitation'\n }\n\n /** Read from the fragment on connect. Empty when the link was opened without one. */\n @property({ type: String }) token?: string\n\n /** The server asked for a password, so this person is creating an account. */\n @property({ type: Boolean }) needsPassword?: boolean\n\n /** Joined. The page stops being a form and says where to go. */\n @property({ type: Boolean }) joined?: boolean\n\n @property({ type: Boolean }) busy?: boolean\n\n connectedCallback() {\n super.connectedCallback()\n\n const { token, clean } = readInvitationToken(window.location.href)\n\n this.token = token\n\n /*\n * Taken out of the address bar and out of this history entry. `replaceState` rather than\n * `pushState`: going back should not put the token back on screen.\n */\n if (clean && clean !== window.location.href) {\n window.history.replaceState(null, '', clean)\n }\n }\n\n render() {\n const { icon, title, description } = this.applicationMeta\n const { disableUserFavoredLanguage, languages } = this.data || {}\n\n return html`\n <div class=\"wrap\">\n <div class=\"auth-brand\">\n <img src=${icon} />\n <strong class=\"name\">${title}</strong>\n <span class=\"welcome-msg\">${description}</span>\n </div>\n\n <div class=\"auth-form\">\n <h3><ox-i18n msgid=\"title.accept invitation\"></ox-i18n></h3>\n\n ${this.joined ? this.renderDone() : this.renderForm(disableUserFavoredLanguage, languages)}\n </div>\n </div>\n\n <ox-snack-bar id=\"snackbar\" level=\"error\" .message=${this.message}></ox-snack-bar>\n `\n }\n\n private renderDone() {\n return html`\n <p class=\"note\">${i18next.t('text.you have joined. sign in to continue')}</p>\n <md-elevated-button @click=${() => (window.location.href = '/auth/signin')}>\n <ox-i18n msgid=\"button.sign in\"></ox-i18n>\n </md-elevated-button>\n `\n }\n\n private renderForm(disableUserFavoredLanguage: boolean, languages: any) {\n return html`\n <form id=\"form\" @keypress=${(e: KeyboardEvent) => e.key === 'Enter' && this.accept()}>\n <p class=\"note\">${i18next.t('text.confirm the address this invitation was sent to')}</p>\n\n <div class=\"field\">\n <md-filled-text-field\n name=\"email\"\n type=\"email\"\n label=${String(i18next.t('field.email'))}\n autocomplete=\"email\"\n required\n ><md-icon slot=\"leading-icon\">mail</md-icon></md-filled-text-field\n >\n </div>\n\n ${this.token\n ? nothing\n : html`\n <div class=\"field\">\n <md-filled-text-field\n name=\"token\"\n label=${String(i18next.t('label.invitation code'))}\n supporting-text=${String(i18next.t('text.the code is printed in the invitation email'))}\n autocomplete=\"off\"\n required\n ><md-icon slot=\"leading-icon\">key</md-icon></md-filled-text-field\n >\n </div>\n `}\n ${this.needsPassword ? this.renderNewAccount() : nothing}\n\n <md-elevated-button id=\"submit-button\" type=\"button\" ?disabled=${this.busy} @click=${() => this.accept()}>\n <ox-i18n msgid=\"button.accept invitation\"></ox-i18n>\n </md-elevated-button>\n\n ${!disableUserFavoredLanguage\n ? html`<div id=\"locale-area\">\n <label for=\"locale-selector\"><md-icon>language</md-icon></label>\n <ox-i18n-selector\n id=\"locale-selector\"\n value=${i18next.language || 'en-US'}\n .languages=${languages}\n @change=${(e: CustomEvent) => e.detail && i18next.changeLanguage(e.detail as any)}\n ></ox-i18n-selector>\n </div>`\n : nothing}\n </form>\n `\n }\n\n private renderNewAccount() {\n return html`\n <p class=\"note\">${i18next.t('text.choose a password to finish joining')}</p>\n\n <div class=\"field\">\n <md-filled-text-field name=\"name\" label=${String(i18next.t('field.name'))} autocomplete=\"name\"\n ><md-icon slot=\"leading-icon\">person</md-icon></md-filled-text-field\n >\n </div>\n\n <div class=\"field\">\n <md-filled-text-field\n name=\"password\"\n type=\"password\"\n label=${String(i18next.t('label.password'))}\n autocomplete=\"new-password\"\n required\n ><md-icon slot=\"leading-icon\">password</md-icon></md-filled-text-field\n >\n </div>\n `\n }\n\n /*\n * ⚠ Not called `valueOf`. Overriding `Object.prototype.valueOf` with a different signature\n * makes the class type unassignable to `Object`, and every `@property` decorator on it then\n * fails to resolve — with an error that talks about decorators and never mentions the method.\n */\n private fieldValue(name: string): string {\n return (this.renderRoot.querySelector(`[name=\"${name}\"]`) as any)?.value?.trim() || ''\n }\n\n /*\n * The base class calls this to commit the page. It does not submit the form element: the\n * answer here is read rather than navigated to, because a refusal can mean \"now ask for a\n * password\" instead of \"stop\".\n */\n async submit() {\n await this.accept()\n }\n\n private async accept() {\n if (this.busy) {\n return\n }\n\n this.busy = true\n this.message = ''\n\n try {\n const response = await fetch('/accept-invitation', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n token: this.token || this.fieldValue('token'),\n email: this.fieldValue('email'),\n name: this.fieldValue('name'),\n password: this.fieldValue('password')\n })\n })\n\n if (response.ok) {\n this.joined = true\n return\n }\n\n const refusal = await response.json().catch(() => ({}))\n\n /*\n * The one refusal that is not the end of the attempt — it means this person has no account\n * yet, which is exactly who this page is for.\n */\n if (refusal?.refusal?.code === 'invitation-needs-a-password' || refusal?.code === 'invitation-needs-a-password') {\n this.needsPassword = true\n this.message = String(i18next.t('text.choose a password to finish joining'))\n return\n }\n\n this.message = refusal?.message || String(i18next.t('text.this invitation cannot be used'))\n } catch (e) {\n this.message = String(i18next.t('text.this invitation cannot be used'))\n } finally {\n this.busy = false\n }\n }\n\n updated(changed: any) {\n super.updated(changed)\n\n if (changed.has('message')) {\n if (!this.message) {\n this.hideSnackbar()\n } else {\n this.showSnackbar({ timer: -1 })\n }\n }\n }\n}\n"]}
@@ -17,17 +17,20 @@ export declare class ApplianceEditor extends ApplianceEditor_base {
17
17
  private failed;
18
18
  /** The single-record read did not answer. Distinct from answering with nothing. */
19
19
  private loadFailed;
20
+ private revocable;
20
21
  private get editing();
21
22
  render(): import("lit-html").TemplateResult<1>;
22
23
  private renderCredential;
23
24
  private fieldError;
24
25
  connectedCallback(): Promise<void>;
26
+ private fetchRevocable;
25
27
  fetchAppliance(): Promise<void>;
26
28
  private cancel;
27
29
  save(e: Event): Promise<void>;
28
30
  private createAppliance;
29
31
  private updateAppliance;
30
32
  private generateSecret;
33
+ private revokeCredential;
31
34
  private deleteAppliance;
32
35
  /** The list has to refetch, and the popup is done. */
33
36
  private saved;
@@ -12,6 +12,7 @@ import { OxPrompt } from '@operato/popup/ox-prompt.js';
12
12
  import { CommonHeaderStyles, CommonPopupStyles, ScrollbarStyles } from '@operato/styles';
13
13
  import { parseJwt, sleep } from '@operato/utils';
14
14
  import './secret-field.js';
15
+ import { readRevocable, showsRevokeButton, showsRevokedNote } from '../utils/credential-panel-state.js';
15
16
  import { changedValues, filledValues, missingRequired } from '../utils/form-values.js';
16
17
  /**
17
18
  * Registering and editing an appliance, in a popup.
@@ -44,6 +45,7 @@ const APPLIANCE_FIELDS = `
44
45
  model
45
46
  netmask
46
47
  accessToken
48
+ credentialRevoked
47
49
  `;
48
50
  let ApplianceEditor = class ApplianceEditor extends localize(i18next)(LitElement) {
49
51
  constructor() {
@@ -56,6 +58,15 @@ let ApplianceEditor = class ApplianceEditor extends localize(i18next)(LitElement
56
58
  this.failed = false;
57
59
  /** The single-record read did not answer. Distinct from answering with nothing. */
58
60
  this.loadFailed = false;
61
+ /*
62
+ * Does revoking work on this installation, for this reader?
63
+ *
64
+ * Revocation is behind a configuration switch, and the server answers one question that folds
65
+ * the switch and the reader's privilege together — see `applianceCredentialRevocable`. False
66
+ * until that answer comes back, so a slow or refused read shows no button rather than one that
67
+ * refuses when pressed.
68
+ */
69
+ this.revocable = false;
59
70
  }
60
71
  /*
61
72
  * ── Where the scrolling happens ─────────────────────────────────────────
@@ -164,6 +175,15 @@ let ApplianceEditor = class ApplianceEditor extends localize(i18next)(LitElement
164
175
  margin-right: auto;
165
176
  }
166
177
 
178
+ /* taking a credential away is not the same kind of act as issuing one. */
179
+ md-text-button.danger {
180
+ --md-text-button-label-text-color: var(--md-sys-color-error);
181
+ }
182
+
183
+ .note.revoked {
184
+ color: var(--md-sys-color-error);
185
+ }
186
+
167
187
  @media screen and (max-width: 480px) {
168
188
  [field] {
169
189
  grid-column: span 2;
@@ -274,6 +294,15 @@ let ApplianceEditor = class ApplianceEditor extends localize(i18next)(LitElement
274
294
  /*
275
295
  * The credential panel only exists once the appliance does — there is no token to show
276
296
  * before it is created, and the list query deliberately never asks for one.
297
+ *
298
+ * ── Why a revoked appliance says so ─────────────────────────────────────
299
+ * Revoking clears the token, so without a word for it the panel looks exactly like one whose
300
+ * credential was never issued. Those are opposite situations: one is waiting to be set up,
301
+ * the other has been deliberately shut off, and only one of them wants somebody to act.
302
+ *
303
+ * ── Why the revoke button is not always here ────────────────────────────
304
+ * It appears only where the installation turned revocation on and the reader may use it. See
305
+ * the note on `revocable`.
277
306
  */
278
307
  renderCredential(appliance, exp) {
279
308
  return html `
@@ -282,6 +311,9 @@ let ApplianceEditor = class ApplianceEditor extends localize(i18next)(LitElement
282
311
  <div field>
283
312
  <label for="access-token">${i18next.t('label.access token')}</label>
284
313
  <secret-field id="access-token" .value=${appliance.accessToken || ''}></secret-field>
314
+ ${showsRevokedNote(appliance)
315
+ ? html `<div class="note revoked">${i18next.t('text.this credential has been revoked')}</div>`
316
+ : ''}
285
317
  ${exp
286
318
  ? html `<div class="note">
287
319
  ${i18next.t('text.token expiry time')} ${new Date(exp * 1000).toLocaleString()} :
@@ -292,6 +324,13 @@ let ApplianceEditor = class ApplianceEditor extends localize(i18next)(LitElement
292
324
  <md-text-button @click=${this.generateSecret.bind(this)}>
293
325
  ${i18next.t('button.generate new access token')}
294
326
  </md-text-button>
327
+ ${showsRevokeButton(this.revocable, appliance)
328
+ ? html `
329
+ <md-text-button class="danger" @click=${this.revokeCredential.bind(this)}>
330
+ ${i18next.t('button.revoke credential')}
331
+ </md-text-button>
332
+ `
333
+ : ''}
295
334
  </div>
296
335
  </div>
297
336
  </fieldset>
@@ -303,9 +342,26 @@ let ApplianceEditor = class ApplianceEditor extends localize(i18next)(LitElement
303
342
  async connectedCallback() {
304
343
  super.connectedCallback();
305
344
  if (this.editing) {
306
- await this.fetchAppliance();
345
+ await Promise.all([this.fetchAppliance(), this.fetchRevocable()]);
307
346
  }
308
347
  }
348
+ /*
349
+ * Whether the revoke button exists at all.
350
+ *
351
+ * A refusal arrives as `response.errors` rather than a throw, and both a refusal and a `false`
352
+ * mean the same thing to this screen: no button. So there is nothing to report here — the
353
+ * absence of the button is the whole answer.
354
+ */
355
+ async fetchRevocable() {
356
+ const response = await client.query({
357
+ query: gql `
358
+ query {
359
+ applianceCredentialRevocable
360
+ }
361
+ `
362
+ });
363
+ this.revocable = readRevocable(response);
364
+ }
309
365
  async fetchAppliance() {
310
366
  const response = await client.query({
311
367
  query: gql `
@@ -420,6 +476,47 @@ let ApplianceEditor = class ApplianceEditor extends localize(i18next)(LitElement
420
476
  this.appliance = response.data.generateApplianceSecret;
421
477
  this.dispatchEvent(new CustomEvent('appliance-changed', { bubbles: true, composed: true }));
422
478
  }
479
+ /*
480
+ * Take the credential away and leave the appliance where it is.
481
+ *
482
+ * ── Why this is not the delete button ───────────────────────────────────
483
+ * Deleting takes the appliance and its history with it. This is for a device that is lost,
484
+ * decommissioned or suspect and whose record still has to stand — the row keeps pointing at
485
+ * the same identity, and issuing a new secret later picks that identity back up.
486
+ *
487
+ * ── Why the popup stays open ────────────────────────────────────────────
488
+ * The panel has to show the result: the token is gone and the appliance now says it was
489
+ * revoked. Closing would leave the reader guessing whether the press landed, and the list
490
+ * behind it is refetched either way by the event below.
491
+ */
492
+ async revokeCredential(e) {
493
+ e.preventDefault();
494
+ const confirmed = await OxPrompt.open({
495
+ type: 'warning',
496
+ title: i18next.t('button.revoke credential'),
497
+ text: i18next.t('text.revoking stops every token this appliance holds'),
498
+ confirmButton: { text: i18next.t('button.revoke credential') },
499
+ cancelButton: { text: i18next.t('button.cancel') }
500
+ });
501
+ if (!confirmed) {
502
+ return;
503
+ }
504
+ const response = await client.mutate({
505
+ mutation: gql `
506
+ mutation ($id: String!) {
507
+ revokeApplianceCredential(id: $id) { ${APPLIANCE_FIELDS} }
508
+ }
509
+ `,
510
+ variables: { id: this.applianceId }
511
+ });
512
+ if (response.errors) {
513
+ this.failed = true;
514
+ return;
515
+ }
516
+ this.failed = false;
517
+ this.appliance = response.data.revokeApplianceCredential;
518
+ this.dispatchEvent(new CustomEvent('appliance-changed', { bubbles: true, composed: true }));
519
+ }
423
520
  async deleteAppliance(e) {
424
521
  e.preventDefault();
425
522
  const confirmed = await OxPrompt.open({
@@ -506,6 +603,10 @@ __decorate([
506
603
  state(),
507
604
  __metadata("design:type", Boolean)
508
605
  ], ApplianceEditor.prototype, "loadFailed", void 0);
606
+ __decorate([
607
+ state(),
608
+ __metadata("design:type", Boolean)
609
+ ], ApplianceEditor.prototype, "revocable", void 0);
509
610
  ApplianceEditor = __decorate([
510
611
  customElement('appliance-editor')
511
612
  ], ApplianceEditor);