@atlasauth/angular 0.3.0 → 0.4.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.
- package/CHANGELOG.md +41 -0
- package/dist/esm2022/lib/atlas.service.mjs +87 -32
- package/dist/esm2022/lib/components/attempt-flow.component.mjs +65 -4
- package/dist/esm2022/lib/components/flow-controller.mjs +49 -2
- package/dist/esm2022/lib/components/sign-in.component.mjs +11 -4
- package/dist/esm2022/lib/components/sign-up-form.component.mjs +25 -3
- package/dist/esm2022/lib/components/sign-up.component.mjs +2 -2
- package/dist/esm2022/lib/i18n.mjs +4 -1
- package/dist/esm2022/public-api.mjs +4 -1
- package/dist/fesm2022/atlasauth-angular.mjs +236 -41
- package/dist/fesm2022/atlasauth-angular.mjs.map +1 -1
- package/dist/lib/atlas.service.d.ts +23 -3
- package/dist/lib/components/attempt-flow.component.d.ts +14 -1
- package/dist/lib/components/flow-controller.d.ts +26 -0
- package/dist/lib/components/sign-up-form.component.d.ts +5 -1
- package/dist/public-api.d.ts +2 -1
- package/package.json +6 -5
|
@@ -4,8 +4,8 @@ import { isPlatformBrowser, DOCUMENT } from '@angular/common';
|
|
|
4
4
|
import { toSignal } from '@angular/core/rxjs-interop';
|
|
5
5
|
import { BehaviorSubject, combineLatest, firstValueFrom } from 'rxjs';
|
|
6
6
|
import { map, distinctUntilChanged, filter, take } from 'rxjs/operators';
|
|
7
|
-
import { TokenCache, resolveFrontendApi, FapiClient,
|
|
8
|
-
export { FapiClient, advance, flowFromPending, getProviderToken, initialFlow, pollAttempt, prepareFactor, requestForStep, shouldKeepPolling } from '@atlasauth/js';
|
|
7
|
+
import { TokenCache, resolveFrontendApi, FapiClient, SignOutGuard, getProviderToken, readRedirectResult, completeOAuthPopupLanding, mapOAuthPopupPayload, stripRedirectParams, closeOAuthPopupAfterSignIn, readExpiry, msUntilRefresh, nextStep, createCaptchaController, flowFromPending, initialFlow, fetchInstanceSettings, offeredFirstFactors, prepareFactor, signInCaptchaOptions, flowNeedsCaptcha, advanceWithCaptcha, createSignUp, shouldKeepPolling, pollAttempt } from '@atlasauth/js';
|
|
8
|
+
export { FapiClient, SignOutError, advance, flowFromPending, getProviderToken, initialFlow, pollAttempt, prepareFactor, requestForStep, shouldKeepPolling } from '@atlasauth/js';
|
|
9
9
|
import { evaluate } from '@atlasauth/authz';
|
|
10
10
|
export { evaluate, hasFromClaims } from '@atlasauth/authz';
|
|
11
11
|
import { renderDesignAppearance, instanceDesignFrom, applyAppearanceToDom } from '@atlasauth/design/runtime';
|
|
@@ -167,6 +167,7 @@ const EN_US = {
|
|
|
167
167
|
'signIn.orDivider': 'or',
|
|
168
168
|
'signIn.socialButton': 'Continue with {provider}',
|
|
169
169
|
'signIn.emailCodeLabel': 'Verification code',
|
|
170
|
+
'signIn.useEmailCode': 'Email me a code',
|
|
170
171
|
'signIn.magicLinkSent': 'Check your email for a sign-in link.',
|
|
171
172
|
'signIn.checkOtherTab': 'You can close this tab — you are signed in where you started.',
|
|
172
173
|
'signUp.title': 'Create your account',
|
|
@@ -182,6 +183,8 @@ const EN_US = {
|
|
|
182
183
|
'signUp.terms': 'Terms of Service',
|
|
183
184
|
'signUp.privacy': 'Privacy Policy',
|
|
184
185
|
'signUp.and': 'and',
|
|
186
|
+
// = EMAIL_UNAVAILABLE_MESSAGE from @atlasauth/js.
|
|
187
|
+
'signUp.emailUnavailable': "This app can't send email right now. Try another sign-in method or contact the app owner.",
|
|
185
188
|
'captcha.prompt': 'Complete the security check to continue.',
|
|
186
189
|
'captcha.required': 'Please complete the security check, then try again.',
|
|
187
190
|
'captcha.failed': "The security check didn't pass. Please try again.",
|
|
@@ -262,6 +265,14 @@ class AtlasService {
|
|
|
262
265
|
cache = new TokenCache();
|
|
263
266
|
fapiClient;
|
|
264
267
|
refreshTimer = null;
|
|
268
|
+
/**
|
|
269
|
+
* The sessions this browser signed out of (tombstones in localStorage, per
|
|
270
|
+
* Frontend API origin), the revoke and its background re-tries. Every reload /
|
|
271
|
+
* refresh asks it before adopting a session, so a sign-out the server never
|
|
272
|
+
* confirmed is not undone by the still-valid refresh cookie.
|
|
273
|
+
*/
|
|
274
|
+
guard;
|
|
275
|
+
stopWatchingSignOut = null;
|
|
265
276
|
catalog;
|
|
266
277
|
/**
|
|
267
278
|
* A sign-in attempt handed back by an OAuth redirect that still needs a second
|
|
@@ -323,6 +334,10 @@ class AtlasService {
|
|
|
323
334
|
baseUrl: this.baseUrl,
|
|
324
335
|
fetchImpl: this.doFetch,
|
|
325
336
|
});
|
|
337
|
+
this.guard = new SignOutGuard({
|
|
338
|
+
request: (path, init) => this.call(path, init),
|
|
339
|
+
frontendApi: this.baseUrl,
|
|
340
|
+
});
|
|
326
341
|
// Captured synchronously (not in an async init) so it is available before any
|
|
327
342
|
// flow component reads it: a needs_second_factor redirect carries a status but
|
|
328
343
|
// no ticket, and that is what the 2FA resume seeds from.
|
|
@@ -330,10 +345,19 @@ class AtlasService {
|
|
|
330
345
|
// Boot once, browser only. On the server there is no cookie jar to read a
|
|
331
346
|
// session from, so we leave the state at `loading` and let hydration boot it.
|
|
332
347
|
if (this.isBrowser) {
|
|
348
|
+
// Another tab signed out of THIS session (its tombstone reached the shared
|
|
349
|
+
// store): drop the token and re-check, which then refuses the session.
|
|
350
|
+
this.stopWatchingSignOut = this.guard.watch(() => this.cache.peek()?.sessionId ?? this._session.value?.id, () => {
|
|
351
|
+
this.cache.clear();
|
|
352
|
+
void this.reload();
|
|
353
|
+
});
|
|
333
354
|
void this.boot();
|
|
334
355
|
}
|
|
335
356
|
}
|
|
336
357
|
ngOnDestroy() {
|
|
358
|
+
this.stopWatchingSignOut?.();
|
|
359
|
+
this.stopWatchingSignOut = null;
|
|
360
|
+
this.guard.dispose();
|
|
337
361
|
this.clearRefreshTimer();
|
|
338
362
|
this._status.complete();
|
|
339
363
|
this._user.complete();
|
|
@@ -412,25 +436,27 @@ class AtlasService {
|
|
|
412
436
|
has(condition) {
|
|
413
437
|
return evaluate(this._claims.value, condition).allowed;
|
|
414
438
|
}
|
|
415
|
-
/**
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
this.
|
|
431
|
-
this.
|
|
432
|
-
this.
|
|
433
|
-
this.
|
|
439
|
+
/**
|
|
440
|
+
* Revoke the current session server-side and drop all local auth state.
|
|
441
|
+
*
|
|
442
|
+
* The session id is remembered before the revoke (a tombstone shared by every
|
|
443
|
+
* tab), so a reload, a token refresh, a sibling tab or a tab opened later never
|
|
444
|
+
* re-adopts it — even when the revoke failed (it is retried once, then again in
|
|
445
|
+
* the background) and the refresh cookie is still valid. Resolves
|
|
446
|
+
* `{ revoked, activeSessionId? }`; with `{ strict: true }` an unconfirmed revoke
|
|
447
|
+
* rejects with a `SignOutError` instead (after the local sign-out). A parked
|
|
448
|
+
* session the server promoted on revoke is adopted. Front-channel logout runs
|
|
449
|
+
* unless `{ frontchannel: false }`.
|
|
450
|
+
*/
|
|
451
|
+
async signOut(options = {}) {
|
|
452
|
+
// With no cached token the guard finds the cookie session itself
|
|
453
|
+
// (GET /v1/client), so a missing JWT never skips the revoke.
|
|
454
|
+
const sessionId = this.cache.peek()?.sessionId ?? this._session.value?.id ?? null;
|
|
455
|
+
const attempt = await this.guard.revoke(sessionId, options);
|
|
456
|
+
this.clearLocalState();
|
|
457
|
+
if (this.guard.promoted(attempt))
|
|
458
|
+
await this.reload();
|
|
459
|
+
return this.guard.settle(attempt, options);
|
|
434
460
|
}
|
|
435
461
|
/**
|
|
436
462
|
* Switch the active organization (or `null` for the personal account). The
|
|
@@ -458,14 +484,10 @@ class AtlasService {
|
|
|
458
484
|
if (!response.ok)
|
|
459
485
|
throw new Error('boot failed');
|
|
460
486
|
const body = (await response.json());
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
this.
|
|
465
|
-
this._user.next(null);
|
|
466
|
-
this._session.next(null);
|
|
467
|
-
this._claims.next(null);
|
|
468
|
-
this._memberships.next([]);
|
|
487
|
+
// A session this browser signed out of is never re-adopted from the
|
|
488
|
+
// refresh cookie; the guard re-tries its revoke in the background.
|
|
489
|
+
if (!body.session || !body.user || this.guard.refuse(body.session.id)) {
|
|
490
|
+
this.clearLocalState();
|
|
469
491
|
return;
|
|
470
492
|
}
|
|
471
493
|
this._user.next(body.user);
|
|
@@ -493,17 +515,41 @@ class AtlasService {
|
|
|
493
515
|
* has no ticket, so nothing is exchanged and the app boots signed-out.
|
|
494
516
|
*/
|
|
495
517
|
async boot() {
|
|
496
|
-
const
|
|
518
|
+
const fromUrl = readRedirectResult(window.location.search);
|
|
519
|
+
let result = fromUrl;
|
|
520
|
+
// §oauth popup mode: this page may be the landing of a sign-in popup whose
|
|
521
|
+
// opener a Cross-Origin-Opener-Policy severed (`#atlas_popup_result=…`).
|
|
522
|
+
// The result is relayed to the opener, which redeems the ticket, and this
|
|
523
|
+
// window closes. If no opener takes it (its tab is gone), finish here like
|
|
524
|
+
// a redirect.
|
|
525
|
+
const landing = await completeOAuthPopupLanding().catch(() => null);
|
|
526
|
+
if (landing && !landing.delivered) {
|
|
527
|
+
const outcome = mapOAuthPopupPayload(landing.payload);
|
|
528
|
+
if (outcome?.ticket && outcome.attemptId) {
|
|
529
|
+
result = { attemptId: outcome.attemptId, ticket: outcome.ticket };
|
|
530
|
+
}
|
|
531
|
+
else if (outcome?.continuationUrl) {
|
|
532
|
+
// A second factor / connect step / provider error: the redirect flow's page.
|
|
533
|
+
window.location.replace(outcome.continuationUrl);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
let exchanged = false;
|
|
497
538
|
if (result?.ticket) {
|
|
498
|
-
await this.call('/v1/client/tickets/exchange', {
|
|
539
|
+
const response = await this.call('/v1/client/tickets/exchange', {
|
|
499
540
|
method: 'POST',
|
|
500
541
|
body: JSON.stringify({ attempt_id: result.attemptId, ticket: result.ticket }),
|
|
501
542
|
}).catch(() => undefined);
|
|
543
|
+
exchanged = !!response?.ok;
|
|
502
544
|
}
|
|
503
|
-
if (
|
|
545
|
+
if (fromUrl) {
|
|
504
546
|
window.history.replaceState({}, document.title, stripRedirectParams(window.location.href));
|
|
505
547
|
}
|
|
506
548
|
await this.reload();
|
|
549
|
+
// Signed in inside the Atlas popup (the redirect flow ran in it): hand back
|
|
550
|
+
// to the waiting opener and close. A no-op in an ordinary tab.
|
|
551
|
+
if (exchanged)
|
|
552
|
+
void closeOAuthPopupAfterSignIn();
|
|
507
553
|
}
|
|
508
554
|
readPendingAttempt() {
|
|
509
555
|
const result = readRedirectResult(window.location.search);
|
|
@@ -525,6 +571,9 @@ class AtlasService {
|
|
|
525
571
|
});
|
|
526
572
|
}
|
|
527
573
|
applyToken(jwt, sessionId) {
|
|
574
|
+
// An in-flight refresh must not resurrect a session signed out meanwhile.
|
|
575
|
+
if (this.guard.isSignedOut(sessionId))
|
|
576
|
+
return;
|
|
528
577
|
const expiresAt = readExpiry(jwt);
|
|
529
578
|
if (!expiresAt)
|
|
530
579
|
return;
|
|
@@ -550,6 +599,15 @@ class AtlasService {
|
|
|
550
599
|
await this.reload();
|
|
551
600
|
}
|
|
552
601
|
}
|
|
602
|
+
clearLocalState() {
|
|
603
|
+
this.clearRefreshTimer();
|
|
604
|
+
this.cache.clear();
|
|
605
|
+
this._status.next('signed_out');
|
|
606
|
+
this._user.next(null);
|
|
607
|
+
this._session.next(null);
|
|
608
|
+
this._claims.next(null);
|
|
609
|
+
this._memberships.next([]);
|
|
610
|
+
}
|
|
553
611
|
/** §7.4: proactive, jittered refresh scheduled from the token's own expiry. */
|
|
554
612
|
scheduleRefresh() {
|
|
555
613
|
this.clearRefreshTimer();
|
|
@@ -901,8 +959,18 @@ class AttemptFlowComponent {
|
|
|
901
959
|
* with `needs_captcha`. Null when the flow draws none.
|
|
902
960
|
*/
|
|
903
961
|
captcha = null;
|
|
962
|
+
/**
|
|
963
|
+
* The first factors to offer on the `needs_first_factor` step — pass
|
|
964
|
+
* `AtlasFlowController.firstFactors()`, which drops email code / link while the
|
|
965
|
+
* app cannot deliver email. Null uses the server's list as-is.
|
|
966
|
+
*/
|
|
967
|
+
firstFactors = null;
|
|
968
|
+
/** The first factor already sent (`AtlasFlowController.preparedFactor()`): its code is collected. */
|
|
969
|
+
preparedFactor = null;
|
|
904
970
|
/** Emits the collected field values when the form is submitted. */
|
|
905
971
|
submitFlow = new EventEmitter();
|
|
972
|
+
/** Emits when the user picks a first factor the server must send first (an emailed code). */
|
|
973
|
+
chooseFactor = new EventEmitter();
|
|
906
974
|
values = {};
|
|
907
975
|
t(key, vars) {
|
|
908
976
|
return translate(this.atlas.localization, key, vars);
|
|
@@ -930,8 +998,22 @@ class AttemptFlowComponent {
|
|
|
930
998
|
const kind = this.step().kind;
|
|
931
999
|
return !!this.captcha && (kind === 'collect_identifier' || kind === 'collect_captcha');
|
|
932
1000
|
}
|
|
1001
|
+
/** The first factors on offer at a `needs_first_factor` step (empty on any other). */
|
|
1002
|
+
offered() {
|
|
1003
|
+
const step = this.step();
|
|
1004
|
+
if (step.kind !== 'collect_first_factor')
|
|
1005
|
+
return [];
|
|
1006
|
+
return this.firstFactors ?? step.strategies;
|
|
1007
|
+
}
|
|
1008
|
+
showEmailCodeOption() {
|
|
1009
|
+
return this.preparedFactor !== 'email_code' && this.offered().includes('email_code');
|
|
1010
|
+
}
|
|
933
1011
|
showSubmit() {
|
|
934
1012
|
const kind = this.step().kind;
|
|
1013
|
+
// Nothing to submit on a first-factor step that only offers an emailed code
|
|
1014
|
+
// until that code is sent.
|
|
1015
|
+
if (kind === 'collect_first_factor')
|
|
1016
|
+
return this.fields().length > 0;
|
|
935
1017
|
return kind !== 'done' && kind !== 'await_oauth';
|
|
936
1018
|
}
|
|
937
1019
|
fields() {
|
|
@@ -939,8 +1021,17 @@ class AttemptFlowComponent {
|
|
|
939
1021
|
switch (step.kind) {
|
|
940
1022
|
case 'collect_identifier':
|
|
941
1023
|
return [{ kind: 'input', name: 'identifier', labelKey: 'signIn.identifierLabel', type: 'email' }];
|
|
942
|
-
case 'collect_first_factor':
|
|
943
|
-
|
|
1024
|
+
case 'collect_first_factor': {
|
|
1025
|
+
if (this.preparedFactor === 'email_code') {
|
|
1026
|
+
return [{ kind: 'input', name: 'code', labelKey: 'signIn.emailCodeLabel', type: 'text' }];
|
|
1027
|
+
}
|
|
1028
|
+
// The password field unless the only offer is an emailed code (chosen
|
|
1029
|
+
// with the button below the form).
|
|
1030
|
+
const offered = this.offered();
|
|
1031
|
+
return offered.includes('email_code') && !offered.includes('password')
|
|
1032
|
+
? []
|
|
1033
|
+
: [{ kind: 'input', name: 'password', labelKey: 'signIn.passwordLabel', type: 'password' }];
|
|
1034
|
+
}
|
|
944
1035
|
case 'collect_second_factor':
|
|
945
1036
|
return [{ kind: 'input', name: 'code', labelKey: 'mfa.codeLabel', type: 'text' }];
|
|
946
1037
|
case 'enroll_second_factor':
|
|
@@ -971,7 +1062,7 @@ class AttemptFlowComponent {
|
|
|
971
1062
|
}
|
|
972
1063
|
}
|
|
973
1064
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AttemptFlowComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
974
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: AttemptFlowComponent, isStandalone: true, selector: "atlas-attempt-flow", inputs: { attempt: "attempt", busy: "busy", errors: "errors", titleKey: "titleKey", captcha: "captcha" }, outputs: { submitFlow: "submitFlow" }, ngImport: i0, template: `
|
|
1065
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: AttemptFlowComponent, isStandalone: true, selector: "atlas-attempt-flow", inputs: { attempt: "attempt", busy: "busy", errors: "errors", titleKey: "titleKey", captcha: "captcha", firstFactors: "firstFactors", preparedFactor: "preparedFactor" }, outputs: { submitFlow: "submitFlow", chooseFactor: "chooseFactor" }, ngImport: i0, template: `
|
|
975
1066
|
<div [class]="cls('card', 'atlas-card')">
|
|
976
1067
|
<h1 [class]="cls('headerTitle', 'atlas-title')">{{ t(titleKey) }}</h1>
|
|
977
1068
|
|
|
@@ -1009,6 +1100,17 @@ class AttemptFlowComponent {
|
|
|
1009
1100
|
{{ t('signIn.submit') }}
|
|
1010
1101
|
</button>
|
|
1011
1102
|
}
|
|
1103
|
+
|
|
1104
|
+
@if (showEmailCodeOption()) {
|
|
1105
|
+
<button
|
|
1106
|
+
[class]="cls('formButtonSecondary', 'atlas-button-secondary')"
|
|
1107
|
+
[disabled]="busy"
|
|
1108
|
+
type="button"
|
|
1109
|
+
(click)="chooseFactor.emit('email_code')"
|
|
1110
|
+
>
|
|
1111
|
+
{{ t('signIn.useEmailCode') }}
|
|
1112
|
+
</button>
|
|
1113
|
+
}
|
|
1012
1114
|
</form>
|
|
1013
1115
|
</div>
|
|
1014
1116
|
`, isInline: true, dependencies: [{ kind: "directive", type: AtlasCaptchaSlotDirective, selector: "[atlasCaptchaSlot]", inputs: ["atlasCaptchaSlot"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
@@ -1058,6 +1160,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
1058
1160
|
{{ t('signIn.submit') }}
|
|
1059
1161
|
</button>
|
|
1060
1162
|
}
|
|
1163
|
+
|
|
1164
|
+
@if (showEmailCodeOption()) {
|
|
1165
|
+
<button
|
|
1166
|
+
[class]="cls('formButtonSecondary', 'atlas-button-secondary')"
|
|
1167
|
+
[disabled]="busy"
|
|
1168
|
+
type="button"
|
|
1169
|
+
(click)="chooseFactor.emit('email_code')"
|
|
1170
|
+
>
|
|
1171
|
+
{{ t('signIn.useEmailCode') }}
|
|
1172
|
+
</button>
|
|
1173
|
+
}
|
|
1061
1174
|
</form>
|
|
1062
1175
|
</div>
|
|
1063
1176
|
`,
|
|
@@ -1072,8 +1185,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
1072
1185
|
type: Input
|
|
1073
1186
|
}], captcha: [{
|
|
1074
1187
|
type: Input
|
|
1188
|
+
}], firstFactors: [{
|
|
1189
|
+
type: Input
|
|
1190
|
+
}], preparedFactor: [{
|
|
1191
|
+
type: Input
|
|
1075
1192
|
}], submitFlow: [{
|
|
1076
1193
|
type: Output
|
|
1194
|
+
}], chooseFactor: [{
|
|
1195
|
+
type: Output
|
|
1077
1196
|
}] } });
|
|
1078
1197
|
/** A read-only message row: `labelKey` holds the i18n key of the text to show. */
|
|
1079
1198
|
function msg(labelKey) {
|
|
@@ -1113,6 +1232,12 @@ class AtlasFlowController {
|
|
|
1113
1232
|
captchaDrawn = signal(false);
|
|
1114
1233
|
/** Whether {@link signUpCaptcha} currently draws a widget (for templates). */
|
|
1115
1234
|
signUpCaptchaDrawn = signal(false);
|
|
1235
|
+
/**
|
|
1236
|
+
* The first factor the server was asked to send ({@link prepare}) on the
|
|
1237
|
+
* current `needs_first_factor` step — the form then collects its code. Null
|
|
1238
|
+
* otherwise.
|
|
1239
|
+
*/
|
|
1240
|
+
preparedFactor = signal(null);
|
|
1116
1241
|
pollTimer = null;
|
|
1117
1242
|
identifier = '';
|
|
1118
1243
|
constructor(client, reload, pending,
|
|
@@ -1134,6 +1259,44 @@ class AtlasFlowController {
|
|
|
1134
1259
|
this.syncCaptcha();
|
|
1135
1260
|
}
|
|
1136
1261
|
}
|
|
1262
|
+
/**
|
|
1263
|
+
* The first factors to offer on a `needs_first_factor` step: the server's list
|
|
1264
|
+
* without email code / link while the app cannot deliver email
|
|
1265
|
+
* (`settings.emailDelivery.available === false`). Empty on any other step.
|
|
1266
|
+
*/
|
|
1267
|
+
firstFactors() {
|
|
1268
|
+
const attempt = this.state().attempt;
|
|
1269
|
+
const step = attempt ? nextStep(attempt) : null;
|
|
1270
|
+
return step?.kind === 'collect_first_factor'
|
|
1271
|
+
? offeredFirstFactors(step.strategies, this.settings()?.emailDelivery)
|
|
1272
|
+
: [];
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* True when email + password sign-up cannot complete because the app cannot
|
|
1276
|
+
* send the verification email (`settings.emailDelivery.passwordSignUp === false`):
|
|
1277
|
+
* `<atlas-sign-up>` shows the "can't send email" notice instead of the form.
|
|
1278
|
+
*/
|
|
1279
|
+
signUpEmailUnavailable() {
|
|
1280
|
+
return this.settings()?.emailDelivery.passwordSignUp === false;
|
|
1281
|
+
}
|
|
1282
|
+
/** False when a password reset email cannot be delivered — hide "Forgot password?". */
|
|
1283
|
+
passwordResetAvailable() {
|
|
1284
|
+
return this.settings()?.emailDelivery.passwordReset !== false;
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* Ask the server to email a sign-in code for the current `needs_first_factor`
|
|
1288
|
+
* step (`POST …/prepare_first_factor`); the form then collects the code, which
|
|
1289
|
+
* {@link submit} posts as the `email_code` strategy.
|
|
1290
|
+
*/
|
|
1291
|
+
async prepare(strategy) {
|
|
1292
|
+
if (!this.firstFactors().includes(strategy))
|
|
1293
|
+
return;
|
|
1294
|
+
this.state.update((current) => ({ ...current, busy: true, errors: [] }));
|
|
1295
|
+
const next = await prepareFactor(this.client, this.state(), strategy);
|
|
1296
|
+
this.setState(next);
|
|
1297
|
+
if (!next.errors.length)
|
|
1298
|
+
this.preparedFactor.set(strategy);
|
|
1299
|
+
}
|
|
1137
1300
|
/** Keep both captchas in step with the settings and the flow's current step. */
|
|
1138
1301
|
syncCaptcha() {
|
|
1139
1302
|
const settings = this.settings();
|
|
@@ -1149,6 +1312,9 @@ class AtlasFlowController {
|
|
|
1149
1312
|
}
|
|
1150
1313
|
setState(next) {
|
|
1151
1314
|
this.state.set(next);
|
|
1315
|
+
// A prepared factor belongs to the first-factor step it was sent for.
|
|
1316
|
+
if (next.attempt?.status !== 'needs_first_factor')
|
|
1317
|
+
this.preparedFactor.set(null);
|
|
1152
1318
|
this.syncCaptcha();
|
|
1153
1319
|
}
|
|
1154
1320
|
/**
|
|
@@ -1261,8 +1427,9 @@ class SignInComponent {
|
|
|
1261
1427
|
ngOnInit() {
|
|
1262
1428
|
// If seeded from a pending attempt that is already pollable, start polling.
|
|
1263
1429
|
this.flow.syncPolling();
|
|
1264
|
-
// §5 the instance's captcha settings
|
|
1265
|
-
// draws it here with no code change
|
|
1430
|
+
// §5 the instance's captcha settings (switching captcha on in the dashboard
|
|
1431
|
+
// draws it here with no code change) and email delivery (no emailed-code
|
|
1432
|
+
// option while the app cannot send email).
|
|
1266
1433
|
if (typeof window !== 'undefined')
|
|
1267
1434
|
void this.flow.loadSettings();
|
|
1268
1435
|
}
|
|
@@ -1277,9 +1444,12 @@ class SignInComponent {
|
|
|
1277
1444
|
[errors]="flow.state().errors"
|
|
1278
1445
|
titleKey="signIn.title"
|
|
1279
1446
|
[captcha]="flow.captchaDrawn() ? flow.captcha : null"
|
|
1447
|
+
[firstFactors]="flow.firstFactors()"
|
|
1448
|
+
[preparedFactor]="flow.preparedFactor()"
|
|
1280
1449
|
(submitFlow)="flow.submit($event)"
|
|
1450
|
+
(chooseFactor)="flow.prepare($event)"
|
|
1281
1451
|
></atlas-attempt-flow>
|
|
1282
|
-
`, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "titleKey", "captcha"], outputs: ["submitFlow"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1452
|
+
`, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "titleKey", "captcha", "firstFactors", "preparedFactor"], outputs: ["submitFlow", "chooseFactor"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1283
1453
|
}
|
|
1284
1454
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignInComponent, decorators: [{
|
|
1285
1455
|
type: Component,
|
|
@@ -1295,7 +1465,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
1295
1465
|
[errors]="flow.state().errors"
|
|
1296
1466
|
titleKey="signIn.title"
|
|
1297
1467
|
[captcha]="flow.captchaDrawn() ? flow.captcha : null"
|
|
1468
|
+
[firstFactors]="flow.firstFactors()"
|
|
1469
|
+
[preparedFactor]="flow.preparedFactor()"
|
|
1298
1470
|
(submitFlow)="flow.submit($event)"
|
|
1471
|
+
(chooseFactor)="flow.prepare($event)"
|
|
1299
1472
|
></atlas-attempt-flow>
|
|
1300
1473
|
`,
|
|
1301
1474
|
}]
|
|
@@ -1307,7 +1480,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
1307
1480
|
* The create-account step of `<atlas-sign-up>`: the instance's configured extra
|
|
1308
1481
|
* fields, email + password, the legal-consent row and — when the instance
|
|
1309
1482
|
* requires one on sign-up — the captcha widget. Values live on the component so
|
|
1310
|
-
* a refused submit never clears them.
|
|
1483
|
+
* a refused submit never clears them. When the app cannot send the verification
|
|
1484
|
+
* email (`settings.emailDelivery.passwordSignUp === false`) it shows the
|
|
1485
|
+
* `signUp.emailUnavailable` notice instead of the form.
|
|
1311
1486
|
*/
|
|
1312
1487
|
class SignUpFormComponent {
|
|
1313
1488
|
atlas = inject(AtlasService);
|
|
@@ -1328,6 +1503,10 @@ class SignUpFormComponent {
|
|
|
1328
1503
|
setValue(name, value) {
|
|
1329
1504
|
this.values = { ...this.values, [name]: value };
|
|
1330
1505
|
}
|
|
1506
|
+
/** Email + password sign-up cannot complete: the app cannot send the verification email. */
|
|
1507
|
+
emailUnavailable() {
|
|
1508
|
+
return this.settings?.emailDelivery?.passwordSignUp === false;
|
|
1509
|
+
}
|
|
1331
1510
|
fields() {
|
|
1332
1511
|
return this.settings?.signUpFields ?? [];
|
|
1333
1512
|
}
|
|
@@ -1366,7 +1545,7 @@ class SignUpFormComponent {
|
|
|
1366
1545
|
}
|
|
1367
1546
|
onSubmit(event) {
|
|
1368
1547
|
event.preventDefault();
|
|
1369
|
-
if (!this.consentOk())
|
|
1548
|
+
if (this.emailUnavailable() || !this.consentOk())
|
|
1370
1549
|
return;
|
|
1371
1550
|
this.submitSignUp.emit({
|
|
1372
1551
|
values: { ...this.values },
|
|
@@ -1378,6 +1557,13 @@ class SignUpFormComponent {
|
|
|
1378
1557
|
<div [class]="cls('card', 'atlas-card')">
|
|
1379
1558
|
<h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
|
|
1380
1559
|
|
|
1560
|
+
@if (emailUnavailable()) {
|
|
1561
|
+
<!-- The app cannot send the verification email, so an email + password
|
|
1562
|
+
sign-up could never complete: say so instead of offering the form. -->
|
|
1563
|
+
<p [class]="cls('formFieldError', 'atlas-error')" role="alert" data-atlas-email-unavailable="">
|
|
1564
|
+
{{ t('signUp.emailUnavailable') }}
|
|
1565
|
+
</p>
|
|
1566
|
+
} @else {
|
|
1381
1567
|
@for (error of formErrors(); track error.code) {
|
|
1382
1568
|
<p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ error.message }}</p>
|
|
1383
1569
|
}
|
|
@@ -1528,6 +1714,7 @@ class SignUpFormComponent {
|
|
|
1528
1714
|
{{ t('signUp.submit') }}
|
|
1529
1715
|
</button>
|
|
1530
1716
|
</form>
|
|
1717
|
+
}
|
|
1531
1718
|
</div>
|
|
1532
1719
|
`, isInline: true, dependencies: [{ kind: "directive", type: AtlasCaptchaSlotDirective, selector: "[atlasCaptchaSlot]", inputs: ["atlasCaptchaSlot"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1533
1720
|
}
|
|
@@ -1542,6 +1729,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
1542
1729
|
<div [class]="cls('card', 'atlas-card')">
|
|
1543
1730
|
<h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
|
|
1544
1731
|
|
|
1732
|
+
@if (emailUnavailable()) {
|
|
1733
|
+
<!-- The app cannot send the verification email, so an email + password
|
|
1734
|
+
sign-up could never complete: say so instead of offering the form. -->
|
|
1735
|
+
<p [class]="cls('formFieldError', 'atlas-error')" role="alert" data-atlas-email-unavailable="">
|
|
1736
|
+
{{ t('signUp.emailUnavailable') }}
|
|
1737
|
+
</p>
|
|
1738
|
+
} @else {
|
|
1545
1739
|
@for (error of formErrors(); track error.code) {
|
|
1546
1740
|
<p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ error.message }}</p>
|
|
1547
1741
|
}
|
|
@@ -1692,6 +1886,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
1692
1886
|
{{ t('signUp.submit') }}
|
|
1693
1887
|
</button>
|
|
1694
1888
|
</form>
|
|
1889
|
+
}
|
|
1695
1890
|
</div>
|
|
1696
1891
|
`,
|
|
1697
1892
|
}]
|
|
@@ -1751,7 +1946,7 @@ class SignUpComponent {
|
|
|
1751
1946
|
(submitSignUp)="flow.create($event.values, $event.consent)"
|
|
1752
1947
|
></atlas-sign-up-form>
|
|
1753
1948
|
}
|
|
1754
|
-
`, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "titleKey", "captcha"], outputs: ["submitFlow"] }, { kind: "component", type: SignUpFormComponent, selector: "atlas-sign-up-form", inputs: ["settings", "busy", "errors", "captcha"], outputs: ["submitSignUp"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1949
|
+
`, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "titleKey", "captcha", "firstFactors", "preparedFactor"], outputs: ["submitFlow", "chooseFactor"] }, { kind: "component", type: SignUpFormComponent, selector: "atlas-sign-up-form", inputs: ["settings", "busy", "errors", "captcha"], outputs: ["submitSignUp"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1755
1950
|
}
|
|
1756
1951
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpComponent, decorators: [{
|
|
1757
1952
|
type: Component,
|