@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.
@@ -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, 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
- /** 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([]);
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
- 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([]);
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 result = readRedirectResult(window.location.search);
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 (result) {
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
- return [{ kind: 'input', name: 'password', labelKey: 'signIn.passwordLabel', type: 'password' }];
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: switching captcha on in the dashboard
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,