@things-factory/auth-ui 10.1.206 → 10.1.212
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/auth-style-sign.js +15 -7
- package/dist-client/auth-style-sign.js.map +1 -1
- package/dist-client/components/abstract-sign.d.ts +9 -1
- package/dist-client/components/abstract-sign.js +48 -28
- package/dist-client/components/abstract-sign.js.map +1 -1
- package/dist-client/components/my-passkeys.d.ts +31 -0
- package/dist-client/components/my-passkeys.js +247 -0
- package/dist-client/components/my-passkeys.js.map +1 -0
- package/dist-client/components/profile-component.d.ts +1 -1
- package/dist-client/components/profile-component.js +4 -53
- package/dist-client/components/profile-component.js.map +1 -1
- package/dist-client/entries/auth/passkey-offer.d.ts +27 -0
- package/dist-client/entries/auth/passkey-offer.js +161 -0
- package/dist-client/entries/auth/passkey-offer.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/audit-words.js +3 -1
- package/dist-client/utils/audit-words.js.map +1 -1
- package/dist-client/utils/audit-words.test.js +6 -0
- package/dist-client/utils/audit-words.test.js.map +1 -1
- package/dist-client/utils/passkey.d.ts +38 -0
- package/dist-client/utils/passkey.js +84 -0
- package/dist-client/utils/passkey.js.map +1 -0
- package/dist-client/utils/passkey.test.d.ts +1 -0
- package/dist-client/utils/passkey.test.js +75 -0
- package/dist-client/utils/passkey.test.js.map +1 -0
- package/package.json +5 -5
- package/translations/en.json +17 -1
- package/translations/es.json +17 -1
- package/translations/ja.json +17 -1
- package/translations/ko.json +18 -2
- package/translations/ms.json +17 -1
- package/translations/zh.json +17 -1
|
@@ -71,21 +71,29 @@ export const AUTH_STYLE_SIGN = css `
|
|
|
71
71
|
text-align: left;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/* Equal columns, one per button - the buttons' own paddings differ, so flex would not share evenly. */
|
|
74
75
|
.submit-buttons-container {
|
|
75
76
|
grid-column: 1 / -1;
|
|
76
77
|
text-align: center;
|
|
77
78
|
|
|
78
|
-
display:
|
|
79
|
+
display: grid;
|
|
80
|
+
grid-auto-flow: column;
|
|
81
|
+
grid-auto-columns: 1fr;
|
|
79
82
|
align-items: center;
|
|
80
83
|
gap: 10px;
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
/* The passkey button stands beside the submit button, drawn in the page's own on-primary colour. */
|
|
87
|
+
md-outlined-button.passkey {
|
|
88
|
+
--md-outlined-button-outline-color: var(--md-sys-color-on-primary);
|
|
89
|
+
--md-outlined-button-label-text-color: var(--md-sys-color-on-primary);
|
|
90
|
+
--md-outlined-button-icon-color: var(--md-sys-color-on-primary);
|
|
91
|
+
--md-outlined-button-hover-label-text-color: var(--md-sys-color-on-primary);
|
|
92
|
+
--md-outlined-button-hover-icon-color: var(--md-sys-color-on-primary);
|
|
93
|
+
--md-outlined-button-focus-label-text-color: var(--md-sys-color-on-primary);
|
|
94
|
+
--md-outlined-button-focus-icon-color: var(--md-sys-color-on-primary);
|
|
95
|
+
--md-outlined-button-pressed-label-text-color: var(--md-sys-color-on-primary);
|
|
96
|
+
--md-outlined-button-pressed-icon-color: var(--md-sys-color-on-primary);
|
|
89
97
|
}
|
|
90
98
|
|
|
91
99
|
.field md-filled-text-field {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-style-sign.js","sourceRoot":"","sources":["../client/auth-style-sign.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,KAAK,CAAA;AAEzB,MAAM,CAAC,MAAM,eAAe,GAAG,GAAG,CAAA
|
|
1
|
+
{"version":3,"file":"auth-style-sign.js","sourceRoot":"","sources":["../client/auth-style-sign.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,KAAK,CAAA;AAEzB,MAAM,CAAC,MAAM,eAAe,GAAG,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuMjC,CAAA","sourcesContent":["import { css } from 'lit'\n\nexport const AUTH_STYLE_SIGN = css`\n :host {\n display: flex;\n background-color: var(--md-sys-color-primary);\n color: var(--md-sys-color-on-primary);\n\n --md-filled-text-field-supporting-text-color: var(--md-sys-color-on-primary);\n }\n\n :host *:focus {\n outline: none;\n }\n\n :host * {\n box-sizing: border-box;\n }\n\n .wrap {\n display: block;\n width: 450px;\n min-width: 350px;\n margin: 0 auto;\n padding-bottom: 100px;\n text-align: center;\n }\n\n .auth-brand {\n color: var(--md-sys-color-on-primary);\n }\n\n .auth-brand img {\n margin: 15% auto 5px auto;\n width: 100px;\n border: 3px solid var(--md-sys-color-on-primary);\n border-radius: 25px;\n box-shadow: var(--box-shadow);\n }\n .name {\n display: block;\n font: var(--auth-brand-name);\n text-shadow: var(--auth-brand-name-shadow);\n }\n .auth-brand .welcome-msg {\n font: var(--auth-brand-welcome-msg);\n }\n .auth-form {\n display: grid;\n grid-gap: var(--spacing-medium);\n grid-template-columns: 1fr 1fr;\n }\n\n form {\n grid-column: 1 / -1;\n display: grid;\n grid-template-columns: 1fr 1fr;\n grid-gap: var(--spacing-medium);\n align-items: center;\n }\n\n h3 {\n grid-column: 1 / -1;\n margin: 50px 0 0 0;\n font: var(--auth-title-font);\n color: var(--auth-title-color, var(--md-sys-color-on-primary));\n text-transform: uppercase;\n }\n\n .field {\n grid-column: 1 / -1;\n text-align: left;\n }\n\n /* Equal columns, one per button - the buttons' own paddings differ, so flex would not share evenly. */\n .submit-buttons-container {\n grid-column: 1 / -1;\n text-align: center;\n\n display: grid;\n grid-auto-flow: column;\n grid-auto-columns: 1fr;\n align-items: center;\n gap: 10px;\n }\n\n /* The passkey button stands beside the submit button, drawn in the page's own on-primary colour. */\n md-outlined-button.passkey {\n --md-outlined-button-outline-color: var(--md-sys-color-on-primary);\n --md-outlined-button-label-text-color: var(--md-sys-color-on-primary);\n --md-outlined-button-icon-color: var(--md-sys-color-on-primary);\n --md-outlined-button-hover-label-text-color: var(--md-sys-color-on-primary);\n --md-outlined-button-hover-icon-color: var(--md-sys-color-on-primary);\n --md-outlined-button-focus-label-text-color: var(--md-sys-color-on-primary);\n --md-outlined-button-focus-icon-color: var(--md-sys-color-on-primary);\n --md-outlined-button-pressed-label-text-color: var(--md-sys-color-on-primary);\n --md-outlined-button-pressed-icon-color: var(--md-sys-color-on-primary);\n }\n\n .field md-filled-text-field {\n grid-column: 1 / -1;\n width: 100%;\n }\n\n md-text-button,\n md-elevated-button {\n grid-column: 1 / -1;\n flex: 1;\n }\n\n .wrap .link {\n text-decoration: none;\n justify-self: flex-start;\n }\n\n .wrap .link md-text-button {\n --md-text-button-label-text-color: var(--md-sys-color-on-primary);\n --md-text-button-focus-label-text-color: var(--md-sys-color-on-primary);\n --md-text-button-hover-label-text-color: var(--md-sys-color-on-primary);\n }\n\n .wrap .link md-icon {\n color: var(--md-sys-color-on-primary);\n }\n\n #locale-area {\n display: flex;\n grid-column: 1 / -1;\n padding: 0 var(--spacing-medium);\n }\n\n #locale-area > label {\n display: flex;\n align-items: center;\n color: var(--md-sys-color-on-primary);\n --md-icon-size: 16px;\n }\n\n #locale-selector {\n font-size: 16px;\n width: 100%;\n }\n\n #locale-selector {\n --i18n-selector-field-border: none;\n --i18n-selector-field-background-color: none;\n --i18n-selector-field-font-size: 14px;\n --i18n-selector-field-color: var(--md-sys-color-on-primary);\n }\n\n .lottie-container {\n width: 100%;\n height: 300px;\n position: absolute;\n left: 0;\n bottom: 0;\n pointer-events: none;\n }\n .lottie-container lottie-player {\n position: absolute;\n bottom: -6%;\n width: 100%;\n height: auto;\n }\n\n @media (max-width: 450px) {\n .wrap {\n width: 85%;\n min-width: 320px;\n }\n .auth-form {\n grid-template-columns: 1fr;\n }\n .auth-brand img {\n margin: 12% auto 5px auto;\n width: 75px;\n }\n h3 {\n margin: 15px 0 0 0;\n }\n .lottie-container {\n overflow: hidden;\n height: 200px;\n pointer-events: none;\n }\n .lottie-container lottie-player {\n width: 1200px;\n left: -30%;\n }\n }\n\n @media screen and (min-width: 1400px) {\n .wrap {\n padding-bottom: 150px;\n }\n }\n @media screen and (min-width: 2500px) {\n .wrap {\n padding-bottom: 280px;\n }\n }\n`\n"]}
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
+
import '@material/web/button/outlined-button.js';
|
|
1
2
|
import '@operato/i18n/ox-i18n.js';
|
|
2
3
|
import { AbstractAuthPage } from './abstract-auth-page.js';
|
|
3
4
|
export declare abstract class AbstractSign extends AbstractAuthPage {
|
|
4
5
|
submit(): Promise<void>;
|
|
5
6
|
get formfields(): import("lit-html").TemplateResult<1>;
|
|
6
|
-
|
|
7
|
+
firstUpdated(): void;
|
|
8
|
+
/**
|
|
9
|
+
* Sign in with a passkey - one on this device, or a phone nearby through the QR code the browser
|
|
10
|
+
* shows (ADR-0156 decision 3).
|
|
11
|
+
*/
|
|
12
|
+
signinWithPasskey({ autofill }?: {
|
|
13
|
+
autofill?: boolean;
|
|
14
|
+
}): Promise<void>;
|
|
7
15
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import '@material/web/button/outlined-button.js';
|
|
1
2
|
import '@operato/i18n/ox-i18n.js';
|
|
2
3
|
import { html, nothing } from 'lit';
|
|
3
|
-
import { startAuthentication } from '@simplewebauthn/browser';
|
|
4
|
+
import { browserSupportsWebAuthnAutofill, startAuthentication } from '@simplewebauthn/browser';
|
|
4
5
|
import { i18next } from '@operato/i18n';
|
|
5
6
|
import { notify } from '@operato/layout';
|
|
7
|
+
import { readSigninAnswer, wasCancelled } from '../utils/passkey.js';
|
|
6
8
|
import { AbstractAuthPage } from './abstract-auth-page.js';
|
|
7
9
|
const isAvailableWebauthn = 'PublicKeyCredential' in window;
|
|
8
10
|
export class AbstractSign extends AbstractAuthPage {
|
|
@@ -22,7 +24,7 @@ export class AbstractSign extends AbstractAuthPage {
|
|
|
22
24
|
label=${String(i18next.t('field.user-id or email'))}
|
|
23
25
|
required
|
|
24
26
|
.value=${username}
|
|
25
|
-
autocomplete=${autocompletable ? 'username' : 'off'}
|
|
27
|
+
autocomplete=${autocompletable ? 'username webauthn' : 'off'}
|
|
26
28
|
autocapitalize="off"
|
|
27
29
|
pattern="^(?:[A-Za-z0-9]*|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,})$"
|
|
28
30
|
@input=${(e) => {
|
|
@@ -52,41 +54,59 @@ export class AbstractSign extends AbstractAuthPage {
|
|
|
52
54
|
<md-elevated-button class="submit-button" type="button" raised @click=${e => this._onSubmit(e)}>
|
|
53
55
|
<ox-i18n msgid="field.${this.pageName}"> </ox-i18n>
|
|
54
56
|
</md-elevated-button>
|
|
55
|
-
${isAvailableWebauthn
|
|
56
|
-
? html
|
|
57
|
+
${isAvailableWebauthn && autocompletable
|
|
58
|
+
? html `<md-outlined-button class="passkey" type="button" @click=${() => this.signinWithPasskey()}>
|
|
59
|
+
<md-icon slot="icon">passkey</md-icon>${i18next.t('button.signin with passkey')}
|
|
60
|
+
</md-outlined-button>`
|
|
57
61
|
: nothing}
|
|
58
62
|
</div>
|
|
59
63
|
`;
|
|
60
64
|
}
|
|
61
|
-
|
|
65
|
+
firstUpdated() {
|
|
66
|
+
super.firstUpdated();
|
|
67
|
+
/*
|
|
68
|
+
* Where the browser can, the passkeys it holds for this site show in the user-id field's
|
|
69
|
+
* suggestions (ADR-0156 decision 5). The request waits for the person; pressing the passkey
|
|
70
|
+
* button cancels it and asks again.
|
|
71
|
+
*/
|
|
72
|
+
if (isAvailableWebauthn && this.autocompletable) {
|
|
73
|
+
this.signinWithPasskey({ autofill: true });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Sign in with a passkey - one on this device, or a phone nearby through the QR code the browser
|
|
78
|
+
* shows (ADR-0156 decision 3).
|
|
79
|
+
*/
|
|
80
|
+
async signinWithPasskey({ autofill = false } = {}) {
|
|
62
81
|
try {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
82
|
+
if (autofill && !(await browserSupportsWebAuthnAutofill()))
|
|
83
|
+
return;
|
|
84
|
+
const optionsJSON = await fetch('/auth/signin-webauthn/challenge').then(res => res.json());
|
|
85
|
+
const response = await startAuthentication({
|
|
86
|
+
optionsJSON,
|
|
87
|
+
useBrowserAutofill: autofill,
|
|
88
|
+
/* The field lives in md-filled-text-field's shadow root, where a document query does not reach. */
|
|
89
|
+
verifyBrowserAutofillInput: false
|
|
90
|
+
});
|
|
91
|
+
const answer = await readSigninAnswer(await fetch('/auth/signin-webauthn', {
|
|
66
92
|
method: 'POST',
|
|
67
|
-
headers: {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const { redirectURL } = verification;
|
|
74
|
-
if (redirectURL) {
|
|
75
|
-
window.location.href = redirectURL;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
else {
|
|
79
|
-
notify({
|
|
80
|
-
level: 'error',
|
|
81
|
-
message: verification.message
|
|
82
|
-
});
|
|
93
|
+
headers: { 'Content-Type': 'application/json' },
|
|
94
|
+
body: JSON.stringify({ response, redirectTo: this.redirectTo || '/' })
|
|
95
|
+
}));
|
|
96
|
+
if (answer.outcome === 'signed-in') {
|
|
97
|
+
window.location.href = answer.redirectURL;
|
|
98
|
+
return;
|
|
83
99
|
}
|
|
100
|
+
notify({ level: 'error', message: answer.message || i18next.t('error.authn verification failed') });
|
|
84
101
|
}
|
|
85
102
|
catch (error) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
103
|
+
/* A closed prompt, or the autofill request cancelled by the button - the person knows. */
|
|
104
|
+
if (wasCancelled(error))
|
|
105
|
+
return;
|
|
106
|
+
console.error('passkey sign-in failed:', error);
|
|
107
|
+
if (!autofill) {
|
|
108
|
+
notify({ level: 'error', message: i18next.t('error.authn verification failed') });
|
|
109
|
+
}
|
|
90
110
|
}
|
|
91
111
|
}
|
|
92
112
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"abstract-sign.js","sourceRoot":"","sources":["../../client/components/abstract-sign.ts"],"names":[],"mappings":"AAAA,OAAO,0BAA0B,CAAA;AAEjC,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,KAAK,CAAA;AACnC,OAAO,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAA;
|
|
1
|
+
{"version":3,"file":"abstract-sign.js","sourceRoot":"","sources":["../../client/components/abstract-sign.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/button/outlined-button.js'\nimport '@operato/i18n/ox-i18n.js'\n\nimport { html, nothing } from 'lit'\nimport { browserSupportsWebAuthnAutofill, startAuthentication } from '@simplewebauthn/browser'\n\nimport { i18next } from '@operato/i18n'\nimport { notify } from '@operato/layout'\n\nimport { readSigninAnswer, wasCancelled } from '../utils/passkey.js'\nimport { AbstractAuthPage } from './abstract-auth-page.js'\n\nconst isAvailableWebauthn = 'PublicKeyCredential' in window\n\nexport abstract class AbstractSign extends AbstractAuthPage {\n async submit() {\n this.formEl.submit()\n }\n\n get formfields() {\n const username = this.data?.username || ''\n const autocompletable = this.autocompletable\n\n return html`\n <input id=\"redirectTo\" type=\"hidden\" name=\"redirectTo\" .value=${this.redirectTo || '/'} />\n\n <div class=\"field\">\n <md-filled-text-field\n name=\"username\"\n type=\"text\"\n label=${String(i18next.t('field.user-id or email'))}\n required\n .value=${username}\n autocomplete=${autocompletable ? 'username webauthn' : 'off'}\n autocapitalize=\"off\"\n pattern=\"^(?:[A-Za-z0-9]*|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,})$\"\n @input=${(e: Event) => {\n const target = e.target as HTMLInputElement\n if (target.validity.typeMismatch) {\n target.setCustomValidity(i18next.t('text.invalid-username'))\n } else {\n target.setCustomValidity('')\n }\n }}\n ><md-icon slot=\"leading-icon\">id_card</md-icon></md-filled-text-field\n >\n </div>\n <div class=\"field\">\n <md-filled-text-field\n name=\"password\"\n type=\"password\"\n label=${String(i18next.t('field.password'))}\n autocomplete=${autocompletable ? 'current-password' : 'off'}\n required\n ><md-icon slot=\"leading-icon\">password</md-icon></md-filled-text-field\n >\n </div>\n\n <div class=\"submit-buttons-container\">\n <md-elevated-button class=\"submit-button\" type=\"button\" raised @click=${e => this._onSubmit(e)}>\n <ox-i18n msgid=\"field.${this.pageName}\"> </ox-i18n>\n </md-elevated-button>\n ${isAvailableWebauthn && autocompletable\n ? html`<md-outlined-button class=\"passkey\" type=\"button\" @click=${() => this.signinWithPasskey()}>\n <md-icon slot=\"icon\">passkey</md-icon>${i18next.t('button.signin with passkey')}\n </md-outlined-button>`\n : nothing}\n </div>\n `\n }\n\n firstUpdated() {\n super.firstUpdated()\n\n /*\n * Where the browser can, the passkeys it holds for this site show in the user-id field's\n * suggestions (ADR-0156 decision 5). The request waits for the person; pressing the passkey\n * button cancels it and asks again.\n */\n if (isAvailableWebauthn && this.autocompletable) {\n this.signinWithPasskey({ autofill: true })\n }\n }\n\n /**\n * Sign in with a passkey - one on this device, or a phone nearby through the QR code the browser\n * shows (ADR-0156 decision 3).\n */\n async signinWithPasskey({ autofill = false }: { autofill?: boolean } = {}) {\n try {\n if (autofill && !(await browserSupportsWebAuthnAutofill())) return\n\n const optionsJSON = await fetch('/auth/signin-webauthn/challenge').then(res => res.json())\n const response = await startAuthentication({\n optionsJSON,\n useBrowserAutofill: autofill,\n /* The field lives in md-filled-text-field's shadow root, where a document query does not reach. */\n verifyBrowserAutofillInput: false\n })\n\n const answer = await readSigninAnswer(\n await fetch('/auth/signin-webauthn', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ response, redirectTo: this.redirectTo || '/' })\n })\n )\n\n if (answer.outcome === 'signed-in') {\n window.location.href = answer.redirectURL\n return\n }\n\n notify({ level: 'error', message: answer.message || i18next.t('error.authn verification failed') })\n } catch (error) {\n /* A closed prompt, or the autofill request cancelled by the button - the person knows. */\n if (wasCancelled(error)) return\n\n console.error('passkey sign-in failed:', error)\n if (!autofill) {\n notify({ level: 'error', message: i18next.t('error.authn verification failed') })\n }\n }\n }\n}\n"]}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import '@material/web/button/text-button.js';
|
|
2
|
+
import '@material/web/icon/icon.js';
|
|
3
|
+
import '@material/web/iconbutton/icon-button.js';
|
|
4
|
+
import { LitElement, nothing } from 'lit';
|
|
5
|
+
type Passkey = {
|
|
6
|
+
id: string;
|
|
7
|
+
name?: string | null;
|
|
8
|
+
createdAt?: string;
|
|
9
|
+
lastUsedAt?: string | null;
|
|
10
|
+
};
|
|
11
|
+
declare const MyPasskeys_base: (new (...args: any[]) => LitElement) & typeof LitElement;
|
|
12
|
+
/**
|
|
13
|
+
* The person's own passkeys on the profile: what is registered, when it was last used, a name to
|
|
14
|
+
* tell them apart, and removal for a device that is lost (ADR-0156 decision 6).
|
|
15
|
+
*/
|
|
16
|
+
export declare class MyPasskeys extends MyPasskeys_base {
|
|
17
|
+
static styles: import("lit").CSSResult;
|
|
18
|
+
passkeys: Passkey[];
|
|
19
|
+
renaming?: string;
|
|
20
|
+
private supported;
|
|
21
|
+
render(): import("lit-html").TemplateResult<1> | typeof nothing;
|
|
22
|
+
firstUpdated(): void;
|
|
23
|
+
private when;
|
|
24
|
+
private fetch;
|
|
25
|
+
register(): Promise<void>;
|
|
26
|
+
private startRenaming;
|
|
27
|
+
/** Enter or leaving the field keeps the name; an empty or unchanged name keeps the old one. */
|
|
28
|
+
private rename;
|
|
29
|
+
private removePasskey;
|
|
30
|
+
}
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { __decorate, __metadata } from "tslib";
|
|
2
|
+
import '@material/web/button/text-button.js';
|
|
3
|
+
import '@material/web/icon/icon.js';
|
|
4
|
+
import '@material/web/iconbutton/icon-button.js';
|
|
5
|
+
import { gql } from 'graphql-tag';
|
|
6
|
+
import { css, html, LitElement, nothing } from 'lit';
|
|
7
|
+
import { customElement, state } from 'lit/decorators.js';
|
|
8
|
+
import { startRegistration } from '@simplewebauthn/browser';
|
|
9
|
+
import { client } from '@operato/graphql';
|
|
10
|
+
import { i18next, localize } from '@operato/i18n';
|
|
11
|
+
import { notify } from '@operato/layout';
|
|
12
|
+
import { OxPrompt } from '@operato/popup/ox-prompt.js';
|
|
13
|
+
import { alreadyRegistered, deviceLabel, wasCancelled } from '../utils/passkey.js';
|
|
14
|
+
import { readVerification } from '../utils/webauthn-verification.js';
|
|
15
|
+
/**
|
|
16
|
+
* The person's own passkeys on the profile: what is registered, when it was last used, a name to
|
|
17
|
+
* tell them apart, and removal for a device that is lost (ADR-0156 decision 6).
|
|
18
|
+
*/
|
|
19
|
+
let MyPasskeys = class MyPasskeys extends localize(i18next)(LitElement) {
|
|
20
|
+
constructor() {
|
|
21
|
+
super(...arguments);
|
|
22
|
+
this.passkeys = [];
|
|
23
|
+
this.supported = 'PublicKeyCredential' in window;
|
|
24
|
+
}
|
|
25
|
+
static { this.styles = css `
|
|
26
|
+
:host {
|
|
27
|
+
display: block;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
ul {
|
|
31
|
+
list-style: none;
|
|
32
|
+
margin: 0;
|
|
33
|
+
padding: 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
li {
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
gap: var(--spacing-small);
|
|
40
|
+
padding: var(--spacing-small) 0;
|
|
41
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.what {
|
|
45
|
+
flex: 1;
|
|
46
|
+
min-width: 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.name {
|
|
50
|
+
overflow: hidden;
|
|
51
|
+
text-overflow: ellipsis;
|
|
52
|
+
white-space: nowrap;
|
|
53
|
+
color: var(--md-sys-color-on-background);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.when {
|
|
57
|
+
font-size: 12px;
|
|
58
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.none {
|
|
62
|
+
margin: var(--spacing-small) 0;
|
|
63
|
+
font-size: 14px;
|
|
64
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.what input {
|
|
68
|
+
width: 100%;
|
|
69
|
+
box-sizing: border-box;
|
|
70
|
+
padding: 4px 6px;
|
|
71
|
+
font: inherit;
|
|
72
|
+
color: var(--md-sys-color-on-surface);
|
|
73
|
+
background: var(--md-sys-color-surface-container-highest);
|
|
74
|
+
border: 1px solid var(--md-sys-color-outline);
|
|
75
|
+
border-radius: var(--border-radius);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
md-icon-button {
|
|
79
|
+
--md-icon-button-icon-size: 20px;
|
|
80
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
81
|
+
}
|
|
82
|
+
`; }
|
|
83
|
+
render() {
|
|
84
|
+
if (!this.supported)
|
|
85
|
+
return nothing;
|
|
86
|
+
return html `
|
|
87
|
+
${this.passkeys.length
|
|
88
|
+
? html `
|
|
89
|
+
<ul>
|
|
90
|
+
${this.passkeys.map(passkey => html `
|
|
91
|
+
<li>
|
|
92
|
+
<div class="what">
|
|
93
|
+
${this.renaming === passkey.id
|
|
94
|
+
? html `<input
|
|
95
|
+
aria-label=${i18next.t('text.passkey name')}
|
|
96
|
+
.value=${passkey.name || ''}
|
|
97
|
+
maxlength="64"
|
|
98
|
+
@keydown=${(e) => {
|
|
99
|
+
if (e.key === 'Enter')
|
|
100
|
+
this.rename(passkey, e.target.value);
|
|
101
|
+
if (e.key === 'Escape')
|
|
102
|
+
this.renaming = undefined;
|
|
103
|
+
}}
|
|
104
|
+
@blur=${(e) => this.rename(passkey, e.target.value)}
|
|
105
|
+
/>`
|
|
106
|
+
: html `<div class="name">${passkey.name || i18next.t('label.passkey')}</div>`}
|
|
107
|
+
<div class="when">${this.when(passkey)}</div>
|
|
108
|
+
</div>
|
|
109
|
+
<md-icon-button title=${i18next.t('button.rename')} @click=${() => this.startRenaming(passkey)}>
|
|
110
|
+
<md-icon>edit</md-icon>
|
|
111
|
+
</md-icon-button>
|
|
112
|
+
<md-icon-button title=${i18next.t('button.delete')} @click=${() => this.removePasskey(passkey)}>
|
|
113
|
+
<md-icon>delete</md-icon>
|
|
114
|
+
</md-icon-button>
|
|
115
|
+
</li>
|
|
116
|
+
`)}
|
|
117
|
+
</ul>
|
|
118
|
+
`
|
|
119
|
+
: html `<p class="none">${i18next.t('text.no passkey yet')}</p>`}
|
|
120
|
+
|
|
121
|
+
<md-text-button @click=${() => this.register()}>
|
|
122
|
+
<md-icon slot="icon">passkey</md-icon>${i18next.t('button.add passkey')}
|
|
123
|
+
</md-text-button>
|
|
124
|
+
`;
|
|
125
|
+
}
|
|
126
|
+
firstUpdated() {
|
|
127
|
+
if (this.supported)
|
|
128
|
+
this.fetch();
|
|
129
|
+
}
|
|
130
|
+
when(passkey) {
|
|
131
|
+
const date = (value) => (value ? new Date(value).toLocaleDateString(i18next.language) : '');
|
|
132
|
+
const made = i18next.t('text.passkey added on', { date: date(passkey.createdAt) });
|
|
133
|
+
return passkey.lastUsedAt ? `${made} · ${i18next.t('text.passkey last used on', { date: date(passkey.lastUsedAt) })}` : made;
|
|
134
|
+
}
|
|
135
|
+
async fetch() {
|
|
136
|
+
const response = await client.query({
|
|
137
|
+
query: gql `
|
|
138
|
+
query {
|
|
139
|
+
myWebAuthCredentials {
|
|
140
|
+
id
|
|
141
|
+
name
|
|
142
|
+
createdAt
|
|
143
|
+
lastUsedAt
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
`,
|
|
147
|
+
fetchPolicy: 'no-cache'
|
|
148
|
+
});
|
|
149
|
+
if (!response.errors?.length) {
|
|
150
|
+
this.passkeys = response.data.myWebAuthCredentials;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/*
|
|
154
|
+
* The three endings stay apart (see `utils/webauthn-verification.ts`): registered, refused by the
|
|
155
|
+
* server, or never got there - and a prompt the person closed is not reported at all.
|
|
156
|
+
*/
|
|
157
|
+
async register() {
|
|
158
|
+
try {
|
|
159
|
+
const optionsJSON = await fetch('/auth/register-webauthn/challenge').then(res => res.json());
|
|
160
|
+
const response = await startRegistration({ optionsJSON });
|
|
161
|
+
const verification = await readVerification(await fetch('/auth/verify-registration', {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'Content-Type': 'application/json' },
|
|
164
|
+
body: JSON.stringify({ response, name: deviceLabel(navigator.userAgent) })
|
|
165
|
+
}));
|
|
166
|
+
if (verification.outcome === 'registered') {
|
|
167
|
+
notify({ level: 'info', message: i18next.t('text.user credential registered successfully') });
|
|
168
|
+
await this.fetch();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
/* The server's own words, in English, for whoever reads the console - not for the screen. */
|
|
172
|
+
console.error('passkey registration refused:', verification.reason);
|
|
173
|
+
notify({ level: 'error', message: i18next.t('error.user credential registration not allowed') });
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (wasCancelled(error))
|
|
177
|
+
return;
|
|
178
|
+
console.error('passkey registration failed:', error);
|
|
179
|
+
notify({
|
|
180
|
+
level: alreadyRegistered(error) ? 'info' : 'error',
|
|
181
|
+
message: i18next.t(alreadyRegistered(error) ? 'text.passkey already on this device' : 'error.user credential registration failed')
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async startRenaming(passkey) {
|
|
186
|
+
this.renaming = passkey.id;
|
|
187
|
+
await this.updateComplete;
|
|
188
|
+
const input = this.renderRoot.querySelector('.what input');
|
|
189
|
+
input?.focus();
|
|
190
|
+
input?.select();
|
|
191
|
+
}
|
|
192
|
+
/** Enter or leaving the field keeps the name; an empty or unchanged name keeps the old one. */
|
|
193
|
+
async rename(passkey, value) {
|
|
194
|
+
if (this.renaming !== passkey.id)
|
|
195
|
+
return;
|
|
196
|
+
this.renaming = undefined;
|
|
197
|
+
const name = value.trim();
|
|
198
|
+
if (!name || name === passkey.name)
|
|
199
|
+
return;
|
|
200
|
+
const response = await client.mutate({
|
|
201
|
+
mutation: gql `
|
|
202
|
+
mutation ($id: String!, $name: String!) {
|
|
203
|
+
updateMyWebAuthCredential(id: $id, name: $name) {
|
|
204
|
+
id
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
`,
|
|
208
|
+
variables: { id: passkey.id, name }
|
|
209
|
+
});
|
|
210
|
+
if (!response.errors?.length)
|
|
211
|
+
await this.fetch();
|
|
212
|
+
}
|
|
213
|
+
async removePasskey(passkey) {
|
|
214
|
+
const confirmed = await OxPrompt.open({
|
|
215
|
+
type: 'warning',
|
|
216
|
+
title: i18next.t('title.remove passkey'),
|
|
217
|
+
text: i18next.t('text.removed passkey cannot sign in', { name: passkey.name || i18next.t('label.passkey') }),
|
|
218
|
+
confirmButton: { text: i18next.t('button.delete') },
|
|
219
|
+
cancelButton: { text: i18next.t('button.cancel') }
|
|
220
|
+
});
|
|
221
|
+
if (!confirmed)
|
|
222
|
+
return;
|
|
223
|
+
const response = await client.mutate({
|
|
224
|
+
mutation: gql `
|
|
225
|
+
mutation ($id: String!) {
|
|
226
|
+
deleteMyWebAuthCredential(id: $id)
|
|
227
|
+
}
|
|
228
|
+
`,
|
|
229
|
+
variables: { id: passkey.id }
|
|
230
|
+
});
|
|
231
|
+
if (!response.errors?.length)
|
|
232
|
+
await this.fetch();
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
__decorate([
|
|
236
|
+
state(),
|
|
237
|
+
__metadata("design:type", Array)
|
|
238
|
+
], MyPasskeys.prototype, "passkeys", void 0);
|
|
239
|
+
__decorate([
|
|
240
|
+
state(),
|
|
241
|
+
__metadata("design:type", String)
|
|
242
|
+
], MyPasskeys.prototype, "renaming", void 0);
|
|
243
|
+
MyPasskeys = __decorate([
|
|
244
|
+
customElement('my-passkeys')
|
|
245
|
+
], MyPasskeys);
|
|
246
|
+
export { MyPasskeys };
|
|
247
|
+
//# sourceMappingURL=my-passkeys.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"my-passkeys.js","sourceRoot":"","sources":["../../client/components/my-passkeys.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/button/text-button.js'\nimport '@material/web/icon/icon.js'\nimport '@material/web/iconbutton/icon-button.js'\n\nimport { gql } from 'graphql-tag'\nimport { css, html, LitElement, nothing } from 'lit'\nimport { customElement, state } from 'lit/decorators.js'\nimport { startRegistration } from '@simplewebauthn/browser'\n\nimport { client } from '@operato/graphql'\nimport { i18next, localize } from '@operato/i18n'\nimport { notify } from '@operato/layout'\nimport { OxPrompt } from '@operato/popup/ox-prompt.js'\n\nimport { alreadyRegistered, deviceLabel, wasCancelled } from '../utils/passkey.js'\nimport { readVerification } from '../utils/webauthn-verification.js'\n\ntype Passkey = { id: string; name?: string | null; createdAt?: string; lastUsedAt?: string | null }\n\n/**\n * The person's own passkeys on the profile: what is registered, when it was last used, a name to\n * tell them apart, and removal for a device that is lost (ADR-0156 decision 6).\n */\n@customElement('my-passkeys')\nexport class MyPasskeys extends localize(i18next)(LitElement) {\n static styles = css`\n :host {\n display: block;\n }\n\n ul {\n list-style: none;\n margin: 0;\n padding: 0;\n }\n\n li {\n display: flex;\n align-items: center;\n gap: var(--spacing-small);\n padding: var(--spacing-small) 0;\n border-bottom: 1px solid var(--md-sys-color-outline-variant);\n }\n\n .what {\n flex: 1;\n min-width: 0;\n }\n\n .name {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--md-sys-color-on-background);\n }\n\n .when {\n font-size: 12px;\n color: var(--md-sys-color-on-surface-variant);\n }\n\n .none {\n margin: var(--spacing-small) 0;\n font-size: 14px;\n color: var(--md-sys-color-on-surface-variant);\n }\n\n .what input {\n width: 100%;\n box-sizing: border-box;\n padding: 4px 6px;\n font: inherit;\n color: var(--md-sys-color-on-surface);\n background: var(--md-sys-color-surface-container-highest);\n border: 1px solid var(--md-sys-color-outline);\n border-radius: var(--border-radius);\n }\n\n md-icon-button {\n --md-icon-button-icon-size: 20px;\n color: var(--md-sys-color-on-surface-variant);\n }\n `\n\n @state() passkeys: Passkey[] = []\n @state() renaming?: string\n\n private supported = 'PublicKeyCredential' in window\n\n render() {\n if (!this.supported) return nothing\n\n return html`\n ${this.passkeys.length\n ? html`\n <ul>\n ${this.passkeys.map(\n passkey => html`\n <li>\n <div class=\"what\">\n ${this.renaming === passkey.id\n ? html`<input\n aria-label=${i18next.t('text.passkey name')}\n .value=${passkey.name || ''}\n maxlength=\"64\"\n @keydown=${(e: KeyboardEvent) => {\n if (e.key === 'Enter') this.rename(passkey, (e.target as HTMLInputElement).value)\n if (e.key === 'Escape') this.renaming = undefined\n }}\n @blur=${(e: FocusEvent) => this.rename(passkey, (e.target as HTMLInputElement).value)}\n />`\n : html`<div class=\"name\">${passkey.name || i18next.t('label.passkey')}</div>`}\n <div class=\"when\">${this.when(passkey)}</div>\n </div>\n <md-icon-button title=${i18next.t('button.rename')} @click=${() => this.startRenaming(passkey)}>\n <md-icon>edit</md-icon>\n </md-icon-button>\n <md-icon-button title=${i18next.t('button.delete')} @click=${() => this.removePasskey(passkey)}>\n <md-icon>delete</md-icon>\n </md-icon-button>\n </li>\n `\n )}\n </ul>\n `\n : html`<p class=\"none\">${i18next.t('text.no passkey yet')}</p>`}\n\n <md-text-button @click=${() => this.register()}>\n <md-icon slot=\"icon\">passkey</md-icon>${i18next.t('button.add passkey')}\n </md-text-button>\n `\n }\n\n firstUpdated() {\n if (this.supported) this.fetch()\n }\n\n private when(passkey: Passkey) {\n const date = (value?: string | null) => (value ? new Date(value).toLocaleDateString(i18next.language) : '')\n const made = i18next.t('text.passkey added on', { date: date(passkey.createdAt) })\n\n return passkey.lastUsedAt ? `${made} · ${i18next.t('text.passkey last used on', { date: date(passkey.lastUsedAt) })}` : made\n }\n\n private async fetch() {\n const response = await client.query({\n query: gql`\n query {\n myWebAuthCredentials {\n id\n name\n createdAt\n lastUsedAt\n }\n }\n `,\n fetchPolicy: 'no-cache'\n })\n\n if (!response.errors?.length) {\n this.passkeys = response.data.myWebAuthCredentials\n }\n }\n\n /*\n * The three endings stay apart (see `utils/webauthn-verification.ts`): registered, refused by the\n * server, or never got there - and a prompt the person closed is not reported at all.\n */\n async register() {\n try {\n const optionsJSON = await fetch('/auth/register-webauthn/challenge').then(res => res.json())\n const response = await startRegistration({ optionsJSON })\n\n const verification = await readVerification(\n await fetch('/auth/verify-registration', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ response, name: deviceLabel(navigator.userAgent) })\n })\n )\n\n if (verification.outcome === 'registered') {\n notify({ level: 'info', message: i18next.t('text.user credential registered successfully') })\n await this.fetch()\n return\n }\n\n /* The server's own words, in English, for whoever reads the console - not for the screen. */\n console.error('passkey registration refused:', verification.reason)\n notify({ level: 'error', message: i18next.t('error.user credential registration not allowed') })\n } catch (error) {\n if (wasCancelled(error)) return\n\n console.error('passkey registration failed:', error)\n notify({\n level: alreadyRegistered(error) ? 'info' : 'error',\n message: i18next.t(alreadyRegistered(error) ? 'text.passkey already on this device' : 'error.user credential registration failed')\n })\n }\n }\n\n private async startRenaming(passkey: Passkey) {\n this.renaming = passkey.id\n await this.updateComplete\n const input = this.renderRoot.querySelector('.what input') as HTMLInputElement | null\n input?.focus()\n input?.select()\n }\n\n /** Enter or leaving the field keeps the name; an empty or unchanged name keeps the old one. */\n private async rename(passkey: Passkey, value: string) {\n if (this.renaming !== passkey.id) return\n this.renaming = undefined\n\n const name = value.trim()\n if (!name || name === passkey.name) return\n\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!, $name: String!) {\n updateMyWebAuthCredential(id: $id, name: $name) {\n id\n }\n }\n `,\n variables: { id: passkey.id, name }\n })\n\n if (!response.errors?.length) await this.fetch()\n }\n\n private async removePasskey(passkey: Passkey) {\n const confirmed = await OxPrompt.open({\n type: 'warning',\n title: i18next.t('title.remove passkey'),\n text: i18next.t('text.removed passkey cannot sign in', { name: passkey.name || i18next.t('label.passkey') }),\n confirmButton: { text: i18next.t('button.delete') },\n cancelButton: { text: i18next.t('button.cancel') }\n })\n if (!confirmed) return\n\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!) {\n deleteMyWebAuthCredential(id: $id)\n }\n `,\n variables: { id: passkey.id }\n })\n\n if (!response.errors?.length) await this.fetch()\n }\n}\n"]}
|
|
@@ -3,6 +3,7 @@ import '@operato/i18n/ox-i18n-selector.js';
|
|
|
3
3
|
import './change-password.js';
|
|
4
4
|
import './delete-user-popup.js';
|
|
5
5
|
import './my-login-history.js';
|
|
6
|
+
import './my-passkeys.js';
|
|
6
7
|
import { LitElement } from 'lit';
|
|
7
8
|
declare const ProfileComponent_base: (new (...args: any[]) => LitElement) & typeof LitElement;
|
|
8
9
|
export declare class ProfileComponent extends ProfileComponent_base {
|
|
@@ -39,7 +40,6 @@ export declare class ProfileComponent extends ProfileComponent_base {
|
|
|
39
40
|
onLocaleChanged(value: any): Promise<void>;
|
|
40
41
|
openLoginHistory(): void;
|
|
41
42
|
deleteUser(): void;
|
|
42
|
-
registerUser(): Promise<void>;
|
|
43
43
|
get applicationMeta(): {
|
|
44
44
|
icon: string;
|
|
45
45
|
title: string;
|
|
@@ -4,16 +4,15 @@ import '@operato/i18n/ox-i18n-selector.js';
|
|
|
4
4
|
import './change-password.js';
|
|
5
5
|
import './delete-user-popup.js';
|
|
6
6
|
import './my-login-history.js';
|
|
7
|
+
import './my-passkeys.js';
|
|
7
8
|
import { gql } from 'graphql-tag';
|
|
8
9
|
import { css, html, LitElement, nothing } from 'lit';
|
|
9
10
|
import { customElement, property, query, state } from 'lit/decorators.js';
|
|
10
|
-
import { startRegistration } from '@simplewebauthn/browser';
|
|
11
11
|
import { client } from '@operato/graphql';
|
|
12
12
|
import { i18next, localize } from '@operato/i18n';
|
|
13
13
|
import { notify, openPopup } from '@operato/layout';
|
|
14
14
|
import { FormFieldStyles } from '@operato/styles';
|
|
15
15
|
import { auth, getLanguages } from '@things-factory/auth-base/dist-client';
|
|
16
|
-
import { readVerification } from '../utils/webauthn-verification.js';
|
|
17
16
|
const isAvailableWebauthn = 'PublicKeyCredential' in window;
|
|
18
17
|
let ProfileComponent = class ProfileComponent extends localize(i18next)(LitElement) {
|
|
19
18
|
constructor() {
|
|
@@ -205,9 +204,9 @@ let ProfileComponent = class ProfileComponent extends localize(i18next)(LitEleme
|
|
|
205
204
|
|
|
206
205
|
${isAvailableWebauthn
|
|
207
206
|
? html `
|
|
208
|
-
<
|
|
209
|
-
|
|
210
|
-
>
|
|
207
|
+
<hr />
|
|
208
|
+
<label for="passkeys"><ox-i18n msgid="label.passkey"></ox-i18n></label>
|
|
209
|
+
<my-passkeys id="passkeys"></my-passkeys>
|
|
211
210
|
`
|
|
212
211
|
: nothing}
|
|
213
212
|
|
|
@@ -317,54 +316,6 @@ let ProfileComponent = class ProfileComponent extends localize(i18next)(LitEleme
|
|
|
317
316
|
title: i18next.t('label.delete account')
|
|
318
317
|
});
|
|
319
318
|
}
|
|
320
|
-
/*
|
|
321
|
-
* ⚠ The three endings here are not interchangeable, and the screen used to give the same one
|
|
322
|
-
* for all of them.
|
|
323
|
-
*
|
|
324
|
-
* registered the server confirmed it
|
|
325
|
-
* refused the server said no — usually this credential is already registered
|
|
326
|
-
* failed we never got an answer: the browser refused, the device was cancelled,
|
|
327
|
-
* the network dropped
|
|
328
|
-
*
|
|
329
|
-
* `await verification.text()` on an already-parsed body threw a TypeError that the catch
|
|
330
|
-
* below swallowed, so **refused arrived as failed** and the refusal message was unreachable
|
|
331
|
-
* code. `readVerification` works out which ending it is without being able to throw; see
|
|
332
|
-
* `utils/webauthn-verification.ts`.
|
|
333
|
-
*/
|
|
334
|
-
async registerUser() {
|
|
335
|
-
try {
|
|
336
|
-
const options = await fetch('/auth/register-webauthn/challenge').then(res => res.json());
|
|
337
|
-
const attResp = await startRegistration(options);
|
|
338
|
-
const verification = await readVerification(await fetch('/auth/verify-registration', {
|
|
339
|
-
method: 'POST',
|
|
340
|
-
headers: {
|
|
341
|
-
'Content-Type': 'application/json'
|
|
342
|
-
},
|
|
343
|
-
body: JSON.stringify(attResp)
|
|
344
|
-
}));
|
|
345
|
-
if (verification.outcome === 'registered') {
|
|
346
|
-
notify({
|
|
347
|
-
level: 'info',
|
|
348
|
-
message: i18next.t('text.user credential registered successfully')
|
|
349
|
-
});
|
|
350
|
-
return;
|
|
351
|
-
}
|
|
352
|
-
/* The server's own words, in English, for whoever reads the console — not for the screen. */
|
|
353
|
-
console.error('passkey registration refused:', verification.reason);
|
|
354
|
-
notify({
|
|
355
|
-
level: 'error',
|
|
356
|
-
message: i18next.t('error.user credential registration not allowed')
|
|
357
|
-
});
|
|
358
|
-
}
|
|
359
|
-
catch (error) {
|
|
360
|
-
/* Never reached the server, or never got past the device. A different thing to say. */
|
|
361
|
-
console.error('passkey registration failed:', error);
|
|
362
|
-
notify({
|
|
363
|
-
level: 'error',
|
|
364
|
-
message: i18next.t('error.user credential registration failed')
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
319
|
get applicationMeta() {
|
|
369
320
|
var iconLink = document.querySelector('link[rel="application-icon"]');
|
|
370
321
|
var titleMeta = document.querySelector('meta[name="application-name"]');
|