@franzzemen/brokenstock-shell 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,12 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, makeEnvironmentProviders, signal, computed, Injectable, inject, input, Directive, isDevMode, effect, untracked, HostListener, ViewChild, ViewEncapsulation, ChangeDetectionStrategy, Component, DestroyRef, NgZone, viewChild, afterNextRender, linkedSignal } from '@angular/core';
2
+ import { InjectionToken, makeEnvironmentProviders, signal, computed, Injectable, inject, input, Directive, isDevMode, effect, untracked, HostListener, ViewChild, ViewEncapsulation, ChangeDetectionStrategy, Component, DestroyRef, NgZone, viewChild, afterNextRender, output } from '@angular/core';
3
3
  import * as i2$1 from '@angular/router';
4
4
  import { Router, RouterLink, RouterLinkActive, ActivatedRoute, RouterOutlet } from '@angular/router';
5
5
  import { BEARER_TOKEN_REGEX, HttpHeader, USERNAME_REGEX, PASSWORD_REGEX } from '@franzzemen/utility';
6
6
  import { validateCredentials } from '@franzzemen/identity';
7
- import { HttpClient } from '@angular/common/http';
8
- import { catchError, from, switchMap, throwError, firstValueFrom } from 'rxjs';
9
- import * as i3 from '@angular/forms';
7
+ import { HttpClient, HttpErrorResponse } from '@angular/common/http';
8
+ import { firstValueFrom, catchError, from, switchMap, throwError } from 'rxjs';
9
+ import * as i1$2 from '@angular/forms';
10
10
  import { NG_VALIDATORS, FormsModule } from '@angular/forms';
11
11
  import { Marked } from 'marked';
12
12
  import { gfmHeadingId } from 'marked-gfm-heading-id';
@@ -23,14 +23,14 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
23
23
  import { CdkTrapFocus } from '@angular/cdk/a11y';
24
24
  import * as i5 from '@angular/cdk/text-field';
25
25
  import { TextFieldModule } from '@angular/cdk/text-field';
26
- import { MatFormField, MatLabel } from '@angular/material/form-field';
26
+ import { MatFormField, MatLabel, MatFormFieldModule } from '@angular/material/form-field';
27
27
  import * as i4 from '@angular/material/input';
28
28
  import { MatInput, MatInputModule } from '@angular/material/input';
29
29
  import * as i6 from '@angular/material/button';
30
30
  import { MatButtonModule } from '@angular/material/button';
31
31
  import * as i7 from '@angular/material/checkbox';
32
32
  import { MatCheckboxModule } from '@angular/material/checkbox';
33
- import * as i1$2 from '@angular/material/dialog';
33
+ import * as i2$2 from '@angular/material/dialog';
34
34
  import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
35
35
 
36
36
  /**
@@ -342,6 +342,77 @@ function decideRenewal(before, current) {
342
342
  return 'renew';
343
343
  }
344
344
 
345
+ /*
346
+ Created by Franz Zemen 2026-10-17
347
+ License Type: UNLICENSED
348
+
349
+ Authenticator sign-in — what the browser sees (authenticator-sign-in.prd.md).
350
+ */
351
+ /**
352
+ * The one message for a pause (D8). The server sends no time and the screen invents none.
353
+ */
354
+ const TOO_MANY_ATTEMPTS = 'Too many attempts.';
355
+ /** Plain words for each refusal, shown on the sign-in screen and in the code dialog. */
356
+ function authenticatorMessage(reason) {
357
+ switch (reason) {
358
+ case 'code-required': return 'Enter the 6-digit code from your authenticator app.';
359
+ case 'invalid-code': return 'That code did not work. Try the current one.';
360
+ case 'too-many-attempts': return TOO_MANY_ATTEMPTS;
361
+ case 'authenticator-not-set-up': return 'This account has no authenticator set up. Ask the administrator to reset it.';
362
+ case 'setup-required': return 'Scan this QR code with your authenticator app, then enter the 6-digit code it shows.';
363
+ default: return 'Unable to sign in';
364
+ }
365
+ }
366
+ /** The text file the Download button saves (D7). */
367
+ function recoveryCodesFile(appName, username, codes, now = new Date()) {
368
+ return [
369
+ `${appName} recovery codes${username ? ` for ${username}` : ''}`,
370
+ `Generated ${now.toISOString()}`,
371
+ '',
372
+ 'Each code works once, in place of the 6-digit code from your authenticator app.',
373
+ 'Keep this file somewhere safe and offline. Anyone with these codes and your password can sign in.',
374
+ '',
375
+ ...codes,
376
+ '',
377
+ ].join('\n');
378
+ }
379
+
380
+ /**
381
+ * A refusal from an authenticator route, carrying the server's `reason`
382
+ * (authenticator-sign-in.prd.md). `message` is plain words for the screen.
383
+ */
384
+ class AuthenticatorError extends Error {
385
+ constructor(reason, message, status) {
386
+ super(message);
387
+ this.reason = reason;
388
+ this.status = status;
389
+ this.name = 'AuthenticatorError';
390
+ }
391
+ }
392
+ /** Sign-in rejected because the next step is a code (or setting one up), not because anything failed. */
393
+ const CODE_STEP = 'code-step';
394
+ /**
395
+ * The sign-in request body, exactly as sent (multiple-devices E8, authenticator-sign-in.prd.md).
396
+ *
397
+ * Fields appear only when they carry a decision: the route's body schema is strict,
398
+ * and `additional: false` on a first attempt would be a statement where silence is
399
+ * meant. A code typed now wins; otherwise a ticket from a 409 that followed a
400
+ * correct code stands in for it (the server accepts a code only once, D20).
401
+ */
402
+ function buildLoginBody(credentials, force, options, signInTicket) {
403
+ const proof = options.code
404
+ ? { code: options.code }
405
+ : options.recoveryCode
406
+ ? { recoveryCode: options.recoveryCode }
407
+ : signInTicket ? { signInTicket } : {};
408
+ return {
409
+ ...credentials,
410
+ ...(force ? { force: true } : {}),
411
+ ...(options.additional ? { additional: true } : {}),
412
+ ...(options.endSessionIds?.length ? { endSessionIds: options.endSessionIds } : {}),
413
+ ...proof,
414
+ };
415
+ }
345
416
  /** Name of the cross-window renewal lock (PRD D5). */
346
417
  const RENEWAL_LOCK = 'brokenstock.session.renew';
347
418
  /** How long a window waits for the renewing window to publish a token (PRD D5). */
@@ -349,6 +420,11 @@ const PEER_RENEWAL_WAIT_MS = 10_000;
349
420
  /** How long the renewal request itself may take before it is abandoned (PRD D5). */
350
421
  const RENEWAL_TIMEOUT_MS = 15_000;
351
422
  class AuthenticationService {
423
+ /**
424
+ * The ticket from a 409 that followed a correct code. The retry sends it in place
425
+ * of the code, which the server will not accept twice (D20). Never shown.
426
+ */
427
+ #signInTicket;
352
428
  #refreshing;
353
429
  #auth;
354
430
  #httpClient;
@@ -381,6 +457,25 @@ class AuthenticationService {
381
457
  ...(ngDevMode ? [{ debugName: "additionalSignInAllowed" }] : /* istanbul ignore next */ []));
382
458
  this.pendingCredentials = signal(undefined, /* @ts-ignore */
383
459
  ...(ngDevMode ? [{ debugName: "pendingCredentials" }] : /* istanbul ignore next */ []));
460
+ // ----- Authenticator (authenticator-sign-in.prd.md) -----
461
+ /** The extra step the sign-in screen is on after the password was accepted. */
462
+ this.codeStep = signal('none', /* @ts-ignore */
463
+ ...(ngDevMode ? [{ debugName: "codeStep" }] : /* istanbul ignore next */ []));
464
+ /** Inside an AWS-opened setup window: the QR and key to show (D9, D10). */
465
+ this.setupChallenge = signal(undefined, /* @ts-ignore */
466
+ ...(ngDevMode ? [{ debugName: "setupChallenge" }] : /* istanbul ignore next */ []));
467
+ /** New recovery codes to show ONCE, with a Download button (D7). Cleared when acknowledged. */
468
+ this.recoveryCodesToShow = signal(undefined, /* @ts-ignore */
469
+ ...(ngDevMode ? [{ debugName: "recoveryCodesToShow" }] : /* istanbul ignore next */ []));
470
+ /** Recovery codes left, from the last sign-in or status read. */
471
+ this.recoveryCodesLeft = signal(undefined, /* @ts-ignore */
472
+ ...(ngDevMode ? [{ debugName: "recoveryCodesLeft" }] : /* istanbul ignore next */ []));
473
+ /**
474
+ * Whether the AWS-set bypass is on for this account (D12). Drives the banner on
475
+ * every screen. Yes/no only: the server never says when it ends.
476
+ */
477
+ this.bypassActive = signal(false, /* @ts-ignore */
478
+ ...(ngDevMode ? [{ debugName: "bypassActive" }] : /* istanbul ignore next */ []));
384
479
  this.#refreshing = null;
385
480
  this.#auth = 'Bearer ..';
386
481
  this.#httpClient = inject(HttpClient);
@@ -471,6 +566,18 @@ class AuthenticationService {
471
566
  this.conflictSessions.set(undefined);
472
567
  this.additionalSignInAllowed.set(false);
473
568
  this.pendingCredentials.set(undefined);
569
+ this.#signInTicket = undefined;
570
+ }
571
+ /** Back to the password form from the code or setup step. */
572
+ cancelCodeStep() {
573
+ this.codeStep.set('none');
574
+ this.setupChallenge.set(undefined);
575
+ this.pendingCredentials.set(undefined);
576
+ this.#signInTicket = undefined;
577
+ }
578
+ /** The new recovery codes have been seen (and, we hope, downloaded). */
579
+ acknowledgeRecoveryCodes() {
580
+ this.recoveryCodesToShow.set(undefined);
474
581
  }
475
582
  /**
476
583
  * The session is gone and cannot be recovered — renewal failed permanently,
@@ -527,15 +634,7 @@ class AuthenticationService {
527
634
  this.#thinkingService.startThinking();
528
635
  return new Promise((resolve, reject) => {
529
636
  this.authenticating.set(true);
530
- // Fields are added only when they carry a decision. The route's body
531
- // schema is strict, and sending `additional: false` on a first attempt
532
- // would be a statement where silence is meant.
533
- const body = {
534
- ...credentials,
535
- ...(force ? { force: true } : {}),
536
- ...(options.additional ? { additional: true } : {}),
537
- ...(options.endSessionIds?.length ? { endSessionIds: options.endSessionIds } : {}),
538
- };
637
+ const body = buildLoginBody(credentials, force, options, this.#signInTicket);
539
638
  const subscription = this.#httpClient
540
639
  .post(this.#authUrls.login, body, {
541
640
  headers: {
@@ -559,6 +658,14 @@ class AuthenticationService {
559
658
  return;
560
659
  }
561
660
  this.#adoptToken(auth);
661
+ const authenticator = response.body.authenticator;
662
+ this.recoveryCodesLeft.set(authenticator?.recoveryCodesLeft);
663
+ this.bypassActive.set(authenticator?.bypassActive === true);
664
+ if (authenticator?.recoveryCodes?.length)
665
+ this.recoveryCodesToShow.set(authenticator.recoveryCodes);
666
+ this.codeStep.set('none');
667
+ this.setupChallenge.set(undefined);
668
+ this.#signInTicket = undefined;
562
669
  this.authenticated.set(true);
563
670
  this.pendingCredentials.set(undefined);
564
671
  resolve();
@@ -574,7 +681,17 @@ class AuthenticationService {
574
681
  },
575
682
  error: (err) => {
576
683
  try {
684
+ const reason = err.error?.reason;
577
685
  if (err.status === 409) {
686
+ // After a correct code: the retry carries this instead (D20), and
687
+ // new recovery codes from a setup must not be lost to this panel.
688
+ if (typeof err.error?.signInTicket === 'string')
689
+ this.#signInTicket = err.error.signInTicket;
690
+ if (Array.isArray(err.error?.recoveryCodes))
691
+ this.recoveryCodesToShow.set(err.error.recoveryCodes);
692
+ // The code is done; the session choice is all that is left on screen.
693
+ this.codeStep.set('none');
694
+ this.setupChallenge.set(undefined);
578
695
  this.conflictSessions.set(err.error?.activeSessions ?? []);
579
696
  // Absent means false: an older back end that does not send this
580
697
  // does not permit signing in alongside, so the screen must not
@@ -583,6 +700,27 @@ class AuthenticationService {
583
700
  this.pendingCredentials.set(credentials);
584
701
  reject('conflict');
585
702
  }
703
+ else if (reason === 'code-required') {
704
+ this.codeStep.set('code');
705
+ this.pendingCredentials.set(credentials);
706
+ reject(CODE_STEP);
707
+ }
708
+ else if (reason === 'setup-required') {
709
+ this.codeStep.set('setup');
710
+ this.setupChallenge.set({ qr: err.error.qr, key: err.error.key });
711
+ this.pendingCredentials.set(credentials);
712
+ reject(CODE_STEP);
713
+ }
714
+ else if (reason) {
715
+ // invalid-code / too-many-attempts / authenticator-not-set-up:
716
+ // stay on the step the user is on; say what happened, in plain words.
717
+ if (reason === 'too-many-attempts' || reason === 'authenticator-not-set-up')
718
+ this.#signInTicket = undefined;
719
+ reject(authenticatorMessage(reason));
720
+ }
721
+ else if (err.status === 401) {
722
+ reject('Wrong username or password');
723
+ }
586
724
  else {
587
725
  reject(err.message ?? 'Unable to login');
588
726
  }
@@ -596,6 +734,68 @@ class AuthenticationService {
596
734
  });
597
735
  });
598
736
  }
737
+ // ---------------------------------------------------------------------------
738
+ // Authenticator settings and the on-demand check (authenticator-sign-in.prd.md)
739
+ //
740
+ // All under the sign-in URL (`/login/…` or `/admin/login/…`), on the auth
741
+ // worker. A wrong code here is 403, never 401, so the interceptor does not
742
+ // read it as an expired session.
743
+ // ---------------------------------------------------------------------------
744
+ /** Whether an authenticator is set up, codes left, and whether the bypass is on. Refreshes the banner. */
745
+ async authenticatorStatus() {
746
+ const status = await this.#authenticatorCall('GET', 'authenticator');
747
+ this.bypassActive.set(status.bypassActive);
748
+ this.recoveryCodesLeft.set(status.recoveryCodesLeft);
749
+ return status;
750
+ }
751
+ /** Begin setting up (or replacing) the authenticator: the password again, plus a current code when replacing (D5). */
752
+ startAuthenticatorSetup(password, current = {}) {
753
+ return this.#authenticatorCall('POST', 'authenticator/start', { password, ...current });
754
+ }
755
+ /** Finish setup with the first code from the newly scanned app. Returns the 10 recovery codes, once. */
756
+ async confirmAuthenticatorSetup(code) {
757
+ const { recoveryCodes } = await this.#authenticatorCall('POST', 'authenticator/confirm', { code });
758
+ this.recoveryCodesLeft.set(recoveryCodes.length);
759
+ return recoveryCodes;
760
+ }
761
+ /** Ten new recovery codes; the old ones stop working (D7). Needs a current code. */
762
+ async reissueRecoveryCodes(code) {
763
+ const { recoveryCodes } = await this.#authenticatorCall('POST', 'recovery-codes', { code });
764
+ this.recoveryCodesLeft.set(recoveryCodes.length);
765
+ return recoveryCodes;
766
+ }
767
+ /**
768
+ * The on-demand check (D15): proves the code again on this session and returns
769
+ * when it was recorded. `purpose` is a short label for the log, e.g.
770
+ * 'arm-live-trading' — it changes nothing. The bypass never satisfies this.
771
+ */
772
+ async verifyCode(entry, purpose) {
773
+ const { codeVerifiedAtEpoch } = await this.#authenticatorCall('POST', 'verify-code', { ...entry, ...(purpose ? { purpose } : {}) });
774
+ return codeVerifiedAtEpoch;
775
+ }
776
+ async #authenticatorCall(method, path, body) {
777
+ const url = `${this.#authUrls.login}/${path}`;
778
+ const headers = {
779
+ [HttpHeader.Authorization]: this.auth,
780
+ [HttpHeader.AppContext]: 'brokenstock',
781
+ ...(body !== undefined ? { [HttpHeader.ContentType]: 'application/json' } : {}),
782
+ };
783
+ try {
784
+ return method === 'GET'
785
+ ? await firstValueFrom(this.#httpClient.get(url, { headers }))
786
+ : await firstValueFrom(this.#httpClient.post(url, body, { headers }));
787
+ }
788
+ catch (err) {
789
+ if (err instanceof HttpErrorResponse) {
790
+ const reason = err.error?.reason;
791
+ const message = reason
792
+ ? authenticatorMessage(reason)
793
+ : err.status === 400 ? 'Check what you typed and try again.' : (err.error?.message ?? 'Something went wrong');
794
+ throw new AuthenticatorError(reason, message, err.status);
795
+ }
796
+ throw err;
797
+ }
798
+ }
599
799
  /**
600
800
  * Renews the session, coordinating with the other windows of this browser.
601
801
  *
@@ -701,6 +901,12 @@ class AuthenticationService {
701
901
  this.userSession.set(undefined);
702
902
  this.conflictSessions.set(undefined);
703
903
  this.pendingCredentials.set(undefined);
904
+ this.codeStep.set('none');
905
+ this.setupChallenge.set(undefined);
906
+ this.recoveryCodesToShow.set(undefined);
907
+ this.recoveryCodesLeft.set(undefined);
908
+ this.bypassActive.set(false);
909
+ this.#signInTicket = undefined;
704
910
  this.#auth = 'Bearer ..';
705
911
  this.#store.clear();
706
912
  this.#router.navigate(['/login']).catch(console.error);
@@ -2332,12 +2538,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
2332
2538
  * Asks the command box to take the cursor. Called by the shell after most
2333
2539
  * actions (rail choices, Log Out, ?, ⧉, Escape, sign-in) and by apps.
2334
2540
  *
2335
- * ⚠ A NO-OP ON A PHONE (scanner-phone-display E11). The console is not rendered
2336
- * below 820px, and the one place every focus request passes through is here, so
2337
- * this is where it is refused: focusing a text box on a phone raises the
2338
- * on-screen keyboard over the screen the user just opened. Refusing it at the
2339
- * source also means a request made on a phone is not left counted up, waiting to
2340
- * be acted on when the console appears after a rotation.
2541
+ * ⚠ A NO-OP ON A PHONE (scanner-phone-display D10/E11). The console is shown
2542
+ * below 820px, but nothing may put the cursor in it there: focusing a text box
2543
+ * on a phone raises the on-screen keyboard over the screen the user just opened.
2544
+ * The one place every focus request passes through is here, so this is where it
2545
+ * is refused. Refusing it at the source also means a request made on a phone is
2546
+ * not left counted up, to be acted on after a rotation to a wider screen.
2341
2547
  */
2342
2548
  class TerminalFocusService {
2343
2549
  constructor() {
@@ -3164,15 +3370,12 @@ class SideMenu {
3164
3370
  * untouched — they are document-level listeners and work from a hardware
3165
3371
  * keyboard whatever the layout.
3166
3372
  *
3167
- * The console show/hide button is left out too: the console is not rendered
3168
- * on a phone at all (Franz, 2026-10-16, scanner-phone-display E11), so the
3169
- * button would have nothing to show.
3373
+ * The console show/hide button stays (Franz, 2026-10-17: the console is back
3374
+ * on phones, reversing 0.8.1). Turning the console ON from here does not put
3375
+ * the cursor in the command box on a phone — see CommandTerminal.focusOnAppear.
3170
3376
  */
3171
3377
  this.drawer = input(false, /* @ts-ignore */
3172
3378
  ...(ngDevMode ? [{ debugName: "drawer" }] : /* istanbul ignore next */ []));
3173
- /** The console show/hide button — the icon column only (see `drawer`). */
3174
- this.showConsoleToggle = computed(() => !this.drawer(), /* @ts-ignore */
3175
- ...(ngDevMode ? [{ debugName: "showConsoleToggle" }] : /* istanbul ignore next */ []));
3176
3379
  this.#allMenuItems = inject(SHELL_MENU_ITEMS, { optional: true }) ?? [];
3177
3380
  this.menuItems = computed(() => this.#allMenuItems.filter(item => !item.visible || item.visible()), /* @ts-ignore */
3178
3381
  ...(ngDevMode ? [{ debugName: "menuItems" }] : /* istanbul ignore next */ []));
@@ -3213,13 +3416,17 @@ class SideMenu {
3213
3416
  }
3214
3417
  toggleTerminal() {
3215
3418
  this.#terminalVisibility.toggle();
3419
+ // ⚠ No terminal focus here either: on a phone, showing the console is not a
3420
+ // request to type. The menu closes so the user sees the console appear.
3421
+ if (this.drawer())
3422
+ this.#phoneLayout.closeMenu();
3216
3423
  }
3217
3424
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: SideMenu, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
3218
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: SideMenu, isStandalone: true, selector: "shell-side-menu", inputs: { drawer: { classPropertyName: "drawer", publicName: "drawer", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<nav class=\"side-menu\" [class.drawer]=\"drawer()\" [attr.aria-label]=\"drawer() ? 'Menu' : null\">\n @for (item of menuItems(); track item.route) {\n @if (isEnabled(item)) {\n <a\n class=\"menu-item\"\n [routerLink]=\"item.route\"\n routerLinkActive=\"active\"\n [matTooltip]=\"item.label\"\n matTooltipPosition=\"right\"\n [matTooltipDisabled]=\"drawer()\"\n (click)=\"onMenuClick(item)\"\n >\n <mat-icon>{{ item.icon }}</mat-icon>\n @if (drawer()) {\n <span class=\"menu-label\">{{ item.label }}</span>\n }\n </a>\n } @else {\n <!-- No routerLink: the item is inert. Pointer events stay live (see the\n .disabled rule) so the tooltip still fires \u2014 it carries the only\n explanation the user gets. In the phone menu the explanation is shown\n as text instead, since there is no hover to fire it. -->\n <span\n class=\"menu-item disabled\"\n [matTooltip]=\"disabledReasonFor(item)\"\n matTooltipPosition=\"right\"\n [matTooltipDisabled]=\"drawer()\"\n aria-disabled=\"true\"\n >\n <mat-icon>{{ item.icon }}</mat-icon>\n @if (drawer()) {\n <span class=\"menu-label\">\n {{ item.label }}\n <span class=\"menu-reason\">{{ disabledReasonFor(item) }}</span>\n </span>\n }\n </span>\n }\n }\n <div class=\"menu-spacer\"></div>\n @if (showConsoleToggle()) {\n <button\n class=\"menu-item terminal-toggle\"\n type=\"button\"\n [class.active]=\"terminalVisible()\"\n [matTooltip]=\"terminalVisible() ? 'Hide command console' : 'Show command console'\"\n matTooltipPosition=\"right\"\n [attr.aria-pressed]=\"terminalVisible()\"\n (click)=\"toggleTerminal()\"\n >\n <mat-icon>terminal</mat-icon>\n </button>\n }\n @if (hasShortcuts() && !drawer()) {\n <button\n class=\"help-button\"\n matTooltip=\"Keyboard shortcuts\"\n matTooltipPosition=\"right\"\n (click)=\"toggleHelp()\"\n >\n <span class=\"help-modifier\">Alt+</span><span class=\"help-key\">?</span>\n </button>\n }\n</nav>\n", styles: [".side-menu{display:flex;flex-direction:column;align-items:center;padding-top:8px;gap:4px;height:100%;background-color:var(--color-surface);border-right:1px solid var(--color-surface-border)}.menu-item{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:8px;color:var(--color-secondary-info);text-decoration:none;transition:background-color .15s,color .15s}.menu-item:hover{background-color:var(--color-surface-light);color:var(--color-interactive)}.menu-item.active,.menu-item.active:hover{background-color:var(--color-interactive, #00e5ff);color:var(--color-surface-dark, #0d1117)}.menu-item.disabled{opacity:.25;cursor:not-allowed}.menu-item.disabled:hover{background-color:transparent;color:var(--color-secondary-info)}.menu-spacer{flex:1}.menu-item.terminal-toggle{border:none;background:none;padding:0;cursor:pointer;margin-bottom:4px}.menu-item.terminal-toggle.active{color:var(--color-interactive)}.menu-item.terminal-toggle.active:hover{background-color:var(--color-interactive);color:var(--color-surface-dark)}.help-button{margin-bottom:8px;padding:4px 6px;border:1px solid var(--color-surface-border);border-radius:6px;background:none;font-size:.7rem;font-family:Roboto Mono,monospace;cursor:pointer;color:var(--color-interactive);transition:background-color .15s}.help-button:hover{background-color:var(--color-surface-light)}.help-modifier{opacity:.6}.help-key{font-weight:700}.side-menu.drawer{align-items:stretch;padding:8px;gap:2px;overflow-y:auto}.side-menu.drawer .menu-item{width:auto;height:44px;flex-shrink:0;justify-content:flex-start;gap:14px;padding:0 12px}.side-menu.drawer .menu-item.disabled{opacity:.5;height:auto;min-height:44px;padding-top:4px;padding-bottom:4px}.menu-label{display:block;min-width:0;font-size:.85rem;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.menu-reason{display:block;font-size:.7rem;white-space:normal}\n"], dependencies: [{ kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "browserUrl", "routerLink"] }, { kind: "directive", type: RouterLinkActive, selector: "[routerLinkActive]", inputs: ["routerLinkActiveOptions", "ariaCurrentWhenActive", "routerLinkActive"], outputs: ["isActiveChange"], exportAs: ["routerLinkActive"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i2.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
3425
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: SideMenu, isStandalone: true, selector: "shell-side-menu", inputs: { drawer: { classPropertyName: "drawer", publicName: "drawer", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<nav class=\"side-menu\" [class.drawer]=\"drawer()\" [attr.aria-label]=\"drawer() ? 'Menu' : null\">\n @for (item of menuItems(); track item.route) {\n @if (isEnabled(item)) {\n <a\n class=\"menu-item\"\n [routerLink]=\"item.route\"\n routerLinkActive=\"active\"\n [matTooltip]=\"item.label\"\n matTooltipPosition=\"right\"\n [matTooltipDisabled]=\"drawer()\"\n (click)=\"onMenuClick(item)\"\n >\n <mat-icon>{{ item.icon }}</mat-icon>\n @if (drawer()) {\n <span class=\"menu-label\">{{ item.label }}</span>\n }\n </a>\n } @else {\n <!-- No routerLink: the item is inert. Pointer events stay live (see the\n .disabled rule) so the tooltip still fires \u2014 it carries the only\n explanation the user gets. In the phone menu the explanation is shown\n as text instead, since there is no hover to fire it. -->\n <span\n class=\"menu-item disabled\"\n [matTooltip]=\"disabledReasonFor(item)\"\n matTooltipPosition=\"right\"\n [matTooltipDisabled]=\"drawer()\"\n aria-disabled=\"true\"\n >\n <mat-icon>{{ item.icon }}</mat-icon>\n @if (drawer()) {\n <span class=\"menu-label\">\n {{ item.label }}\n <span class=\"menu-reason\">{{ disabledReasonFor(item) }}</span>\n </span>\n }\n </span>\n }\n }\n <div class=\"menu-spacer\"></div>\n <button\n class=\"menu-item terminal-toggle\"\n type=\"button\"\n [class.active]=\"terminalVisible()\"\n [matTooltip]=\"terminalVisible() ? 'Hide command console' : 'Show command console'\"\n matTooltipPosition=\"right\"\n [matTooltipDisabled]=\"drawer()\"\n [attr.aria-pressed]=\"terminalVisible()\"\n (click)=\"toggleTerminal()\"\n >\n <mat-icon>terminal</mat-icon>\n @if (drawer()) {\n <span class=\"menu-label\">{{ terminalVisible() ? 'Hide command console' : 'Show command console' }}</span>\n }\n </button>\n @if (hasShortcuts() && !drawer()) {\n <button\n class=\"help-button\"\n matTooltip=\"Keyboard shortcuts\"\n matTooltipPosition=\"right\"\n (click)=\"toggleHelp()\"\n >\n <span class=\"help-modifier\">Alt+</span><span class=\"help-key\">?</span>\n </button>\n }\n</nav>\n", styles: [".side-menu{display:flex;flex-direction:column;align-items:center;padding-top:8px;gap:4px;height:100%;background-color:var(--color-surface);border-right:1px solid var(--color-surface-border)}.menu-item{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:8px;color:var(--color-secondary-info);text-decoration:none;transition:background-color .15s,color .15s}.menu-item:hover{background-color:var(--color-surface-light);color:var(--color-interactive)}.menu-item.active,.menu-item.active:hover{background-color:var(--color-interactive, #00e5ff);color:var(--color-surface-dark, #0d1117)}.menu-item.disabled{opacity:.25;cursor:not-allowed}.menu-item.disabled:hover{background-color:transparent;color:var(--color-secondary-info)}.menu-spacer{flex:1}.menu-item.terminal-toggle{border:none;background:none;padding:0;cursor:pointer;margin-bottom:4px}.menu-item.terminal-toggle.active{color:var(--color-interactive)}.menu-item.terminal-toggle.active:hover{background-color:var(--color-interactive);color:var(--color-surface-dark)}.help-button{margin-bottom:8px;padding:4px 6px;border:1px solid var(--color-surface-border);border-radius:6px;background:none;font-size:.7rem;font-family:Roboto Mono,monospace;cursor:pointer;color:var(--color-interactive);transition:background-color .15s}.help-button:hover{background-color:var(--color-surface-light)}.help-modifier{opacity:.6}.help-key{font-weight:700}.side-menu.drawer{align-items:stretch;padding:8px;gap:2px;overflow-y:auto}.side-menu.drawer .menu-item{width:auto;height:44px;flex-shrink:0;justify-content:flex-start;gap:14px;padding:0 12px}.side-menu.drawer .menu-item.terminal-toggle{padding:0 12px;margin-bottom:0;font:inherit;color:var(--color-secondary-info)}.side-menu.drawer .menu-item.terminal-toggle.active{color:var(--color-interactive)}.side-menu.drawer .menu-item.terminal-toggle.active:hover{background-color:var(--color-interactive);color:var(--color-surface-dark)}.side-menu.drawer .menu-item.disabled{opacity:.5;height:auto;min-height:44px;padding-top:4px;padding-bottom:4px}.menu-label{display:block;min-width:0;font-size:.85rem;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.menu-reason{display:block;font-size:.7rem;white-space:normal}\n"], dependencies: [{ kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "browserUrl", "routerLink"] }, { kind: "directive", type: RouterLinkActive, selector: "[routerLinkActive]", inputs: ["routerLinkActiveOptions", "ariaCurrentWhenActive", "routerLinkActive"], outputs: ["isActiveChange"], exportAs: ["routerLinkActive"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i2.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
3219
3426
  }
3220
3427
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: SideMenu, decorators: [{
3221
3428
  type: Component,
3222
- args: [{ selector: 'shell-side-menu', imports: [RouterLink, RouterLinkActive, MatIconModule, MatTooltipModule], changeDetection: ChangeDetectionStrategy.Eager, template: "<nav class=\"side-menu\" [class.drawer]=\"drawer()\" [attr.aria-label]=\"drawer() ? 'Menu' : null\">\n @for (item of menuItems(); track item.route) {\n @if (isEnabled(item)) {\n <a\n class=\"menu-item\"\n [routerLink]=\"item.route\"\n routerLinkActive=\"active\"\n [matTooltip]=\"item.label\"\n matTooltipPosition=\"right\"\n [matTooltipDisabled]=\"drawer()\"\n (click)=\"onMenuClick(item)\"\n >\n <mat-icon>{{ item.icon }}</mat-icon>\n @if (drawer()) {\n <span class=\"menu-label\">{{ item.label }}</span>\n }\n </a>\n } @else {\n <!-- No routerLink: the item is inert. Pointer events stay live (see the\n .disabled rule) so the tooltip still fires \u2014 it carries the only\n explanation the user gets. In the phone menu the explanation is shown\n as text instead, since there is no hover to fire it. -->\n <span\n class=\"menu-item disabled\"\n [matTooltip]=\"disabledReasonFor(item)\"\n matTooltipPosition=\"right\"\n [matTooltipDisabled]=\"drawer()\"\n aria-disabled=\"true\"\n >\n <mat-icon>{{ item.icon }}</mat-icon>\n @if (drawer()) {\n <span class=\"menu-label\">\n {{ item.label }}\n <span class=\"menu-reason\">{{ disabledReasonFor(item) }}</span>\n </span>\n }\n </span>\n }\n }\n <div class=\"menu-spacer\"></div>\n @if (showConsoleToggle()) {\n <button\n class=\"menu-item terminal-toggle\"\n type=\"button\"\n [class.active]=\"terminalVisible()\"\n [matTooltip]=\"terminalVisible() ? 'Hide command console' : 'Show command console'\"\n matTooltipPosition=\"right\"\n [attr.aria-pressed]=\"terminalVisible()\"\n (click)=\"toggleTerminal()\"\n >\n <mat-icon>terminal</mat-icon>\n </button>\n }\n @if (hasShortcuts() && !drawer()) {\n <button\n class=\"help-button\"\n matTooltip=\"Keyboard shortcuts\"\n matTooltipPosition=\"right\"\n (click)=\"toggleHelp()\"\n >\n <span class=\"help-modifier\">Alt+</span><span class=\"help-key\">?</span>\n </button>\n }\n</nav>\n", styles: [".side-menu{display:flex;flex-direction:column;align-items:center;padding-top:8px;gap:4px;height:100%;background-color:var(--color-surface);border-right:1px solid var(--color-surface-border)}.menu-item{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:8px;color:var(--color-secondary-info);text-decoration:none;transition:background-color .15s,color .15s}.menu-item:hover{background-color:var(--color-surface-light);color:var(--color-interactive)}.menu-item.active,.menu-item.active:hover{background-color:var(--color-interactive, #00e5ff);color:var(--color-surface-dark, #0d1117)}.menu-item.disabled{opacity:.25;cursor:not-allowed}.menu-item.disabled:hover{background-color:transparent;color:var(--color-secondary-info)}.menu-spacer{flex:1}.menu-item.terminal-toggle{border:none;background:none;padding:0;cursor:pointer;margin-bottom:4px}.menu-item.terminal-toggle.active{color:var(--color-interactive)}.menu-item.terminal-toggle.active:hover{background-color:var(--color-interactive);color:var(--color-surface-dark)}.help-button{margin-bottom:8px;padding:4px 6px;border:1px solid var(--color-surface-border);border-radius:6px;background:none;font-size:.7rem;font-family:Roboto Mono,monospace;cursor:pointer;color:var(--color-interactive);transition:background-color .15s}.help-button:hover{background-color:var(--color-surface-light)}.help-modifier{opacity:.6}.help-key{font-weight:700}.side-menu.drawer{align-items:stretch;padding:8px;gap:2px;overflow-y:auto}.side-menu.drawer .menu-item{width:auto;height:44px;flex-shrink:0;justify-content:flex-start;gap:14px;padding:0 12px}.side-menu.drawer .menu-item.disabled{opacity:.5;height:auto;min-height:44px;padding-top:4px;padding-bottom:4px}.menu-label{display:block;min-width:0;font-size:.85rem;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.menu-reason{display:block;font-size:.7rem;white-space:normal}\n"] }]
3429
+ args: [{ selector: 'shell-side-menu', imports: [RouterLink, RouterLinkActive, MatIconModule, MatTooltipModule], changeDetection: ChangeDetectionStrategy.Eager, template: "<nav class=\"side-menu\" [class.drawer]=\"drawer()\" [attr.aria-label]=\"drawer() ? 'Menu' : null\">\n @for (item of menuItems(); track item.route) {\n @if (isEnabled(item)) {\n <a\n class=\"menu-item\"\n [routerLink]=\"item.route\"\n routerLinkActive=\"active\"\n [matTooltip]=\"item.label\"\n matTooltipPosition=\"right\"\n [matTooltipDisabled]=\"drawer()\"\n (click)=\"onMenuClick(item)\"\n >\n <mat-icon>{{ item.icon }}</mat-icon>\n @if (drawer()) {\n <span class=\"menu-label\">{{ item.label }}</span>\n }\n </a>\n } @else {\n <!-- No routerLink: the item is inert. Pointer events stay live (see the\n .disabled rule) so the tooltip still fires \u2014 it carries the only\n explanation the user gets. In the phone menu the explanation is shown\n as text instead, since there is no hover to fire it. -->\n <span\n class=\"menu-item disabled\"\n [matTooltip]=\"disabledReasonFor(item)\"\n matTooltipPosition=\"right\"\n [matTooltipDisabled]=\"drawer()\"\n aria-disabled=\"true\"\n >\n <mat-icon>{{ item.icon }}</mat-icon>\n @if (drawer()) {\n <span class=\"menu-label\">\n {{ item.label }}\n <span class=\"menu-reason\">{{ disabledReasonFor(item) }}</span>\n </span>\n }\n </span>\n }\n }\n <div class=\"menu-spacer\"></div>\n <button\n class=\"menu-item terminal-toggle\"\n type=\"button\"\n [class.active]=\"terminalVisible()\"\n [matTooltip]=\"terminalVisible() ? 'Hide command console' : 'Show command console'\"\n matTooltipPosition=\"right\"\n [matTooltipDisabled]=\"drawer()\"\n [attr.aria-pressed]=\"terminalVisible()\"\n (click)=\"toggleTerminal()\"\n >\n <mat-icon>terminal</mat-icon>\n @if (drawer()) {\n <span class=\"menu-label\">{{ terminalVisible() ? 'Hide command console' : 'Show command console' }}</span>\n }\n </button>\n @if (hasShortcuts() && !drawer()) {\n <button\n class=\"help-button\"\n matTooltip=\"Keyboard shortcuts\"\n matTooltipPosition=\"right\"\n (click)=\"toggleHelp()\"\n >\n <span class=\"help-modifier\">Alt+</span><span class=\"help-key\">?</span>\n </button>\n }\n</nav>\n", styles: [".side-menu{display:flex;flex-direction:column;align-items:center;padding-top:8px;gap:4px;height:100%;background-color:var(--color-surface);border-right:1px solid var(--color-surface-border)}.menu-item{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:8px;color:var(--color-secondary-info);text-decoration:none;transition:background-color .15s,color .15s}.menu-item:hover{background-color:var(--color-surface-light);color:var(--color-interactive)}.menu-item.active,.menu-item.active:hover{background-color:var(--color-interactive, #00e5ff);color:var(--color-surface-dark, #0d1117)}.menu-item.disabled{opacity:.25;cursor:not-allowed}.menu-item.disabled:hover{background-color:transparent;color:var(--color-secondary-info)}.menu-spacer{flex:1}.menu-item.terminal-toggle{border:none;background:none;padding:0;cursor:pointer;margin-bottom:4px}.menu-item.terminal-toggle.active{color:var(--color-interactive)}.menu-item.terminal-toggle.active:hover{background-color:var(--color-interactive);color:var(--color-surface-dark)}.help-button{margin-bottom:8px;padding:4px 6px;border:1px solid var(--color-surface-border);border-radius:6px;background:none;font-size:.7rem;font-family:Roboto Mono,monospace;cursor:pointer;color:var(--color-interactive);transition:background-color .15s}.help-button:hover{background-color:var(--color-surface-light)}.help-modifier{opacity:.6}.help-key{font-weight:700}.side-menu.drawer{align-items:stretch;padding:8px;gap:2px;overflow-y:auto}.side-menu.drawer .menu-item{width:auto;height:44px;flex-shrink:0;justify-content:flex-start;gap:14px;padding:0 12px}.side-menu.drawer .menu-item.terminal-toggle{padding:0 12px;margin-bottom:0;font:inherit;color:var(--color-secondary-info)}.side-menu.drawer .menu-item.terminal-toggle.active{color:var(--color-interactive)}.side-menu.drawer .menu-item.terminal-toggle.active:hover{background-color:var(--color-interactive);color:var(--color-surface-dark)}.side-menu.drawer .menu-item.disabled{opacity:.5;height:auto;min-height:44px;padding-top:4px;padding-bottom:4px}.menu-label{display:block;min-width:0;font-size:.85rem;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.menu-reason{display:block;font-size:.7rem;white-space:normal}\n"] }]
3223
3430
  }], propDecorators: { drawer: [{ type: i0.Input, args: [{ isSignal: true, alias: "drawer", required: false }] }] } });
3224
3431
 
3225
3432
  class MainContent {
@@ -3261,11 +3468,11 @@ class CommandTerminal {
3261
3468
  /**
3262
3469
  * Whether the command box takes the cursor when the console first appears.
3263
3470
  *
3264
- * True for a standalone console and for the console showing at start-up or
3265
- * after the user turns it back on. The shell passes false when the console
3266
- * appears because the screen widened past 820px (a tablet or phone rotated to
3267
- * landscape): a layout change is not the user asking to type, and taking the
3268
- * cursor there would pull it out of whatever they were doing.
3471
+ * True for a standalone console. The shell passes false on a phone (below
3472
+ * 820px), including when the user turns the console on from the ☰ menu:
3473
+ * showing the console is not a request to type, and focusing the box would
3474
+ * raise the on-screen keyboard over it (scanner-phone-display D10). On a
3475
+ * phone, focusInput() refuses anyway; this keeps the intent explicit.
3269
3476
  */
3270
3477
  this.focusOnAppear = input(true, /* @ts-ignore */
3271
3478
  ...(ngDevMode ? [{ debugName: "focusOnAppear" }] : /* istanbul ignore next */ []));
@@ -3372,9 +3579,11 @@ class CommandTerminal {
3372
3579
  return '';
3373
3580
  }
3374
3581
  /**
3375
- * ⚠ A no-op on a phone (scanner-phone-display E11). The shell does not render
3376
- * the console there at all; this guard covers an app that mounts the console
3377
- * itself, so nothing on a phone can raise the on-screen keyboard unasked.
3582
+ * ⚠ A no-op on a phone (scanner-phone-display D10/E11). The console is shown
3583
+ * there, but only the user tapping the box may give it the cursor; every
3584
+ * programmatic path (first appearance, a finished command, a focus request,
3585
+ * an app calling this) ends here and is refused, so nothing on a phone can
3586
+ * raise the on-screen keyboard unasked.
3378
3587
  */
3379
3588
  focusInput() {
3380
3589
  if (this.#phoneLayout.isPhone())
@@ -3382,7 +3591,7 @@ class CommandTerminal {
3382
3591
  this.inputEl()?.nativeElement.focus();
3383
3592
  }
3384
3593
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: CommandTerminal, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
3385
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: CommandTerminal, isStandalone: true, selector: "shell-command-terminal", inputs: { focusOnAppear: { classPropertyName: "focusOnAppear", publicName: "focusOnAppear", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "inputEl", first: true, predicate: ["terminalInput"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"terminal\">\n <div class=\"terminal-input-row\">\n <span class=\"prompt\">$</span>\n <div class=\"input-wrapper\">\n @if (ghostText()) {\n <input\n class=\"ghost-input\"\n type=\"text\"\n [value]=\"ghostText()\"\n disabled\n tabindex=\"-1\"\n aria-hidden=\"true\"\n />\n }\n <input\n #terminalInput\n class=\"terminal-input\"\n type=\"text\"\n [ngModel]=\"currentInput()\"\n (ngModelChange)=\"onInputChange($event)\"\n (keydown.enter)=\"onSubmit()\"\n (keydown.tab)=\"onTab($any($event))\"\n (keydown.escape)=\"dismissHelp()\"\n placeholder=\"Type a command...\"\n [disabled]=\"isProcessing()\"\n autocomplete=\"off\"\n spellcheck=\"false\"\n />\n </div>\n </div>\n <div class=\"terminal-output\">\n @for (entry of history(); track entry.result.timestamp) {\n @if (entry.input) {\n <div class=\"terminal-line\">\n <span class=\"prompt\">$</span>\n <span class=\"command-text\">{{ entry.input }}@if ((entry.repeatCount ?? 1) > 1) { <span class=\"repeat-count\">(\u00D7{{ entry.repeatCount }})</span>}</span>\n </div>\n }\n @if (entry.result.message) {\n <div class=\"terminal-line\" [class]=\"getSeverityClass(entry.result)\">{{ entry.result.message }}</div>\n }\n }\n </div>\n</div>\n", styles: [".terminal{display:flex;flex-direction:column;height:100%;background-color:var(--color-terminal-bg);border-top:1px solid var(--color-surface-border);font-family:Roboto Mono,Courier New,monospace;font-size:.85rem}.terminal-output{flex:1;overflow-y:auto;padding:8px 12px;display:flex;flex-direction:column}.terminal-line{line-height:1.6;color:var(--color-text-primary);white-space:pre-wrap}.terminal-line.error{color:var(--color-loss)}.terminal-line.warning{color:var(--color-warning)}.terminal-line.info{color:var(--color-info)}.prompt{color:var(--color-terminal-prompt);margin-right:8px;font-weight:700}.command-text{color:var(--color-terminal-text)}.repeat-count{color:var(--color-secondary-info);opacity:.6;margin-left:4px}.terminal-input-row{display:flex;align-items:center;padding:8px 12px;border-bottom:1px solid var(--color-surface-border)}.input-wrapper{flex:1;display:grid}.ghost-input,.terminal-input{grid-area:1 / 1;font-family:inherit;font-size:inherit;line-height:normal;margin:0;padding:0;border:none;background:transparent;outline:none;-webkit-appearance:none;appearance:none;width:100%}.ghost-input{color:var(--color-secondary-info);opacity:.3;pointer-events:none}.terminal-input{color:var(--color-terminal-text);caret-color:var(--color-terminal-prompt)}.terminal-input::placeholder{color:var(--color-secondary-info);opacity:.5}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i3.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: i3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
3594
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: CommandTerminal, isStandalone: true, selector: "shell-command-terminal", inputs: { focusOnAppear: { classPropertyName: "focusOnAppear", publicName: "focusOnAppear", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "inputEl", first: true, predicate: ["terminalInput"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"terminal\">\n <div class=\"terminal-input-row\">\n <span class=\"prompt\">$</span>\n <div class=\"input-wrapper\">\n @if (ghostText()) {\n <input\n class=\"ghost-input\"\n type=\"text\"\n [value]=\"ghostText()\"\n disabled\n tabindex=\"-1\"\n aria-hidden=\"true\"\n />\n }\n <input\n #terminalInput\n class=\"terminal-input\"\n type=\"text\"\n [ngModel]=\"currentInput()\"\n (ngModelChange)=\"onInputChange($event)\"\n (keydown.enter)=\"onSubmit()\"\n (keydown.tab)=\"onTab($any($event))\"\n (keydown.escape)=\"dismissHelp()\"\n placeholder=\"Type a command...\"\n [disabled]=\"isProcessing()\"\n autocomplete=\"off\"\n spellcheck=\"false\"\n />\n </div>\n </div>\n <div class=\"terminal-output\">\n @for (entry of history(); track entry.result.timestamp) {\n @if (entry.input) {\n <div class=\"terminal-line\">\n <span class=\"prompt\">$</span>\n <span class=\"command-text\">{{ entry.input }}@if ((entry.repeatCount ?? 1) > 1) { <span class=\"repeat-count\">(\u00D7{{ entry.repeatCount }})</span>}</span>\n </div>\n }\n @if (entry.result.message) {\n <div class=\"terminal-line\" [class]=\"getSeverityClass(entry.result)\">{{ entry.result.message }}</div>\n }\n }\n </div>\n</div>\n", styles: [".terminal{display:flex;flex-direction:column;height:100%;background-color:var(--color-terminal-bg);border-top:1px solid var(--color-surface-border);font-family:Roboto Mono,Courier New,monospace;font-size:.85rem}.terminal-output{flex:1;overflow-y:auto;padding:8px 12px;display:flex;flex-direction:column}.terminal-line{line-height:1.6;color:var(--color-text-primary);white-space:pre-wrap}.terminal-line.error{color:var(--color-loss)}.terminal-line.warning{color:var(--color-warning)}.terminal-line.info{color:var(--color-info)}.prompt{color:var(--color-terminal-prompt);margin-right:8px;font-weight:700}.command-text{color:var(--color-terminal-text)}.repeat-count{color:var(--color-secondary-info);opacity:.6;margin-left:4px}.terminal-input-row{display:flex;align-items:center;padding:8px 12px;border-bottom:1px solid var(--color-surface-border)}.input-wrapper{flex:1;display:grid}.ghost-input,.terminal-input{grid-area:1 / 1;font-family:inherit;font-size:inherit;line-height:normal;margin:0;padding:0;border:none;background:transparent;outline:none;-webkit-appearance:none;appearance:none;width:100%}.ghost-input{color:var(--color-secondary-info);opacity:.3;pointer-events:none}.terminal-input{color:var(--color-terminal-text);caret-color:var(--color-terminal-prompt)}.terminal-input::placeholder{color:var(--color-secondary-info);opacity:.5}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { 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.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
3386
3595
  }
3387
3596
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: CommandTerminal, decorators: [{
3388
3597
  type: Component,
@@ -3392,6 +3601,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
3392
3601
  const MIN_TERMINAL_HEIGHT = 80;
3393
3602
  const MAX_TERMINAL_RATIO = 0.6;
3394
3603
  const DEFAULT_TERMINAL_HEIGHT = 160;
3604
+ /**
3605
+ * How often a signed-in window re-reads whether the AWS-set bypass is on
3606
+ * (authenticator-sign-in.prd.md D12). The server never says when it ends, so the
3607
+ * banner goes away on the first read after it has.
3608
+ */
3609
+ const BYPASS_RECHECK_MS = 2 * 60 * 1000;
3395
3610
  class Shell {
3396
3611
  constructor() {
3397
3612
  this.#thinkingService = inject(ThinkingService);
@@ -3407,37 +3622,38 @@ class Shell {
3407
3622
  this.isPhone = this.#phoneLayout.isPhone;
3408
3623
  this.phoneMenuOpen = this.#phoneLayout.menuOpen;
3409
3624
  /**
3410
- * Whether the console (command box, output, and the resize bar above it) is
3411
- * shown, and whether it takes the cursor when it appears.
3412
- *
3413
- * `show`: never on a phone — Franz, 2026-10-16 (scanner-phone-display E11).
3414
- * The user's show/hide choice is kept untouched, so rotating to a width of
3415
- * 820px or more brings the console back exactly as it was left. Commands do
3416
- * not depend on the console: `CommandService.execute` runs from any button
3417
- * whether or not it is on screen.
3625
+ * Whether the command box takes the cursor when the console appears: on a
3626
+ * desktop yes, on a phone never (scanner-phone-display D10, Franz 2026-10-17).
3418
3627
  *
3419
- * `focusOnAppear`: false only when the console is appearing because the
3420
- * screen widened past 820px (see CommandTerminal.focusOnAppear); true at
3421
- * start-up and after the user turns the console on.
3628
+ * The console is shown on a phone again (reversing 0.8.1), but nothing may
3629
+ * put the cursor in its box there — the on-screen keyboard would cover the
3630
+ * screen. That includes the user turning the console ON from the ☰ menu: a
3631
+ * show is not a request to type, and the keyboard rising over the console the
3632
+ * moment it appears would hide the output the user wanted to see. Tapping the
3633
+ * box is the only way it gets the cursor on a phone.
3422
3634
  *
3423
- * ⚠ ONE SIGNAL, READ UNCONDITIONALLY BY THE TEMPLATE (`[class.terminal-hidden]`),
3424
- * and that is load-bearing. The focus decision needs the PREVIOUS layout to
3425
- * tell "widened from a phone" apart from "turned on". A linkedSignal only sees
3426
- * the state as of its last read, so a value read only inside the console's
3427
- * `@if` — never read while on a phone — would miss the phone state entirely
3428
- * and grab the cursor on rotation.
3635
+ * Rotating across 820px never mounts or unmounts the console (its visibility
3636
+ * is the user's choice alone), so this is only read when the user turns it on
3637
+ * or at start-up. CommandTerminal.focusInput also refuses on a phone, as a
3638
+ * second guard.
3429
3639
  */
3430
- this.consoleState = linkedSignal({ ...(ngDevMode ? { debugName: "consoleState" } : /* istanbul ignore next */ {}), source: () => ({ visible: this.terminalVisible(), phone: this.isPhone() }),
3431
- computation: (now, previous) => {
3432
- const show = now.visible && !now.phone;
3433
- if (!previous)
3434
- return { show, focusOnAppear: true };
3435
- if (previous.source.visible !== now.visible)
3436
- return { show, focusOnAppear: true };
3437
- if (previous.source.phone && !now.phone)
3438
- return { show, focusOnAppear: false };
3439
- return { show, focusOnAppear: previous.value.focusOnAppear };
3440
- } });
3640
+ this.consoleFocusOnAppear = computed(() => !this.isPhone(), /* @ts-ignore */
3641
+ ...(ngDevMode ? [{ debugName: "consoleFocusOnAppear" }] : /* istanbul ignore next */ []));
3642
+ /** "Sign-in code check is off." on every screen while the bypass is on (D12). */
3643
+ this.bypassActive = this.#authService.bypassActive;
3644
+ /**
3645
+ * Keeps `bypassActive` current while signed in: once now (a window restored from
3646
+ * storage never saw the sign-in response) and then every two minutes.
3647
+ */
3648
+ this.#bypassWatch = effect(onCleanup => {
3649
+ if (!this.isAuthenticated())
3650
+ return;
3651
+ const read = () => { this.#authService.authenticatorStatus().catch(() => undefined); };
3652
+ read();
3653
+ const timer = setInterval(read, BYPASS_RECHECK_MS);
3654
+ onCleanup(() => clearInterval(timer));
3655
+ }, /* @ts-ignore */
3656
+ ...(ngDevMode ? [{ debugName: "#bypassWatch" }] : /* istanbul ignore next */ []));
3441
3657
  this.terminalHeight = signal(`${DEFAULT_TERMINAL_HEIGHT}px`, /* @ts-ignore */
3442
3658
  ...(ngDevMode ? [{ debugName: "terminalHeight" }] : /* istanbul ignore next */ []));
3443
3659
  this.isDragging = signal(false, /* @ts-ignore */
@@ -3450,9 +3666,8 @@ class Shell {
3450
3666
  document.removeEventListener('mousemove', this.#onMouseMove);
3451
3667
  document.removeEventListener('mouseup', this.#onMouseUp);
3452
3668
  };
3453
- // Touch drag for the resize bar (scanner-phone-display E12). Only reachable
3454
- // at 820px and wider — a tablet — since the bar is not rendered on a phone.
3455
- // Kept separate from the mouse path so the desktop's behaviour is unchanged;
3669
+ // Touch drag for the resize bar (scanner-phone-display E12): tablets and,
3670
+ // since the console is back there (D10, 2026-10-17), phones. Kept separate from the mouse path so the desktop's behaviour is unchanged;
3456
3671
  // the bar's `touch-action: none` stops the page scrolling under the finger.
3457
3672
  this.#onTouchMove = (event) => {
3458
3673
  const touch = event.touches[0];
@@ -3471,6 +3686,11 @@ class Shell {
3471
3686
  #authService;
3472
3687
  #terminalVisibility;
3473
3688
  #phoneLayout;
3689
+ /**
3690
+ * Keeps `bypassActive` current while signed in: once now (a window restored from
3691
+ * storage never saw the sign-in response) and then every two minutes.
3692
+ */
3693
+ #bypassWatch;
3474
3694
  closePhoneMenu() {
3475
3695
  this.#phoneLayout.closeMenu();
3476
3696
  }
@@ -3495,9 +3715,8 @@ class Shell {
3495
3715
  document.addEventListener('mousemove', this.#onMouseMove);
3496
3716
  document.addEventListener('mouseup', this.#onMouseUp);
3497
3717
  }
3498
- // Touch drag for the resize bar (scanner-phone-display E12). Only reachable
3499
- // at 820px and wider — a tablet — since the bar is not rendered on a phone.
3500
- // Kept separate from the mouse path so the desktop's behaviour is unchanged;
3718
+ // Touch drag for the resize bar (scanner-phone-display E12): tablets and,
3719
+ // since the console is back there (D10, 2026-10-17), phones. Kept separate from the mouse path so the desktop's behaviour is unchanged;
3501
3720
  // the bar's `touch-action: none` stops the page scrolling under the finger.
3502
3721
  #onTouchMove;
3503
3722
  #onTouchEnd;
@@ -3513,13 +3732,97 @@ class Shell {
3513
3732
  document.addEventListener('touchcancel', this.#onTouchEnd);
3514
3733
  }
3515
3734
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: Shell, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
3516
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: Shell, isStandalone: true, selector: "shell-root", ngImport: i0, template: "<div class=\"shell\" [style.--terminal-height]=\"terminalHeight()\" [class.is-dragging]=\"isDragging()\" [class.terminal-hidden]=\"!consoleState().show\">\n <shell-status-bar class=\"shell-status\" [class.disabled-zone]=\"isThinking()\">\n <ng-content />\n </shell-status-bar>\n @if (isAuthenticated() && !isPhone()) {\n <shell-side-menu class=\"shell-menu\" [class.disabled-zone]=\"isThinking()\" />\n }\n <shell-main-content class=\"shell-main\" [class.full-width]=\"!isAuthenticated()\" />\n @if (consoleState().show) {\n <!-- Never on a phone (scanner-phone-display E11): see Shell.consoleState. -->\n <div class=\"shell-resize\" [class.dragging]=\"isDragging()\" (mousedown)=\"onResizeStart($event)\" (touchstart)=\"onResizeTouchStart($event)\"></div>\n <shell-command-terminal class=\"shell-terminal\" [class.disabled-zone]=\"isThinking()\" [focusOnAppear]=\"consoleState().focusOnAppear\" />\n }\n @if (isAuthenticated() && phoneMenuOpen()) {\n <!-- Phone menu (scanner-phone-display D9): the icon column's items, opened from the \u2630 button. -->\n <div class=\"phone-menu-backdrop\" (click)=\"closePhoneMenu()\"></div>\n <shell-side-menu\n id=\"shell-phone-menu\"\n class=\"phone-menu\"\n [class.disabled-zone]=\"isThinking()\"\n [drawer]=\"true\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label=\"Menu\"\n cdkTrapFocus\n [cdkTrapFocusAutoCapture]=\"true\"\n (keydown.escape)=\"closePhoneMenu()\"\n />\n }\n <faq-modal />\n\n @if (helpOverlay(); as helpText) {\n <div class=\"help-backdrop\" (click)=\"dismissHelp()\">\n <div class=\"help-panel\" (click)=\"$event.stopPropagation()\" role=\"dialog\" aria-label=\"Command help\">\n <div class=\"help-panel-header\">\n <span class=\"help-panel-title\">Help</span>\n <button class=\"help-panel-close\" type=\"button\" (click)=\"dismissHelp()\" aria-label=\"Close help\">\u2715</button>\n </div>\n <div class=\"help-panel-content\">{{ helpText }}</div>\n <div class=\"help-panel-footer\">\n <span>Tip: for most commands you can swap the first and second words \u2014 e.g. \"disable user\" works the same as \"user disable\"</span>\n <span class=\"help-panel-footer-dismiss\">Press Escape, click outside, or start typing to close</span>\n </div>\n </div>\n </div>\n }\n</div>\n", styles: [".shell{position:relative;display:grid;grid-template-areas:\"status status\" \"menu main\" \"resize resize\" \"terminal terminal\";grid-template-columns:56px 1fr;grid-template-rows:48px 1fr 4px var(--terminal-height, 160px);height:100vh;height:100dvh;width:100vw;overflow:hidden;background-color:var(--color-surface-dark)}.shell.terminal-hidden{grid-template-rows:48px 1fr 0 0}.shell-status{grid-area:status}.shell-menu{grid-area:menu}.shell-main{grid-area:main;overflow:hidden}.shell-main.full-width{grid-column:1 / -1}.shell-resize{grid-area:resize;cursor:row-resize;touch-action:none;background-color:var(--color-surface-border);transition:background-color .15s ease}.shell-resize:hover,.shell-resize.dragging{background-color:var(--color-interactive)}.shell-terminal{grid-area:terminal}.shell.is-dragging{user-select:none;-webkit-user-select:none}.disabled-zone{pointer-events:none;opacity:.4;transition:opacity .2s ease}.help-backdrop{position:absolute;inset:0;z-index:100;background-color:#000000a6;display:flex;align-items:center;justify-content:center}.help-panel{background-color:var(--color-surface, #161b22);border:2px solid var(--color-interactive, #00e5ff);border-radius:4px;width:70%;max-width:820px;max-height:75vh;display:flex;flex-direction:column;font-family:Roboto Mono,Courier New,monospace;font-size:.85rem}.help-panel-header{display:flex;justify-content:space-between;align-items:center;padding:8px 16px;background-color:var(--color-surface-light, #21262d);border-bottom:1px solid var(--color-interactive, #00e5ff);border-radius:2px 2px 0 0;flex-shrink:0}.help-panel-title{color:var(--color-interactive, #00e5ff);font-weight:700;letter-spacing:.05em;text-transform:uppercase;font-size:.75rem}.help-panel-close{background:none;border:1px solid var(--color-surface-border, #30363d);color:var(--color-secondary-info, #9e9e9e);cursor:pointer;padding:2px 8px;font-size:.9rem;line-height:1.4;border-radius:3px;transition:color .15s ease,border-color .15s ease}.help-panel-close:hover{color:var(--color-text-primary, #ffffff);border-color:var(--color-interactive, #00e5ff)}.help-panel-content{flex:1;overflow-y:auto;padding:16px 20px;color:var(--color-text-primary, #ffffff);white-space:pre-wrap;line-height:1.7}.help-panel-footer{padding:6px 16px;background-color:var(--color-surface-light, #21262d);border-top:1px solid var(--color-surface-border, #30363d);border-radius:0 0 2px 2px;color:var(--color-secondary-info, #9e9e9e);font-size:.75rem;flex-shrink:0;display:flex;flex-direction:column;gap:2px}.help-panel-footer-dismiss{opacity:.6}@media(max-width:819.98px){.shell{grid-template-areas:\"status\" \"main\" \"resize\" \"terminal\";grid-template-columns:minmax(0,1fr)}.shell-menu{display:none}}.phone-menu-backdrop{position:absolute;inset:48px 0 0;z-index:50;background-color:color-mix(in srgb,var(--color-surface-dark) 70%,transparent)}.phone-menu{position:absolute;top:48px;bottom:0;left:0;z-index:51;display:block;width:min(280px,85vw);box-shadow:4px 0 16px color-mix(in srgb,var(--color-surface-dark) 60%,transparent)}\n"], dependencies: [{ kind: "component", type: StatusBar, selector: "shell-status-bar" }, { kind: "component", type: SideMenu, selector: "shell-side-menu", inputs: ["drawer"] }, { kind: "component", type: MainContent, selector: "shell-main-content" }, { kind: "component", type: CommandTerminal, selector: "shell-command-terminal", inputs: ["focusOnAppear"] }, { kind: "component", type: FaqModal, selector: "faq-modal" }, { kind: "directive", type: CdkTrapFocus, selector: "[cdkTrapFocus]", inputs: ["cdkTrapFocus", "cdkTrapFocusAutoCapture"], exportAs: ["cdkTrapFocus"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
3735
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: Shell, isStandalone: true, selector: "shell-root", ngImport: i0, template: "<div class=\"shell\" [style.--terminal-height]=\"terminalHeight()\" [class.is-dragging]=\"isDragging()\" [class.terminal-hidden]=\"!terminalVisible()\">\n <shell-status-bar class=\"shell-status\" [class.disabled-zone]=\"isThinking()\">\n <ng-content />\n </shell-status-bar>\n @if (isAuthenticated() && !isPhone()) {\n <shell-side-menu class=\"shell-menu\" [class.disabled-zone]=\"isThinking()\" />\n }\n <shell-main-content class=\"shell-main\" [class.full-width]=\"!isAuthenticated()\" />\n @if (terminalVisible()) {\n <!-- On a phone too (scanner-phone-display D10), but never given the cursor there: see Shell.consoleFocusOnAppear. -->\n <div class=\"shell-resize\" [class.dragging]=\"isDragging()\" (mousedown)=\"onResizeStart($event)\" (touchstart)=\"onResizeTouchStart($event)\"></div>\n <shell-command-terminal class=\"shell-terminal\" [class.disabled-zone]=\"isThinking()\" [focusOnAppear]=\"consoleFocusOnAppear()\" />\n }\n @if (isAuthenticated() && phoneMenuOpen()) {\n <!-- Phone menu (scanner-phone-display D9): the icon column's items, opened from the \u2630 button. -->\n <div class=\"phone-menu-backdrop\" (click)=\"closePhoneMenu()\"></div>\n <shell-side-menu\n id=\"shell-phone-menu\"\n class=\"phone-menu\"\n [class.disabled-zone]=\"isThinking()\"\n [drawer]=\"true\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label=\"Menu\"\n cdkTrapFocus\n [cdkTrapFocusAutoCapture]=\"true\"\n (keydown.escape)=\"closePhoneMenu()\"\n />\n }\n @if (isAuthenticated() && bypassActive()) {\n <!-- authenticator-sign-in.prd.md D12: cannot be dismissed; no end time (the server never sends one). -->\n <div class=\"bypass-banner\" role=\"status\">Sign-in code check is off.</div>\n }\n <faq-modal />\n\n @if (helpOverlay(); as helpText) {\n <div class=\"help-backdrop\" (click)=\"dismissHelp()\">\n <div class=\"help-panel\" (click)=\"$event.stopPropagation()\" role=\"dialog\" aria-label=\"Command help\">\n <div class=\"help-panel-header\">\n <span class=\"help-panel-title\">Help</span>\n <button class=\"help-panel-close\" type=\"button\" (click)=\"dismissHelp()\" aria-label=\"Close help\">\u2715</button>\n </div>\n <div class=\"help-panel-content\">{{ helpText }}</div>\n <div class=\"help-panel-footer\">\n <span>Tip: for most commands you can swap the first and second words \u2014 e.g. \"disable user\" works the same as \"user disable\"</span>\n <span class=\"help-panel-footer-dismiss\">Press Escape, click outside, or start typing to close</span>\n </div>\n </div>\n </div>\n }\n</div>\n", styles: [".shell{position:relative;display:grid;grid-template-areas:\"status status\" \"menu main\" \"resize resize\" \"terminal terminal\";grid-template-columns:56px 1fr;grid-template-rows:48px 1fr 4px var(--terminal-height, 160px);height:100vh;height:100dvh;width:100vw;overflow:hidden;background-color:var(--color-surface-dark)}.shell.terminal-hidden{grid-template-rows:48px 1fr 0 0}.shell-status{grid-area:status}.shell-menu{grid-area:menu}.shell-main{grid-area:main;overflow:hidden}.shell-main.full-width{grid-column:1 / -1}.shell-resize{grid-area:resize;cursor:row-resize;touch-action:none;background-color:var(--color-surface-border);transition:background-color .15s ease}.shell-resize:hover,.shell-resize.dragging{background-color:var(--color-interactive)}.shell-terminal{grid-area:terminal}.shell.is-dragging{user-select:none;-webkit-user-select:none}.disabled-zone{pointer-events:none;opacity:.4;transition:opacity .2s ease}.help-backdrop{position:absolute;inset:0;z-index:100;background-color:#000000a6;display:flex;align-items:center;justify-content:center}.help-panel{background-color:var(--color-surface, #161b22);border:2px solid var(--color-interactive, #00e5ff);border-radius:4px;width:70%;max-width:820px;max-height:75vh;display:flex;flex-direction:column;font-family:Roboto Mono,Courier New,monospace;font-size:.85rem}.help-panel-header{display:flex;justify-content:space-between;align-items:center;padding:8px 16px;background-color:var(--color-surface-light, #21262d);border-bottom:1px solid var(--color-interactive, #00e5ff);border-radius:2px 2px 0 0;flex-shrink:0}.help-panel-title{color:var(--color-interactive, #00e5ff);font-weight:700;letter-spacing:.05em;text-transform:uppercase;font-size:.75rem}.help-panel-close{background:none;border:1px solid var(--color-surface-border, #30363d);color:var(--color-secondary-info, #9e9e9e);cursor:pointer;padding:2px 8px;font-size:.9rem;line-height:1.4;border-radius:3px;transition:color .15s ease,border-color .15s ease}.help-panel-close:hover{color:var(--color-text-primary, #ffffff);border-color:var(--color-interactive, #00e5ff)}.help-panel-content{flex:1;overflow-y:auto;padding:16px 20px;color:var(--color-text-primary, #ffffff);white-space:pre-wrap;line-height:1.7}.help-panel-footer{padding:6px 16px;background-color:var(--color-surface-light, #21262d);border-top:1px solid var(--color-surface-border, #30363d);border-radius:0 0 2px 2px;color:var(--color-secondary-info, #9e9e9e);font-size:.75rem;flex-shrink:0;display:flex;flex-direction:column;gap:2px}.help-panel-footer-dismiss{opacity:.6}@media(max-width:819.98px){.shell{grid-template-areas:\"status\" \"main\" \"resize\" \"terminal\";grid-template-columns:minmax(0,1fr)}.shell-menu{display:none}}.phone-menu-backdrop{position:absolute;inset:48px 0 0;z-index:50;background-color:color-mix(in srgb,var(--color-surface-dark) 70%,transparent)}.phone-menu{position:absolute;top:48px;bottom:0;left:0;z-index:51;display:block;width:min(280px,85vw);box-shadow:4px 0 16px color-mix(in srgb,var(--color-surface-dark) 60%,transparent)}.bypass-banner{position:fixed;top:52px;right:12px;z-index:1000;padding:6px 12px;border:2px solid var(--color-loss);border-radius:4px;background-color:var(--color-surface);color:var(--color-loss);font-weight:600;pointer-events:none}\n"], dependencies: [{ kind: "component", type: StatusBar, selector: "shell-status-bar" }, { kind: "component", type: SideMenu, selector: "shell-side-menu", inputs: ["drawer"] }, { kind: "component", type: MainContent, selector: "shell-main-content" }, { kind: "component", type: CommandTerminal, selector: "shell-command-terminal", inputs: ["focusOnAppear"] }, { kind: "component", type: FaqModal, selector: "faq-modal" }, { kind: "directive", type: CdkTrapFocus, selector: "[cdkTrapFocus]", inputs: ["cdkTrapFocus", "cdkTrapFocusAutoCapture"], exportAs: ["cdkTrapFocus"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
3517
3736
  }
3518
3737
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: Shell, decorators: [{
3519
3738
  type: Component,
3520
- args: [{ selector: 'shell-root', imports: [StatusBar, SideMenu, MainContent, CommandTerminal, FaqModal, CdkTrapFocus], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"shell\" [style.--terminal-height]=\"terminalHeight()\" [class.is-dragging]=\"isDragging()\" [class.terminal-hidden]=\"!consoleState().show\">\n <shell-status-bar class=\"shell-status\" [class.disabled-zone]=\"isThinking()\">\n <ng-content />\n </shell-status-bar>\n @if (isAuthenticated() && !isPhone()) {\n <shell-side-menu class=\"shell-menu\" [class.disabled-zone]=\"isThinking()\" />\n }\n <shell-main-content class=\"shell-main\" [class.full-width]=\"!isAuthenticated()\" />\n @if (consoleState().show) {\n <!-- Never on a phone (scanner-phone-display E11): see Shell.consoleState. -->\n <div class=\"shell-resize\" [class.dragging]=\"isDragging()\" (mousedown)=\"onResizeStart($event)\" (touchstart)=\"onResizeTouchStart($event)\"></div>\n <shell-command-terminal class=\"shell-terminal\" [class.disabled-zone]=\"isThinking()\" [focusOnAppear]=\"consoleState().focusOnAppear\" />\n }\n @if (isAuthenticated() && phoneMenuOpen()) {\n <!-- Phone menu (scanner-phone-display D9): the icon column's items, opened from the \u2630 button. -->\n <div class=\"phone-menu-backdrop\" (click)=\"closePhoneMenu()\"></div>\n <shell-side-menu\n id=\"shell-phone-menu\"\n class=\"phone-menu\"\n [class.disabled-zone]=\"isThinking()\"\n [drawer]=\"true\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label=\"Menu\"\n cdkTrapFocus\n [cdkTrapFocusAutoCapture]=\"true\"\n (keydown.escape)=\"closePhoneMenu()\"\n />\n }\n <faq-modal />\n\n @if (helpOverlay(); as helpText) {\n <div class=\"help-backdrop\" (click)=\"dismissHelp()\">\n <div class=\"help-panel\" (click)=\"$event.stopPropagation()\" role=\"dialog\" aria-label=\"Command help\">\n <div class=\"help-panel-header\">\n <span class=\"help-panel-title\">Help</span>\n <button class=\"help-panel-close\" type=\"button\" (click)=\"dismissHelp()\" aria-label=\"Close help\">\u2715</button>\n </div>\n <div class=\"help-panel-content\">{{ helpText }}</div>\n <div class=\"help-panel-footer\">\n <span>Tip: for most commands you can swap the first and second words \u2014 e.g. \"disable user\" works the same as \"user disable\"</span>\n <span class=\"help-panel-footer-dismiss\">Press Escape, click outside, or start typing to close</span>\n </div>\n </div>\n </div>\n }\n</div>\n", styles: [".shell{position:relative;display:grid;grid-template-areas:\"status status\" \"menu main\" \"resize resize\" \"terminal terminal\";grid-template-columns:56px 1fr;grid-template-rows:48px 1fr 4px var(--terminal-height, 160px);height:100vh;height:100dvh;width:100vw;overflow:hidden;background-color:var(--color-surface-dark)}.shell.terminal-hidden{grid-template-rows:48px 1fr 0 0}.shell-status{grid-area:status}.shell-menu{grid-area:menu}.shell-main{grid-area:main;overflow:hidden}.shell-main.full-width{grid-column:1 / -1}.shell-resize{grid-area:resize;cursor:row-resize;touch-action:none;background-color:var(--color-surface-border);transition:background-color .15s ease}.shell-resize:hover,.shell-resize.dragging{background-color:var(--color-interactive)}.shell-terminal{grid-area:terminal}.shell.is-dragging{user-select:none;-webkit-user-select:none}.disabled-zone{pointer-events:none;opacity:.4;transition:opacity .2s ease}.help-backdrop{position:absolute;inset:0;z-index:100;background-color:#000000a6;display:flex;align-items:center;justify-content:center}.help-panel{background-color:var(--color-surface, #161b22);border:2px solid var(--color-interactive, #00e5ff);border-radius:4px;width:70%;max-width:820px;max-height:75vh;display:flex;flex-direction:column;font-family:Roboto Mono,Courier New,monospace;font-size:.85rem}.help-panel-header{display:flex;justify-content:space-between;align-items:center;padding:8px 16px;background-color:var(--color-surface-light, #21262d);border-bottom:1px solid var(--color-interactive, #00e5ff);border-radius:2px 2px 0 0;flex-shrink:0}.help-panel-title{color:var(--color-interactive, #00e5ff);font-weight:700;letter-spacing:.05em;text-transform:uppercase;font-size:.75rem}.help-panel-close{background:none;border:1px solid var(--color-surface-border, #30363d);color:var(--color-secondary-info, #9e9e9e);cursor:pointer;padding:2px 8px;font-size:.9rem;line-height:1.4;border-radius:3px;transition:color .15s ease,border-color .15s ease}.help-panel-close:hover{color:var(--color-text-primary, #ffffff);border-color:var(--color-interactive, #00e5ff)}.help-panel-content{flex:1;overflow-y:auto;padding:16px 20px;color:var(--color-text-primary, #ffffff);white-space:pre-wrap;line-height:1.7}.help-panel-footer{padding:6px 16px;background-color:var(--color-surface-light, #21262d);border-top:1px solid var(--color-surface-border, #30363d);border-radius:0 0 2px 2px;color:var(--color-secondary-info, #9e9e9e);font-size:.75rem;flex-shrink:0;display:flex;flex-direction:column;gap:2px}.help-panel-footer-dismiss{opacity:.6}@media(max-width:819.98px){.shell{grid-template-areas:\"status\" \"main\" \"resize\" \"terminal\";grid-template-columns:minmax(0,1fr)}.shell-menu{display:none}}.phone-menu-backdrop{position:absolute;inset:48px 0 0;z-index:50;background-color:color-mix(in srgb,var(--color-surface-dark) 70%,transparent)}.phone-menu{position:absolute;top:48px;bottom:0;left:0;z-index:51;display:block;width:min(280px,85vw);box-shadow:4px 0 16px color-mix(in srgb,var(--color-surface-dark) 60%,transparent)}\n"] }]
3739
+ args: [{ selector: 'shell-root', imports: [StatusBar, SideMenu, MainContent, CommandTerminal, FaqModal, CdkTrapFocus], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"shell\" [style.--terminal-height]=\"terminalHeight()\" [class.is-dragging]=\"isDragging()\" [class.terminal-hidden]=\"!terminalVisible()\">\n <shell-status-bar class=\"shell-status\" [class.disabled-zone]=\"isThinking()\">\n <ng-content />\n </shell-status-bar>\n @if (isAuthenticated() && !isPhone()) {\n <shell-side-menu class=\"shell-menu\" [class.disabled-zone]=\"isThinking()\" />\n }\n <shell-main-content class=\"shell-main\" [class.full-width]=\"!isAuthenticated()\" />\n @if (terminalVisible()) {\n <!-- On a phone too (scanner-phone-display D10), but never given the cursor there: see Shell.consoleFocusOnAppear. -->\n <div class=\"shell-resize\" [class.dragging]=\"isDragging()\" (mousedown)=\"onResizeStart($event)\" (touchstart)=\"onResizeTouchStart($event)\"></div>\n <shell-command-terminal class=\"shell-terminal\" [class.disabled-zone]=\"isThinking()\" [focusOnAppear]=\"consoleFocusOnAppear()\" />\n }\n @if (isAuthenticated() && phoneMenuOpen()) {\n <!-- Phone menu (scanner-phone-display D9): the icon column's items, opened from the \u2630 button. -->\n <div class=\"phone-menu-backdrop\" (click)=\"closePhoneMenu()\"></div>\n <shell-side-menu\n id=\"shell-phone-menu\"\n class=\"phone-menu\"\n [class.disabled-zone]=\"isThinking()\"\n [drawer]=\"true\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label=\"Menu\"\n cdkTrapFocus\n [cdkTrapFocusAutoCapture]=\"true\"\n (keydown.escape)=\"closePhoneMenu()\"\n />\n }\n @if (isAuthenticated() && bypassActive()) {\n <!-- authenticator-sign-in.prd.md D12: cannot be dismissed; no end time (the server never sends one). -->\n <div class=\"bypass-banner\" role=\"status\">Sign-in code check is off.</div>\n }\n <faq-modal />\n\n @if (helpOverlay(); as helpText) {\n <div class=\"help-backdrop\" (click)=\"dismissHelp()\">\n <div class=\"help-panel\" (click)=\"$event.stopPropagation()\" role=\"dialog\" aria-label=\"Command help\">\n <div class=\"help-panel-header\">\n <span class=\"help-panel-title\">Help</span>\n <button class=\"help-panel-close\" type=\"button\" (click)=\"dismissHelp()\" aria-label=\"Close help\">\u2715</button>\n </div>\n <div class=\"help-panel-content\">{{ helpText }}</div>\n <div class=\"help-panel-footer\">\n <span>Tip: for most commands you can swap the first and second words \u2014 e.g. \"disable user\" works the same as \"user disable\"</span>\n <span class=\"help-panel-footer-dismiss\">Press Escape, click outside, or start typing to close</span>\n </div>\n </div>\n </div>\n }\n</div>\n", styles: [".shell{position:relative;display:grid;grid-template-areas:\"status status\" \"menu main\" \"resize resize\" \"terminal terminal\";grid-template-columns:56px 1fr;grid-template-rows:48px 1fr 4px var(--terminal-height, 160px);height:100vh;height:100dvh;width:100vw;overflow:hidden;background-color:var(--color-surface-dark)}.shell.terminal-hidden{grid-template-rows:48px 1fr 0 0}.shell-status{grid-area:status}.shell-menu{grid-area:menu}.shell-main{grid-area:main;overflow:hidden}.shell-main.full-width{grid-column:1 / -1}.shell-resize{grid-area:resize;cursor:row-resize;touch-action:none;background-color:var(--color-surface-border);transition:background-color .15s ease}.shell-resize:hover,.shell-resize.dragging{background-color:var(--color-interactive)}.shell-terminal{grid-area:terminal}.shell.is-dragging{user-select:none;-webkit-user-select:none}.disabled-zone{pointer-events:none;opacity:.4;transition:opacity .2s ease}.help-backdrop{position:absolute;inset:0;z-index:100;background-color:#000000a6;display:flex;align-items:center;justify-content:center}.help-panel{background-color:var(--color-surface, #161b22);border:2px solid var(--color-interactive, #00e5ff);border-radius:4px;width:70%;max-width:820px;max-height:75vh;display:flex;flex-direction:column;font-family:Roboto Mono,Courier New,monospace;font-size:.85rem}.help-panel-header{display:flex;justify-content:space-between;align-items:center;padding:8px 16px;background-color:var(--color-surface-light, #21262d);border-bottom:1px solid var(--color-interactive, #00e5ff);border-radius:2px 2px 0 0;flex-shrink:0}.help-panel-title{color:var(--color-interactive, #00e5ff);font-weight:700;letter-spacing:.05em;text-transform:uppercase;font-size:.75rem}.help-panel-close{background:none;border:1px solid var(--color-surface-border, #30363d);color:var(--color-secondary-info, #9e9e9e);cursor:pointer;padding:2px 8px;font-size:.9rem;line-height:1.4;border-radius:3px;transition:color .15s ease,border-color .15s ease}.help-panel-close:hover{color:var(--color-text-primary, #ffffff);border-color:var(--color-interactive, #00e5ff)}.help-panel-content{flex:1;overflow-y:auto;padding:16px 20px;color:var(--color-text-primary, #ffffff);white-space:pre-wrap;line-height:1.7}.help-panel-footer{padding:6px 16px;background-color:var(--color-surface-light, #21262d);border-top:1px solid var(--color-surface-border, #30363d);border-radius:0 0 2px 2px;color:var(--color-secondary-info, #9e9e9e);font-size:.75rem;flex-shrink:0;display:flex;flex-direction:column;gap:2px}.help-panel-footer-dismiss{opacity:.6}@media(max-width:819.98px){.shell{grid-template-areas:\"status\" \"main\" \"resize\" \"terminal\";grid-template-columns:minmax(0,1fr)}.shell-menu{display:none}}.phone-menu-backdrop{position:absolute;inset:48px 0 0;z-index:50;background-color:color-mix(in srgb,var(--color-surface-dark) 70%,transparent)}.phone-menu{position:absolute;top:48px;bottom:0;left:0;z-index:51;display:block;width:min(280px,85vw);box-shadow:4px 0 16px color-mix(in srgb,var(--color-surface-dark) 60%,transparent)}.bypass-banner{position:fixed;top:52px;right:12px;z-index:1000;padding:6px 12px;border:2px solid var(--color-loss);border-radius:4px;background-color:var(--color-surface);color:var(--color-loss);font-weight:600;pointer-events:none}\n"] }]
3521
3740
  }] });
3522
3741
 
3742
+ /*
3743
+ Created by Franz Zemen 2026-10-17
3744
+ License Type: UNLICENSED
3745
+
3746
+ The 10 recovery codes, shown ONCE (authenticator-sign-in.prd.md D7), with Download (a text file made
3747
+ in the browser — nothing is stored on the server as a file) and Copy. "Continue" is the only way on,
3748
+ so the codes cannot scroll past unseen.
3749
+ */
3750
+ class RecoveryCodes {
3751
+ constructor() {
3752
+ this.codes = input.required(/* @ts-ignore */
3753
+ ...(ngDevMode ? [{ debugName: "codes" }] : /* istanbul ignore next */ []));
3754
+ this.appName = input('BrokenStock', /* @ts-ignore */
3755
+ ...(ngDevMode ? [{ debugName: "appName" }] : /* istanbul ignore next */ []));
3756
+ this.username = input(undefined, /* @ts-ignore */
3757
+ ...(ngDevMode ? [{ debugName: "username" }] : /* istanbul ignore next */ []));
3758
+ this.done = output();
3759
+ this.copied = signal(false, /* @ts-ignore */
3760
+ ...(ngDevMode ? [{ debugName: "copied" }] : /* istanbul ignore next */ []));
3761
+ }
3762
+ download() {
3763
+ const text = recoveryCodesFile(this.appName(), this.username(), this.codes());
3764
+ const url = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
3765
+ const a = document.createElement('a');
3766
+ a.href = url;
3767
+ a.download = `${this.appName().toLowerCase().replace(/[^a-z0-9]+/g, '-')}-recovery-codes.txt`;
3768
+ a.click();
3769
+ setTimeout(() => URL.revokeObjectURL(url));
3770
+ }
3771
+ copy() {
3772
+ navigator.clipboard?.writeText(this.codes().join('\n')).then(() => this.copied.set(true)).catch(() => undefined);
3773
+ }
3774
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: RecoveryCodes, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
3775
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: RecoveryCodes, isStandalone: true, selector: "shell-recovery-codes", inputs: { codes: { classPropertyName: "codes", publicName: "codes", isSignal: true, isRequired: true, transformFunction: null }, appName: { classPropertyName: "appName", publicName: "appName", isSignal: true, isRequired: false, transformFunction: null }, username: { classPropertyName: "username", publicName: "username", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { done: "done" }, ngImport: i0, template: `
3776
+ <div class="recovery-codes">
3777
+ <p class="recovery-codes-lead text-text-primary"><strong>Save your recovery codes.</strong></p>
3778
+ <p class="text-secondary-info">
3779
+ Each one works once, in place of the 6-digit code, if you lose your phone.
3780
+ They will not be shown again.
3781
+ </p>
3782
+ <ol class="recovery-codes-list">
3783
+ @for (code of codes(); track code) {
3784
+ <li><code>{{ code }}</code></li>
3785
+ }
3786
+ </ol>
3787
+ <div class="recovery-codes-actions">
3788
+ <button mat-stroked-button type="button" (click)="download()">
3789
+ <mat-icon>download</mat-icon> Download
3790
+ </button>
3791
+ <button mat-stroked-button type="button" (click)="copy()">
3792
+ <mat-icon>content_copy</mat-icon> {{ copied() ? 'Copied' : 'Copy' }}
3793
+ </button>
3794
+ <button mat-flat-button type="button" (click)="done.emit()">I have saved them — continue</button>
3795
+ </div>
3796
+ </div>
3797
+ `, isInline: true, styles: [".recovery-codes{max-width:360px;padding:12px 16px;border-radius:4px;background-color:var(--color-surface-light);text-align:left;font-size:.9rem}.recovery-codes p{margin:0 0 8px}.recovery-codes-list{columns:2;margin:8px 0 12px;padding-left:1.5em;font-family:var(--shell-mono, monospace)}.recovery-codes-list li{margin:2px 0}.recovery-codes-actions{display:flex;flex-wrap:wrap;gap:8px}\n"], dependencies: [{ 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: MatIconModule }, { kind: "component", type: i1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
3798
+ }
3799
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: RecoveryCodes, decorators: [{
3800
+ type: Component,
3801
+ args: [{ selector: 'shell-recovery-codes', changeDetection: ChangeDetectionStrategy.OnPush, imports: [MatButtonModule, MatIconModule], template: `
3802
+ <div class="recovery-codes">
3803
+ <p class="recovery-codes-lead text-text-primary"><strong>Save your recovery codes.</strong></p>
3804
+ <p class="text-secondary-info">
3805
+ Each one works once, in place of the 6-digit code, if you lose your phone.
3806
+ They will not be shown again.
3807
+ </p>
3808
+ <ol class="recovery-codes-list">
3809
+ @for (code of codes(); track code) {
3810
+ <li><code>{{ code }}</code></li>
3811
+ }
3812
+ </ol>
3813
+ <div class="recovery-codes-actions">
3814
+ <button mat-stroked-button type="button" (click)="download()">
3815
+ <mat-icon>download</mat-icon> Download
3816
+ </button>
3817
+ <button mat-stroked-button type="button" (click)="copy()">
3818
+ <mat-icon>content_copy</mat-icon> {{ copied() ? 'Copied' : 'Copy' }}
3819
+ </button>
3820
+ <button mat-flat-button type="button" (click)="done.emit()">I have saved them — continue</button>
3821
+ </div>
3822
+ </div>
3823
+ `, styles: [".recovery-codes{max-width:360px;padding:12px 16px;border-radius:4px;background-color:var(--color-surface-light);text-align:left;font-size:.9rem}.recovery-codes p{margin:0 0 8px}.recovery-codes-list{columns:2;margin:8px 0 12px;padding-left:1.5em;font-family:var(--shell-mono, monospace)}.recovery-codes-list li{margin:2px 0}.recovery-codes-actions{display:flex;flex-wrap:wrap;gap:8px}\n"] }]
3824
+ }], propDecorators: { codes: [{ type: i0.Input, args: [{ isSignal: true, alias: "codes", required: true }] }], appName: [{ type: i0.Input, args: [{ isSignal: true, alias: "appName", required: false }] }], username: [{ type: i0.Input, args: [{ isSignal: true, alias: "username", required: false }] }], done: [{ type: i0.Output, args: ["done"] }] } });
3825
+
3523
3826
  class Login {
3524
3827
  #postLoginRoute;
3525
3828
  #commandService;
@@ -3545,6 +3848,11 @@ class Login {
3545
3848
  */
3546
3849
  this.selected = signal(new Set(), /* @ts-ignore */
3547
3850
  ...(ngDevMode ? [{ debugName: "selected" }] : /* istanbul ignore next */ []));
3851
+ /** The code or recovery code typed on the code step (authenticator-sign-in.prd.md). */
3852
+ this.codeText = '';
3853
+ /** Typing a recovery code instead of the 6-digit code. */
3854
+ this.useRecovery = signal(false, /* @ts-ignore */
3855
+ ...(ngDevMode ? [{ debugName: "useRecovery" }] : /* istanbul ignore next */ []));
3548
3856
  this.USERNAME_REGEX = USERNAME_REGEX;
3549
3857
  this.PASSWORD_REGEX = PASSWORD_REGEX;
3550
3858
  this.appName = inject(SHELL_APP_NAME, { optional: true }) ?? 'Application';
@@ -3655,6 +3963,37 @@ class Login {
3655
3963
  this.selected.set(new Set());
3656
3964
  this.#doLogin(false, { endSessionIds: ids });
3657
3965
  }
3966
+ /** The code step: the 6-digit code (or a recovery code), with the credentials already accepted. */
3967
+ onSubmitCode(event) {
3968
+ event.preventDefault();
3969
+ this.errors = [];
3970
+ const typed = this.codeText.trim();
3971
+ if (!typed)
3972
+ return;
3973
+ this.submitted = true;
3974
+ this.#doLogin(false, this.useRecovery() ? { recoveryCode: typed } : { code: typed });
3975
+ }
3976
+ onToggleRecovery() {
3977
+ this.useRecovery.set(!this.useRecovery());
3978
+ this.codeText = '';
3979
+ this.errors = [];
3980
+ }
3981
+ /** Back to the password form. */
3982
+ onCancelCode() {
3983
+ this.authenticationService.cancelCodeStep();
3984
+ this.codeText = '';
3985
+ this.useRecovery.set(false);
3986
+ this.errors = [];
3987
+ this.submitted = false;
3988
+ }
3989
+ /** New recovery codes were shown after sign-in; now go where sign-in was going. */
3990
+ onRecoveryCodesSaved() {
3991
+ this.authenticationService.acknowledgeRecoveryCodes();
3992
+ // Setup can finish on an attempt that then stops at "already signed in
3993
+ // elsewhere": the codes are shown first, and the session choice is still open.
3994
+ if (this.authenticationService.authenticated())
3995
+ this.#navigateAfterLogin();
3996
+ }
3658
3997
  onCancelConflict() {
3659
3998
  this.authenticationService.clearConflict();
3660
3999
  this.selected.set(new Set());
@@ -3665,19 +4004,31 @@ class Login {
3665
4004
  .login(this.model, force, choice)
3666
4005
  .then(() => {
3667
4006
  this.#commandService.echoCommand(`login ${this.model.username}`);
3668
- this.router.navigateByUrl(this.#resolvePostLoginUrl()).then(() => {
3669
- setTimeout(() => this.#terminalFocusService.requestFocus());
3670
- }).catch(console.error);
4007
+ this.codeText = '';
4008
+ this.useRecovery.set(false);
4009
+ // Setup finished during this sign-in: the codes are on screen and must be
4010
+ // seen before the app takes over the page.
4011
+ if (this.authenticationService.recoveryCodesToShow()) {
4012
+ this.submitted = false;
4013
+ return;
4014
+ }
4015
+ this.#navigateAfterLogin();
3671
4016
  })
3672
4017
  .catch((err) => {
3673
- if (err !== 'conflict') {
4018
+ if (err !== 'conflict' && err !== CODE_STEP) {
3674
4019
  this.errors.push({ ndx: 0, message: typeof err === 'string' ? err : 'Unable to login' });
3675
4020
  }
4021
+ this.codeText = '';
3676
4022
  this.submitted = false;
3677
4023
  });
3678
4024
  }
4025
+ #navigateAfterLogin() {
4026
+ this.router.navigateByUrl(this.#resolvePostLoginUrl()).then(() => {
4027
+ setTimeout(() => this.#terminalFocusService.requestFocus());
4028
+ }).catch(console.error);
4029
+ }
3679
4030
  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 }); }
3680
- 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 <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 @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)}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i3.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i3.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: i3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i3.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i3.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "directive", type: i3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i3.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: "pipe", type: DatePipe, name: "date" }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
4031
+ 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 }); }
3681
4032
  }
3682
4033
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: Login, decorators: [{
3683
4034
  type: Component,
@@ -3693,9 +4044,240 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
3693
4044
  RegexValidatorDirective,
3694
4045
  TextFieldModule,
3695
4046
  DatePipe,
3696
- ], 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 <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 @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)}\n"] }]
4047
+ RecoveryCodes,
4048
+ ], 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"] }]
3697
4049
  }], ctorParameters: () => [{ type: AuthenticationService }, { type: i2$1.Router }] });
3698
4050
 
4051
+ /*
4052
+ Created by Franz Zemen 2026-10-17
4053
+ License Type: UNLICENSED
4054
+
4055
+ Settings → Sign-in code (authenticator-sign-in.prd.md D5, D7). Shared by every app: each routes to
4056
+ `AuthenticatorSettings` wherever its settings live.
4057
+
4058
+ - Set up an authenticator (or replace one): the password again, plus a current code or a
4059
+ recovery code when replacing → QR + typed-in key → the first code → 10 recovery codes.
4060
+ - New recovery codes: a current code → 10 new ones; the old ones stop working.
4061
+
4062
+ ⛔ There is deliberately no "remove" and no "turn off" here (D13). Those are AWS-only; the screen
4063
+ says how to get them done.
4064
+ */
4065
+ class AuthenticatorSettings {
4066
+ constructor() {
4067
+ this.#auth = inject(AuthenticationService);
4068
+ this.appName = inject(SHELL_APP_NAME, { optional: true }) ?? 'BrokenStock';
4069
+ this.status = signal(undefined, /* @ts-ignore */
4070
+ ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
4071
+ this.mode = signal('view', /* @ts-ignore */
4072
+ ...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
4073
+ this.busy = signal(false, /* @ts-ignore */
4074
+ ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
4075
+ this.error = signal(undefined, /* @ts-ignore */
4076
+ ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
4077
+ this.challenge = signal(undefined, /* @ts-ignore */
4078
+ ...(ngDevMode ? [{ debugName: "challenge" }] : /* istanbul ignore next */ []));
4079
+ this.codes = signal(undefined, /* @ts-ignore */
4080
+ ...(ngDevMode ? [{ debugName: "codes" }] : /* istanbul ignore next */ []));
4081
+ this.useRecovery = signal(false, /* @ts-ignore */
4082
+ ...(ngDevMode ? [{ debugName: "useRecovery" }] : /* istanbul ignore next */ []));
4083
+ this.password = '';
4084
+ this.code = '';
4085
+ }
4086
+ #auth;
4087
+ ngOnInit() {
4088
+ void this.#load();
4089
+ }
4090
+ async #load() {
4091
+ try {
4092
+ this.status.set(await this.#auth.authenticatorStatus());
4093
+ }
4094
+ catch (err) {
4095
+ this.error.set(err instanceof AuthenticatorError ? err.message : 'Could not load your sign-in code settings.');
4096
+ }
4097
+ }
4098
+ begin(mode) {
4099
+ this.#reset();
4100
+ this.mode.set(mode);
4101
+ }
4102
+ cancel() {
4103
+ this.#reset();
4104
+ this.mode.set('view');
4105
+ }
4106
+ toggleRecovery() {
4107
+ this.useRecovery.set(!this.useRecovery());
4108
+ this.code = '';
4109
+ }
4110
+ /** Password (and current code when replacing) → QR. */
4111
+ async start(event) {
4112
+ event.preventDefault();
4113
+ await this.#run(async () => {
4114
+ const current = this.status()?.enrolled
4115
+ ? (this.useRecovery() ? { recoveryCode: this.code.trim() } : { code: this.code.trim() })
4116
+ : {};
4117
+ this.challenge.set(await this.#auth.startAuthenticatorSetup(this.password, current));
4118
+ this.password = '';
4119
+ this.code = '';
4120
+ this.useRecovery.set(false);
4121
+ this.mode.set('scan');
4122
+ });
4123
+ }
4124
+ /** The first code from the newly scanned app → recovery codes. */
4125
+ async confirm(event) {
4126
+ event.preventDefault();
4127
+ await this.#run(async () => {
4128
+ this.codes.set(await this.#auth.confirmAuthenticatorSetup(this.code.trim()));
4129
+ this.challenge.set(undefined);
4130
+ this.code = '';
4131
+ this.mode.set('codes');
4132
+ });
4133
+ }
4134
+ async reissue(event) {
4135
+ event.preventDefault();
4136
+ await this.#run(async () => {
4137
+ this.codes.set(await this.#auth.reissueRecoveryCodes(this.code.trim()));
4138
+ this.code = '';
4139
+ this.mode.set('codes');
4140
+ });
4141
+ }
4142
+ async codesSaved() {
4143
+ this.#reset();
4144
+ this.mode.set('view');
4145
+ await this.#load();
4146
+ }
4147
+ async #run(work) {
4148
+ this.busy.set(true);
4149
+ this.error.set(undefined);
4150
+ try {
4151
+ await work();
4152
+ }
4153
+ catch (err) {
4154
+ this.error.set(err instanceof AuthenticatorError ? err.message : 'Something went wrong');
4155
+ this.code = '';
4156
+ }
4157
+ finally {
4158
+ this.busy.set(false);
4159
+ }
4160
+ }
4161
+ #reset() {
4162
+ this.password = '';
4163
+ this.code = '';
4164
+ this.error.set(undefined);
4165
+ this.challenge.set(undefined);
4166
+ this.codes.set(undefined);
4167
+ this.useRecovery.set(false);
4168
+ }
4169
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: AuthenticatorSettings, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
4170
+ 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 }); }
4171
+ }
4172
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: AuthenticatorSettings, decorators: [{
4173
+ type: Component,
4174
+ 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"] }]
4175
+ }] });
4176
+
4177
+ /*
4178
+ Created by Franz Zemen 2026-10-17
4179
+ License Type: UNLICENSED
4180
+
4181
+ "Enter your code" — the reusable dialog for the on-demand check (authenticator-sign-in.prd.md D15).
4182
+ Live Trading opens it to arm trading; anything else that needs the code again can too.
4183
+
4184
+ Closes with the time the server recorded (epoch ms), or `undefined` if cancelled. Wrong codes keep
4185
+ the dialog open with the reason in plain words; after five, "Too many attempts." and nothing more.
4186
+
4187
+ const at = await openCodeDialog(dialog, {title: 'Arm live trading', purpose: 'arm-live-trading'});
4188
+ */
4189
+ class CodeDialog {
4190
+ constructor() {
4191
+ this.data = inject(MAT_DIALOG_DATA);
4192
+ this.dialogRef = inject((MatDialogRef));
4193
+ this.#auth = inject(AuthenticationService);
4194
+ this.text = '';
4195
+ this.useRecovery = signal(false, /* @ts-ignore */
4196
+ ...(ngDevMode ? [{ debugName: "useRecovery" }] : /* istanbul ignore next */ []));
4197
+ this.busy = signal(false, /* @ts-ignore */
4198
+ ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
4199
+ this.error = signal(undefined, /* @ts-ignore */
4200
+ ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
4201
+ }
4202
+ #auth;
4203
+ toggleRecovery() {
4204
+ this.useRecovery.set(!this.useRecovery());
4205
+ this.text = '';
4206
+ this.error.set(undefined);
4207
+ }
4208
+ async submit(event) {
4209
+ event.preventDefault();
4210
+ const typed = this.text.trim();
4211
+ if (!typed)
4212
+ return;
4213
+ this.busy.set(true);
4214
+ this.error.set(undefined);
4215
+ try {
4216
+ const at = await this.#auth.verifyCode(this.useRecovery() ? { recoveryCode: typed } : { code: typed }, this.data.purpose);
4217
+ this.dialogRef.close(at);
4218
+ }
4219
+ catch (err) {
4220
+ this.error.set(err instanceof AuthenticatorError ? err.message : 'Something went wrong');
4221
+ this.text = '';
4222
+ }
4223
+ finally {
4224
+ this.busy.set(false);
4225
+ }
4226
+ }
4227
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: CodeDialog, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
4228
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: CodeDialog, isStandalone: true, selector: "shell-code-dialog", ngImport: i0, template: `
4229
+ <h2 mat-dialog-title>{{ data.title }}</h2>
4230
+ <form (submit)="submit($event)">
4231
+ <mat-dialog-content>
4232
+ <p>{{ data.message ?? (useRecovery() ? 'Enter one of your recovery codes.' : 'Enter the 6-digit code from your authenticator app.') }}</p>
4233
+ <mat-form-field appearance="outline" class="code-field">
4234
+ <mat-label>{{ useRecovery() ? 'Recovery code' : 'Code' }}</mat-label>
4235
+ <input matInput name="code" [(ngModel)]="text" [disabled]="busy()"
4236
+ [attr.inputmode]="useRecovery() ? 'text' : 'numeric'"
4237
+ [attr.autocomplete]="useRecovery() ? 'off' : 'one-time-code'" cdkFocusInitial />
4238
+ </mat-form-field>
4239
+ @if (error(); as message) {
4240
+ <p class="code-error text-loss" role="alert">{{ message }}</p>
4241
+ }
4242
+ </mat-dialog-content>
4243
+ <mat-dialog-actions align="end">
4244
+ <button mat-button type="button" (click)="toggleRecovery()">{{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}</button>
4245
+ <button mat-button type="button" (click)="dialogRef.close(undefined)">Cancel</button>
4246
+ <button mat-flat-button type="submit" [disabled]="busy() || !text.trim()">{{ data.confirmLabel ?? 'Confirm' }}</button>
4247
+ </mat-dialog-actions>
4248
+ </form>
4249
+ `, isInline: true, styles: [":host{display:block}.code-field{width:100%}.code-error{margin: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.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: MatDialogModule }, { kind: "directive", type: i2$2.MatDialogTitle, selector: "[mat-dialog-title], [matDialogTitle]", inputs: ["id"], exportAs: ["matDialogTitle"] }, { kind: "directive", type: i2$2.MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "directive", type: i2$2.MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { 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"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
4250
+ }
4251
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: CodeDialog, decorators: [{
4252
+ type: Component,
4253
+ args: [{ selector: 'shell-code-dialog', changeDetection: ChangeDetectionStrategy.OnPush, imports: [FormsModule, MatDialogModule, MatButtonModule, MatFormFieldModule, MatInputModule], template: `
4254
+ <h2 mat-dialog-title>{{ data.title }}</h2>
4255
+ <form (submit)="submit($event)">
4256
+ <mat-dialog-content>
4257
+ <p>{{ data.message ?? (useRecovery() ? 'Enter one of your recovery codes.' : 'Enter the 6-digit code from your authenticator app.') }}</p>
4258
+ <mat-form-field appearance="outline" class="code-field">
4259
+ <mat-label>{{ useRecovery() ? 'Recovery code' : 'Code' }}</mat-label>
4260
+ <input matInput name="code" [(ngModel)]="text" [disabled]="busy()"
4261
+ [attr.inputmode]="useRecovery() ? 'text' : 'numeric'"
4262
+ [attr.autocomplete]="useRecovery() ? 'off' : 'one-time-code'" cdkFocusInitial />
4263
+ </mat-form-field>
4264
+ @if (error(); as message) {
4265
+ <p class="code-error text-loss" role="alert">{{ message }}</p>
4266
+ }
4267
+ </mat-dialog-content>
4268
+ <mat-dialog-actions align="end">
4269
+ <button mat-button type="button" (click)="toggleRecovery()">{{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}</button>
4270
+ <button mat-button type="button" (click)="dialogRef.close(undefined)">Cancel</button>
4271
+ <button mat-flat-button type="submit" [disabled]="busy() || !text.trim()">{{ data.confirmLabel ?? 'Confirm' }}</button>
4272
+ </mat-dialog-actions>
4273
+ </form>
4274
+ `, styles: [":host{display:block}.code-field{width:100%}.code-error{margin:0}\n"] }]
4275
+ }] });
4276
+ /** Opens the dialog; resolves with the recorded time (epoch ms), or undefined when cancelled. */
4277
+ function openCodeDialog(dialog, data) {
4278
+ return firstValueFrom(dialog.open(CodeDialog, { data, width: '360px' }).afterClosed());
4279
+ }
4280
+
3699
4281
  class ConfirmDialog {
3700
4282
  constructor() {
3701
4283
  this.data = inject(MAT_DIALOG_DATA);
@@ -3714,7 +4296,7 @@ class ConfirmDialog {
3714
4296
  <button mat-button (click)="dialogRef.close(false)">{{ data.cancelLabel ?? 'Cancel' }}</button>
3715
4297
  <button mat-flat-button color="warn" (click)="dialogRef.close(true)">{{ data.confirmLabel ?? 'Confirm' }}</button>
3716
4298
  </mat-dialog-actions>
3717
- `, isInline: true, styles: [":host{display:block}.warning-message{color:var(--color-warning);margin-top:8px}\n"], dependencies: [{ kind: "ngmodule", type: MatDialogModule }, { kind: "directive", type: i1$2.MatDialogTitle, selector: "[mat-dialog-title], [matDialogTitle]", inputs: ["id"], exportAs: ["matDialogTitle"] }, { kind: "directive", type: i1$2.MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "directive", type: i1$2.MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { 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"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
4299
+ `, isInline: true, styles: [":host{display:block}.warning-message{color:var(--color-warning);margin-top:8px}\n"], dependencies: [{ kind: "ngmodule", type: MatDialogModule }, { kind: "directive", type: i2$2.MatDialogTitle, selector: "[mat-dialog-title], [matDialogTitle]", inputs: ["id"], exportAs: ["matDialogTitle"] }, { kind: "directive", type: i2$2.MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "directive", type: i2$2.MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { 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"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
3718
4300
  }
3719
4301
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: ConfirmDialog, decorators: [{
3720
4302
  type: Component,
@@ -3742,5 +4324,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
3742
4324
  * Generated bundle index. Do not edit.
3743
4325
  */
3744
4326
 
3745
- export { AuthenticationService, CLEAR_USAGE, 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, PhoneLayoutService, PublicFaqIndex, PublicFaqPage, PublicFaqToc, 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, SessionTokenStore, Shell, SideMenu, StatusBar, TerminalFocusService, TerminalVisibilityService, ThemeService, ThinkingService, audienceAllowed, authGuard, authInterceptor, buildFallbackChain, decideRenewal, guestGuard, matchesRoute, provideShellConfig, tokenize };
4327
+ 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, 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, SessionTokenStore, Shell, SideMenu, StatusBar, TOO_MANY_ATTEMPTS, TerminalFocusService, TerminalVisibilityService, ThemeService, ThinkingService, audienceAllowed, authGuard, authInterceptor, authenticatorMessage, buildFallbackChain, buildLoginBody, decideRenewal, guestGuard, matchesRoute, openCodeDialog, provideShellConfig, recoveryCodesFile, tokenize };
3746
4328
  //# sourceMappingURL=franzzemen-brokenstock-shell.mjs.map