@franzzemen/brokenstock-shell 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { InjectionToken, makeEnvironmentProviders, signal, computed, Injectable, inject, input, Directive, isDevMode, effect, untracked, HostListener, ViewChild, ViewEncapsulation, ChangeDetectionStrategy, Component, DestroyRef, NgZone, viewChild, afterNextRender } 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);
|
|
@@ -3385,7 +3591,7 @@ class CommandTerminal {
|
|
|
3385
3591
|
this.inputEl()?.nativeElement.focus();
|
|
3386
3592
|
}
|
|
3387
3593
|
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)=\"dismissHelp()\"\n placeholder=\"Type a command...\"\n [disabled]=\"isProcessing()\"\n autocomplete=\"off\"\n spellcheck=\"false\"\n />\n </div>\n </div>\n <div class=\"terminal-output\">\n @for (entry of history(); track entry.result.timestamp) {\n @if (entry.input) {\n <div class=\"terminal-line\">\n <span class=\"prompt\">$</span>\n <span class=\"command-text\">{{ entry.input }}@if ((entry.repeatCount ?? 1) > 1) { <span class=\"repeat-count\">(\u00D7{{ entry.repeatCount }})</span>}</span>\n </div>\n }\n @if (entry.result.message) {\n <div class=\"terminal-line\" [class]=\"getSeverityClass(entry.result)\">{{ entry.result.message }}</div>\n }\n }\n </div>\n</div>\n", styles: [".terminal{display:flex;flex-direction:column;height:100%;background-color:var(--color-terminal-bg);border-top:1px solid var(--color-surface-border);font-family:Roboto Mono,Courier New,monospace;font-size:.85rem}.terminal-output{flex:1;overflow-y:auto;padding:8px 12px;display:flex;flex-direction:column}.terminal-line{line-height:1.6;color:var(--color-text-primary);white-space:pre-wrap}.terminal-line.error{color:var(--color-loss)}.terminal-line.warning{color:var(--color-warning)}.terminal-line.info{color:var(--color-info)}.prompt{color:var(--color-terminal-prompt);margin-right:8px;font-weight:700}.command-text{color:var(--color-terminal-text)}.repeat-count{color:var(--color-secondary-info);opacity:.6;margin-left:4px}.terminal-input-row{display:flex;align-items:center;padding:8px 12px;border-bottom:1px solid var(--color-surface-border)}.input-wrapper{flex:1;display:grid}.ghost-input,.terminal-input{grid-area:1 / 1;font-family:inherit;font-size:inherit;line-height:normal;margin:0;padding:0;border:none;background:transparent;outline:none;-webkit-appearance:none;appearance:none;width:100%}.ghost-input{color:var(--color-secondary-info);opacity:.3;pointer-events:none}.terminal-input{color:var(--color-terminal-text);caret-color:var(--color-terminal-prompt)}.terminal-input::placeholder{color:var(--color-secondary-info);opacity:.5}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type:
|
|
3594
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: CommandTerminal, isStandalone: true, selector: "shell-command-terminal", inputs: { focusOnAppear: { classPropertyName: "focusOnAppear", publicName: "focusOnAppear", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "inputEl", first: true, predicate: ["terminalInput"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"terminal\">\n <div class=\"terminal-input-row\">\n <span class=\"prompt\">$</span>\n <div class=\"input-wrapper\">\n @if (ghostText()) {\n <input\n class=\"ghost-input\"\n type=\"text\"\n [value]=\"ghostText()\"\n disabled\n tabindex=\"-1\"\n aria-hidden=\"true\"\n />\n }\n <input\n #terminalInput\n class=\"terminal-input\"\n type=\"text\"\n [ngModel]=\"currentInput()\"\n (ngModelChange)=\"onInputChange($event)\"\n (keydown.enter)=\"onSubmit()\"\n (keydown.tab)=\"onTab($any($event))\"\n (keydown.escape)=\"dismissHelp()\"\n placeholder=\"Type a command...\"\n [disabled]=\"isProcessing()\"\n autocomplete=\"off\"\n spellcheck=\"false\"\n />\n </div>\n </div>\n <div class=\"terminal-output\">\n @for (entry of history(); track entry.result.timestamp) {\n @if (entry.input) {\n <div class=\"terminal-line\">\n <span class=\"prompt\">$</span>\n <span class=\"command-text\">{{ entry.input }}@if ((entry.repeatCount ?? 1) > 1) { <span class=\"repeat-count\">(\u00D7{{ entry.repeatCount }})</span>}</span>\n </div>\n }\n @if (entry.result.message) {\n <div class=\"terminal-line\" [class]=\"getSeverityClass(entry.result)\">{{ entry.result.message }}</div>\n }\n }\n </div>\n</div>\n", styles: [".terminal{display:flex;flex-direction:column;height:100%;background-color:var(--color-terminal-bg);border-top:1px solid var(--color-surface-border);font-family:Roboto Mono,Courier New,monospace;font-size:.85rem}.terminal-output{flex:1;overflow-y:auto;padding:8px 12px;display:flex;flex-direction:column}.terminal-line{line-height:1.6;color:var(--color-text-primary);white-space:pre-wrap}.terminal-line.error{color:var(--color-loss)}.terminal-line.warning{color:var(--color-warning)}.terminal-line.info{color:var(--color-info)}.prompt{color:var(--color-terminal-prompt);margin-right:8px;font-weight:700}.command-text{color:var(--color-terminal-text)}.repeat-count{color:var(--color-secondary-info);opacity:.6;margin-left:4px}.terminal-input-row{display:flex;align-items:center;padding:8px 12px;border-bottom:1px solid var(--color-surface-border)}.input-wrapper{flex:1;display:grid}.ghost-input,.terminal-input{grid-area:1 / 1;font-family:inherit;font-size:inherit;line-height:normal;margin:0;padding:0;border:none;background:transparent;outline:none;-webkit-appearance:none;appearance:none;width:100%}.ghost-input{color:var(--color-secondary-info);opacity:.3;pointer-events:none}.terminal-input{color:var(--color-terminal-text);caret-color:var(--color-terminal-prompt)}.terminal-input::placeholder{color:var(--color-secondary-info);opacity:.5}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$2.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
|
|
3389
3595
|
}
|
|
3390
3596
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: CommandTerminal, decorators: [{
|
|
3391
3597
|
type: Component,
|
|
@@ -3395,6 +3601,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
|
|
|
3395
3601
|
const MIN_TERMINAL_HEIGHT = 80;
|
|
3396
3602
|
const MAX_TERMINAL_RATIO = 0.6;
|
|
3397
3603
|
const DEFAULT_TERMINAL_HEIGHT = 160;
|
|
3604
|
+
/**
|
|
3605
|
+
* How often a signed-in window re-reads whether the AWS-set bypass is on
|
|
3606
|
+
* (authenticator-sign-in.prd.md D12). The server never says when it ends, so the
|
|
3607
|
+
* banner goes away on the first read after it has.
|
|
3608
|
+
*/
|
|
3609
|
+
const BYPASS_RECHECK_MS = 2 * 60 * 1000;
|
|
3398
3610
|
class Shell {
|
|
3399
3611
|
constructor() {
|
|
3400
3612
|
this.#thinkingService = inject(ThinkingService);
|
|
@@ -3427,6 +3639,21 @@ class Shell {
|
|
|
3427
3639
|
*/
|
|
3428
3640
|
this.consoleFocusOnAppear = computed(() => !this.isPhone(), /* @ts-ignore */
|
|
3429
3641
|
...(ngDevMode ? [{ debugName: "consoleFocusOnAppear" }] : /* istanbul ignore next */ []));
|
|
3642
|
+
/** "Sign-in code check is off." on every screen while the bypass is on (D12). */
|
|
3643
|
+
this.bypassActive = this.#authService.bypassActive;
|
|
3644
|
+
/**
|
|
3645
|
+
* Keeps `bypassActive` current while signed in: once now (a window restored from
|
|
3646
|
+
* storage never saw the sign-in response) and then every two minutes.
|
|
3647
|
+
*/
|
|
3648
|
+
this.#bypassWatch = effect(onCleanup => {
|
|
3649
|
+
if (!this.isAuthenticated())
|
|
3650
|
+
return;
|
|
3651
|
+
const read = () => { this.#authService.authenticatorStatus().catch(() => undefined); };
|
|
3652
|
+
read();
|
|
3653
|
+
const timer = setInterval(read, BYPASS_RECHECK_MS);
|
|
3654
|
+
onCleanup(() => clearInterval(timer));
|
|
3655
|
+
}, /* @ts-ignore */
|
|
3656
|
+
...(ngDevMode ? [{ debugName: "#bypassWatch" }] : /* istanbul ignore next */ []));
|
|
3430
3657
|
this.terminalHeight = signal(`${DEFAULT_TERMINAL_HEIGHT}px`, /* @ts-ignore */
|
|
3431
3658
|
...(ngDevMode ? [{ debugName: "terminalHeight" }] : /* istanbul ignore next */ []));
|
|
3432
3659
|
this.isDragging = signal(false, /* @ts-ignore */
|
|
@@ -3459,6 +3686,11 @@ class Shell {
|
|
|
3459
3686
|
#authService;
|
|
3460
3687
|
#terminalVisibility;
|
|
3461
3688
|
#phoneLayout;
|
|
3689
|
+
/**
|
|
3690
|
+
* Keeps `bypassActive` current while signed in: once now (a window restored from
|
|
3691
|
+
* storage never saw the sign-in response) and then every two minutes.
|
|
3692
|
+
*/
|
|
3693
|
+
#bypassWatch;
|
|
3462
3694
|
closePhoneMenu() {
|
|
3463
3695
|
this.#phoneLayout.closeMenu();
|
|
3464
3696
|
}
|
|
@@ -3500,13 +3732,97 @@ class Shell {
|
|
|
3500
3732
|
document.addEventListener('touchcancel', this.#onTouchEnd);
|
|
3501
3733
|
}
|
|
3502
3734
|
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 }); }
|
|
3735
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: Shell, isStandalone: true, selector: "shell-root", ngImport: i0, template: "<div class=\"shell\" [style.--terminal-height]=\"terminalHeight()\" [class.is-dragging]=\"isDragging()\" [class.terminal-hidden]=\"!terminalVisible()\">\n <shell-status-bar class=\"shell-status\" [class.disabled-zone]=\"isThinking()\">\n <ng-content />\n </shell-status-bar>\n @if (isAuthenticated() && !isPhone()) {\n <shell-side-menu class=\"shell-menu\" [class.disabled-zone]=\"isThinking()\" />\n }\n <shell-main-content class=\"shell-main\" [class.full-width]=\"!isAuthenticated()\" />\n @if (terminalVisible()) {\n <!-- On a phone too (scanner-phone-display D10), but never given the cursor there: see Shell.consoleFocusOnAppear. -->\n <div class=\"shell-resize\" [class.dragging]=\"isDragging()\" (mousedown)=\"onResizeStart($event)\" (touchstart)=\"onResizeTouchStart($event)\"></div>\n <shell-command-terminal class=\"shell-terminal\" [class.disabled-zone]=\"isThinking()\" [focusOnAppear]=\"consoleFocusOnAppear()\" />\n }\n @if (isAuthenticated() && phoneMenuOpen()) {\n <!-- Phone menu (scanner-phone-display D9): the icon column's items, opened from the \u2630 button. -->\n <div class=\"phone-menu-backdrop\" (click)=\"closePhoneMenu()\"></div>\n <shell-side-menu\n id=\"shell-phone-menu\"\n class=\"phone-menu\"\n [class.disabled-zone]=\"isThinking()\"\n [drawer]=\"true\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label=\"Menu\"\n cdkTrapFocus\n [cdkTrapFocusAutoCapture]=\"true\"\n (keydown.escape)=\"closePhoneMenu()\"\n />\n }\n @if (isAuthenticated() && bypassActive()) {\n <!-- authenticator-sign-in.prd.md D12: cannot be dismissed; no end time (the server never sends one). -->\n <div class=\"bypass-banner\" role=\"status\">Sign-in code check is off.</div>\n }\n <faq-modal />\n\n @if (helpOverlay(); as helpText) {\n <div class=\"help-backdrop\" (click)=\"dismissHelp()\">\n <div class=\"help-panel\" (click)=\"$event.stopPropagation()\" role=\"dialog\" aria-label=\"Command help\">\n <div class=\"help-panel-header\">\n <span class=\"help-panel-title\">Help</span>\n <button class=\"help-panel-close\" type=\"button\" (click)=\"dismissHelp()\" aria-label=\"Close help\">\u2715</button>\n </div>\n <div class=\"help-panel-content\">{{ helpText }}</div>\n <div class=\"help-panel-footer\">\n <span>Tip: for most commands you can swap the first and second words \u2014 e.g. \"disable user\" works the same as \"user disable\"</span>\n <span class=\"help-panel-footer-dismiss\">Press Escape, click outside, or start typing to close</span>\n </div>\n </div>\n </div>\n }\n</div>\n", styles: [".shell{position:relative;display:grid;grid-template-areas:\"status status\" \"menu main\" \"resize resize\" \"terminal terminal\";grid-template-columns:56px 1fr;grid-template-rows:48px 1fr 4px var(--terminal-height, 160px);height:100vh;height:100dvh;width:100vw;overflow:hidden;background-color:var(--color-surface-dark)}.shell.terminal-hidden{grid-template-rows:48px 1fr 0 0}.shell-status{grid-area:status}.shell-menu{grid-area:menu}.shell-main{grid-area:main;overflow:hidden}.shell-main.full-width{grid-column:1 / -1}.shell-resize{grid-area:resize;cursor:row-resize;touch-action:none;background-color:var(--color-surface-border);transition:background-color .15s ease}.shell-resize:hover,.shell-resize.dragging{background-color:var(--color-interactive)}.shell-terminal{grid-area:terminal}.shell.is-dragging{user-select:none;-webkit-user-select:none}.disabled-zone{pointer-events:none;opacity:.4;transition:opacity .2s ease}.help-backdrop{position:absolute;inset:0;z-index:100;background-color:#000000a6;display:flex;align-items:center;justify-content:center}.help-panel{background-color:var(--color-surface, #161b22);border:2px solid var(--color-interactive, #00e5ff);border-radius:4px;width:70%;max-width:820px;max-height:75vh;display:flex;flex-direction:column;font-family:Roboto Mono,Courier New,monospace;font-size:.85rem}.help-panel-header{display:flex;justify-content:space-between;align-items:center;padding:8px 16px;background-color:var(--color-surface-light, #21262d);border-bottom:1px solid var(--color-interactive, #00e5ff);border-radius:2px 2px 0 0;flex-shrink:0}.help-panel-title{color:var(--color-interactive, #00e5ff);font-weight:700;letter-spacing:.05em;text-transform:uppercase;font-size:.75rem}.help-panel-close{background:none;border:1px solid var(--color-surface-border, #30363d);color:var(--color-secondary-info, #9e9e9e);cursor:pointer;padding:2px 8px;font-size:.9rem;line-height:1.4;border-radius:3px;transition:color .15s ease,border-color .15s ease}.help-panel-close:hover{color:var(--color-text-primary, #ffffff);border-color:var(--color-interactive, #00e5ff)}.help-panel-content{flex:1;overflow-y:auto;padding:16px 20px;color:var(--color-text-primary, #ffffff);white-space:pre-wrap;line-height:1.7}.help-panel-footer{padding:6px 16px;background-color:var(--color-surface-light, #21262d);border-top:1px solid var(--color-surface-border, #30363d);border-radius:0 0 2px 2px;color:var(--color-secondary-info, #9e9e9e);font-size:.75rem;flex-shrink:0;display:flex;flex-direction:column;gap:2px}.help-panel-footer-dismiss{opacity:.6}@media(max-width:819.98px){.shell{grid-template-areas:\"status\" \"main\" \"resize\" \"terminal\";grid-template-columns:minmax(0,1fr)}.shell-menu{display:none}}.phone-menu-backdrop{position:absolute;inset:48px 0 0;z-index:50;background-color:color-mix(in srgb,var(--color-surface-dark) 70%,transparent)}.phone-menu{position:absolute;top:48px;bottom:0;left:0;z-index:51;display:block;width:min(280px,85vw);box-shadow:4px 0 16px color-mix(in srgb,var(--color-surface-dark) 60%,transparent)}.bypass-banner{position:fixed;top:52px;right:12px;z-index:1000;padding:6px 12px;border:2px solid var(--color-loss);border-radius:4px;background-color:var(--color-surface);color:var(--color-loss);font-weight:600;pointer-events:none}\n"], dependencies: [{ kind: "component", type: StatusBar, selector: "shell-status-bar" }, { kind: "component", type: SideMenu, selector: "shell-side-menu", inputs: ["drawer"] }, { kind: "component", type: MainContent, selector: "shell-main-content" }, { kind: "component", type: CommandTerminal, selector: "shell-command-terminal", inputs: ["focusOnAppear"] }, { kind: "component", type: FaqModal, selector: "faq-modal" }, { kind: "directive", type: CdkTrapFocus, selector: "[cdkTrapFocus]", inputs: ["cdkTrapFocus", "cdkTrapFocusAutoCapture"], exportAs: ["cdkTrapFocus"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
|
|
3504
3736
|
}
|
|
3505
3737
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: Shell, decorators: [{
|
|
3506
3738
|
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"] }]
|
|
3739
|
+
args: [{ selector: 'shell-root', imports: [StatusBar, SideMenu, MainContent, CommandTerminal, FaqModal, CdkTrapFocus], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"shell\" [style.--terminal-height]=\"terminalHeight()\" [class.is-dragging]=\"isDragging()\" [class.terminal-hidden]=\"!terminalVisible()\">\n <shell-status-bar class=\"shell-status\" [class.disabled-zone]=\"isThinking()\">\n <ng-content />\n </shell-status-bar>\n @if (isAuthenticated() && !isPhone()) {\n <shell-side-menu class=\"shell-menu\" [class.disabled-zone]=\"isThinking()\" />\n }\n <shell-main-content class=\"shell-main\" [class.full-width]=\"!isAuthenticated()\" />\n @if (terminalVisible()) {\n <!-- On a phone too (scanner-phone-display D10), but never given the cursor there: see Shell.consoleFocusOnAppear. -->\n <div class=\"shell-resize\" [class.dragging]=\"isDragging()\" (mousedown)=\"onResizeStart($event)\" (touchstart)=\"onResizeTouchStart($event)\"></div>\n <shell-command-terminal class=\"shell-terminal\" [class.disabled-zone]=\"isThinking()\" [focusOnAppear]=\"consoleFocusOnAppear()\" />\n }\n @if (isAuthenticated() && phoneMenuOpen()) {\n <!-- Phone menu (scanner-phone-display D9): the icon column's items, opened from the \u2630 button. -->\n <div class=\"phone-menu-backdrop\" (click)=\"closePhoneMenu()\"></div>\n <shell-side-menu\n id=\"shell-phone-menu\"\n class=\"phone-menu\"\n [class.disabled-zone]=\"isThinking()\"\n [drawer]=\"true\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label=\"Menu\"\n cdkTrapFocus\n [cdkTrapFocusAutoCapture]=\"true\"\n (keydown.escape)=\"closePhoneMenu()\"\n />\n }\n @if (isAuthenticated() && bypassActive()) {\n <!-- authenticator-sign-in.prd.md D12: cannot be dismissed; no end time (the server never sends one). -->\n <div class=\"bypass-banner\" role=\"status\">Sign-in code check is off.</div>\n }\n <faq-modal />\n\n @if (helpOverlay(); as helpText) {\n <div class=\"help-backdrop\" (click)=\"dismissHelp()\">\n <div class=\"help-panel\" (click)=\"$event.stopPropagation()\" role=\"dialog\" aria-label=\"Command help\">\n <div class=\"help-panel-header\">\n <span class=\"help-panel-title\">Help</span>\n <button class=\"help-panel-close\" type=\"button\" (click)=\"dismissHelp()\" aria-label=\"Close help\">\u2715</button>\n </div>\n <div class=\"help-panel-content\">{{ helpText }}</div>\n <div class=\"help-panel-footer\">\n <span>Tip: for most commands you can swap the first and second words \u2014 e.g. \"disable user\" works the same as \"user disable\"</span>\n <span class=\"help-panel-footer-dismiss\">Press Escape, click outside, or start typing to close</span>\n </div>\n </div>\n </div>\n }\n</div>\n", styles: [".shell{position:relative;display:grid;grid-template-areas:\"status status\" \"menu main\" \"resize resize\" \"terminal terminal\";grid-template-columns:56px 1fr;grid-template-rows:48px 1fr 4px var(--terminal-height, 160px);height:100vh;height:100dvh;width:100vw;overflow:hidden;background-color:var(--color-surface-dark)}.shell.terminal-hidden{grid-template-rows:48px 1fr 0 0}.shell-status{grid-area:status}.shell-menu{grid-area:menu}.shell-main{grid-area:main;overflow:hidden}.shell-main.full-width{grid-column:1 / -1}.shell-resize{grid-area:resize;cursor:row-resize;touch-action:none;background-color:var(--color-surface-border);transition:background-color .15s ease}.shell-resize:hover,.shell-resize.dragging{background-color:var(--color-interactive)}.shell-terminal{grid-area:terminal}.shell.is-dragging{user-select:none;-webkit-user-select:none}.disabled-zone{pointer-events:none;opacity:.4;transition:opacity .2s ease}.help-backdrop{position:absolute;inset:0;z-index:100;background-color:#000000a6;display:flex;align-items:center;justify-content:center}.help-panel{background-color:var(--color-surface, #161b22);border:2px solid var(--color-interactive, #00e5ff);border-radius:4px;width:70%;max-width:820px;max-height:75vh;display:flex;flex-direction:column;font-family:Roboto Mono,Courier New,monospace;font-size:.85rem}.help-panel-header{display:flex;justify-content:space-between;align-items:center;padding:8px 16px;background-color:var(--color-surface-light, #21262d);border-bottom:1px solid var(--color-interactive, #00e5ff);border-radius:2px 2px 0 0;flex-shrink:0}.help-panel-title{color:var(--color-interactive, #00e5ff);font-weight:700;letter-spacing:.05em;text-transform:uppercase;font-size:.75rem}.help-panel-close{background:none;border:1px solid var(--color-surface-border, #30363d);color:var(--color-secondary-info, #9e9e9e);cursor:pointer;padding:2px 8px;font-size:.9rem;line-height:1.4;border-radius:3px;transition:color .15s ease,border-color .15s ease}.help-panel-close:hover{color:var(--color-text-primary, #ffffff);border-color:var(--color-interactive, #00e5ff)}.help-panel-content{flex:1;overflow-y:auto;padding:16px 20px;color:var(--color-text-primary, #ffffff);white-space:pre-wrap;line-height:1.7}.help-panel-footer{padding:6px 16px;background-color:var(--color-surface-light, #21262d);border-top:1px solid var(--color-surface-border, #30363d);border-radius:0 0 2px 2px;color:var(--color-secondary-info, #9e9e9e);font-size:.75rem;flex-shrink:0;display:flex;flex-direction:column;gap:2px}.help-panel-footer-dismiss{opacity:.6}@media(max-width:819.98px){.shell{grid-template-areas:\"status\" \"main\" \"resize\" \"terminal\";grid-template-columns:minmax(0,1fr)}.shell-menu{display:none}}.phone-menu-backdrop{position:absolute;inset:48px 0 0;z-index:50;background-color:color-mix(in srgb,var(--color-surface-dark) 70%,transparent)}.phone-menu{position:absolute;top:48px;bottom:0;left:0;z-index:51;display:block;width:min(280px,85vw);box-shadow:4px 0 16px color-mix(in srgb,var(--color-surface-dark) 60%,transparent)}.bypass-banner{position:fixed;top:52px;right:12px;z-index:1000;padding:6px 12px;border:2px solid var(--color-loss);border-radius:4px;background-color:var(--color-surface);color:var(--color-loss);font-weight:600;pointer-events:none}\n"] }]
|
|
3508
3740
|
}] });
|
|
3509
3741
|
|
|
3742
|
+
/*
|
|
3743
|
+
Created by Franz Zemen 2026-10-17
|
|
3744
|
+
License Type: UNLICENSED
|
|
3745
|
+
|
|
3746
|
+
The 10 recovery codes, shown ONCE (authenticator-sign-in.prd.md D7), with Download (a text file made
|
|
3747
|
+
in the browser — nothing is stored on the server as a file) and Copy. "Continue" is the only way on,
|
|
3748
|
+
so the codes cannot scroll past unseen.
|
|
3749
|
+
*/
|
|
3750
|
+
class RecoveryCodes {
|
|
3751
|
+
constructor() {
|
|
3752
|
+
this.codes = input.required(/* @ts-ignore */
|
|
3753
|
+
...(ngDevMode ? [{ debugName: "codes" }] : /* istanbul ignore next */ []));
|
|
3754
|
+
this.appName = input('BrokenStock', /* @ts-ignore */
|
|
3755
|
+
...(ngDevMode ? [{ debugName: "appName" }] : /* istanbul ignore next */ []));
|
|
3756
|
+
this.username = input(undefined, /* @ts-ignore */
|
|
3757
|
+
...(ngDevMode ? [{ debugName: "username" }] : /* istanbul ignore next */ []));
|
|
3758
|
+
this.done = output();
|
|
3759
|
+
this.copied = signal(false, /* @ts-ignore */
|
|
3760
|
+
...(ngDevMode ? [{ debugName: "copied" }] : /* istanbul ignore next */ []));
|
|
3761
|
+
}
|
|
3762
|
+
download() {
|
|
3763
|
+
const text = recoveryCodesFile(this.appName(), this.username(), this.codes());
|
|
3764
|
+
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
|
|
3765
|
+
const a = document.createElement('a');
|
|
3766
|
+
a.href = url;
|
|
3767
|
+
a.download = `${this.appName().toLowerCase().replace(/[^a-z0-9]+/g, '-')}-recovery-codes.txt`;
|
|
3768
|
+
a.click();
|
|
3769
|
+
setTimeout(() => URL.revokeObjectURL(url));
|
|
3770
|
+
}
|
|
3771
|
+
copy() {
|
|
3772
|
+
navigator.clipboard?.writeText(this.codes().join('\n')).then(() => this.copied.set(true)).catch(() => undefined);
|
|
3773
|
+
}
|
|
3774
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: RecoveryCodes, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
3775
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: RecoveryCodes, isStandalone: true, selector: "shell-recovery-codes", inputs: { codes: { classPropertyName: "codes", publicName: "codes", isSignal: true, isRequired: true, transformFunction: null }, appName: { classPropertyName: "appName", publicName: "appName", isSignal: true, isRequired: false, transformFunction: null }, username: { classPropertyName: "username", publicName: "username", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { done: "done" }, ngImport: i0, template: `
|
|
3776
|
+
<div class="recovery-codes">
|
|
3777
|
+
<p class="recovery-codes-lead text-text-primary"><strong>Save your recovery codes.</strong></p>
|
|
3778
|
+
<p class="text-secondary-info">
|
|
3779
|
+
Each one works once, in place of the 6-digit code, if you lose your phone.
|
|
3780
|
+
They will not be shown again.
|
|
3781
|
+
</p>
|
|
3782
|
+
<ol class="recovery-codes-list">
|
|
3783
|
+
@for (code of codes(); track code) {
|
|
3784
|
+
<li><code>{{ code }}</code></li>
|
|
3785
|
+
}
|
|
3786
|
+
</ol>
|
|
3787
|
+
<div class="recovery-codes-actions">
|
|
3788
|
+
<button mat-stroked-button type="button" (click)="download()">
|
|
3789
|
+
<mat-icon>download</mat-icon> Download
|
|
3790
|
+
</button>
|
|
3791
|
+
<button mat-stroked-button type="button" (click)="copy()">
|
|
3792
|
+
<mat-icon>content_copy</mat-icon> {{ copied() ? 'Copied' : 'Copy' }}
|
|
3793
|
+
</button>
|
|
3794
|
+
<button mat-flat-button type="button" (click)="done.emit()">I have saved them — continue</button>
|
|
3795
|
+
</div>
|
|
3796
|
+
</div>
|
|
3797
|
+
`, isInline: true, styles: [".recovery-codes{max-width:360px;padding:12px 16px;border-radius:4px;background-color:var(--color-surface-light);text-align:left;font-size:.9rem}.recovery-codes p{margin:0 0 8px}.recovery-codes-list{columns:2;margin:8px 0 12px;padding-left:1.5em;font-family:var(--shell-mono, monospace)}.recovery-codes-list li{margin:2px 0}.recovery-codes-actions{display:flex;flex-wrap:wrap;gap:8px}\n"], dependencies: [{ kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i6.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
3798
|
+
}
|
|
3799
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: RecoveryCodes, decorators: [{
|
|
3800
|
+
type: Component,
|
|
3801
|
+
args: [{ selector: 'shell-recovery-codes', changeDetection: ChangeDetectionStrategy.OnPush, imports: [MatButtonModule, MatIconModule], template: `
|
|
3802
|
+
<div class="recovery-codes">
|
|
3803
|
+
<p class="recovery-codes-lead text-text-primary"><strong>Save your recovery codes.</strong></p>
|
|
3804
|
+
<p class="text-secondary-info">
|
|
3805
|
+
Each one works once, in place of the 6-digit code, if you lose your phone.
|
|
3806
|
+
They will not be shown again.
|
|
3807
|
+
</p>
|
|
3808
|
+
<ol class="recovery-codes-list">
|
|
3809
|
+
@for (code of codes(); track code) {
|
|
3810
|
+
<li><code>{{ code }}</code></li>
|
|
3811
|
+
}
|
|
3812
|
+
</ol>
|
|
3813
|
+
<div class="recovery-codes-actions">
|
|
3814
|
+
<button mat-stroked-button type="button" (click)="download()">
|
|
3815
|
+
<mat-icon>download</mat-icon> Download
|
|
3816
|
+
</button>
|
|
3817
|
+
<button mat-stroked-button type="button" (click)="copy()">
|
|
3818
|
+
<mat-icon>content_copy</mat-icon> {{ copied() ? 'Copied' : 'Copy' }}
|
|
3819
|
+
</button>
|
|
3820
|
+
<button mat-flat-button type="button" (click)="done.emit()">I have saved them — continue</button>
|
|
3821
|
+
</div>
|
|
3822
|
+
</div>
|
|
3823
|
+
`, styles: [".recovery-codes{max-width:360px;padding:12px 16px;border-radius:4px;background-color:var(--color-surface-light);text-align:left;font-size:.9rem}.recovery-codes p{margin:0 0 8px}.recovery-codes-list{columns:2;margin:8px 0 12px;padding-left:1.5em;font-family:var(--shell-mono, monospace)}.recovery-codes-list li{margin:2px 0}.recovery-codes-actions{display:flex;flex-wrap:wrap;gap:8px}\n"] }]
|
|
3824
|
+
}], propDecorators: { codes: [{ type: i0.Input, args: [{ isSignal: true, alias: "codes", required: true }] }], appName: [{ type: i0.Input, args: [{ isSignal: true, alias: "appName", required: false }] }], username: [{ type: i0.Input, args: [{ isSignal: true, alias: "username", required: false }] }], done: [{ type: i0.Output, args: ["done"] }] } });
|
|
3825
|
+
|
|
3510
3826
|
class Login {
|
|
3511
3827
|
#postLoginRoute;
|
|
3512
3828
|
#commandService;
|
|
@@ -3532,6 +3848,11 @@ class Login {
|
|
|
3532
3848
|
*/
|
|
3533
3849
|
this.selected = signal(new Set(), /* @ts-ignore */
|
|
3534
3850
|
...(ngDevMode ? [{ debugName: "selected" }] : /* istanbul ignore next */ []));
|
|
3851
|
+
/** The code or recovery code typed on the code step (authenticator-sign-in.prd.md). */
|
|
3852
|
+
this.codeText = '';
|
|
3853
|
+
/** Typing a recovery code instead of the 6-digit code. */
|
|
3854
|
+
this.useRecovery = signal(false, /* @ts-ignore */
|
|
3855
|
+
...(ngDevMode ? [{ debugName: "useRecovery" }] : /* istanbul ignore next */ []));
|
|
3535
3856
|
this.USERNAME_REGEX = USERNAME_REGEX;
|
|
3536
3857
|
this.PASSWORD_REGEX = PASSWORD_REGEX;
|
|
3537
3858
|
this.appName = inject(SHELL_APP_NAME, { optional: true }) ?? 'Application';
|
|
@@ -3642,6 +3963,37 @@ class Login {
|
|
|
3642
3963
|
this.selected.set(new Set());
|
|
3643
3964
|
this.#doLogin(false, { endSessionIds: ids });
|
|
3644
3965
|
}
|
|
3966
|
+
/** The code step: the 6-digit code (or a recovery code), with the credentials already accepted. */
|
|
3967
|
+
onSubmitCode(event) {
|
|
3968
|
+
event.preventDefault();
|
|
3969
|
+
this.errors = [];
|
|
3970
|
+
const typed = this.codeText.trim();
|
|
3971
|
+
if (!typed)
|
|
3972
|
+
return;
|
|
3973
|
+
this.submitted = true;
|
|
3974
|
+
this.#doLogin(false, this.useRecovery() ? { recoveryCode: typed } : { code: typed });
|
|
3975
|
+
}
|
|
3976
|
+
onToggleRecovery() {
|
|
3977
|
+
this.useRecovery.set(!this.useRecovery());
|
|
3978
|
+
this.codeText = '';
|
|
3979
|
+
this.errors = [];
|
|
3980
|
+
}
|
|
3981
|
+
/** Back to the password form. */
|
|
3982
|
+
onCancelCode() {
|
|
3983
|
+
this.authenticationService.cancelCodeStep();
|
|
3984
|
+
this.codeText = '';
|
|
3985
|
+
this.useRecovery.set(false);
|
|
3986
|
+
this.errors = [];
|
|
3987
|
+
this.submitted = false;
|
|
3988
|
+
}
|
|
3989
|
+
/** New recovery codes were shown after sign-in; now go where sign-in was going. */
|
|
3990
|
+
onRecoveryCodesSaved() {
|
|
3991
|
+
this.authenticationService.acknowledgeRecoveryCodes();
|
|
3992
|
+
// Setup can finish on an attempt that then stops at "already signed in
|
|
3993
|
+
// elsewhere": the codes are shown first, and the session choice is still open.
|
|
3994
|
+
if (this.authenticationService.authenticated())
|
|
3995
|
+
this.#navigateAfterLogin();
|
|
3996
|
+
}
|
|
3645
3997
|
onCancelConflict() {
|
|
3646
3998
|
this.authenticationService.clearConflict();
|
|
3647
3999
|
this.selected.set(new Set());
|
|
@@ -3652,19 +4004,31 @@ class Login {
|
|
|
3652
4004
|
.login(this.model, force, choice)
|
|
3653
4005
|
.then(() => {
|
|
3654
4006
|
this.#commandService.echoCommand(`login ${this.model.username}`);
|
|
3655
|
-
this.
|
|
3656
|
-
|
|
3657
|
-
|
|
4007
|
+
this.codeText = '';
|
|
4008
|
+
this.useRecovery.set(false);
|
|
4009
|
+
// Setup finished during this sign-in: the codes are on screen and must be
|
|
4010
|
+
// seen before the app takes over the page.
|
|
4011
|
+
if (this.authenticationService.recoveryCodesToShow()) {
|
|
4012
|
+
this.submitted = false;
|
|
4013
|
+
return;
|
|
4014
|
+
}
|
|
4015
|
+
this.#navigateAfterLogin();
|
|
3658
4016
|
})
|
|
3659
4017
|
.catch((err) => {
|
|
3660
|
-
if (err !== 'conflict') {
|
|
4018
|
+
if (err !== 'conflict' && err !== CODE_STEP) {
|
|
3661
4019
|
this.errors.push({ ndx: 0, message: typeof err === 'string' ? err : 'Unable to login' });
|
|
3662
4020
|
}
|
|
4021
|
+
this.codeText = '';
|
|
3663
4022
|
this.submitted = false;
|
|
3664
4023
|
});
|
|
3665
4024
|
}
|
|
4025
|
+
#navigateAfterLogin() {
|
|
4026
|
+
this.router.navigateByUrl(this.#resolvePostLoginUrl()).then(() => {
|
|
4027
|
+
setTimeout(() => this.#terminalFocusService.requestFocus());
|
|
4028
|
+
}).catch(console.error);
|
|
4029
|
+
}
|
|
3666
4030
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: Login, deps: [{ token: AuthenticationService }, { token: i2$1.Router }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
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 }); }
|
|
4031
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: Login, isStandalone: true, selector: "shell-login", ngImport: i0, template: "@let usernameRegex = USERNAME_REGEX;\n@let passwordRegex = PASSWORD_REGEX;\n<div class=\"login-container\">\n <div class=\"login-header\">\n <h2 class=\"text-text-primary\">{{ appName }}</h2>\n <p class=\"text-secondary-info\">Sign in to continue</p>\n </div>\n @let step = authenticationService.codeStep();\n @if (authenticationService.recoveryCodesToShow(); as codes) {\n <shell-recovery-codes [codes]=\"codes\" [appName]=\"appName\" [username]=\"model.username\" (done)=\"onRecoveryCodesSaved()\" />\n } @else if (step !== 'none') {\n <form (submit)=\"onSubmitCode($event)\" class=\"login-form\" aria-label=\"Authenticator code\">\n @if (step === 'setup' && authenticationService.setupChallenge(); as challenge) {\n <p class=\"text-secondary-info login-code-lead\">\n Set up your authenticator: scan this with Google Authenticator (or Authy, 1Password),\n then enter the 6-digit code it shows.\n </p>\n <img class=\"login-qr\" [src]=\"challenge.qr\" alt=\"QR code for your authenticator app\" width=\"200\" height=\"200\" />\n <p class=\"text-secondary-info login-key\">Can't scan? Enter this key: <code>{{ challenge.key }}</code></p>\n } @else {\n <p class=\"text-secondary-info login-code-lead\">\n @if (useRecovery()) {\n Enter one of your recovery codes.\n } @else {\n Enter the 6-digit code from your authenticator app.\n }\n </p>\n }\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Code' }}</mat-label>\n <input\n matInput\n name=\"code\"\n [(ngModel)]=\"codeText\"\n [disabled]=\"submitted\"\n [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\"\n [attr.maxlength]=\"useRecovery() ? 16 : 7\"\n [placeholder]=\"useRecovery() ? 'xxxxx-xxxxx' : '123456'\"\n autofocus\n />\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n {{ step === 'setup' ? 'Finish setup and sign in' : 'Sign in' }}\n </button>\n <div class=\"login-code-links\">\n @if (step === 'code') {\n <button mat-button type=\"button\" (click)=\"onToggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}\n </button>\n }\n <button mat-button type=\"button\" (click)=\"onCancelCode()\">Back</button>\n </div>\n </form>\n } @else {\n <form #loginForm=\"ngForm\" (ngSubmit)=\"onSubmit($event)\" class=\"login-form\">\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Username</mat-label>\n <input\n #usernameInput\n matInput\n placeholder=\"Enter username\"\n [(ngModel)]=\"model.username\"\n name=\"username\"\n [disabled]=\"submitted\"\n required\n regexValidator\n [regex]=\"usernameRegex\"\n (cdkAutofill)=\"onAutofill($event, usernameInput)\"\n />\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Password</mat-label>\n <input\n #passwordInput\n matInput\n placeholder=\"Enter password\"\n [type]=\"hide() ? 'password' : 'text'\"\n [(ngModel)]=\"model.password\"\n name=\"password\"\n [disabled]=\"submitted\"\n regexValidator\n [regex]=\"passwordRegex\"\n (cdkAutofill)=\"onAutofill($event, passwordInput)\"\n />\n <button\n mat-icon-button\n matSuffix\n type=\"button\"\n (click)=\"clickEvent($event)\"\n [attr.aria-label]=\"'Hide password'\"\n [attr.aria-pressed]=\"hide()\"\n >\n <mat-icon>{{ hide() ? 'visibility_off' : 'visibility' }}</mat-icon>\n </button>\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n Login\n </button>\n </form>\n }\n @if (errors.length > 0) {\n <div class=\"login-errors\">\n @for (error of errors; track error.ndx) {\n <div class=\"login-error text-loss\">{{ error.message }}</div>\n }\n </div>\n }\n @if (authenticationService.conflictSessions(); as sessions) {\n <div class=\"login-conflict\">\n <p class=\"text-secondary-info\">\n You are already signed in {{ sessions.length === 1 ? 'somewhere else' : 'on other devices' }}:\n </p>\n @for (session of sessions; track session.sessionId ?? $index) {\n <div class=\"login-conflict-session\">\n @if (canEndIndividually()) {\n <mat-checkbox\n [checked]=\"isSelected(session)\"\n [disabled]=\"!session.sessionId\"\n (change)=\"toggleSelected(session, $event.checked)\"\n [attr.aria-label]=\"'End ' + deviceLabel(session)\"\n ></mat-checkbox>\n }\n <span class=\"text-secondary-info\">\n <strong>{{ deviceLabel(session) }}</strong>\n <!-- signedInAtEpoch is when the password was typed. startEpoch is\n when the row was written, which renewal resets \u2014 so it is shown\n only when there is nothing better, and labelled for what it is. -->\n @if (session.signedInAtEpoch) {\n \u2014 since {{ session.signedInAtEpoch | date:'short' }}\n } @else {\n \u2014 last active {{ session.startEpoch | date:'short' }}\n }\n </span>\n </div>\n }\n <div class=\"login-conflict-actions\">\n <button mat-stroked-button type=\"button\" (click)=\"onCancelConflict()\">Cancel</button>\n @if (selectedCount() > 0) {\n <button mat-stroked-button type=\"button\" (click)=\"onEndSelected()\">\n End {{ selectedCount() === 1 ? 'that one' : selectedCount() + ' of them' }} and sign in\n </button>\n }\n <button mat-stroked-button type=\"button\" (click)=\"onTakeOver()\">\n {{ sessions.length === 1 ? 'End it and sign in' : 'End them all and sign in' }}\n </button>\n @if (authenticationService.additionalSignInAllowed()) {\n <button mat-flat-button type=\"button\" (click)=\"onSignInAsWell()\">Sign in here as well</button>\n }\n </div>\n </div>\n }\n</div>\n", styles: [".login-container{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center}.login-header{margin-bottom:32px}.login-header h2{font-size:1.5rem;font-weight:300;margin-bottom:8px}.login-form{display:flex;flex-direction:column;align-items:center;gap:8px;width:320px}.login-field{width:100%;text-align:left}.login-takeover{align-self:flex-start;margin-top:4px}.login-button{margin-top:8px;width:100%}.login-errors{margin-top:16px;max-width:320px}.login-conflict{margin-top:16px;max-width:320px;padding:12px 16px;border-radius:4px;background-color:var(--color-surface-light);font-size:.85rem;text-align:left}.login-conflict p{margin:0 0 8px}.login-conflict-session{display:flex;align-items:center;gap:8px;margin:4px 0}.login-conflict-actions{flex-wrap:wrap;display:flex;gap:8px;margin-top:12px;justify-content:flex-end}.login-error{padding:8px 12px;margin-bottom:4px;font-size:.85rem;border-radius:4px;background-color:var(--color-surface-light)}.login-code-lead{margin:0 0 8px;font-size:.9rem}.login-qr{display:block;margin:4px auto 8px;image-rendering:pixelated}.login-key{margin:0 0 8px;font-size:.8rem;word-break:break-all}.login-code-links{display:flex;justify-content:space-between;width:100%}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$2.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$2.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$2.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$2.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "directive", type: i1$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$2.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "directive", type: MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i4.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: i5.CdkAutofill, selector: "[cdkAutofill]", outputs: ["cdkAutofill"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i6.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i6.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i7.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: RegexValidatorDirective, selector: "[regexValidator]", inputs: ["regex"] }, { kind: "ngmodule", type: TextFieldModule }, { kind: "component", type: RecoveryCodes, selector: "shell-recovery-codes", inputs: ["codes", "appName", "username"], outputs: ["done"] }, { kind: "pipe", type: DatePipe, name: "date" }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
|
|
3668
4032
|
}
|
|
3669
4033
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: Login, decorators: [{
|
|
3670
4034
|
type: Component,
|
|
@@ -3680,9 +4044,240 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
|
|
|
3680
4044
|
RegexValidatorDirective,
|
|
3681
4045
|
TextFieldModule,
|
|
3682
4046
|
DatePipe,
|
|
3683
|
-
|
|
4047
|
+
RecoveryCodes,
|
|
4048
|
+
], template: "@let usernameRegex = USERNAME_REGEX;\n@let passwordRegex = PASSWORD_REGEX;\n<div class=\"login-container\">\n <div class=\"login-header\">\n <h2 class=\"text-text-primary\">{{ appName }}</h2>\n <p class=\"text-secondary-info\">Sign in to continue</p>\n </div>\n @let step = authenticationService.codeStep();\n @if (authenticationService.recoveryCodesToShow(); as codes) {\n <shell-recovery-codes [codes]=\"codes\" [appName]=\"appName\" [username]=\"model.username\" (done)=\"onRecoveryCodesSaved()\" />\n } @else if (step !== 'none') {\n <form (submit)=\"onSubmitCode($event)\" class=\"login-form\" aria-label=\"Authenticator code\">\n @if (step === 'setup' && authenticationService.setupChallenge(); as challenge) {\n <p class=\"text-secondary-info login-code-lead\">\n Set up your authenticator: scan this with Google Authenticator (or Authy, 1Password),\n then enter the 6-digit code it shows.\n </p>\n <img class=\"login-qr\" [src]=\"challenge.qr\" alt=\"QR code for your authenticator app\" width=\"200\" height=\"200\" />\n <p class=\"text-secondary-info login-key\">Can't scan? Enter this key: <code>{{ challenge.key }}</code></p>\n } @else {\n <p class=\"text-secondary-info login-code-lead\">\n @if (useRecovery()) {\n Enter one of your recovery codes.\n } @else {\n Enter the 6-digit code from your authenticator app.\n }\n </p>\n }\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Code' }}</mat-label>\n <input\n matInput\n name=\"code\"\n [(ngModel)]=\"codeText\"\n [disabled]=\"submitted\"\n [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\"\n [attr.maxlength]=\"useRecovery() ? 16 : 7\"\n [placeholder]=\"useRecovery() ? 'xxxxx-xxxxx' : '123456'\"\n autofocus\n />\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n {{ step === 'setup' ? 'Finish setup and sign in' : 'Sign in' }}\n </button>\n <div class=\"login-code-links\">\n @if (step === 'code') {\n <button mat-button type=\"button\" (click)=\"onToggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}\n </button>\n }\n <button mat-button type=\"button\" (click)=\"onCancelCode()\">Back</button>\n </div>\n </form>\n } @else {\n <form #loginForm=\"ngForm\" (ngSubmit)=\"onSubmit($event)\" class=\"login-form\">\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Username</mat-label>\n <input\n #usernameInput\n matInput\n placeholder=\"Enter username\"\n [(ngModel)]=\"model.username\"\n name=\"username\"\n [disabled]=\"submitted\"\n required\n regexValidator\n [regex]=\"usernameRegex\"\n (cdkAutofill)=\"onAutofill($event, usernameInput)\"\n />\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Password</mat-label>\n <input\n #passwordInput\n matInput\n placeholder=\"Enter password\"\n [type]=\"hide() ? 'password' : 'text'\"\n [(ngModel)]=\"model.password\"\n name=\"password\"\n [disabled]=\"submitted\"\n regexValidator\n [regex]=\"passwordRegex\"\n (cdkAutofill)=\"onAutofill($event, passwordInput)\"\n />\n <button\n mat-icon-button\n matSuffix\n type=\"button\"\n (click)=\"clickEvent($event)\"\n [attr.aria-label]=\"'Hide password'\"\n [attr.aria-pressed]=\"hide()\"\n >\n <mat-icon>{{ hide() ? 'visibility_off' : 'visibility' }}</mat-icon>\n </button>\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n Login\n </button>\n </form>\n }\n @if (errors.length > 0) {\n <div class=\"login-errors\">\n @for (error of errors; track error.ndx) {\n <div class=\"login-error text-loss\">{{ error.message }}</div>\n }\n </div>\n }\n @if (authenticationService.conflictSessions(); as sessions) {\n <div class=\"login-conflict\">\n <p class=\"text-secondary-info\">\n You are already signed in {{ sessions.length === 1 ? 'somewhere else' : 'on other devices' }}:\n </p>\n @for (session of sessions; track session.sessionId ?? $index) {\n <div class=\"login-conflict-session\">\n @if (canEndIndividually()) {\n <mat-checkbox\n [checked]=\"isSelected(session)\"\n [disabled]=\"!session.sessionId\"\n (change)=\"toggleSelected(session, $event.checked)\"\n [attr.aria-label]=\"'End ' + deviceLabel(session)\"\n ></mat-checkbox>\n }\n <span class=\"text-secondary-info\">\n <strong>{{ deviceLabel(session) }}</strong>\n <!-- signedInAtEpoch is when the password was typed. startEpoch is\n when the row was written, which renewal resets \u2014 so it is shown\n only when there is nothing better, and labelled for what it is. -->\n @if (session.signedInAtEpoch) {\n \u2014 since {{ session.signedInAtEpoch | date:'short' }}\n } @else {\n \u2014 last active {{ session.startEpoch | date:'short' }}\n }\n </span>\n </div>\n }\n <div class=\"login-conflict-actions\">\n <button mat-stroked-button type=\"button\" (click)=\"onCancelConflict()\">Cancel</button>\n @if (selectedCount() > 0) {\n <button mat-stroked-button type=\"button\" (click)=\"onEndSelected()\">\n End {{ selectedCount() === 1 ? 'that one' : selectedCount() + ' of them' }} and sign in\n </button>\n }\n <button mat-stroked-button type=\"button\" (click)=\"onTakeOver()\">\n {{ sessions.length === 1 ? 'End it and sign in' : 'End them all and sign in' }}\n </button>\n @if (authenticationService.additionalSignInAllowed()) {\n <button mat-flat-button type=\"button\" (click)=\"onSignInAsWell()\">Sign in here as well</button>\n }\n </div>\n </div>\n }\n</div>\n", styles: [".login-container{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center}.login-header{margin-bottom:32px}.login-header h2{font-size:1.5rem;font-weight:300;margin-bottom:8px}.login-form{display:flex;flex-direction:column;align-items:center;gap:8px;width:320px}.login-field{width:100%;text-align:left}.login-takeover{align-self:flex-start;margin-top:4px}.login-button{margin-top:8px;width:100%}.login-errors{margin-top:16px;max-width:320px}.login-conflict{margin-top:16px;max-width:320px;padding:12px 16px;border-radius:4px;background-color:var(--color-surface-light);font-size:.85rem;text-align:left}.login-conflict p{margin:0 0 8px}.login-conflict-session{display:flex;align-items:center;gap:8px;margin:4px 0}.login-conflict-actions{flex-wrap:wrap;display:flex;gap:8px;margin-top:12px;justify-content:flex-end}.login-error{padding:8px 12px;margin-bottom:4px;font-size:.85rem;border-radius:4px;background-color:var(--color-surface-light)}.login-code-lead{margin:0 0 8px;font-size:.9rem}.login-qr{display:block;margin:4px auto 8px;image-rendering:pixelated}.login-key{margin:0 0 8px;font-size:.8rem;word-break:break-all}.login-code-links{display:flex;justify-content:space-between;width:100%}\n"] }]
|
|
3684
4049
|
}], ctorParameters: () => [{ type: AuthenticationService }, { type: i2$1.Router }] });
|
|
3685
4050
|
|
|
4051
|
+
/*
|
|
4052
|
+
Created by Franz Zemen 2026-10-17
|
|
4053
|
+
License Type: UNLICENSED
|
|
4054
|
+
|
|
4055
|
+
Settings → Sign-in code (authenticator-sign-in.prd.md D5, D7). Shared by every app: each routes to
|
|
4056
|
+
`AuthenticatorSettings` wherever its settings live.
|
|
4057
|
+
|
|
4058
|
+
- Set up an authenticator (or replace one): the password again, plus a current code or a
|
|
4059
|
+
recovery code when replacing → QR + typed-in key → the first code → 10 recovery codes.
|
|
4060
|
+
- New recovery codes: a current code → 10 new ones; the old ones stop working.
|
|
4061
|
+
|
|
4062
|
+
⛔ There is deliberately no "remove" and no "turn off" here (D13). Those are AWS-only; the screen
|
|
4063
|
+
says how to get them done.
|
|
4064
|
+
*/
|
|
4065
|
+
class AuthenticatorSettings {
|
|
4066
|
+
constructor() {
|
|
4067
|
+
this.#auth = inject(AuthenticationService);
|
|
4068
|
+
this.appName = inject(SHELL_APP_NAME, { optional: true }) ?? 'BrokenStock';
|
|
4069
|
+
this.status = signal(undefined, /* @ts-ignore */
|
|
4070
|
+
...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
|
|
4071
|
+
this.mode = signal('view', /* @ts-ignore */
|
|
4072
|
+
...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
|
|
4073
|
+
this.busy = signal(false, /* @ts-ignore */
|
|
4074
|
+
...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
4075
|
+
this.error = signal(undefined, /* @ts-ignore */
|
|
4076
|
+
...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
4077
|
+
this.challenge = signal(undefined, /* @ts-ignore */
|
|
4078
|
+
...(ngDevMode ? [{ debugName: "challenge" }] : /* istanbul ignore next */ []));
|
|
4079
|
+
this.codes = signal(undefined, /* @ts-ignore */
|
|
4080
|
+
...(ngDevMode ? [{ debugName: "codes" }] : /* istanbul ignore next */ []));
|
|
4081
|
+
this.useRecovery = signal(false, /* @ts-ignore */
|
|
4082
|
+
...(ngDevMode ? [{ debugName: "useRecovery" }] : /* istanbul ignore next */ []));
|
|
4083
|
+
this.password = '';
|
|
4084
|
+
this.code = '';
|
|
4085
|
+
}
|
|
4086
|
+
#auth;
|
|
4087
|
+
ngOnInit() {
|
|
4088
|
+
void this.#load();
|
|
4089
|
+
}
|
|
4090
|
+
async #load() {
|
|
4091
|
+
try {
|
|
4092
|
+
this.status.set(await this.#auth.authenticatorStatus());
|
|
4093
|
+
}
|
|
4094
|
+
catch (err) {
|
|
4095
|
+
this.error.set(err instanceof AuthenticatorError ? err.message : 'Could not load your sign-in code settings.');
|
|
4096
|
+
}
|
|
4097
|
+
}
|
|
4098
|
+
begin(mode) {
|
|
4099
|
+
this.#reset();
|
|
4100
|
+
this.mode.set(mode);
|
|
4101
|
+
}
|
|
4102
|
+
cancel() {
|
|
4103
|
+
this.#reset();
|
|
4104
|
+
this.mode.set('view');
|
|
4105
|
+
}
|
|
4106
|
+
toggleRecovery() {
|
|
4107
|
+
this.useRecovery.set(!this.useRecovery());
|
|
4108
|
+
this.code = '';
|
|
4109
|
+
}
|
|
4110
|
+
/** Password (and current code when replacing) → QR. */
|
|
4111
|
+
async start(event) {
|
|
4112
|
+
event.preventDefault();
|
|
4113
|
+
await this.#run(async () => {
|
|
4114
|
+
const current = this.status()?.enrolled
|
|
4115
|
+
? (this.useRecovery() ? { recoveryCode: this.code.trim() } : { code: this.code.trim() })
|
|
4116
|
+
: {};
|
|
4117
|
+
this.challenge.set(await this.#auth.startAuthenticatorSetup(this.password, current));
|
|
4118
|
+
this.password = '';
|
|
4119
|
+
this.code = '';
|
|
4120
|
+
this.useRecovery.set(false);
|
|
4121
|
+
this.mode.set('scan');
|
|
4122
|
+
});
|
|
4123
|
+
}
|
|
4124
|
+
/** The first code from the newly scanned app → recovery codes. */
|
|
4125
|
+
async confirm(event) {
|
|
4126
|
+
event.preventDefault();
|
|
4127
|
+
await this.#run(async () => {
|
|
4128
|
+
this.codes.set(await this.#auth.confirmAuthenticatorSetup(this.code.trim()));
|
|
4129
|
+
this.challenge.set(undefined);
|
|
4130
|
+
this.code = '';
|
|
4131
|
+
this.mode.set('codes');
|
|
4132
|
+
});
|
|
4133
|
+
}
|
|
4134
|
+
async reissue(event) {
|
|
4135
|
+
event.preventDefault();
|
|
4136
|
+
await this.#run(async () => {
|
|
4137
|
+
this.codes.set(await this.#auth.reissueRecoveryCodes(this.code.trim()));
|
|
4138
|
+
this.code = '';
|
|
4139
|
+
this.mode.set('codes');
|
|
4140
|
+
});
|
|
4141
|
+
}
|
|
4142
|
+
async codesSaved() {
|
|
4143
|
+
this.#reset();
|
|
4144
|
+
this.mode.set('view');
|
|
4145
|
+
await this.#load();
|
|
4146
|
+
}
|
|
4147
|
+
async #run(work) {
|
|
4148
|
+
this.busy.set(true);
|
|
4149
|
+
this.error.set(undefined);
|
|
4150
|
+
try {
|
|
4151
|
+
await work();
|
|
4152
|
+
}
|
|
4153
|
+
catch (err) {
|
|
4154
|
+
this.error.set(err instanceof AuthenticatorError ? err.message : 'Something went wrong');
|
|
4155
|
+
this.code = '';
|
|
4156
|
+
}
|
|
4157
|
+
finally {
|
|
4158
|
+
this.busy.set(false);
|
|
4159
|
+
}
|
|
4160
|
+
}
|
|
4161
|
+
#reset() {
|
|
4162
|
+
this.password = '';
|
|
4163
|
+
this.code = '';
|
|
4164
|
+
this.error.set(undefined);
|
|
4165
|
+
this.challenge.set(undefined);
|
|
4166
|
+
this.codes.set(undefined);
|
|
4167
|
+
this.useRecovery.set(false);
|
|
4168
|
+
}
|
|
4169
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: AuthenticatorSettings, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
4170
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: AuthenticatorSettings, isStandalone: true, selector: "shell-authenticator-settings", ngImport: i0, template: "<section class=\"authenticator-settings\">\n <h2 class=\"text-text-primary\">Sign-in code</h2>\n <p class=\"text-secondary-info\">\n Signing in asks for your password and a 6-digit code from an authenticator app on your phone\n (Google Authenticator, Authy, 1Password).\n </p>\n\n @if (status(); as s) {\n @switch (mode()) {\n @case ('view') {\n <dl class=\"authenticator-facts\">\n <dt>Authenticator</dt>\n <dd>{{ s.enrolled ? 'Set up' : 'Not set up' }}</dd>\n @if (s.enrolled) {\n <dt>Recovery codes left</dt>\n <dd [class.text-loss]=\"s.recoveryCodesLeft <= 2\">{{ s.recoveryCodesLeft }} of 10</dd>\n }\n <dt>Code required at sign-in</dt>\n <dd>{{ s.enrolled ? 'Yes' : (s.codeRequired ? 'Yes' : 'Not yet \u2014 set one up') }}</dd>\n </dl>\n <div class=\"authenticator-actions\">\n <button mat-flat-button type=\"button\" (click)=\"begin('password')\">\n {{ s.enrolled ? 'Replace authenticator' : 'Set up authenticator' }}\n </button>\n @if (s.enrolled) {\n <button mat-stroked-button type=\"button\" (click)=\"begin('reissue')\">New recovery codes</button>\n }\n </div>\n <p class=\"text-secondary-info authenticator-note\">\n Lost your phone? Sign in with a recovery code, then replace the authenticator here.\n No recovery codes left? Only the administrator can reset it, from AWS \u2014 there is no way\n to switch the code off from inside the app.\n </p>\n }\n\n @case ('password') {\n <form class=\"authenticator-form\" (submit)=\"start($event)\">\n <mat-form-field appearance=\"outline\">\n <mat-label>Password</mat-label>\n <input matInput type=\"password\" name=\"password\" [(ngModel)]=\"password\" autocomplete=\"current-password\" [disabled]=\"busy()\" />\n </mat-form-field>\n @if (s.enrolled) {\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Current 6-digit code' }}</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\" [disabled]=\"busy()\" />\n </mat-form-field>\n <button mat-button type=\"button\" class=\"authenticator-link\" (click)=\"toggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Lost the phone? Use a recovery code' }}\n </button>\n }\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !password || (s.enrolled && !code.trim())\">Continue</button>\n </div>\n </form>\n }\n\n @case ('scan') {\n @if (challenge(); as c) {\n <form class=\"authenticator-form\" (submit)=\"confirm($event)\">\n <p class=\"text-secondary-info\">Scan this with your authenticator app, then enter the 6-digit code it shows.</p>\n <img class=\"authenticator-qr\" [src]=\"c.qr\" alt=\"QR code for your authenticator app\" width=\"200\" height=\"200\" />\n <p class=\"text-secondary-info authenticator-key\">Can't scan? Enter this key: <code>{{ c.key }}</code></p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Code</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" [disabled]=\"busy()\" />\n </mat-form-field>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Finish setup</button>\n </div>\n @if (s.enrolled) {\n <p class=\"text-secondary-info authenticator-note\">Your old authenticator keeps working until you finish.</p>\n }\n </form>\n }\n }\n\n @case ('reissue') {\n <form class=\"authenticator-form\" (submit)=\"reissue($event)\">\n <p class=\"text-secondary-info\">Ten new recovery codes. The ones you have now will stop working.</p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Current 6-digit code</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" [disabled]=\"busy()\" />\n </mat-form-field>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Make new codes</button>\n </div>\n </form>\n }\n\n @case ('codes') {\n @if (codes(); as list) {\n <shell-recovery-codes [codes]=\"list\" [appName]=\"appName\" (done)=\"codesSaved()\" />\n }\n }\n }\n }\n\n @if (error(); as message) {\n <p class=\"text-loss authenticator-error\" role=\"alert\">{{ message }}</p>\n }\n</section>\n", styles: [".authenticator-settings{max-width:480px;padding:16px}.authenticator-settings h2{font-size:1.25rem;font-weight:400;margin:0 0 8px}.authenticator-facts{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;margin:16px 0}.authenticator-facts dt{color:var(--color-secondary-info)}.authenticator-facts dd{margin:0}.authenticator-form{display:flex;flex-direction:column;gap:4px;max-width:320px;margin-top:12px}.authenticator-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}.authenticator-link{align-self:flex-start}.authenticator-note{margin-top:16px;font-size:.85rem}.authenticator-qr{display:block;margin:4px 0 8px;image-rendering:pixelated}.authenticator-key{font-size:.8rem;word-break:break-all}.authenticator-error{margin-top:12px}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$2.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$2.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$2.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$2.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i6.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i4.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: RecoveryCodes, selector: "shell-recovery-codes", inputs: ["codes", "appName", "username"], outputs: ["done"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
4171
|
+
}
|
|
4172
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: AuthenticatorSettings, decorators: [{
|
|
4173
|
+
type: Component,
|
|
4174
|
+
args: [{ selector: 'shell-authenticator-settings', changeDetection: ChangeDetectionStrategy.OnPush, imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, RecoveryCodes], template: "<section class=\"authenticator-settings\">\n <h2 class=\"text-text-primary\">Sign-in code</h2>\n <p class=\"text-secondary-info\">\n Signing in asks for your password and a 6-digit code from an authenticator app on your phone\n (Google Authenticator, Authy, 1Password).\n </p>\n\n @if (status(); as s) {\n @switch (mode()) {\n @case ('view') {\n <dl class=\"authenticator-facts\">\n <dt>Authenticator</dt>\n <dd>{{ s.enrolled ? 'Set up' : 'Not set up' }}</dd>\n @if (s.enrolled) {\n <dt>Recovery codes left</dt>\n <dd [class.text-loss]=\"s.recoveryCodesLeft <= 2\">{{ s.recoveryCodesLeft }} of 10</dd>\n }\n <dt>Code required at sign-in</dt>\n <dd>{{ s.enrolled ? 'Yes' : (s.codeRequired ? 'Yes' : 'Not yet \u2014 set one up') }}</dd>\n </dl>\n <div class=\"authenticator-actions\">\n <button mat-flat-button type=\"button\" (click)=\"begin('password')\">\n {{ s.enrolled ? 'Replace authenticator' : 'Set up authenticator' }}\n </button>\n @if (s.enrolled) {\n <button mat-stroked-button type=\"button\" (click)=\"begin('reissue')\">New recovery codes</button>\n }\n </div>\n <p class=\"text-secondary-info authenticator-note\">\n Lost your phone? Sign in with a recovery code, then replace the authenticator here.\n No recovery codes left? Only the administrator can reset it, from AWS \u2014 there is no way\n to switch the code off from inside the app.\n </p>\n }\n\n @case ('password') {\n <form class=\"authenticator-form\" (submit)=\"start($event)\">\n <mat-form-field appearance=\"outline\">\n <mat-label>Password</mat-label>\n <input matInput type=\"password\" name=\"password\" [(ngModel)]=\"password\" autocomplete=\"current-password\" [disabled]=\"busy()\" />\n </mat-form-field>\n @if (s.enrolled) {\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Current 6-digit code' }}</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\" [disabled]=\"busy()\" />\n </mat-form-field>\n <button mat-button type=\"button\" class=\"authenticator-link\" (click)=\"toggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Lost the phone? Use a recovery code' }}\n </button>\n }\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !password || (s.enrolled && !code.trim())\">Continue</button>\n </div>\n </form>\n }\n\n @case ('scan') {\n @if (challenge(); as c) {\n <form class=\"authenticator-form\" (submit)=\"confirm($event)\">\n <p class=\"text-secondary-info\">Scan this with your authenticator app, then enter the 6-digit code it shows.</p>\n <img class=\"authenticator-qr\" [src]=\"c.qr\" alt=\"QR code for your authenticator app\" width=\"200\" height=\"200\" />\n <p class=\"text-secondary-info authenticator-key\">Can't scan? Enter this key: <code>{{ c.key }}</code></p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Code</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" [disabled]=\"busy()\" />\n </mat-form-field>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Finish setup</button>\n </div>\n @if (s.enrolled) {\n <p class=\"text-secondary-info authenticator-note\">Your old authenticator keeps working until you finish.</p>\n }\n </form>\n }\n }\n\n @case ('reissue') {\n <form class=\"authenticator-form\" (submit)=\"reissue($event)\">\n <p class=\"text-secondary-info\">Ten new recovery codes. The ones you have now will stop working.</p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Current 6-digit code</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" [disabled]=\"busy()\" />\n </mat-form-field>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Make new codes</button>\n </div>\n </form>\n }\n\n @case ('codes') {\n @if (codes(); as list) {\n <shell-recovery-codes [codes]=\"list\" [appName]=\"appName\" (done)=\"codesSaved()\" />\n }\n }\n }\n }\n\n @if (error(); as message) {\n <p class=\"text-loss authenticator-error\" role=\"alert\">{{ message }}</p>\n }\n</section>\n", styles: [".authenticator-settings{max-width:480px;padding:16px}.authenticator-settings h2{font-size:1.25rem;font-weight:400;margin:0 0 8px}.authenticator-facts{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;margin:16px 0}.authenticator-facts dt{color:var(--color-secondary-info)}.authenticator-facts dd{margin:0}.authenticator-form{display:flex;flex-direction:column;gap:4px;max-width:320px;margin-top:12px}.authenticator-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}.authenticator-link{align-self:flex-start}.authenticator-note{margin-top:16px;font-size:.85rem}.authenticator-qr{display:block;margin:4px 0 8px;image-rendering:pixelated}.authenticator-key{font-size:.8rem;word-break:break-all}.authenticator-error{margin-top:12px}\n"] }]
|
|
4175
|
+
}] });
|
|
4176
|
+
|
|
4177
|
+
/*
|
|
4178
|
+
Created by Franz Zemen 2026-10-17
|
|
4179
|
+
License Type: UNLICENSED
|
|
4180
|
+
|
|
4181
|
+
"Enter your code" — the reusable dialog for the on-demand check (authenticator-sign-in.prd.md D15).
|
|
4182
|
+
Live Trading opens it to arm trading; anything else that needs the code again can too.
|
|
4183
|
+
|
|
4184
|
+
Closes with the time the server recorded (epoch ms), or `undefined` if cancelled. Wrong codes keep
|
|
4185
|
+
the dialog open with the reason in plain words; after five, "Too many attempts." and nothing more.
|
|
4186
|
+
|
|
4187
|
+
const at = await openCodeDialog(dialog, {title: 'Arm live trading', purpose: 'arm-live-trading'});
|
|
4188
|
+
*/
|
|
4189
|
+
class CodeDialog {
|
|
4190
|
+
constructor() {
|
|
4191
|
+
this.data = inject(MAT_DIALOG_DATA);
|
|
4192
|
+
this.dialogRef = inject((MatDialogRef));
|
|
4193
|
+
this.#auth = inject(AuthenticationService);
|
|
4194
|
+
this.text = '';
|
|
4195
|
+
this.useRecovery = signal(false, /* @ts-ignore */
|
|
4196
|
+
...(ngDevMode ? [{ debugName: "useRecovery" }] : /* istanbul ignore next */ []));
|
|
4197
|
+
this.busy = signal(false, /* @ts-ignore */
|
|
4198
|
+
...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
4199
|
+
this.error = signal(undefined, /* @ts-ignore */
|
|
4200
|
+
...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
4201
|
+
}
|
|
4202
|
+
#auth;
|
|
4203
|
+
toggleRecovery() {
|
|
4204
|
+
this.useRecovery.set(!this.useRecovery());
|
|
4205
|
+
this.text = '';
|
|
4206
|
+
this.error.set(undefined);
|
|
4207
|
+
}
|
|
4208
|
+
async submit(event) {
|
|
4209
|
+
event.preventDefault();
|
|
4210
|
+
const typed = this.text.trim();
|
|
4211
|
+
if (!typed)
|
|
4212
|
+
return;
|
|
4213
|
+
this.busy.set(true);
|
|
4214
|
+
this.error.set(undefined);
|
|
4215
|
+
try {
|
|
4216
|
+
const at = await this.#auth.verifyCode(this.useRecovery() ? { recoveryCode: typed } : { code: typed }, this.data.purpose);
|
|
4217
|
+
this.dialogRef.close(at);
|
|
4218
|
+
}
|
|
4219
|
+
catch (err) {
|
|
4220
|
+
this.error.set(err instanceof AuthenticatorError ? err.message : 'Something went wrong');
|
|
4221
|
+
this.text = '';
|
|
4222
|
+
}
|
|
4223
|
+
finally {
|
|
4224
|
+
this.busy.set(false);
|
|
4225
|
+
}
|
|
4226
|
+
}
|
|
4227
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: CodeDialog, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
4228
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: CodeDialog, isStandalone: true, selector: "shell-code-dialog", ngImport: i0, template: `
|
|
4229
|
+
<h2 mat-dialog-title>{{ data.title }}</h2>
|
|
4230
|
+
<form (submit)="submit($event)">
|
|
4231
|
+
<mat-dialog-content>
|
|
4232
|
+
<p>{{ data.message ?? (useRecovery() ? 'Enter one of your recovery codes.' : 'Enter the 6-digit code from your authenticator app.') }}</p>
|
|
4233
|
+
<mat-form-field appearance="outline" class="code-field">
|
|
4234
|
+
<mat-label>{{ useRecovery() ? 'Recovery code' : 'Code' }}</mat-label>
|
|
4235
|
+
<input matInput name="code" [(ngModel)]="text" [disabled]="busy()"
|
|
4236
|
+
[attr.inputmode]="useRecovery() ? 'text' : 'numeric'"
|
|
4237
|
+
[attr.autocomplete]="useRecovery() ? 'off' : 'one-time-code'" cdkFocusInitial />
|
|
4238
|
+
</mat-form-field>
|
|
4239
|
+
@if (error(); as message) {
|
|
4240
|
+
<p class="code-error text-loss" role="alert">{{ message }}</p>
|
|
4241
|
+
}
|
|
4242
|
+
</mat-dialog-content>
|
|
4243
|
+
<mat-dialog-actions align="end">
|
|
4244
|
+
<button mat-button type="button" (click)="toggleRecovery()">{{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}</button>
|
|
4245
|
+
<button mat-button type="button" (click)="dialogRef.close(undefined)">Cancel</button>
|
|
4246
|
+
<button mat-flat-button type="submit" [disabled]="busy() || !text.trim()">{{ data.confirmLabel ?? 'Confirm' }}</button>
|
|
4247
|
+
</mat-dialog-actions>
|
|
4248
|
+
</form>
|
|
4249
|
+
`, isInline: true, styles: [":host{display:block}.code-field{width:100%}.code-error{margin:0}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$2.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$2.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$2.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$2.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatDialogModule }, { kind: "directive", type: i2$2.MatDialogTitle, selector: "[mat-dialog-title], [matDialogTitle]", inputs: ["id"], exportAs: ["matDialogTitle"] }, { kind: "directive", type: i2$2.MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "directive", type: i2$2.MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i6.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i4.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
4250
|
+
}
|
|
4251
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: CodeDialog, decorators: [{
|
|
4252
|
+
type: Component,
|
|
4253
|
+
args: [{ selector: 'shell-code-dialog', changeDetection: ChangeDetectionStrategy.OnPush, imports: [FormsModule, MatDialogModule, MatButtonModule, MatFormFieldModule, MatInputModule], template: `
|
|
4254
|
+
<h2 mat-dialog-title>{{ data.title }}</h2>
|
|
4255
|
+
<form (submit)="submit($event)">
|
|
4256
|
+
<mat-dialog-content>
|
|
4257
|
+
<p>{{ data.message ?? (useRecovery() ? 'Enter one of your recovery codes.' : 'Enter the 6-digit code from your authenticator app.') }}</p>
|
|
4258
|
+
<mat-form-field appearance="outline" class="code-field">
|
|
4259
|
+
<mat-label>{{ useRecovery() ? 'Recovery code' : 'Code' }}</mat-label>
|
|
4260
|
+
<input matInput name="code" [(ngModel)]="text" [disabled]="busy()"
|
|
4261
|
+
[attr.inputmode]="useRecovery() ? 'text' : 'numeric'"
|
|
4262
|
+
[attr.autocomplete]="useRecovery() ? 'off' : 'one-time-code'" cdkFocusInitial />
|
|
4263
|
+
</mat-form-field>
|
|
4264
|
+
@if (error(); as message) {
|
|
4265
|
+
<p class="code-error text-loss" role="alert">{{ message }}</p>
|
|
4266
|
+
}
|
|
4267
|
+
</mat-dialog-content>
|
|
4268
|
+
<mat-dialog-actions align="end">
|
|
4269
|
+
<button mat-button type="button" (click)="toggleRecovery()">{{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}</button>
|
|
4270
|
+
<button mat-button type="button" (click)="dialogRef.close(undefined)">Cancel</button>
|
|
4271
|
+
<button mat-flat-button type="submit" [disabled]="busy() || !text.trim()">{{ data.confirmLabel ?? 'Confirm' }}</button>
|
|
4272
|
+
</mat-dialog-actions>
|
|
4273
|
+
</form>
|
|
4274
|
+
`, styles: [":host{display:block}.code-field{width:100%}.code-error{margin:0}\n"] }]
|
|
4275
|
+
}] });
|
|
4276
|
+
/** Opens the dialog; resolves with the recorded time (epoch ms), or undefined when cancelled. */
|
|
4277
|
+
function openCodeDialog(dialog, data) {
|
|
4278
|
+
return firstValueFrom(dialog.open(CodeDialog, { data, width: '360px' }).afterClosed());
|
|
4279
|
+
}
|
|
4280
|
+
|
|
3686
4281
|
class ConfirmDialog {
|
|
3687
4282
|
constructor() {
|
|
3688
4283
|
this.data = inject(MAT_DIALOG_DATA);
|
|
@@ -3701,7 +4296,7 @@ class ConfirmDialog {
|
|
|
3701
4296
|
<button mat-button (click)="dialogRef.close(false)">{{ data.cancelLabel ?? 'Cancel' }}</button>
|
|
3702
4297
|
<button mat-flat-button color="warn" (click)="dialogRef.close(true)">{{ data.confirmLabel ?? 'Confirm' }}</button>
|
|
3703
4298
|
</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:
|
|
4299
|
+
`, isInline: true, styles: [":host{display:block}.warning-message{color:var(--color-warning);margin-top:8px}\n"], dependencies: [{ kind: "ngmodule", type: MatDialogModule }, { kind: "directive", type: i2$2.MatDialogTitle, selector: "[mat-dialog-title], [matDialogTitle]", inputs: ["id"], exportAs: ["matDialogTitle"] }, { kind: "directive", type: i2$2.MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "directive", type: i2$2.MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i6.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
|
|
3705
4300
|
}
|
|
3706
4301
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: ConfirmDialog, decorators: [{
|
|
3707
4302
|
type: Component,
|
|
@@ -3729,5 +4324,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
|
|
|
3729
4324
|
* Generated bundle index. Do not edit.
|
|
3730
4325
|
*/
|
|
3731
4326
|
|
|
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 };
|
|
4327
|
+
export { AuthenticationService, AuthenticatorError, AuthenticatorSettings, CLEAR_USAGE, CODE_STEP, CodeDialog, CommandRegistryService, CommandService, CommandTerminal, ConfirmDialog, FAQ_CONTENT_LOADER, FAQ_DEV_EDITOR, FAQ_DEV_MODE, FAQ_DEV_WORKSPACE_ROOT, FAQ_PUBLIC_PUBLISHER, FAQ_PUBLIC_SITE_BASE_URL, FaqAuthoringPanel, FaqHistoryService, FaqMarkdownRendererService, FaqModal, FaqService, HELP_USAGE, HttpFaqContentLoader, KeyboardShortcutService, Login, MainContent, PhoneLayoutService, PublicFaqIndex, PublicFaqPage, PublicFaqToc, RecoveryCodes, RegexValidatorDirective, SESSION_TOKEN_KEY, SHELL_APP_NAME, SHELL_AUTH_URLS, SHELL_COMMANDS, SHELL_MENU_ITEMS, SHELL_PHONE_MEDIA_QUERY, SHELL_POST_LOGIN_ROUTE, SHELL_SHARED_SESSION, SessionTokenStore, Shell, SideMenu, StatusBar, TOO_MANY_ATTEMPTS, TerminalFocusService, TerminalVisibilityService, ThemeService, ThinkingService, audienceAllowed, authGuard, authInterceptor, authenticatorMessage, buildFallbackChain, buildLoginBody, decideRenewal, guestGuard, matchesRoute, openCodeDialog, provideShellConfig, recoveryCodesFile, tokenize };
|
|
3733
4328
|
//# sourceMappingURL=franzzemen-brokenstock-shell.mjs.map
|