@atlasauth/angular 0.13.0 → 0.15.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,7 +4,7 @@ import { isPlatformBrowser, DOCUMENT } from '@angular/common';
4
4
  import { toSignal } from '@angular/core/rxjs-interop';
5
5
  import { BehaviorSubject, Subject, combineLatest, firstValueFrom } from 'rxjs';
6
6
  import { map, distinctUntilChanged, filter, take } from 'rxjs/operators';
7
- import { createOrganizationAuditLog, POLICY_STRINGS, TokenCache, SignOutNotifier, resolveFrontendApi, withDevBrowser, catalogLocale, configureReferralCapture, FapiClient, SignOutGuard, localizedErrorMessage, reauthenticate, getTemplateToken, getProviderToken, signOutReasonFromClient, readRedirectResult, completeOAuthPopupLanding, mapOAuthPopupPayload, stripRedirectParams, closeOAuthPopupAfterSignIn, SDK_HEADER, sdkHeaderValue, reportSdkAdvisory, readExpiry, msUntilRefresh, policyTranslator, injectPolicyStyles, mountPolicyClickwrap, mountPolicyConsent, controllerForPending, mountPolicyAcceptStep, joinTitles, formatPolicyDate, PolicyGateController, groupSetupKey, enrollmentSubmitKey, nextStep, awaitingSignUpLink, canDownloadRecoveryCodes, copyRecoveryCodes, downloadRecoveryCodes, createCaptchaController, atlasCaptchaLabels, PolicyAcceptanceController, flowFromPending, initialFlow, createMfaEnrollment, createPasskeySignInController, startSignInWithAtlas, mfaChoicesFrom, fetchInstanceSettings, fetchLastSignInMethod, offeredFirstFactors, prepareFactor, signInCaptchaOptions, flowNeedsCaptcha, planFirstFactor, advanceWithCaptcha, loadPolicies, createSignUp, prepareSecondFactorCode, resendSignUpVerification, createPasskeySignUp, finishPasskeySignUp, pendingRecoveryCodes, acknowledgeRecoveryCodes, shouldKeepPolling, pollAttempt, lastUsedMethodLabel, signUp, passkeysSupported, confirmPolicies, awaitingPasskey, listCibaRequests, getCibaPin, decideCibaRequest, setCibaPin, clearCibaPin, OrganizationProfileController, describeAuditAction, actorFromClaims } from '@atlasauth/js';
7
+ import { createOrganizationAuditLog, POLICY_STRINGS, TokenCache, SignOutNotifier, resolveFrontendApi, withDevBrowser, catalogLocale, configureReferralCapture, FapiClient, SignOutGuard, localizedErrorMessage, reauthenticate, getTemplateToken, getProviderToken, signOutReasonFromClient, readRedirectResult, completeOAuthPopupLanding, mapOAuthPopupPayload, stripRedirectParams, closeOAuthPopupAfterSignIn, SDK_HEADER, sdkHeaderValue, reportSdkAdvisory, readExpiry, msUntilRefresh, policyTranslator, injectPolicyStyles, mountPolicyClickwrap, mountPolicyConsent, controllerForPending, mountPolicyAcceptStep, joinTitles, formatPolicyDate, PolicyGateController, groupSetupKey, enrollmentSubmitKey, nextStep, awaitingSignUpLink, canDownloadRecoveryCodes, copyRecoveryCodes, downloadRecoveryCodes, createCaptchaController, atlasCaptchaLabels, PolicyAcceptanceController, flowFromPending, initialFlow, createMfaEnrollment, createPasskeySignInController, startSignInWithAtlas, mfaChoicesFrom, fetchInstanceSettings, fetchLastSignInMethod, offeredFirstFactors, preparePasswordHoldProof, prepareFactor, signInCaptchaOptions, flowNeedsCaptcha, planFirstFactor, attemptPasswordHoldProof, advanceWithCaptcha, loadPolicies, createSignUp, prepareSecondFactorCode, resendSignUpVerification, createPasskeySignUp, finishPasskeySignUp, pendingRecoveryCodes, acknowledgeRecoveryCodes, pendingExistingAccountNotice, acknowledgeExistingAccount, shouldKeepPolling, pollAttempt, lastUsedMethodLabel, signUp, passkeysSupported, confirmPolicies, existingAccountResetUrl, signUpProvedByLink, signInAfterProofUrl, awaitingPasskey, listCibaRequests, getCibaPin, decideCibaRequest, setCibaPin, clearCibaPin, OrganizationProfileController, describeAuditAction, actorFromClaims } from '@atlasauth/js';
8
8
  export { FapiClient, SIGN_OUT_EVENT, SignOutError, advance, flowFromPending, getProviderToken, initialFlow, onAtlasSignOut, pollAttempt, prepareFactor, requestForStep, shouldKeepPolling } from '@atlasauth/js';
9
9
  import { evaluate } from '@atlasauth/authz';
10
10
  export { evaluate, hasFromClaims } from '@atlasauth/authz';
@@ -207,6 +207,13 @@ const EN_US = {
207
207
  'signIn.continueWithAtlas': 'Continue with Atlas',
208
208
  'signIn.emailCodeLabel': 'Verification code',
209
209
  'signIn.useEmailCode': 'Email me a code',
210
+ 'signIn.useEmailLink': 'Email me a sign-in link',
211
+ 'signIn.linkSentTo': 'We sent a sign-in link to {email}. Open it on any device to continue.',
212
+ 'signIn.resendLink': 'Resend link',
213
+ 'signIn.passwordOption': 'Use your password instead',
214
+ 'signIn.passwordHoldReleased': 'Your email is verified. Enter your password to sign in.',
215
+ 'signUp.existingAccount': 'You already had an account. Your existing password still applies.',
216
+ 'signUp.emailConfirmed': 'Your email is confirmed. Sign in to continue.',
210
217
  'signIn.codeSentTo': 'We sent a code to {email}. Enter it below.',
211
218
  'signIn.resendCode': 'Resend code',
212
219
  'signIn.sending': 'Sending…',
@@ -366,6 +373,9 @@ const EN_US = {
366
373
  'error.email_delivery_unavailable': "This app can't send email right now. Try another sign-in method or contact the app owner.",
367
374
  'error.strategy_disabled': 'This sign-in method is not available for this app.',
368
375
  'error.sign_up_restricted': 'Sign-up on this app is by invitation only.',
376
+ 'error.sign_up_denied': 'Sign-up is not available for this account.',
377
+ 'error.password_not_yet_usable': 'Verify your email address before signing in with this password.',
378
+ 'error.account_deleted': 'The account for this address was deleted, so it can’t be used to sign up again.',
369
379
  'error.waitlisted': "You're on the waitlist. We'll email you when a place opens up.",
370
380
  'error.identifier_not_allowed': 'This email or phone number is not allowed on this app.',
371
381
  'error.disposable_email_blocked': 'Sign-up with a disposable or temporary email address is not allowed.',
@@ -374,6 +384,7 @@ const EN_US = {
374
384
  'error.sign_ups_disabled': 'Sign-ups are temporarily disabled.',
375
385
  'error.high_risk_blocked': 'This sign-in was blocked because it looked unusual.',
376
386
  'error.sms_region_blocked': 'Text messages are not available for this phone number.',
387
+ 'error.github_team_required': 'Your GitHub account is not a member of a GitHub organization or team allowed to sign in here.',
377
388
  };
378
389
  /**
379
390
  * Look up a key and interpolate `{placeholders}`.
@@ -392,7 +403,7 @@ function withFallback(locale) {
392
403
  }
393
404
 
394
405
  /** This package's version, sent as `X-Atlas-SDK` (kept in step with package.json by a test). */
395
- const ATLAS_ANGULAR_VERSION = '0.13.0';
406
+ const ATLAS_ANGULAR_VERSION = '0.15.0';
396
407
  const ATLAS_ANGULAR_SDK = { name: '@atlasauth/angular', version: ATLAS_ANGULAR_VERSION };
397
408
 
398
409
  /**
@@ -1484,10 +1495,16 @@ class AttemptFlowComponent {
1484
1495
  firstFactors = null;
1485
1496
  /** The first factor already sent (`AtlasFlowController.preparedFactor()`): its code is collected. */
1486
1497
  preparedFactor = null;
1498
+ /** `FlowState.passwordHold`: where the proof went, and whether the password signs in now. */
1499
+ passwordHold = null;
1500
+ /** `AtlasFlowController.holdProof()`: the emailed proof a password hold still waits on. */
1501
+ holdProof = null;
1487
1502
  /** Emits the collected field values when the form is submitted. */
1488
1503
  submitFlow = new EventEmitter();
1489
1504
  /** Emits when the user picks a first factor the server must send first (an emailed code). */
1490
1505
  chooseFactor = new EventEmitter();
1506
+ /** Emits on "Use your password instead", from an emailed factor already sent. */
1507
+ usePassword = new EventEmitter();
1491
1508
  /**
1492
1509
  * Offer "Email me a code instead" at the second factor (the instance's
1493
1510
  * `account.emailSecondFactor`); `emailSecondFactor` asks for it and
@@ -1548,6 +1565,11 @@ class AttemptFlowComponent {
1548
1565
  });
1549
1566
  return;
1550
1567
  }
1568
+ // Only what is on screen: a code typed earlier must not turn a password into a code attempt.
1569
+ if (this.step().kind === 'collect_first_factor') {
1570
+ this.submitFlow.emit(this.preparedFactor === 'email_code' ? { code: this.values['code'] ?? '' } : { password: this.values['password'] ?? '' });
1571
+ return;
1572
+ }
1551
1573
  this.submitFlow.emit(this.values);
1552
1574
  }
1553
1575
  /** The shared enrolment step is drawn (a controller was passed and the flow is enrolling). */
@@ -1619,7 +1641,13 @@ class AttemptFlowComponent {
1619
1641
  codeSentText() {
1620
1642
  if (this.step().kind !== 'collect_first_factor' || this.preparedFactor !== 'email_code')
1621
1643
  return null;
1622
- return this.t('signIn.codeSentTo', { email: this.attempt?.identifier ?? '' });
1644
+ return this.t('signIn.codeSentTo', { email: this.passwordHold?.email ?? this.attempt?.identifier ?? '' });
1645
+ }
1646
+ /** The "we sent a link to …" line, once a link was sent. */
1647
+ linkSentText() {
1648
+ if (this.step().kind !== 'collect_first_factor' || this.preparedFactor !== 'email_link')
1649
+ return null;
1650
+ return this.t('signIn.linkSentTo', { email: this.passwordHold?.email ?? this.attempt?.identifier ?? '' });
1623
1651
  }
1624
1652
  /** The attempt is `needs_policy_acceptance`. */
1625
1653
  awaitingPolicies() {
@@ -1630,7 +1658,7 @@ class AttemptFlowComponent {
1630
1658
  // Nothing to submit on a first-factor step that only offers an emailed code
1631
1659
  // until that code is sent.
1632
1660
  if (kind === 'collect_first_factor')
1633
- return this.fields().length > 0;
1661
+ return this.fields().some((f) => f.kind === 'input');
1634
1662
  if (this.linkOnly())
1635
1663
  return false;
1636
1664
  // Enrolment: nothing to submit while choosing a method or fetching the setup.
@@ -1648,12 +1676,17 @@ class AttemptFlowComponent {
1648
1676
  if (this.preparedFactor === 'email_code') {
1649
1677
  return [{ kind: 'input', name: 'code', labelKey: 'signIn.emailCodeLabel', type: 'text' }];
1650
1678
  }
1679
+ if (this.preparedFactor === 'email_link')
1680
+ return [];
1651
1681
  // The password field unless the only offer is an emailed code (chosen
1652
1682
  // with the button below the form).
1653
1683
  const offered = this.offered();
1654
1684
  return offered.includes('email_code') && !offered.includes('password')
1655
1685
  ? []
1656
- : [{ kind: 'input', name: 'password', labelKey: 'signIn.passwordLabel', type: 'password' }];
1686
+ : [
1687
+ ...(this.passwordHold?.released ? [released()] : []),
1688
+ { kind: 'input', name: 'password', labelKey: 'signIn.passwordLabel', type: 'password' },
1689
+ ];
1657
1690
  }
1658
1691
  case 'collect_second_factor':
1659
1692
  return [{ kind: 'input', name: 'code', labelKey: 'mfa.codeLabel', type: 'text' }];
@@ -1697,7 +1730,7 @@ class AttemptFlowComponent {
1697
1730
  }
1698
1731
  }
1699
1732
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AttemptFlowComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1700
- 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", sessionKept: "sessionKept", titleKey: "titleKey", captcha: "captcha", firstFactors: "firstFactors", preparedFactor: "preparedFactor", offerEmailSecondFactor: "offerEmailSecondFactor", emailedSecondFactor: "emailedSecondFactor", offerSmsSecondFactor: "offerSmsSecondFactor", textedSecondFactor: "textedSecondFactor", enrollment: "enrollment", enrollmentState: "enrollmentState", emailVerification: "emailVerification" }, outputs: { submitFlow: "submitFlow", chooseFactor: "chooseFactor", emailSecondFactor: "emailSecondFactor", smsSecondFactor: "smsSecondFactor", resend: "resend", policyDeclined: "policyDeclined" }, ngImport: i0, template: `
1733
+ 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", sessionKept: "sessionKept", titleKey: "titleKey", captcha: "captcha", firstFactors: "firstFactors", preparedFactor: "preparedFactor", passwordHold: "passwordHold", holdProof: "holdProof", offerEmailSecondFactor: "offerEmailSecondFactor", emailedSecondFactor: "emailedSecondFactor", offerSmsSecondFactor: "offerSmsSecondFactor", textedSecondFactor: "textedSecondFactor", enrollment: "enrollment", enrollmentState: "enrollmentState", emailVerification: "emailVerification" }, outputs: { submitFlow: "submitFlow", chooseFactor: "chooseFactor", usePassword: "usePassword", emailSecondFactor: "emailSecondFactor", smsSecondFactor: "smsSecondFactor", resend: "resend", policyDeclined: "policyDeclined" }, ngImport: i0, template: `
1701
1734
  <div [class]="cls('card', 'atlas-card')">
1702
1735
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t(titleKey) }}</h1>
1703
1736
 
@@ -1709,6 +1742,9 @@ class AttemptFlowComponent {
1709
1742
  @if (codeSentText(); as sentText) {
1710
1743
  <p class="atlas-text" data-atlas-code-sent="">{{ sentText }}</p>
1711
1744
  }
1745
+ @if (linkSentText(); as sentLink) {
1746
+ <p class="atlas-text" data-atlas-link-sent="">{{ sentLink }}</p>
1747
+ }
1712
1748
  @if (linkOnly()) {
1713
1749
  <!-- Nothing to type: the flow polls until the emailed link is opened (anywhere). -->
1714
1750
  <p class="atlas-text" data-atlas-link-sent="" aria-live="polite">
@@ -1817,6 +1853,8 @@ class AttemptFlowComponent {
1817
1853
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1818
1854
  }
1819
1855
  </label>
1856
+ } @else if (f.kind === 'released') {
1857
+ <p class="atlas-text" data-atlas-password-released="">{{ t(f.labelKey) }}</p>
1820
1858
  } @else if (f.kind === 'alert') {
1821
1859
  <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ t(f.labelKey) }}</p>
1822
1860
  } @else {
@@ -1898,6 +1936,31 @@ class AttemptFlowComponent {
1898
1936
  {{ t(preparedFactor === 'email_code' ? 'signIn.resendCode' : 'signIn.useEmailCode') }}
1899
1937
  </button>
1900
1938
  }
1939
+
1940
+ @if (holdProof === 'email_link' && step().kind === 'collect_first_factor') {
1941
+ <button
1942
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1943
+ [disabled]="busy"
1944
+ type="button"
1945
+ [attr.data-strategy]="preparedFactor === 'email_link' ? null : 'email_link'"
1946
+ [attr.data-atlas-resend]="preparedFactor === 'email_link' ? '' : null"
1947
+ (click)="chooseFactor.emit('email_link')"
1948
+ >
1949
+ {{ t(preparedFactor === 'email_link' ? 'signIn.resendLink' : 'signIn.useEmailLink') }}
1950
+ </button>
1951
+ }
1952
+
1953
+ @if (preparedFactor && offered().includes('password')) {
1954
+ <button
1955
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1956
+ [disabled]="busy"
1957
+ type="button"
1958
+ data-strategy="password"
1959
+ (click)="usePassword.emit()"
1960
+ >
1961
+ {{ t('signIn.passwordOption') }}
1962
+ </button>
1963
+ }
1901
1964
  </form>
1902
1965
  </div>
1903
1966
  `, isInline: true, dependencies: [{ kind: "directive", type: AtlasCaptchaSlotDirective, selector: "[atlasCaptchaSlot]", inputs: ["atlasCaptchaSlot"] }, { kind: "component", type: PolicyAcceptStepComponent, selector: "atlas-policy-accept-step", inputs: ["attempt", "busy"], outputs: ["submitFlow", "declined"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
@@ -1921,6 +1984,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1921
1984
  @if (codeSentText(); as sentText) {
1922
1985
  <p class="atlas-text" data-atlas-code-sent="">{{ sentText }}</p>
1923
1986
  }
1987
+ @if (linkSentText(); as sentLink) {
1988
+ <p class="atlas-text" data-atlas-link-sent="">{{ sentLink }}</p>
1989
+ }
1924
1990
  @if (linkOnly()) {
1925
1991
  <!-- Nothing to type: the flow polls until the emailed link is opened (anywhere). -->
1926
1992
  <p class="atlas-text" data-atlas-link-sent="" aria-live="polite">
@@ -2029,6 +2095,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2029
2095
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
2030
2096
  }
2031
2097
  </label>
2098
+ } @else if (f.kind === 'released') {
2099
+ <p class="atlas-text" data-atlas-password-released="">{{ t(f.labelKey) }}</p>
2032
2100
  } @else if (f.kind === 'alert') {
2033
2101
  <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ t(f.labelKey) }}</p>
2034
2102
  } @else {
@@ -2110,6 +2178,31 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2110
2178
  {{ t(preparedFactor === 'email_code' ? 'signIn.resendCode' : 'signIn.useEmailCode') }}
2111
2179
  </button>
2112
2180
  }
2181
+
2182
+ @if (holdProof === 'email_link' && step().kind === 'collect_first_factor') {
2183
+ <button
2184
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
2185
+ [disabled]="busy"
2186
+ type="button"
2187
+ [attr.data-strategy]="preparedFactor === 'email_link' ? null : 'email_link'"
2188
+ [attr.data-atlas-resend]="preparedFactor === 'email_link' ? '' : null"
2189
+ (click)="chooseFactor.emit('email_link')"
2190
+ >
2191
+ {{ t(preparedFactor === 'email_link' ? 'signIn.resendLink' : 'signIn.useEmailLink') }}
2192
+ </button>
2193
+ }
2194
+
2195
+ @if (preparedFactor && offered().includes('password')) {
2196
+ <button
2197
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
2198
+ [disabled]="busy"
2199
+ type="button"
2200
+ data-strategy="password"
2201
+ (click)="usePassword.emit()"
2202
+ >
2203
+ {{ t('signIn.passwordOption') }}
2204
+ </button>
2205
+ }
2113
2206
  </form>
2114
2207
  </div>
2115
2208
  `,
@@ -2130,10 +2223,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2130
2223
  type: Input
2131
2224
  }], preparedFactor: [{
2132
2225
  type: Input
2226
+ }], passwordHold: [{
2227
+ type: Input
2228
+ }], holdProof: [{
2229
+ type: Input
2133
2230
  }], submitFlow: [{
2134
2231
  type: Output
2135
2232
  }], chooseFactor: [{
2136
2233
  type: Output
2234
+ }], usePassword: [{
2235
+ type: Output
2137
2236
  }], offerEmailSecondFactor: [{
2138
2237
  type: Input
2139
2238
  }], emailedSecondFactor: [{
@@ -2161,6 +2260,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2161
2260
  function msg(labelKey) {
2162
2261
  return { kind: 'message', name: '', labelKey, type: 'text' };
2163
2262
  }
2263
+ function released() {
2264
+ return { kind: 'released', name: '', labelKey: 'signIn.passwordHoldReleased', type: 'text' };
2265
+ }
2164
2266
 
2165
2267
  /**
2166
2268
  * The recovery codes a mid-sign-in MFA enrolment came with, shown once before
@@ -2296,6 +2398,7 @@ class AtlasFlowController {
2296
2398
  client;
2297
2399
  reload;
2298
2400
  t;
2401
+ holdExistingAccountNotice;
2299
2402
  state;
2300
2403
  /**
2301
2404
  * The instance's public sign-in / sign-up settings (captcha, sign-up fields,
@@ -2358,10 +2461,13 @@ class AtlasFlowController {
2358
2461
  identifier = '';
2359
2462
  constructor(client, reload, pending,
2360
2463
  /** Resolves the captcha messages' catalog keys. Defaults to en-US. */
2361
- t = (key) => translate(EN_US, key)) {
2464
+ t = (key) => translate(EN_US, key),
2465
+ /** Wait for {@link continueAfterExistingAccount} (`<atlas-sign-up>` draws the notice). */
2466
+ holdExistingAccountNotice = false) {
2362
2467
  this.client = client;
2363
2468
  this.reload = reload;
2364
2469
  this.t = t;
2470
+ this.holdExistingAccountNotice = holdExistingAccountNotice;
2365
2471
  // Seeded from a redirect that came back mid-flow (e.g. OAuth needing a second
2366
2472
  // factor) so the user resumes at the demanded step instead of starting over.
2367
2473
  this.state = signal(pending ? flowFromPending(pending) : initialFlow);
@@ -2428,9 +2534,27 @@ class AtlasFlowController {
2428
2534
  firstFactors() {
2429
2535
  const attempt = this.state().attempt;
2430
2536
  const step = attempt ? nextStep(attempt) : null;
2431
- return step?.kind === 'collect_first_factor'
2432
- ? offeredFirstFactors(step.strategies, this.settings()?.emailDelivery)
2433
- : [];
2537
+ if (step?.kind !== 'collect_first_factor')
2538
+ return [];
2539
+ const offered = offeredFirstFactors(step.strategies, this.settings()?.emailDelivery);
2540
+ const proof = this.holdProof();
2541
+ return proof && !offered.includes(proof) ? [...offered, proof] : offered;
2542
+ }
2543
+ /**
2544
+ * The emailed proof a password hold still waits on (`FlowState.passwordHold`): the
2545
+ * code, or a link where the instance verifies by link only. Null with no hold, once
2546
+ * proven, or while the app cannot deliver email.
2547
+ */
2548
+ holdProof() {
2549
+ const hold = this.state().passwordHold;
2550
+ if (!hold || hold.released || this.settings()?.emailDelivery?.available === false)
2551
+ return null;
2552
+ return this.settings()?.account?.emailVerification === 'link' ? 'email_link' : 'email_code';
2553
+ }
2554
+ /** Back from the emailed factor to the password field. */
2555
+ usePassword() {
2556
+ this.preparedFactor.set(null);
2557
+ this.state.update((current) => ({ ...current, prepared: null, pollSecret: null, errors: [] }));
2434
2558
  }
2435
2559
  /**
2436
2560
  * True when email + password sign-up cannot complete because the app cannot
@@ -2447,16 +2571,21 @@ class AtlasFlowController {
2447
2571
  /**
2448
2572
  * Ask the server to email a sign-in code for the current `needs_first_factor`
2449
2573
  * step (`POST …/prepare_first_factor`); the form then collects the code, which
2450
- * {@link submit} posts as the `email_code` strategy.
2574
+ * {@link submit} posts as the `email_code` strategy. While a password hold waits
2575
+ * on it ({@link holdProof}), this emails that proof instead.
2451
2576
  */
2452
2577
  async prepare(strategy) {
2453
2578
  if (!this.firstFactors().includes(strategy))
2454
2579
  return;
2580
+ const proof = this.holdProof();
2455
2581
  this.state.update((current) => ({ ...current, busy: true, errors: [] }));
2456
- const next = await prepareFactor(this.client, this.state(), strategy);
2582
+ const next = strategy === proof
2583
+ ? await preparePasswordHoldProof(this.client, this.state(), proof)
2584
+ : await prepareFactor(this.client, this.state(), strategy);
2457
2585
  this.setState(next);
2458
2586
  if (!next.errors.length)
2459
2587
  this.preparedFactor.set(strategy);
2588
+ this.syncPolling();
2460
2589
  }
2461
2590
  /** Keep both captchas in step with the settings and the flow's current step. */
2462
2591
  syncCaptcha() {
@@ -2472,8 +2601,12 @@ class AtlasFlowController {
2472
2601
  this.signUpCaptchaDrawn.set(this.signUpCaptcha.needed);
2473
2602
  }
2474
2603
  setState(next) {
2475
- const previousId = this.state().attempt?.id ?? null;
2604
+ const previous = this.state();
2605
+ const previousId = previous.attempt?.id ?? null;
2476
2606
  this.state.set(next);
2607
+ // A proven hold goes back to the password field.
2608
+ if (next.passwordHold?.released && !previous.passwordHold?.released)
2609
+ this.preparedFactor.set(null);
2477
2610
  // A prepared factor belongs to the first-factor step it was sent for.
2478
2611
  if (next.attempt?.status !== 'needs_first_factor')
2479
2612
  this.preparedFactor.set(null);
@@ -2509,6 +2642,11 @@ class AtlasFlowController {
2509
2642
  */
2510
2643
  async submit(values) {
2511
2644
  this.state.update((current) => ({ ...current, busy: true, errors: [] }));
2645
+ // A code that lifts a password hold goes to its own attempt, not this sign-in.
2646
+ if (this.preparedFactor() === 'email_code' && this.holdProof() === 'email_code') {
2647
+ this.setState(await attemptPasswordHoldProof(this.client, this.state(), values.code ?? ''));
2648
+ return;
2649
+ }
2512
2650
  const out = await advanceWithCaptcha(this.client, this.state(), values, {
2513
2651
  captcha: this.captcha,
2514
2652
  identifier: this.identifier,
@@ -2604,10 +2742,24 @@ class AtlasFlowController {
2604
2742
  async saveRecoveryCodes() {
2605
2743
  await this.finish(acknowledgeRecoveryCodes(this.state()));
2606
2744
  }
2745
+ /** The address belonged to an existing account: the notice is up until {@link continueAfterExistingAccount}. */
2746
+ existingAccountNotice() {
2747
+ return pendingExistingAccountNotice(this.state());
2748
+ }
2749
+ /** The user read the notice: finish the sign-up. */
2750
+ async continueAfterExistingAccount() {
2751
+ await this.finish(acknowledgeExistingAccount(this.state()));
2752
+ }
2607
2753
  async finish(next) {
2608
2754
  // An enrolment's recovery codes are shown first; `saveRecoveryCodes` finishes.
2609
2755
  if (pendingRecoveryCodes(next)) {
2610
- this.setState({ ...next, busy: false });
2756
+ // The ticket dies in 60 seconds and saving the codes can take longer: redeem it now,
2757
+ // and `saveRecoveryCodes` finishes. A refused exchange keeps the ticket, so the save reports it.
2758
+ this.setState({ ...next, busy: true });
2759
+ const res = next.ticket
2760
+ ? await this.client.post('/v1/client/tickets/exchange', { attempt_id: next.attempt.id, ticket: next.ticket })
2761
+ : null;
2762
+ this.setState({ ...next, busy: false, ticket: res?.ok ? null : next.ticket });
2611
2763
  return;
2612
2764
  }
2613
2765
  if (next.attempt?.status === 'complete') {
@@ -2622,6 +2774,10 @@ class AtlasFlowController {
2622
2774
  });
2623
2775
  exchanged = res.ok;
2624
2776
  }
2777
+ if (exchanged && this.holdExistingAccountNotice && pendingExistingAccountNotice(next)) {
2778
+ this.setState({ ...next, ticket: null, busy: false });
2779
+ return;
2780
+ }
2625
2781
  const kept = exchanged && (await this.reload());
2626
2782
  this.setState({ ...next, busy: false, sessionKept: kept });
2627
2783
  }
@@ -2781,6 +2937,9 @@ class SignInComponent {
2781
2937
  [captcha]="flow.captchaDrawn() ? flow.captcha : null"
2782
2938
  [firstFactors]="flow.firstFactors()"
2783
2939
  [preparedFactor]="flow.preparedFactor()"
2940
+ [passwordHold]="flow.state().passwordHold ?? null"
2941
+ [holdProof]="flow.holdProof()"
2942
+ (usePassword)="flow.usePassword()"
2784
2943
  [offerEmailSecondFactor]="flow.settings()?.account?.emailSecondFactor === true"
2785
2944
  [emailedSecondFactor]="flow.emailSecondFactor()"
2786
2945
  (submitFlow)="submitIdentifier($event)"
@@ -2810,7 +2969,7 @@ class SignInComponent {
2810
2969
  {{ t('signIn.noAccount') }} <a class="atlas-link" [href]="href">{{ t('signIn.signUpLink') }}</a>
2811
2970
  </p>
2812
2971
  }
2813
- `, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "sessionKept", "titleKey", "captcha", "firstFactors", "preparedFactor", "offerEmailSecondFactor", "emailedSecondFactor", "offerSmsSecondFactor", "textedSecondFactor", "enrollment", "enrollmentState", "emailVerification"], outputs: ["submitFlow", "chooseFactor", "emailSecondFactor", "smsSecondFactor", "resend", "policyDeclined"] }, { kind: "component", type: RecoveryCodesComponent, selector: "atlas-recovery-codes", inputs: ["codes", "busy"], outputs: ["saved"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2972
+ `, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "sessionKept", "titleKey", "captcha", "firstFactors", "preparedFactor", "passwordHold", "holdProof", "offerEmailSecondFactor", "emailedSecondFactor", "offerSmsSecondFactor", "textedSecondFactor", "enrollment", "enrollmentState", "emailVerification"], outputs: ["submitFlow", "chooseFactor", "usePassword", "emailSecondFactor", "smsSecondFactor", "resend", "policyDeclined"] }, { kind: "component", type: RecoveryCodesComponent, selector: "atlas-recovery-codes", inputs: ["codes", "busy"], outputs: ["saved"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2814
2973
  }
2815
2974
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignInComponent, decorators: [{
2816
2975
  type: Component,
@@ -2845,6 +3004,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2845
3004
  [captcha]="flow.captchaDrawn() ? flow.captcha : null"
2846
3005
  [firstFactors]="flow.firstFactors()"
2847
3006
  [preparedFactor]="flow.preparedFactor()"
3007
+ [passwordHold]="flow.state().passwordHold ?? null"
3008
+ [holdProof]="flow.holdProof()"
3009
+ (usePassword)="flow.usePassword()"
2848
3010
  [offerEmailSecondFactor]="flow.settings()?.account?.emailSecondFactor === true"
2849
3011
  [emailedSecondFactor]="flow.emailSecondFactor()"
2850
3012
  (submitFlow)="submitIdentifier($event)"
@@ -3455,7 +3617,11 @@ class SignUpComponent {
3455
3617
  atlas = inject(AtlasService);
3456
3618
  /** Where to send the user once the flow completes. Reserved for host routing. */
3457
3619
  afterUrl;
3458
- flow = new AtlasFlowController(this.atlas.createFapiClient(), () => this.atlas.reload(), this.atlas.pendingAttempt, (key) => translate(this.atlas.localization, key));
3620
+ /** Where the existing-account notice's "Forgot password?" goes. Default: the hosted sign-in page; `null` hides it. */
3621
+ resetPasswordUrl;
3622
+ /** Where "Sign in" goes once an emailed link confirmed the address and made no session here. Default: the hosted sign-in page; `null` hides it. */
3623
+ signInUrl;
3624
+ flow = new AtlasFlowController(this.atlas.createFapiClient(), () => this.atlas.reload(), this.atlas.pendingAttempt, (key) => translate(this.atlas.localization, key), true);
3459
3625
  /** Password or passkey-only sign-up (the latter when the instance offers it). */
3460
3626
  mode = signal('password');
3461
3627
  canPasskey() {
@@ -3469,6 +3635,16 @@ class SignUpComponent {
3469
3635
  this.flow.state.update((current) => ({ ...current, errors: [] }));
3470
3636
  this.mode.set(this.passkeyForm() ? 'password' : 'passkey');
3471
3637
  }
3638
+ resetHref() {
3639
+ return existingAccountResetUrl({ url: this.resetPasswordUrl, frontendApi: this.atlas.frontendApi, redirectUrl: this.afterUrl });
3640
+ }
3641
+ /** A link confirmed the address and made no session here. Says the same for any address. */
3642
+ emailConfirmed() {
3643
+ return signUpProvedByLink(this.flow.state().attempt);
3644
+ }
3645
+ signInHref() {
3646
+ return signInAfterProofUrl({ url: this.signInUrl, frontendApi: this.atlas.frontendApi, redirectUrl: this.afterUrl });
3647
+ }
3472
3648
  awaitingPasskey() {
3473
3649
  return awaitingPasskey(this.flow.state().attempt);
3474
3650
  }
@@ -3493,8 +3669,26 @@ class SignUpComponent {
3493
3669
  this.flow.destroy();
3494
3670
  }
3495
3671
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3496
- 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: `
3497
- @if (awaitingPasskey()) {
3672
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: SignUpComponent, isStandalone: true, selector: "atlas-sign-up", inputs: { afterUrl: "afterUrl", resetPasswordUrl: "resetPasswordUrl", signInUrl: "signInUrl" }, ngImport: i0, template: `
3673
+ @if (flow.recoveryCodes(); as codes) {
3674
+ <atlas-recovery-codes [codes]="codes" [busy]="flow.state().busy" (saved)="flow.saveRecoveryCodes()"></atlas-recovery-codes>
3675
+ } @else if (flow.existingAccountNotice()) {
3676
+ <div [class]="cls('card', 'atlas-card')" data-atlas-existing-account="">
3677
+ <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
3678
+ <p class="atlas-text">{{ t('signUp.existingAccount') }}</p>
3679
+ @if (resetHref(); as href) {
3680
+ <p class="atlas-text"><a class="atlas-link" [href]="href">{{ t('signIn.forgotPassword') }}</a></p>
3681
+ }
3682
+ <button
3683
+ [class]="cls('formButtonPrimary', 'atlas-button')"
3684
+ type="button"
3685
+ [disabled]="flow.state().busy"
3686
+ (click)="flow.continueAfterExistingAccount()"
3687
+ >
3688
+ {{ t('signUp.submit') }}
3689
+ </button>
3690
+ </div>
3691
+ } @else if (awaitingPasskey()) {
3498
3692
  <div [class]="cls('card', 'atlas-card')" data-atlas-passkey-sign-up="">
3499
3693
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
3500
3694
  @for (error of flow.state().errors; track error.code) {
@@ -3510,6 +3704,14 @@ class SignUpComponent {
3510
3704
  {{ t('signUp.passkeyCreate') }}
3511
3705
  </button>
3512
3706
  </div>
3707
+ } @else if (emailConfirmed()) {
3708
+ <div [class]="cls('card', 'atlas-card')" data-atlas-email-confirmed="">
3709
+ <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
3710
+ <p class="atlas-text" role="status">{{ t('signUp.emailConfirmed') }}</p>
3711
+ @if (signInHref(); as href) {
3712
+ <a [class]="cls('formButtonPrimary', 'atlas-button')" [href]="href">{{ t('signIn.title') }}</a>
3713
+ }
3714
+ </div>
3513
3715
  } @else if (flow.state().attempt) {
3514
3716
  <atlas-attempt-flow
3515
3717
  [attempt]="flow.state().attempt"
@@ -3520,6 +3722,15 @@ class SignUpComponent {
3520
3722
  [emailVerification]="flow.settings()?.account?.emailVerification ?? 'code'"
3521
3723
  (submitFlow)="flow.submit($event)"
3522
3724
  (resend)="flow.resendSignUp()"
3725
+ [offerEmailSecondFactor]="flow.settings()?.account?.emailSecondFactor === true"
3726
+ [emailedSecondFactor]="flow.emailSecondFactor()"
3727
+ [offerSmsSecondFactor]="flow.settings() !== null && flow.mfaChoices().sms"
3728
+ [textedSecondFactor]="flow.smsSecondFactor()"
3729
+ [enrollment]="flow.enrollment"
3730
+ [enrollmentState]="flow.enrollmentState()"
3731
+ (emailSecondFactor)="flow.sendEmailSecondFactor()"
3732
+ (smsSecondFactor)="flow.sendSmsSecondFactor()"
3733
+ (policyDeclined)="flow.declinePolicy($event)"
3523
3734
  ></atlas-attempt-flow>
3524
3735
  } @else {
3525
3736
  <atlas-sign-up-form
@@ -3536,7 +3747,7 @@ class SignUpComponent {
3536
3747
  "
3537
3748
  ></atlas-sign-up-form>
3538
3749
  }
3539
- `, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "sessionKept", "titleKey", "captcha", "firstFactors", "preparedFactor", "offerEmailSecondFactor", "emailedSecondFactor", "offerSmsSecondFactor", "textedSecondFactor", "enrollment", "enrollmentState", "emailVerification"], outputs: ["submitFlow", "chooseFactor", "emailSecondFactor", "smsSecondFactor", "resend", "policyDeclined"] }, { kind: "component", type: SignUpFormComponent, selector: "atlas-sign-up-form", inputs: ["settings", "busy", "errors", "captcha", "passkey", "switchable", "policies"], outputs: ["switchMode", "submitSignUp"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3750
+ `, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "sessionKept", "titleKey", "captcha", "firstFactors", "preparedFactor", "passwordHold", "holdProof", "offerEmailSecondFactor", "emailedSecondFactor", "offerSmsSecondFactor", "textedSecondFactor", "enrollment", "enrollmentState", "emailVerification"], outputs: ["submitFlow", "chooseFactor", "usePassword", "emailSecondFactor", "smsSecondFactor", "resend", "policyDeclined"] }, { kind: "component", type: RecoveryCodesComponent, selector: "atlas-recovery-codes", inputs: ["codes", "busy"], outputs: ["saved"] }, { kind: "component", type: SignUpFormComponent, selector: "atlas-sign-up-form", inputs: ["settings", "busy", "errors", "captcha", "passkey", "switchable", "policies"], outputs: ["switchMode", "submitSignUp"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3540
3751
  }
3541
3752
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpComponent, decorators: [{
3542
3753
  type: Component,
@@ -3544,9 +3755,27 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
3544
3755
  selector: 'atlas-sign-up',
3545
3756
  standalone: true,
3546
3757
  changeDetection: ChangeDetectionStrategy.OnPush,
3547
- imports: [AttemptFlowComponent, SignUpFormComponent],
3758
+ imports: [AttemptFlowComponent, RecoveryCodesComponent, SignUpFormComponent],
3548
3759
  template: `
3549
- @if (awaitingPasskey()) {
3760
+ @if (flow.recoveryCodes(); as codes) {
3761
+ <atlas-recovery-codes [codes]="codes" [busy]="flow.state().busy" (saved)="flow.saveRecoveryCodes()"></atlas-recovery-codes>
3762
+ } @else if (flow.existingAccountNotice()) {
3763
+ <div [class]="cls('card', 'atlas-card')" data-atlas-existing-account="">
3764
+ <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
3765
+ <p class="atlas-text">{{ t('signUp.existingAccount') }}</p>
3766
+ @if (resetHref(); as href) {
3767
+ <p class="atlas-text"><a class="atlas-link" [href]="href">{{ t('signIn.forgotPassword') }}</a></p>
3768
+ }
3769
+ <button
3770
+ [class]="cls('formButtonPrimary', 'atlas-button')"
3771
+ type="button"
3772
+ [disabled]="flow.state().busy"
3773
+ (click)="flow.continueAfterExistingAccount()"
3774
+ >
3775
+ {{ t('signUp.submit') }}
3776
+ </button>
3777
+ </div>
3778
+ } @else if (awaitingPasskey()) {
3550
3779
  <div [class]="cls('card', 'atlas-card')" data-atlas-passkey-sign-up="">
3551
3780
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
3552
3781
  @for (error of flow.state().errors; track error.code) {
@@ -3562,6 +3791,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
3562
3791
  {{ t('signUp.passkeyCreate') }}
3563
3792
  </button>
3564
3793
  </div>
3794
+ } @else if (emailConfirmed()) {
3795
+ <div [class]="cls('card', 'atlas-card')" data-atlas-email-confirmed="">
3796
+ <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
3797
+ <p class="atlas-text" role="status">{{ t('signUp.emailConfirmed') }}</p>
3798
+ @if (signInHref(); as href) {
3799
+ <a [class]="cls('formButtonPrimary', 'atlas-button')" [href]="href">{{ t('signIn.title') }}</a>
3800
+ }
3801
+ </div>
3565
3802
  } @else if (flow.state().attempt) {
3566
3803
  <atlas-attempt-flow
3567
3804
  [attempt]="flow.state().attempt"
@@ -3572,6 +3809,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
3572
3809
  [emailVerification]="flow.settings()?.account?.emailVerification ?? 'code'"
3573
3810
  (submitFlow)="flow.submit($event)"
3574
3811
  (resend)="flow.resendSignUp()"
3812
+ [offerEmailSecondFactor]="flow.settings()?.account?.emailSecondFactor === true"
3813
+ [emailedSecondFactor]="flow.emailSecondFactor()"
3814
+ [offerSmsSecondFactor]="flow.settings() !== null && flow.mfaChoices().sms"
3815
+ [textedSecondFactor]="flow.smsSecondFactor()"
3816
+ [enrollment]="flow.enrollment"
3817
+ [enrollmentState]="flow.enrollmentState()"
3818
+ (emailSecondFactor)="flow.sendEmailSecondFactor()"
3819
+ (smsSecondFactor)="flow.sendSmsSecondFactor()"
3820
+ (policyDeclined)="flow.declinePolicy($event)"
3575
3821
  ></atlas-attempt-flow>
3576
3822
  } @else {
3577
3823
  <atlas-sign-up-form
@@ -3592,6 +3838,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
3592
3838
  }]
3593
3839
  }], propDecorators: { afterUrl: [{
3594
3840
  type: Input
3841
+ }], resetPasswordUrl: [{
3842
+ type: Input
3843
+ }], signInUrl: [{
3844
+ type: Input
3595
3845
  }] } });
3596
3846
 
3597
3847
  /**