@franzzemen/brokenstock-shell 0.9.5 → 0.10.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.
|
@@ -360,6 +360,7 @@ function authenticatorMessage(reason) {
|
|
|
360
360
|
case 'too-many-attempts': return TOO_MANY_ATTEMPTS;
|
|
361
361
|
case 'authenticator-not-set-up': return 'This account has no authenticator set up. Ask the administrator to reset it.';
|
|
362
362
|
case 'setup-required': return 'Scan this QR code with your authenticator app, then enter the 6-digit code it shows.';
|
|
363
|
+
case 'passkey-refused': return 'That passkey was not accepted. Try again, or sign in with your password.';
|
|
363
364
|
default: return 'Unable to sign in';
|
|
364
365
|
}
|
|
365
366
|
}
|
|
@@ -377,6 +378,146 @@ function recoveryCodesFile(appName, username, codes, now = new Date()) {
|
|
|
377
378
|
].join('\n');
|
|
378
379
|
}
|
|
379
380
|
|
|
381
|
+
/*
|
|
382
|
+
Created by Franz Zemen 2026-10-21
|
|
383
|
+
License Type: UNLICENSED
|
|
384
|
+
|
|
385
|
+
The browser half of passkeys (passkey-sign-in.prd.md): turn the server's JSON options into what
|
|
386
|
+
`navigator.credentials` takes, and the passkey it returns back into JSON for the server.
|
|
387
|
+
|
|
388
|
+
No library. Browsers that can do one passkey across brokenstock.com and brokenstock.org (D3) can
|
|
389
|
+
do all of this natively; the base64url conversion below is the whole of what a library adds, and
|
|
390
|
+
it saves shipping one to every page. A browser without passkeys gets `passkeysSupported() ===
|
|
391
|
+
false` and the screens hide the passkey buttons — password + code always remains (D2).
|
|
392
|
+
*/
|
|
393
|
+
/** The user cancelled or the browser timed out — not an error worth a red message. */
|
|
394
|
+
class PasskeyCancelledError extends Error {
|
|
395
|
+
constructor() { super('Passkey prompt cancelled'); this.name = 'PasskeyCancelledError'; }
|
|
396
|
+
}
|
|
397
|
+
function passkeysSupported() {
|
|
398
|
+
return typeof window !== 'undefined'
|
|
399
|
+
&& typeof window.PublicKeyCredential === 'function'
|
|
400
|
+
&& typeof navigator !== 'undefined'
|
|
401
|
+
&& !!navigator.credentials;
|
|
402
|
+
}
|
|
403
|
+
/** Whether the browser can offer passkeys in the username box's suggestions (D9). */
|
|
404
|
+
async function passkeyAutofillSupported() {
|
|
405
|
+
if (!passkeysSupported())
|
|
406
|
+
return false;
|
|
407
|
+
const pkc = window.PublicKeyCredential;
|
|
408
|
+
try {
|
|
409
|
+
return typeof pkc.isConditionalMediationAvailable === 'function' && await pkc.isConditionalMediationAvailable();
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
function base64urlToBuffer(value) {
|
|
416
|
+
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
417
|
+
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
|
418
|
+
const binary = atob(padded);
|
|
419
|
+
const bytes = new Uint8Array(binary.length);
|
|
420
|
+
for (let i = 0; i < binary.length; i++)
|
|
421
|
+
bytes[i] = binary.charCodeAt(i);
|
|
422
|
+
return bytes.buffer;
|
|
423
|
+
}
|
|
424
|
+
function bufferToBase64url(buffer) {
|
|
425
|
+
const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
426
|
+
let binary = '';
|
|
427
|
+
for (let i = 0; i < bytes.length; i++)
|
|
428
|
+
binary += String.fromCharCode(bytes[i]);
|
|
429
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
430
|
+
}
|
|
431
|
+
function descriptors(list) {
|
|
432
|
+
return list?.map(c => ({
|
|
433
|
+
id: base64urlToBuffer(c.id),
|
|
434
|
+
type: 'public-key',
|
|
435
|
+
...(c.transports ? { transports: c.transports } : {}),
|
|
436
|
+
}));
|
|
437
|
+
}
|
|
438
|
+
function toRequestOptions(json) {
|
|
439
|
+
return {
|
|
440
|
+
challenge: base64urlToBuffer(json.challenge),
|
|
441
|
+
...(json.rpId ? { rpId: json.rpId } : {}),
|
|
442
|
+
...(json.timeout ? { timeout: json.timeout } : {}),
|
|
443
|
+
...(json.userVerification ? { userVerification: json.userVerification } : {}),
|
|
444
|
+
...(json.allowCredentials?.length ? { allowCredentials: descriptors(json.allowCredentials) } : {}),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
function toCreationOptions(json) {
|
|
448
|
+
return {
|
|
449
|
+
challenge: base64urlToBuffer(json.challenge),
|
|
450
|
+
rp: json.rp,
|
|
451
|
+
user: { id: base64urlToBuffer(json.user.id), name: json.user.name, displayName: json.user.displayName },
|
|
452
|
+
pubKeyCredParams: json.pubKeyCredParams,
|
|
453
|
+
...(json.timeout ? { timeout: json.timeout } : {}),
|
|
454
|
+
...(json.excludeCredentials?.length ? { excludeCredentials: descriptors(json.excludeCredentials) } : {}),
|
|
455
|
+
...(json.authenticatorSelection ? { authenticatorSelection: json.authenticatorSelection } : {}),
|
|
456
|
+
...(json.attestation ? { attestation: json.attestation } : {}),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
function credentialToJSON(credential) {
|
|
460
|
+
const r = credential.response;
|
|
461
|
+
const response = { clientDataJSON: bufferToBase64url(r.clientDataJSON) };
|
|
462
|
+
if ('attestationObject' in r && r.attestationObject) {
|
|
463
|
+
response['attestationObject'] = bufferToBase64url(r.attestationObject);
|
|
464
|
+
if (typeof r.getTransports === 'function')
|
|
465
|
+
response['transports'] = r.getTransports();
|
|
466
|
+
}
|
|
467
|
+
if ('authenticatorData' in r && r.authenticatorData) {
|
|
468
|
+
response['authenticatorData'] = bufferToBase64url(r.authenticatorData);
|
|
469
|
+
response['signature'] = bufferToBase64url(r.signature);
|
|
470
|
+
if (r.userHandle)
|
|
471
|
+
response['userHandle'] = bufferToBase64url(r.userHandle);
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
id: credential.id,
|
|
475
|
+
rawId: bufferToBase64url(credential.rawId),
|
|
476
|
+
type: credential.type,
|
|
477
|
+
response,
|
|
478
|
+
clientExtensionResults: credential.getClientExtensionResults(),
|
|
479
|
+
...(credential.authenticatorAttachment ? { authenticatorAttachment: credential.authenticatorAttachment } : {}),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
function cancelled(err) {
|
|
483
|
+
return err instanceof DOMException && (err.name === 'NotAllowedError' || err.name === 'AbortError');
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Ask the browser for a passkey. `conditional` waits quietly for the user to pick one from the
|
|
487
|
+
* username box's suggestions; pass a `signal` to stop waiting when the screen goes away.
|
|
488
|
+
*/
|
|
489
|
+
async function getPasskey(options, conditional = false, signal) {
|
|
490
|
+
try {
|
|
491
|
+
const credential = await navigator.credentials.get({
|
|
492
|
+
publicKey: toRequestOptions(options),
|
|
493
|
+
...(conditional ? { mediation: 'conditional' } : {}),
|
|
494
|
+
...(signal ? { signal } : {}),
|
|
495
|
+
});
|
|
496
|
+
if (!credential)
|
|
497
|
+
throw new PasskeyCancelledError();
|
|
498
|
+
return credentialToJSON(credential);
|
|
499
|
+
}
|
|
500
|
+
catch (err) {
|
|
501
|
+
if (cancelled(err))
|
|
502
|
+
throw new PasskeyCancelledError();
|
|
503
|
+
throw err;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/** Ask the browser to make a passkey. */
|
|
507
|
+
async function createPasskey(options) {
|
|
508
|
+
try {
|
|
509
|
+
const credential = await navigator.credentials.create({ publicKey: toCreationOptions(options) });
|
|
510
|
+
if (!credential)
|
|
511
|
+
throw new PasskeyCancelledError();
|
|
512
|
+
return credentialToJSON(credential);
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
if (cancelled(err))
|
|
516
|
+
throw new PasskeyCancelledError();
|
|
517
|
+
throw err;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
380
521
|
/**
|
|
381
522
|
* A refusal from an authenticator route, carrying the server's `reason`
|
|
382
523
|
* (authenticator-sign-in.prd.md). `message` is plain words for the screen.
|
|
@@ -391,6 +532,8 @@ class AuthenticatorError extends Error {
|
|
|
391
532
|
}
|
|
392
533
|
/** Sign-in rejected because the next step is a code (or setting one up), not because anything failed. */
|
|
393
534
|
const CODE_STEP = 'code-step';
|
|
535
|
+
/** The user dismissed the passkey prompt — nothing failed, nothing to say. */
|
|
536
|
+
const PASSKEY_CANCELLED = 'passkey-cancelled';
|
|
394
537
|
/**
|
|
395
538
|
* The sign-in request body, exactly as sent (multiple-devices E8, authenticator-sign-in.prd.md).
|
|
396
539
|
*
|
|
@@ -476,6 +619,12 @@ class AuthenticationService {
|
|
|
476
619
|
*/
|
|
477
620
|
this.bypassActive = signal(false, /* @ts-ignore */
|
|
478
621
|
...(ngDevMode ? [{ debugName: "bypassActive" }] : /* istanbul ignore next */ []));
|
|
622
|
+
/**
|
|
623
|
+
* The open "already signed in elsewhere" choice came from a passkey sign-in, so its retry goes
|
|
624
|
+
* to the passkey route with the ticket, not to /login with a password (passkey-sign-in.prd.md D8).
|
|
625
|
+
*/
|
|
626
|
+
this.pendingPasskey = signal(false, /* @ts-ignore */
|
|
627
|
+
...(ngDevMode ? [{ debugName: "pendingPasskey" }] : /* istanbul ignore next */ []));
|
|
479
628
|
this.#refreshing = null;
|
|
480
629
|
this.#auth = 'Bearer ..';
|
|
481
630
|
this.#httpClient = inject(HttpClient);
|
|
@@ -566,6 +715,7 @@ class AuthenticationService {
|
|
|
566
715
|
this.conflictSessions.set(undefined);
|
|
567
716
|
this.additionalSignInAllowed.set(false);
|
|
568
717
|
this.pendingCredentials.set(undefined);
|
|
718
|
+
this.pendingPasskey.set(false);
|
|
569
719
|
this.#signInTicket = undefined;
|
|
570
720
|
}
|
|
571
721
|
/** Back to the password form from the code or setup step. */
|
|
@@ -632,11 +782,119 @@ class AuthenticationService {
|
|
|
632
782
|
}
|
|
633
783
|
this.conflictSessions.set(undefined);
|
|
634
784
|
this.#thinkingService.startThinking();
|
|
785
|
+
// A ticket from a passkey sign-in's 409 stands in for a passkey, never for a code: it must not
|
|
786
|
+
// ride along on a password sign-in, where it would be checked — and counted — as a wrong code.
|
|
787
|
+
const ticket = this.pendingPasskey() ? undefined : this.#signInTicket;
|
|
788
|
+
const body = buildLoginBody(credentials, force, options, ticket);
|
|
789
|
+
return this.#signIn(this.#authUrls.login, body, credentials);
|
|
790
|
+
}
|
|
791
|
+
// ---------------------------------------------------------------------------
|
|
792
|
+
// Passkey sign-in (passkey-sign-in.prd.md D1, D8, D9)
|
|
793
|
+
// ---------------------------------------------------------------------------
|
|
794
|
+
/**
|
|
795
|
+
* Sign in with a passkey: no username, password or code. `conditional` waits quietly for the user
|
|
796
|
+
* to pick a passkey from the username box's suggestions (D9); abort `signal` when the screen goes.
|
|
797
|
+
* Rejects with PASSKEY_CANCELLED when the user dismissed the prompt, 'conflict' on a 409 (retry
|
|
798
|
+
* with `retryPasskeySignIn`), or a plain-words message.
|
|
799
|
+
*/
|
|
800
|
+
async loginWithPasskey(opts = {}) {
|
|
801
|
+
const options = await firstValueFrom(this.#httpClient.post(`${this.#authUrls.login}/passkey/options`, {}, { headers: { [HttpHeader.ContentType]: 'application/json', [HttpHeader.AppContext]: 'brokenstock' } }));
|
|
802
|
+
let response;
|
|
803
|
+
try {
|
|
804
|
+
response = await getPasskey(options, opts.conditional === true, opts.signal);
|
|
805
|
+
}
|
|
806
|
+
catch (err) {
|
|
807
|
+
if (err instanceof PasskeyCancelledError)
|
|
808
|
+
throw PASSKEY_CANCELLED;
|
|
809
|
+
throw authenticatorMessage('passkey-refused');
|
|
810
|
+
}
|
|
811
|
+
this.conflictSessions.set(undefined);
|
|
812
|
+
this.#signInTicket = undefined;
|
|
813
|
+
this.#thinkingService.startThinking();
|
|
814
|
+
return this.#signIn(`${this.#authUrls.login}/passkey`, { response }, undefined);
|
|
815
|
+
}
|
|
816
|
+
/** The answer to "already signed in elsewhere" after a passkey sign-in: the ticket stands in for the passkey (D8). */
|
|
817
|
+
retryPasskeySignIn(force, choice = {}) {
|
|
818
|
+
const ticket = this.#signInTicket;
|
|
819
|
+
if (!ticket)
|
|
820
|
+
return Promise.reject(authenticatorMessage('passkey-refused'));
|
|
821
|
+
this.conflictSessions.set(undefined);
|
|
822
|
+
this.#thinkingService.startThinking();
|
|
823
|
+
return this.#signIn(`${this.#authUrls.login}/passkey`, {
|
|
824
|
+
signInTicket: ticket,
|
|
825
|
+
...(force ? { force: true } : {}),
|
|
826
|
+
...(choice.additional ? { additional: true } : {}),
|
|
827
|
+
...(choice.endSessionIds?.length ? { endSessionIds: choice.endSessionIds } : {}),
|
|
828
|
+
}, undefined);
|
|
829
|
+
}
|
|
830
|
+
// ---------------------------------------------------------------------------
|
|
831
|
+
// Passkey settings and the on-demand check (passkey-sign-in.prd.md D11, D13–D15)
|
|
832
|
+
// ---------------------------------------------------------------------------
|
|
833
|
+
/** This user's passkeys. */
|
|
834
|
+
async listPasskeys() {
|
|
835
|
+
const { passkeys } = await this.#authenticatorCall('GET', 'passkeys');
|
|
836
|
+
return passkeys;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Add a passkey (D13, D14). `proof` is password + code (or recovery code), or 'passkey' to prove
|
|
840
|
+
* with one already added. Opens the browser's passkey prompt; resolves with the new passkey.
|
|
841
|
+
*/
|
|
842
|
+
async addPasskey(name, proof) {
|
|
843
|
+
const proofBody = proof === 'passkey' ? { passkey: await this.#passkeyResponse('confirm') } : proof;
|
|
844
|
+
const options = await this.#authenticatorCall('POST', 'passkeys/add/options', proofBody);
|
|
845
|
+
let response;
|
|
846
|
+
try {
|
|
847
|
+
response = await createPasskey(options);
|
|
848
|
+
}
|
|
849
|
+
catch (err) {
|
|
850
|
+
if (err instanceof PasskeyCancelledError)
|
|
851
|
+
throw new AuthenticatorError(undefined, 'Cancelled — no passkey was added.', 0);
|
|
852
|
+
if (err instanceof DOMException && err.name === 'InvalidStateError') {
|
|
853
|
+
throw new AuthenticatorError(undefined, 'This device already has a passkey for this account.', 0);
|
|
854
|
+
}
|
|
855
|
+
throw new AuthenticatorError(undefined, 'The browser could not make a passkey.', 0);
|
|
856
|
+
}
|
|
857
|
+
return this.#authenticatorCall('POST', 'passkeys/add', { name, response });
|
|
858
|
+
}
|
|
859
|
+
/** Remove one of your passkeys (D15), proved with a code, a recovery code, or a passkey. */
|
|
860
|
+
async removePasskey(credentialId, proof) {
|
|
861
|
+
const proofBody = proof === 'passkey' ? { passkey: await this.#passkeyResponse('confirm') } : proof;
|
|
862
|
+
const { removed } = await this.#authenticatorCall('POST', 'passkeys/remove', { credentialId, ...proofBody });
|
|
863
|
+
return removed;
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* The on-demand check with a passkey (D11) — the passkey twin of `verifyCode`. Opens the
|
|
867
|
+
* browser's prompt (fingerprint or PIN, always) and, on success, the server records it on this
|
|
868
|
+
* session exactly as a code would. `purpose` is a label for the log, e.g. 'arm-trading'.
|
|
869
|
+
* Resolves with when it was recorded; rejects with an AuthenticatorError (cancelled, refused, or
|
|
870
|
+
* no passkey on this account — reason 'passkey-refused').
|
|
871
|
+
*/
|
|
872
|
+
async verifyWithPasskey(purpose) {
|
|
873
|
+
const response = await this.#passkeyResponse('verify', purpose);
|
|
874
|
+
const { codeVerifiedAtEpoch } = await this.#authenticatorCall('POST', 'passkey/verify', { response, ...(purpose ? { purpose } : {}) });
|
|
875
|
+
return codeVerifiedAtEpoch;
|
|
876
|
+
}
|
|
877
|
+
/** A challenge bound to this session, answered by one of this user's passkeys. */
|
|
878
|
+
async #passkeyResponse(kind, purpose) {
|
|
879
|
+
const options = await this.#authenticatorCall('POST', 'passkeys/challenge', { kind, ...(purpose ? { purpose } : {}) });
|
|
880
|
+
try {
|
|
881
|
+
return await getPasskey(options);
|
|
882
|
+
}
|
|
883
|
+
catch (err) {
|
|
884
|
+
if (err instanceof PasskeyCancelledError)
|
|
885
|
+
throw new AuthenticatorError(undefined, 'Cancelled.', 0);
|
|
886
|
+
throw new AuthenticatorError('passkey-refused', authenticatorMessage('passkey-refused'), 0);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Post a sign-in and adopt what comes back — shared by the password and passkey routes.
|
|
891
|
+
* `credentials` is undefined for a passkey sign-in, which has none.
|
|
892
|
+
*/
|
|
893
|
+
#signIn(url, body, credentials) {
|
|
635
894
|
return new Promise((resolve, reject) => {
|
|
636
895
|
this.authenticating.set(true);
|
|
637
|
-
const body = buildLoginBody(credentials, force, options, this.#signInTicket);
|
|
638
896
|
const subscription = this.#httpClient
|
|
639
|
-
.post(
|
|
897
|
+
.post(url, body, {
|
|
640
898
|
headers: {
|
|
641
899
|
[HttpHeader.ContentType]: 'application/json',
|
|
642
900
|
[HttpHeader.AppContext]: 'brokenstock',
|
|
@@ -666,6 +924,7 @@ class AuthenticationService {
|
|
|
666
924
|
this.codeStep.set('none');
|
|
667
925
|
this.setupChallenge.set(undefined);
|
|
668
926
|
this.#signInTicket = undefined;
|
|
927
|
+
this.pendingPasskey.set(false);
|
|
669
928
|
this.authenticated.set(true);
|
|
670
929
|
this.pendingCredentials.set(undefined);
|
|
671
930
|
resolve();
|
|
@@ -698,6 +957,9 @@ class AuthenticationService {
|
|
|
698
957
|
// offer it.
|
|
699
958
|
this.additionalSignInAllowed.set(err.error?.additionalSignInAllowed === true);
|
|
700
959
|
this.pendingCredentials.set(credentials);
|
|
960
|
+
// A passkey sign-in has no password to send again: its retry goes back to the
|
|
961
|
+
// passkey route with the ticket alone (passkey-sign-in.prd.md D8).
|
|
962
|
+
this.pendingPasskey.set(credentials === undefined);
|
|
701
963
|
reject('conflict');
|
|
702
964
|
}
|
|
703
965
|
else if (reason === 'code-required') {
|
|
@@ -719,7 +981,7 @@ class AuthenticationService {
|
|
|
719
981
|
reject(authenticatorMessage(reason));
|
|
720
982
|
}
|
|
721
983
|
else if (err.status === 401) {
|
|
722
|
-
reject('Wrong username or password');
|
|
984
|
+
reject(credentials ? 'Wrong username or password' : authenticatorMessage('passkey-refused'));
|
|
723
985
|
}
|
|
724
986
|
else {
|
|
725
987
|
reject(err.message ?? 'Unable to login');
|
|
@@ -906,6 +1168,7 @@ class AuthenticationService {
|
|
|
906
1168
|
this.recoveryCodesToShow.set(undefined);
|
|
907
1169
|
this.recoveryCodesLeft.set(undefined);
|
|
908
1170
|
this.bypassActive.set(false);
|
|
1171
|
+
this.pendingPasskey.set(false);
|
|
909
1172
|
this.#signInTicket = undefined;
|
|
910
1173
|
this.#auth = 'Bearer ..';
|
|
911
1174
|
this.#store.clear();
|
|
@@ -4137,6 +4400,8 @@ class Login {
|
|
|
4137
4400
|
#commandService;
|
|
4138
4401
|
#terminalFocusService;
|
|
4139
4402
|
#activatedRoute;
|
|
4403
|
+
/** The quiet wait for a passkey picked from the username box's suggestions (passkey D9). */
|
|
4404
|
+
#autofill;
|
|
4140
4405
|
constructor(authenticationService, router) {
|
|
4141
4406
|
this.authenticationService = authenticationService;
|
|
4142
4407
|
this.router = router;
|
|
@@ -4169,6 +4434,56 @@ class Login {
|
|
|
4169
4434
|
this.#commandService = inject(CommandService);
|
|
4170
4435
|
this.#terminalFocusService = inject(TerminalFocusService);
|
|
4171
4436
|
this.#activatedRoute = inject(ActivatedRoute);
|
|
4437
|
+
/** Whether this browser can sign in with a passkey at all; the button is hidden otherwise (passkey D2). */
|
|
4438
|
+
this.passkeys = passkeysSupported();
|
|
4439
|
+
}
|
|
4440
|
+
/** Safe on the server: the support check is false wherever there is no browser. */
|
|
4441
|
+
ngOnInit() {
|
|
4442
|
+
void this.#startPasskeyAutofill();
|
|
4443
|
+
}
|
|
4444
|
+
ngOnDestroy() {
|
|
4445
|
+
this.#autofill?.abort();
|
|
4446
|
+
}
|
|
4447
|
+
/** "Sign in with a passkey". The quiet wait must stop first: a browser runs one passkey prompt at a time. */
|
|
4448
|
+
onPasskey() {
|
|
4449
|
+
this.errors = [];
|
|
4450
|
+
this.#autofill?.abort();
|
|
4451
|
+
this.#autofill = undefined;
|
|
4452
|
+
this.submitted = true;
|
|
4453
|
+
this.#passkeySignIn(false);
|
|
4454
|
+
}
|
|
4455
|
+
/**
|
|
4456
|
+
* Offer passkeys in the username box's suggestions (D9): a passkey request that waits until the
|
|
4457
|
+
* user picks one there. Silent when unsupported or when anything goes wrong — the button and the
|
|
4458
|
+
* password form are still on screen.
|
|
4459
|
+
*/
|
|
4460
|
+
async #startPasskeyAutofill() {
|
|
4461
|
+
if (!(await passkeyAutofillSupported()))
|
|
4462
|
+
return;
|
|
4463
|
+
if (this.authenticationService.authenticated())
|
|
4464
|
+
return;
|
|
4465
|
+
this.#autofill?.abort();
|
|
4466
|
+
const controller = new AbortController();
|
|
4467
|
+
this.#autofill = controller;
|
|
4468
|
+
this.#passkeySignIn(true, controller.signal);
|
|
4469
|
+
}
|
|
4470
|
+
#passkeySignIn(conditional, signal) {
|
|
4471
|
+
this.authenticationService
|
|
4472
|
+
.loginWithPasskey({ conditional, ...(signal ? { signal } : {}) })
|
|
4473
|
+
.then(() => this.#afterSignIn(undefined))
|
|
4474
|
+
.catch((err) => {
|
|
4475
|
+
// The quiet wait ending says nothing about a button press that may be in flight.
|
|
4476
|
+
if (!conditional)
|
|
4477
|
+
this.submitted = false;
|
|
4478
|
+
if (err === 'conflict')
|
|
4479
|
+
return;
|
|
4480
|
+
if (err !== PASSKEY_CANCELLED && !conditional) {
|
|
4481
|
+
this.errors.push({ ndx: 0, message: typeof err === 'string' ? err : 'Unable to sign in with a passkey' });
|
|
4482
|
+
}
|
|
4483
|
+
// Offer the suggestions again, unless this screen is going away.
|
|
4484
|
+
if (!signal?.aborted)
|
|
4485
|
+
void this.#startPasskeyAutofill();
|
|
4486
|
+
});
|
|
4172
4487
|
}
|
|
4173
4488
|
/**
|
|
4174
4489
|
* Returns the post-login destination. Honors a `returnUrl` query param if
|
|
@@ -4309,20 +4624,14 @@ class Login {
|
|
|
4309
4624
|
this.submitted = false;
|
|
4310
4625
|
}
|
|
4311
4626
|
#doLogin(force, choice = {}) {
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
this.
|
|
4317
|
-
this.
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
if (this.authenticationService.recoveryCodesToShow()) {
|
|
4321
|
-
this.submitted = false;
|
|
4322
|
-
return;
|
|
4323
|
-
}
|
|
4324
|
-
this.#navigateAfterLogin();
|
|
4325
|
-
})
|
|
4627
|
+
// The open "already signed in elsewhere" choice came from a passkey: its retry carries the
|
|
4628
|
+
// ticket to the passkey route — there is no password to send (passkey D8).
|
|
4629
|
+
const viaPasskey = this.authenticationService.pendingPasskey();
|
|
4630
|
+
const attempt = viaPasskey
|
|
4631
|
+
? this.authenticationService.retryPasskeySignIn(force, choice)
|
|
4632
|
+
: this.authenticationService.login(this.model, force, choice);
|
|
4633
|
+
attempt
|
|
4634
|
+
.then(() => this.#afterSignIn(viaPasskey ? undefined : this.model.username))
|
|
4326
4635
|
.catch((err) => {
|
|
4327
4636
|
if (err !== 'conflict' && err !== CODE_STEP) {
|
|
4328
4637
|
this.errors.push({ ndx: 0, message: typeof err === 'string' ? err : 'Unable to login' });
|
|
@@ -4331,13 +4640,26 @@ class Login {
|
|
|
4331
4640
|
this.submitted = false;
|
|
4332
4641
|
});
|
|
4333
4642
|
}
|
|
4643
|
+
#afterSignIn(username) {
|
|
4644
|
+
this.#autofill?.abort();
|
|
4645
|
+
this.#commandService.echoCommand(username ? `login ${username}` : 'login (passkey)');
|
|
4646
|
+
this.codeText = '';
|
|
4647
|
+
this.useRecovery.set(false);
|
|
4648
|
+
// Setup finished during this sign-in: the codes are on screen and must be
|
|
4649
|
+
// seen before the app takes over the page.
|
|
4650
|
+
if (this.authenticationService.recoveryCodesToShow()) {
|
|
4651
|
+
this.submitted = false;
|
|
4652
|
+
return;
|
|
4653
|
+
}
|
|
4654
|
+
this.#navigateAfterLogin();
|
|
4655
|
+
}
|
|
4334
4656
|
#navigateAfterLogin() {
|
|
4335
4657
|
this.router.navigateByUrl(this.#resolvePostLoginUrl()).then(() => {
|
|
4336
4658
|
setTimeout(() => this.#terminalFocusService.requestFocus());
|
|
4337
4659
|
}).catch(console.error);
|
|
4338
4660
|
}
|
|
4339
4661
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: Login, deps: [{ token: AuthenticationService }, { token: i2$1.Router }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
4340
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: Login, isStandalone: true, selector: "shell-login", ngImport: i0, template: "@let usernameRegex = USERNAME_REGEX;\n@let passwordRegex = PASSWORD_REGEX;\n<div class=\"login-container\">\n <div class=\"login-header\">\n <h2 class=\"text-text-primary\">{{ appName }}</h2>\n <p class=\"text-secondary-info\">Sign in to continue</p>\n </div>\n @let step = authenticationService.codeStep();\n @if (authenticationService.recoveryCodesToShow(); as codes) {\n <shell-recovery-codes [codes]=\"codes\" [appName]=\"appName\" [username]=\"model.username\" (done)=\"onRecoveryCodesSaved()\" />\n } @else if (step !== 'none') {\n <form (submit)=\"onSubmitCode($event)\" class=\"login-form\" aria-label=\"Authenticator code\">\n @if (step === 'setup' && authenticationService.setupChallenge(); as challenge) {\n <p class=\"text-secondary-info login-code-lead\">\n Set up your authenticator: scan this with Google Authenticator (or Authy, 1Password),\n then enter the 6-digit code it shows.\n </p>\n <img class=\"login-qr\" [src]=\"challenge.qr\" alt=\"QR code for your authenticator app\" width=\"200\" height=\"200\" />\n <p class=\"text-secondary-info login-key\">Can't scan? Enter this key: <code>{{ challenge.key }}</code></p>\n } @else {\n <p class=\"text-secondary-info login-code-lead\">\n @if (useRecovery()) {\n Enter one of your recovery codes.\n } @else {\n Enter the 6-digit code from your authenticator app.\n }\n </p>\n }\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Code' }}</mat-label>\n <input\n matInput\n name=\"code\"\n [(ngModel)]=\"codeText\"\n [disabled]=\"submitted\"\n [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\"\n [attr.maxlength]=\"useRecovery() ? 16 : 7\"\n [placeholder]=\"useRecovery() ? 'xxxxx-xxxxx' : '123456'\"\n autofocus\n />\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n {{ step === 'setup' ? 'Finish setup and sign in' : 'Sign in' }}\n </button>\n <div class=\"login-code-links\">\n @if (step === 'code') {\n <button mat-button type=\"button\" (click)=\"onToggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}\n </button>\n }\n <button mat-button type=\"button\" (click)=\"onCancelCode()\">Back</button>\n </div>\n </form>\n } @else {\n <form #loginForm=\"ngForm\" (ngSubmit)=\"onSubmit($event)\" class=\"login-form\">\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Username</mat-label>\n <input\n #usernameInput\n matInput\n placeholder=\"Enter username\"\n [(ngModel)]=\"model.username\"\n name=\"username\"\n [disabled]=\"submitted\"\n required\n regexValidator\n [regex]=\"usernameRegex\"\n (cdkAutofill)=\"onAutofill($event, usernameInput)\"\n />\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Password</mat-label>\n <input\n #passwordInput\n matInput\n placeholder=\"Enter password\"\n [type]=\"hide() ? 'password' : 'text'\"\n [(ngModel)]=\"model.password\"\n name=\"password\"\n [disabled]=\"submitted\"\n regexValidator\n [regex]=\"passwordRegex\"\n (cdkAutofill)=\"onAutofill($event, passwordInput)\"\n />\n <button\n mat-icon-button\n matSuffix\n type=\"button\"\n (click)=\"clickEvent($event)\"\n [attr.aria-label]=\"'Hide password'\"\n [attr.aria-pressed]=\"hide()\"\n >\n <mat-icon>{{ hide() ? 'visibility_off' : 'visibility' }}</mat-icon>\n </button>\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n Login\n </button>\n </form>\n }\n @if (errors.length > 0) {\n <div class=\"login-errors\">\n @for (error of errors; track error.ndx) {\n <div class=\"login-error text-loss\">{{ error.message }}</div>\n }\n </div>\n }\n @if (authenticationService.conflictSessions(); as sessions) {\n <div class=\"login-conflict\">\n <p class=\"text-secondary-info\">\n You are already signed in {{ sessions.length === 1 ? 'somewhere else' : 'on other devices' }}:\n </p>\n @for (session of sessions; track session.sessionId ?? $index) {\n <div class=\"login-conflict-session\">\n @if (canEndIndividually()) {\n <mat-checkbox\n [checked]=\"isSelected(session)\"\n [disabled]=\"!session.sessionId\"\n (change)=\"toggleSelected(session, $event.checked)\"\n [attr.aria-label]=\"'End ' + deviceLabel(session)\"\n ></mat-checkbox>\n }\n <span class=\"text-secondary-info\">\n <strong>{{ deviceLabel(session) }}</strong>\n <!-- signedInAtEpoch is when the password was typed. startEpoch is\n when the row was written, which renewal resets \u2014 so it is shown\n only when there is nothing better, and labelled for what it is. -->\n @if (session.signedInAtEpoch) {\n \u2014 since {{ session.signedInAtEpoch | date:'short' }}\n } @else {\n \u2014 last active {{ session.startEpoch | date:'short' }}\n }\n </span>\n </div>\n }\n <div class=\"login-conflict-actions\">\n <button mat-stroked-button type=\"button\" (click)=\"onCancelConflict()\">Cancel</button>\n @if (selectedCount() > 0) {\n <button mat-stroked-button type=\"button\" (click)=\"onEndSelected()\">\n End {{ selectedCount() === 1 ? 'that one' : selectedCount() + ' of them' }} and sign in\n </button>\n }\n <button mat-stroked-button type=\"button\" (click)=\"onTakeOver()\">\n {{ sessions.length === 1 ? 'End it and sign in' : 'End them all and sign in' }}\n </button>\n @if (authenticationService.additionalSignInAllowed()) {\n <button mat-flat-button type=\"button\" (click)=\"onSignInAsWell()\">Sign in here as well</button>\n }\n </div>\n </div>\n }\n</div>\n", styles: [".login-container{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center}.login-header{margin-bottom:32px}.login-header h2{font-size:1.5rem;font-weight:300;margin-bottom:8px}.login-form{display:flex;flex-direction:column;align-items:center;gap:8px;width:320px}.login-field{width:100%;text-align:left}.login-takeover{align-self:flex-start;margin-top:4px}.login-button{margin-top:8px;width:100%}.login-errors{margin-top:16px;max-width:320px}.login-conflict{margin-top:16px;max-width:320px;padding:12px 16px;border-radius:4px;background-color:var(--color-surface-light);font-size:.85rem;text-align:left}.login-conflict p{margin:0 0 8px}.login-conflict-session{display:flex;align-items:center;gap:8px;margin:4px 0}.login-conflict-actions{flex-wrap:wrap;display:flex;gap:8px;margin-top:12px;justify-content:flex-end}.login-error{padding:8px 12px;margin-bottom:4px;font-size:.85rem;border-radius:4px;background-color:var(--color-surface-light)}.login-code-lead{margin:0 0 8px;font-size:.9rem}.login-qr{display:block;margin:4px auto 8px;image-rendering:pixelated}.login-key{margin:0 0 8px;font-size:.8rem;word-break:break-all}.login-code-links{display:flex;justify-content:space-between;width:100%}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$2.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$2.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$2.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$2.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "directive", type: i1$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$2.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "directive", type: MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i4.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: i5.CdkAutofill, selector: "[cdkAutofill]", outputs: ["cdkAutofill"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i6.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i6.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i7.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: RegexValidatorDirective, selector: "[regexValidator]", inputs: ["regex"] }, { kind: "ngmodule", type: TextFieldModule }, { kind: "component", type: RecoveryCodes, selector: "shell-recovery-codes", inputs: ["codes", "appName", "username"], outputs: ["done"] }, { kind: "pipe", type: DatePipe, name: "date" }], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
|
|
4662
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: Login, isStandalone: true, selector: "shell-login", ngImport: i0, template: "@let usernameRegex = USERNAME_REGEX;\n@let passwordRegex = PASSWORD_REGEX;\n<div class=\"login-container\">\n <div class=\"login-header\">\n <h2 class=\"text-text-primary\">{{ appName }}</h2>\n <p class=\"text-secondary-info\">Sign in to continue</p>\n </div>\n @let step = authenticationService.codeStep();\n @if (authenticationService.recoveryCodesToShow(); as codes) {\n <shell-recovery-codes [codes]=\"codes\" [appName]=\"appName\" [username]=\"model.username\" (done)=\"onRecoveryCodesSaved()\" />\n } @else if (step !== 'none') {\n <form (submit)=\"onSubmitCode($event)\" class=\"login-form\" aria-label=\"Authenticator code\">\n @if (step === 'setup' && authenticationService.setupChallenge(); as challenge) {\n <p class=\"text-secondary-info login-code-lead\">\n Set up your authenticator: scan this with Google Authenticator (or Authy, 1Password),\n then enter the 6-digit code it shows.\n </p>\n <img class=\"login-qr\" [src]=\"challenge.qr\" alt=\"QR code for your authenticator app\" width=\"200\" height=\"200\" />\n <p class=\"text-secondary-info login-key\">Can't scan? Enter this key: <code>{{ challenge.key }}</code></p>\n } @else {\n <p class=\"text-secondary-info login-code-lead\">\n @if (useRecovery()) {\n Enter one of your recovery codes.\n } @else {\n Enter the 6-digit code from your authenticator app.\n }\n </p>\n }\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Code' }}</mat-label>\n <input\n matInput\n name=\"code\"\n [(ngModel)]=\"codeText\"\n [disabled]=\"submitted\"\n [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\"\n [attr.maxlength]=\"useRecovery() ? 16 : 7\"\n [placeholder]=\"useRecovery() ? 'xxxxx-xxxxx' : '123456'\"\n autofocus\n />\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n {{ step === 'setup' ? 'Finish setup and sign in' : 'Sign in' }}\n </button>\n <div class=\"login-code-links\">\n @if (step === 'code') {\n <button mat-button type=\"button\" (click)=\"onToggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}\n </button>\n }\n <button mat-button type=\"button\" (click)=\"onCancelCode()\">Back</button>\n </div>\n </form>\n } @else {\n <form #loginForm=\"ngForm\" (ngSubmit)=\"onSubmit($event)\" class=\"login-form\">\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Username</mat-label>\n <input\n #usernameInput\n matInput\n placeholder=\"Enter username\"\n [(ngModel)]=\"model.username\"\n name=\"username\"\n autocomplete=\"username webauthn\"\n [disabled]=\"submitted\"\n required\n regexValidator\n [regex]=\"usernameRegex\"\n (cdkAutofill)=\"onAutofill($event, usernameInput)\"\n />\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Password</mat-label>\n <input\n #passwordInput\n matInput\n placeholder=\"Enter password\"\n [type]=\"hide() ? 'password' : 'text'\"\n [(ngModel)]=\"model.password\"\n name=\"password\"\n [disabled]=\"submitted\"\n regexValidator\n [regex]=\"passwordRegex\"\n (cdkAutofill)=\"onAutofill($event, passwordInput)\"\n />\n <button\n mat-icon-button\n matSuffix\n type=\"button\"\n (click)=\"clickEvent($event)\"\n [attr.aria-label]=\"'Hide password'\"\n [attr.aria-pressed]=\"hide()\"\n >\n <mat-icon>{{ hide() ? 'visibility_off' : 'visibility' }}</mat-icon>\n </button>\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n Login\n </button>\n @if (passkeys) {\n <button mat-stroked-button type=\"button\" [disabled]=\"submitted\" class=\"login-button\" (click)=\"onPasskey()\">\n <mat-icon>fingerprint</mat-icon>\n Sign in with a passkey\n </button>\n }\n </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 }); }
|
|
4341
4663
|
}
|
|
4342
4664
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: Login, decorators: [{
|
|
4343
4665
|
type: Component,
|
|
@@ -4354,7 +4676,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
|
|
|
4354
4676
|
TextFieldModule,
|
|
4355
4677
|
DatePipe,
|
|
4356
4678
|
RecoveryCodes,
|
|
4357
|
-
], template: "@let usernameRegex = USERNAME_REGEX;\n@let passwordRegex = PASSWORD_REGEX;\n<div class=\"login-container\">\n <div class=\"login-header\">\n <h2 class=\"text-text-primary\">{{ appName }}</h2>\n <p class=\"text-secondary-info\">Sign in to continue</p>\n </div>\n @let step = authenticationService.codeStep();\n @if (authenticationService.recoveryCodesToShow(); as codes) {\n <shell-recovery-codes [codes]=\"codes\" [appName]=\"appName\" [username]=\"model.username\" (done)=\"onRecoveryCodesSaved()\" />\n } @else if (step !== 'none') {\n <form (submit)=\"onSubmitCode($event)\" class=\"login-form\" aria-label=\"Authenticator code\">\n @if (step === 'setup' && authenticationService.setupChallenge(); as challenge) {\n <p class=\"text-secondary-info login-code-lead\">\n Set up your authenticator: scan this with Google Authenticator (or Authy, 1Password),\n then enter the 6-digit code it shows.\n </p>\n <img class=\"login-qr\" [src]=\"challenge.qr\" alt=\"QR code for your authenticator app\" width=\"200\" height=\"200\" />\n <p class=\"text-secondary-info login-key\">Can't scan? Enter this key: <code>{{ challenge.key }}</code></p>\n } @else {\n <p class=\"text-secondary-info login-code-lead\">\n @if (useRecovery()) {\n Enter one of your recovery codes.\n } @else {\n Enter the 6-digit code from your authenticator app.\n }\n </p>\n }\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Code' }}</mat-label>\n <input\n matInput\n name=\"code\"\n [(ngModel)]=\"codeText\"\n [disabled]=\"submitted\"\n [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\"\n [attr.maxlength]=\"useRecovery() ? 16 : 7\"\n [placeholder]=\"useRecovery() ? 'xxxxx-xxxxx' : '123456'\"\n autofocus\n />\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n {{ step === 'setup' ? 'Finish setup and sign in' : 'Sign in' }}\n </button>\n <div class=\"login-code-links\">\n @if (step === 'code') {\n <button mat-button type=\"button\" (click)=\"onToggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}\n </button>\n }\n <button mat-button type=\"button\" (click)=\"onCancelCode()\">Back</button>\n </div>\n </form>\n } @else {\n <form #loginForm=\"ngForm\" (ngSubmit)=\"onSubmit($event)\" class=\"login-form\">\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Username</mat-label>\n <input\n #usernameInput\n matInput\n placeholder=\"Enter username\"\n [(ngModel)]=\"model.username\"\n name=\"username\"\n [disabled]=\"submitted\"\n required\n regexValidator\n [regex]=\"usernameRegex\"\n (cdkAutofill)=\"onAutofill($event, usernameInput)\"\n />\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Password</mat-label>\n <input\n #passwordInput\n matInput\n placeholder=\"Enter password\"\n [type]=\"hide() ? 'password' : 'text'\"\n [(ngModel)]=\"model.password\"\n name=\"password\"\n [disabled]=\"submitted\"\n regexValidator\n [regex]=\"passwordRegex\"\n (cdkAutofill)=\"onAutofill($event, passwordInput)\"\n />\n <button\n mat-icon-button\n matSuffix\n type=\"button\"\n (click)=\"clickEvent($event)\"\n [attr.aria-label]=\"'Hide password'\"\n [attr.aria-pressed]=\"hide()\"\n >\n <mat-icon>{{ hide() ? 'visibility_off' : 'visibility' }}</mat-icon>\n </button>\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n Login\n </button>\n </form>\n }\n @if (errors.length > 0) {\n <div class=\"login-errors\">\n @for (error of errors; track error.ndx) {\n <div class=\"login-error text-loss\">{{ error.message }}</div>\n }\n </div>\n }\n @if (authenticationService.conflictSessions(); as sessions) {\n <div class=\"login-conflict\">\n <p class=\"text-secondary-info\">\n You are already signed in {{ sessions.length === 1 ? 'somewhere else' : 'on other devices' }}:\n </p>\n @for (session of sessions; track session.sessionId ?? $index) {\n <div class=\"login-conflict-session\">\n @if (canEndIndividually()) {\n <mat-checkbox\n [checked]=\"isSelected(session)\"\n [disabled]=\"!session.sessionId\"\n (change)=\"toggleSelected(session, $event.checked)\"\n [attr.aria-label]=\"'End ' + deviceLabel(session)\"\n ></mat-checkbox>\n }\n <span class=\"text-secondary-info\">\n <strong>{{ deviceLabel(session) }}</strong>\n <!-- signedInAtEpoch is when the password was typed. startEpoch is\n when the row was written, which renewal resets \u2014 so it is shown\n only when there is nothing better, and labelled for what it is. -->\n @if (session.signedInAtEpoch) {\n \u2014 since {{ session.signedInAtEpoch | date:'short' }}\n } @else {\n \u2014 last active {{ session.startEpoch | date:'short' }}\n }\n </span>\n </div>\n }\n <div class=\"login-conflict-actions\">\n <button mat-stroked-button type=\"button\" (click)=\"onCancelConflict()\">Cancel</button>\n @if (selectedCount() > 0) {\n <button mat-stroked-button type=\"button\" (click)=\"onEndSelected()\">\n End {{ selectedCount() === 1 ? 'that one' : selectedCount() + ' of them' }} and sign in\n </button>\n }\n <button mat-stroked-button type=\"button\" (click)=\"onTakeOver()\">\n {{ sessions.length === 1 ? 'End it and sign in' : 'End them all and sign in' }}\n </button>\n @if (authenticationService.additionalSignInAllowed()) {\n <button mat-flat-button type=\"button\" (click)=\"onSignInAsWell()\">Sign in here as well</button>\n }\n </div>\n </div>\n }\n</div>\n", styles: [".login-container{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center}.login-header{margin-bottom:32px}.login-header h2{font-size:1.5rem;font-weight:300;margin-bottom:8px}.login-form{display:flex;flex-direction:column;align-items:center;gap:8px;width:320px}.login-field{width:100%;text-align:left}.login-takeover{align-self:flex-start;margin-top:4px}.login-button{margin-top:8px;width:100%}.login-errors{margin-top:16px;max-width:320px}.login-conflict{margin-top:16px;max-width:320px;padding:12px 16px;border-radius:4px;background-color:var(--color-surface-light);font-size:.85rem;text-align:left}.login-conflict p{margin:0 0 8px}.login-conflict-session{display:flex;align-items:center;gap:8px;margin:4px 0}.login-conflict-actions{flex-wrap:wrap;display:flex;gap:8px;margin-top:12px;justify-content:flex-end}.login-error{padding:8px 12px;margin-bottom:4px;font-size:.85rem;border-radius:4px;background-color:var(--color-surface-light)}.login-code-lead{margin:0 0 8px;font-size:.9rem}.login-qr{display:block;margin:4px auto 8px;image-rendering:pixelated}.login-key{margin:0 0 8px;font-size:.8rem;word-break:break-all}.login-code-links{display:flex;justify-content:space-between;width:100%}\n"] }]
|
|
4679
|
+
], template: "@let usernameRegex = USERNAME_REGEX;\n@let passwordRegex = PASSWORD_REGEX;\n<div class=\"login-container\">\n <div class=\"login-header\">\n <h2 class=\"text-text-primary\">{{ appName }}</h2>\n <p class=\"text-secondary-info\">Sign in to continue</p>\n </div>\n @let step = authenticationService.codeStep();\n @if (authenticationService.recoveryCodesToShow(); as codes) {\n <shell-recovery-codes [codes]=\"codes\" [appName]=\"appName\" [username]=\"model.username\" (done)=\"onRecoveryCodesSaved()\" />\n } @else if (step !== 'none') {\n <form (submit)=\"onSubmitCode($event)\" class=\"login-form\" aria-label=\"Authenticator code\">\n @if (step === 'setup' && authenticationService.setupChallenge(); as challenge) {\n <p class=\"text-secondary-info login-code-lead\">\n Set up your authenticator: scan this with Google Authenticator (or Authy, 1Password),\n then enter the 6-digit code it shows.\n </p>\n <img class=\"login-qr\" [src]=\"challenge.qr\" alt=\"QR code for your authenticator app\" width=\"200\" height=\"200\" />\n <p class=\"text-secondary-info login-key\">Can't scan? Enter this key: <code>{{ challenge.key }}</code></p>\n } @else {\n <p class=\"text-secondary-info login-code-lead\">\n @if (useRecovery()) {\n Enter one of your recovery codes.\n } @else {\n Enter the 6-digit code from your authenticator app.\n }\n </p>\n }\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Code' }}</mat-label>\n <input\n matInput\n name=\"code\"\n [(ngModel)]=\"codeText\"\n [disabled]=\"submitted\"\n [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\"\n [attr.maxlength]=\"useRecovery() ? 16 : 7\"\n [placeholder]=\"useRecovery() ? 'xxxxx-xxxxx' : '123456'\"\n autofocus\n />\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n {{ step === 'setup' ? 'Finish setup and sign in' : 'Sign in' }}\n </button>\n <div class=\"login-code-links\">\n @if (step === 'code') {\n <button mat-button type=\"button\" (click)=\"onToggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}\n </button>\n }\n <button mat-button type=\"button\" (click)=\"onCancelCode()\">Back</button>\n </div>\n </form>\n } @else {\n <form #loginForm=\"ngForm\" (ngSubmit)=\"onSubmit($event)\" class=\"login-form\">\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Username</mat-label>\n <input\n #usernameInput\n matInput\n placeholder=\"Enter username\"\n [(ngModel)]=\"model.username\"\n name=\"username\"\n autocomplete=\"username webauthn\"\n [disabled]=\"submitted\"\n required\n regexValidator\n [regex]=\"usernameRegex\"\n (cdkAutofill)=\"onAutofill($event, usernameInput)\"\n />\n </mat-form-field>\n <mat-form-field appearance=\"outline\" class=\"login-field\">\n <mat-label>Password</mat-label>\n <input\n #passwordInput\n matInput\n placeholder=\"Enter password\"\n [type]=\"hide() ? 'password' : 'text'\"\n [(ngModel)]=\"model.password\"\n name=\"password\"\n [disabled]=\"submitted\"\n regexValidator\n [regex]=\"passwordRegex\"\n (cdkAutofill)=\"onAutofill($event, passwordInput)\"\n />\n <button\n mat-icon-button\n matSuffix\n type=\"button\"\n (click)=\"clickEvent($event)\"\n [attr.aria-label]=\"'Hide password'\"\n [attr.aria-pressed]=\"hide()\"\n >\n <mat-icon>{{ hide() ? 'visibility_off' : 'visibility' }}</mat-icon>\n </button>\n </mat-form-field>\n <button mat-flat-button type=\"submit\" [disabled]=\"submitted\" class=\"login-button\">\n Login\n </button>\n @if (passkeys) {\n <button mat-stroked-button type=\"button\" [disabled]=\"submitted\" class=\"login-button\" (click)=\"onPasskey()\">\n <mat-icon>fingerprint</mat-icon>\n Sign in with a passkey\n </button>\n }\n </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"] }]
|
|
4358
4680
|
}], ctorParameters: () => [{ type: AuthenticationService }, { type: i2$1.Router }] });
|
|
4359
4681
|
|
|
4360
4682
|
/*
|
|
@@ -4370,6 +4692,11 @@ Settings → Sign-in code (authenticator-sign-in.prd.md D5, D7). Shared by every
|
|
|
4370
4692
|
|
|
4371
4693
|
⛔ There is deliberately no "remove" and no "turn off" here (D13). Those are AWS-only; the screen
|
|
4372
4694
|
says how to get them done.
|
|
4695
|
+
|
|
4696
|
+
Passkeys (passkey-sign-in.prd.md D13–D17), shown when the browser can do them and an authenticator
|
|
4697
|
+
is set up (D2 — a passkey is an option beside it, never instead):
|
|
4698
|
+
- Add one: a name, then password + code (or recovery code), or a passkey already added (D14).
|
|
4699
|
+
- Remove one: a code, a recovery code, or a passkey (D15). Removing only takes a way in away.
|
|
4373
4700
|
*/
|
|
4374
4701
|
class AuthenticatorSettings {
|
|
4375
4702
|
constructor() {
|
|
@@ -4389,8 +4716,16 @@ class AuthenticatorSettings {
|
|
|
4389
4716
|
...(ngDevMode ? [{ debugName: "codes" }] : /* istanbul ignore next */ []));
|
|
4390
4717
|
this.useRecovery = signal(false, /* @ts-ignore */
|
|
4391
4718
|
...(ngDevMode ? [{ debugName: "useRecovery" }] : /* istanbul ignore next */ []));
|
|
4719
|
+
/** Whether this browser can make and use passkeys at all. */
|
|
4720
|
+
this.passkeys = passkeysSupported();
|
|
4721
|
+
this.passkeyList = signal(undefined, /* @ts-ignore */
|
|
4722
|
+
...(ngDevMode ? [{ debugName: "passkeyList" }] : /* istanbul ignore next */ []));
|
|
4723
|
+
/** The passkey being removed, while its confirmation is on screen. */
|
|
4724
|
+
this.removing = signal(undefined, /* @ts-ignore */
|
|
4725
|
+
...(ngDevMode ? [{ debugName: "removing" }] : /* istanbul ignore next */ []));
|
|
4392
4726
|
this.password = '';
|
|
4393
4727
|
this.code = '';
|
|
4728
|
+
this.passkeyName = '';
|
|
4394
4729
|
}
|
|
4395
4730
|
#auth;
|
|
4396
4731
|
ngOnInit() {
|
|
@@ -4398,12 +4733,55 @@ class AuthenticatorSettings {
|
|
|
4398
4733
|
}
|
|
4399
4734
|
async #load() {
|
|
4400
4735
|
try {
|
|
4401
|
-
|
|
4736
|
+
const status = await this.#auth.authenticatorStatus();
|
|
4737
|
+
this.status.set(status);
|
|
4738
|
+
if (this.passkeys && status.enrolled)
|
|
4739
|
+
this.passkeyList.set(await this.#auth.listPasskeys());
|
|
4402
4740
|
}
|
|
4403
4741
|
catch (err) {
|
|
4404
4742
|
this.error.set(err instanceof AuthenticatorError ? err.message : 'Could not load your sign-in code settings.');
|
|
4405
4743
|
}
|
|
4406
4744
|
}
|
|
4745
|
+
beginAddPasskey() {
|
|
4746
|
+
this.#reset();
|
|
4747
|
+
this.mode.set('passkey-add');
|
|
4748
|
+
}
|
|
4749
|
+
beginRemovePasskey(passkey) {
|
|
4750
|
+
this.#reset();
|
|
4751
|
+
this.removing.set(passkey);
|
|
4752
|
+
this.mode.set('passkey-remove');
|
|
4753
|
+
}
|
|
4754
|
+
/** Add, proved with password + code (or recovery code) — or with a passkey already added. */
|
|
4755
|
+
async addPasskey(event, withPasskey = false) {
|
|
4756
|
+
event?.preventDefault();
|
|
4757
|
+
const name = this.passkeyName.trim();
|
|
4758
|
+
if (!name)
|
|
4759
|
+
return;
|
|
4760
|
+
await this.#run(async () => {
|
|
4761
|
+
await this.#auth.addPasskey(name, withPasskey ? 'passkey' : { password: this.password, ...this.#codeEntry() });
|
|
4762
|
+
await this.#passkeysChanged();
|
|
4763
|
+
});
|
|
4764
|
+
}
|
|
4765
|
+
/** Remove, proved with a code or recovery code — or with a passkey. */
|
|
4766
|
+
async removePasskey(event, withPasskey = false) {
|
|
4767
|
+
event?.preventDefault();
|
|
4768
|
+
const target = this.removing();
|
|
4769
|
+
if (!target)
|
|
4770
|
+
return;
|
|
4771
|
+
await this.#run(async () => {
|
|
4772
|
+
const proof = withPasskey ? 'passkey' : this.#codeEntry();
|
|
4773
|
+
await this.#auth.removePasskey(target.credentialId, proof);
|
|
4774
|
+
await this.#passkeysChanged();
|
|
4775
|
+
});
|
|
4776
|
+
}
|
|
4777
|
+
#codeEntry() {
|
|
4778
|
+
return this.useRecovery() ? { recoveryCode: this.code.trim() } : { code: this.code.trim() };
|
|
4779
|
+
}
|
|
4780
|
+
async #passkeysChanged() {
|
|
4781
|
+
this.#reset();
|
|
4782
|
+
this.mode.set('view');
|
|
4783
|
+
this.passkeyList.set(await this.#auth.listPasskeys());
|
|
4784
|
+
}
|
|
4407
4785
|
begin(mode) {
|
|
4408
4786
|
this.#reset();
|
|
4409
4787
|
this.mode.set(mode);
|
|
@@ -4470,17 +4848,19 @@ class AuthenticatorSettings {
|
|
|
4470
4848
|
#reset() {
|
|
4471
4849
|
this.password = '';
|
|
4472
4850
|
this.code = '';
|
|
4851
|
+
this.passkeyName = '';
|
|
4852
|
+
this.removing.set(undefined);
|
|
4473
4853
|
this.error.set(undefined);
|
|
4474
4854
|
this.challenge.set(undefined);
|
|
4475
4855
|
this.codes.set(undefined);
|
|
4476
4856
|
this.useRecovery.set(false);
|
|
4477
4857
|
}
|
|
4478
4858
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: AuthenticatorSettings, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
4479
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: AuthenticatorSettings, isStandalone: true, selector: "shell-authenticator-settings", ngImport: i0, template: "<section class=\"authenticator-settings\">\n <h2 class=\"text-text-primary\">Sign-in code</h2>\n <p class=\"text-secondary-info\">\n Signing in asks for your password and a 6-digit code from an authenticator app on your phone\n (Google Authenticator, Authy, 1Password).\n </p>\n\n @if (status(); as s) {\n @switch (mode()) {\n @case ('view') {\n <dl class=\"authenticator-facts\">\n <dt>Authenticator</dt>\n <dd>{{ s.enrolled ? 'Set up' : 'Not set up' }}</dd>\n @if (s.enrolled) {\n <dt>Recovery codes left</dt>\n <dd [class.text-loss]=\"s.recoveryCodesLeft <= 2\">{{ s.recoveryCodesLeft }} of 10</dd>\n }\n <dt>Code required at sign-in</dt>\n <dd>{{ s.enrolled ? 'Yes' : (s.codeRequired ? 'Yes' : 'Not yet \u2014 set one up') }}</dd>\n </dl>\n <div class=\"authenticator-actions\">\n <button mat-flat-button type=\"button\" (click)=\"begin('password')\">\n {{ s.enrolled ? 'Replace authenticator' : 'Set up authenticator' }}\n </button>\n @if (s.enrolled) {\n <button mat-stroked-button type=\"button\" (click)=\"begin('reissue')\">New recovery codes</button>\n }\n </div>\n <p class=\"text-secondary-info authenticator-note\">\n Lost your phone? Sign in with a recovery code, then replace the authenticator here.\n No recovery codes left? Only the administrator can reset it, from AWS \u2014 there is no way\n to switch the code off from inside the app.\n </p>\n }\n\n @case ('password') {\n <form class=\"authenticator-form\" (submit)=\"start($event)\">\n <mat-form-field appearance=\"outline\">\n <mat-label>Password</mat-label>\n <input matInput type=\"password\" name=\"password\" [(ngModel)]=\"password\" autocomplete=\"current-password\" [disabled]=\"busy()\" />\n </mat-form-field>\n @if (s.enrolled) {\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Current 6-digit code' }}</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\" [disabled]=\"busy()\" />\n </mat-form-field>\n <button mat-button type=\"button\" class=\"authenticator-link\" (click)=\"toggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Lost the phone? Use a recovery code' }}\n </button>\n }\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !password || (s.enrolled && !code.trim())\">Continue</button>\n </div>\n </form>\n }\n\n @case ('scan') {\n @if (challenge(); as c) {\n <form class=\"authenticator-form\" (submit)=\"confirm($event)\">\n <p class=\"text-secondary-info\">Scan this with your authenticator app, then enter the 6-digit code it shows.</p>\n <img class=\"authenticator-qr\" [src]=\"c.qr\" alt=\"QR code for your authenticator app\" width=\"200\" height=\"200\" />\n <p class=\"text-secondary-info authenticator-key\">Can't scan? Enter this key: <code>{{ c.key }}</code></p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Code</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" [disabled]=\"busy()\" />\n </mat-form-field>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Finish setup</button>\n </div>\n @if (s.enrolled) {\n <p class=\"text-secondary-info authenticator-note\">Your old authenticator keeps working until you finish.</p>\n }\n </form>\n }\n }\n\n @case ('reissue') {\n <form class=\"authenticator-form\" (submit)=\"reissue($event)\">\n <p class=\"text-secondary-info\">Ten new recovery codes. The ones you have now will stop working.</p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Current 6-digit code</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" [disabled]=\"busy()\" />\n </mat-form-field>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Make new codes</button>\n </div>\n </form>\n }\n\n @case ('codes') {\n @if (codes(); as list) {\n <shell-recovery-codes [codes]=\"list\" [appName]=\"appName\" (done)=\"codesSaved()\" />\n }\n }\n }\n }\n\n @if (error(); as message) {\n <p class=\"text-loss authenticator-error\" role=\"alert\">{{ message }}</p>\n }\n</section>\n", styles: [".authenticator-settings{max-width:480px;padding:16px}.authenticator-settings h2{font-size:1.25rem;font-weight:400;margin:0 0 8px}.authenticator-facts{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;margin:16px 0}.authenticator-facts dt{color:var(--color-secondary-info)}.authenticator-facts dd{margin:0}.authenticator-form{display:flex;flex-direction:column;gap:4px;max-width:320px;margin-top:12px}.authenticator-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}.authenticator-link{align-self:flex-start}.authenticator-note{margin-top:16px;font-size:.85rem}.authenticator-qr{display:block;margin:4px 0 8px;image-rendering:pixelated}.authenticator-key{font-size:.8rem;word-break:break-all}.authenticator-error{margin-top:12px}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$2.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$2.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$2.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$2.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i6.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i4.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: RecoveryCodes, selector: "shell-recovery-codes", inputs: ["codes", "appName", "username"], outputs: ["done"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
4859
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: AuthenticatorSettings, isStandalone: true, selector: "shell-authenticator-settings", ngImport: i0, template: "<section class=\"authenticator-settings\">\n <h2 class=\"text-text-primary\">Sign-in code</h2>\n <p class=\"text-secondary-info\">\n Signing in asks for your password and a 6-digit code from an authenticator app on your phone\n (Google Authenticator, Authy, 1Password).\n </p>\n\n @if (status(); as s) {\n @switch (mode()) {\n @case ('view') {\n <dl class=\"authenticator-facts\">\n <dt>Authenticator</dt>\n <dd>{{ s.enrolled ? 'Set up' : 'Not set up' }}</dd>\n @if (s.enrolled) {\n <dt>Recovery codes left</dt>\n <dd [class.text-loss]=\"s.recoveryCodesLeft <= 2\">{{ s.recoveryCodesLeft }} of 10</dd>\n }\n <dt>Code required at sign-in</dt>\n <dd>{{ s.enrolled ? 'Yes' : (s.codeRequired ? 'Yes' : 'Not yet \u2014 set one up') }}</dd>\n </dl>\n <div class=\"authenticator-actions\">\n <button mat-flat-button type=\"button\" (click)=\"begin('password')\">\n {{ s.enrolled ? 'Replace authenticator' : 'Set up authenticator' }}\n </button>\n @if (s.enrolled) {\n <button mat-stroked-button type=\"button\" (click)=\"begin('reissue')\">New recovery codes</button>\n }\n </div>\n <p class=\"text-secondary-info authenticator-note\">\n Lost your phone? Sign in with a recovery code, then replace the authenticator here.\n No recovery codes left? Only the administrator can reset it, from AWS \u2014 there is no way\n to switch the code off from inside the app.\n </p>\n @if (passkeys && s.enrolled) {\n <h3 class=\"text-text-primary passkey-heading\">Passkeys</h3>\n <p class=\"text-secondary-info\">\n Sign in with your fingerprint, face or device PIN instead of the password and code \u2014 on\n broken-stock and broken-stock-admin. The password and code keep working.\n </p>\n @if (passkeyList(); as list) {\n @if (list.length === 0) {\n <p class=\"text-secondary-info\">No passkeys yet.</p>\n } @else {\n <ul class=\"passkey-list\">\n @for (p of list; track p.credentialId) {\n <li class=\"passkey-row\">\n <span>\n <strong>{{ p.name }}</strong>\n <span class=\"text-secondary-info\">\n \u2014 added {{ p.addedAtEpoch | date:'mediumDate' }},\n {{ p.lastUsedAtEpoch ? 'last used ' + (p.lastUsedAtEpoch | date:'short') : 'not used yet' }},\n <span title=\"As the browser reported when the passkey was used\">\n {{ p.backedUp ? 'synced between your devices' : 'kept on one device or key' }}\n </span>\n </span>\n </span>\n <button mat-button type=\"button\" (click)=\"beginRemovePasskey(p)\">Remove</button>\n </li>\n }\n </ul>\n }\n }\n <div class=\"authenticator-actions\">\n <button mat-stroked-button type=\"button\" (click)=\"beginAddPasskey()\">Add a passkey</button>\n </div>\n }\n }\n\n @case ('passkey-add') {\n <form class=\"authenticator-form\" (submit)=\"addPasskey($event)\">\n <p class=\"text-secondary-info\">\n Name it after the device or key it lives on, then confirm it is you.\n </p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Name</mat-label>\n <input matInput name=\"passkeyName\" [(ngModel)]=\"passkeyName\" maxlength=\"64\" placeholder=\"MacBook, iPhone, YubiKey\" [disabled]=\"busy()\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>Password</mat-label>\n <input matInput type=\"password\" name=\"password\" [(ngModel)]=\"password\" autocomplete=\"current-password\" [disabled]=\"busy()\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Current 6-digit code' }}</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\" [disabled]=\"busy()\" />\n </mat-form-field>\n <button mat-button type=\"button\" class=\"authenticator-link\" (click)=\"toggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}\n </button>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n @if ((passkeyList() ?? []).length > 0) {\n <button mat-stroked-button type=\"button\" [disabled]=\"busy() || !passkeyName.trim()\" (click)=\"addPasskey(undefined, true)\">\n Confirm with a passkey instead\n </button>\n }\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !passkeyName.trim() || !password || !code.trim()\">Add passkey</button>\n </div>\n </form>\n }\n\n @case ('passkey-remove') {\n @if (removing(); as target) {\n <form class=\"authenticator-form\" (submit)=\"removePasskey($event)\">\n <p class=\"text-secondary-info\">\n Remove <strong>{{ target.name }}</strong>? It will no longer sign you in. Your password and\n code still work.\n </p>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Current 6-digit code' }}</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\" [disabled]=\"busy()\" />\n </mat-form-field>\n <button mat-button type=\"button\" class=\"authenticator-link\" (click)=\"toggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}\n </button>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-stroked-button type=\"button\" [disabled]=\"busy()\" (click)=\"removePasskey(undefined, true)\">Confirm with a passkey</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Remove</button>\n </div>\n </form>\n }\n }\n\n @case ('password') {\n <form class=\"authenticator-form\" (submit)=\"start($event)\">\n <mat-form-field appearance=\"outline\">\n <mat-label>Password</mat-label>\n <input matInput type=\"password\" name=\"password\" [(ngModel)]=\"password\" autocomplete=\"current-password\" [disabled]=\"busy()\" />\n </mat-form-field>\n @if (s.enrolled) {\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Current 6-digit code' }}</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\" [disabled]=\"busy()\" />\n </mat-form-field>\n <button mat-button type=\"button\" class=\"authenticator-link\" (click)=\"toggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Lost the phone? Use a recovery code' }}\n </button>\n }\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !password || (s.enrolled && !code.trim())\">Continue</button>\n </div>\n </form>\n }\n\n @case ('scan') {\n @if (challenge(); as c) {\n <form class=\"authenticator-form\" (submit)=\"confirm($event)\">\n <p class=\"text-secondary-info\">Scan this with your authenticator app, then enter the 6-digit code it shows.</p>\n <img class=\"authenticator-qr\" [src]=\"c.qr\" alt=\"QR code for your authenticator app\" width=\"200\" height=\"200\" />\n <p class=\"text-secondary-info authenticator-key\">Can't scan? Enter this key: <code>{{ c.key }}</code></p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Code</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" [disabled]=\"busy()\" />\n </mat-form-field>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Finish setup</button>\n </div>\n @if (s.enrolled) {\n <p class=\"text-secondary-info authenticator-note\">Your old authenticator keeps working until you finish.</p>\n }\n </form>\n }\n }\n\n @case ('reissue') {\n <form class=\"authenticator-form\" (submit)=\"reissue($event)\">\n <p class=\"text-secondary-info\">Ten new recovery codes. The ones you have now will stop working.</p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Current 6-digit code</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" [disabled]=\"busy()\" />\n </mat-form-field>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Make new codes</button>\n </div>\n </form>\n }\n\n @case ('codes') {\n @if (codes(); as list) {\n <shell-recovery-codes [codes]=\"list\" [appName]=\"appName\" (done)=\"codesSaved()\" />\n }\n }\n }\n }\n\n @if (error(); as message) {\n <p class=\"text-loss authenticator-error\" role=\"alert\">{{ message }}</p>\n }\n</section>\n", styles: [".authenticator-settings{max-width:480px;padding:16px}.authenticator-settings h2{font-size:1.25rem;font-weight:400;margin:0 0 8px}.authenticator-facts{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;margin:16px 0}.authenticator-facts dt{color:var(--color-secondary-info)}.authenticator-facts dd{margin:0}.authenticator-form{display:flex;flex-direction:column;gap:4px;max-width:320px;margin-top:12px}.authenticator-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}.authenticator-link{align-self:flex-start}.authenticator-note{margin-top:16px;font-size:.85rem}.authenticator-qr{display:block;margin:4px 0 8px;image-rendering:pixelated}.authenticator-key{font-size:.8rem;word-break:break-all}.authenticator-error{margin-top:12px}.passkey-heading{font-size:1.05rem;font-weight:400;margin:24px 0 4px}.passkey-list{list-style:none;padding:0;margin:8px 0}.passkey-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 0}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$2.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$2.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$2.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$2.MaxLengthValidator, selector: "[maxlength][formControlName],[maxlength][formControl],[maxlength][ngModel]", inputs: ["maxlength"] }, { kind: "directive", type: i1$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$2.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i6.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i4.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: RecoveryCodes, selector: "shell-recovery-codes", inputs: ["codes", "appName", "username"], outputs: ["done"] }, { kind: "pipe", type: DatePipe, name: "date" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
4480
4860
|
}
|
|
4481
4861
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: AuthenticatorSettings, decorators: [{
|
|
4482
4862
|
type: Component,
|
|
4483
|
-
args: [{ selector: 'shell-authenticator-settings', changeDetection: ChangeDetectionStrategy.OnPush, imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, RecoveryCodes], template: "<section class=\"authenticator-settings\">\n <h2 class=\"text-text-primary\">Sign-in code</h2>\n <p class=\"text-secondary-info\">\n Signing in asks for your password and a 6-digit code from an authenticator app on your phone\n (Google Authenticator, Authy, 1Password).\n </p>\n\n @if (status(); as s) {\n @switch (mode()) {\n @case ('view') {\n <dl class=\"authenticator-facts\">\n <dt>Authenticator</dt>\n <dd>{{ s.enrolled ? 'Set up' : 'Not set up' }}</dd>\n @if (s.enrolled) {\n <dt>Recovery codes left</dt>\n <dd [class.text-loss]=\"s.recoveryCodesLeft <= 2\">{{ s.recoveryCodesLeft }} of 10</dd>\n }\n <dt>Code required at sign-in</dt>\n <dd>{{ s.enrolled ? 'Yes' : (s.codeRequired ? 'Yes' : 'Not yet \u2014 set one up') }}</dd>\n </dl>\n <div class=\"authenticator-actions\">\n <button mat-flat-button type=\"button\" (click)=\"begin('password')\">\n {{ s.enrolled ? 'Replace authenticator' : 'Set up authenticator' }}\n </button>\n @if (s.enrolled) {\n <button mat-stroked-button type=\"button\" (click)=\"begin('reissue')\">New recovery codes</button>\n }\n </div>\n <p class=\"text-secondary-info authenticator-note\">\n Lost your phone? Sign in with a recovery code, then replace the authenticator here.\n No recovery codes left? Only the administrator can reset it, from AWS \u2014 there is no way\n to switch the code off from inside the app.\n </p>\n }\n\n @case ('password') {\n <form class=\"authenticator-form\" (submit)=\"start($event)\">\n <mat-form-field appearance=\"outline\">\n <mat-label>Password</mat-label>\n <input matInput type=\"password\" name=\"password\" [(ngModel)]=\"password\" autocomplete=\"current-password\" [disabled]=\"busy()\" />\n </mat-form-field>\n @if (s.enrolled) {\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Current 6-digit code' }}</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\" [disabled]=\"busy()\" />\n </mat-form-field>\n <button mat-button type=\"button\" class=\"authenticator-link\" (click)=\"toggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Lost the phone? Use a recovery code' }}\n </button>\n }\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !password || (s.enrolled && !code.trim())\">Continue</button>\n </div>\n </form>\n }\n\n @case ('scan') {\n @if (challenge(); as c) {\n <form class=\"authenticator-form\" (submit)=\"confirm($event)\">\n <p class=\"text-secondary-info\">Scan this with your authenticator app, then enter the 6-digit code it shows.</p>\n <img class=\"authenticator-qr\" [src]=\"c.qr\" alt=\"QR code for your authenticator app\" width=\"200\" height=\"200\" />\n <p class=\"text-secondary-info authenticator-key\">Can't scan? Enter this key: <code>{{ c.key }}</code></p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Code</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" [disabled]=\"busy()\" />\n </mat-form-field>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Finish setup</button>\n </div>\n @if (s.enrolled) {\n <p class=\"text-secondary-info authenticator-note\">Your old authenticator keeps working until you finish.</p>\n }\n </form>\n }\n }\n\n @case ('reissue') {\n <form class=\"authenticator-form\" (submit)=\"reissue($event)\">\n <p class=\"text-secondary-info\">Ten new recovery codes. The ones you have now will stop working.</p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Current 6-digit code</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" [disabled]=\"busy()\" />\n </mat-form-field>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Make new codes</button>\n </div>\n </form>\n }\n\n @case ('codes') {\n @if (codes(); as list) {\n <shell-recovery-codes [codes]=\"list\" [appName]=\"appName\" (done)=\"codesSaved()\" />\n }\n }\n }\n }\n\n @if (error(); as message) {\n <p class=\"text-loss authenticator-error\" role=\"alert\">{{ message }}</p>\n }\n</section>\n", styles: [".authenticator-settings{max-width:480px;padding:16px}.authenticator-settings h2{font-size:1.25rem;font-weight:400;margin:0 0 8px}.authenticator-facts{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;margin:16px 0}.authenticator-facts dt{color:var(--color-secondary-info)}.authenticator-facts dd{margin:0}.authenticator-form{display:flex;flex-direction:column;gap:4px;max-width:320px;margin-top:12px}.authenticator-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}.authenticator-link{align-self:flex-start}.authenticator-note{margin-top:16px;font-size:.85rem}.authenticator-qr{display:block;margin:4px 0 8px;image-rendering:pixelated}.authenticator-key{font-size:.8rem;word-break:break-all}.authenticator-error{margin-top:12px}\n"] }]
|
|
4863
|
+
args: [{ selector: 'shell-authenticator-settings', changeDetection: ChangeDetectionStrategy.OnPush, imports: [FormsModule, DatePipe, MatButtonModule, MatFormFieldModule, MatInputModule, RecoveryCodes], template: "<section class=\"authenticator-settings\">\n <h2 class=\"text-text-primary\">Sign-in code</h2>\n <p class=\"text-secondary-info\">\n Signing in asks for your password and a 6-digit code from an authenticator app on your phone\n (Google Authenticator, Authy, 1Password).\n </p>\n\n @if (status(); as s) {\n @switch (mode()) {\n @case ('view') {\n <dl class=\"authenticator-facts\">\n <dt>Authenticator</dt>\n <dd>{{ s.enrolled ? 'Set up' : 'Not set up' }}</dd>\n @if (s.enrolled) {\n <dt>Recovery codes left</dt>\n <dd [class.text-loss]=\"s.recoveryCodesLeft <= 2\">{{ s.recoveryCodesLeft }} of 10</dd>\n }\n <dt>Code required at sign-in</dt>\n <dd>{{ s.enrolled ? 'Yes' : (s.codeRequired ? 'Yes' : 'Not yet \u2014 set one up') }}</dd>\n </dl>\n <div class=\"authenticator-actions\">\n <button mat-flat-button type=\"button\" (click)=\"begin('password')\">\n {{ s.enrolled ? 'Replace authenticator' : 'Set up authenticator' }}\n </button>\n @if (s.enrolled) {\n <button mat-stroked-button type=\"button\" (click)=\"begin('reissue')\">New recovery codes</button>\n }\n </div>\n <p class=\"text-secondary-info authenticator-note\">\n Lost your phone? Sign in with a recovery code, then replace the authenticator here.\n No recovery codes left? Only the administrator can reset it, from AWS \u2014 there is no way\n to switch the code off from inside the app.\n </p>\n @if (passkeys && s.enrolled) {\n <h3 class=\"text-text-primary passkey-heading\">Passkeys</h3>\n <p class=\"text-secondary-info\">\n Sign in with your fingerprint, face or device PIN instead of the password and code \u2014 on\n broken-stock and broken-stock-admin. The password and code keep working.\n </p>\n @if (passkeyList(); as list) {\n @if (list.length === 0) {\n <p class=\"text-secondary-info\">No passkeys yet.</p>\n } @else {\n <ul class=\"passkey-list\">\n @for (p of list; track p.credentialId) {\n <li class=\"passkey-row\">\n <span>\n <strong>{{ p.name }}</strong>\n <span class=\"text-secondary-info\">\n \u2014 added {{ p.addedAtEpoch | date:'mediumDate' }},\n {{ p.lastUsedAtEpoch ? 'last used ' + (p.lastUsedAtEpoch | date:'short') : 'not used yet' }},\n <span title=\"As the browser reported when the passkey was used\">\n {{ p.backedUp ? 'synced between your devices' : 'kept on one device or key' }}\n </span>\n </span>\n </span>\n <button mat-button type=\"button\" (click)=\"beginRemovePasskey(p)\">Remove</button>\n </li>\n }\n </ul>\n }\n }\n <div class=\"authenticator-actions\">\n <button mat-stroked-button type=\"button\" (click)=\"beginAddPasskey()\">Add a passkey</button>\n </div>\n }\n }\n\n @case ('passkey-add') {\n <form class=\"authenticator-form\" (submit)=\"addPasskey($event)\">\n <p class=\"text-secondary-info\">\n Name it after the device or key it lives on, then confirm it is you.\n </p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Name</mat-label>\n <input matInput name=\"passkeyName\" [(ngModel)]=\"passkeyName\" maxlength=\"64\" placeholder=\"MacBook, iPhone, YubiKey\" [disabled]=\"busy()\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>Password</mat-label>\n <input matInput type=\"password\" name=\"password\" [(ngModel)]=\"password\" autocomplete=\"current-password\" [disabled]=\"busy()\" />\n </mat-form-field>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Current 6-digit code' }}</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\" [disabled]=\"busy()\" />\n </mat-form-field>\n <button mat-button type=\"button\" class=\"authenticator-link\" (click)=\"toggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}\n </button>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n @if ((passkeyList() ?? []).length > 0) {\n <button mat-stroked-button type=\"button\" [disabled]=\"busy() || !passkeyName.trim()\" (click)=\"addPasskey(undefined, true)\">\n Confirm with a passkey instead\n </button>\n }\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !passkeyName.trim() || !password || !code.trim()\">Add passkey</button>\n </div>\n </form>\n }\n\n @case ('passkey-remove') {\n @if (removing(); as target) {\n <form class=\"authenticator-form\" (submit)=\"removePasskey($event)\">\n <p class=\"text-secondary-info\">\n Remove <strong>{{ target.name }}</strong>? It will no longer sign you in. Your password and\n code still work.\n </p>\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Current 6-digit code' }}</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\" [disabled]=\"busy()\" />\n </mat-form-field>\n <button mat-button type=\"button\" class=\"authenticator-link\" (click)=\"toggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Use a recovery code' }}\n </button>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-stroked-button type=\"button\" [disabled]=\"busy()\" (click)=\"removePasskey(undefined, true)\">Confirm with a passkey</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Remove</button>\n </div>\n </form>\n }\n }\n\n @case ('password') {\n <form class=\"authenticator-form\" (submit)=\"start($event)\">\n <mat-form-field appearance=\"outline\">\n <mat-label>Password</mat-label>\n <input matInput type=\"password\" name=\"password\" [(ngModel)]=\"password\" autocomplete=\"current-password\" [disabled]=\"busy()\" />\n </mat-form-field>\n @if (s.enrolled) {\n <mat-form-field appearance=\"outline\">\n <mat-label>{{ useRecovery() ? 'Recovery code' : 'Current 6-digit code' }}</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" [attr.inputmode]=\"useRecovery() ? 'text' : 'numeric'\"\n [attr.autocomplete]=\"useRecovery() ? 'off' : 'one-time-code'\" [disabled]=\"busy()\" />\n </mat-form-field>\n <button mat-button type=\"button\" class=\"authenticator-link\" (click)=\"toggleRecovery()\">\n {{ useRecovery() ? 'Use the 6-digit code' : 'Lost the phone? Use a recovery code' }}\n </button>\n }\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !password || (s.enrolled && !code.trim())\">Continue</button>\n </div>\n </form>\n }\n\n @case ('scan') {\n @if (challenge(); as c) {\n <form class=\"authenticator-form\" (submit)=\"confirm($event)\">\n <p class=\"text-secondary-info\">Scan this with your authenticator app, then enter the 6-digit code it shows.</p>\n <img class=\"authenticator-qr\" [src]=\"c.qr\" alt=\"QR code for your authenticator app\" width=\"200\" height=\"200\" />\n <p class=\"text-secondary-info authenticator-key\">Can't scan? Enter this key: <code>{{ c.key }}</code></p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Code</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" [disabled]=\"busy()\" />\n </mat-form-field>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Finish setup</button>\n </div>\n @if (s.enrolled) {\n <p class=\"text-secondary-info authenticator-note\">Your old authenticator keeps working until you finish.</p>\n }\n </form>\n }\n }\n\n @case ('reissue') {\n <form class=\"authenticator-form\" (submit)=\"reissue($event)\">\n <p class=\"text-secondary-info\">Ten new recovery codes. The ones you have now will stop working.</p>\n <mat-form-field appearance=\"outline\">\n <mat-label>Current 6-digit code</mat-label>\n <input matInput name=\"code\" [(ngModel)]=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" [disabled]=\"busy()\" />\n </mat-form-field>\n <div class=\"authenticator-actions\">\n <button mat-button type=\"button\" (click)=\"cancel()\">Cancel</button>\n <button mat-flat-button type=\"submit\" [disabled]=\"busy() || !code.trim()\">Make new codes</button>\n </div>\n </form>\n }\n\n @case ('codes') {\n @if (codes(); as list) {\n <shell-recovery-codes [codes]=\"list\" [appName]=\"appName\" (done)=\"codesSaved()\" />\n }\n }\n }\n }\n\n @if (error(); as message) {\n <p class=\"text-loss authenticator-error\" role=\"alert\">{{ message }}</p>\n }\n</section>\n", styles: [".authenticator-settings{max-width:480px;padding:16px}.authenticator-settings h2{font-size:1.25rem;font-weight:400;margin:0 0 8px}.authenticator-facts{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;margin:16px 0}.authenticator-facts dt{color:var(--color-secondary-info)}.authenticator-facts dd{margin:0}.authenticator-form{display:flex;flex-direction:column;gap:4px;max-width:320px;margin-top:12px}.authenticator-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}.authenticator-link{align-self:flex-start}.authenticator-note{margin-top:16px;font-size:.85rem}.authenticator-qr{display:block;margin:4px 0 8px;image-rendering:pixelated}.authenticator-key{font-size:.8rem;word-break:break-all}.authenticator-error{margin-top:12px}.passkey-heading{font-size:1.05rem;font-weight:400;margin:24px 0 4px}.passkey-list{list-style:none;padding:0;margin:8px 0}.passkey-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 0}\n"] }]
|
|
4484
4864
|
}] });
|
|
4485
4865
|
|
|
4486
4866
|
/*
|
|
@@ -4633,5 +5013,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
|
|
|
4633
5013
|
* Generated bundle index. Do not edit.
|
|
4634
5014
|
*/
|
|
4635
5015
|
|
|
4636
|
-
export { AuthenticationService, AuthenticatorError, AuthenticatorSettings, CLEAR_USAGE, CODE_STEP, CodeDialog, CommandRegistryService, CommandService, CommandTerminal, ConfirmDialog, FAQ_CONTENT_LOADER, FAQ_DEV_EDITOR, FAQ_DEV_MODE, FAQ_DEV_WORKSPACE_ROOT, FAQ_PUBLIC_PUBLISHER, FAQ_PUBLIC_SITE_BASE_URL, FaqAuthoringPanel, FaqHistoryService, FaqMarkdownRendererService, FaqModal, FaqService, HELP_USAGE, HttpFaqContentLoader, KeyboardShortcutService, Login, MainContent, PendingPromptState, PhoneLayoutService, PublicFaqIndex, PublicFaqPage, PublicFaqToc, RecoveryCodes, RegexValidatorDirective, SESSION_TOKEN_KEY, SHELL_APP_NAME, SHELL_AUTH_URLS, SHELL_COMMANDS, SHELL_MENU_ITEMS, SHELL_PHONE_MEDIA_QUERY, SHELL_POST_LOGIN_ROUTE, SHELL_SHARED_SESSION, ScreenKeysService, SessionTokenStore, Shell, SideMenu, StatusBar, TERMINAL_INPUT_CLASS, TOO_MANY_ATTEMPTS, TerminalFocusService, TerminalVisibilityService, ThemeService, ThinkingService, answerEcho, audienceAllowed, authGuard, authInterceptor, authenticatorMessage, buildFallbackChain, buildLoginBody, comboMatches, decideRenewal, guestGuard, isEligibleKey, isTypingElsewhere, isYes, matchesRoute, openCodeDialog, provideShellConfig, recoveryCodesFile, tokenize };
|
|
5016
|
+
export { AuthenticationService, AuthenticatorError, AuthenticatorSettings, CLEAR_USAGE, CODE_STEP, CodeDialog, CommandRegistryService, CommandService, CommandTerminal, ConfirmDialog, FAQ_CONTENT_LOADER, FAQ_DEV_EDITOR, FAQ_DEV_MODE, FAQ_DEV_WORKSPACE_ROOT, FAQ_PUBLIC_PUBLISHER, FAQ_PUBLIC_SITE_BASE_URL, FaqAuthoringPanel, FaqHistoryService, FaqMarkdownRendererService, FaqModal, FaqService, HELP_USAGE, HttpFaqContentLoader, KeyboardShortcutService, Login, MainContent, PASSKEY_CANCELLED, PendingPromptState, PhoneLayoutService, PublicFaqIndex, PublicFaqPage, PublicFaqToc, RecoveryCodes, RegexValidatorDirective, SESSION_TOKEN_KEY, SHELL_APP_NAME, SHELL_AUTH_URLS, SHELL_COMMANDS, SHELL_MENU_ITEMS, SHELL_PHONE_MEDIA_QUERY, SHELL_POST_LOGIN_ROUTE, SHELL_SHARED_SESSION, ScreenKeysService, SessionTokenStore, Shell, SideMenu, StatusBar, TERMINAL_INPUT_CLASS, TOO_MANY_ATTEMPTS, TerminalFocusService, TerminalVisibilityService, ThemeService, ThinkingService, answerEcho, audienceAllowed, authGuard, authInterceptor, authenticatorMessage, buildFallbackChain, buildLoginBody, comboMatches, decideRenewal, guestGuard, isEligibleKey, isTypingElsewhere, isYes, matchesRoute, openCodeDialog, passkeyAutofillSupported, passkeysSupported, provideShellConfig, recoveryCodesFile, tokenize };
|
|
4637
5017
|
//# sourceMappingURL=franzzemen-brokenstock-shell.mjs.map
|