@atlasauth/angular 0.2.1 → 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, FapiClient, getProviderToken, runFrontChannelLogoutFromResponse, readRedirectResult, stripRedirectParams, readExpiry, msUntilRefresh, nextStep, flowFromPending, initialFlow, advance, 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,12 +167,27 @@ 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',
173
174
  'signUp.submit': 'Continue',
174
175
  'signUp.haveAccount': 'Already have an account?',
175
176
  'signUp.signInLink': 'Sign in',
177
+ 'signUp.emailLabel': 'Email address',
178
+ 'signUp.passwordLabel': 'Password',
179
+ 'signUp.optionalField': '{label} (optional)',
180
+ 'signUp.selectPlaceholder': 'Select…',
181
+ 'signUp.consentAgree': 'I agree to the',
182
+ 'signUp.consentNotice': 'By continuing you agree to the',
183
+ 'signUp.terms': 'Terms of Service',
184
+ 'signUp.privacy': 'Privacy Policy',
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.",
188
+ 'captcha.prompt': 'Complete the security check to continue.',
189
+ 'captcha.required': 'Please complete the security check, then try again.',
190
+ 'captcha.failed': "The security check didn't pass. Please try again.",
176
191
  'mfa.title': 'Two-step verification',
177
192
  'mfa.subtitle': 'Enter the code from your authenticator app.',
178
193
  'mfa.codeLabel': 'Code',
@@ -250,6 +265,14 @@ class AtlasService {
250
265
  cache = new TokenCache();
251
266
  fapiClient;
252
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;
253
276
  catalog;
254
277
  /**
255
278
  * A sign-in attempt handed back by an OAuth redirect that still needs a second
@@ -299,7 +322,11 @@ class AtlasService {
299
322
  constructor() {
300
323
  const options = this.options;
301
324
  this.publishableKey = options.publishableKey;
302
- this.baseUrl = options.frontendApi ?? '';
325
+ // Throws an `AtlasConfigError` on a missing/invalid value rather than quietly
326
+ // sending every request to a relative path on the app's origin.
327
+ this.baseUrl = resolveFrontendApi(options.frontendApi, {
328
+ component: 'provideAtlas() / AtlasModule.forRoot()',
329
+ });
303
330
  this.doFetch = options.fetchImpl ?? ((...args) => fetch(...args));
304
331
  this.catalog = options.localization ? withFallback(options.localization) : EN_US;
305
332
  this.fapiClient = new FapiClient({
@@ -307,6 +334,10 @@ class AtlasService {
307
334
  baseUrl: this.baseUrl,
308
335
  fetchImpl: this.doFetch,
309
336
  });
337
+ this.guard = new SignOutGuard({
338
+ request: (path, init) => this.call(path, init),
339
+ frontendApi: this.baseUrl,
340
+ });
310
341
  // Captured synchronously (not in an async init) so it is available before any
311
342
  // flow component reads it: a needs_second_factor redirect carries a status but
312
343
  // no ticket, and that is what the 2FA resume seeds from.
@@ -314,10 +345,19 @@ class AtlasService {
314
345
  // Boot once, browser only. On the server there is no cookie jar to read a
315
346
  // session from, so we leave the state at `loading` and let hydration boot it.
316
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
+ });
317
354
  void this.boot();
318
355
  }
319
356
  }
320
357
  ngOnDestroy() {
358
+ this.stopWatchingSignOut?.();
359
+ this.stopWatchingSignOut = null;
360
+ this.guard.dispose();
321
361
  this.clearRefreshTimer();
322
362
  this._status.complete();
323
363
  this._user.complete();
@@ -396,25 +436,27 @@ class AtlasService {
396
436
  has(condition) {
397
437
  return evaluate(this._claims.value, condition).allowed;
398
438
  }
399
- /** Revoke the current session server-side and drop all local auth state. */
400
- async signOut(options) {
401
- const current = this.cache.peek();
402
- if (current) {
403
- const revoked = await this.call(`/v1/client/sessions/${current.sessionId}/revoke`, {
404
- method: 'POST',
405
- }).catch(() => undefined);
406
- // OIDC Front-Channel Logout: let the apps signed in through this session
407
- // clear theirs in this browser (hidden iframes, capped at 5 s). Best-effort.
408
- if (options?.frontchannel !== false)
409
- await runFrontChannelLogoutFromResponse(revoked);
410
- }
411
- this.clearRefreshTimer();
412
- this.cache.clear();
413
- this._status.next('signed_out');
414
- this._user.next(null);
415
- this._session.next(null);
416
- this._claims.next(null);
417
- 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);
418
460
  }
419
461
  /**
420
462
  * Switch the active organization (or `null` for the personal account). The
@@ -442,14 +484,10 @@ class AtlasService {
442
484
  if (!response.ok)
443
485
  throw new Error('boot failed');
444
486
  const body = (await response.json());
445
- if (!body.session || !body.user) {
446
- this.clearRefreshTimer();
447
- this.cache.clear();
448
- this._status.next('signed_out');
449
- this._user.next(null);
450
- this._session.next(null);
451
- this._claims.next(null);
452
- 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();
453
491
  return;
454
492
  }
455
493
  this._user.next(body.user);
@@ -477,17 +515,41 @@ class AtlasService {
477
515
  * has no ticket, so nothing is exchanged and the app boots signed-out.
478
516
  */
479
517
  async boot() {
480
- 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;
481
538
  if (result?.ticket) {
482
- await this.call('/v1/client/tickets/exchange', {
539
+ const response = await this.call('/v1/client/tickets/exchange', {
483
540
  method: 'POST',
484
541
  body: JSON.stringify({ attempt_id: result.attemptId, ticket: result.ticket }),
485
542
  }).catch(() => undefined);
543
+ exchanged = !!response?.ok;
486
544
  }
487
- if (result) {
545
+ if (fromUrl) {
488
546
  window.history.replaceState({}, document.title, stripRedirectParams(window.location.href));
489
547
  }
490
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();
491
553
  }
492
554
  readPendingAttempt() {
493
555
  const result = readRedirectResult(window.location.search);
@@ -509,6 +571,9 @@ class AtlasService {
509
571
  });
510
572
  }
511
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;
512
577
  const expiresAt = readExpiry(jwt);
513
578
  if (!expiresAt)
514
579
  return;
@@ -534,6 +599,15 @@ class AtlasService {
534
599
  await this.reload();
535
600
  }
536
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
+ }
537
611
  /** §7.4: proactive, jittered refresh scheduled from the token's own expiry. */
538
612
  scheduleRefresh() {
539
613
  this.clearRefreshTimer();
@@ -823,6 +897,46 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
823
897
  }]
824
898
  }] });
825
899
 
900
+ /**
901
+ * `[atlasCaptchaSlot]="controller"` — the element a flow's captcha widget draws
902
+ * into (see `createCaptchaController` in `@atlasauth/js`). Attaches the
903
+ * controller to the host element while it is rendered and detaches it (removing
904
+ * the widget) when it goes away.
905
+ */
906
+ class AtlasCaptchaSlotDirective {
907
+ host = inject(ElementRef);
908
+ current = null;
909
+ initialized = false;
910
+ set controller(next) {
911
+ if (next === this.current)
912
+ return;
913
+ if (this.initialized)
914
+ this.current?.attach(null);
915
+ this.current = next;
916
+ if (this.initialized)
917
+ this.current?.attach(this.host.nativeElement);
918
+ }
919
+ ngOnInit() {
920
+ this.initialized = true;
921
+ this.current?.attach(this.host.nativeElement);
922
+ }
923
+ ngOnDestroy() {
924
+ this.current?.attach(null);
925
+ }
926
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasCaptchaSlotDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
927
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "17.3.12", type: AtlasCaptchaSlotDirective, isStandalone: true, selector: "[atlasCaptchaSlot]", inputs: { controller: ["atlasCaptchaSlot", "controller"] }, ngImport: i0 });
928
+ }
929
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasCaptchaSlotDirective, decorators: [{
930
+ type: Directive,
931
+ args: [{
932
+ selector: '[atlasCaptchaSlot]',
933
+ standalone: true,
934
+ }]
935
+ }], propDecorators: { controller: [{
936
+ type: Input,
937
+ args: ['atlasCaptchaSlot']
938
+ }] } });
939
+
826
940
  /**
827
941
  * The server-driven flow shell, shared by `<atlas-sign-in>` and
828
942
  * `<atlas-sign-up>`. The Angular peer of React's internal `AttemptFlow`.
@@ -839,8 +953,24 @@ class AttemptFlowComponent {
839
953
  busy = false;
840
954
  errors = [];
841
955
  titleKey = 'signIn.title';
956
+ /**
957
+ * The sign-in captcha, drawn above the submit button on the identifier step
958
+ * (an always-on sign-in captcha) and on the challenge step the server demands
959
+ * with `needs_captcha`. Null when the flow draws none.
960
+ */
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;
842
970
  /** Emits the collected field values when the form is submitted. */
843
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();
844
974
  values = {};
845
975
  t(key, vars) {
846
976
  return translate(this.atlas.localization, key, vars);
@@ -864,8 +994,26 @@ class AttemptFlowComponent {
864
994
  step() {
865
995
  return this.attempt ? nextStep(this.attempt) : { kind: 'collect_identifier' };
866
996
  }
997
+ showCaptcha() {
998
+ const kind = this.step().kind;
999
+ return !!this.captcha && (kind === 'collect_identifier' || kind === 'collect_captcha');
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
+ }
867
1011
  showSubmit() {
868
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;
869
1017
  return kind !== 'done' && kind !== 'await_oauth';
870
1018
  }
871
1019
  fields() {
@@ -873,8 +1021,17 @@ class AttemptFlowComponent {
873
1021
  switch (step.kind) {
874
1022
  case 'collect_identifier':
875
1023
  return [{ kind: 'input', name: 'identifier', labelKey: 'signIn.identifierLabel', type: 'email' }];
876
- case 'collect_first_factor':
877
- 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
+ }
878
1035
  case 'collect_second_factor':
879
1036
  return [{ kind: 'input', name: 'code', labelKey: 'mfa.codeLabel', type: 'text' }];
880
1037
  case 'enroll_second_factor':
@@ -888,6 +1045,10 @@ class AttemptFlowComponent {
888
1045
  return [{ kind: 'input', name: 'code', labelKey: 'signIn.emailCodeLabel', type: 'text' }];
889
1046
  case 'collect_new_password':
890
1047
  return [{ kind: 'input', name: 'password', labelKey: 'reset.newPasswordLabel', type: 'password' }];
1048
+ case 'collect_captcha':
1049
+ // §5 the server withheld the attempt until a captcha clears. Solving the
1050
+ // widget resubmits the identifier on its own; the button retries.
1051
+ return [msg('captcha.prompt')];
891
1052
  case 'await_oauth':
892
1053
  return [msg('signIn.magicLinkSent')];
893
1054
  case 'done':
@@ -901,7 +1062,7 @@ class AttemptFlowComponent {
901
1062
  }
902
1063
  }
903
1064
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AttemptFlowComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
904
- 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" }, 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: `
905
1066
  <div [class]="cls('card', 'atlas-card')">
906
1067
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t(titleKey) }}</h1>
907
1068
 
@@ -917,6 +1078,7 @@ class AttemptFlowComponent {
917
1078
  <input
918
1079
  [class]="cls('formFieldInput', 'atlas-input')"
919
1080
  [type]="f.type"
1081
+ [attr.name]="f.name"
920
1082
  [value]="values[f.name] ?? ''"
921
1083
  (input)="setValue(f.name, $any($event.target).value)"
922
1084
  />
@@ -929,14 +1091,29 @@ class AttemptFlowComponent {
929
1091
  }
930
1092
  }
931
1093
 
1094
+ @if (showCaptcha()) {
1095
+ <div class="atlas-captcha" data-atlas-captcha="" [atlasCaptchaSlot]="captcha"></div>
1096
+ }
1097
+
932
1098
  @if (showSubmit()) {
933
1099
  <button [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy" type="submit">
934
1100
  {{ t('signIn.submit') }}
935
1101
  </button>
936
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
+ }
937
1114
  </form>
938
1115
  </div>
939
- `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1116
+ `, isInline: true, dependencies: [{ kind: "directive", type: AtlasCaptchaSlotDirective, selector: "[atlasCaptchaSlot]", inputs: ["atlasCaptchaSlot"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
940
1117
  }
941
1118
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AttemptFlowComponent, decorators: [{
942
1119
  type: Component,
@@ -944,6 +1121,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
944
1121
  selector: 'atlas-attempt-flow',
945
1122
  standalone: true,
946
1123
  changeDetection: ChangeDetectionStrategy.OnPush,
1124
+ imports: [AtlasCaptchaSlotDirective],
947
1125
  template: `
948
1126
  <div [class]="cls('card', 'atlas-card')">
949
1127
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t(titleKey) }}</h1>
@@ -960,6 +1138,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
960
1138
  <input
961
1139
  [class]="cls('formFieldInput', 'atlas-input')"
962
1140
  [type]="f.type"
1141
+ [attr.name]="f.name"
963
1142
  [value]="values[f.name] ?? ''"
964
1143
  (input)="setValue(f.name, $any($event.target).value)"
965
1144
  />
@@ -972,11 +1151,26 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
972
1151
  }
973
1152
  }
974
1153
 
1154
+ @if (showCaptcha()) {
1155
+ <div class="atlas-captcha" data-atlas-captcha="" [atlasCaptchaSlot]="captcha"></div>
1156
+ }
1157
+
975
1158
  @if (showSubmit()) {
976
1159
  <button [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy" type="submit">
977
1160
  {{ t('signIn.submit') }}
978
1161
  </button>
979
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
+ }
980
1174
  </form>
981
1175
  </div>
982
1176
  `,
@@ -989,8 +1183,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
989
1183
  type: Input
990
1184
  }], titleKey: [{
991
1185
  type: Input
1186
+ }], captcha: [{
1187
+ type: Input
1188
+ }], firstFactors: [{
1189
+ type: Input
1190
+ }], preparedFactor: [{
1191
+ type: Input
992
1192
  }], submitFlow: [{
993
1193
  type: Output
1194
+ }], chooseFactor: [{
1195
+ type: Output
994
1196
  }] } });
995
1197
  /** A read-only message row: `labelKey` holds the i18n key of the text to show. */
996
1198
  function msg(labelKey) {
@@ -1005,18 +1207,115 @@ function msg(labelKey) {
1005
1207
  * server via `advance`, and this controller only posts whatever the current step
1006
1208
  * needs and re-renders the returned status. State lives in a signal so templates
1007
1209
  * update whether the app runs zoned or zoneless.
1210
+ *
1211
+ * `<atlas-sign-up>` starts the attempt with {@link create}
1212
+ * (`POST /v1/client/sign_ups`); the emailed-code step then runs through the same
1213
+ * {@link submit}. Both flows draw the instance's captcha (from
1214
+ * {@link loadSettings}) when it is required.
1008
1215
  */
1009
1216
  class AtlasFlowController {
1010
1217
  client;
1011
1218
  reload;
1219
+ t;
1012
1220
  state;
1221
+ /**
1222
+ * The instance's public sign-in / sign-up settings (captcha, sign-up fields,
1223
+ * legal consent) from `GET /v1/appearance`; null until {@link loadSettings}
1224
+ * resolves, or when the server could not be reached.
1225
+ */
1226
+ settings = signal(null);
1227
+ /** The sign-in captcha (identifier step / `needs_captcha` challenge). */
1228
+ captcha = createCaptchaController();
1229
+ /** The sign-up captcha (the create-account step). */
1230
+ signUpCaptcha = createCaptchaController();
1231
+ /** Whether {@link captcha} currently draws a widget (for templates). */
1232
+ captchaDrawn = signal(false);
1233
+ /** Whether {@link signUpCaptcha} currently draws a widget (for templates). */
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);
1013
1241
  pollTimer = null;
1014
- constructor(client, reload, pending) {
1242
+ identifier = '';
1243
+ constructor(client, reload, pending,
1244
+ /** Resolves the captcha messages' catalog keys. Defaults to en-US. */
1245
+ t = (key) => translate(EN_US, key)) {
1015
1246
  this.client = client;
1016
1247
  this.reload = reload;
1248
+ this.t = t;
1017
1249
  // Seeded from a redirect that came back mid-flow (e.g. OAuth needing a second
1018
1250
  // factor) so the user resumes at the demanded step instead of starting over.
1019
1251
  this.state = signal(pending ? flowFromPending(pending) : initialFlow);
1252
+ this.syncCaptcha();
1253
+ }
1254
+ /** Fetch the instance's public settings and draw whatever captcha they require. */
1255
+ async loadSettings() {
1256
+ const loaded = await fetchInstanceSettings(this.client);
1257
+ if (loaded) {
1258
+ this.settings.set(loaded);
1259
+ this.syncCaptcha();
1260
+ }
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
+ }
1300
+ /** Keep both captchas in step with the settings and the flow's current step. */
1301
+ syncCaptcha() {
1302
+ const settings = this.settings();
1303
+ const state = this.state();
1304
+ this.captcha.configure(signInCaptchaOptions(settings?.captchaFlows.signIn, state, () => void this.submit({})));
1305
+ const signUpFlow = settings?.captchaFlows.signUp;
1306
+ this.signUpCaptcha.configure({
1307
+ flow: signUpFlow,
1308
+ active: !state.attempt && flowNeedsCaptcha(signUpFlow),
1309
+ });
1310
+ this.captchaDrawn.set(this.captcha.needed);
1311
+ this.signUpCaptchaDrawn.set(this.signUpCaptcha.needed);
1312
+ }
1313
+ setState(next) {
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);
1318
+ this.syncCaptcha();
1020
1319
  }
1021
1320
  /**
1022
1321
  * Advance the flow with the values the user typed. On completion, exchange the
@@ -1026,8 +1325,31 @@ class AtlasFlowController {
1026
1325
  */
1027
1326
  async submit(values) {
1028
1327
  this.state.update((current) => ({ ...current, busy: true, errors: [] }));
1029
- const next = await advance(this.client, this.state(), values);
1030
- this.state.set(next);
1328
+ const out = await advanceWithCaptcha(this.client, this.state(), values, {
1329
+ captcha: this.captcha,
1330
+ identifier: this.identifier,
1331
+ t: this.t,
1332
+ });
1333
+ this.identifier = out.identifier;
1334
+ await this.finish(out.state);
1335
+ }
1336
+ /**
1337
+ * Create an account (`POST /v1/client/sign_ups`) from a create-account form's
1338
+ * values — email, password and any configured sign-up fields — carrying the
1339
+ * sign-up captcha's token when the instance requires one. The emailed code is
1340
+ * then posted with {@link submit}.
1341
+ */
1342
+ async create(values, consent = true) {
1343
+ this.state.update((current) => ({ ...current, busy: true, errors: [] }));
1344
+ const next = await createSignUp(this.client, values, {
1345
+ consent,
1346
+ captcha: this.signUpCaptcha,
1347
+ t: this.t,
1348
+ });
1349
+ await this.finish(next);
1350
+ }
1351
+ async finish(next) {
1352
+ this.setState(next);
1031
1353
  if (next.attempt?.status === 'complete') {
1032
1354
  if (next.ticket) {
1033
1355
  await this.client.post('/v1/client/tickets/exchange', {
@@ -1057,7 +1379,7 @@ class AtlasFlowController {
1057
1379
  this.pollTimer = setInterval(() => {
1058
1380
  void (async () => {
1059
1381
  const { state: next, ticket } = await pollAttempt(this.client, this.state());
1060
- this.state.set(next);
1382
+ this.setState(next);
1061
1383
  if (!ticket || !next.attempt)
1062
1384
  return;
1063
1385
  // The ticket is exchanged by a normal request, so no session token ever
@@ -1077,15 +1399,19 @@ class AtlasFlowController {
1077
1399
  this.pollTimer = null;
1078
1400
  }
1079
1401
  }
1080
- /** Release the poll timer — call from the component's `ngOnDestroy`. */
1402
+ /** Release the poll timer and the captcha widgets — call from the component's `ngOnDestroy`. */
1081
1403
  destroy() {
1082
1404
  this.stopPolling();
1405
+ this.captcha.destroy();
1406
+ this.signUpCaptcha.destroy();
1083
1407
  }
1084
1408
  }
1085
1409
 
1086
1410
  /**
1087
1411
  * `<atlas-sign-in>` — the full, multi-step sign-in flow driven entirely by the
1088
- * server-side attempt status. The Angular peer of React's `<SignIn/>`.
1412
+ * server-side attempt status. The Angular peer of React's `<SignIn/>`. Draws the
1413
+ * instance's sign-in captcha when it is required (always-on, or a
1414
+ * `needs_captcha` challenge).
1089
1415
  *
1090
1416
  * ```html
1091
1417
  * <atlas-sign-in *atlasSignedOut afterUrl="/dashboard"></atlas-sign-in>
@@ -1097,10 +1423,15 @@ class SignInComponent {
1097
1423
  afterUrl;
1098
1424
  flow = new AtlasFlowController(this.atlas.createFapiClient(), () => this.atlas.reload(),
1099
1425
  // Resume mid-flow if an OAuth redirect came back owing a second factor.
1100
- this.atlas.pendingAttempt);
1426
+ this.atlas.pendingAttempt, (key) => translate(this.atlas.localization, key));
1101
1427
  ngOnInit() {
1102
1428
  // If seeded from a pending attempt that is already pollable, start polling.
1103
1429
  this.flow.syncPolling();
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).
1433
+ if (typeof window !== 'undefined')
1434
+ void this.flow.loadSettings();
1104
1435
  }
1105
1436
  ngOnDestroy() {
1106
1437
  this.flow.destroy();
@@ -1112,9 +1443,13 @@ class SignInComponent {
1112
1443
  [busy]="flow.state().busy"
1113
1444
  [errors]="flow.state().errors"
1114
1445
  titleKey="signIn.title"
1446
+ [captcha]="flow.captchaDrawn() ? flow.captcha : null"
1447
+ [firstFactors]="flow.firstFactors()"
1448
+ [preparedFactor]="flow.preparedFactor()"
1115
1449
  (submitFlow)="flow.submit($event)"
1450
+ (chooseFactor)="flow.prepare($event)"
1116
1451
  ></atlas-attempt-flow>
1117
- `, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "titleKey"], 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 });
1118
1453
  }
1119
1454
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignInComponent, decorators: [{
1120
1455
  type: Component,
@@ -1129,7 +1464,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1129
1464
  [busy]="flow.state().busy"
1130
1465
  [errors]="flow.state().errors"
1131
1466
  titleKey="signIn.title"
1467
+ [captcha]="flow.captchaDrawn() ? flow.captcha : null"
1468
+ [firstFactors]="flow.firstFactors()"
1469
+ [preparedFactor]="flow.preparedFactor()"
1132
1470
  (submitFlow)="flow.submit($event)"
1471
+ (chooseFactor)="flow.prepare($event)"
1133
1472
  ></atlas-attempt-flow>
1134
1473
  `,
1135
1474
  }]
@@ -1138,9 +1477,438 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1138
1477
  }] } });
1139
1478
 
1140
1479
  /**
1141
- * `<atlas-sign-up>` — the full, server-driven sign-up flow. The Angular peer of
1142
- * React's `<SignUp/>`. It shares the exact flow engine with `<atlas-sign-in>`;
1143
- * only the title differs, because the sequence of steps is decided by the server.
1480
+ * The create-account step of `<atlas-sign-up>`: the instance's configured extra
1481
+ * fields, email + password, the legal-consent row and — when the instance
1482
+ * requires one on sign-up — the captcha widget. Values live on the component so
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.
1486
+ */
1487
+ class SignUpFormComponent {
1488
+ atlas = inject(AtlasService);
1489
+ settings = null;
1490
+ busy = false;
1491
+ errors = [];
1492
+ captcha = null;
1493
+ /** Emits the typed values and the consent tick when the form is submitted. */
1494
+ submitSignUp = new EventEmitter();
1495
+ values = {};
1496
+ agreed = false;
1497
+ t(key, vars) {
1498
+ return translate(this.atlas.localization, key, vars);
1499
+ }
1500
+ cls(key, base) {
1501
+ return classFor(key, this.atlas.appearance, base);
1502
+ }
1503
+ setValue(name, value) {
1504
+ this.values = { ...this.values, [name]: value };
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
+ }
1510
+ fields() {
1511
+ return this.settings?.signUpFields ?? [];
1512
+ }
1513
+ label(f) {
1514
+ return f.required ? f.label : this.t('signUp.optionalField', { label: f.label });
1515
+ }
1516
+ inputType(f) {
1517
+ return ['email', 'tel', 'url', 'number', 'date'].includes(f.type) ? f.type : 'text';
1518
+ }
1519
+ /** Same encoding as React, the embed widget and the hosted page: one value,
1520
+ * or a comma-joined list for multiselect. */
1521
+ picked(key) {
1522
+ const value = this.values[key];
1523
+ return value ? value.split(', ') : [];
1524
+ }
1525
+ toggle(f, option, on) {
1526
+ if (f.type === 'radio') {
1527
+ this.setValue(f.key, option);
1528
+ return;
1529
+ }
1530
+ const current = this.picked(f.key);
1531
+ this.setValue(f.key, (on ? [...current, option] : current.filter((p) => p !== option)).join(', '));
1532
+ }
1533
+ showLegal() {
1534
+ const legal = this.settings?.legal;
1535
+ return !!legal && (!!legal.termsUrl || !!legal.privacyUrl);
1536
+ }
1537
+ consentOk() {
1538
+ return !this.settings?.legal?.required || this.agreed;
1539
+ }
1540
+ formErrors() {
1541
+ return this.errors.filter((error) => !error.param);
1542
+ }
1543
+ fieldErrorsFor(name) {
1544
+ return this.errors.filter((error) => error.param === name);
1545
+ }
1546
+ onSubmit(event) {
1547
+ event.preventDefault();
1548
+ if (this.emailUnavailable() || !this.consentOk())
1549
+ return;
1550
+ this.submitSignUp.emit({
1551
+ values: { ...this.values },
1552
+ consent: this.settings?.legal?.required ? this.agreed : true,
1553
+ });
1554
+ }
1555
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1556
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: SignUpFormComponent, isStandalone: true, selector: "atlas-sign-up-form", inputs: { settings: "settings", busy: "busy", errors: "errors", captcha: "captcha" }, outputs: { submitSignUp: "submitSignUp" }, ngImport: i0, template: `
1557
+ <div [class]="cls('card', 'atlas-card')">
1558
+ <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
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 {
1567
+ @for (error of formErrors(); track error.code) {
1568
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ error.message }}</p>
1569
+ }
1570
+
1571
+ <form (submit)="onSubmit($event)">
1572
+ @for (f of fields(); track f.key) {
1573
+ @if (f.type === 'radio' || f.type === 'multiselect') {
1574
+ <div [class]="cls('formField', 'atlas-field')">
1575
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ label(f) }}</span>
1576
+ <span class="atlas-choices">
1577
+ @for (o of f.options; track o) {
1578
+ <label class="atlas-choice">
1579
+ <input
1580
+ [type]="f.type === 'radio' ? 'radio' : 'checkbox'"
1581
+ [attr.name]="f.key"
1582
+ [value]="o"
1583
+ [checked]="picked(f.key).includes(o)"
1584
+ (change)="toggle(f, o, $any($event.target).checked)"
1585
+ />
1586
+ {{ o }}
1587
+ </label>
1588
+ }
1589
+ </span>
1590
+ @for (error of fieldErrorsFor(f.key); track error.code) {
1591
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1592
+ }
1593
+ </div>
1594
+ } @else {
1595
+ <label [class]="cls('formField', 'atlas-field')">
1596
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ label(f) }}</span>
1597
+ @if (f.type === 'checkbox') {
1598
+ <input
1599
+ type="checkbox"
1600
+ [attr.name]="f.key"
1601
+ [checked]="values[f.key] === 'true'"
1602
+ (change)="setValue(f.key, $any($event.target).checked ? 'true' : '')"
1603
+ />
1604
+ } @else if (f.type === 'select') {
1605
+ <select
1606
+ [class]="cls('formFieldInput', 'atlas-input')"
1607
+ [attr.name]="f.key"
1608
+ [value]="values[f.key] ?? ''"
1609
+ [required]="f.required"
1610
+ (change)="setValue(f.key, $any($event.target).value)"
1611
+ >
1612
+ <option value="">{{ f.placeholder ?? t('signUp.selectPlaceholder') }}</option>
1613
+ @for (o of f.options; track o) {
1614
+ <option [value]="o">{{ o }}</option>
1615
+ }
1616
+ </select>
1617
+ } @else if (f.type === 'textarea') {
1618
+ <textarea
1619
+ [class]="cls('formFieldInput', 'atlas-input')"
1620
+ [attr.name]="f.key"
1621
+ rows="3"
1622
+ [value]="values[f.key] ?? ''"
1623
+ [required]="f.required"
1624
+ [attr.placeholder]="f.placeholder"
1625
+ (input)="setValue(f.key, $any($event.target).value)"
1626
+ ></textarea>
1627
+ } @else {
1628
+ <input
1629
+ [class]="cls('formFieldInput', 'atlas-input')"
1630
+ [type]="inputType(f)"
1631
+ [attr.name]="f.key"
1632
+ [value]="values[f.key] ?? ''"
1633
+ [required]="f.required"
1634
+ [attr.placeholder]="f.placeholder"
1635
+ (input)="setValue(f.key, $any($event.target).value)"
1636
+ />
1637
+ }
1638
+ @for (error of fieldErrorsFor(f.key); track error.code) {
1639
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1640
+ }
1641
+ </label>
1642
+ }
1643
+ }
1644
+
1645
+ <label [class]="cls('formField', 'atlas-field')">
1646
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('signUp.emailLabel') }}</span>
1647
+ <input
1648
+ [class]="cls('formFieldInput', 'atlas-input')"
1649
+ type="email"
1650
+ name="email"
1651
+ autocomplete="email"
1652
+ required
1653
+ [value]="values['email'] ?? ''"
1654
+ (input)="setValue('email', $any($event.target).value)"
1655
+ />
1656
+ @for (error of fieldErrorsFor('email'); track error.code) {
1657
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1658
+ }
1659
+ </label>
1660
+
1661
+ <label [class]="cls('formField', 'atlas-field')">
1662
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('signUp.passwordLabel') }}</span>
1663
+ <input
1664
+ [class]="cls('formFieldInput', 'atlas-input')"
1665
+ type="password"
1666
+ name="password"
1667
+ autocomplete="new-password"
1668
+ required
1669
+ [value]="values['password'] ?? ''"
1670
+ (input)="setValue('password', $any($event.target).value)"
1671
+ />
1672
+ @for (error of fieldErrorsFor('password'); track error.code) {
1673
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1674
+ }
1675
+ </label>
1676
+
1677
+ @if (showLegal()) {
1678
+ <label [class]="cls('formField', 'atlas-consent')">
1679
+ @if (settings?.legal?.required) {
1680
+ <input
1681
+ type="checkbox"
1682
+ name="consent"
1683
+ [checked]="agreed"
1684
+ (change)="agreed = $any($event.target).checked"
1685
+ />
1686
+ }
1687
+ <span class="atlas-text">
1688
+ {{ t(settings?.legal?.required ? 'signUp.consentAgree' : 'signUp.consentNotice') }}
1689
+ @if (settings?.legal?.termsUrl; as terms) {
1690
+ <a class="atlas-link" [href]="terms" target="_blank" rel="noopener noreferrer">{{ t('signUp.terms') }}</a>
1691
+ }
1692
+ @if (settings?.legal?.termsUrl && settings?.legal?.privacyUrl) {
1693
+ {{ t('signUp.and') }}
1694
+ }
1695
+ @if (settings?.legal?.privacyUrl; as privacy) {
1696
+ <a class="atlas-link" [href]="privacy" target="_blank" rel="noopener noreferrer">{{ t('signUp.privacy') }}</a>
1697
+ }.
1698
+ </span>
1699
+ @for (error of fieldErrorsFor('consent'); track error.code) {
1700
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1701
+ }
1702
+ </label>
1703
+ }
1704
+
1705
+ @if (captcha) {
1706
+ <div class="atlas-captcha" data-atlas-captcha="" [atlasCaptchaSlot]="captcha"></div>
1707
+ }
1708
+
1709
+ <button
1710
+ [class]="cls('formButtonPrimary', 'atlas-button')"
1711
+ [disabled]="busy || !consentOk()"
1712
+ type="submit"
1713
+ >
1714
+ {{ t('signUp.submit') }}
1715
+ </button>
1716
+ </form>
1717
+ }
1718
+ </div>
1719
+ `, isInline: true, dependencies: [{ kind: "directive", type: AtlasCaptchaSlotDirective, selector: "[atlasCaptchaSlot]", inputs: ["atlasCaptchaSlot"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1720
+ }
1721
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpFormComponent, decorators: [{
1722
+ type: Component,
1723
+ args: [{
1724
+ selector: 'atlas-sign-up-form',
1725
+ standalone: true,
1726
+ changeDetection: ChangeDetectionStrategy.OnPush,
1727
+ imports: [AtlasCaptchaSlotDirective],
1728
+ template: `
1729
+ <div [class]="cls('card', 'atlas-card')">
1730
+ <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
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 {
1739
+ @for (error of formErrors(); track error.code) {
1740
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ error.message }}</p>
1741
+ }
1742
+
1743
+ <form (submit)="onSubmit($event)">
1744
+ @for (f of fields(); track f.key) {
1745
+ @if (f.type === 'radio' || f.type === 'multiselect') {
1746
+ <div [class]="cls('formField', 'atlas-field')">
1747
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ label(f) }}</span>
1748
+ <span class="atlas-choices">
1749
+ @for (o of f.options; track o) {
1750
+ <label class="atlas-choice">
1751
+ <input
1752
+ [type]="f.type === 'radio' ? 'radio' : 'checkbox'"
1753
+ [attr.name]="f.key"
1754
+ [value]="o"
1755
+ [checked]="picked(f.key).includes(o)"
1756
+ (change)="toggle(f, o, $any($event.target).checked)"
1757
+ />
1758
+ {{ o }}
1759
+ </label>
1760
+ }
1761
+ </span>
1762
+ @for (error of fieldErrorsFor(f.key); track error.code) {
1763
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1764
+ }
1765
+ </div>
1766
+ } @else {
1767
+ <label [class]="cls('formField', 'atlas-field')">
1768
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ label(f) }}</span>
1769
+ @if (f.type === 'checkbox') {
1770
+ <input
1771
+ type="checkbox"
1772
+ [attr.name]="f.key"
1773
+ [checked]="values[f.key] === 'true'"
1774
+ (change)="setValue(f.key, $any($event.target).checked ? 'true' : '')"
1775
+ />
1776
+ } @else if (f.type === 'select') {
1777
+ <select
1778
+ [class]="cls('formFieldInput', 'atlas-input')"
1779
+ [attr.name]="f.key"
1780
+ [value]="values[f.key] ?? ''"
1781
+ [required]="f.required"
1782
+ (change)="setValue(f.key, $any($event.target).value)"
1783
+ >
1784
+ <option value="">{{ f.placeholder ?? t('signUp.selectPlaceholder') }}</option>
1785
+ @for (o of f.options; track o) {
1786
+ <option [value]="o">{{ o }}</option>
1787
+ }
1788
+ </select>
1789
+ } @else if (f.type === 'textarea') {
1790
+ <textarea
1791
+ [class]="cls('formFieldInput', 'atlas-input')"
1792
+ [attr.name]="f.key"
1793
+ rows="3"
1794
+ [value]="values[f.key] ?? ''"
1795
+ [required]="f.required"
1796
+ [attr.placeholder]="f.placeholder"
1797
+ (input)="setValue(f.key, $any($event.target).value)"
1798
+ ></textarea>
1799
+ } @else {
1800
+ <input
1801
+ [class]="cls('formFieldInput', 'atlas-input')"
1802
+ [type]="inputType(f)"
1803
+ [attr.name]="f.key"
1804
+ [value]="values[f.key] ?? ''"
1805
+ [required]="f.required"
1806
+ [attr.placeholder]="f.placeholder"
1807
+ (input)="setValue(f.key, $any($event.target).value)"
1808
+ />
1809
+ }
1810
+ @for (error of fieldErrorsFor(f.key); track error.code) {
1811
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1812
+ }
1813
+ </label>
1814
+ }
1815
+ }
1816
+
1817
+ <label [class]="cls('formField', 'atlas-field')">
1818
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('signUp.emailLabel') }}</span>
1819
+ <input
1820
+ [class]="cls('formFieldInput', 'atlas-input')"
1821
+ type="email"
1822
+ name="email"
1823
+ autocomplete="email"
1824
+ required
1825
+ [value]="values['email'] ?? ''"
1826
+ (input)="setValue('email', $any($event.target).value)"
1827
+ />
1828
+ @for (error of fieldErrorsFor('email'); track error.code) {
1829
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1830
+ }
1831
+ </label>
1832
+
1833
+ <label [class]="cls('formField', 'atlas-field')">
1834
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('signUp.passwordLabel') }}</span>
1835
+ <input
1836
+ [class]="cls('formFieldInput', 'atlas-input')"
1837
+ type="password"
1838
+ name="password"
1839
+ autocomplete="new-password"
1840
+ required
1841
+ [value]="values['password'] ?? ''"
1842
+ (input)="setValue('password', $any($event.target).value)"
1843
+ />
1844
+ @for (error of fieldErrorsFor('password'); track error.code) {
1845
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1846
+ }
1847
+ </label>
1848
+
1849
+ @if (showLegal()) {
1850
+ <label [class]="cls('formField', 'atlas-consent')">
1851
+ @if (settings?.legal?.required) {
1852
+ <input
1853
+ type="checkbox"
1854
+ name="consent"
1855
+ [checked]="agreed"
1856
+ (change)="agreed = $any($event.target).checked"
1857
+ />
1858
+ }
1859
+ <span class="atlas-text">
1860
+ {{ t(settings?.legal?.required ? 'signUp.consentAgree' : 'signUp.consentNotice') }}
1861
+ @if (settings?.legal?.termsUrl; as terms) {
1862
+ <a class="atlas-link" [href]="terms" target="_blank" rel="noopener noreferrer">{{ t('signUp.terms') }}</a>
1863
+ }
1864
+ @if (settings?.legal?.termsUrl && settings?.legal?.privacyUrl) {
1865
+ {{ t('signUp.and') }}
1866
+ }
1867
+ @if (settings?.legal?.privacyUrl; as privacy) {
1868
+ <a class="atlas-link" [href]="privacy" target="_blank" rel="noopener noreferrer">{{ t('signUp.privacy') }}</a>
1869
+ }.
1870
+ </span>
1871
+ @for (error of fieldErrorsFor('consent'); track error.code) {
1872
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1873
+ }
1874
+ </label>
1875
+ }
1876
+
1877
+ @if (captcha) {
1878
+ <div class="atlas-captcha" data-atlas-captcha="" [atlasCaptchaSlot]="captcha"></div>
1879
+ }
1880
+
1881
+ <button
1882
+ [class]="cls('formButtonPrimary', 'atlas-button')"
1883
+ [disabled]="busy || !consentOk()"
1884
+ type="submit"
1885
+ >
1886
+ {{ t('signUp.submit') }}
1887
+ </button>
1888
+ </form>
1889
+ }
1890
+ </div>
1891
+ `,
1892
+ }]
1893
+ }], propDecorators: { settings: [{
1894
+ type: Input
1895
+ }], busy: [{
1896
+ type: Input
1897
+ }], errors: [{
1898
+ type: Input
1899
+ }], captcha: [{
1900
+ type: Input
1901
+ }], submitSignUp: [{
1902
+ type: Output
1903
+ }] } });
1904
+
1905
+ /**
1906
+ * `<atlas-sign-up>` — creates an account. The Angular peer of React's
1907
+ * `<SignUp/>`. The first step is the create-account form (the instance's
1908
+ * configured fields, email, password, legal consent and — when required — the
1909
+ * captcha), which POSTs `/v1/client/sign_ups`; from there it shares the flow
1910
+ * engine with `<atlas-sign-in>` (the emailed-code step, then the ticket
1911
+ * exchange), because the sequence of steps is decided by the server.
1144
1912
  *
1145
1913
  * ```html
1146
1914
  * <atlas-sign-up *atlasSignedOut></atlas-sign-up>
@@ -1150,20 +1918,35 @@ class SignUpComponent {
1150
1918
  atlas = inject(AtlasService);
1151
1919
  /** Where to send the user once the flow completes. Reserved for host routing. */
1152
1920
  afterUrl;
1153
- flow = new AtlasFlowController(this.atlas.createFapiClient(), () => this.atlas.reload(), this.atlas.pendingAttempt);
1921
+ flow = new AtlasFlowController(this.atlas.createFapiClient(), () => this.atlas.reload(), this.atlas.pendingAttempt, (key) => translate(this.atlas.localization, key));
1922
+ ngOnInit() {
1923
+ // §5 the instance's sign-up fields, consent policy and captcha settings.
1924
+ if (typeof window !== 'undefined')
1925
+ void this.flow.loadSettings();
1926
+ }
1154
1927
  ngOnDestroy() {
1155
1928
  this.flow.destroy();
1156
1929
  }
1157
1930
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1158
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: SignUpComponent, isStandalone: true, selector: "atlas-sign-up", inputs: { afterUrl: "afterUrl" }, ngImport: i0, template: `
1159
- <atlas-attempt-flow
1160
- [attempt]="flow.state().attempt"
1161
- [busy]="flow.state().busy"
1162
- [errors]="flow.state().errors"
1163
- titleKey="signUp.title"
1164
- (submitFlow)="flow.submit($event)"
1165
- ></atlas-attempt-flow>
1166
- `, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "titleKey"], outputs: ["submitFlow"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1931
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: SignUpComponent, isStandalone: true, selector: "atlas-sign-up", inputs: { afterUrl: "afterUrl" }, ngImport: i0, template: `
1932
+ @if (flow.state().attempt) {
1933
+ <atlas-attempt-flow
1934
+ [attempt]="flow.state().attempt"
1935
+ [busy]="flow.state().busy"
1936
+ [errors]="flow.state().errors"
1937
+ titleKey="signUp.title"
1938
+ (submitFlow)="flow.submit($event)"
1939
+ ></atlas-attempt-flow>
1940
+ } @else {
1941
+ <atlas-sign-up-form
1942
+ [settings]="flow.settings()"
1943
+ [busy]="flow.state().busy"
1944
+ [errors]="flow.state().errors"
1945
+ [captcha]="flow.signUpCaptchaDrawn() ? flow.signUpCaptcha : null"
1946
+ (submitSignUp)="flow.create($event.values, $event.consent)"
1947
+ ></atlas-sign-up-form>
1948
+ }
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 });
1167
1950
  }
1168
1951
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpComponent, decorators: [{
1169
1952
  type: Component,
@@ -1171,15 +1954,25 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1171
1954
  selector: 'atlas-sign-up',
1172
1955
  standalone: true,
1173
1956
  changeDetection: ChangeDetectionStrategy.OnPush,
1174
- imports: [AttemptFlowComponent],
1957
+ imports: [AttemptFlowComponent, SignUpFormComponent],
1175
1958
  template: `
1176
- <atlas-attempt-flow
1177
- [attempt]="flow.state().attempt"
1178
- [busy]="flow.state().busy"
1179
- [errors]="flow.state().errors"
1180
- titleKey="signUp.title"
1181
- (submitFlow)="flow.submit($event)"
1182
- ></atlas-attempt-flow>
1959
+ @if (flow.state().attempt) {
1960
+ <atlas-attempt-flow
1961
+ [attempt]="flow.state().attempt"
1962
+ [busy]="flow.state().busy"
1963
+ [errors]="flow.state().errors"
1964
+ titleKey="signUp.title"
1965
+ (submitFlow)="flow.submit($event)"
1966
+ ></atlas-attempt-flow>
1967
+ } @else {
1968
+ <atlas-sign-up-form
1969
+ [settings]="flow.settings()"
1970
+ [busy]="flow.state().busy"
1971
+ [errors]="flow.state().errors"
1972
+ [captcha]="flow.signUpCaptchaDrawn() ? flow.signUpCaptcha : null"
1973
+ (submitSignUp)="flow.create($event.values, $event.consent)"
1974
+ ></atlas-sign-up-form>
1975
+ }
1183
1976
  `,
1184
1977
  }]
1185
1978
  }], propDecorators: { afterUrl: [{
@@ -1630,5 +2423,5 @@ function atlasChildGuard(options = {}) {
1630
2423
  * Generated bundle index. Do not edit.
1631
2424
  */
1632
2425
 
1633
- export { ATLAS_OPTIONS, ATLAS_UI, AtlasFlowController, AtlasLoadedDirective, AtlasLoadingDirective, AtlasModule, AtlasProtectDirective, AtlasRootDirective, AtlasService, AtlasSignedInDirective, AtlasSignedOutDirective, AttemptFlowComponent, DEFAULT_TOKENS, EN_US, OrganizationProfileComponent, OrganizationSwitcherComponent, SignInComponent, SignUpComponent, THEMES, UserButtonComponent, UserProfileComponent, atlasChildGuard, atlasGuard, classFor, cssVariables, explicitTokens, provideAtlas, renderAppearance, resolveTokens, translate, withFallback };
2426
+ export { ATLAS_OPTIONS, ATLAS_UI, AtlasCaptchaSlotDirective, AtlasFlowController, AtlasLoadedDirective, AtlasLoadingDirective, AtlasModule, AtlasProtectDirective, AtlasRootDirective, AtlasService, AtlasSignedInDirective, AtlasSignedOutDirective, AttemptFlowComponent, DEFAULT_TOKENS, EN_US, OrganizationProfileComponent, OrganizationSwitcherComponent, SignInComponent, SignUpComponent, SignUpFormComponent, THEMES, UserButtonComponent, UserProfileComponent, atlasChildGuard, atlasGuard, classFor, cssVariables, explicitTokens, provideAtlas, renderAppearance, resolveTokens, translate, withFallback };
1634
2427
  //# sourceMappingURL=atlasauth-angular.mjs.map