@franzzemen/brokenstock-shell 0.8.2 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -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 } from '@angular/core';
|
|
2
|
+
import { InjectionToken, makeEnvironmentProviders, signal, computed, Injectable, inject, input, Directive, isDevMode, effect, untracked, HostListener, ViewChild, ViewEncapsulation, ChangeDetectionStrategy, Component, DestroyRef, NgZone, viewChild, afterNextRender, output } from '@angular/core';
|
|
3
3
|
import * as i2$1 from '@angular/router';
|
|
4
4
|
import { Router, RouterLink, RouterLinkActive, ActivatedRoute, RouterOutlet } from '@angular/router';
|
|
5
5
|
import { BEARER_TOKEN_REGEX, HttpHeader, USERNAME_REGEX, PASSWORD_REGEX } from '@franzzemen/utility';
|
|
6
6
|
import { validateCredentials } from '@franzzemen/identity';
|
|
7
|
-
import { HttpClient } from '@angular/common/http';
|
|
8
|
-
import { catchError, from, switchMap, throwError
|
|
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);
|
|
@@ -2804,8 +3010,64 @@ const HELP_USAGE = 'help [command]\n\n' +
|
|
|
2804
3010
|
' Arguments:\n' +
|
|
2805
3011
|
' [command] Optional command name to show detailed help for';
|
|
2806
3012
|
|
|
3013
|
+
/*
|
|
3014
|
+
Created by Franz Zemen 2026-10-18
|
|
3015
|
+
License Type: UNLICENSED
|
|
3016
|
+
|
|
3017
|
+
A question a command leaves open in the terminal (live-trading-foundation.prd.md D24).
|
|
3018
|
+
|
|
3019
|
+
A command can ask instead of acting: "Send SIM · Buy 100 NVDA? (Enter or yes to send, Esc to
|
|
3020
|
+
cancel)". The next line typed — including a bare Enter — is the answer and goes to the command,
|
|
3021
|
+
not to the command parser. Esc cancels. The arming code is asked the same way, with the input
|
|
3022
|
+
masked so the code never appears on screen or in the terminal's history.
|
|
3023
|
+
|
|
3024
|
+
Plain logic, no Angular: `CommandService` holds one of these and the terminal reads it.
|
|
3025
|
+
*/
|
|
3026
|
+
/** True for the answers that mean yes: a bare Enter, `y`, `yes`. */
|
|
3027
|
+
function isYes(text) {
|
|
3028
|
+
const t = text.trim().toLowerCase();
|
|
3029
|
+
return t === '' || t === 'y' || t === 'yes';
|
|
3030
|
+
}
|
|
3031
|
+
/** What the history shows for an answer: the text, a masked run, or "(Enter)". */
|
|
3032
|
+
function answerEcho(prompt, text) {
|
|
3033
|
+
if (text === '')
|
|
3034
|
+
return '(Enter)';
|
|
3035
|
+
return prompt.masked ? '•'.repeat(text.length) : text;
|
|
3036
|
+
}
|
|
3037
|
+
/** Holds at most one open question. Asking again cancels the one before it. */
|
|
3038
|
+
class PendingPromptState {
|
|
3039
|
+
#current = null;
|
|
3040
|
+
get current() {
|
|
3041
|
+
return this.#current;
|
|
3042
|
+
}
|
|
3043
|
+
ask(prompt) {
|
|
3044
|
+
const before = this.#current;
|
|
3045
|
+
this.#current = prompt;
|
|
3046
|
+
if (before && before !== prompt)
|
|
3047
|
+
before.cancel?.();
|
|
3048
|
+
}
|
|
3049
|
+
/**
|
|
3050
|
+
* Takes the open question and closes it, so an answer that asks again opens a fresh one rather
|
|
3051
|
+
* than being closed afterwards.
|
|
3052
|
+
*/
|
|
3053
|
+
take() {
|
|
3054
|
+
const p = this.#current;
|
|
3055
|
+
this.#current = null;
|
|
3056
|
+
return p;
|
|
3057
|
+
}
|
|
3058
|
+
cancel() {
|
|
3059
|
+
const p = this.#current;
|
|
3060
|
+
if (!p)
|
|
3061
|
+
return false;
|
|
3062
|
+
this.#current = null;
|
|
3063
|
+
p.cancel?.();
|
|
3064
|
+
return true;
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
|
|
2807
3068
|
const CommandInputSchema = z.string().trim().min(1, 'Command cannot be empty');
|
|
2808
3069
|
class CommandService {
|
|
3070
|
+
#prompts;
|
|
2809
3071
|
constructor() {
|
|
2810
3072
|
this.history = signal([], /* @ts-ignore */
|
|
2811
3073
|
...(ngDevMode ? [{ debugName: "history" }] : /* istanbul ignore next */ []));
|
|
@@ -2815,6 +3077,13 @@ class CommandService {
|
|
|
2815
3077
|
...(ngDevMode ? [{ debugName: "isProcessing" }] : /* istanbul ignore next */ []));
|
|
2816
3078
|
this.helpOverlay = signal(null, /* @ts-ignore */
|
|
2817
3079
|
...(ngDevMode ? [{ debugName: "helpOverlay" }] : /* istanbul ignore next */ []));
|
|
3080
|
+
/**
|
|
3081
|
+
* The question a command left open, or null (live-trading-foundation.prd.md D24). While set, the
|
|
3082
|
+
* next line — a bare Enter included — is its answer, not a command; Esc cancels.
|
|
3083
|
+
*/
|
|
3084
|
+
this.pending = signal(null, /* @ts-ignore */
|
|
3085
|
+
...(ngDevMode ? [{ debugName: "pending" }] : /* istanbul ignore next */ []));
|
|
3086
|
+
this.#prompts = new PendingPromptState();
|
|
2818
3087
|
this.authService = inject(AuthenticationService);
|
|
2819
3088
|
this.registry = inject(CommandRegistryService);
|
|
2820
3089
|
this.router = inject(Router);
|
|
@@ -2826,12 +3095,68 @@ class CommandService {
|
|
|
2826
3095
|
effect(() => {
|
|
2827
3096
|
if (!this.authService.authenticated()) {
|
|
2828
3097
|
this.history.set([]);
|
|
3098
|
+
// A question asked for a session that has ended must not be answerable by the next one.
|
|
3099
|
+
this.#prompts.cancel();
|
|
3100
|
+
this.pending.set(null);
|
|
2829
3101
|
}
|
|
2830
3102
|
});
|
|
2831
3103
|
}
|
|
2832
3104
|
dismissHelp() {
|
|
2833
3105
|
this.helpOverlay.set(null);
|
|
2834
3106
|
}
|
|
3107
|
+
/** Leaves a question open; the next line typed answers it. A question already open is cancelled. */
|
|
3108
|
+
ask(prompt) {
|
|
3109
|
+
this.#prompts.ask(prompt);
|
|
3110
|
+
this.pending.set(prompt);
|
|
3111
|
+
this.history.update(h => [...h, {
|
|
3112
|
+
input: '',
|
|
3113
|
+
result: { success: true, message: prompt.question, timestamp: new Date(), severity: 'info' },
|
|
3114
|
+
}]);
|
|
3115
|
+
}
|
|
3116
|
+
/** Esc: closes the open question, if any. True when there was one. */
|
|
3117
|
+
cancelPending() {
|
|
3118
|
+
if (!this.#prompts.cancel())
|
|
3119
|
+
return false;
|
|
3120
|
+
this.pending.set(null);
|
|
3121
|
+
this.currentInput.set('');
|
|
3122
|
+
this.history.update(h => [...h, {
|
|
3123
|
+
input: '',
|
|
3124
|
+
result: { success: true, message: 'Cancelled — nothing was sent.', timestamp: new Date(), severity: 'info' },
|
|
3125
|
+
}]);
|
|
3126
|
+
return true;
|
|
3127
|
+
}
|
|
3128
|
+
#answer(rawInput) {
|
|
3129
|
+
const prompt = this.#prompts.take();
|
|
3130
|
+
if (!prompt)
|
|
3131
|
+
return;
|
|
3132
|
+
this.pending.set(null);
|
|
3133
|
+
const text = rawInput.trim();
|
|
3134
|
+
const echo = answerEcho(prompt, text);
|
|
3135
|
+
this.currentInput.set('');
|
|
3136
|
+
const record = (result) => {
|
|
3137
|
+
this.history.update(h => [...h, { input: echo, result }]);
|
|
3138
|
+
// An answer may ask again; show the new question after the answer's line.
|
|
3139
|
+
const next = this.#prompts.current;
|
|
3140
|
+
if (next && next !== this.pending())
|
|
3141
|
+
this.pending.set(next);
|
|
3142
|
+
};
|
|
3143
|
+
let out;
|
|
3144
|
+
try {
|
|
3145
|
+
out = prompt.answer(text);
|
|
3146
|
+
}
|
|
3147
|
+
catch (err) {
|
|
3148
|
+
out = { success: false, message: err?.message ?? String(err), timestamp: new Date() };
|
|
3149
|
+
}
|
|
3150
|
+
if (out instanceof Promise) {
|
|
3151
|
+
this.isProcessing.set(true);
|
|
3152
|
+
out
|
|
3153
|
+
.then(record, err => record({ success: false, message: err?.message ?? String(err), timestamp: new Date() }))
|
|
3154
|
+
.finally(() => this.isProcessing.set(false));
|
|
3155
|
+
}
|
|
3156
|
+
else {
|
|
3157
|
+
record(out);
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
2835
3160
|
echoCommand(input) {
|
|
2836
3161
|
if (this.#coalesce(input))
|
|
2837
3162
|
return;
|
|
@@ -2853,6 +3178,11 @@ class CommandService {
|
|
|
2853
3178
|
}]);
|
|
2854
3179
|
}
|
|
2855
3180
|
execute(rawInput) {
|
|
3181
|
+
// An open question takes the line, a bare Enter included (D24).
|
|
3182
|
+
if (this.#prompts.current) {
|
|
3183
|
+
this.#answer(rawInput);
|
|
3184
|
+
return;
|
|
3185
|
+
}
|
|
2856
3186
|
const validation = CommandInputSchema.safeParse(rawInput);
|
|
2857
3187
|
if (!validation.success) {
|
|
2858
3188
|
this.history.update(h => [...h, {
|
|
@@ -3274,6 +3604,8 @@ class CommandTerminal {
|
|
|
3274
3604
|
...(ngDevMode ? [{ debugName: "history" }] : /* istanbul ignore next */ []));
|
|
3275
3605
|
this.currentInput = this.commandService.currentInput;
|
|
3276
3606
|
this.isProcessing = this.commandService.isProcessing;
|
|
3607
|
+
/** The question a command left open (D24): the input answers it; Esc cancels. */
|
|
3608
|
+
this.pending = this.commandService.pending;
|
|
3277
3609
|
this.inputEl = viewChild('terminalInput', /* @ts-ignore */
|
|
3278
3610
|
...(ngDevMode ? [{ debugName: "inputEl" }] : /* istanbul ignore next */ []));
|
|
3279
3611
|
this.tabCycleIndex = -1;
|
|
@@ -3318,7 +3650,8 @@ class CommandTerminal {
|
|
|
3318
3650
|
}
|
|
3319
3651
|
onSubmit() {
|
|
3320
3652
|
const input = this.currentInput();
|
|
3321
|
-
|
|
3653
|
+
// With a question open, a bare Enter is an answer.
|
|
3654
|
+
if (!input.trim() && !this.pending())
|
|
3322
3655
|
return;
|
|
3323
3656
|
this.commandService.execute(input);
|
|
3324
3657
|
this.tabCycleIndex = -1;
|
|
@@ -3336,6 +3669,11 @@ class CommandTerminal {
|
|
|
3336
3669
|
dismissHelp() {
|
|
3337
3670
|
this.commandService.dismissHelp();
|
|
3338
3671
|
}
|
|
3672
|
+
/** Esc: cancels an open question first, otherwise closes the help overlay. */
|
|
3673
|
+
onEscape() {
|
|
3674
|
+
if (!this.commandService.cancelPending())
|
|
3675
|
+
this.dismissHelp();
|
|
3676
|
+
}
|
|
3339
3677
|
onTab(event) {
|
|
3340
3678
|
event.preventDefault();
|
|
3341
3679
|
const input = this.currentInput().trim();
|
|
@@ -3385,16 +3723,22 @@ class CommandTerminal {
|
|
|
3385
3723
|
this.inputEl()?.nativeElement.focus();
|
|
3386
3724
|
}
|
|
3387
3725
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: CommandTerminal, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
3388
|
-
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)=\"
|
|
3726
|
+
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() && !pending()) {\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]=\"pending()?.masked ? 'password' : 'text'\"\n [class.answering]=\"!!pending()\"\n [ngModel]=\"currentInput()\"\n (ngModelChange)=\"onInputChange($event)\"\n (keydown.enter)=\"onSubmit()\"\n (keydown.tab)=\"onTab($any($event))\"\n (keydown.escape)=\"onEscape()\"\n [placeholder]=\"pending()?.question ?? 'Type a command...'\"\n [attr.autocomplete]=\"pending()?.masked ? 'one-time-code' : 'off'\"\n [disabled]=\"isProcessing()\"\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}.terminal-input.answering::placeholder{color:var(--color-neutral);opacity:1}\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 }); }
|
|
3389
3727
|
}
|
|
3390
3728
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: CommandTerminal, decorators: [{
|
|
3391
3729
|
type: Component,
|
|
3392
|
-
args: [{ selector: 'shell-command-terminal', imports: [FormsModule], changeDetection: ChangeDetectionStrategy.Eager, 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)=\"
|
|
3730
|
+
args: [{ selector: 'shell-command-terminal', imports: [FormsModule], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"terminal\">\n <div class=\"terminal-input-row\">\n <span class=\"prompt\">$</span>\n <div class=\"input-wrapper\">\n @if (ghostText() && !pending()) {\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]=\"pending()?.masked ? 'password' : 'text'\"\n [class.answering]=\"!!pending()\"\n [ngModel]=\"currentInput()\"\n (ngModelChange)=\"onInputChange($event)\"\n (keydown.enter)=\"onSubmit()\"\n (keydown.tab)=\"onTab($any($event))\"\n (keydown.escape)=\"onEscape()\"\n [placeholder]=\"pending()?.question ?? 'Type a command...'\"\n [attr.autocomplete]=\"pending()?.masked ? 'one-time-code' : 'off'\"\n [disabled]=\"isProcessing()\"\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}.terminal-input.answering::placeholder{color:var(--color-neutral);opacity:1}\n"] }]
|
|
3393
3731
|
}], ctorParameters: () => [], propDecorators: { focusOnAppear: [{ type: i0.Input, args: [{ isSignal: true, alias: "focusOnAppear", required: false }] }], inputEl: [{ type: i0.ViewChild, args: ['terminalInput', { isSignal: true }] }] } });
|
|
3394
3732
|
|
|
3395
3733
|
const MIN_TERMINAL_HEIGHT = 80;
|
|
3396
3734
|
const MAX_TERMINAL_RATIO = 0.6;
|
|
3397
3735
|
const DEFAULT_TERMINAL_HEIGHT = 160;
|
|
3736
|
+
/**
|
|
3737
|
+
* How often a signed-in window re-reads whether the AWS-set bypass is on
|
|
3738
|
+
* (authenticator-sign-in.prd.md D12). The server never says when it ends, so the
|
|
3739
|
+
* banner goes away on the first read after it has.
|
|
3740
|
+
*/
|
|
3741
|
+
const BYPASS_RECHECK_MS = 2 * 60 * 1000;
|
|
3398
3742
|
class Shell {
|
|
3399
3743
|
constructor() {
|
|
3400
3744
|
this.#thinkingService = inject(ThinkingService);
|
|
@@ -3427,6 +3771,21 @@ class Shell {
|
|
|
3427
3771
|
*/
|
|
3428
3772
|
this.consoleFocusOnAppear = computed(() => !this.isPhone(), /* @ts-ignore */
|
|
3429
3773
|
...(ngDevMode ? [{ debugName: "consoleFocusOnAppear" }] : /* istanbul ignore next */ []));
|
|
3774
|
+
/** "Sign-in code check is off." on every screen while the bypass is on (D12). */
|
|
3775
|
+
this.bypassActive = this.#authService.bypassActive;
|
|
3776
|
+
/**
|
|
3777
|
+
* Keeps `bypassActive` current while signed in: once now (a window restored from
|
|
3778
|
+
* storage never saw the sign-in response) and then every two minutes.
|
|
3779
|
+
*/
|
|
3780
|
+
this.#bypassWatch = effect(onCleanup => {
|
|
3781
|
+
if (!this.isAuthenticated())
|
|
3782
|
+
return;
|
|
3783
|
+
const read = () => { this.#authService.authenticatorStatus().catch(() => undefined); };
|
|
3784
|
+
read();
|
|
3785
|
+
const timer = setInterval(read, BYPASS_RECHECK_MS);
|
|
3786
|
+
onCleanup(() => clearInterval(timer));
|
|
3787
|
+
}, /* @ts-ignore */
|
|
3788
|
+
...(ngDevMode ? [{ debugName: "#bypassWatch" }] : /* istanbul ignore next */ []));
|
|
3430
3789
|
this.terminalHeight = signal(`${DEFAULT_TERMINAL_HEIGHT}px`, /* @ts-ignore */
|
|
3431
3790
|
...(ngDevMode ? [{ debugName: "terminalHeight" }] : /* istanbul ignore next */ []));
|
|
3432
3791
|
this.isDragging = signal(false, /* @ts-ignore */
|
|
@@ -3459,6 +3818,11 @@ class Shell {
|
|
|
3459
3818
|
#authService;
|
|
3460
3819
|
#terminalVisibility;
|
|
3461
3820
|
#phoneLayout;
|
|
3821
|
+
/**
|
|
3822
|
+
* Keeps `bypassActive` current while signed in: once now (a window restored from
|
|
3823
|
+
* storage never saw the sign-in response) and then every two minutes.
|
|
3824
|
+
*/
|
|
3825
|
+
#bypassWatch;
|
|
3462
3826
|
closePhoneMenu() {
|
|
3463
3827
|
this.#phoneLayout.closeMenu();
|
|
3464
3828
|
}
|
|
@@ -3500,13 +3864,97 @@ class Shell {
|
|
|
3500
3864
|
document.addEventListener('touchcancel', this.#onTouchEnd);
|
|
3501
3865
|
}
|
|
3502
3866
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: Shell, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
3503
|
-
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 <faq-modal />\n\n @if (helpOverlay(); as helpText) {\n <div class=\"help-backdrop\" (click)=\"dismissHelp()\">\n <div class=\"help-panel\" (click)=\"$event.stopPropagation()\" role=\"dialog\" aria-label=\"Command help\">\n <div class=\"help-panel-header\">\n <span class=\"help-panel-title\">Help</span>\n <button class=\"help-panel-close\" type=\"button\" (click)=\"dismissHelp()\" aria-label=\"Close help\">\u2715</button>\n </div>\n <div class=\"help-panel-content\">{{ helpText }}</div>\n <div class=\"help-panel-footer\">\n <span>Tip: for most commands you can swap the first and second words \u2014 e.g. \"disable user\" works the same as \"user disable\"</span>\n <span class=\"help-panel-footer-dismiss\">Press Escape, click outside, or start typing to close</span>\n </div>\n </div>\n </div>\n }\n</div>\n", styles: [".shell{position:relative;display:grid;grid-template-areas:\"status status\" \"menu main\" \"resize resize\" \"terminal terminal\";grid-template-columns:56px 1fr;grid-template-rows:48px 1fr 4px var(--terminal-height, 160px);height:100vh;height:100dvh;width:100vw;overflow:hidden;background-color:var(--color-surface-dark)}.shell.terminal-hidden{grid-template-rows:48px 1fr 0 0}.shell-status{grid-area:status}.shell-menu{grid-area:menu}.shell-main{grid-area:main;overflow:hidden}.shell-main.full-width{grid-column:1 / -1}.shell-resize{grid-area:resize;cursor:row-resize;touch-action:none;background-color:var(--color-surface-border);transition:background-color .15s ease}.shell-resize:hover,.shell-resize.dragging{background-color:var(--color-interactive)}.shell-terminal{grid-area:terminal}.shell.is-dragging{user-select:none;-webkit-user-select:none}.disabled-zone{pointer-events:none;opacity:.4;transition:opacity .2s ease}.help-backdrop{position:absolute;inset:0;z-index:100;background-color:#000000a6;display:flex;align-items:center;justify-content:center}.help-panel{background-color:var(--color-surface, #161b22);border:2px solid var(--color-interactive, #00e5ff);border-radius:4px;width:70%;max-width:820px;max-height:75vh;display:flex;flex-direction:column;font-family:Roboto Mono,Courier New,monospace;font-size:.85rem}.help-panel-header{display:flex;justify-content:space-between;align-items:center;padding:8px 16px;background-color:var(--color-surface-light, #21262d);border-bottom:1px solid var(--color-interactive, #00e5ff);border-radius:2px 2px 0 0;flex-shrink:0}.help-panel-title{color:var(--color-interactive, #00e5ff);font-weight:700;letter-spacing:.05em;text-transform:uppercase;font-size:.75rem}.help-panel-close{background:none;border:1px solid var(--color-surface-border, #30363d);color:var(--color-secondary-info, #9e9e9e);cursor:pointer;padding:2px 8px;font-size:.9rem;line-height:1.4;border-radius:3px;transition:color .15s ease,border-color .15s ease}.help-panel-close:hover{color:var(--color-text-primary, #ffffff);border-color:var(--color-interactive, #00e5ff)}.help-panel-content{flex:1;overflow-y:auto;padding:16px 20px;color:var(--color-text-primary, #ffffff);white-space:pre-wrap;line-height:1.7}.help-panel-footer{padding:6px 16px;background-color:var(--color-surface-light, #21262d);border-top:1px solid var(--color-surface-border, #30363d);border-radius:0 0 2px 2px;color:var(--color-secondary-info, #9e9e9e);font-size:.75rem;flex-shrink:0;display:flex;flex-direction:column;gap:2px}.help-panel-footer-dismiss{opacity:.6}@media(max-width:819.98px){.shell{grid-template-areas:\"status\" \"main\" \"resize\" \"terminal\";grid-template-columns:minmax(0,1fr)}.shell-menu{display:none}}.phone-menu-backdrop{position:absolute;inset:48px 0 0;z-index:50;background-color:color-mix(in srgb,var(--color-surface-dark) 70%,transparent)}.phone-menu{position:absolute;top:48px;bottom:0;left:0;z-index:51;display:block;width:min(280px,85vw);box-shadow:4px 0 16px color-mix(in srgb,var(--color-surface-dark) 60%,transparent)}\n"], dependencies: [{ kind: "component", type: StatusBar, selector: "shell-status-bar" }, { kind: "component", type: SideMenu, selector: "shell-side-menu", inputs: ["drawer"] }, { kind: "component", type: MainContent, selector: "shell-main-content" }, { kind: "component", type: CommandTerminal, selector: "shell-command-terminal", inputs: ["focusOnAppear"] }, { kind: "component", type: FaqModal, selector: "faq-modal" }, { kind: "directive", type: CdkTrapFocus, selector: "[cdkTrapFocus]", inputs: ["cdkTrapFocus", "cdkTrapFocusAutoCapture"], exportAs: ["cdkTrapFocus"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
|
|
3867
|
+
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 }); }
|
|
3504
3868
|
}
|
|
3505
3869
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: Shell, decorators: [{
|
|
3506
3870
|
type: Component,
|
|
3507
|
-
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 <faq-modal />\n\n @if (helpOverlay(); as helpText) {\n <div class=\"help-backdrop\" (click)=\"dismissHelp()\">\n <div class=\"help-panel\" (click)=\"$event.stopPropagation()\" role=\"dialog\" aria-label=\"Command help\">\n <div class=\"help-panel-header\">\n <span class=\"help-panel-title\">Help</span>\n <button class=\"help-panel-close\" type=\"button\" (click)=\"dismissHelp()\" aria-label=\"Close help\">\u2715</button>\n </div>\n <div class=\"help-panel-content\">{{ helpText }}</div>\n <div class=\"help-panel-footer\">\n <span>Tip: for most commands you can swap the first and second words \u2014 e.g. \"disable user\" works the same as \"user disable\"</span>\n <span class=\"help-panel-footer-dismiss\">Press Escape, click outside, or start typing to close</span>\n </div>\n </div>\n </div>\n }\n</div>\n", styles: [".shell{position:relative;display:grid;grid-template-areas:\"status status\" \"menu main\" \"resize resize\" \"terminal terminal\";grid-template-columns:56px 1fr;grid-template-rows:48px 1fr 4px var(--terminal-height, 160px);height:100vh;height:100dvh;width:100vw;overflow:hidden;background-color:var(--color-surface-dark)}.shell.terminal-hidden{grid-template-rows:48px 1fr 0 0}.shell-status{grid-area:status}.shell-menu{grid-area:menu}.shell-main{grid-area:main;overflow:hidden}.shell-main.full-width{grid-column:1 / -1}.shell-resize{grid-area:resize;cursor:row-resize;touch-action:none;background-color:var(--color-surface-border);transition:background-color .15s ease}.shell-resize:hover,.shell-resize.dragging{background-color:var(--color-interactive)}.shell-terminal{grid-area:terminal}.shell.is-dragging{user-select:none;-webkit-user-select:none}.disabled-zone{pointer-events:none;opacity:.4;transition:opacity .2s ease}.help-backdrop{position:absolute;inset:0;z-index:100;background-color:#000000a6;display:flex;align-items:center;justify-content:center}.help-panel{background-color:var(--color-surface, #161b22);border:2px solid var(--color-interactive, #00e5ff);border-radius:4px;width:70%;max-width:820px;max-height:75vh;display:flex;flex-direction:column;font-family:Roboto Mono,Courier New,monospace;font-size:.85rem}.help-panel-header{display:flex;justify-content:space-between;align-items:center;padding:8px 16px;background-color:var(--color-surface-light, #21262d);border-bottom:1px solid var(--color-interactive, #00e5ff);border-radius:2px 2px 0 0;flex-shrink:0}.help-panel-title{color:var(--color-interactive, #00e5ff);font-weight:700;letter-spacing:.05em;text-transform:uppercase;font-size:.75rem}.help-panel-close{background:none;border:1px solid var(--color-surface-border, #30363d);color:var(--color-secondary-info, #9e9e9e);cursor:pointer;padding:2px 8px;font-size:.9rem;line-height:1.4;border-radius:3px;transition:color .15s ease,border-color .15s ease}.help-panel-close:hover{color:var(--color-text-primary, #ffffff);border-color:var(--color-interactive, #00e5ff)}.help-panel-content{flex:1;overflow-y:auto;padding:16px 20px;color:var(--color-text-primary, #ffffff);white-space:pre-wrap;line-height:1.7}.help-panel-footer{padding:6px 16px;background-color:var(--color-surface-light, #21262d);border-top:1px solid var(--color-surface-border, #30363d);border-radius:0 0 2px 2px;color:var(--color-secondary-info, #9e9e9e);font-size:.75rem;flex-shrink:0;display:flex;flex-direction:column;gap:2px}.help-panel-footer-dismiss{opacity:.6}@media(max-width:819.98px){.shell{grid-template-areas:\"status\" \"main\" \"resize\" \"terminal\";grid-template-columns:minmax(0,1fr)}.shell-menu{display:none}}.phone-menu-backdrop{position:absolute;inset:48px 0 0;z-index:50;background-color:color-mix(in srgb,var(--color-surface-dark) 70%,transparent)}.phone-menu{position:absolute;top:48px;bottom:0;left:0;z-index:51;display:block;width:min(280px,85vw);box-shadow:4px 0 16px color-mix(in srgb,var(--color-surface-dark) 60%,transparent)}\n"] }]
|
|
3871
|
+
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"] }]
|
|
3508
3872
|
}] });
|
|
3509
3873
|
|
|
3874
|
+
/*
|
|
3875
|
+
Created by Franz Zemen 2026-10-17
|
|
3876
|
+
License Type: UNLICENSED
|
|
3877
|
+
|
|
3878
|
+
The 10 recovery codes, shown ONCE (authenticator-sign-in.prd.md D7), with Download (a text file made
|
|
3879
|
+
in the browser — nothing is stored on the server as a file) and Copy. "Continue" is the only way on,
|
|
3880
|
+
so the codes cannot scroll past unseen.
|
|
3881
|
+
*/
|
|
3882
|
+
class RecoveryCodes {
|
|
3883
|
+
constructor() {
|
|
3884
|
+
this.codes = input.required(/* @ts-ignore */
|
|
3885
|
+
...(ngDevMode ? [{ debugName: "codes" }] : /* istanbul ignore next */ []));
|
|
3886
|
+
this.appName = input('BrokenStock', /* @ts-ignore */
|
|
3887
|
+
...(ngDevMode ? [{ debugName: "appName" }] : /* istanbul ignore next */ []));
|
|
3888
|
+
this.username = input(undefined, /* @ts-ignore */
|
|
3889
|
+
...(ngDevMode ? [{ debugName: "username" }] : /* istanbul ignore next */ []));
|
|
3890
|
+
this.done = output();
|
|
3891
|
+
this.copied = signal(false, /* @ts-ignore */
|
|
3892
|
+
...(ngDevMode ? [{ debugName: "copied" }] : /* istanbul ignore next */ []));
|
|
3893
|
+
}
|
|
3894
|
+
download() {
|
|
3895
|
+
const text = recoveryCodesFile(this.appName(), this.username(), this.codes());
|
|
3896
|
+
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
|
|
3897
|
+
const a = document.createElement('a');
|
|
3898
|
+
a.href = url;
|
|
3899
|
+
a.download = `${this.appName().toLowerCase().replace(/[^a-z0-9]+/g, '-')}-recovery-codes.txt`;
|
|
3900
|
+
a.click();
|
|
3901
|
+
setTimeout(() => URL.revokeObjectURL(url));
|
|
3902
|
+
}
|
|
3903
|
+
copy() {
|
|
3904
|
+
navigator.clipboard?.writeText(this.codes().join('\n')).then(() => this.copied.set(true)).catch(() => undefined);
|
|
3905
|
+
}
|
|
3906
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: RecoveryCodes, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
3907
|
+
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: `
|
|
3908
|
+
<div class="recovery-codes">
|
|
3909
|
+
<p class="recovery-codes-lead text-text-primary"><strong>Save your recovery codes.</strong></p>
|
|
3910
|
+
<p class="text-secondary-info">
|
|
3911
|
+
Each one works once, in place of the 6-digit code, if you lose your phone.
|
|
3912
|
+
They will not be shown again.
|
|
3913
|
+
</p>
|
|
3914
|
+
<ol class="recovery-codes-list">
|
|
3915
|
+
@for (code of codes(); track code) {
|
|
3916
|
+
<li><code>{{ code }}</code></li>
|
|
3917
|
+
}
|
|
3918
|
+
</ol>
|
|
3919
|
+
<div class="recovery-codes-actions">
|
|
3920
|
+
<button mat-stroked-button type="button" (click)="download()">
|
|
3921
|
+
<mat-icon>download</mat-icon> Download
|
|
3922
|
+
</button>
|
|
3923
|
+
<button mat-stroked-button type="button" (click)="copy()">
|
|
3924
|
+
<mat-icon>content_copy</mat-icon> {{ copied() ? 'Copied' : 'Copy' }}
|
|
3925
|
+
</button>
|
|
3926
|
+
<button mat-flat-button type="button" (click)="done.emit()">I have saved them — continue</button>
|
|
3927
|
+
</div>
|
|
3928
|
+
</div>
|
|
3929
|
+
`, 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 }); }
|
|
3930
|
+
}
|
|
3931
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: RecoveryCodes, decorators: [{
|
|
3932
|
+
type: Component,
|
|
3933
|
+
args: [{ selector: 'shell-recovery-codes', changeDetection: ChangeDetectionStrategy.OnPush, imports: [MatButtonModule, MatIconModule], template: `
|
|
3934
|
+
<div class="recovery-codes">
|
|
3935
|
+
<p class="recovery-codes-lead text-text-primary"><strong>Save your recovery codes.</strong></p>
|
|
3936
|
+
<p class="text-secondary-info">
|
|
3937
|
+
Each one works once, in place of the 6-digit code, if you lose your phone.
|
|
3938
|
+
They will not be shown again.
|
|
3939
|
+
</p>
|
|
3940
|
+
<ol class="recovery-codes-list">
|
|
3941
|
+
@for (code of codes(); track code) {
|
|
3942
|
+
<li><code>{{ code }}</code></li>
|
|
3943
|
+
}
|
|
3944
|
+
</ol>
|
|
3945
|
+
<div class="recovery-codes-actions">
|
|
3946
|
+
<button mat-stroked-button type="button" (click)="download()">
|
|
3947
|
+
<mat-icon>download</mat-icon> Download
|
|
3948
|
+
</button>
|
|
3949
|
+
<button mat-stroked-button type="button" (click)="copy()">
|
|
3950
|
+
<mat-icon>content_copy</mat-icon> {{ copied() ? 'Copied' : 'Copy' }}
|
|
3951
|
+
</button>
|
|
3952
|
+
<button mat-flat-button type="button" (click)="done.emit()">I have saved them — continue</button>
|
|
3953
|
+
</div>
|
|
3954
|
+
</div>
|
|
3955
|
+
`, 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"] }]
|
|
3956
|
+
}], 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"] }] } });
|
|
3957
|
+
|
|
3510
3958
|
class Login {
|
|
3511
3959
|
#postLoginRoute;
|
|
3512
3960
|
#commandService;
|
|
@@ -3532,6 +3980,11 @@ class Login {
|
|
|
3532
3980
|
*/
|
|
3533
3981
|
this.selected = signal(new Set(), /* @ts-ignore */
|
|
3534
3982
|
...(ngDevMode ? [{ debugName: "selected" }] : /* istanbul ignore next */ []));
|
|
3983
|
+
/** The code or recovery code typed on the code step (authenticator-sign-in.prd.md). */
|
|
3984
|
+
this.codeText = '';
|
|
3985
|
+
/** Typing a recovery code instead of the 6-digit code. */
|
|
3986
|
+
this.useRecovery = signal(false, /* @ts-ignore */
|
|
3987
|
+
...(ngDevMode ? [{ debugName: "useRecovery" }] : /* istanbul ignore next */ []));
|
|
3535
3988
|
this.USERNAME_REGEX = USERNAME_REGEX;
|
|
3536
3989
|
this.PASSWORD_REGEX = PASSWORD_REGEX;
|
|
3537
3990
|
this.appName = inject(SHELL_APP_NAME, { optional: true }) ?? 'Application';
|
|
@@ -3642,6 +4095,37 @@ class Login {
|
|
|
3642
4095
|
this.selected.set(new Set());
|
|
3643
4096
|
this.#doLogin(false, { endSessionIds: ids });
|
|
3644
4097
|
}
|
|
4098
|
+
/** The code step: the 6-digit code (or a recovery code), with the credentials already accepted. */
|
|
4099
|
+
onSubmitCode(event) {
|
|
4100
|
+
event.preventDefault();
|
|
4101
|
+
this.errors = [];
|
|
4102
|
+
const typed = this.codeText.trim();
|
|
4103
|
+
if (!typed)
|
|
4104
|
+
return;
|
|
4105
|
+
this.submitted = true;
|
|
4106
|
+
this.#doLogin(false, this.useRecovery() ? { recoveryCode: typed } : { code: typed });
|
|
4107
|
+
}
|
|
4108
|
+
onToggleRecovery() {
|
|
4109
|
+
this.useRecovery.set(!this.useRecovery());
|
|
4110
|
+
this.codeText = '';
|
|
4111
|
+
this.errors = [];
|
|
4112
|
+
}
|
|
4113
|
+
/** Back to the password form. */
|
|
4114
|
+
onCancelCode() {
|
|
4115
|
+
this.authenticationService.cancelCodeStep();
|
|
4116
|
+
this.codeText = '';
|
|
4117
|
+
this.useRecovery.set(false);
|
|
4118
|
+
this.errors = [];
|
|
4119
|
+
this.submitted = false;
|
|
4120
|
+
}
|
|
4121
|
+
/** New recovery codes were shown after sign-in; now go where sign-in was going. */
|
|
4122
|
+
onRecoveryCodesSaved() {
|
|
4123
|
+
this.authenticationService.acknowledgeRecoveryCodes();
|
|
4124
|
+
// Setup can finish on an attempt that then stops at "already signed in
|
|
4125
|
+
// elsewhere": the codes are shown first, and the session choice is still open.
|
|
4126
|
+
if (this.authenticationService.authenticated())
|
|
4127
|
+
this.#navigateAfterLogin();
|
|
4128
|
+
}
|
|
3645
4129
|
onCancelConflict() {
|
|
3646
4130
|
this.authenticationService.clearConflict();
|
|
3647
4131
|
this.selected.set(new Set());
|
|
@@ -3652,19 +4136,31 @@ class Login {
|
|
|
3652
4136
|
.login(this.model, force, choice)
|
|
3653
4137
|
.then(() => {
|
|
3654
4138
|
this.#commandService.echoCommand(`login ${this.model.username}`);
|
|
3655
|
-
this.
|
|
3656
|
-
|
|
3657
|
-
|
|
4139
|
+
this.codeText = '';
|
|
4140
|
+
this.useRecovery.set(false);
|
|
4141
|
+
// Setup finished during this sign-in: the codes are on screen and must be
|
|
4142
|
+
// seen before the app takes over the page.
|
|
4143
|
+
if (this.authenticationService.recoveryCodesToShow()) {
|
|
4144
|
+
this.submitted = false;
|
|
4145
|
+
return;
|
|
4146
|
+
}
|
|
4147
|
+
this.#navigateAfterLogin();
|
|
3658
4148
|
})
|
|
3659
4149
|
.catch((err) => {
|
|
3660
|
-
if (err !== 'conflict') {
|
|
4150
|
+
if (err !== 'conflict' && err !== CODE_STEP) {
|
|
3661
4151
|
this.errors.push({ ndx: 0, message: typeof err === 'string' ? err : 'Unable to login' });
|
|
3662
4152
|
}
|
|
4153
|
+
this.codeText = '';
|
|
3663
4154
|
this.submitted = false;
|
|
3664
4155
|
});
|
|
3665
4156
|
}
|
|
4157
|
+
#navigateAfterLogin() {
|
|
4158
|
+
this.router.navigateByUrl(this.#resolvePostLoginUrl()).then(() => {
|
|
4159
|
+
setTimeout(() => this.#terminalFocusService.requestFocus());
|
|
4160
|
+
}).catch(console.error);
|
|
4161
|
+
}
|
|
3666
4162
|
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 }); }
|
|
3667
|
-
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 }); }
|
|
4163
|
+
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 }); }
|
|
3668
4164
|
}
|
|
3669
4165
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: Login, decorators: [{
|
|
3670
4166
|
type: Component,
|
|
@@ -3680,9 +4176,240 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
|
|
|
3680
4176
|
RegexValidatorDirective,
|
|
3681
4177
|
TextFieldModule,
|
|
3682
4178
|
DatePipe,
|
|
3683
|
-
|
|
4179
|
+
RecoveryCodes,
|
|
4180
|
+
], 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"] }]
|
|
3684
4181
|
}], ctorParameters: () => [{ type: AuthenticationService }, { type: i2$1.Router }] });
|
|
3685
4182
|
|
|
4183
|
+
/*
|
|
4184
|
+
Created by Franz Zemen 2026-10-17
|
|
4185
|
+
License Type: UNLICENSED
|
|
4186
|
+
|
|
4187
|
+
Settings → Sign-in code (authenticator-sign-in.prd.md D5, D7). Shared by every app: each routes to
|
|
4188
|
+
`AuthenticatorSettings` wherever its settings live.
|
|
4189
|
+
|
|
4190
|
+
- Set up an authenticator (or replace one): the password again, plus a current code or a
|
|
4191
|
+
recovery code when replacing → QR + typed-in key → the first code → 10 recovery codes.
|
|
4192
|
+
- New recovery codes: a current code → 10 new ones; the old ones stop working.
|
|
4193
|
+
|
|
4194
|
+
⛔ There is deliberately no "remove" and no "turn off" here (D13). Those are AWS-only; the screen
|
|
4195
|
+
says how to get them done.
|
|
4196
|
+
*/
|
|
4197
|
+
class AuthenticatorSettings {
|
|
4198
|
+
constructor() {
|
|
4199
|
+
this.#auth = inject(AuthenticationService);
|
|
4200
|
+
this.appName = inject(SHELL_APP_NAME, { optional: true }) ?? 'BrokenStock';
|
|
4201
|
+
this.status = signal(undefined, /* @ts-ignore */
|
|
4202
|
+
...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
|
|
4203
|
+
this.mode = signal('view', /* @ts-ignore */
|
|
4204
|
+
...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
|
|
4205
|
+
this.busy = signal(false, /* @ts-ignore */
|
|
4206
|
+
...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
4207
|
+
this.error = signal(undefined, /* @ts-ignore */
|
|
4208
|
+
...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
4209
|
+
this.challenge = signal(undefined, /* @ts-ignore */
|
|
4210
|
+
...(ngDevMode ? [{ debugName: "challenge" }] : /* istanbul ignore next */ []));
|
|
4211
|
+
this.codes = signal(undefined, /* @ts-ignore */
|
|
4212
|
+
...(ngDevMode ? [{ debugName: "codes" }] : /* istanbul ignore next */ []));
|
|
4213
|
+
this.useRecovery = signal(false, /* @ts-ignore */
|
|
4214
|
+
...(ngDevMode ? [{ debugName: "useRecovery" }] : /* istanbul ignore next */ []));
|
|
4215
|
+
this.password = '';
|
|
4216
|
+
this.code = '';
|
|
4217
|
+
}
|
|
4218
|
+
#auth;
|
|
4219
|
+
ngOnInit() {
|
|
4220
|
+
void this.#load();
|
|
4221
|
+
}
|
|
4222
|
+
async #load() {
|
|
4223
|
+
try {
|
|
4224
|
+
this.status.set(await this.#auth.authenticatorStatus());
|
|
4225
|
+
}
|
|
4226
|
+
catch (err) {
|
|
4227
|
+
this.error.set(err instanceof AuthenticatorError ? err.message : 'Could not load your sign-in code settings.');
|
|
4228
|
+
}
|
|
4229
|
+
}
|
|
4230
|
+
begin(mode) {
|
|
4231
|
+
this.#reset();
|
|
4232
|
+
this.mode.set(mode);
|
|
4233
|
+
}
|
|
4234
|
+
cancel() {
|
|
4235
|
+
this.#reset();
|
|
4236
|
+
this.mode.set('view');
|
|
4237
|
+
}
|
|
4238
|
+
toggleRecovery() {
|
|
4239
|
+
this.useRecovery.set(!this.useRecovery());
|
|
4240
|
+
this.code = '';
|
|
4241
|
+
}
|
|
4242
|
+
/** Password (and current code when replacing) → QR. */
|
|
4243
|
+
async start(event) {
|
|
4244
|
+
event.preventDefault();
|
|
4245
|
+
await this.#run(async () => {
|
|
4246
|
+
const current = this.status()?.enrolled
|
|
4247
|
+
? (this.useRecovery() ? { recoveryCode: this.code.trim() } : { code: this.code.trim() })
|
|
4248
|
+
: {};
|
|
4249
|
+
this.challenge.set(await this.#auth.startAuthenticatorSetup(this.password, current));
|
|
4250
|
+
this.password = '';
|
|
4251
|
+
this.code = '';
|
|
4252
|
+
this.useRecovery.set(false);
|
|
4253
|
+
this.mode.set('scan');
|
|
4254
|
+
});
|
|
4255
|
+
}
|
|
4256
|
+
/** The first code from the newly scanned app → recovery codes. */
|
|
4257
|
+
async confirm(event) {
|
|
4258
|
+
event.preventDefault();
|
|
4259
|
+
await this.#run(async () => {
|
|
4260
|
+
this.codes.set(await this.#auth.confirmAuthenticatorSetup(this.code.trim()));
|
|
4261
|
+
this.challenge.set(undefined);
|
|
4262
|
+
this.code = '';
|
|
4263
|
+
this.mode.set('codes');
|
|
4264
|
+
});
|
|
4265
|
+
}
|
|
4266
|
+
async reissue(event) {
|
|
4267
|
+
event.preventDefault();
|
|
4268
|
+
await this.#run(async () => {
|
|
4269
|
+
this.codes.set(await this.#auth.reissueRecoveryCodes(this.code.trim()));
|
|
4270
|
+
this.code = '';
|
|
4271
|
+
this.mode.set('codes');
|
|
4272
|
+
});
|
|
4273
|
+
}
|
|
4274
|
+
async codesSaved() {
|
|
4275
|
+
this.#reset();
|
|
4276
|
+
this.mode.set('view');
|
|
4277
|
+
await this.#load();
|
|
4278
|
+
}
|
|
4279
|
+
async #run(work) {
|
|
4280
|
+
this.busy.set(true);
|
|
4281
|
+
this.error.set(undefined);
|
|
4282
|
+
try {
|
|
4283
|
+
await work();
|
|
4284
|
+
}
|
|
4285
|
+
catch (err) {
|
|
4286
|
+
this.error.set(err instanceof AuthenticatorError ? err.message : 'Something went wrong');
|
|
4287
|
+
this.code = '';
|
|
4288
|
+
}
|
|
4289
|
+
finally {
|
|
4290
|
+
this.busy.set(false);
|
|
4291
|
+
}
|
|
4292
|
+
}
|
|
4293
|
+
#reset() {
|
|
4294
|
+
this.password = '';
|
|
4295
|
+
this.code = '';
|
|
4296
|
+
this.error.set(undefined);
|
|
4297
|
+
this.challenge.set(undefined);
|
|
4298
|
+
this.codes.set(undefined);
|
|
4299
|
+
this.useRecovery.set(false);
|
|
4300
|
+
}
|
|
4301
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: AuthenticatorSettings, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
4302
|
+
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 }); }
|
|
4303
|
+
}
|
|
4304
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: AuthenticatorSettings, decorators: [{
|
|
4305
|
+
type: Component,
|
|
4306
|
+
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"] }]
|
|
4307
|
+
}] });
|
|
4308
|
+
|
|
4309
|
+
/*
|
|
4310
|
+
Created by Franz Zemen 2026-10-17
|
|
4311
|
+
License Type: UNLICENSED
|
|
4312
|
+
|
|
4313
|
+
"Enter your code" — the reusable dialog for the on-demand check (authenticator-sign-in.prd.md D15).
|
|
4314
|
+
Live Trading opens it to arm trading; anything else that needs the code again can too.
|
|
4315
|
+
|
|
4316
|
+
Closes with the time the server recorded (epoch ms), or `undefined` if cancelled. Wrong codes keep
|
|
4317
|
+
the dialog open with the reason in plain words; after five, "Too many attempts." and nothing more.
|
|
4318
|
+
|
|
4319
|
+
const at = await openCodeDialog(dialog, {title: 'Arm live trading', purpose: 'arm-live-trading'});
|
|
4320
|
+
*/
|
|
4321
|
+
class CodeDialog {
|
|
4322
|
+
constructor() {
|
|
4323
|
+
this.data = inject(MAT_DIALOG_DATA);
|
|
4324
|
+
this.dialogRef = inject((MatDialogRef));
|
|
4325
|
+
this.#auth = inject(AuthenticationService);
|
|
4326
|
+
this.text = '';
|
|
4327
|
+
this.useRecovery = signal(false, /* @ts-ignore */
|
|
4328
|
+
...(ngDevMode ? [{ debugName: "useRecovery" }] : /* istanbul ignore next */ []));
|
|
4329
|
+
this.busy = signal(false, /* @ts-ignore */
|
|
4330
|
+
...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
4331
|
+
this.error = signal(undefined, /* @ts-ignore */
|
|
4332
|
+
...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
4333
|
+
}
|
|
4334
|
+
#auth;
|
|
4335
|
+
toggleRecovery() {
|
|
4336
|
+
this.useRecovery.set(!this.useRecovery());
|
|
4337
|
+
this.text = '';
|
|
4338
|
+
this.error.set(undefined);
|
|
4339
|
+
}
|
|
4340
|
+
async submit(event) {
|
|
4341
|
+
event.preventDefault();
|
|
4342
|
+
const typed = this.text.trim();
|
|
4343
|
+
if (!typed)
|
|
4344
|
+
return;
|
|
4345
|
+
this.busy.set(true);
|
|
4346
|
+
this.error.set(undefined);
|
|
4347
|
+
try {
|
|
4348
|
+
const at = await this.#auth.verifyCode(this.useRecovery() ? { recoveryCode: typed } : { code: typed }, this.data.purpose);
|
|
4349
|
+
this.dialogRef.close(at);
|
|
4350
|
+
}
|
|
4351
|
+
catch (err) {
|
|
4352
|
+
this.error.set(err instanceof AuthenticatorError ? err.message : 'Something went wrong');
|
|
4353
|
+
this.text = '';
|
|
4354
|
+
}
|
|
4355
|
+
finally {
|
|
4356
|
+
this.busy.set(false);
|
|
4357
|
+
}
|
|
4358
|
+
}
|
|
4359
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: CodeDialog, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
4360
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: CodeDialog, isStandalone: true, selector: "shell-code-dialog", ngImport: i0, template: `
|
|
4361
|
+
<h2 mat-dialog-title>{{ data.title }}</h2>
|
|
4362
|
+
<form (submit)="submit($event)">
|
|
4363
|
+
<mat-dialog-content>
|
|
4364
|
+
<p>{{ data.message ?? (useRecovery() ? 'Enter one of your recovery codes.' : 'Enter the 6-digit code from your authenticator app.') }}</p>
|
|
4365
|
+
<mat-form-field appearance="outline" class="code-field">
|
|
4366
|
+
<mat-label>{{ useRecovery() ? 'Recovery code' : 'Code' }}</mat-label>
|
|
4367
|
+
<input matInput name="code" [(ngModel)]="text" [disabled]="busy()"
|
|
4368
|
+
[attr.inputmode]="useRecovery() ? 'text' : 'numeric'"
|
|
4369
|
+
[attr.autocomplete]="useRecovery() ? 'off' : 'one-time-code'" cdkFocusInitial />
|
|
4370
|
+
</mat-form-field>
|
|
4371
|
+
@if (error(); as message) {
|
|
4372
|
+
<p class="code-error text-loss" role="alert">{{ message }}</p>
|
|
4373
|
+
}
|
|
4374
|
+
</mat-dialog-content>
|
|
4375
|
+
<mat-dialog-actions align="end">
|
|
4376
|
+
<button mat-button type="button" (click)="toggleRecovery()">{{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}</button>
|
|
4377
|
+
<button mat-button type="button" (click)="dialogRef.close(undefined)">Cancel</button>
|
|
4378
|
+
<button mat-flat-button type="submit" [disabled]="busy() || !text.trim()">{{ data.confirmLabel ?? 'Confirm' }}</button>
|
|
4379
|
+
</mat-dialog-actions>
|
|
4380
|
+
</form>
|
|
4381
|
+
`, 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 }); }
|
|
4382
|
+
}
|
|
4383
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: CodeDialog, decorators: [{
|
|
4384
|
+
type: Component,
|
|
4385
|
+
args: [{ selector: 'shell-code-dialog', changeDetection: ChangeDetectionStrategy.OnPush, imports: [FormsModule, MatDialogModule, MatButtonModule, MatFormFieldModule, MatInputModule], template: `
|
|
4386
|
+
<h2 mat-dialog-title>{{ data.title }}</h2>
|
|
4387
|
+
<form (submit)="submit($event)">
|
|
4388
|
+
<mat-dialog-content>
|
|
4389
|
+
<p>{{ data.message ?? (useRecovery() ? 'Enter one of your recovery codes.' : 'Enter the 6-digit code from your authenticator app.') }}</p>
|
|
4390
|
+
<mat-form-field appearance="outline" class="code-field">
|
|
4391
|
+
<mat-label>{{ useRecovery() ? 'Recovery code' : 'Code' }}</mat-label>
|
|
4392
|
+
<input matInput name="code" [(ngModel)]="text" [disabled]="busy()"
|
|
4393
|
+
[attr.inputmode]="useRecovery() ? 'text' : 'numeric'"
|
|
4394
|
+
[attr.autocomplete]="useRecovery() ? 'off' : 'one-time-code'" cdkFocusInitial />
|
|
4395
|
+
</mat-form-field>
|
|
4396
|
+
@if (error(); as message) {
|
|
4397
|
+
<p class="code-error text-loss" role="alert">{{ message }}</p>
|
|
4398
|
+
}
|
|
4399
|
+
</mat-dialog-content>
|
|
4400
|
+
<mat-dialog-actions align="end">
|
|
4401
|
+
<button mat-button type="button" (click)="toggleRecovery()">{{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}</button>
|
|
4402
|
+
<button mat-button type="button" (click)="dialogRef.close(undefined)">Cancel</button>
|
|
4403
|
+
<button mat-flat-button type="submit" [disabled]="busy() || !text.trim()">{{ data.confirmLabel ?? 'Confirm' }}</button>
|
|
4404
|
+
</mat-dialog-actions>
|
|
4405
|
+
</form>
|
|
4406
|
+
`, styles: [":host{display:block}.code-field{width:100%}.code-error{margin:0}\n"] }]
|
|
4407
|
+
}] });
|
|
4408
|
+
/** Opens the dialog; resolves with the recorded time (epoch ms), or undefined when cancelled. */
|
|
4409
|
+
function openCodeDialog(dialog, data) {
|
|
4410
|
+
return firstValueFrom(dialog.open(CodeDialog, { data, width: '360px' }).afterClosed());
|
|
4411
|
+
}
|
|
4412
|
+
|
|
3686
4413
|
class ConfirmDialog {
|
|
3687
4414
|
constructor() {
|
|
3688
4415
|
this.data = inject(MAT_DIALOG_DATA);
|
|
@@ -3701,7 +4428,7 @@ class ConfirmDialog {
|
|
|
3701
4428
|
<button mat-button (click)="dialogRef.close(false)">{{ data.cancelLabel ?? 'Cancel' }}</button>
|
|
3702
4429
|
<button mat-flat-button color="warn" (click)="dialogRef.close(true)">{{ data.confirmLabel ?? 'Confirm' }}</button>
|
|
3703
4430
|
</mat-dialog-actions>
|
|
3704
|
-
`, isInline: true, styles: [":host{display:block}.warning-message{color:var(--color-warning);margin-top:8px}\n"], dependencies: [{ kind: "ngmodule", type: MatDialogModule }, { kind: "directive", type:
|
|
4431
|
+
`, 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 }); }
|
|
3705
4432
|
}
|
|
3706
4433
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: ConfirmDialog, decorators: [{
|
|
3707
4434
|
type: Component,
|
|
@@ -3729,5 +4456,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
|
|
|
3729
4456
|
* Generated bundle index. Do not edit.
|
|
3730
4457
|
*/
|
|
3731
4458
|
|
|
3732
|
-
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 };
|
|
4459
|
+
export { AuthenticationService, AuthenticatorError, AuthenticatorSettings, CLEAR_USAGE, CODE_STEP, CodeDialog, CommandRegistryService, CommandService, CommandTerminal, ConfirmDialog, FAQ_CONTENT_LOADER, FAQ_DEV_EDITOR, FAQ_DEV_MODE, FAQ_DEV_WORKSPACE_ROOT, FAQ_PUBLIC_PUBLISHER, FAQ_PUBLIC_SITE_BASE_URL, FaqAuthoringPanel, FaqHistoryService, FaqMarkdownRendererService, FaqModal, FaqService, HELP_USAGE, HttpFaqContentLoader, KeyboardShortcutService, Login, MainContent, PendingPromptState, PhoneLayoutService, PublicFaqIndex, PublicFaqPage, PublicFaqToc, RecoveryCodes, RegexValidatorDirective, SESSION_TOKEN_KEY, SHELL_APP_NAME, SHELL_AUTH_URLS, SHELL_COMMANDS, SHELL_MENU_ITEMS, SHELL_PHONE_MEDIA_QUERY, SHELL_POST_LOGIN_ROUTE, SHELL_SHARED_SESSION, SessionTokenStore, Shell, SideMenu, StatusBar, TOO_MANY_ATTEMPTS, TerminalFocusService, TerminalVisibilityService, ThemeService, ThinkingService, answerEcho, audienceAllowed, authGuard, authInterceptor, authenticatorMessage, buildFallbackChain, buildLoginBody, decideRenewal, guestGuard, isYes, matchesRoute, openCodeDialog, provideShellConfig, recoveryCodesFile, tokenize };
|
|
3733
4460
|
//# sourceMappingURL=franzzemen-brokenstock-shell.mjs.map
|