@atlasauth/angular 0.13.0 → 0.14.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, 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,12 @@ 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.',
210
216
  'signIn.codeSentTo': 'We sent a code to {email}. Enter it below.',
211
217
  'signIn.resendCode': 'Resend code',
212
218
  'signIn.sending': 'Sending…',
@@ -366,6 +372,9 @@ const EN_US = {
366
372
  'error.email_delivery_unavailable': "This app can't send email right now. Try another sign-in method or contact the app owner.",
367
373
  'error.strategy_disabled': 'This sign-in method is not available for this app.',
368
374
  'error.sign_up_restricted': 'Sign-up on this app is by invitation only.',
375
+ 'error.sign_up_denied': 'Sign-up is not available for this account.',
376
+ 'error.password_not_yet_usable': 'Verify your email address before signing in with this password.',
377
+ 'error.account_deleted': 'The account for this address was deleted, so it can’t be used to sign up again.',
369
378
  'error.waitlisted': "You're on the waitlist. We'll email you when a place opens up.",
370
379
  'error.identifier_not_allowed': 'This email or phone number is not allowed on this app.',
371
380
  'error.disposable_email_blocked': 'Sign-up with a disposable or temporary email address is not allowed.',
@@ -374,6 +383,7 @@ const EN_US = {
374
383
  'error.sign_ups_disabled': 'Sign-ups are temporarily disabled.',
375
384
  'error.high_risk_blocked': 'This sign-in was blocked because it looked unusual.',
376
385
  'error.sms_region_blocked': 'Text messages are not available for this phone number.',
386
+ 'error.github_team_required': 'Your GitHub account is not a member of a GitHub organization or team allowed to sign in here.',
377
387
  };
378
388
  /**
379
389
  * Look up a key and interpolate `{placeholders}`.
@@ -392,7 +402,7 @@ function withFallback(locale) {
392
402
  }
393
403
 
394
404
  /** 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';
405
+ const ATLAS_ANGULAR_VERSION = '0.14.0';
396
406
  const ATLAS_ANGULAR_SDK = { name: '@atlasauth/angular', version: ATLAS_ANGULAR_VERSION };
397
407
 
398
408
  /**
@@ -1484,10 +1494,16 @@ class AttemptFlowComponent {
1484
1494
  firstFactors = null;
1485
1495
  /** The first factor already sent (`AtlasFlowController.preparedFactor()`): its code is collected. */
1486
1496
  preparedFactor = null;
1497
+ /** `FlowState.passwordHold`: where the proof went, and whether the password signs in now. */
1498
+ passwordHold = null;
1499
+ /** `AtlasFlowController.holdProof()`: the emailed proof a password hold still waits on. */
1500
+ holdProof = null;
1487
1501
  /** Emits the collected field values when the form is submitted. */
1488
1502
  submitFlow = new EventEmitter();
1489
1503
  /** Emits when the user picks a first factor the server must send first (an emailed code). */
1490
1504
  chooseFactor = new EventEmitter();
1505
+ /** Emits on "Use your password instead", from an emailed factor already sent. */
1506
+ usePassword = new EventEmitter();
1491
1507
  /**
1492
1508
  * Offer "Email me a code instead" at the second factor (the instance's
1493
1509
  * `account.emailSecondFactor`); `emailSecondFactor` asks for it and
@@ -1548,6 +1564,11 @@ class AttemptFlowComponent {
1548
1564
  });
1549
1565
  return;
1550
1566
  }
1567
+ // Only what is on screen: a code typed earlier must not turn a password into a code attempt.
1568
+ if (this.step().kind === 'collect_first_factor') {
1569
+ this.submitFlow.emit(this.preparedFactor === 'email_code' ? { code: this.values['code'] ?? '' } : { password: this.values['password'] ?? '' });
1570
+ return;
1571
+ }
1551
1572
  this.submitFlow.emit(this.values);
1552
1573
  }
1553
1574
  /** The shared enrolment step is drawn (a controller was passed and the flow is enrolling). */
@@ -1619,7 +1640,13 @@ class AttemptFlowComponent {
1619
1640
  codeSentText() {
1620
1641
  if (this.step().kind !== 'collect_first_factor' || this.preparedFactor !== 'email_code')
1621
1642
  return null;
1622
- return this.t('signIn.codeSentTo', { email: this.attempt?.identifier ?? '' });
1643
+ return this.t('signIn.codeSentTo', { email: this.passwordHold?.email ?? this.attempt?.identifier ?? '' });
1644
+ }
1645
+ /** The "we sent a link to …" line, once a link was sent. */
1646
+ linkSentText() {
1647
+ if (this.step().kind !== 'collect_first_factor' || this.preparedFactor !== 'email_link')
1648
+ return null;
1649
+ return this.t('signIn.linkSentTo', { email: this.passwordHold?.email ?? this.attempt?.identifier ?? '' });
1623
1650
  }
1624
1651
  /** The attempt is `needs_policy_acceptance`. */
1625
1652
  awaitingPolicies() {
@@ -1630,7 +1657,7 @@ class AttemptFlowComponent {
1630
1657
  // Nothing to submit on a first-factor step that only offers an emailed code
1631
1658
  // until that code is sent.
1632
1659
  if (kind === 'collect_first_factor')
1633
- return this.fields().length > 0;
1660
+ return this.fields().some((f) => f.kind === 'input');
1634
1661
  if (this.linkOnly())
1635
1662
  return false;
1636
1663
  // Enrolment: nothing to submit while choosing a method or fetching the setup.
@@ -1645,15 +1672,24 @@ class AttemptFlowComponent {
1645
1672
  // `webauthn`: saved passkeys appear in this field's autofill while <atlas-sign-in> holds a conditional request.
1646
1673
  return [{ kind: 'input', name: 'identifier', labelKey: 'signIn.identifierLabel', type: 'email', autocomplete: 'username webauthn' }];
1647
1674
  case 'collect_first_factor': {
1675
+ // A link proved an address on an existing account: no session, and the password signs in.
1676
+ if (this.attempt?.object === 'sign_up_attempt' && !(this.attempt.supported_first_factors ?? []).includes('passkey')) {
1677
+ return [released()];
1678
+ }
1648
1679
  if (this.preparedFactor === 'email_code') {
1649
1680
  return [{ kind: 'input', name: 'code', labelKey: 'signIn.emailCodeLabel', type: 'text' }];
1650
1681
  }
1682
+ if (this.preparedFactor === 'email_link')
1683
+ return [];
1651
1684
  // The password field unless the only offer is an emailed code (chosen
1652
1685
  // with the button below the form).
1653
1686
  const offered = this.offered();
1654
1687
  return offered.includes('email_code') && !offered.includes('password')
1655
1688
  ? []
1656
- : [{ kind: 'input', name: 'password', labelKey: 'signIn.passwordLabel', type: 'password' }];
1689
+ : [
1690
+ ...(this.passwordHold?.released ? [released()] : []),
1691
+ { kind: 'input', name: 'password', labelKey: 'signIn.passwordLabel', type: 'password' },
1692
+ ];
1657
1693
  }
1658
1694
  case 'collect_second_factor':
1659
1695
  return [{ kind: 'input', name: 'code', labelKey: 'mfa.codeLabel', type: 'text' }];
@@ -1697,7 +1733,7 @@ class AttemptFlowComponent {
1697
1733
  }
1698
1734
  }
1699
1735
  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: `
1736
+ 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
1737
  <div [class]="cls('card', 'atlas-card')">
1702
1738
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t(titleKey) }}</h1>
1703
1739
 
@@ -1709,6 +1745,9 @@ class AttemptFlowComponent {
1709
1745
  @if (codeSentText(); as sentText) {
1710
1746
  <p class="atlas-text" data-atlas-code-sent="">{{ sentText }}</p>
1711
1747
  }
1748
+ @if (linkSentText(); as sentLink) {
1749
+ <p class="atlas-text" data-atlas-link-sent="">{{ sentLink }}</p>
1750
+ }
1712
1751
  @if (linkOnly()) {
1713
1752
  <!-- Nothing to type: the flow polls until the emailed link is opened (anywhere). -->
1714
1753
  <p class="atlas-text" data-atlas-link-sent="" aria-live="polite">
@@ -1817,6 +1856,8 @@ class AttemptFlowComponent {
1817
1856
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1818
1857
  }
1819
1858
  </label>
1859
+ } @else if (f.kind === 'released') {
1860
+ <p class="atlas-text" data-atlas-password-released="">{{ t(f.labelKey) }}</p>
1820
1861
  } @else if (f.kind === 'alert') {
1821
1862
  <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ t(f.labelKey) }}</p>
1822
1863
  } @else {
@@ -1898,6 +1939,31 @@ class AttemptFlowComponent {
1898
1939
  {{ t(preparedFactor === 'email_code' ? 'signIn.resendCode' : 'signIn.useEmailCode') }}
1899
1940
  </button>
1900
1941
  }
1942
+
1943
+ @if (holdProof === 'email_link' && step().kind === 'collect_first_factor') {
1944
+ <button
1945
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1946
+ [disabled]="busy"
1947
+ type="button"
1948
+ [attr.data-strategy]="preparedFactor === 'email_link' ? null : 'email_link'"
1949
+ [attr.data-atlas-resend]="preparedFactor === 'email_link' ? '' : null"
1950
+ (click)="chooseFactor.emit('email_link')"
1951
+ >
1952
+ {{ t(preparedFactor === 'email_link' ? 'signIn.resendLink' : 'signIn.useEmailLink') }}
1953
+ </button>
1954
+ }
1955
+
1956
+ @if (preparedFactor && offered().includes('password')) {
1957
+ <button
1958
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1959
+ [disabled]="busy"
1960
+ type="button"
1961
+ data-strategy="password"
1962
+ (click)="usePassword.emit()"
1963
+ >
1964
+ {{ t('signIn.passwordOption') }}
1965
+ </button>
1966
+ }
1901
1967
  </form>
1902
1968
  </div>
1903
1969
  `, 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 +1987,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1921
1987
  @if (codeSentText(); as sentText) {
1922
1988
  <p class="atlas-text" data-atlas-code-sent="">{{ sentText }}</p>
1923
1989
  }
1990
+ @if (linkSentText(); as sentLink) {
1991
+ <p class="atlas-text" data-atlas-link-sent="">{{ sentLink }}</p>
1992
+ }
1924
1993
  @if (linkOnly()) {
1925
1994
  <!-- Nothing to type: the flow polls until the emailed link is opened (anywhere). -->
1926
1995
  <p class="atlas-text" data-atlas-link-sent="" aria-live="polite">
@@ -2029,6 +2098,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2029
2098
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
2030
2099
  }
2031
2100
  </label>
2101
+ } @else if (f.kind === 'released') {
2102
+ <p class="atlas-text" data-atlas-password-released="">{{ t(f.labelKey) }}</p>
2032
2103
  } @else if (f.kind === 'alert') {
2033
2104
  <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ t(f.labelKey) }}</p>
2034
2105
  } @else {
@@ -2110,6 +2181,31 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2110
2181
  {{ t(preparedFactor === 'email_code' ? 'signIn.resendCode' : 'signIn.useEmailCode') }}
2111
2182
  </button>
2112
2183
  }
2184
+
2185
+ @if (holdProof === 'email_link' && step().kind === 'collect_first_factor') {
2186
+ <button
2187
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
2188
+ [disabled]="busy"
2189
+ type="button"
2190
+ [attr.data-strategy]="preparedFactor === 'email_link' ? null : 'email_link'"
2191
+ [attr.data-atlas-resend]="preparedFactor === 'email_link' ? '' : null"
2192
+ (click)="chooseFactor.emit('email_link')"
2193
+ >
2194
+ {{ t(preparedFactor === 'email_link' ? 'signIn.resendLink' : 'signIn.useEmailLink') }}
2195
+ </button>
2196
+ }
2197
+
2198
+ @if (preparedFactor && offered().includes('password')) {
2199
+ <button
2200
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
2201
+ [disabled]="busy"
2202
+ type="button"
2203
+ data-strategy="password"
2204
+ (click)="usePassword.emit()"
2205
+ >
2206
+ {{ t('signIn.passwordOption') }}
2207
+ </button>
2208
+ }
2113
2209
  </form>
2114
2210
  </div>
2115
2211
  `,
@@ -2130,10 +2226,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2130
2226
  type: Input
2131
2227
  }], preparedFactor: [{
2132
2228
  type: Input
2229
+ }], passwordHold: [{
2230
+ type: Input
2231
+ }], holdProof: [{
2232
+ type: Input
2133
2233
  }], submitFlow: [{
2134
2234
  type: Output
2135
2235
  }], chooseFactor: [{
2136
2236
  type: Output
2237
+ }], usePassword: [{
2238
+ type: Output
2137
2239
  }], offerEmailSecondFactor: [{
2138
2240
  type: Input
2139
2241
  }], emailedSecondFactor: [{
@@ -2161,6 +2263,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2161
2263
  function msg(labelKey) {
2162
2264
  return { kind: 'message', name: '', labelKey, type: 'text' };
2163
2265
  }
2266
+ function released() {
2267
+ return { kind: 'released', name: '', labelKey: 'signIn.passwordHoldReleased', type: 'text' };
2268
+ }
2164
2269
 
2165
2270
  /**
2166
2271
  * The recovery codes a mid-sign-in MFA enrolment came with, shown once before
@@ -2296,6 +2401,7 @@ class AtlasFlowController {
2296
2401
  client;
2297
2402
  reload;
2298
2403
  t;
2404
+ holdExistingAccountNotice;
2299
2405
  state;
2300
2406
  /**
2301
2407
  * The instance's public sign-in / sign-up settings (captcha, sign-up fields,
@@ -2358,10 +2464,13 @@ class AtlasFlowController {
2358
2464
  identifier = '';
2359
2465
  constructor(client, reload, pending,
2360
2466
  /** Resolves the captcha messages' catalog keys. Defaults to en-US. */
2361
- t = (key) => translate(EN_US, key)) {
2467
+ t = (key) => translate(EN_US, key),
2468
+ /** Wait for {@link continueAfterExistingAccount} (`<atlas-sign-up>` draws the notice). */
2469
+ holdExistingAccountNotice = false) {
2362
2470
  this.client = client;
2363
2471
  this.reload = reload;
2364
2472
  this.t = t;
2473
+ this.holdExistingAccountNotice = holdExistingAccountNotice;
2365
2474
  // Seeded from a redirect that came back mid-flow (e.g. OAuth needing a second
2366
2475
  // factor) so the user resumes at the demanded step instead of starting over.
2367
2476
  this.state = signal(pending ? flowFromPending(pending) : initialFlow);
@@ -2428,9 +2537,27 @@ class AtlasFlowController {
2428
2537
  firstFactors() {
2429
2538
  const attempt = this.state().attempt;
2430
2539
  const step = attempt ? nextStep(attempt) : null;
2431
- return step?.kind === 'collect_first_factor'
2432
- ? offeredFirstFactors(step.strategies, this.settings()?.emailDelivery)
2433
- : [];
2540
+ if (step?.kind !== 'collect_first_factor')
2541
+ return [];
2542
+ const offered = offeredFirstFactors(step.strategies, this.settings()?.emailDelivery);
2543
+ const proof = this.holdProof();
2544
+ return proof && !offered.includes(proof) ? [...offered, proof] : offered;
2545
+ }
2546
+ /**
2547
+ * The emailed proof a password hold still waits on (`FlowState.passwordHold`): the
2548
+ * code, or a link where the instance verifies by link only. Null with no hold, once
2549
+ * proven, or while the app cannot deliver email.
2550
+ */
2551
+ holdProof() {
2552
+ const hold = this.state().passwordHold;
2553
+ if (!hold || hold.released || this.settings()?.emailDelivery?.available === false)
2554
+ return null;
2555
+ return this.settings()?.account?.emailVerification === 'link' ? 'email_link' : 'email_code';
2556
+ }
2557
+ /** Back from the emailed factor to the password field. */
2558
+ usePassword() {
2559
+ this.preparedFactor.set(null);
2560
+ this.state.update((current) => ({ ...current, prepared: null, pollSecret: null, errors: [] }));
2434
2561
  }
2435
2562
  /**
2436
2563
  * True when email + password sign-up cannot complete because the app cannot
@@ -2447,16 +2574,21 @@ class AtlasFlowController {
2447
2574
  /**
2448
2575
  * Ask the server to email a sign-in code for the current `needs_first_factor`
2449
2576
  * step (`POST …/prepare_first_factor`); the form then collects the code, which
2450
- * {@link submit} posts as the `email_code` strategy.
2577
+ * {@link submit} posts as the `email_code` strategy. While a password hold waits
2578
+ * on it ({@link holdProof}), this emails that proof instead.
2451
2579
  */
2452
2580
  async prepare(strategy) {
2453
2581
  if (!this.firstFactors().includes(strategy))
2454
2582
  return;
2583
+ const proof = this.holdProof();
2455
2584
  this.state.update((current) => ({ ...current, busy: true, errors: [] }));
2456
- const next = await prepareFactor(this.client, this.state(), strategy);
2585
+ const next = strategy === proof
2586
+ ? await preparePasswordHoldProof(this.client, this.state(), proof)
2587
+ : await prepareFactor(this.client, this.state(), strategy);
2457
2588
  this.setState(next);
2458
2589
  if (!next.errors.length)
2459
2590
  this.preparedFactor.set(strategy);
2591
+ this.syncPolling();
2460
2592
  }
2461
2593
  /** Keep both captchas in step with the settings and the flow's current step. */
2462
2594
  syncCaptcha() {
@@ -2472,8 +2604,12 @@ class AtlasFlowController {
2472
2604
  this.signUpCaptchaDrawn.set(this.signUpCaptcha.needed);
2473
2605
  }
2474
2606
  setState(next) {
2475
- const previousId = this.state().attempt?.id ?? null;
2607
+ const previous = this.state();
2608
+ const previousId = previous.attempt?.id ?? null;
2476
2609
  this.state.set(next);
2610
+ // A proven hold goes back to the password field.
2611
+ if (next.passwordHold?.released && !previous.passwordHold?.released)
2612
+ this.preparedFactor.set(null);
2477
2613
  // A prepared factor belongs to the first-factor step it was sent for.
2478
2614
  if (next.attempt?.status !== 'needs_first_factor')
2479
2615
  this.preparedFactor.set(null);
@@ -2509,6 +2645,11 @@ class AtlasFlowController {
2509
2645
  */
2510
2646
  async submit(values) {
2511
2647
  this.state.update((current) => ({ ...current, busy: true, errors: [] }));
2648
+ // A code that lifts a password hold goes to its own attempt, not this sign-in.
2649
+ if (this.preparedFactor() === 'email_code' && this.holdProof() === 'email_code') {
2650
+ this.setState(await attemptPasswordHoldProof(this.client, this.state(), values.code ?? ''));
2651
+ return;
2652
+ }
2512
2653
  const out = await advanceWithCaptcha(this.client, this.state(), values, {
2513
2654
  captcha: this.captcha,
2514
2655
  identifier: this.identifier,
@@ -2604,6 +2745,14 @@ class AtlasFlowController {
2604
2745
  async saveRecoveryCodes() {
2605
2746
  await this.finish(acknowledgeRecoveryCodes(this.state()));
2606
2747
  }
2748
+ /** The address belonged to an existing account: the notice is up until {@link continueAfterExistingAccount}. */
2749
+ existingAccountNotice() {
2750
+ return pendingExistingAccountNotice(this.state());
2751
+ }
2752
+ /** The user read the notice: finish the sign-up. */
2753
+ async continueAfterExistingAccount() {
2754
+ await this.finish(acknowledgeExistingAccount(this.state()));
2755
+ }
2607
2756
  async finish(next) {
2608
2757
  // An enrolment's recovery codes are shown first; `saveRecoveryCodes` finishes.
2609
2758
  if (pendingRecoveryCodes(next)) {
@@ -2622,6 +2771,10 @@ class AtlasFlowController {
2622
2771
  });
2623
2772
  exchanged = res.ok;
2624
2773
  }
2774
+ if (exchanged && this.holdExistingAccountNotice && pendingExistingAccountNotice(next)) {
2775
+ this.setState({ ...next, ticket: null, busy: false });
2776
+ return;
2777
+ }
2625
2778
  const kept = exchanged && (await this.reload());
2626
2779
  this.setState({ ...next, busy: false, sessionKept: kept });
2627
2780
  }
@@ -2781,6 +2934,9 @@ class SignInComponent {
2781
2934
  [captcha]="flow.captchaDrawn() ? flow.captcha : null"
2782
2935
  [firstFactors]="flow.firstFactors()"
2783
2936
  [preparedFactor]="flow.preparedFactor()"
2937
+ [passwordHold]="flow.state().passwordHold ?? null"
2938
+ [holdProof]="flow.holdProof()"
2939
+ (usePassword)="flow.usePassword()"
2784
2940
  [offerEmailSecondFactor]="flow.settings()?.account?.emailSecondFactor === true"
2785
2941
  [emailedSecondFactor]="flow.emailSecondFactor()"
2786
2942
  (submitFlow)="submitIdentifier($event)"
@@ -2810,7 +2966,7 @@ class SignInComponent {
2810
2966
  {{ t('signIn.noAccount') }} <a class="atlas-link" [href]="href">{{ t('signIn.signUpLink') }}</a>
2811
2967
  </p>
2812
2968
  }
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 });
2969
+ `, 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
2970
  }
2815
2971
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignInComponent, decorators: [{
2816
2972
  type: Component,
@@ -2845,6 +3001,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2845
3001
  [captcha]="flow.captchaDrawn() ? flow.captcha : null"
2846
3002
  [firstFactors]="flow.firstFactors()"
2847
3003
  [preparedFactor]="flow.preparedFactor()"
3004
+ [passwordHold]="flow.state().passwordHold ?? null"
3005
+ [holdProof]="flow.holdProof()"
3006
+ (usePassword)="flow.usePassword()"
2848
3007
  [offerEmailSecondFactor]="flow.settings()?.account?.emailSecondFactor === true"
2849
3008
  [emailedSecondFactor]="flow.emailSecondFactor()"
2850
3009
  (submitFlow)="submitIdentifier($event)"
@@ -3455,7 +3614,9 @@ class SignUpComponent {
3455
3614
  atlas = inject(AtlasService);
3456
3615
  /** Where to send the user once the flow completes. Reserved for host routing. */
3457
3616
  afterUrl;
3458
- flow = new AtlasFlowController(this.atlas.createFapiClient(), () => this.atlas.reload(), this.atlas.pendingAttempt, (key) => translate(this.atlas.localization, key));
3617
+ /** Where the existing-account notice's "Forgot password?" goes. Default: the hosted sign-in page; `null` hides it. */
3618
+ resetPasswordUrl;
3619
+ flow = new AtlasFlowController(this.atlas.createFapiClient(), () => this.atlas.reload(), this.atlas.pendingAttempt, (key) => translate(this.atlas.localization, key), true);
3459
3620
  /** Password or passkey-only sign-up (the latter when the instance offers it). */
3460
3621
  mode = signal('password');
3461
3622
  canPasskey() {
@@ -3469,6 +3630,9 @@ class SignUpComponent {
3469
3630
  this.flow.state.update((current) => ({ ...current, errors: [] }));
3470
3631
  this.mode.set(this.passkeyForm() ? 'password' : 'passkey');
3471
3632
  }
3633
+ resetHref() {
3634
+ return existingAccountResetUrl({ url: this.resetPasswordUrl, frontendApi: this.atlas.frontendApi, redirectUrl: this.afterUrl });
3635
+ }
3472
3636
  awaitingPasskey() {
3473
3637
  return awaitingPasskey(this.flow.state().attempt);
3474
3638
  }
@@ -3493,8 +3657,24 @@ class SignUpComponent {
3493
3657
  this.flow.destroy();
3494
3658
  }
3495
3659
  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()) {
3660
+ 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" }, ngImport: i0, template: `
3661
+ @if (flow.existingAccountNotice()) {
3662
+ <div [class]="cls('card', 'atlas-card')" data-atlas-existing-account="">
3663
+ <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
3664
+ <p class="atlas-text">{{ t('signUp.existingAccount') }}</p>
3665
+ @if (resetHref(); as href) {
3666
+ <p class="atlas-text"><a class="atlas-link" [href]="href">{{ t('signIn.forgotPassword') }}</a></p>
3667
+ }
3668
+ <button
3669
+ [class]="cls('formButtonPrimary', 'atlas-button')"
3670
+ type="button"
3671
+ [disabled]="flow.state().busy"
3672
+ (click)="flow.continueAfterExistingAccount()"
3673
+ >
3674
+ {{ t('signUp.submit') }}
3675
+ </button>
3676
+ </div>
3677
+ } @else if (awaitingPasskey()) {
3498
3678
  <div [class]="cls('card', 'atlas-card')" data-atlas-passkey-sign-up="">
3499
3679
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
3500
3680
  @for (error of flow.state().errors; track error.code) {
@@ -3536,7 +3716,7 @@ class SignUpComponent {
3536
3716
  "
3537
3717
  ></atlas-sign-up-form>
3538
3718
  }
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 });
3719
+ `, 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: SignUpFormComponent, selector: "atlas-sign-up-form", inputs: ["settings", "busy", "errors", "captcha", "passkey", "switchable", "policies"], outputs: ["switchMode", "submitSignUp"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3540
3720
  }
3541
3721
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpComponent, decorators: [{
3542
3722
  type: Component,
@@ -3546,7 +3726,23 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
3546
3726
  changeDetection: ChangeDetectionStrategy.OnPush,
3547
3727
  imports: [AttemptFlowComponent, SignUpFormComponent],
3548
3728
  template: `
3549
- @if (awaitingPasskey()) {
3729
+ @if (flow.existingAccountNotice()) {
3730
+ <div [class]="cls('card', 'atlas-card')" data-atlas-existing-account="">
3731
+ <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
3732
+ <p class="atlas-text">{{ t('signUp.existingAccount') }}</p>
3733
+ @if (resetHref(); as href) {
3734
+ <p class="atlas-text"><a class="atlas-link" [href]="href">{{ t('signIn.forgotPassword') }}</a></p>
3735
+ }
3736
+ <button
3737
+ [class]="cls('formButtonPrimary', 'atlas-button')"
3738
+ type="button"
3739
+ [disabled]="flow.state().busy"
3740
+ (click)="flow.continueAfterExistingAccount()"
3741
+ >
3742
+ {{ t('signUp.submit') }}
3743
+ </button>
3744
+ </div>
3745
+ } @else if (awaitingPasskey()) {
3550
3746
  <div [class]="cls('card', 'atlas-card')" data-atlas-passkey-sign-up="">
3551
3747
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
3552
3748
  @for (error of flow.state().errors; track error.code) {
@@ -3592,6 +3788,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
3592
3788
  }]
3593
3789
  }], propDecorators: { afterUrl: [{
3594
3790
  type: Input
3791
+ }], resetPasswordUrl: [{
3792
+ type: Input
3595
3793
  }] } });
3596
3794
 
3597
3795
  /**