@franzzemen/brokenstock-shell 0.9.5 → 0.10.0

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.
@@ -360,6 +360,7 @@ function authenticatorMessage(reason) {
360
360
  case 'too-many-attempts': return TOO_MANY_ATTEMPTS;
361
361
  case 'authenticator-not-set-up': return 'This account has no authenticator set up. Ask the administrator to reset it.';
362
362
  case 'setup-required': return 'Scan this QR code with your authenticator app, then enter the 6-digit code it shows.';
363
+ case 'passkey-refused': return 'That passkey was not accepted. Try again, or sign in with your password.';
363
364
  default: return 'Unable to sign in';
364
365
  }
365
366
  }
@@ -377,6 +378,146 @@ function recoveryCodesFile(appName, username, codes, now = new Date()) {
377
378
  ].join('\n');
378
379
  }
379
380
 
381
+ /*
382
+ Created by Franz Zemen 2026-10-21
383
+ License Type: UNLICENSED
384
+
385
+ The browser half of passkeys (passkey-sign-in.prd.md): turn the server's JSON options into what
386
+ `navigator.credentials` takes, and the passkey it returns back into JSON for the server.
387
+
388
+ No library. Browsers that can do one passkey across brokenstock.com and brokenstock.org (D3) can
389
+ do all of this natively; the base64url conversion below is the whole of what a library adds, and
390
+ it saves shipping one to every page. A browser without passkeys gets `passkeysSupported() ===
391
+ false` and the screens hide the passkey buttons — password + code always remains (D2).
392
+ */
393
+ /** The user cancelled or the browser timed out — not an error worth a red message. */
394
+ class PasskeyCancelledError extends Error {
395
+ constructor() { super('Passkey prompt cancelled'); this.name = 'PasskeyCancelledError'; }
396
+ }
397
+ function passkeysSupported() {
398
+ return typeof window !== 'undefined'
399
+ && typeof window.PublicKeyCredential === 'function'
400
+ && typeof navigator !== 'undefined'
401
+ && !!navigator.credentials;
402
+ }
403
+ /** Whether the browser can offer passkeys in the username box's suggestions (D9). */
404
+ async function passkeyAutofillSupported() {
405
+ if (!passkeysSupported())
406
+ return false;
407
+ const pkc = window.PublicKeyCredential;
408
+ try {
409
+ return typeof pkc.isConditionalMediationAvailable === 'function' && await pkc.isConditionalMediationAvailable();
410
+ }
411
+ catch {
412
+ return false;
413
+ }
414
+ }
415
+ function base64urlToBuffer(value) {
416
+ const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
417
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
418
+ const binary = atob(padded);
419
+ const bytes = new Uint8Array(binary.length);
420
+ for (let i = 0; i < binary.length; i++)
421
+ bytes[i] = binary.charCodeAt(i);
422
+ return bytes.buffer;
423
+ }
424
+ function bufferToBase64url(buffer) {
425
+ const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
426
+ let binary = '';
427
+ for (let i = 0; i < bytes.length; i++)
428
+ binary += String.fromCharCode(bytes[i]);
429
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
430
+ }
431
+ function descriptors(list) {
432
+ return list?.map(c => ({
433
+ id: base64urlToBuffer(c.id),
434
+ type: 'public-key',
435
+ ...(c.transports ? { transports: c.transports } : {}),
436
+ }));
437
+ }
438
+ function toRequestOptions(json) {
439
+ return {
440
+ challenge: base64urlToBuffer(json.challenge),
441
+ ...(json.rpId ? { rpId: json.rpId } : {}),
442
+ ...(json.timeout ? { timeout: json.timeout } : {}),
443
+ ...(json.userVerification ? { userVerification: json.userVerification } : {}),
444
+ ...(json.allowCredentials?.length ? { allowCredentials: descriptors(json.allowCredentials) } : {}),
445
+ };
446
+ }
447
+ function toCreationOptions(json) {
448
+ return {
449
+ challenge: base64urlToBuffer(json.challenge),
450
+ rp: json.rp,
451
+ user: { id: base64urlToBuffer(json.user.id), name: json.user.name, displayName: json.user.displayName },
452
+ pubKeyCredParams: json.pubKeyCredParams,
453
+ ...(json.timeout ? { timeout: json.timeout } : {}),
454
+ ...(json.excludeCredentials?.length ? { excludeCredentials: descriptors(json.excludeCredentials) } : {}),
455
+ ...(json.authenticatorSelection ? { authenticatorSelection: json.authenticatorSelection } : {}),
456
+ ...(json.attestation ? { attestation: json.attestation } : {}),
457
+ };
458
+ }
459
+ function credentialToJSON(credential) {
460
+ const r = credential.response;
461
+ const response = { clientDataJSON: bufferToBase64url(r.clientDataJSON) };
462
+ if ('attestationObject' in r && r.attestationObject) {
463
+ response['attestationObject'] = bufferToBase64url(r.attestationObject);
464
+ if (typeof r.getTransports === 'function')
465
+ response['transports'] = r.getTransports();
466
+ }
467
+ if ('authenticatorData' in r && r.authenticatorData) {
468
+ response['authenticatorData'] = bufferToBase64url(r.authenticatorData);
469
+ response['signature'] = bufferToBase64url(r.signature);
470
+ if (r.userHandle)
471
+ response['userHandle'] = bufferToBase64url(r.userHandle);
472
+ }
473
+ return {
474
+ id: credential.id,
475
+ rawId: bufferToBase64url(credential.rawId),
476
+ type: credential.type,
477
+ response,
478
+ clientExtensionResults: credential.getClientExtensionResults(),
479
+ ...(credential.authenticatorAttachment ? { authenticatorAttachment: credential.authenticatorAttachment } : {}),
480
+ };
481
+ }
482
+ function cancelled(err) {
483
+ return err instanceof DOMException && (err.name === 'NotAllowedError' || err.name === 'AbortError');
484
+ }
485
+ /**
486
+ * Ask the browser for a passkey. `conditional` waits quietly for the user to pick one from the
487
+ * username box's suggestions; pass a `signal` to stop waiting when the screen goes away.
488
+ */
489
+ async function getPasskey(options, conditional = false, signal) {
490
+ try {
491
+ const credential = await navigator.credentials.get({
492
+ publicKey: toRequestOptions(options),
493
+ ...(conditional ? { mediation: 'conditional' } : {}),
494
+ ...(signal ? { signal } : {}),
495
+ });
496
+ if (!credential)
497
+ throw new PasskeyCancelledError();
498
+ return credentialToJSON(credential);
499
+ }
500
+ catch (err) {
501
+ if (cancelled(err))
502
+ throw new PasskeyCancelledError();
503
+ throw err;
504
+ }
505
+ }
506
+ /** Ask the browser to make a passkey. */
507
+ async function createPasskey(options) {
508
+ try {
509
+ const credential = await navigator.credentials.create({ publicKey: toCreationOptions(options) });
510
+ if (!credential)
511
+ throw new PasskeyCancelledError();
512
+ return credentialToJSON(credential);
513
+ }
514
+ catch (err) {
515
+ if (cancelled(err))
516
+ throw new PasskeyCancelledError();
517
+ throw err;
518
+ }
519
+ }
520
+
380
521
  /**
381
522
  * A refusal from an authenticator route, carrying the server's `reason`
382
523
  * (authenticator-sign-in.prd.md). `message` is plain words for the screen.
@@ -391,6 +532,8 @@ class AuthenticatorError extends Error {
391
532
  }
392
533
  /** Sign-in rejected because the next step is a code (or setting one up), not because anything failed. */
393
534
  const CODE_STEP = 'code-step';
535
+ /** The user dismissed the passkey prompt — nothing failed, nothing to say. */
536
+ const PASSKEY_CANCELLED = 'passkey-cancelled';
394
537
  /**
395
538
  * The sign-in request body, exactly as sent (multiple-devices E8, authenticator-sign-in.prd.md).
396
539
  *
@@ -476,6 +619,12 @@ class AuthenticationService {
476
619
  */
477
620
  this.bypassActive = signal(false, /* @ts-ignore */
478
621
  ...(ngDevMode ? [{ debugName: "bypassActive" }] : /* istanbul ignore next */ []));
622
+ /**
623
+ * The open "already signed in elsewhere" choice came from a passkey sign-in, so its retry goes
624
+ * to the passkey route with the ticket, not to /login with a password (passkey-sign-in.prd.md D8).
625
+ */
626
+ this.pendingPasskey = signal(false, /* @ts-ignore */
627
+ ...(ngDevMode ? [{ debugName: "pendingPasskey" }] : /* istanbul ignore next */ []));
479
628
  this.#refreshing = null;
480
629
  this.#auth = 'Bearer ..';
481
630
  this.#httpClient = inject(HttpClient);
@@ -566,6 +715,7 @@ class AuthenticationService {
566
715
  this.conflictSessions.set(undefined);
567
716
  this.additionalSignInAllowed.set(false);
568
717
  this.pendingCredentials.set(undefined);
718
+ this.pendingPasskey.set(false);
569
719
  this.#signInTicket = undefined;
570
720
  }
571
721
  /** Back to the password form from the code or setup step. */
@@ -632,11 +782,119 @@ class AuthenticationService {
632
782
  }
633
783
  this.conflictSessions.set(undefined);
634
784
  this.#thinkingService.startThinking();
785
+ // A ticket from a passkey sign-in's 409 stands in for a passkey, never for a code: it must not
786
+ // ride along on a password sign-in, where it would be checked — and counted — as a wrong code.
787
+ const ticket = this.pendingPasskey() ? undefined : this.#signInTicket;
788
+ const body = buildLoginBody(credentials, force, options, ticket);
789
+ return this.#signIn(this.#authUrls.login, body, credentials);
790
+ }
791
+ // ---------------------------------------------------------------------------
792
+ // Passkey sign-in (passkey-sign-in.prd.md D1, D8, D9)
793
+ // ---------------------------------------------------------------------------
794
+ /**
795
+ * Sign in with a passkey: no username, password or code. `conditional` waits quietly for the user
796
+ * to pick a passkey from the username box's suggestions (D9); abort `signal` when the screen goes.
797
+ * Rejects with PASSKEY_CANCELLED when the user dismissed the prompt, 'conflict' on a 409 (retry
798
+ * with `retryPasskeySignIn`), or a plain-words message.
799
+ */
800
+ async loginWithPasskey(opts = {}) {
801
+ const options = await firstValueFrom(this.#httpClient.post(`${this.#authUrls.login}/passkey/options`, {}, { headers: { [HttpHeader.ContentType]: 'application/json', [HttpHeader.AppContext]: 'brokenstock' } }));
802
+ let response;
803
+ try {
804
+ response = await getPasskey(options, opts.conditional === true, opts.signal);
805
+ }
806
+ catch (err) {
807
+ if (err instanceof PasskeyCancelledError)
808
+ throw PASSKEY_CANCELLED;
809
+ throw authenticatorMessage('passkey-refused');
810
+ }
811
+ this.conflictSessions.set(undefined);
812
+ this.#signInTicket = undefined;
813
+ this.#thinkingService.startThinking();
814
+ return this.#signIn(`${this.#authUrls.login}/passkey`, { response }, undefined);
815
+ }
816
+ /** The answer to "already signed in elsewhere" after a passkey sign-in: the ticket stands in for the passkey (D8). */
817
+ retryPasskeySignIn(force, choice = {}) {
818
+ const ticket = this.#signInTicket;
819
+ if (!ticket)
820
+ return Promise.reject(authenticatorMessage('passkey-refused'));
821
+ this.conflictSessions.set(undefined);
822
+ this.#thinkingService.startThinking();
823
+ return this.#signIn(`${this.#authUrls.login}/passkey`, {
824
+ signInTicket: ticket,
825
+ ...(force ? { force: true } : {}),
826
+ ...(choice.additional ? { additional: true } : {}),
827
+ ...(choice.endSessionIds?.length ? { endSessionIds: choice.endSessionIds } : {}),
828
+ }, undefined);
829
+ }
830
+ // ---------------------------------------------------------------------------
831
+ // Passkey settings and the on-demand check (passkey-sign-in.prd.md D11, D13–D15)
832
+ // ---------------------------------------------------------------------------
833
+ /** This user's passkeys. */
834
+ async listPasskeys() {
835
+ const { passkeys } = await this.#authenticatorCall('GET', 'passkeys');
836
+ return passkeys;
837
+ }
838
+ /**
839
+ * Add a passkey (D13, D14). `proof` is password + code (or recovery code), or 'passkey' to prove
840
+ * with one already added. Opens the browser's passkey prompt; resolves with the new passkey.
841
+ */
842
+ async addPasskey(name, proof) {
843
+ const proofBody = proof === 'passkey' ? { passkey: await this.#passkeyResponse('confirm') } : proof;
844
+ const options = await this.#authenticatorCall('POST', 'passkeys/add/options', proofBody);
845
+ let response;
846
+ try {
847
+ response = await createPasskey(options);
848
+ }
849
+ catch (err) {
850
+ if (err instanceof PasskeyCancelledError)
851
+ throw new AuthenticatorError(undefined, 'Cancelled — no passkey was added.', 0);
852
+ if (err instanceof DOMException && err.name === 'InvalidStateError') {
853
+ throw new AuthenticatorError(undefined, 'This device already has a passkey for this account.', 0);
854
+ }
855
+ throw new AuthenticatorError(undefined, 'The browser could not make a passkey.', 0);
856
+ }
857
+ return this.#authenticatorCall('POST', 'passkeys/add', { name, response });
858
+ }
859
+ /** Remove one of your passkeys (D15), proved with a code, a recovery code, or a passkey. */
860
+ async removePasskey(credentialId, proof) {
861
+ const proofBody = proof === 'passkey' ? { passkey: await this.#passkeyResponse('confirm') } : proof;
862
+ const { removed } = await this.#authenticatorCall('POST', 'passkeys/remove', { credentialId, ...proofBody });
863
+ return removed;
864
+ }
865
+ /**
866
+ * The on-demand check with a passkey (D11) — the passkey twin of `verifyCode`. Opens the
867
+ * browser's prompt (fingerprint or PIN, always) and, on success, the server records it on this
868
+ * session exactly as a code would. `purpose` is a label for the log, e.g. 'arm-trading'.
869
+ * Resolves with when it was recorded; rejects with an AuthenticatorError (cancelled, refused, or
870
+ * no passkey on this account — reason 'passkey-refused').
871
+ */
872
+ async verifyWithPasskey(purpose) {
873
+ const response = await this.#passkeyResponse('verify', purpose);
874
+ const { codeVerifiedAtEpoch } = await this.#authenticatorCall('POST', 'passkey/verify', { response, ...(purpose ? { purpose } : {}) });
875
+ return codeVerifiedAtEpoch;
876
+ }
877
+ /** A challenge bound to this session, answered by one of this user's passkeys. */
878
+ async #passkeyResponse(kind, purpose) {
879
+ const options = await this.#authenticatorCall('POST', 'passkeys/challenge', { kind, ...(purpose ? { purpose } : {}) });
880
+ try {
881
+ return await getPasskey(options);
882
+ }
883
+ catch (err) {
884
+ if (err instanceof PasskeyCancelledError)
885
+ throw new AuthenticatorError(undefined, 'Cancelled.', 0);
886
+ throw new AuthenticatorError('passkey-refused', authenticatorMessage('passkey-refused'), 0);
887
+ }
888
+ }
889
+ /**
890
+ * Post a sign-in and adopt what comes back — shared by the password and passkey routes.
891
+ * `credentials` is undefined for a passkey sign-in, which has none.
892
+ */
893
+ #signIn(url, body, credentials) {
635
894
  return new Promise((resolve, reject) => {
636
895
  this.authenticating.set(true);
637
- const body = buildLoginBody(credentials, force, options, this.#signInTicket);
638
896
  const subscription = this.#httpClient
639
- .post(this.#authUrls.login, body, {
897
+ .post(url, body, {
640
898
  headers: {
641
899
  [HttpHeader.ContentType]: 'application/json',
642
900
  [HttpHeader.AppContext]: 'brokenstock',
@@ -666,6 +924,7 @@ class AuthenticationService {
666
924
  this.codeStep.set('none');
667
925
  this.setupChallenge.set(undefined);
668
926
  this.#signInTicket = undefined;
927
+ this.pendingPasskey.set(false);
669
928
  this.authenticated.set(true);
670
929
  this.pendingCredentials.set(undefined);
671
930
  resolve();
@@ -698,6 +957,9 @@ class AuthenticationService {
698
957
  // offer it.
699
958
  this.additionalSignInAllowed.set(err.error?.additionalSignInAllowed === true);
700
959
  this.pendingCredentials.set(credentials);
960
+ // A passkey sign-in has no password to send again: its retry goes back to the
961
+ // passkey route with the ticket alone (passkey-sign-in.prd.md D8).
962
+ this.pendingPasskey.set(credentials === undefined);
701
963
  reject('conflict');
702
964
  }
703
965
  else if (reason === 'code-required') {
@@ -719,7 +981,7 @@ class AuthenticationService {
719
981
  reject(authenticatorMessage(reason));
720
982
  }
721
983
  else if (err.status === 401) {
722
- reject('Wrong username or password');
984
+ reject(credentials ? 'Wrong username or password' : authenticatorMessage('passkey-refused'));
723
985
  }
724
986
  else {
725
987
  reject(err.message ?? 'Unable to login');
@@ -906,6 +1168,7 @@ class AuthenticationService {
906
1168
  this.recoveryCodesToShow.set(undefined);
907
1169
  this.recoveryCodesLeft.set(undefined);
908
1170
  this.bypassActive.set(false);
1171
+ this.pendingPasskey.set(false);
909
1172
  this.#signInTicket = undefined;
910
1173
  this.#auth = 'Bearer ..';
911
1174
  this.#store.clear();
@@ -4137,6 +4400,8 @@ class Login {
4137
4400
  #commandService;
4138
4401
  #terminalFocusService;
4139
4402
  #activatedRoute;
4403
+ /** The quiet wait for a passkey picked from the username box's suggestions (passkey D9). */
4404
+ #autofill;
4140
4405
  constructor(authenticationService, router) {
4141
4406
  this.authenticationService = authenticationService;
4142
4407
  this.router = router;
@@ -4169,6 +4434,56 @@ class Login {
4169
4434
  this.#commandService = inject(CommandService);
4170
4435
  this.#terminalFocusService = inject(TerminalFocusService);
4171
4436
  this.#activatedRoute = inject(ActivatedRoute);
4437
+ /** Whether this browser can sign in with a passkey at all; the button is hidden otherwise (passkey D2). */
4438
+ this.passkeys = passkeysSupported();
4439
+ }
4440
+ /** Safe on the server: the support check is false wherever there is no browser. */
4441
+ ngOnInit() {
4442
+ void this.#startPasskeyAutofill();
4443
+ }
4444
+ ngOnDestroy() {
4445
+ this.#autofill?.abort();
4446
+ }
4447
+ /** "Sign in with a passkey". The quiet wait must stop first: a browser runs one passkey prompt at a time. */
4448
+ onPasskey() {
4449
+ this.errors = [];
4450
+ this.#autofill?.abort();
4451
+ this.#autofill = undefined;
4452
+ this.submitted = true;
4453
+ this.#passkeySignIn(false);
4454
+ }
4455
+ /**
4456
+ * Offer passkeys in the username box's suggestions (D9): a passkey request that waits until the
4457
+ * user picks one there. Silent when unsupported or when anything goes wrong — the button and the
4458
+ * password form are still on screen.
4459
+ */
4460
+ async #startPasskeyAutofill() {
4461
+ if (!(await passkeyAutofillSupported()))
4462
+ return;
4463
+ if (this.authenticationService.authenticated())
4464
+ return;
4465
+ this.#autofill?.abort();
4466
+ const controller = new AbortController();
4467
+ this.#autofill = controller;
4468
+ this.#passkeySignIn(true, controller.signal);
4469
+ }
4470
+ #passkeySignIn(conditional, signal) {
4471
+ this.authenticationService
4472
+ .loginWithPasskey({ conditional, ...(signal ? { signal } : {}) })
4473
+ .then(() => this.#afterSignIn(undefined))
4474
+ .catch((err) => {
4475
+ // The quiet wait ending says nothing about a button press that may be in flight.
4476
+ if (!conditional)
4477
+ this.submitted = false;
4478
+ if (err === 'conflict')
4479
+ return;
4480
+ if (err !== PASSKEY_CANCELLED && !conditional) {
4481
+ this.errors.push({ ndx: 0, message: typeof err === 'string' ? err : 'Unable to sign in with a passkey' });
4482
+ }
4483
+ // Offer the suggestions again, unless this screen is going away.
4484
+ if (!signal?.aborted)
4485
+ void this.#startPasskeyAutofill();
4486
+ });
4172
4487
  }
4173
4488
  /**
4174
4489
  * Returns the post-login destination. Honors a `returnUrl` query param if
@@ -4309,20 +4624,14 @@ class Login {
4309
4624
  this.submitted = false;
4310
4625
  }
4311
4626
  #doLogin(force, choice = {}) {
4312
- this.authenticationService
4313
- .login(this.model, force, choice)
4314
- .then(() => {
4315
- this.#commandService.echoCommand(`login ${this.model.username}`);
4316
- this.codeText = '';
4317
- this.useRecovery.set(false);
4318
- // Setup finished during this sign-in: the codes are on screen and must be
4319
- // seen before the app takes over the page.
4320
- if (this.authenticationService.recoveryCodesToShow()) {
4321
- this.submitted = false;
4322
- return;
4323
- }
4324
- this.#navigateAfterLogin();
4325
- })
4627
+ // The open "already signed in elsewhere" choice came from a passkey: its retry carries the
4628
+ // ticket to the passkey route — there is no password to send (passkey D8).
4629
+ const viaPasskey = this.authenticationService.pendingPasskey();
4630
+ const attempt = viaPasskey
4631
+ ? this.authenticationService.retryPasskeySignIn(force, choice)
4632
+ : this.authenticationService.login(this.model, force, choice);
4633
+ attempt
4634
+ .then(() => this.#afterSignIn(viaPasskey ? undefined : this.model.username))
4326
4635
  .catch((err) => {
4327
4636
  if (err !== 'conflict' && err !== CODE_STEP) {
4328
4637
  this.errors.push({ ndx: 0, message: typeof err === 'string' ? err : 'Unable to login' });
@@ -4331,13 +4640,26 @@ class Login {
4331
4640
  this.submitted = false;
4332
4641
  });
4333
4642
  }
4643
+ #afterSignIn(username) {
4644
+ this.#autofill?.abort();
4645
+ this.#commandService.echoCommand(username ? `login ${username}` : 'login (passkey)');
4646
+ this.codeText = '';
4647
+ this.useRecovery.set(false);
4648
+ // Setup finished during this sign-in: the codes are on screen and must be
4649
+ // seen before the app takes over the page.
4650
+ if (this.authenticationService.recoveryCodesToShow()) {
4651
+ this.submitted = false;
4652
+ return;
4653
+ }
4654
+ this.#navigateAfterLogin();
4655
+ }
4334
4656
  #navigateAfterLogin() {
4335
4657
  this.router.navigateByUrl(this.#resolvePostLoginUrl()).then(() => {
4336
4658
  setTimeout(() => this.#terminalFocusService.requestFocus());
4337
4659
  }).catch(console.error);
4338
4660
  }
4339
4661
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: Login, deps: [{ token: AuthenticationService }, { token: i2$1.Router }], target: i0.ɵɵFactoryTarget.Component }); }
4340
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: Login, isStandalone: true, selector: "shell-login", ngImport: i0, template: "@let usernameRegex = USERNAME_REGEX;\n@let passwordRegex = PASSWORD_REGEX;\n<div class=\"login-container\">\n <div class=\"login-header\">\n <h2 class=\"text-text-primary\">{{ appName }}</h2>\n <p class=\"text-secondary-info\">Sign in to continue</p>\n </div>\n @let step = authenticationService.codeStep();\n @if (authenticationService.recoveryCodesToShow(); as codes) {\n <shell-recovery-codes [codes]=\"codes\" [appName]=\"appName\" [username]=\"model.username\" (done)=\"onRecoveryCodesSaved()\" />\n } @else if (step !== 'none') {\n <form (submit)=\"onSubmitCode($event)\" class=\"login-form\" aria-label=\"Authenticator code\">\n @if (step === 'setup' && authenticationService.setupChallenge(); as challenge) {\n <p class=\"text-secondary-info login-code-lead\">\n Set up your authenticator: scan this with Google Authenticator (or Authy, 1Password),\n then enter the 6-digit code it shows.\n </p>\n <img class=\"login-qr\" [src]=\"challenge.qr\" alt=\"QR code for your authenticator app\" width=\"200\" height=\"200\" />\n <p class=\"text-secondary-info login-key\">Can't scan? Enter this key: <code>{{ challenge.key }}</code></p>\n } @else {\n <p class=\"text-secondary-info login-code-lead\">\n @if (useRecovery()) {\n Enter one of your recovery codes.\n } @else {\n Enter the 6-digit code from your authenticator app.\n }\n </p>\n }\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Code' }}</mat-label>\n <input\n matInput\n name=\"code\"\n [(ngModel)]=\"codeText\"\n [disabled]=\"submitted\"\n [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\"\n [attr.maxlength]=\"useRecovery() ? 16 : 7\"\n [placeholder]=\"useRecovery() ? 'xxxxx-xxxxx' : '123456'\"\n autofocus\n />\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n {{ step === 'setup' ? 'Finish setup and sign in' : 'Sign in' }}\n </button>\n <div class=\"login-code-links\">\n @if (step === 'code') {\n <button mat-button type=\"button\" (click)=\"onToggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}\n </button>\n }\n <button mat-button type=\"button\" (click)=\"onCancelCode()\">Back</button>\n </div>\n </form>\n } @else {\n <form #loginForm=\"ngForm\" (ngSubmit)=\"onSubmit($event)\" class=\"login-form\">\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Username</mat-label>\n <input\n #usernameInput\n matInput\n placeholder=\"Enter username\"\n [(ngModel)]=\"model.username\"\n name=\"username\"\n [disabled]=\"submitted\"\n required\n regexValidator\n [regex]=\"usernameRegex\"\n (cdkAutofill)=\"onAutofill($event, usernameInput)\"\n />\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Password</mat-label>\n <input\n #passwordInput\n matInput\n placeholder=\"Enter password\"\n [type]=\"hide() ? 'password' : 'text'\"\n [(ngModel)]=\"model.password\"\n name=\"password\"\n [disabled]=\"submitted\"\n regexValidator\n [regex]=\"passwordRegex\"\n (cdkAutofill)=\"onAutofill($event, passwordInput)\"\n />\n <button\n mat-icon-button\n matSuffix\n type=\"button\"\n (click)=\"clickEvent($event)\"\n [attr.aria-label]=\"'Hide password'\"\n [attr.aria-pressed]=\"hide()\"\n >\n <mat-icon>{{ hide() ? 'visibility_off' : 'visibility' }}</mat-icon>\n </button>\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n Login\n </button>\n </form>\n }\n @if (errors.length > 0) {\n <div class=\"login-errors\">\n @for (error of errors; track error.ndx) {\n <div class=\"login-error text-loss\">{{ error.message }}</div>\n }\n </div>\n }\n @if (authenticationService.conflictSessions(); as sessions) {\n <div class=\"login-conflict\">\n <p class=\"text-secondary-info\">\n You are already signed in {{ sessions.length === 1 ? 'somewhere else' : 'on other devices' }}:\n </p>\n @for (session of sessions; track session.sessionId ?? $index) {\n <div class=\"login-conflict-session\">\n @if (canEndIndividually()) {\n <mat-checkbox\n [checked]=\"isSelected(session)\"\n [disabled]=\"!session.sessionId\"\n (change)=\"toggleSelected(session, $event.checked)\"\n [attr.aria-label]=\"'End ' + deviceLabel(session)\"\n ></mat-checkbox>\n }\n <span class=\"text-secondary-info\">\n <strong>{{ deviceLabel(session) }}</strong>\n <!-- signedInAtEpoch is when the password was typed. startEpoch is\n when the row was written, which renewal resets \u2014 so it is shown\n only when there is nothing better, and labelled for what it is. -->\n @if (session.signedInAtEpoch) {\n \u2014 since {{ session.signedInAtEpoch | date:'short' }}\n } @else {\n \u2014 last active {{ session.startEpoch | date:'short' }}\n }\n </span>\n </div>\n }\n <div class=\"login-conflict-actions\">\n <button mat-stroked-button type=\"button\" (click)=\"onCancelConflict()\">Cancel</button>\n @if (selectedCount() > 0) {\n <button mat-stroked-button type=\"button\" (click)=\"onEndSelected()\">\n End {{ selectedCount() === 1 ? 'that one' : selectedCount() + ' of them' }} and sign in\n </button>\n }\n <button mat-stroked-button type=\"button\" (click)=\"onTakeOver()\">\n {{ sessions.length === 1 ? 'End it and sign in' : 'End them all and sign in' }}\n </button>\n @if (authenticationService.additionalSignInAllowed()) {\n <button mat-flat-button type=\"button\" (click)=\"onSignInAsWell()\">Sign in here as well</button>\n }\n </div>\n </div>\n }\n</div>\n", styles: [".login-container{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center}.login-header{margin-bottom:32px}.login-header h2{font-size:1.5rem;font-weight:300;margin-bottom:8px}.login-form{display:flex;flex-direction:column;align-items:center;gap:8px;width:320px}.login-field{width:100%;text-align:left}.login-takeover{align-self:flex-start;margin-top:4px}.login-button{margin-top:8px;width:100%}.login-errors{margin-top:16px;max-width:320px}.login-conflict{margin-top:16px;max-width:320px;padding:12px 16px;border-radius:4px;background-color:var(--color-surface-light);font-size:.85rem;text-align:left}.login-conflict p{margin:0 0 8px}.login-conflict-session{display:flex;align-items:center;gap:8px;margin:4px 0}.login-conflict-actions{flex-wrap:wrap;display:flex;gap:8px;margin-top:12px;justify-content:flex-end}.login-error{padding:8px 12px;margin-bottom:4px;font-size:.85rem;border-radius:4px;background-color:var(--color-surface-light)}.login-code-lead{margin:0 0 8px;font-size:.9rem}.login-qr{display:block;margin:4px auto 8px;image-rendering:pixelated}.login-key{margin:0 0 8px;font-size:.8rem;word-break:break-all}.login-code-links{display:flex;justify-content:space-between;width:100%}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$2.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$2.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$2.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$2.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "directive", type: i1$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$2.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "directive", type: MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i4.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: i5.CdkAutofill, selector: "[cdkAutofill]", outputs: ["cdkAutofill"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i6.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i6.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i7.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: RegexValidatorDirective, selector: "[regexValidator]", inputs: ["regex"] }, { kind: "ngmodule", type: TextFieldModule }, { kind: "component", type: RecoveryCodes, selector: "shell-recovery-codes", inputs: ["codes", "appName", "username"], outputs: ["done"] }, { kind: "pipe", type: DatePipe, name: "date" }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
4662
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: Login, isStandalone: true, selector: "shell-login", ngImport: i0, template: "@let usernameRegex = USERNAME_REGEX;\n@let passwordRegex = PASSWORD_REGEX;\n<div class=\"login-container\">\n <div class=\"login-header\">\n <h2 class=\"text-text-primary\">{{ appName }}</h2>\n <p class=\"text-secondary-info\">Sign in to continue</p>\n </div>\n @let step = authenticationService.codeStep();\n @if (authenticationService.recoveryCodesToShow(); as codes) {\n <shell-recovery-codes [codes]=\"codes\" [appName]=\"appName\" [username]=\"model.username\" (done)=\"onRecoveryCodesSaved()\" />\n } @else if (step !== 'none') {\n <form (submit)=\"onSubmitCode($event)\" class=\"login-form\" aria-label=\"Authenticator code\">\n @if (step === 'setup' && authenticationService.setupChallenge(); as challenge) {\n <p class=\"text-secondary-info login-code-lead\">\n Set up your authenticator: scan this with Google Authenticator (or Authy, 1Password),\n then enter the 6-digit code it shows.\n </p>\n <img class=\"login-qr\" [src]=\"challenge.qr\" alt=\"QR code for your authenticator app\" width=\"200\" height=\"200\" />\n <p class=\"text-secondary-info login-key\">Can't scan? Enter this key: <code>{{ challenge.key }}</code></p>\n } @else {\n <p class=\"text-secondary-info login-code-lead\">\n @if (useRecovery()) {\n Enter one of your recovery codes.\n } @else {\n Enter the 6-digit code from your authenticator app.\n }\n </p>\n }\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Code' }}</mat-label>\n <input\n matInput\n name=\"code\"\n [(ngModel)]=\"codeText\"\n [disabled]=\"submitted\"\n [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\"\n [attr.maxlength]=\"useRecovery() ? 16 : 7\"\n [placeholder]=\"useRecovery() ? 'xxxxx-xxxxx' : '123456'\"\n autofocus\n />\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n {{ step === 'setup' ? 'Finish setup and sign in' : 'Sign in' }}\n </button>\n <div class=\"login-code-links\">\n @if (step === 'code') {\n <button mat-button type=\"button\" (click)=\"onToggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}\n </button>\n }\n <button mat-button type=\"button\" (click)=\"onCancelCode()\">Back</button>\n </div>\n </form>\n } @else {\n <form #loginForm=\"ngForm\" (ngSubmit)=\"onSubmit($event)\" class=\"login-form\">\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Username</mat-label>\n <input\n #usernameInput\n matInput\n placeholder=\"Enter username\"\n [(ngModel)]=\"model.username\"\n name=\"username\"\n autocomplete=\"username webauthn\"\n [disabled]=\"submitted\"\n required\n regexValidator\n [regex]=\"usernameRegex\"\n (cdkAutofill)=\"onAutofill($event, usernameInput)\"\n />\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Password</mat-label>\n <input\n #passwordInput\n matInput\n placeholder=\"Enter password\"\n [type]=\"hide() ? 'password' : 'text'\"\n [(ngModel)]=\"model.password\"\n name=\"password\"\n [disabled]=\"submitted\"\n regexValidator\n [regex]=\"passwordRegex\"\n (cdkAutofill)=\"onAutofill($event, passwordInput)\"\n />\n <button\n mat-icon-button\n matSuffix\n type=\"button\"\n (click)=\"clickEvent($event)\"\n [attr.aria-label]=\"'Hide password'\"\n [attr.aria-pressed]=\"hide()\"\n >\n <mat-icon>{{ hide() ? 'visibility_off' : 'visibility' }}</mat-icon>\n </button>\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n Login\n </button>\n @if (passkeys) {\n <button mat-stroked-button type=\"button\" [disabled]=\"submitted\" class=\"login-button\" (click)=\"onPasskey()\">\n <mat-icon>fingerprint</mat-icon>\n Sign in with a passkey\n </button>\n }\n </form>\n }\n @if (errors.length > 0) {\n <div class=\"login-errors\">\n @for (error of errors; track error.ndx) {\n <div class=\"login-error text-loss\">{{ error.message }}</div>\n }\n </div>\n }\n @if (authenticationService.conflictSessions(); as sessions) {\n <div class=\"login-conflict\">\n <p class=\"text-secondary-info\">\n You are already signed in {{ sessions.length === 1 ? 'somewhere else' : 'on other devices' }}:\n </p>\n @for (session of sessions; track session.sessionId ?? $index) {\n <div class=\"login-conflict-session\">\n @if (canEndIndividually()) {\n <mat-checkbox\n [checked]=\"isSelected(session)\"\n [disabled]=\"!session.sessionId\"\n (change)=\"toggleSelected(session, $event.checked)\"\n [attr.aria-label]=\"'End ' + deviceLabel(session)\"\n ></mat-checkbox>\n }\n <span class=\"text-secondary-info\">\n <strong>{{ deviceLabel(session) }}</strong>\n <!-- signedInAtEpoch is when the password was typed. startEpoch is\n when the row was written, which renewal resets \u2014 so it is shown\n only when there is nothing better, and labelled for what it is. -->\n @if (session.signedInAtEpoch) {\n \u2014 since {{ session.signedInAtEpoch | date:'short' }}\n } @else {\n \u2014 last active {{ session.startEpoch | date:'short' }}\n }\n </span>\n </div>\n }\n <div class=\"login-conflict-actions\">\n <button mat-stroked-button type=\"button\" (click)=\"onCancelConflict()\">Cancel</button>\n @if (selectedCount() > 0) {\n <button mat-stroked-button type=\"button\" (click)=\"onEndSelected()\">\n End {{ selectedCount() === 1 ? 'that one' : selectedCount() + ' of them' }} and sign in\n </button>\n }\n <button mat-stroked-button type=\"button\" (click)=\"onTakeOver()\">\n {{ sessions.length === 1 ? 'End it and sign in' : 'End them all and sign in' }}\n </button>\n @if (authenticationService.additionalSignInAllowed()) {\n <button mat-flat-button type=\"button\" (click)=\"onSignInAsWell()\">Sign in here as well</button>\n }\n </div>\n </div>\n }\n</div>\n", styles: [".login-container{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center}.login-header{margin-bottom:32px}.login-header h2{font-size:1.5rem;font-weight:300;margin-bottom:8px}.login-form{display:flex;flex-direction:column;align-items:center;gap:8px;width:320px}.login-field{width:100%;text-align:left}.login-takeover{align-self:flex-start;margin-top:4px}.login-button{margin-top:8px;width:100%}.login-errors{margin-top:16px;max-width:320px}.login-conflict{margin-top:16px;max-width:320px;padding:12px 16px;border-radius:4px;background-color:var(--color-surface-light);font-size:.85rem;text-align:left}.login-conflict p{margin:0 0 8px}.login-conflict-session{display:flex;align-items:center;gap:8px;margin:4px 0}.login-conflict-actions{flex-wrap:wrap;display:flex;gap:8px;margin-top:12px;justify-content:flex-end}.login-error{padding:8px 12px;margin-bottom:4px;font-size:.85rem;border-radius:4px;background-color:var(--color-surface-light)}.login-code-lead{margin:0 0 8px;font-size:.9rem}.login-qr{display:block;margin:4px auto 8px;image-rendering:pixelated}.login-key{margin:0 0 8px;font-size:.8rem;word-break:break-all}.login-code-links{display:flex;justify-content:space-between;width:100%}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$2.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$2.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$2.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$2.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "directive", type: i1$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$2.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "directive", type: MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i4.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: i5.CdkAutofill, selector: "[cdkAutofill]", outputs: ["cdkAutofill"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i6.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i6.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i7.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: RegexValidatorDirective, selector: "[regexValidator]", inputs: ["regex"] }, { kind: "ngmodule", type: TextFieldModule }, { kind: "component", type: RecoveryCodes, selector: "shell-recovery-codes", inputs: ["codes", "appName", "username"], outputs: ["done"] }, { kind: "pipe", type: DatePipe, name: "date" }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
4341
4663
  }
4342
4664
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: Login, decorators: [{
4343
4665
  type: Component,
@@ -4354,7 +4676,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
4354
4676
  TextFieldModule,
4355
4677
  DatePipe,
4356
4678
  RecoveryCodes,
4357
- ], template: "@let usernameRegex = USERNAME_REGEX;\n@let passwordRegex = PASSWORD_REGEX;\n<div class=\"login-container\">\n <div class=\"login-header\">\n <h2 class=\"text-text-primary\">{{ appName }}</h2>\n <p class=\"text-secondary-info\">Sign in to continue</p>\n </div>\n @let step = authenticationService.codeStep();\n @if (authenticationService.recoveryCodesToShow(); as codes) {\n <shell-recovery-codes [codes]=\"codes\" [appName]=\"appName\" [username]=\"model.username\" (done)=\"onRecoveryCodesSaved()\" />\n } @else if (step !== 'none') {\n <form (submit)=\"onSubmitCode($event)\" class=\"login-form\" aria-label=\"Authenticator code\">\n @if (step === 'setup' && authenticationService.setupChallenge(); as challenge) {\n <p class=\"text-secondary-info login-code-lead\">\n Set up your authenticator: scan this with Google Authenticator (or Authy, 1Password),\n then enter the 6-digit code it shows.\n </p>\n <img class=\"login-qr\" [src]=\"challenge.qr\" alt=\"QR code for your authenticator app\" width=\"200\" height=\"200\" />\n <p class=\"text-secondary-info login-key\">Can't scan? Enter this key: <code>{{ challenge.key }}</code></p>\n } @else {\n <p class=\"text-secondary-info login-code-lead\">\n @if (useRecovery()) {\n Enter one of your recovery codes.\n } @else {\n Enter the 6-digit code from your authenticator app.\n }\n </p>\n }\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Code' }}</mat-label>\n <input\n matInput\n name=\"code\"\n [(ngModel)]=\"codeText\"\n [disabled]=\"submitted\"\n [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\"\n [attr.maxlength]=\"useRecovery() ? 16 : 7\"\n [placeholder]=\"useRecovery() ? 'xxxxx-xxxxx' : '123456'\"\n autofocus\n />\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n {{ step === 'setup' ? 'Finish setup and sign in' : 'Sign in' }}\n </button>\n <div class=\"login-code-links\">\n @if (step === 'code') {\n <button mat-button type=\"button\" (click)=\"onToggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}\n </button>\n }\n <button mat-button type=\"button\" (click)=\"onCancelCode()\">Back</button>\n </div>\n </form>\n } @else {\n <form #loginForm=\"ngForm\" (ngSubmit)=\"onSubmit($event)\" class=\"login-form\">\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Username</mat-label>\n <input\n #usernameInput\n matInput\n placeholder=\"Enter username\"\n [(ngModel)]=\"model.username\"\n name=\"username\"\n [disabled]=\"submitted\"\n required\n regexValidator\n [regex]=\"usernameRegex\"\n (cdkAutofill)=\"onAutofill($event, usernameInput)\"\n />\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Password</mat-label>\n <input\n #passwordInput\n matInput\n placeholder=\"Enter password\"\n [type]=\"hide() ? 'password' : 'text'\"\n [(ngModel)]=\"model.password\"\n name=\"password\"\n [disabled]=\"submitted\"\n regexValidator\n [regex]=\"passwordRegex\"\n (cdkAutofill)=\"onAutofill($event, passwordInput)\"\n />\n <button\n mat-icon-button\n matSuffix\n type=\"button\"\n (click)=\"clickEvent($event)\"\n [attr.aria-label]=\"'Hide password'\"\n [attr.aria-pressed]=\"hide()\"\n >\n <mat-icon>{{ hide() ? 'visibility_off' : 'visibility' }}</mat-icon>\n </button>\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n Login\n </button>\n </form>\n }\n @if (errors.length > 0) {\n <div class=\"login-errors\">\n @for (error of errors; track error.ndx) {\n <div class=\"login-error text-loss\">{{ error.message }}</div>\n }\n </div>\n }\n @if (authenticationService.conflictSessions(); as sessions) {\n <div class=\"login-conflict\">\n <p class=\"text-secondary-info\">\n You are already signed in {{ sessions.length === 1 ? 'somewhere else' : 'on other devices' }}:\n </p>\n @for (session of sessions; track session.sessionId ?? $index) {\n <div class=\"login-conflict-session\">\n @if (canEndIndividually()) {\n <mat-checkbox\n [checked]=\"isSelected(session)\"\n [disabled]=\"!session.sessionId\"\n (change)=\"toggleSelected(session, $event.checked)\"\n [attr.aria-label]=\"'End ' + deviceLabel(session)\"\n ></mat-checkbox>\n }\n <span class=\"text-secondary-info\">\n <strong>{{ deviceLabel(session) }}</strong>\n <!-- signedInAtEpoch is when the password was typed. startEpoch is\n when the row was written, which renewal resets \u2014 so it is shown\n only when there is nothing better, and labelled for what it is. -->\n @if (session.signedInAtEpoch) {\n \u2014 since {{ session.signedInAtEpoch | date:'short' }}\n } @else {\n \u2014 last active {{ session.startEpoch | date:'short' }}\n }\n </span>\n </div>\n }\n <div class=\"login-conflict-actions\">\n <button mat-stroked-button type=\"button\" (click)=\"onCancelConflict()\">Cancel</button>\n @if (selectedCount() > 0) {\n <button mat-stroked-button type=\"button\" (click)=\"onEndSelected()\">\n End {{ selectedCount() === 1 ? 'that one' : selectedCount() + ' of them' }} and sign in\n </button>\n }\n <button mat-stroked-button type=\"button\" (click)=\"onTakeOver()\">\n {{ sessions.length === 1 ? 'End it and sign in' : 'End them all and sign in' }}\n </button>\n @if (authenticationService.additionalSignInAllowed()) {\n <button mat-flat-button type=\"button\" (click)=\"onSignInAsWell()\">Sign in here as well</button>\n }\n </div>\n </div>\n }\n</div>\n", styles: [".login-container{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center}.login-header{margin-bottom:32px}.login-header h2{font-size:1.5rem;font-weight:300;margin-bottom:8px}.login-form{display:flex;flex-direction:column;align-items:center;gap:8px;width:320px}.login-field{width:100%;text-align:left}.login-takeover{align-self:flex-start;margin-top:4px}.login-button{margin-top:8px;width:100%}.login-errors{margin-top:16px;max-width:320px}.login-conflict{margin-top:16px;max-width:320px;padding:12px 16px;border-radius:4px;background-color:var(--color-surface-light);font-size:.85rem;text-align:left}.login-conflict p{margin:0 0 8px}.login-conflict-session{display:flex;align-items:center;gap:8px;margin:4px 0}.login-conflict-actions{flex-wrap:wrap;display:flex;gap:8px;margin-top:12px;justify-content:flex-end}.login-error{padding:8px 12px;margin-bottom:4px;font-size:.85rem;border-radius:4px;background-color:var(--color-surface-light)}.login-code-lead{margin:0 0 8px;font-size:.9rem}.login-qr{display:block;margin:4px auto 8px;image-rendering:pixelated}.login-key{margin:0 0 8px;font-size:.8rem;word-break:break-all}.login-code-links{display:flex;justify-content:space-between;width:100%}\n"] }]
4679
+ ], template: "@let usernameRegex = USERNAME_REGEX;\n@let passwordRegex = PASSWORD_REGEX;\n<div class=\"login-container\">\n <div class=\"login-header\">\n <h2 class=\"text-text-primary\">{{ appName }}</h2>\n <p class=\"text-secondary-info\">Sign in to continue</p>\n </div>\n @let step = authenticationService.codeStep();\n @if (authenticationService.recoveryCodesToShow(); as codes) {\n <shell-recovery-codes [codes]=\"codes\" [appName]=\"appName\" [username]=\"model.username\" (done)=\"onRecoveryCodesSaved()\" />\n } @else if (step !== 'none') {\n <form (submit)=\"onSubmitCode($event)\" class=\"login-form\" aria-label=\"Authenticator code\">\n @if (step === 'setup' && authenticationService.setupChallenge(); as challenge) {\n <p class=\"text-secondary-info login-code-lead\">\n Set up your authenticator: scan this with Google Authenticator (or Authy, 1Password),\n then enter the 6-digit code it shows.\n </p>\n <img class=\"login-qr\" [src]=\"challenge.qr\" alt=\"QR code for your authenticator app\" width=\"200\" height=\"200\" />\n <p class=\"text-secondary-info login-key\">Can't scan? Enter this key: <code>{{ challenge.key }}</code></p>\n } @else {\n <p class=\"text-secondary-info login-code-lead\">\n @if (useRecovery()) {\n Enter one of your recovery codes.\n } @else {\n Enter the 6-digit code from your authenticator app.\n }\n </p>\n }\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Code' }}</mat-label>\n <input\n matInput\n name=\"code\"\n [(ngModel)]=\"codeText\"\n [disabled]=\"submitted\"\n [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\"\n [attr.maxlength]=\"useRecovery() ? 16 : 7\"\n [placeholder]=\"useRecovery() ? 'xxxxx-xxxxx' : '123456'\"\n autofocus\n />\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n {{ step === 'setup' ? 'Finish setup and sign in' : 'Sign in' }}\n </button>\n <div class=\"login-code-links\">\n @if (step === 'code') {\n <button mat-button type=\"button\" (click)=\"onToggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}\n </button>\n }\n <button mat-button type=\"button\" (click)=\"onCancelCode()\">Back</button>\n </div>\n </form>\n } @else {\n <form #loginForm=\"ngForm\" (ngSubmit)=\"onSubmit($event)\" class=\"login-form\">\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Username</mat-label>\n <input\n #usernameInput\n matInput\n placeholder=\"Enter username\"\n [(ngModel)]=\"model.username\"\n name=\"username\"\n autocomplete=\"username webauthn\"\n [disabled]=\"submitted\"\n required\n regexValidator\n [regex]=\"usernameRegex\"\n (cdkAutofill)=\"onAutofill($event, usernameInput)\"\n />\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Password</mat-label>\n <input\n #passwordInput\n matInput\n placeholder=\"Enter password\"\n [type]=\"hide() ? 'password' : 'text'\"\n [(ngModel)]=\"model.password\"\n name=\"password\"\n [disabled]=\"submitted\"\n regexValidator\n [regex]=\"passwordRegex\"\n (cdkAutofill)=\"onAutofill($event, passwordInput)\"\n />\n <button\n mat-icon-button\n matSuffix\n type=\"button\"\n (click)=\"clickEvent($event)\"\n [attr.aria-label]=\"'Hide password'\"\n [attr.aria-pressed]=\"hide()\"\n >\n <mat-icon>{{ hide() ? 'visibility_off' : 'visibility' }}</mat-icon>\n </button>\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n Login\n </button>\n @if (passkeys) {\n <button mat-stroked-button type=\"button\" [disabled]=\"submitted\" class=\"login-button\" (click)=\"onPasskey()\">\n <mat-icon>fingerprint</mat-icon>\n Sign in with a passkey\n </button>\n }\n </form>\n }\n @if (errors.length > 0) {\n <div class=\"login-errors\">\n @for (error of errors; track error.ndx) {\n <div class=\"login-error text-loss\">{{ error.message }}</div>\n }\n </div>\n }\n @if (authenticationService.conflictSessions(); as sessions) {\n <div class=\"login-conflict\">\n <p class=\"text-secondary-info\">\n You are already signed in {{ sessions.length === 1 ? 'somewhere else' : 'on other devices' }}:\n </p>\n @for (session of sessions; track session.sessionId ?? $index) {\n <div class=\"login-conflict-session\">\n @if (canEndIndividually()) {\n <mat-checkbox\n [checked]=\"isSelected(session)\"\n [disabled]=\"!session.sessionId\"\n (change)=\"toggleSelected(session, $event.checked)\"\n [attr.aria-label]=\"'End ' + deviceLabel(session)\"\n ></mat-checkbox>\n }\n <span class=\"text-secondary-info\">\n <strong>{{ deviceLabel(session) }}</strong>\n <!-- signedInAtEpoch is when the password was typed. startEpoch is\n when the row was written, which renewal resets \u2014 so it is shown\n only when there is nothing better, and labelled for what it is. -->\n @if (session.signedInAtEpoch) {\n \u2014 since {{ session.signedInAtEpoch | date:'short' }}\n } @else {\n \u2014 last active {{ session.startEpoch | date:'short' }}\n }\n </span>\n </div>\n }\n <div class=\"login-conflict-actions\">\n <button mat-stroked-button type=\"button\" (click)=\"onCancelConflict()\">Cancel</button>\n @if (selectedCount() > 0) {\n <button mat-stroked-button type=\"button\" (click)=\"onEndSelected()\">\n End {{ selectedCount() === 1 ? 'that one' : selectedCount() + ' of them' }} and sign in\n </button>\n }\n <button mat-stroked-button type=\"button\" (click)=\"onTakeOver()\">\n {{ sessions.length === 1 ? 'End it and sign in' : 'End them all and sign in' }}\n </button>\n @if (authenticationService.additionalSignInAllowed()) {\n <button mat-flat-button type=\"button\" (click)=\"onSignInAsWell()\">Sign in here as well</button>\n }\n </div>\n </div>\n }\n</div>\n", styles: [".login-container{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center}.login-header{margin-bottom:32px}.login-header h2{font-size:1.5rem;font-weight:300;margin-bottom:8px}.login-form{display:flex;flex-direction:column;align-items:center;gap:8px;width:320px}.login-field{width:100%;text-align:left}.login-takeover{align-self:flex-start;margin-top:4px}.login-button{margin-top:8px;width:100%}.login-errors{margin-top:16px;max-width:320px}.login-conflict{margin-top:16px;max-width:320px;padding:12px 16px;border-radius:4px;background-color:var(--color-surface-light);font-size:.85rem;text-align:left}.login-conflict p{margin:0 0 8px}.login-conflict-session{display:flex;align-items:center;gap:8px;margin:4px 0}.login-conflict-actions{flex-wrap:wrap;display:flex;gap:8px;margin-top:12px;justify-content:flex-end}.login-error{padding:8px 12px;margin-bottom:4px;font-size:.85rem;border-radius:4px;background-color:var(--color-surface-light)}.login-code-lead{margin:0 0 8px;font-size:.9rem}.login-qr{display:block;margin:4px auto 8px;image-rendering:pixelated}.login-key{margin:0 0 8px;font-size:.8rem;word-break:break-all}.login-code-links{display:flex;justify-content:space-between;width:100%}\n"] }]
4358
4680
  }], ctorParameters: () => [{ type: AuthenticationService }, { type: i2$1.Router }] });
4359
4681
 
4360
4682
  /*
@@ -4370,6 +4692,11 @@ Settings → Sign-in code (authenticator-sign-in.prd.md D5, D7). Shared by every
4370
4692
 
4371
4693
  ⛔ There is deliberately no "remove" and no "turn off" here (D13). Those are AWS-only; the screen
4372
4694
  says how to get them done.
4695
+
4696
+ Passkeys (passkey-sign-in.prd.md D13–D17), shown when the browser can do them and an authenticator
4697
+ is set up (D2 — a passkey is an option beside it, never instead):
4698
+ - Add one: a name, then password + code (or recovery code), or a passkey already added (D14).
4699
+ - Remove one: a code, a recovery code, or a passkey (D15). Removing only takes a way in away.
4373
4700
  */
4374
4701
  class AuthenticatorSettings {
4375
4702
  constructor() {
@@ -4389,8 +4716,16 @@ class AuthenticatorSettings {
4389
4716
  ...(ngDevMode ? [{ debugName: "codes" }] : /* istanbul ignore next */ []));
4390
4717
  this.useRecovery = signal(false, /* @ts-ignore */
4391
4718
  ...(ngDevMode ? [{ debugName: "useRecovery" }] : /* istanbul ignore next */ []));
4719
+ /** Whether this browser can make and use passkeys at all. */
4720
+ this.passkeys = passkeysSupported();
4721
+ this.passkeyList = signal(undefined, /* @ts-ignore */
4722
+ ...(ngDevMode ? [{ debugName: "passkeyList" }] : /* istanbul ignore next */ []));
4723
+ /** The passkey being removed, while its confirmation is on screen. */
4724
+ this.removing = signal(undefined, /* @ts-ignore */
4725
+ ...(ngDevMode ? [{ debugName: "removing" }] : /* istanbul ignore next */ []));
4392
4726
  this.password = '';
4393
4727
  this.code = '';
4728
+ this.passkeyName = '';
4394
4729
  }
4395
4730
  #auth;
4396
4731
  ngOnInit() {
@@ -4398,12 +4733,55 @@ class AuthenticatorSettings {
4398
4733
  }
4399
4734
  async #load() {
4400
4735
  try {
4401
- this.status.set(await this.#auth.authenticatorStatus());
4736
+ const status = await this.#auth.authenticatorStatus();
4737
+ this.status.set(status);
4738
+ if (this.passkeys && status.enrolled)
4739
+ this.passkeyList.set(await this.#auth.listPasskeys());
4402
4740
  }
4403
4741
  catch (err) {
4404
4742
  this.error.set(err instanceof AuthenticatorError ? err.message : 'Could not load your sign-in code settings.');
4405
4743
  }
4406
4744
  }
4745
+ beginAddPasskey() {
4746
+ this.#reset();
4747
+ this.mode.set('passkey-add');
4748
+ }
4749
+ beginRemovePasskey(passkey) {
4750
+ this.#reset();
4751
+ this.removing.set(passkey);
4752
+ this.mode.set('passkey-remove');
4753
+ }
4754
+ /** Add, proved with password + code (or recovery code) — or with a passkey already added. */
4755
+ async addPasskey(event, withPasskey = false) {
4756
+ event?.preventDefault();
4757
+ const name = this.passkeyName.trim();
4758
+ if (!name)
4759
+ return;
4760
+ await this.#run(async () => {
4761
+ await this.#auth.addPasskey(name, withPasskey ? 'passkey' : { password: this.password, ...this.#codeEntry() });
4762
+ await this.#passkeysChanged();
4763
+ });
4764
+ }
4765
+ /** Remove, proved with a code or recovery code — or with a passkey. */
4766
+ async removePasskey(event, withPasskey = false) {
4767
+ event?.preventDefault();
4768
+ const target = this.removing();
4769
+ if (!target)
4770
+ return;
4771
+ await this.#run(async () => {
4772
+ const proof = withPasskey ? 'passkey' : this.#codeEntry();
4773
+ await this.#auth.removePasskey(target.credentialId, proof);
4774
+ await this.#passkeysChanged();
4775
+ });
4776
+ }
4777
+ #codeEntry() {
4778
+ return this.useRecovery() ? { recoveryCode: this.code.trim() } : { code: this.code.trim() };
4779
+ }
4780
+ async #passkeysChanged() {
4781
+ this.#reset();
4782
+ this.mode.set('view');
4783
+ this.passkeyList.set(await this.#auth.listPasskeys());
4784
+ }
4407
4785
  begin(mode) {
4408
4786
  this.#reset();
4409
4787
  this.mode.set(mode);
@@ -4470,17 +4848,19 @@ class AuthenticatorSettings {
4470
4848
  #reset() {
4471
4849
  this.password = '';
4472
4850
  this.code = '';
4851
+ this.passkeyName = '';
4852
+ this.removing.set(undefined);
4473
4853
  this.error.set(undefined);
4474
4854
  this.challenge.set(undefined);
4475
4855
  this.codes.set(undefined);
4476
4856
  this.useRecovery.set(false);
4477
4857
  }
4478
4858
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: AuthenticatorSettings, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
4479
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: AuthenticatorSettings, isStandalone: true, selector: "shell-authenticator-settings", ngImport: i0, template: "<section class=\"authenticator-settings\">\n <h2 class=\"text-text-primary\">Sign-in code</h2>\n <p class=\"text-secondary-info\">\n Signing in asks for your password and a 6-digit code from an authenticator app on your phone\n (Google Authenticator, Authy, 1Password).\n </p>\n\n @if (status(); as s) {\n @switch (mode()) {\n @case ('view') {\n <dl class=\"authenticator-facts\">\n <dt>Authenticator</dt>\n <dd>{{ s.enrolled ? 'Set up' : 'Not set up' }}</dd>\n @if (s.enrolled) {\n <dt>Recovery codes left</dt>\n <dd [class.text-loss]=\"s.recoveryCodesLeft <= 2\">{{ s.recoveryCodesLeft }} of 10</dd>\n }\n <dt>Code required at sign-in</dt>\n <dd>{{ s.enrolled ? 'Yes' : (s.codeRequired ? 'Yes' : 'Not yet \u2014 set one up') }}</dd>\n </dl>\n <div class=\"authenticator-actions\">\n <button mat-flat-button type=\"button\" (click)=\"begin('password')\">\n {{ s.enrolled ? 'Replace authenticator' : 'Set up authenticator' }}\n </button>\n @if (s.enrolled) {\n <button mat-stroked-button type=\"button\" (click)=\"begin('reissue')\">New recovery codes</button>\n }\n </div>\n <p class=\"text-secondary-info authenticator-note\">\n Lost your phone? Sign in with a recovery code, then replace the authenticator here.\n No recovery codes left? Only the administrator can reset it, from AWS \u2014 there is no way\n to switch the code off from inside the app.\n </p>\n }\n\n @case ('password') {\n <form class=\"authenticator-form\" (submit)=\"start($event)\">\n <mat-form-field appearance=\"outline\">\n <mat-label>Password</mat-label>\n <input matInput type=\"password\" name=\"password\" [(ngModel)]=\"password\" autocomplete=\"current-password\" [disabled]=\"busy()\" />\n </mat-form-field>\n @if (s.enrolled) {\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Current 6-digit code' }}</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\" [disabled]=\"busy()\" />\n </mat-form-field>\n <button mat-button type=\"button\" class=\"authenticator-link\" (click)=\"toggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Lost the phone? Use a recovery code' }}\n </button>\n }\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !password || (s.enrolled && !code.trim())\">Continue</button>\n </div>\n </form>\n }\n\n @case ('scan') {\n @if (challenge(); as c) {\n <form class=\"authenticator-form\" (submit)=\"confirm($event)\">\n <p class=\"text-secondary-info\">Scan this with your authenticator app, then enter the 6-digit code it shows.</p>\n <img class=\"authenticator-qr\" [src]=\"c.qr\" alt=\"QR code for your authenticator app\" width=\"200\" height=\"200\" />\n <p class=\"text-secondary-info authenticator-key\">Can't scan? Enter this key: <code>{{ c.key }}</code></p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Code</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" [disabled]=\"busy()\" />\n </mat-form-field>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Finish setup</button>\n </div>\n @if (s.enrolled) {\n <p class=\"text-secondary-info authenticator-note\">Your old authenticator keeps working until you finish.</p>\n }\n </form>\n }\n }\n\n @case ('reissue') {\n <form class=\"authenticator-form\" (submit)=\"reissue($event)\">\n <p class=\"text-secondary-info\">Ten new recovery codes. The ones you have now will stop working.</p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Current 6-digit code</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" [disabled]=\"busy()\" />\n </mat-form-field>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Make new codes</button>\n </div>\n </form>\n }\n\n @case ('codes') {\n @if (codes(); as list) {\n <shell-recovery-codes [codes]=\"list\" [appName]=\"appName\" (done)=\"codesSaved()\" />\n }\n }\n }\n }\n\n @if (error(); as message) {\n <p class=\"text-loss authenticator-error\" role=\"alert\">{{ message }}</p>\n }\n</section>\n", styles: [".authenticator-settings{max-width:480px;padding:16px}.authenticator-settings h2{font-size:1.25rem;font-weight:400;margin:0 0 8px}.authenticator-facts{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;margin:16px 0}.authenticator-facts dt{color:var(--color-secondary-info)}.authenticator-facts dd{margin:0}.authenticator-form{display:flex;flex-direction:column;gap:4px;max-width:320px;margin-top:12px}.authenticator-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}.authenticator-link{align-self:flex-start}.authenticator-note{margin-top:16px;font-size:.85rem}.authenticator-qr{display:block;margin:4px 0 8px;image-rendering:pixelated}.authenticator-key{font-size:.8rem;word-break:break-all}.authenticator-error{margin-top:12px}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$2.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$2.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$2.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$2.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i6.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i4.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: RecoveryCodes, selector: "shell-recovery-codes", inputs: ["codes", "appName", "username"], outputs: ["done"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
4859
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: AuthenticatorSettings, isStandalone: true, selector: "shell-authenticator-settings", ngImport: i0, template: "<section class=\"authenticator-settings\">\n <h2 class=\"text-text-primary\">Sign-in code</h2>\n <p class=\"text-secondary-info\">\n Signing in asks for your password and a 6-digit code from an authenticator app on your phone\n (Google Authenticator, Authy, 1Password).\n </p>\n\n @if (status(); as s) {\n @switch (mode()) {\n @case ('view') {\n <dl class=\"authenticator-facts\">\n <dt>Authenticator</dt>\n <dd>{{ s.enrolled ? 'Set up' : 'Not set up' }}</dd>\n @if (s.enrolled) {\n <dt>Recovery codes left</dt>\n <dd [class.text-loss]=\"s.recoveryCodesLeft <= 2\">{{ s.recoveryCodesLeft }} of 10</dd>\n }\n <dt>Code required at sign-in</dt>\n <dd>{{ s.enrolled ? 'Yes' : (s.codeRequired ? 'Yes' : 'Not yet \u2014 set one up') }}</dd>\n </dl>\n <div class=\"authenticator-actions\">\n <button mat-flat-button type=\"button\" (click)=\"begin('password')\">\n {{ s.enrolled ? 'Replace authenticator' : 'Set up authenticator' }}\n </button>\n @if (s.enrolled) {\n <button mat-stroked-button type=\"button\" (click)=\"begin('reissue')\">New recovery codes</button>\n }\n </div>\n <p class=\"text-secondary-info authenticator-note\">\n Lost your phone? Sign in with a recovery code, then replace the authenticator here.\n No recovery codes left? Only the administrator can reset it, from AWS \u2014 there is no way\n to switch the code off from inside the app.\n </p>\n @if (passkeys && s.enrolled) {\n <h3 class=\"text-text-primary passkey-heading\">Passkeys</h3>\n <p class=\"text-secondary-info\">\n Sign in with your fingerprint, face or device PIN instead of the password and code \u2014 on\n broken-stock and broken-stock-admin. The password and code keep working.\n </p>\n @if (passkeyList(); as list) {\n @if (list.length === 0) {\n <p class=\"text-secondary-info\">No passkeys yet.</p>\n } @else {\n <ul class=\"passkey-list\">\n @for (p of list; track p.credentialId) {\n <li class=\"passkey-row\">\n <span>\n <strong>{{ p.name }}</strong>\n <span class=\"text-secondary-info\">\n \u2014 added {{ p.addedAtEpoch | date:'mediumDate' }},\n {{ p.lastUsedAtEpoch ? 'last used ' + (p.lastUsedAtEpoch | date:'short') : 'not used yet' }},\n <span title=\"As the browser reported when the passkey was used\">\n {{ p.backedUp ? 'synced between your devices' : 'kept on one device or key' }}\n </span>\n </span>\n </span>\n <button mat-button type=\"button\" (click)=\"beginRemovePasskey(p)\">Remove</button>\n </li>\n }\n </ul>\n }\n }\n <div class=\"authenticator-actions\">\n <button mat-stroked-button type=\"button\" (click)=\"beginAddPasskey()\">Add a passkey</button>\n </div>\n }\n }\n\n @case ('passkey-add') {\n <form class=\"authenticator-form\" (submit)=\"addPasskey($event)\">\n <p class=\"text-secondary-info\">\n Name it after the device or key it lives on, then confirm it is you.\n </p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Name</mat-label>\n <input matInput name=\"passkeyName\" [(ngModel)]=\"passkeyName\" maxlength=\"64\" placeholder=\"MacBook, iPhone, YubiKey\" [disabled]=\"busy()\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>Password</mat-label>\n <input matInput type=\"password\" name=\"password\" [(ngModel)]=\"password\" autocomplete=\"current-password\" [disabled]=\"busy()\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Current 6-digit code' }}</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\" [disabled]=\"busy()\" />\n </mat-form-field>\n <button mat-button type=\"button\" class=\"authenticator-link\" (click)=\"toggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}\n </button>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n @if ((passkeyList() ?? []).length > 0) {\n <button mat-stroked-button type=\"button\" [disabled]=\"busy() || !passkeyName.trim()\" (click)=\"addPasskey(undefined, true)\">\n Confirm with a passkey instead\n </button>\n }\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !passkeyName.trim() || !password || !code.trim()\">Add passkey</button>\n </div>\n </form>\n }\n\n @case ('passkey-remove') {\n @if (removing(); as target) {\n <form class=\"authenticator-form\" (submit)=\"removePasskey($event)\">\n <p class=\"text-secondary-info\">\n Remove <strong>{{ target.name }}</strong>? It will no longer sign you in. Your password and\n code still work.\n </p>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Current 6-digit code' }}</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\" [disabled]=\"busy()\" />\n </mat-form-field>\n <button mat-button type=\"button\" class=\"authenticator-link\" (click)=\"toggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}\n </button>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-stroked-button type=\"button\" [disabled]=\"busy()\" (click)=\"removePasskey(undefined, true)\">Confirm with a passkey</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Remove</button>\n </div>\n </form>\n }\n }\n\n @case ('password') {\n <form class=\"authenticator-form\" (submit)=\"start($event)\">\n <mat-form-field appearance=\"outline\">\n <mat-label>Password</mat-label>\n <input matInput type=\"password\" name=\"password\" [(ngModel)]=\"password\" autocomplete=\"current-password\" [disabled]=\"busy()\" />\n </mat-form-field>\n @if (s.enrolled) {\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Current 6-digit code' }}</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\" [disabled]=\"busy()\" />\n </mat-form-field>\n <button mat-button type=\"button\" class=\"authenticator-link\" (click)=\"toggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Lost the phone? Use a recovery code' }}\n </button>\n }\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !password || (s.enrolled && !code.trim())\">Continue</button>\n </div>\n </form>\n }\n\n @case ('scan') {\n @if (challenge(); as c) {\n <form class=\"authenticator-form\" (submit)=\"confirm($event)\">\n <p class=\"text-secondary-info\">Scan this with your authenticator app, then enter the 6-digit code it shows.</p>\n <img class=\"authenticator-qr\" [src]=\"c.qr\" alt=\"QR code for your authenticator app\" width=\"200\" height=\"200\" />\n <p class=\"text-secondary-info authenticator-key\">Can't scan? Enter this key: <code>{{ c.key }}</code></p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Code</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" [disabled]=\"busy()\" />\n </mat-form-field>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Finish setup</button>\n </div>\n @if (s.enrolled) {\n <p class=\"text-secondary-info authenticator-note\">Your old authenticator keeps working until you finish.</p>\n }\n </form>\n }\n }\n\n @case ('reissue') {\n <form class=\"authenticator-form\" (submit)=\"reissue($event)\">\n <p class=\"text-secondary-info\">Ten new recovery codes. The ones you have now will stop working.</p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Current 6-digit code</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" [disabled]=\"busy()\" />\n </mat-form-field>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Make new codes</button>\n </div>\n </form>\n }\n\n @case ('codes') {\n @if (codes(); as list) {\n <shell-recovery-codes [codes]=\"list\" [appName]=\"appName\" (done)=\"codesSaved()\" />\n }\n }\n }\n }\n\n @if (error(); as message) {\n <p class=\"text-loss authenticator-error\" role=\"alert\">{{ message }}</p>\n }\n</section>\n", styles: [".authenticator-settings{max-width:480px;padding:16px}.authenticator-settings h2{font-size:1.25rem;font-weight:400;margin:0 0 8px}.authenticator-facts{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;margin:16px 0}.authenticator-facts dt{color:var(--color-secondary-info)}.authenticator-facts dd{margin:0}.authenticator-form{display:flex;flex-direction:column;gap:4px;max-width:320px;margin-top:12px}.authenticator-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}.authenticator-link{align-self:flex-start}.authenticator-note{margin-top:16px;font-size:.85rem}.authenticator-qr{display:block;margin:4px 0 8px;image-rendering:pixelated}.authenticator-key{font-size:.8rem;word-break:break-all}.authenticator-error{margin-top:12px}.passkey-heading{font-size:1.05rem;font-weight:400;margin:24px 0 4px}.passkey-list{list-style:none;padding:0;margin:8px 0}.passkey-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 0}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$2.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$2.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$2.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$2.MaxLengthValidator, selector: "[maxlength][formControlName],[maxlength][formControl],[maxlength][ngModel]", inputs: ["maxlength"] }, { kind: "directive", type: i1$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$2.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i6.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i4.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: RecoveryCodes, selector: "shell-recovery-codes", inputs: ["codes", "appName", "username"], outputs: ["done"] }, { kind: "pipe", type: DatePipe, name: "date" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
4480
4860
  }
4481
4861
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: AuthenticatorSettings, decorators: [{
4482
4862
  type: Component,
4483
- args: [{ selector: 'shell-authenticator-settings', changeDetection: ChangeDetectionStrategy.OnPush, imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, RecoveryCodes], template: "<section class=\"authenticator-settings\">\n <h2 class=\"text-text-primary\">Sign-in code</h2>\n <p class=\"text-secondary-info\">\n Signing in asks for your password and a 6-digit code from an authenticator app on your phone\n (Google Authenticator, Authy, 1Password).\n </p>\n\n @if (status(); as s) {\n @switch (mode()) {\n @case ('view') {\n <dl class=\"authenticator-facts\">\n <dt>Authenticator</dt>\n <dd>{{ s.enrolled ? 'Set up' : 'Not set up' }}</dd>\n @if (s.enrolled) {\n <dt>Recovery codes left</dt>\n <dd [class.text-loss]=\"s.recoveryCodesLeft <= 2\">{{ s.recoveryCodesLeft }} of 10</dd>\n }\n <dt>Code required at sign-in</dt>\n <dd>{{ s.enrolled ? 'Yes' : (s.codeRequired ? 'Yes' : 'Not yet \u2014 set one up') }}</dd>\n </dl>\n <div class=\"authenticator-actions\">\n <button mat-flat-button type=\"button\" (click)=\"begin('password')\">\n {{ s.enrolled ? 'Replace authenticator' : 'Set up authenticator' }}\n </button>\n @if (s.enrolled) {\n <button mat-stroked-button type=\"button\" (click)=\"begin('reissue')\">New recovery codes</button>\n }\n </div>\n <p class=\"text-secondary-info authenticator-note\">\n Lost your phone? Sign in with a recovery code, then replace the authenticator here.\n No recovery codes left? Only the administrator can reset it, from AWS \u2014 there is no way\n to switch the code off from inside the app.\n </p>\n }\n\n @case ('password') {\n <form class=\"authenticator-form\" (submit)=\"start($event)\">\n <mat-form-field appearance=\"outline\">\n <mat-label>Password</mat-label>\n <input matInput type=\"password\" name=\"password\" [(ngModel)]=\"password\" autocomplete=\"current-password\" [disabled]=\"busy()\" />\n </mat-form-field>\n @if (s.enrolled) {\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Current 6-digit code' }}</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\" [disabled]=\"busy()\" />\n </mat-form-field>\n <button mat-button type=\"button\" class=\"authenticator-link\" (click)=\"toggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Lost the phone? Use a recovery code' }}\n </button>\n }\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !password || (s.enrolled && !code.trim())\">Continue</button>\n </div>\n </form>\n }\n\n @case ('scan') {\n @if (challenge(); as c) {\n <form class=\"authenticator-form\" (submit)=\"confirm($event)\">\n <p class=\"text-secondary-info\">Scan this with your authenticator app, then enter the 6-digit code it shows.</p>\n <img class=\"authenticator-qr\" [src]=\"c.qr\" alt=\"QR code for your authenticator app\" width=\"200\" height=\"200\" />\n <p class=\"text-secondary-info authenticator-key\">Can't scan? Enter this key: <code>{{ c.key }}</code></p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Code</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" [disabled]=\"busy()\" />\n </mat-form-field>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Finish setup</button>\n </div>\n @if (s.enrolled) {\n <p class=\"text-secondary-info authenticator-note\">Your old authenticator keeps working until you finish.</p>\n }\n </form>\n }\n }\n\n @case ('reissue') {\n <form class=\"authenticator-form\" (submit)=\"reissue($event)\">\n <p class=\"text-secondary-info\">Ten new recovery codes. The ones you have now will stop working.</p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Current 6-digit code</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" [disabled]=\"busy()\" />\n </mat-form-field>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Make new codes</button>\n </div>\n </form>\n }\n\n @case ('codes') {\n @if (codes(); as list) {\n <shell-recovery-codes [codes]=\"list\" [appName]=\"appName\" (done)=\"codesSaved()\" />\n }\n }\n }\n }\n\n @if (error(); as message) {\n <p class=\"text-loss authenticator-error\" role=\"alert\">{{ message }}</p>\n }\n</section>\n", styles: [".authenticator-settings{max-width:480px;padding:16px}.authenticator-settings h2{font-size:1.25rem;font-weight:400;margin:0 0 8px}.authenticator-facts{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;margin:16px 0}.authenticator-facts dt{color:var(--color-secondary-info)}.authenticator-facts dd{margin:0}.authenticator-form{display:flex;flex-direction:column;gap:4px;max-width:320px;margin-top:12px}.authenticator-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}.authenticator-link{align-self:flex-start}.authenticator-note{margin-top:16px;font-size:.85rem}.authenticator-qr{display:block;margin:4px 0 8px;image-rendering:pixelated}.authenticator-key{font-size:.8rem;word-break:break-all}.authenticator-error{margin-top:12px}\n"] }]
4863
+ args: [{ selector: 'shell-authenticator-settings', changeDetection: ChangeDetectionStrategy.OnPush, imports: [FormsModule, DatePipe, MatButtonModule, MatFormFieldModule, MatInputModule, RecoveryCodes], template: "<section class=\"authenticator-settings\">\n <h2 class=\"text-text-primary\">Sign-in code</h2>\n <p class=\"text-secondary-info\">\n Signing in asks for your password and a 6-digit code from an authenticator app on your phone\n (Google Authenticator, Authy, 1Password).\n </p>\n\n @if (status(); as s) {\n @switch (mode()) {\n @case ('view') {\n <dl class=\"authenticator-facts\">\n <dt>Authenticator</dt>\n <dd>{{ s.enrolled ? 'Set up' : 'Not set up' }}</dd>\n @if (s.enrolled) {\n <dt>Recovery codes left</dt>\n <dd [class.text-loss]=\"s.recoveryCodesLeft <= 2\">{{ s.recoveryCodesLeft }} of 10</dd>\n }\n <dt>Code required at sign-in</dt>\n <dd>{{ s.enrolled ? 'Yes' : (s.codeRequired ? 'Yes' : 'Not yet \u2014 set one up') }}</dd>\n </dl>\n <div class=\"authenticator-actions\">\n <button mat-flat-button type=\"button\" (click)=\"begin('password')\">\n {{ s.enrolled ? 'Replace authenticator' : 'Set up authenticator' }}\n </button>\n @if (s.enrolled) {\n <button mat-stroked-button type=\"button\" (click)=\"begin('reissue')\">New recovery codes</button>\n }\n </div>\n <p class=\"text-secondary-info authenticator-note\">\n Lost your phone? Sign in with a recovery code, then replace the authenticator here.\n No recovery codes left? Only the administrator can reset it, from AWS \u2014 there is no way\n to switch the code off from inside the app.\n </p>\n @if (passkeys && s.enrolled) {\n <h3 class=\"text-text-primary passkey-heading\">Passkeys</h3>\n <p class=\"text-secondary-info\">\n Sign in with your fingerprint, face or device PIN instead of the password and code \u2014 on\n broken-stock and broken-stock-admin. The password and code keep working.\n </p>\n @if (passkeyList(); as list) {\n @if (list.length === 0) {\n <p class=\"text-secondary-info\">No passkeys yet.</p>\n } @else {\n <ul class=\"passkey-list\">\n @for (p of list; track p.credentialId) {\n <li class=\"passkey-row\">\n <span>\n <strong>{{ p.name }}</strong>\n <span class=\"text-secondary-info\">\n \u2014 added {{ p.addedAtEpoch | date:'mediumDate' }},\n {{ p.lastUsedAtEpoch ? 'last used ' + (p.lastUsedAtEpoch | date:'short') : 'not used yet' }},\n <span title=\"As the browser reported when the passkey was used\">\n {{ p.backedUp ? 'synced between your devices' : 'kept on one device or key' }}\n </span>\n </span>\n </span>\n <button mat-button type=\"button\" (click)=\"beginRemovePasskey(p)\">Remove</button>\n </li>\n }\n </ul>\n }\n }\n <div class=\"authenticator-actions\">\n <button mat-stroked-button type=\"button\" (click)=\"beginAddPasskey()\">Add a passkey</button>\n </div>\n }\n }\n\n @case ('passkey-add') {\n <form class=\"authenticator-form\" (submit)=\"addPasskey($event)\">\n <p class=\"text-secondary-info\">\n Name it after the device or key it lives on, then confirm it is you.\n </p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Name</mat-label>\n <input matInput name=\"passkeyName\" [(ngModel)]=\"passkeyName\" maxlength=\"64\" placeholder=\"MacBook, iPhone, YubiKey\" [disabled]=\"busy()\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>Password</mat-label>\n <input matInput type=\"password\" name=\"password\" [(ngModel)]=\"password\" autocomplete=\"current-password\" [disabled]=\"busy()\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Current 6-digit code' }}</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\" [disabled]=\"busy()\" />\n </mat-form-field>\n <button mat-button type=\"button\" class=\"authenticator-link\" (click)=\"toggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}\n </button>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n @if ((passkeyList() ?? []).length > 0) {\n <button mat-stroked-button type=\"button\" [disabled]=\"busy() || !passkeyName.trim()\" (click)=\"addPasskey(undefined, true)\">\n Confirm with a passkey instead\n </button>\n }\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !passkeyName.trim() || !password || !code.trim()\">Add passkey</button>\n </div>\n </form>\n }\n\n @case ('passkey-remove') {\n @if (removing(); as target) {\n <form class=\"authenticator-form\" (submit)=\"removePasskey($event)\">\n <p class=\"text-secondary-info\">\n Remove <strong>{{ target.name }}</strong>? It will no longer sign you in. Your password and\n code still work.\n </p>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Current 6-digit code' }}</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\" [disabled]=\"busy()\" />\n </mat-form-field>\n <button mat-button type=\"button\" class=\"authenticator-link\" (click)=\"toggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}\n </button>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-stroked-button type=\"button\" [disabled]=\"busy()\" (click)=\"removePasskey(undefined, true)\">Confirm with a passkey</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Remove</button>\n </div>\n </form>\n }\n }\n\n @case ('password') {\n <form class=\"authenticator-form\" (submit)=\"start($event)\">\n <mat-form-field appearance=\"outline\">\n <mat-label>Password</mat-label>\n <input matInput type=\"password\" name=\"password\" [(ngModel)]=\"password\" autocomplete=\"current-password\" [disabled]=\"busy()\" />\n </mat-form-field>\n @if (s.enrolled) {\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Current 6-digit code' }}</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\" [disabled]=\"busy()\" />\n </mat-form-field>\n <button mat-button type=\"button\" class=\"authenticator-link\" (click)=\"toggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Lost the phone? Use a recovery code' }}\n </button>\n }\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !password || (s.enrolled && !code.trim())\">Continue</button>\n </div>\n </form>\n }\n\n @case ('scan') {\n @if (challenge(); as c) {\n <form class=\"authenticator-form\" (submit)=\"confirm($event)\">\n <p class=\"text-secondary-info\">Scan this with your authenticator app, then enter the 6-digit code it shows.</p>\n <img class=\"authenticator-qr\" [src]=\"c.qr\" alt=\"QR code for your authenticator app\" width=\"200\" height=\"200\" />\n <p class=\"text-secondary-info authenticator-key\">Can't scan? Enter this key: <code>{{ c.key }}</code></p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Code</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" [disabled]=\"busy()\" />\n </mat-form-field>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Finish setup</button>\n </div>\n @if (s.enrolled) {\n <p class=\"text-secondary-info authenticator-note\">Your old authenticator keeps working until you finish.</p>\n }\n </form>\n }\n }\n\n @case ('reissue') {\n <form class=\"authenticator-form\" (submit)=\"reissue($event)\">\n <p class=\"text-secondary-info\">Ten new recovery codes. The ones you have now will stop working.</p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Current 6-digit code</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" [disabled]=\"busy()\" />\n </mat-form-field>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Make new codes</button>\n </div>\n </form>\n }\n\n @case ('codes') {\n @if (codes(); as list) {\n <shell-recovery-codes [codes]=\"list\" [appName]=\"appName\" (done)=\"codesSaved()\" />\n }\n }\n }\n }\n\n @if (error(); as message) {\n <p class=\"text-loss authenticator-error\" role=\"alert\">{{ message }}</p>\n }\n</section>\n", styles: [".authenticator-settings{max-width:480px;padding:16px}.authenticator-settings h2{font-size:1.25rem;font-weight:400;margin:0 0 8px}.authenticator-facts{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;margin:16px 0}.authenticator-facts dt{color:var(--color-secondary-info)}.authenticator-facts dd{margin:0}.authenticator-form{display:flex;flex-direction:column;gap:4px;max-width:320px;margin-top:12px}.authenticator-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}.authenticator-link{align-self:flex-start}.authenticator-note{margin-top:16px;font-size:.85rem}.authenticator-qr{display:block;margin:4px 0 8px;image-rendering:pixelated}.authenticator-key{font-size:.8rem;word-break:break-all}.authenticator-error{margin-top:12px}.passkey-heading{font-size:1.05rem;font-weight:400;margin:24px 0 4px}.passkey-list{list-style:none;padding:0;margin:8px 0}.passkey-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 0}\n"] }]
4484
4864
  }] });
4485
4865
 
4486
4866
  /*
@@ -4633,5 +5013,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
4633
5013
  * Generated bundle index. Do not edit.
4634
5014
  */
4635
5015
 
4636
- export { AuthenticationService, AuthenticatorError, AuthenticatorSettings, CLEAR_USAGE, CODE_STEP, CodeDialog, CommandRegistryService, CommandService, CommandTerminal, ConfirmDialog, FAQ_CONTENT_LOADER, FAQ_DEV_EDITOR, FAQ_DEV_MODE, FAQ_DEV_WORKSPACE_ROOT, FAQ_PUBLIC_PUBLISHER, FAQ_PUBLIC_SITE_BASE_URL, FaqAuthoringPanel, FaqHistoryService, FaqMarkdownRendererService, FaqModal, FaqService, HELP_USAGE, HttpFaqContentLoader, KeyboardShortcutService, Login, MainContent, PendingPromptState, PhoneLayoutService, PublicFaqIndex, PublicFaqPage, PublicFaqToc, RecoveryCodes, RegexValidatorDirective, SESSION_TOKEN_KEY, SHELL_APP_NAME, SHELL_AUTH_URLS, SHELL_COMMANDS, SHELL_MENU_ITEMS, SHELL_PHONE_MEDIA_QUERY, SHELL_POST_LOGIN_ROUTE, SHELL_SHARED_SESSION, ScreenKeysService, SessionTokenStore, Shell, SideMenu, StatusBar, TERMINAL_INPUT_CLASS, TOO_MANY_ATTEMPTS, TerminalFocusService, TerminalVisibilityService, ThemeService, ThinkingService, answerEcho, audienceAllowed, authGuard, authInterceptor, authenticatorMessage, buildFallbackChain, buildLoginBody, comboMatches, decideRenewal, guestGuard, isEligibleKey, isTypingElsewhere, isYes, matchesRoute, openCodeDialog, provideShellConfig, recoveryCodesFile, tokenize };
5016
+ export { AuthenticationService, AuthenticatorError, AuthenticatorSettings, CLEAR_USAGE, CODE_STEP, CodeDialog, CommandRegistryService, CommandService, CommandTerminal, ConfirmDialog, FAQ_CONTENT_LOADER, FAQ_DEV_EDITOR, FAQ_DEV_MODE, FAQ_DEV_WORKSPACE_ROOT, FAQ_PUBLIC_PUBLISHER, FAQ_PUBLIC_SITE_BASE_URL, FaqAuthoringPanel, FaqHistoryService, FaqMarkdownRendererService, FaqModal, FaqService, HELP_USAGE, HttpFaqContentLoader, KeyboardShortcutService, Login, MainContent, PASSKEY_CANCELLED, PendingPromptState, PhoneLayoutService, PublicFaqIndex, PublicFaqPage, PublicFaqToc, RecoveryCodes, RegexValidatorDirective, SESSION_TOKEN_KEY, SHELL_APP_NAME, SHELL_AUTH_URLS, SHELL_COMMANDS, SHELL_MENU_ITEMS, SHELL_PHONE_MEDIA_QUERY, SHELL_POST_LOGIN_ROUTE, SHELL_SHARED_SESSION, ScreenKeysService, SessionTokenStore, Shell, SideMenu, StatusBar, TERMINAL_INPUT_CLASS, TOO_MANY_ATTEMPTS, TerminalFocusService, TerminalVisibilityService, ThemeService, ThinkingService, answerEcho, audienceAllowed, authGuard, authInterceptor, authenticatorMessage, buildFallbackChain, buildLoginBody, comboMatches, decideRenewal, guestGuard, isEligibleKey, isTypingElsewhere, isYes, matchesRoute, openCodeDialog, passkeyAutofillSupported, passkeysSupported, provideShellConfig, recoveryCodesFile, tokenize };
4637
5017
  //# sourceMappingURL=franzzemen-brokenstock-shell.mjs.map