@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,
|
|
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
|
|
9
|
-
import * as
|
|
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
|
|
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
|
-
|
|
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
|
|
2336
|
-
* below 820px,
|
|
2337
|
-
*
|
|
2338
|
-
*
|
|
2339
|
-
* source also means a request made on a phone is
|
|
2340
|
-
* be acted on
|
|
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
|
|
3168
|
-
* on
|
|
3169
|
-
*
|
|
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
|
|
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
|
|
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
|
|
3265
|
-
*
|
|
3266
|
-
*
|
|
3267
|
-
*
|
|
3268
|
-
*
|
|
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
|
|
3376
|
-
*
|
|
3377
|
-
*
|
|
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:
|
|
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
|
|
3411
|
-
*
|
|
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
|
-
*
|
|
3420
|
-
*
|
|
3421
|
-
*
|
|
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
|
-
*
|
|
3424
|
-
*
|
|
3425
|
-
*
|
|
3426
|
-
*
|
|
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.
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
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)
|
|
3454
|
-
//
|
|
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)
|
|
3499
|
-
//
|
|
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]=\"!
|
|
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]=\"!
|
|
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.
|
|
3669
|
-
|
|
3670
|
-
|
|
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
|
-
|
|
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:
|
|
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
|