@things-factory/auth-ui 10.1.206 → 10.1.208

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 (32) hide show
  1. package/dist-client/auth-style-sign.js +15 -7
  2. package/dist-client/auth-style-sign.js.map +1 -1
  3. package/dist-client/components/abstract-sign.d.ts +9 -1
  4. package/dist-client/components/abstract-sign.js +48 -28
  5. package/dist-client/components/abstract-sign.js.map +1 -1
  6. package/dist-client/components/my-passkeys.d.ts +31 -0
  7. package/dist-client/components/my-passkeys.js +247 -0
  8. package/dist-client/components/my-passkeys.js.map +1 -0
  9. package/dist-client/components/profile-component.d.ts +1 -1
  10. package/dist-client/components/profile-component.js +4 -53
  11. package/dist-client/components/profile-component.js.map +1 -1
  12. package/dist-client/entries/auth/passkey-offer.d.ts +27 -0
  13. package/dist-client/entries/auth/passkey-offer.js +161 -0
  14. package/dist-client/entries/auth/passkey-offer.js.map +1 -0
  15. package/dist-client/tsconfig.tsbuildinfo +1 -1
  16. package/dist-client/utils/audit-words.js +3 -1
  17. package/dist-client/utils/audit-words.js.map +1 -1
  18. package/dist-client/utils/audit-words.test.js +6 -0
  19. package/dist-client/utils/audit-words.test.js.map +1 -1
  20. package/dist-client/utils/passkey.d.ts +38 -0
  21. package/dist-client/utils/passkey.js +84 -0
  22. package/dist-client/utils/passkey.js.map +1 -0
  23. package/dist-client/utils/passkey.test.d.ts +1 -0
  24. package/dist-client/utils/passkey.test.js +75 -0
  25. package/dist-client/utils/passkey.test.js.map +1 -0
  26. package/package.json +5 -5
  27. package/translations/en.json +17 -1
  28. package/translations/es.json +17 -1
  29. package/translations/ja.json +17 -1
  30. package/translations/ko.json +18 -2
  31. package/translations/ms.json +17 -1
  32. 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: flex;
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
- .fingerprint {
84
- color: var(--md-sys-color-on-primary);
85
- border: 1.5px solid var(--md-sys-color-on-primary);
86
- border-radius: 20%;
87
- width: 36px;
88
- height: 36px;
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+LjC,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 .submit-buttons-container {\n grid-column: 1 / -1;\n text-align: center;\n\n display: flex;\n align-items: center;\n gap: 10px;\n }\n\n .fingerprint {\n color: var(--md-sys-color-on-primary);\n border: 1.5px solid var(--md-sys-color-on-primary);\n border-radius: 20%;\n width: 36px;\n height: 36px;\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
+ {"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
- authenticateUser(): Promise<void>;
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 ` <md-icon class="fingerprint" raised @click=${e => this.authenticateUser()}> fingerprint </md-icon>`
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
- async authenticateUser() {
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
- const options = await fetch('/auth/signin-webauthn/challenge').then(res => res.json());
64
- const assertionResp = await startAuthentication(options);
65
- const verification = await fetch('/auth/signin-webauthn', {
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
- 'Content-Type': 'application/json'
69
- },
70
- body: JSON.stringify(assertionResp)
71
- }).then(res => res.json());
72
- if (verification.verified) {
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
- notify({
87
- level: 'error',
88
- message: i18next.t('error.authn verification failed')
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":"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","sourcesContent":["import '@operato/i18n/ox-i18n.js'\n\nimport { html, nothing } from 'lit'\nimport { startAuthentication } from '@simplewebauthn/browser'\n\nimport { i18next } from '@operato/i18n'\nimport { notify } from '@operato/layout'\n\nimport { AbstractAuthPage } from './abstract-auth-page.js'\n\nconst isAvailableWebauthn = 'PublicKeyCredential' in window\n\ninterface AssertionResponse {\n id: string\n rawId?: number[]\n response: {\n authenticatorData: string\n clientDataJSON: string\n signature: string\n userHandle: string | null\n }\n type: PublicKeyCredentialType\n authenticatorAttachment?: AuthenticatorAttachment\n}\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' : '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\n ? html` <md-icon class=\"fingerprint\" raised @click=${e => this.authenticateUser()}> fingerprint </md-icon>`\n : nothing}\n </div>\n `\n }\n\n async authenticateUser() {\n try {\n const options = await fetch('/auth/signin-webauthn/challenge').then(res => res.json())\n const assertionResp = await startAuthentication(options)\n const verification = await fetch('/auth/signin-webauthn', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify(assertionResp)\n }).then(res => res.json())\n\n if (verification.verified) {\n const { redirectURL } = verification\n\n if (redirectURL) {\n window.location.href = redirectURL\n }\n } else {\n notify({\n level: 'error',\n message: verification.message\n })\n }\n } catch (error) {\n notify({\n level: 'error',\n message: i18next.t('error.authn verification failed')\n })\n }\n }\n}\n"]}
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(): typeof nothing | import("lit-html").TemplateResult<1>;
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
- <md-text-button @click=${() => this.registerUser()}
209
- >${i18next.t('button.security-key registration')}</md-text-button
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"]');