@franzzemen/brokenstock-shell 0.9.5 → 0.10.1

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,65 @@ 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
+ * Shown when the passkey prompt closed without a passkey: most often this device has none (a
4441
+ * passkey lives on the device or account that made it). Plain advice, not an error.
4442
+ */
4443
+ this.passkeyHint = signal(false, /* @ts-ignore */
4444
+ ...(ngDevMode ? [{ debugName: "passkeyHint" }] : /* istanbul ignore next */ []));
4445
+ }
4446
+ /** Safe on the server: the support check is false wherever there is no browser. */
4447
+ ngOnInit() {
4448
+ void this.#startPasskeyAutofill();
4449
+ }
4450
+ ngOnDestroy() {
4451
+ this.#autofill?.abort();
4452
+ }
4453
+ /** "Sign in with a passkey". The quiet wait must stop first: a browser runs one passkey prompt at a time. */
4454
+ onPasskey() {
4455
+ this.errors = [];
4456
+ this.passkeyHint.set(false);
4457
+ this.#autofill?.abort();
4458
+ this.#autofill = undefined;
4459
+ this.submitted = true;
4460
+ this.#passkeySignIn(false);
4461
+ }
4462
+ /**
4463
+ * Offer passkeys in the username box's suggestions (D9): a passkey request that waits until the
4464
+ * user picks one there. Silent when unsupported or when anything goes wrong — the button and the
4465
+ * password form are still on screen.
4466
+ */
4467
+ async #startPasskeyAutofill() {
4468
+ if (!(await passkeyAutofillSupported()))
4469
+ return;
4470
+ if (this.authenticationService.authenticated())
4471
+ return;
4472
+ this.#autofill?.abort();
4473
+ const controller = new AbortController();
4474
+ this.#autofill = controller;
4475
+ this.#passkeySignIn(true, controller.signal);
4476
+ }
4477
+ #passkeySignIn(conditional, signal) {
4478
+ this.authenticationService
4479
+ .loginWithPasskey({ conditional, ...(signal ? { signal } : {}) })
4480
+ .then(() => this.#afterSignIn(undefined))
4481
+ .catch((err) => {
4482
+ // The quiet wait ending says nothing about a button press that may be in flight.
4483
+ if (!conditional)
4484
+ this.submitted = false;
4485
+ if (err === 'conflict')
4486
+ return;
4487
+ if (err === PASSKEY_CANCELLED && !conditional)
4488
+ this.passkeyHint.set(true);
4489
+ if (err !== PASSKEY_CANCELLED && !conditional) {
4490
+ this.errors.push({ ndx: 0, message: typeof err === 'string' ? err : 'Unable to sign in with a passkey' });
4491
+ }
4492
+ // Offer the suggestions again, unless this screen is going away.
4493
+ if (!signal?.aborted)
4494
+ void this.#startPasskeyAutofill();
4495
+ });
4172
4496
  }
4173
4497
  /**
4174
4498
  * Returns the post-login destination. Honors a `returnUrl` query param if
@@ -4309,20 +4633,14 @@ class Login {
4309
4633
  this.submitted = false;
4310
4634
  }
4311
4635
  #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
- })
4636
+ // The open "already signed in elsewhere" choice came from a passkey: its retry carries the
4637
+ // ticket to the passkey route — there is no password to send (passkey D8).
4638
+ const viaPasskey = this.authenticationService.pendingPasskey();
4639
+ const attempt = viaPasskey
4640
+ ? this.authenticationService.retryPasskeySignIn(force, choice)
4641
+ : this.authenticationService.login(this.model, force, choice);
4642
+ attempt
4643
+ .then(() => this.#afterSignIn(viaPasskey ? undefined : this.model.username))
4326
4644
  .catch((err) => {
4327
4645
  if (err !== 'conflict' && err !== CODE_STEP) {
4328
4646
  this.errors.push({ ndx: 0, message: typeof err === 'string' ? err : 'Unable to login' });
@@ -4331,13 +4649,26 @@ class Login {
4331
4649
  this.submitted = false;
4332
4650
  });
4333
4651
  }
4652
+ #afterSignIn(username) {
4653
+ this.#autofill?.abort();
4654
+ this.#commandService.echoCommand(username ? `login ${username}` : 'login (passkey)');
4655
+ this.codeText = '';
4656
+ this.useRecovery.set(false);
4657
+ // Setup finished during this sign-in: the codes are on screen and must be
4658
+ // seen before the app takes over the page.
4659
+ if (this.authenticationService.recoveryCodesToShow()) {
4660
+ this.submitted = false;
4661
+ return;
4662
+ }
4663
+ this.#navigateAfterLogin();
4664
+ }
4334
4665
  #navigateAfterLogin() {
4335
4666
  this.router.navigateByUrl(this.#resolvePostLoginUrl()).then(() => {
4336
4667
  setTimeout(() => this.#terminalFocusService.requestFocus());
4337
4668
  }).catch(console.error);
4338
4669
  }
4339
4670
  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 }); }
4671
+ 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 @if (passkeyHint()) {\n <p class=\"text-secondary-info login-passkey-hint\">\n No passkey on this device? Sign in with your password and code, then add one under Sign-in Code.\n </p>\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%}.login-passkey-hint{margin:4px 0 0;font-size:.85rem}\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
4672
  }
4342
4673
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: Login, decorators: [{
4343
4674
  type: Component,
@@ -4354,7 +4685,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
4354
4685
  TextFieldModule,
4355
4686
  DatePipe,
4356
4687
  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"] }]
4688
+ ], 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 @if (passkeyHint()) {\n <p class=\"text-secondary-info login-passkey-hint\">\n No passkey on this device? Sign in with your password and code, then add one under Sign-in Code.\n </p>\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%}.login-passkey-hint{margin:4px 0 0;font-size:.85rem}\n"] }]
4358
4689
  }], ctorParameters: () => [{ type: AuthenticationService }, { type: i2$1.Router }] });
4359
4690
 
4360
4691
  /*
@@ -4370,6 +4701,11 @@ Settings → Sign-in code (authenticator-sign-in.prd.md D5, D7). Shared by every
4370
4701
 
4371
4702
  ⛔ There is deliberately no "remove" and no "turn off" here (D13). Those are AWS-only; the screen
4372
4703
  says how to get them done.
4704
+
4705
+ Passkeys (passkey-sign-in.prd.md D13–D17), shown when the browser can do them and an authenticator
4706
+ is set up (D2 — a passkey is an option beside it, never instead):
4707
+ - Add one: a name, then password + code (or recovery code), or a passkey already added (D14).
4708
+ - Remove one: a code, a recovery code, or a passkey (D15). Removing only takes a way in away.
4373
4709
  */
4374
4710
  class AuthenticatorSettings {
4375
4711
  constructor() {
@@ -4389,8 +4725,16 @@ class AuthenticatorSettings {
4389
4725
  ...(ngDevMode ? [{ debugName: "codes" }] : /* istanbul ignore next */ []));
4390
4726
  this.useRecovery = signal(false, /* @ts-ignore */
4391
4727
  ...(ngDevMode ? [{ debugName: "useRecovery" }] : /* istanbul ignore next */ []));
4728
+ /** Whether this browser can make and use passkeys at all. */
4729
+ this.passkeys = passkeysSupported();
4730
+ this.passkeyList = signal(undefined, /* @ts-ignore */
4731
+ ...(ngDevMode ? [{ debugName: "passkeyList" }] : /* istanbul ignore next */ []));
4732
+ /** The passkey being removed, while its confirmation is on screen. */
4733
+ this.removing = signal(undefined, /* @ts-ignore */
4734
+ ...(ngDevMode ? [{ debugName: "removing" }] : /* istanbul ignore next */ []));
4392
4735
  this.password = '';
4393
4736
  this.code = '';
4737
+ this.passkeyName = '';
4394
4738
  }
4395
4739
  #auth;
4396
4740
  ngOnInit() {
@@ -4398,12 +4742,55 @@ class AuthenticatorSettings {
4398
4742
  }
4399
4743
  async #load() {
4400
4744
  try {
4401
- this.status.set(await this.#auth.authenticatorStatus());
4745
+ const status = await this.#auth.authenticatorStatus();
4746
+ this.status.set(status);
4747
+ if (this.passkeys && status.enrolled)
4748
+ this.passkeyList.set(await this.#auth.listPasskeys());
4402
4749
  }
4403
4750
  catch (err) {
4404
4751
  this.error.set(err instanceof AuthenticatorError ? err.message : 'Could not load your sign-in code settings.');
4405
4752
  }
4406
4753
  }
4754
+ beginAddPasskey() {
4755
+ this.#reset();
4756
+ this.mode.set('passkey-add');
4757
+ }
4758
+ beginRemovePasskey(passkey) {
4759
+ this.#reset();
4760
+ this.removing.set(passkey);
4761
+ this.mode.set('passkey-remove');
4762
+ }
4763
+ /** Add, proved with password + code (or recovery code) — or with a passkey already added. */
4764
+ async addPasskey(event, withPasskey = false) {
4765
+ event?.preventDefault();
4766
+ const name = this.passkeyName.trim();
4767
+ if (!name)
4768
+ return;
4769
+ await this.#run(async () => {
4770
+ await this.#auth.addPasskey(name, withPasskey ? 'passkey' : { password: this.password, ...this.#codeEntry() });
4771
+ await this.#passkeysChanged();
4772
+ });
4773
+ }
4774
+ /** Remove, proved with a code or recovery code — or with a passkey. */
4775
+ async removePasskey(event, withPasskey = false) {
4776
+ event?.preventDefault();
4777
+ const target = this.removing();
4778
+ if (!target)
4779
+ return;
4780
+ await this.#run(async () => {
4781
+ const proof = withPasskey ? 'passkey' : this.#codeEntry();
4782
+ await this.#auth.removePasskey(target.credentialId, proof);
4783
+ await this.#passkeysChanged();
4784
+ });
4785
+ }
4786
+ #codeEntry() {
4787
+ return this.useRecovery() ? { recoveryCode: this.code.trim() } : { code: this.code.trim() };
4788
+ }
4789
+ async #passkeysChanged() {
4790
+ this.#reset();
4791
+ this.mode.set('view');
4792
+ this.passkeyList.set(await this.#auth.listPasskeys());
4793
+ }
4407
4794
  begin(mode) {
4408
4795
  this.#reset();
4409
4796
  this.mode.set(mode);
@@ -4470,17 +4857,19 @@ class AuthenticatorSettings {
4470
4857
  #reset() {
4471
4858
  this.password = '';
4472
4859
  this.code = '';
4860
+ this.passkeyName = '';
4861
+ this.removing.set(undefined);
4473
4862
  this.error.set(undefined);
4474
4863
  this.challenge.set(undefined);
4475
4864
  this.codes.set(undefined);
4476
4865
  this.useRecovery.set(false);
4477
4866
  }
4478
4867
  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 }); }
4868
+ 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
4869
  }
4481
4870
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: AuthenticatorSettings, decorators: [{
4482
4871
  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"] }]
4872
+ 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
4873
  }] });
4485
4874
 
4486
4875
  /*
@@ -4633,5 +5022,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
4633
5022
  * Generated bundle index. Do not edit.
4634
5023
  */
4635
5024
 
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 };
5025
+ 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
5026
  //# sourceMappingURL=franzzemen-brokenstock-shell.mjs.map