@atlasauth/angular 0.7.0 → 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, 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 } 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.7.0';
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,7 +400,8 @@ 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);
@@ -591,6 +594,7 @@ class AtlasService {
591
594
  await this.reload();
592
595
  }
593
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. */
594
598
  async reload() {
595
599
  try {
596
600
  const response = await this.call('/v1/client');
@@ -601,7 +605,7 @@ class AtlasService {
601
605
  // refresh cookie; the guard re-tries its revoke in the background.
602
606
  if (!body.session || !body.user || this.guard.refuse(body.session.id)) {
603
607
  this.clearLocalState(body.session ? 'other_tab' : signOutReasonFromClient(body));
604
- return;
608
+ return false;
605
609
  }
606
610
  this.notifier.clear();
607
611
  this._user.next(body.user);
@@ -611,6 +615,7 @@ class AtlasService {
611
615
  this.applyToken(body.jwt, body.session.id);
612
616
  this._status.next('signed_in');
613
617
  this.scheduleRefresh();
618
+ return true;
614
619
  }
615
620
  catch {
616
621
  // A failed boot is NOT signed-out. Rendering a sign-in form because the
@@ -618,6 +623,7 @@ class AtlasService {
618
623
  // needless re-authentication. Only promote loading → signed_out.
619
624
  if (this._status.value === 'loading')
620
625
  this._status.next('signed_out');
626
+ return false;
621
627
  }
622
628
  }
623
629
  // --- Internals --------------------------------------------------------------
@@ -1078,6 +1084,8 @@ class AttemptFlowComponent {
1078
1084
  attempt = null;
1079
1085
  busy = false;
1080
1086
  errors = [];
1087
+ /** `FlowState.sessionKept`: whether this browser kept the session the sign-in created. */
1088
+ sessionKept = undefined;
1081
1089
  titleKey = 'signIn.title';
1082
1090
  /**
1083
1091
  * The sign-in captcha, drawn above the submit button on the identifier step
@@ -1192,6 +1200,12 @@ class AttemptFlowComponent {
1192
1200
  case 'await_oauth':
1193
1201
  return [msg('signIn.magicLinkSent')];
1194
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' }];
1195
1209
  return [msg('signIn.checkOtherTab')];
1196
1210
  case 'restart':
1197
1211
  return [msg('error.generic')];
@@ -1202,7 +1216,7 @@ class AttemptFlowComponent {
1202
1216
  }
1203
1217
  }
1204
1218
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AttemptFlowComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1205
- 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: `
1206
1220
  <div [class]="cls('card', 'atlas-card')">
1207
1221
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t(titleKey) }}</h1>
1208
1222
 
@@ -1229,6 +1243,8 @@ class AttemptFlowComponent {
1229
1243
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1230
1244
  }
1231
1245
  </label>
1246
+ } @else if (f.kind === 'alert') {
1247
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ t(f.labelKey) }}</p>
1232
1248
  } @else {
1233
1249
  <p>{{ t(f.labelKey) }}</p>
1234
1250
  }
@@ -1294,6 +1310,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1294
1310
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1295
1311
  }
1296
1312
  </label>
1313
+ } @else if (f.kind === 'alert') {
1314
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ t(f.labelKey) }}</p>
1297
1315
  } @else {
1298
1316
  <p>{{ t(f.labelKey) }}</p>
1299
1317
  }
@@ -1331,6 +1349,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1331
1349
  type: Input
1332
1350
  }], errors: [{
1333
1351
  type: Input
1352
+ }], sessionKept: [{
1353
+ type: Input
1334
1354
  }], titleKey: [{
1335
1355
  type: Input
1336
1356
  }], captcha: [{
@@ -1520,17 +1540,23 @@ class AtlasFlowController {
1520
1540
  await this.finish(next);
1521
1541
  }
1522
1542
  async finish(next) {
1523
- this.setState(next);
1524
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;
1525
1548
  if (next.ticket) {
1526
- await this.client.post('/v1/client/tickets/exchange', {
1549
+ const res = await this.client.post('/v1/client/tickets/exchange', {
1527
1550
  attempt_id: next.attempt.id,
1528
1551
  ticket: next.ticket,
1529
1552
  });
1553
+ exchanged = res.ok;
1530
1554
  }
1531
- await this.reload();
1555
+ const kept = exchanged && (await this.reload());
1556
+ this.setState({ ...next, busy: false, sessionKept: kept });
1532
1557
  }
1533
1558
  else {
1559
+ this.setState(next);
1534
1560
  // A magic-link step may now be pollable (a link opened on another device).
1535
1561
  this.syncPolling();
1536
1562
  }
@@ -1555,11 +1581,12 @@ class AtlasFlowController {
1555
1581
  return;
1556
1582
  // The ticket is exchanged by a normal request, so no session token ever
1557
1583
  // appears in a URL.
1558
- await this.client.post('/v1/client/tickets/exchange', {
1584
+ const res = await this.client.post('/v1/client/tickets/exchange', {
1559
1585
  attempt_id: next.attempt.id,
1560
1586
  ticket,
1561
1587
  });
1562
- await this.reload();
1588
+ const kept = res.ok && (await this.reload());
1589
+ this.setState({ ...this.state(), sessionKept: kept });
1563
1590
  this.stopPolling();
1564
1591
  })();
1565
1592
  }, 2_000);
@@ -1592,6 +1619,31 @@ class SignInComponent {
1592
1619
  atlas = inject(AtlasService);
1593
1620
  /** Where to send the user once the flow completes. Reserved for host routing. */
1594
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
+ }
1595
1647
  flow = new AtlasFlowController(this.atlas.createFapiClient(), () => this.atlas.reload(),
1596
1648
  // Resume mid-flow if an OAuth redirect came back owing a second factor.
1597
1649
  this.atlas.pendingAttempt, (key) => translate(this.atlas.localization, key));
@@ -1608,11 +1660,12 @@ class SignInComponent {
1608
1660
  this.flow.destroy();
1609
1661
  }
1610
1662
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignInComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1611
- 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: `
1612
1664
  <atlas-attempt-flow
1613
1665
  [attempt]="flow.state().attempt"
1614
1666
  [busy]="flow.state().busy"
1615
1667
  [errors]="flow.state().errors"
1668
+ [sessionKept]="flow.state().sessionKept"
1616
1669
  titleKey="signIn.title"
1617
1670
  [captcha]="flow.captchaDrawn() ? flow.captcha : null"
1618
1671
  [firstFactors]="flow.firstFactors()"
@@ -1620,7 +1673,12 @@ class SignInComponent {
1620
1673
  (submitFlow)="flow.submit($event)"
1621
1674
  (chooseFactor)="flow.prepare($event)"
1622
1675
  ></atlas-attempt-flow>
1623
- `, 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 });
1624
1682
  }
1625
1683
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignInComponent, decorators: [{
1626
1684
  type: Component,
@@ -1634,6 +1692,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1634
1692
  [attempt]="flow.state().attempt"
1635
1693
  [busy]="flow.state().busy"
1636
1694
  [errors]="flow.state().errors"
1695
+ [sessionKept]="flow.state().sessionKept"
1637
1696
  titleKey="signIn.title"
1638
1697
  [captcha]="flow.captchaDrawn() ? flow.captcha : null"
1639
1698
  [firstFactors]="flow.firstFactors()"
@@ -1641,10 +1700,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1641
1700
  (submitFlow)="flow.submit($event)"
1642
1701
  (chooseFactor)="flow.prepare($event)"
1643
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
+ }
1644
1708
  `,
1645
1709
  }]
1646
1710
  }], propDecorators: { afterUrl: [{
1647
1711
  type: Input
1712
+ }], signUpUrl: [{
1713
+ type: Input
1648
1714
  }] } });
1649
1715
 
1650
1716
  /**
@@ -2111,6 +2177,7 @@ class SignUpComponent {
2111
2177
  [attempt]="flow.state().attempt"
2112
2178
  [busy]="flow.state().busy"
2113
2179
  [errors]="flow.state().errors"
2180
+ [sessionKept]="flow.state().sessionKept"
2114
2181
  titleKey="signUp.title"
2115
2182
  (submitFlow)="flow.submit($event)"
2116
2183
  ></atlas-attempt-flow>
@@ -2123,7 +2190,7 @@ class SignUpComponent {
2123
2190
  (submitSignUp)="flow.create($event.values, $event.consent)"
2124
2191
  ></atlas-sign-up-form>
2125
2192
  }
2126
- `, 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 });
2127
2194
  }
2128
2195
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpComponent, decorators: [{
2129
2196
  type: Component,
@@ -2138,6 +2205,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2138
2205
  [attempt]="flow.state().attempt"
2139
2206
  [busy]="flow.state().busy"
2140
2207
  [errors]="flow.state().errors"
2208
+ [sessionKept]="flow.state().sessionKept"
2141
2209
  titleKey="signUp.title"
2142
2210
  (submitFlow)="flow.submit($event)"
2143
2211
  ></atlas-attempt-flow>