@atlasauth/angular 0.3.0 → 0.5.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.
@@ -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, getProviderToken, runFrontChannelLogoutFromResponse, readRedirectResult, stripRedirectParams, readExpiry, msUntilRefresh, nextStep, createCaptchaController, flowFromPending, initialFlow, fetchInstanceSettings, signInCaptchaOptions, flowNeedsCaptcha, advanceWithCaptcha, createSignUp, shouldKeepPolling, pollAttempt } from '@atlasauth/js';
8
- export { FapiClient, advance, flowFromPending, getProviderToken, initialFlow, pollAttempt, prepareFactor, requestForStep, shouldKeepPolling } from '@atlasauth/js';
7
+ import { TokenCache, resolveFrontendApi, catalogLocale, FapiClient, SignOutGuard, localizedErrorMessage, 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.",
@@ -217,6 +220,46 @@ const EN_US = {
217
220
  'organization.roleLabel': 'Role',
218
221
  'error.generic': 'Something went wrong. Please try again.',
219
222
  'error.network': 'We could not reach the server. Check your connection.',
223
+ // Server errors, by the stable `reason` the Frontend API sends. Shown instead
224
+ // of the server's `message` (see `localizedErrorMessage` in @atlasauth/js).
225
+ 'error.form_password_incorrect': 'Those credentials are incorrect.',
226
+ 'error.form_code_incorrect': 'That code is incorrect.',
227
+ 'error.form_code_already_used': 'That code has already been used. Wait for a new one.',
228
+ 'error.form_code_expired': 'That code has expired. Request a new one.',
229
+ 'error.form_code_max_attempts': 'Too many incorrect codes. Request a new code.',
230
+ 'error.form_link_invalid': 'That sign-in link is invalid or has already been used.',
231
+ 'error.form_identifier_not_found': "We couldn't find an account with those details.",
232
+ 'error.form_identifier_exists': "There's already an account for this email. Sign in with {methods}.",
233
+ 'error.form_password_pwned': 'This password has appeared in a known data breach. Please choose a different one.',
234
+ 'error.form_password_too_short': 'Password must be at least {min_length} characters.',
235
+ 'error.form_password_too_long': 'Password must be at most {max_length} characters.',
236
+ 'error.form_password_requires_uppercase': 'Password must contain an uppercase letter.',
237
+ 'error.form_password_requires_lowercase': 'Password must contain a lowercase letter.',
238
+ 'error.form_password_requires_number': 'Password must contain a number.',
239
+ 'error.form_password_requires_symbol': 'Password must contain a symbol.',
240
+ 'error.form_password_not_allowed': 'That password is not allowed. Choose a different one.',
241
+ 'error.form_password_contains_identifier': 'Password must not contain your email or username.',
242
+ 'error.form_password_same_as_current': 'Your new password must be different from your current one.',
243
+ 'error.form_password_reused': 'Your new password must not match any of your last {history_count} passwords.',
244
+ 'error.password_expired': 'Your password has expired. Reset it to continue.',
245
+ 'error.email_not_verified': 'Your email address is not verified yet. Verify it before signing in with your password.',
246
+ 'error.sign_in_token_invalid': 'That sign-in token is invalid or has expired.',
247
+ 'error.too_many_attempts': 'Too many failed attempts. Try again later.',
248
+ 'error.too_many_requests': 'Too many requests. Please try again later.',
249
+ 'error.captcha_required': 'Please complete the security check, then try again.',
250
+ 'error.captcha_failed': "The security check didn't pass. Please try again.",
251
+ 'error.captcha_unavailable': 'The security check is unavailable right now. Please try again later.',
252
+ 'error.email_delivery_unavailable': "This app can't send email right now. Try another sign-in method or contact the app owner.",
253
+ 'error.strategy_disabled': 'This sign-in method is not available for this app.',
254
+ 'error.sign_up_restricted': 'Sign-up on this app is by invitation only.',
255
+ 'error.waitlisted': "You're on the waitlist. We'll email you when a place opens up.",
256
+ 'error.identifier_not_allowed': 'This email or phone number is not allowed on this app.',
257
+ 'error.disposable_email_blocked': 'Sign-up with a disposable or temporary email address is not allowed.',
258
+ 'error.sso_required': 'This organization requires signing in through single sign-on.',
259
+ 'error.sign_ins_disabled': 'Sign-ins are temporarily disabled.',
260
+ 'error.sign_ups_disabled': 'Sign-ups are temporarily disabled.',
261
+ 'error.high_risk_blocked': 'This sign-in was blocked because it looked unusual.',
262
+ 'error.sms_region_blocked': 'Text messages are not available for this phone number.',
220
263
  };
221
264
  /**
222
265
  * Look up a key and interpolate `{placeholders}`.
@@ -262,7 +305,19 @@ class AtlasService {
262
305
  cache = new TokenCache();
263
306
  fapiClient;
264
307
  refreshTimer = null;
308
+ /**
309
+ * The sessions this browser signed out of (tombstones in localStorage, per
310
+ * Frontend API origin), the revoke and its background re-tries. Every reload /
311
+ * refresh asks it before adopting a session, so a sign-out the server never
312
+ * confirmed is not undone by the still-valid refresh cookie.
313
+ */
314
+ guard;
315
+ stopWatchingSignOut = null;
265
316
  catalog;
317
+ /** The language layer of `catalog` (the `localization` bundle alone; `EN_US` when none). */
318
+ errorCatalog;
319
+ /** Sent to the Frontend API as `Accept-Language`: the `localization` bundle's `locale` tag. */
320
+ requestLocale;
266
321
  /**
267
322
  * A sign-in attempt handed back by an OAuth redirect that still needs a second
268
323
  * factor (no session was issued). Null in the ordinary case; the `<atlas-sign-in>`
@@ -318,10 +373,17 @@ class AtlasService {
318
373
  });
319
374
  this.doFetch = options.fetchImpl ?? ((...args) => fetch(...args));
320
375
  this.catalog = options.localization ? withFallback(options.localization) : EN_US;
376
+ this.errorCatalog = options.localization ?? EN_US;
377
+ this.requestLocale = catalogLocale(options.localization);
321
378
  this.fapiClient = new FapiClient({
322
379
  publishableKey: this.publishableKey,
323
380
  baseUrl: this.baseUrl,
324
381
  fetchImpl: this.doFetch,
382
+ locale: this.requestLocale,
383
+ });
384
+ this.guard = new SignOutGuard({
385
+ request: (path, init) => this.call(path, init),
386
+ frontendApi: this.baseUrl,
325
387
  });
326
388
  // Captured synchronously (not in an async init) so it is available before any
327
389
  // flow component reads it: a needs_second_factor redirect carries a status but
@@ -330,10 +392,19 @@ class AtlasService {
330
392
  // Boot once, browser only. On the server there is no cookie jar to read a
331
393
  // session from, so we leave the state at `loading` and let hydration boot it.
332
394
  if (this.isBrowser) {
395
+ // Another tab signed out of THIS session (its tombstone reached the shared
396
+ // store): drop the token and re-check, which then refuses the session.
397
+ this.stopWatchingSignOut = this.guard.watch(() => this.cache.peek()?.sessionId ?? this._session.value?.id, () => {
398
+ this.cache.clear();
399
+ void this.reload();
400
+ });
333
401
  void this.boot();
334
402
  }
335
403
  }
336
404
  ngOnDestroy() {
405
+ this.stopWatchingSignOut?.();
406
+ this.stopWatchingSignOut = null;
407
+ this.guard.dispose();
337
408
  this.clearRefreshTimer();
338
409
  this._status.complete();
339
410
  this._user.complete();
@@ -351,6 +422,22 @@ class AtlasService {
351
422
  get frontendApi() {
352
423
  return this.baseUrl;
353
424
  }
425
+ /** The `Accept-Language` sent to the Frontend API: the `localization` bundle's `locale` tag, if any. */
426
+ get locale() {
427
+ return this.requestLocale;
428
+ }
429
+ /**
430
+ * The text to show for a server error, in the app's language: the catalog's
431
+ * `error.<reason>` (looked up in the `localization` bundle, or `EN_US` when
432
+ * none is set), else the server's message, else `error.generic`.
433
+ */
434
+ errorText(error) {
435
+ return localizedErrorMessage(error, this.errorCatalog, this.catalog);
436
+ }
437
+ /** The catalog server errors are looked up in (see {@link errorText}). */
438
+ get errorLocalization() {
439
+ return this.errorCatalog;
440
+ }
354
441
  /** Tokens as CSS custom properties, for a host element to inherit. */
355
442
  appearanceVariables() {
356
443
  return cssVariables(resolveTokens(this.options.appearance));
@@ -384,7 +471,11 @@ class AtlasService {
384
471
  }
385
472
  /** A fresh {@link FapiClient} for driving a sign-in/sign-up flow. */
386
473
  createFapiClient() {
387
- return new FapiClient({ publishableKey: this.publishableKey, baseUrl: this.baseUrl });
474
+ return new FapiClient({
475
+ publishableKey: this.publishableKey,
476
+ baseUrl: this.baseUrl,
477
+ locale: this.requestLocale,
478
+ });
388
479
  }
389
480
  // --- Imperative API (mirrors the React hooks' actions) ----------------------
390
481
  /**
@@ -412,25 +503,27 @@ class AtlasService {
412
503
  has(condition) {
413
504
  return evaluate(this._claims.value, condition).allowed;
414
505
  }
415
- /** Revoke the current session server-side and drop all local auth state. */
416
- async signOut(options) {
417
- const current = this.cache.peek();
418
- if (current) {
419
- const revoked = await this.call(`/v1/client/sessions/${current.sessionId}/revoke`, {
420
- method: 'POST',
421
- }).catch(() => undefined);
422
- // OIDC Front-Channel Logout: let the apps signed in through this session
423
- // clear theirs in this browser (hidden iframes, capped at 5 s). Best-effort.
424
- if (options?.frontchannel !== false)
425
- await runFrontChannelLogoutFromResponse(revoked);
426
- }
427
- this.clearRefreshTimer();
428
- this.cache.clear();
429
- this._status.next('signed_out');
430
- this._user.next(null);
431
- this._session.next(null);
432
- this._claims.next(null);
433
- this._memberships.next([]);
506
+ /**
507
+ * Revoke the current session server-side and drop all local auth state.
508
+ *
509
+ * The session id is remembered before the revoke (a tombstone shared by every
510
+ * tab), so a reload, a token refresh, a sibling tab or a tab opened later never
511
+ * re-adopts it — even when the revoke failed (it is retried once, then again in
512
+ * the background) and the refresh cookie is still valid. Resolves
513
+ * `{ revoked, activeSessionId? }`; with `{ strict: true }` an unconfirmed revoke
514
+ * rejects with a `SignOutError` instead (after the local sign-out). A parked
515
+ * session the server promoted on revoke is adopted. Front-channel logout runs
516
+ * unless `{ frontchannel: false }`.
517
+ */
518
+ async signOut(options = {}) {
519
+ // With no cached token the guard finds the cookie session itself
520
+ // (GET /v1/client), so a missing JWT never skips the revoke.
521
+ const sessionId = this.cache.peek()?.sessionId ?? this._session.value?.id ?? null;
522
+ const attempt = await this.guard.revoke(sessionId, options);
523
+ this.clearLocalState();
524
+ if (this.guard.promoted(attempt))
525
+ await this.reload();
526
+ return this.guard.settle(attempt, options);
434
527
  }
435
528
  /**
436
529
  * Switch the active organization (or `null` for the personal account). The
@@ -458,14 +551,10 @@ class AtlasService {
458
551
  if (!response.ok)
459
552
  throw new Error('boot failed');
460
553
  const body = (await response.json());
461
- if (!body.session || !body.user) {
462
- this.clearRefreshTimer();
463
- this.cache.clear();
464
- this._status.next('signed_out');
465
- this._user.next(null);
466
- this._session.next(null);
467
- this._claims.next(null);
468
- this._memberships.next([]);
554
+ // A session this browser signed out of is never re-adopted from the
555
+ // refresh cookie; the guard re-tries its revoke in the background.
556
+ if (!body.session || !body.user || this.guard.refuse(body.session.id)) {
557
+ this.clearLocalState();
469
558
  return;
470
559
  }
471
560
  this._user.next(body.user);
@@ -493,17 +582,41 @@ class AtlasService {
493
582
  * has no ticket, so nothing is exchanged and the app boots signed-out.
494
583
  */
495
584
  async boot() {
496
- const result = readRedirectResult(window.location.search);
585
+ const fromUrl = readRedirectResult(window.location.search);
586
+ let result = fromUrl;
587
+ // §oauth popup mode: this page may be the landing of a sign-in popup whose
588
+ // opener a Cross-Origin-Opener-Policy severed (`#atlas_popup_result=…`).
589
+ // The result is relayed to the opener, which redeems the ticket, and this
590
+ // window closes. If no opener takes it (its tab is gone), finish here like
591
+ // a redirect.
592
+ const landing = await completeOAuthPopupLanding().catch(() => null);
593
+ if (landing && !landing.delivered) {
594
+ const outcome = mapOAuthPopupPayload(landing.payload);
595
+ if (outcome?.ticket && outcome.attemptId) {
596
+ result = { attemptId: outcome.attemptId, ticket: outcome.ticket };
597
+ }
598
+ else if (outcome?.continuationUrl) {
599
+ // A second factor / connect step / provider error: the redirect flow's page.
600
+ window.location.replace(outcome.continuationUrl);
601
+ return;
602
+ }
603
+ }
604
+ let exchanged = false;
497
605
  if (result?.ticket) {
498
- await this.call('/v1/client/tickets/exchange', {
606
+ const response = await this.call('/v1/client/tickets/exchange', {
499
607
  method: 'POST',
500
608
  body: JSON.stringify({ attempt_id: result.attemptId, ticket: result.ticket }),
501
609
  }).catch(() => undefined);
610
+ exchanged = !!response?.ok;
502
611
  }
503
- if (result) {
612
+ if (fromUrl) {
504
613
  window.history.replaceState({}, document.title, stripRedirectParams(window.location.href));
505
614
  }
506
615
  await this.reload();
616
+ // Signed in inside the Atlas popup (the redirect flow ran in it): hand back
617
+ // to the waiting opener and close. A no-op in an ordinary tab.
618
+ if (exchanged)
619
+ void closeOAuthPopupAfterSignIn();
507
620
  }
508
621
  readPendingAttempt() {
509
622
  const result = readRedirectResult(window.location.search);
@@ -525,6 +638,9 @@ class AtlasService {
525
638
  });
526
639
  }
527
640
  applyToken(jwt, sessionId) {
641
+ // An in-flight refresh must not resurrect a session signed out meanwhile.
642
+ if (this.guard.isSignedOut(sessionId))
643
+ return;
528
644
  const expiresAt = readExpiry(jwt);
529
645
  if (!expiresAt)
530
646
  return;
@@ -550,6 +666,15 @@ class AtlasService {
550
666
  await this.reload();
551
667
  }
552
668
  }
669
+ clearLocalState() {
670
+ this.clearRefreshTimer();
671
+ this.cache.clear();
672
+ this._status.next('signed_out');
673
+ this._user.next(null);
674
+ this._session.next(null);
675
+ this._claims.next(null);
676
+ this._memberships.next([]);
677
+ }
553
678
  /** §7.4: proactive, jittered refresh scheduled from the token's own expiry. */
554
679
  scheduleRefresh() {
555
680
  this.clearRefreshTimer();
@@ -901,12 +1026,28 @@ class AttemptFlowComponent {
901
1026
  * with `needs_captcha`. Null when the flow draws none.
902
1027
  */
903
1028
  captcha = null;
1029
+ /**
1030
+ * The first factors to offer on the `needs_first_factor` step — pass
1031
+ * `AtlasFlowController.firstFactors()`, which drops email code / link while the
1032
+ * app cannot deliver email. Null uses the server's list as-is.
1033
+ */
1034
+ firstFactors = null;
1035
+ /** The first factor already sent (`AtlasFlowController.preparedFactor()`): its code is collected. */
1036
+ preparedFactor = null;
904
1037
  /** Emits the collected field values when the form is submitted. */
905
1038
  submitFlow = new EventEmitter();
1039
+ /** Emits when the user picks a first factor the server must send first (an emailed code). */
1040
+ chooseFactor = new EventEmitter();
906
1041
  values = {};
907
1042
  t(key, vars) {
908
1043
  return translate(this.atlas.localization, key, vars);
909
1044
  }
1045
+ /** A server error in the app's language (`error.<reason>`, else the server's message). */
1046
+ errorText(error) {
1047
+ // `errorLocalization` is read defensively: a stand-in service may lack it.
1048
+ const layer = this.atlas.errorLocalization;
1049
+ return localizedErrorMessage(error, layer ?? this.atlas.localization, this.atlas.localization);
1050
+ }
910
1051
  cls(key, base) {
911
1052
  return classFor(key, this.atlas.appearance, base);
912
1053
  }
@@ -930,8 +1071,22 @@ class AttemptFlowComponent {
930
1071
  const kind = this.step().kind;
931
1072
  return !!this.captcha && (kind === 'collect_identifier' || kind === 'collect_captcha');
932
1073
  }
1074
+ /** The first factors on offer at a `needs_first_factor` step (empty on any other). */
1075
+ offered() {
1076
+ const step = this.step();
1077
+ if (step.kind !== 'collect_first_factor')
1078
+ return [];
1079
+ return this.firstFactors ?? step.strategies;
1080
+ }
1081
+ showEmailCodeOption() {
1082
+ return this.preparedFactor !== 'email_code' && this.offered().includes('email_code');
1083
+ }
933
1084
  showSubmit() {
934
1085
  const kind = this.step().kind;
1086
+ // Nothing to submit on a first-factor step that only offers an emailed code
1087
+ // until that code is sent.
1088
+ if (kind === 'collect_first_factor')
1089
+ return this.fields().length > 0;
935
1090
  return kind !== 'done' && kind !== 'await_oauth';
936
1091
  }
937
1092
  fields() {
@@ -939,8 +1094,17 @@ class AttemptFlowComponent {
939
1094
  switch (step.kind) {
940
1095
  case 'collect_identifier':
941
1096
  return [{ kind: 'input', name: 'identifier', labelKey: 'signIn.identifierLabel', type: 'email' }];
942
- case 'collect_first_factor':
943
- return [{ kind: 'input', name: 'password', labelKey: 'signIn.passwordLabel', type: 'password' }];
1097
+ case 'collect_first_factor': {
1098
+ if (this.preparedFactor === 'email_code') {
1099
+ return [{ kind: 'input', name: 'code', labelKey: 'signIn.emailCodeLabel', type: 'text' }];
1100
+ }
1101
+ // The password field unless the only offer is an emailed code (chosen
1102
+ // with the button below the form).
1103
+ const offered = this.offered();
1104
+ return offered.includes('email_code') && !offered.includes('password')
1105
+ ? []
1106
+ : [{ kind: 'input', name: 'password', labelKey: 'signIn.passwordLabel', type: 'password' }];
1107
+ }
944
1108
  case 'collect_second_factor':
945
1109
  return [{ kind: 'input', name: 'code', labelKey: 'mfa.codeLabel', type: 'text' }];
946
1110
  case 'enroll_second_factor':
@@ -971,12 +1135,12 @@ class AttemptFlowComponent {
971
1135
  }
972
1136
  }
973
1137
  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: `
1138
+ 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
1139
  <div [class]="cls('card', 'atlas-card')">
976
1140
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t(titleKey) }}</h1>
977
1141
 
978
1142
  @for (error of formErrors(); track error.code) {
979
- <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ error.message }}</p>
1143
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ errorText(error) }}</p>
980
1144
  }
981
1145
 
982
1146
  <form (submit)="onSubmit($event)">
@@ -992,7 +1156,7 @@ class AttemptFlowComponent {
992
1156
  (input)="setValue(f.name, $any($event.target).value)"
993
1157
  />
994
1158
  @for (error of fieldErrorsFor(f.name); track error.code) {
995
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1159
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
996
1160
  }
997
1161
  </label>
998
1162
  } @else {
@@ -1009,6 +1173,17 @@ class AttemptFlowComponent {
1009
1173
  {{ t('signIn.submit') }}
1010
1174
  </button>
1011
1175
  }
1176
+
1177
+ @if (showEmailCodeOption()) {
1178
+ <button
1179
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1180
+ [disabled]="busy"
1181
+ type="button"
1182
+ (click)="chooseFactor.emit('email_code')"
1183
+ >
1184
+ {{ t('signIn.useEmailCode') }}
1185
+ </button>
1186
+ }
1012
1187
  </form>
1013
1188
  </div>
1014
1189
  `, isInline: true, dependencies: [{ kind: "directive", type: AtlasCaptchaSlotDirective, selector: "[atlasCaptchaSlot]", inputs: ["atlasCaptchaSlot"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
@@ -1025,7 +1200,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1025
1200
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t(titleKey) }}</h1>
1026
1201
 
1027
1202
  @for (error of formErrors(); track error.code) {
1028
- <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ error.message }}</p>
1203
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ errorText(error) }}</p>
1029
1204
  }
1030
1205
 
1031
1206
  <form (submit)="onSubmit($event)">
@@ -1041,7 +1216,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1041
1216
  (input)="setValue(f.name, $any($event.target).value)"
1042
1217
  />
1043
1218
  @for (error of fieldErrorsFor(f.name); track error.code) {
1044
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1219
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1045
1220
  }
1046
1221
  </label>
1047
1222
  } @else {
@@ -1058,6 +1233,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1058
1233
  {{ t('signIn.submit') }}
1059
1234
  </button>
1060
1235
  }
1236
+
1237
+ @if (showEmailCodeOption()) {
1238
+ <button
1239
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1240
+ [disabled]="busy"
1241
+ type="button"
1242
+ (click)="chooseFactor.emit('email_code')"
1243
+ >
1244
+ {{ t('signIn.useEmailCode') }}
1245
+ </button>
1246
+ }
1061
1247
  </form>
1062
1248
  </div>
1063
1249
  `,
@@ -1072,8 +1258,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1072
1258
  type: Input
1073
1259
  }], captcha: [{
1074
1260
  type: Input
1261
+ }], firstFactors: [{
1262
+ type: Input
1263
+ }], preparedFactor: [{
1264
+ type: Input
1075
1265
  }], submitFlow: [{
1076
1266
  type: Output
1267
+ }], chooseFactor: [{
1268
+ type: Output
1077
1269
  }] } });
1078
1270
  /** A read-only message row: `labelKey` holds the i18n key of the text to show. */
1079
1271
  function msg(labelKey) {
@@ -1113,6 +1305,12 @@ class AtlasFlowController {
1113
1305
  captchaDrawn = signal(false);
1114
1306
  /** Whether {@link signUpCaptcha} currently draws a widget (for templates). */
1115
1307
  signUpCaptchaDrawn = signal(false);
1308
+ /**
1309
+ * The first factor the server was asked to send ({@link prepare}) on the
1310
+ * current `needs_first_factor` step — the form then collects its code. Null
1311
+ * otherwise.
1312
+ */
1313
+ preparedFactor = signal(null);
1116
1314
  pollTimer = null;
1117
1315
  identifier = '';
1118
1316
  constructor(client, reload, pending,
@@ -1134,6 +1332,44 @@ class AtlasFlowController {
1134
1332
  this.syncCaptcha();
1135
1333
  }
1136
1334
  }
1335
+ /**
1336
+ * The first factors to offer on a `needs_first_factor` step: the server's list
1337
+ * without email code / link while the app cannot deliver email
1338
+ * (`settings.emailDelivery.available === false`). Empty on any other step.
1339
+ */
1340
+ firstFactors() {
1341
+ const attempt = this.state().attempt;
1342
+ const step = attempt ? nextStep(attempt) : null;
1343
+ return step?.kind === 'collect_first_factor'
1344
+ ? offeredFirstFactors(step.strategies, this.settings()?.emailDelivery)
1345
+ : [];
1346
+ }
1347
+ /**
1348
+ * True when email + password sign-up cannot complete because the app cannot
1349
+ * send the verification email (`settings.emailDelivery.passwordSignUp === false`):
1350
+ * `<atlas-sign-up>` shows the "can't send email" notice instead of the form.
1351
+ */
1352
+ signUpEmailUnavailable() {
1353
+ return this.settings()?.emailDelivery.passwordSignUp === false;
1354
+ }
1355
+ /** False when a password reset email cannot be delivered — hide "Forgot password?". */
1356
+ passwordResetAvailable() {
1357
+ return this.settings()?.emailDelivery.passwordReset !== false;
1358
+ }
1359
+ /**
1360
+ * Ask the server to email a sign-in code for the current `needs_first_factor`
1361
+ * step (`POST …/prepare_first_factor`); the form then collects the code, which
1362
+ * {@link submit} posts as the `email_code` strategy.
1363
+ */
1364
+ async prepare(strategy) {
1365
+ if (!this.firstFactors().includes(strategy))
1366
+ return;
1367
+ this.state.update((current) => ({ ...current, busy: true, errors: [] }));
1368
+ const next = await prepareFactor(this.client, this.state(), strategy);
1369
+ this.setState(next);
1370
+ if (!next.errors.length)
1371
+ this.preparedFactor.set(strategy);
1372
+ }
1137
1373
  /** Keep both captchas in step with the settings and the flow's current step. */
1138
1374
  syncCaptcha() {
1139
1375
  const settings = this.settings();
@@ -1149,6 +1385,9 @@ class AtlasFlowController {
1149
1385
  }
1150
1386
  setState(next) {
1151
1387
  this.state.set(next);
1388
+ // A prepared factor belongs to the first-factor step it was sent for.
1389
+ if (next.attempt?.status !== 'needs_first_factor')
1390
+ this.preparedFactor.set(null);
1152
1391
  this.syncCaptcha();
1153
1392
  }
1154
1393
  /**
@@ -1261,8 +1500,9 @@ class SignInComponent {
1261
1500
  ngOnInit() {
1262
1501
  // If seeded from a pending attempt that is already pollable, start polling.
1263
1502
  this.flow.syncPolling();
1264
- // §5 the instance's captcha settings: switching captcha on in the dashboard
1265
- // draws it here with no code change.
1503
+ // §5 the instance's captcha settings (switching captcha on in the dashboard
1504
+ // draws it here with no code change) and email delivery (no emailed-code
1505
+ // option while the app cannot send email).
1266
1506
  if (typeof window !== 'undefined')
1267
1507
  void this.flow.loadSettings();
1268
1508
  }
@@ -1277,9 +1517,12 @@ class SignInComponent {
1277
1517
  [errors]="flow.state().errors"
1278
1518
  titleKey="signIn.title"
1279
1519
  [captcha]="flow.captchaDrawn() ? flow.captcha : null"
1520
+ [firstFactors]="flow.firstFactors()"
1521
+ [preparedFactor]="flow.preparedFactor()"
1280
1522
  (submitFlow)="flow.submit($event)"
1523
+ (chooseFactor)="flow.prepare($event)"
1281
1524
  ></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 });
1525
+ `, 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
1526
  }
1284
1527
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignInComponent, decorators: [{
1285
1528
  type: Component,
@@ -1295,7 +1538,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1295
1538
  [errors]="flow.state().errors"
1296
1539
  titleKey="signIn.title"
1297
1540
  [captcha]="flow.captchaDrawn() ? flow.captcha : null"
1541
+ [firstFactors]="flow.firstFactors()"
1542
+ [preparedFactor]="flow.preparedFactor()"
1298
1543
  (submitFlow)="flow.submit($event)"
1544
+ (chooseFactor)="flow.prepare($event)"
1299
1545
  ></atlas-attempt-flow>
1300
1546
  `,
1301
1547
  }]
@@ -1307,7 +1553,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1307
1553
  * The create-account step of `<atlas-sign-up>`: the instance's configured extra
1308
1554
  * fields, email + password, the legal-consent row and — when the instance
1309
1555
  * requires one on sign-up — the captcha widget. Values live on the component so
1310
- * a refused submit never clears them.
1556
+ * a refused submit never clears them. When the app cannot send the verification
1557
+ * email (`settings.emailDelivery.passwordSignUp === false`) it shows the
1558
+ * `signUp.emailUnavailable` notice instead of the form.
1311
1559
  */
1312
1560
  class SignUpFormComponent {
1313
1561
  atlas = inject(AtlasService);
@@ -1322,12 +1570,22 @@ class SignUpFormComponent {
1322
1570
  t(key, vars) {
1323
1571
  return translate(this.atlas.localization, key, vars);
1324
1572
  }
1573
+ /** A server error in the app's language (`error.<reason>`, else the server's message). */
1574
+ errorText(error) {
1575
+ // `errorLocalization` is read defensively: a stand-in service may lack it.
1576
+ const layer = this.atlas.errorLocalization;
1577
+ return localizedErrorMessage(error, layer ?? this.atlas.localization, this.atlas.localization);
1578
+ }
1325
1579
  cls(key, base) {
1326
1580
  return classFor(key, this.atlas.appearance, base);
1327
1581
  }
1328
1582
  setValue(name, value) {
1329
1583
  this.values = { ...this.values, [name]: value };
1330
1584
  }
1585
+ /** Email + password sign-up cannot complete: the app cannot send the verification email. */
1586
+ emailUnavailable() {
1587
+ return this.settings?.emailDelivery?.passwordSignUp === false;
1588
+ }
1331
1589
  fields() {
1332
1590
  return this.settings?.signUpFields ?? [];
1333
1591
  }
@@ -1366,7 +1624,7 @@ class SignUpFormComponent {
1366
1624
  }
1367
1625
  onSubmit(event) {
1368
1626
  event.preventDefault();
1369
- if (!this.consentOk())
1627
+ if (this.emailUnavailable() || !this.consentOk())
1370
1628
  return;
1371
1629
  this.submitSignUp.emit({
1372
1630
  values: { ...this.values },
@@ -1378,8 +1636,15 @@ class SignUpFormComponent {
1378
1636
  <div [class]="cls('card', 'atlas-card')">
1379
1637
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
1380
1638
 
1639
+ @if (emailUnavailable()) {
1640
+ <!-- The app cannot send the verification email, so an email + password
1641
+ sign-up could never complete: say so instead of offering the form. -->
1642
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert" data-atlas-email-unavailable="">
1643
+ {{ t('signUp.emailUnavailable') }}
1644
+ </p>
1645
+ } @else {
1381
1646
  @for (error of formErrors(); track error.code) {
1382
- <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ error.message }}</p>
1647
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ errorText(error) }}</p>
1383
1648
  }
1384
1649
 
1385
1650
  <form (submit)="onSubmit($event)">
@@ -1402,7 +1667,7 @@ class SignUpFormComponent {
1402
1667
  }
1403
1668
  </span>
1404
1669
  @for (error of fieldErrorsFor(f.key); track error.code) {
1405
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1670
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1406
1671
  }
1407
1672
  </div>
1408
1673
  } @else {
@@ -1450,7 +1715,7 @@ class SignUpFormComponent {
1450
1715
  />
1451
1716
  }
1452
1717
  @for (error of fieldErrorsFor(f.key); track error.code) {
1453
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1718
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1454
1719
  }
1455
1720
  </label>
1456
1721
  }
@@ -1468,7 +1733,7 @@ class SignUpFormComponent {
1468
1733
  (input)="setValue('email', $any($event.target).value)"
1469
1734
  />
1470
1735
  @for (error of fieldErrorsFor('email'); track error.code) {
1471
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1736
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1472
1737
  }
1473
1738
  </label>
1474
1739
 
@@ -1484,7 +1749,7 @@ class SignUpFormComponent {
1484
1749
  (input)="setValue('password', $any($event.target).value)"
1485
1750
  />
1486
1751
  @for (error of fieldErrorsFor('password'); track error.code) {
1487
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1752
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1488
1753
  }
1489
1754
  </label>
1490
1755
 
@@ -1511,7 +1776,7 @@ class SignUpFormComponent {
1511
1776
  }.
1512
1777
  </span>
1513
1778
  @for (error of fieldErrorsFor('consent'); track error.code) {
1514
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1779
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1515
1780
  }
1516
1781
  </label>
1517
1782
  }
@@ -1528,6 +1793,7 @@ class SignUpFormComponent {
1528
1793
  {{ t('signUp.submit') }}
1529
1794
  </button>
1530
1795
  </form>
1796
+ }
1531
1797
  </div>
1532
1798
  `, isInline: true, dependencies: [{ kind: "directive", type: AtlasCaptchaSlotDirective, selector: "[atlasCaptchaSlot]", inputs: ["atlasCaptchaSlot"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1533
1799
  }
@@ -1542,8 +1808,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1542
1808
  <div [class]="cls('card', 'atlas-card')">
1543
1809
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
1544
1810
 
1811
+ @if (emailUnavailable()) {
1812
+ <!-- The app cannot send the verification email, so an email + password
1813
+ sign-up could never complete: say so instead of offering the form. -->
1814
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert" data-atlas-email-unavailable="">
1815
+ {{ t('signUp.emailUnavailable') }}
1816
+ </p>
1817
+ } @else {
1545
1818
  @for (error of formErrors(); track error.code) {
1546
- <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ error.message }}</p>
1819
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ errorText(error) }}</p>
1547
1820
  }
1548
1821
 
1549
1822
  <form (submit)="onSubmit($event)">
@@ -1566,7 +1839,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1566
1839
  }
1567
1840
  </span>
1568
1841
  @for (error of fieldErrorsFor(f.key); track error.code) {
1569
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1842
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1570
1843
  }
1571
1844
  </div>
1572
1845
  } @else {
@@ -1614,7 +1887,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1614
1887
  />
1615
1888
  }
1616
1889
  @for (error of fieldErrorsFor(f.key); track error.code) {
1617
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1890
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1618
1891
  }
1619
1892
  </label>
1620
1893
  }
@@ -1632,7 +1905,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1632
1905
  (input)="setValue('email', $any($event.target).value)"
1633
1906
  />
1634
1907
  @for (error of fieldErrorsFor('email'); track error.code) {
1635
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1908
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1636
1909
  }
1637
1910
  </label>
1638
1911
 
@@ -1648,7 +1921,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1648
1921
  (input)="setValue('password', $any($event.target).value)"
1649
1922
  />
1650
1923
  @for (error of fieldErrorsFor('password'); track error.code) {
1651
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1924
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1652
1925
  }
1653
1926
  </label>
1654
1927
 
@@ -1675,7 +1948,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1675
1948
  }.
1676
1949
  </span>
1677
1950
  @for (error of fieldErrorsFor('consent'); track error.code) {
1678
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1951
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1679
1952
  }
1680
1953
  </label>
1681
1954
  }
@@ -1692,6 +1965,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1692
1965
  {{ t('signUp.submit') }}
1693
1966
  </button>
1694
1967
  </form>
1968
+ }
1695
1969
  </div>
1696
1970
  `,
1697
1971
  }]
@@ -1751,7 +2025,7 @@ class SignUpComponent {
1751
2025
  (submitSignUp)="flow.create($event.values, $event.consent)"
1752
2026
  ></atlas-sign-up-form>
1753
2027
  }
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 });
2028
+ `, 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
2029
  }
1756
2030
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpComponent, decorators: [{
1757
2031
  type: Component,