@atlasauth/angular 0.6.1 → 0.8.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 { TokenCache, SignOutNotifier, resolveFrontendApi, catalogLocale, FapiClient, SignOutGuard, localizedErrorMessage, getTemplateToken, getProviderToken, signOutReasonFromClient, readRedirectResult, completeOAuthPopupLanding, mapOAuthPopupPayload, stripRedirectParams, closeOAuthPopupAfterSignIn, SDK_HEADER, sdkHeaderValue, reportSdkAdvisory, readExpiry, msUntilRefresh, nextStep, createCaptchaController, atlasCaptchaLabels, flowFromPending, initialFlow, fetchInstanceSettings, offeredFirstFactors, prepareFactor, signInCaptchaOptions, flowNeedsCaptcha, planFirstFactor, advanceWithCaptcha, createSignUp, shouldKeepPolling, pollAttempt } from '@atlasauth/js';
7
+ import { TokenCache, SignOutNotifier, resolveFrontendApi, withDevBrowser, catalogLocale, configureReferralCapture, FapiClient, SignOutGuard, localizedErrorMessage, getTemplateToken, getProviderToken, signOutReasonFromClient, readRedirectResult, completeOAuthPopupLanding, mapOAuthPopupPayload, stripRedirectParams, closeOAuthPopupAfterSignIn, SDK_HEADER, sdkHeaderValue, reportSdkAdvisory, readExpiry, msUntilRefresh, nextStep, createCaptchaController, atlasCaptchaLabels, flowFromPending, initialFlow, fetchInstanceSettings, offeredFirstFactors, prepareFactor, signInCaptchaOptions, flowNeedsCaptcha, planFirstFactor, advanceWithCaptcha, createSignUp, shouldKeepPolling, pollAttempt, signUp } 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';
@@ -171,6 +171,8 @@ const EN_US = {
171
171
  'signIn.codeSentTo': 'We sent a code to {email}. Enter it below.',
172
172
  'signIn.resendCode': 'Resend code',
173
173
  'signIn.magicLinkSent': 'Check your email for a sign-in link.',
174
+ 'signIn.signedIn': 'You\'re signed in.',
175
+ 'signIn.sessionNotKept': 'You signed in, but this browser didn\'t keep the session. If it blocks third-party cookies, allow cookies for this site and try again.',
174
176
  'signIn.checkOtherTab': 'You can close this tab — you are signed in where you started.',
175
177
  'signUp.title': 'Create your account',
176
178
  'signUp.submit': 'Continue',
@@ -291,7 +293,7 @@ function withFallback(locale) {
291
293
  }
292
294
 
293
295
  /** This package's version, sent as `X-Atlas-SDK` (kept in step with package.json by a test). */
294
- const ATLAS_ANGULAR_VERSION = '0.6.1';
296
+ const ATLAS_ANGULAR_VERSION = '0.8.0';
295
297
  const ATLAS_ANGULAR_SDK = { name: '@atlasauth/angular', version: ATLAS_ANGULAR_VERSION };
296
298
 
297
299
  /**
@@ -398,10 +400,13 @@ class AtlasService {
398
400
  this.baseUrl = resolveFrontendApi(options.frontendApi, {
399
401
  component: 'provideAtlas() / AtlasModule.forRoot()',
400
402
  });
401
- this.doFetch = options.fetchImpl ?? ((...args) => fetch(...args));
403
+ // Development instances keep the session in a header where the browser blocks the cookie.
404
+ this.doFetch = withDevBrowser(options.publishableKey, options.fetchImpl ?? ((...args) => fetch(...args)));
402
405
  this.catalog = options.localization ? withFallback(options.localization) : EN_US;
403
406
  this.errorCatalog = options.localization ?? EN_US;
404
407
  this.requestLocale = catalogLocale(options.localization);
408
+ // Referral attribution: app-wide options every FAPI client on the page falls back to.
409
+ configureReferralCapture({ referral: options.referral, referralConsent: options.referralConsent });
405
410
  this.fapiClient = new FapiClient({
406
411
  publishableKey: this.publishableKey,
407
412
  baseUrl: this.baseUrl,
@@ -589,6 +594,7 @@ class AtlasService {
589
594
  await this.reload();
590
595
  }
591
596
  /** Re-read `/v1/client`: refresh user, session, memberships and status. */
597
+ /** Re-boot from the Frontend API. Resolves true when a session now exists in this browser. */
592
598
  async reload() {
593
599
  try {
594
600
  const response = await this.call('/v1/client');
@@ -599,7 +605,7 @@ class AtlasService {
599
605
  // refresh cookie; the guard re-tries its revoke in the background.
600
606
  if (!body.session || !body.user || this.guard.refuse(body.session.id)) {
601
607
  this.clearLocalState(body.session ? 'other_tab' : signOutReasonFromClient(body));
602
- return;
608
+ return false;
603
609
  }
604
610
  this.notifier.clear();
605
611
  this._user.next(body.user);
@@ -609,6 +615,7 @@ class AtlasService {
609
615
  this.applyToken(body.jwt, body.session.id);
610
616
  this._status.next('signed_in');
611
617
  this.scheduleRefresh();
618
+ return true;
612
619
  }
613
620
  catch {
614
621
  // A failed boot is NOT signed-out. Rendering a sign-in form because the
@@ -616,6 +623,7 @@ class AtlasService {
616
623
  // needless re-authentication. Only promote loading → signed_out.
617
624
  if (this._status.value === 'loading')
618
625
  this._status.next('signed_out');
626
+ return false;
619
627
  }
620
628
  }
621
629
  // --- Internals --------------------------------------------------------------
@@ -1076,6 +1084,8 @@ class AttemptFlowComponent {
1076
1084
  attempt = null;
1077
1085
  busy = false;
1078
1086
  errors = [];
1087
+ /** `FlowState.sessionKept`: whether this browser kept the session the sign-in created. */
1088
+ sessionKept = undefined;
1079
1089
  titleKey = 'signIn.title';
1080
1090
  /**
1081
1091
  * The sign-in captcha, drawn above the submit button on the identifier step
@@ -1190,6 +1200,12 @@ class AttemptFlowComponent {
1190
1200
  case 'await_oauth':
1191
1201
  return [msg('signIn.magicLinkSent')];
1192
1202
  case 'done':
1203
+ // Signed in only when this browser holds the session; a blocked cookie is said
1204
+ // plainly. "Check the other tab" is for a sign-in completed on another device.
1205
+ if (this.sessionKept === true)
1206
+ return [msg('signIn.signedIn')];
1207
+ if (this.sessionKept === false)
1208
+ return [{ kind: 'alert', name: '', labelKey: 'signIn.sessionNotKept', type: 'text' }];
1193
1209
  return [msg('signIn.checkOtherTab')];
1194
1210
  case 'restart':
1195
1211
  return [msg('error.generic')];
@@ -1200,7 +1216,7 @@ class AttemptFlowComponent {
1200
1216
  }
1201
1217
  }
1202
1218
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AttemptFlowComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1203
- 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: `
1219
+ 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" }, outputs: { submitFlow: "submitFlow", chooseFactor: "chooseFactor" }, ngImport: i0, template: `
1204
1220
  <div [class]="cls('card', 'atlas-card')">
1205
1221
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t(titleKey) }}</h1>
1206
1222
 
@@ -1227,6 +1243,8 @@ class AttemptFlowComponent {
1227
1243
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1228
1244
  }
1229
1245
  </label>
1246
+ } @else if (f.kind === 'alert') {
1247
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ t(f.labelKey) }}</p>
1230
1248
  } @else {
1231
1249
  <p>{{ t(f.labelKey) }}</p>
1232
1250
  }
@@ -1292,6 +1310,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1292
1310
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1293
1311
  }
1294
1312
  </label>
1313
+ } @else if (f.kind === 'alert') {
1314
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ t(f.labelKey) }}</p>
1295
1315
  } @else {
1296
1316
  <p>{{ t(f.labelKey) }}</p>
1297
1317
  }
@@ -1329,6 +1349,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1329
1349
  type: Input
1330
1350
  }], errors: [{
1331
1351
  type: Input
1352
+ }], sessionKept: [{
1353
+ type: Input
1332
1354
  }], titleKey: [{
1333
1355
  type: Input
1334
1356
  }], captcha: [{
@@ -1518,17 +1540,23 @@ class AtlasFlowController {
1518
1540
  await this.finish(next);
1519
1541
  }
1520
1542
  async finish(next) {
1521
- this.setState(next);
1522
1543
  if (next.attempt?.status === 'complete') {
1544
+ // Stays busy until this browser actually holds the session: the completion
1545
+ // alone says nothing about whether the cookie was kept.
1546
+ this.setState({ ...next, busy: true });
1547
+ let exchanged = true;
1523
1548
  if (next.ticket) {
1524
- await this.client.post('/v1/client/tickets/exchange', {
1549
+ const res = await this.client.post('/v1/client/tickets/exchange', {
1525
1550
  attempt_id: next.attempt.id,
1526
1551
  ticket: next.ticket,
1527
1552
  });
1553
+ exchanged = res.ok;
1528
1554
  }
1529
- await this.reload();
1555
+ const kept = exchanged && (await this.reload());
1556
+ this.setState({ ...next, busy: false, sessionKept: kept });
1530
1557
  }
1531
1558
  else {
1559
+ this.setState(next);
1532
1560
  // A magic-link step may now be pollable (a link opened on another device).
1533
1561
  this.syncPolling();
1534
1562
  }
@@ -1553,11 +1581,12 @@ class AtlasFlowController {
1553
1581
  return;
1554
1582
  // The ticket is exchanged by a normal request, so no session token ever
1555
1583
  // appears in a URL.
1556
- await this.client.post('/v1/client/tickets/exchange', {
1584
+ const res = await this.client.post('/v1/client/tickets/exchange', {
1557
1585
  attempt_id: next.attempt.id,
1558
1586
  ticket,
1559
1587
  });
1560
- await this.reload();
1588
+ const kept = res.ok && (await this.reload());
1589
+ this.setState({ ...this.state(), sessionKept: kept });
1561
1590
  this.stopPolling();
1562
1591
  })();
1563
1592
  }, 2_000);
@@ -1590,6 +1619,31 @@ class SignInComponent {
1590
1619
  atlas = inject(AtlasService);
1591
1620
  /** Where to send the user once the flow completes. Reserved for host routing. */
1592
1621
  afterUrl;
1622
+ /**
1623
+ * Where "No account? Sign up" goes. Default: the instance's hosted sign-up
1624
+ * page (carrying the visitor's referral). `null` hides it. Always shown, never
1625
+ * only after a failed sign-in, so it says nothing about whether an email has an account.
1626
+ */
1627
+ signUpUrl;
1628
+ t(key) {
1629
+ return translate(this.atlas.localization, key);
1630
+ }
1631
+ signUpHref() {
1632
+ if (this.signUpUrl === null)
1633
+ return null;
1634
+ if (this.signUpUrl)
1635
+ return this.signUpUrl;
1636
+ try {
1637
+ return signUp({
1638
+ frontendApi: this.atlas.frontendApi,
1639
+ redirectUrl: this.afterUrl ?? (typeof window !== 'undefined' ? window.location.href : undefined),
1640
+ navigate: false,
1641
+ });
1642
+ }
1643
+ catch {
1644
+ return null;
1645
+ }
1646
+ }
1593
1647
  flow = new AtlasFlowController(this.atlas.createFapiClient(), () => this.atlas.reload(),
1594
1648
  // Resume mid-flow if an OAuth redirect came back owing a second factor.
1595
1649
  this.atlas.pendingAttempt, (key) => translate(this.atlas.localization, key));
@@ -1606,11 +1660,12 @@ class SignInComponent {
1606
1660
  this.flow.destroy();
1607
1661
  }
1608
1662
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignInComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1609
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: SignInComponent, isStandalone: true, selector: "atlas-sign-in", inputs: { afterUrl: "afterUrl" }, ngImport: i0, template: `
1663
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: SignInComponent, isStandalone: true, selector: "atlas-sign-in", inputs: { afterUrl: "afterUrl", signUpUrl: "signUpUrl" }, ngImport: i0, template: `
1610
1664
  <atlas-attempt-flow
1611
1665
  [attempt]="flow.state().attempt"
1612
1666
  [busy]="flow.state().busy"
1613
1667
  [errors]="flow.state().errors"
1668
+ [sessionKept]="flow.state().sessionKept"
1614
1669
  titleKey="signIn.title"
1615
1670
  [captcha]="flow.captchaDrawn() ? flow.captcha : null"
1616
1671
  [firstFactors]="flow.firstFactors()"
@@ -1618,7 +1673,12 @@ class SignInComponent {
1618
1673
  (submitFlow)="flow.submit($event)"
1619
1674
  (chooseFactor)="flow.prepare($event)"
1620
1675
  ></atlas-attempt-flow>
1621
- `, 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 });
1676
+ @if (flow.state().attempt?.status !== 'complete' && signUpHref(); as href) {
1677
+ <p class="atlas-footer" data-atlas-sign-up-prompt="">
1678
+ {{ t('signIn.noAccount') }} <a class="atlas-link" [href]="href">{{ t('signIn.signUpLink') }}</a>
1679
+ </p>
1680
+ }
1681
+ `, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "sessionKept", "titleKey", "captcha", "firstFactors", "preparedFactor"], outputs: ["submitFlow", "chooseFactor"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1622
1682
  }
1623
1683
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignInComponent, decorators: [{
1624
1684
  type: Component,
@@ -1632,6 +1692,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1632
1692
  [attempt]="flow.state().attempt"
1633
1693
  [busy]="flow.state().busy"
1634
1694
  [errors]="flow.state().errors"
1695
+ [sessionKept]="flow.state().sessionKept"
1635
1696
  titleKey="signIn.title"
1636
1697
  [captcha]="flow.captchaDrawn() ? flow.captcha : null"
1637
1698
  [firstFactors]="flow.firstFactors()"
@@ -1639,10 +1700,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1639
1700
  (submitFlow)="flow.submit($event)"
1640
1701
  (chooseFactor)="flow.prepare($event)"
1641
1702
  ></atlas-attempt-flow>
1703
+ @if (flow.state().attempt?.status !== 'complete' && signUpHref(); as href) {
1704
+ <p class="atlas-footer" data-atlas-sign-up-prompt="">
1705
+ {{ t('signIn.noAccount') }} <a class="atlas-link" [href]="href">{{ t('signIn.signUpLink') }}</a>
1706
+ </p>
1707
+ }
1642
1708
  `,
1643
1709
  }]
1644
1710
  }], propDecorators: { afterUrl: [{
1645
1711
  type: Input
1712
+ }], signUpUrl: [{
1713
+ type: Input
1646
1714
  }] } });
1647
1715
 
1648
1716
  /**
@@ -2109,6 +2177,7 @@ class SignUpComponent {
2109
2177
  [attempt]="flow.state().attempt"
2110
2178
  [busy]="flow.state().busy"
2111
2179
  [errors]="flow.state().errors"
2180
+ [sessionKept]="flow.state().sessionKept"
2112
2181
  titleKey="signUp.title"
2113
2182
  (submitFlow)="flow.submit($event)"
2114
2183
  ></atlas-attempt-flow>
@@ -2121,7 +2190,7 @@ class SignUpComponent {
2121
2190
  (submitSignUp)="flow.create($event.values, $event.consent)"
2122
2191
  ></atlas-sign-up-form>
2123
2192
  }
2124
- `, 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 });
2193
+ `, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "sessionKept", "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 });
2125
2194
  }
2126
2195
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpComponent, decorators: [{
2127
2196
  type: Component,
@@ -2136,6 +2205,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2136
2205
  [attempt]="flow.state().attempt"
2137
2206
  [busy]="flow.state().busy"
2138
2207
  [errors]="flow.state().errors"
2208
+ [sessionKept]="flow.state().sessionKept"
2139
2209
  titleKey="signUp.title"
2140
2210
  (submitFlow)="flow.submit($event)"
2141
2211
  ></atlas-attempt-flow>