@atlasauth/angular 0.5.1 → 0.6.1

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.
@@ -2,10 +2,10 @@ import * as i0 from '@angular/core';
2
2
  import { InjectionToken, inject, PLATFORM_ID, computed, Injectable, TemplateRef, ViewContainerRef, Directive, Input, ElementRef, Renderer2, EventEmitter, Output, ChangeDetectionStrategy, Component, signal, makeEnvironmentProviders, ENVIRONMENT_INITIALIZER, NgModule } from '@angular/core';
3
3
  import { isPlatformBrowser, DOCUMENT } from '@angular/common';
4
4
  import { toSignal } from '@angular/core/rxjs-interop';
5
- import { BehaviorSubject, combineLatest, firstValueFrom } from 'rxjs';
5
+ import { BehaviorSubject, Subject, combineLatest, firstValueFrom } from 'rxjs';
6
6
  import { map, distinctUntilChanged, filter, take } from 'rxjs/operators';
7
- import { TokenCache, resolveFrontendApi, catalogLocale, FapiClient, SignOutGuard, localizedErrorMessage, getProviderToken, readRedirectResult, completeOAuthPopupLanding, mapOAuthPopupPayload, stripRedirectParams, closeOAuthPopupAfterSignIn, readExpiry, msUntilRefresh, nextStep, createCaptchaController, flowFromPending, initialFlow, fetchInstanceSettings, offeredFirstFactors, prepareFactor, signInCaptchaOptions, flowNeedsCaptcha, planFirstFactor, advanceWithCaptcha, createSignUp, shouldKeepPolling, pollAttempt } from '@atlasauth/js';
8
- export { FapiClient, SignOutError, advance, flowFromPending, getProviderToken, initialFlow, pollAttempt, prepareFactor, requestForStep, shouldKeepPolling } from '@atlasauth/js';
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';
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';
11
11
  import { renderDesignAppearance, instanceDesignFrom, applyAppearanceToDom } from '@atlasauth/design/runtime';
@@ -190,6 +190,12 @@ const EN_US = {
190
190
  'captcha.prompt': 'Complete the security check to continue.',
191
191
  'captcha.required': 'Please complete the security check, then try again.',
192
192
  'captcha.failed': "The security check didn't pass. Please try again.",
193
+ 'captcha.checking': 'Checking your browser…',
194
+ 'captcha.verified': 'Security check complete.',
195
+ 'captcha.puzzlePrompt': "Select every picture that shows the same shape as this one. Colour and size don't matter.",
196
+ 'captcha.puzzleConfirm': 'Confirm',
197
+ 'captcha.puzzleTile': 'Picture {n}',
198
+ 'captcha.puzzleAlternative': "Can't do the picture check? Use a longer automatic check instead.",
193
199
  'mfa.title': 'Two-step verification',
194
200
  'mfa.subtitle': 'Enter the code from your authenticator app.',
195
201
  'mfa.codeLabel': 'Code',
@@ -213,6 +219,11 @@ const EN_US = {
213
219
  'userProfile.addEmail': 'Add an email address',
214
220
  'userProfile.makePrimary': 'Make primary',
215
221
  'userProfile.unverified': 'Unverified',
222
+ 'userProfile.verify': 'Verify',
223
+ 'userProfile.verifyConfirm': 'Confirm',
224
+ 'userProfile.verifyCodePlaceholder': 'Code from the email',
225
+ 'userProfile.verifySendFailed': 'We could not send a code right now. Try again in a minute.',
226
+ 'userProfile.verifyCodeRejected': 'That code was not accepted.',
216
227
  'userProfile.revokeDevice': 'Sign out',
217
228
  'userProfile.deleteAccount': 'Delete account',
218
229
  'organization.switcher': 'Switch organization',
@@ -279,6 +290,10 @@ function withFallback(locale) {
279
290
  return { ...EN_US, ...locale };
280
291
  }
281
292
 
293
+ /** 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';
295
+ const ATLAS_ANGULAR_SDK = { name: '@atlasauth/angular', version: ATLAS_ANGULAR_VERSION };
296
+
282
297
  /**
283
298
  * `AtlasService` — the Angular peer of React's `<AtlasProvider>` + hooks.
284
299
  *
@@ -333,6 +348,16 @@ class AtlasService {
333
348
  _session = new BehaviorSubject(null);
334
349
  _claims = new BehaviorSubject(null);
335
350
  _memberships = new BehaviorSubject([]);
351
+ // ATL-S-30 sign-out events, on every path.
352
+ notifier = new SignOutNotifier();
353
+ _signOut = new Subject();
354
+ /**
355
+ * Emits once each time a signed-in user becomes signed out: `reason` is
356
+ * `user`, `other_tab`, `session_expired`, `revoked` (ended elsewhere, seen on
357
+ * the next token refresh) or `unknown`. The place to reset an embedded
358
+ * third party (a chat widget). Also dispatched as `atlas:signout` on `window`.
359
+ */
360
+ signOut$ = this._signOut.asObservable();
336
361
  /** The three-state auth lifecycle: `loading` → `signed_in` | `signed_out`. */
337
362
  status$ = this._status.asObservable();
338
363
  user$ = this._user.asObservable();
@@ -382,7 +407,11 @@ class AtlasService {
382
407
  baseUrl: this.baseUrl,
383
408
  fetchImpl: this.doFetch,
384
409
  locale: this.requestLocale,
410
+ sdk: ATLAS_ANGULAR_SDK,
385
411
  });
412
+ this.notifier.subscribe((event) => this._signOut.next(event));
413
+ if (options.onSignOut)
414
+ this.notifier.subscribe(options.onSignOut);
386
415
  this.guard = new SignOutGuard({
387
416
  request: (path, init) => this.call(path, init),
388
417
  frontendApi: this.baseUrl,
@@ -397,6 +426,7 @@ class AtlasService {
397
426
  // Another tab signed out of THIS session (its tombstone reached the shared
398
427
  // store): drop the token and re-check, which then refuses the session.
399
428
  this.stopWatchingSignOut = this.guard.watch(() => this.cache.peek()?.sessionId ?? this._session.value?.id, () => {
429
+ this.notifier.expect('other_tab');
400
430
  this.cache.clear();
401
431
  void this.reload();
402
432
  });
@@ -413,6 +443,7 @@ class AtlasService {
413
443
  this._session.complete();
414
444
  this._claims.complete();
415
445
  this._memberships.complete();
446
+ this._signOut.complete();
416
447
  }
417
448
  // --- Configuration accessors ------------------------------------------------
418
449
  get appearance() {
@@ -477,6 +508,7 @@ class AtlasService {
477
508
  publishableKey: this.publishableKey,
478
509
  baseUrl: this.baseUrl,
479
510
  locale: this.requestLocale,
511
+ sdk: ATLAS_ANGULAR_SDK,
480
512
  });
481
513
  }
482
514
  // --- Imperative API (mirrors the React hooks' actions) ----------------------
@@ -484,7 +516,12 @@ class AtlasService {
484
516
  * The current session's JWT, always fresh: if the cached token is within 5s of
485
517
  * expiry it is refreshed first rather than handed over. Null when signed out.
486
518
  */
487
- async getToken() {
519
+ /** The session JWT; with `{ template }` a short-lived named-template token (e.g. `'chatly'`). */
520
+ async getToken(options) {
521
+ if (options?.template) {
522
+ const sessionId = this.cache.peek()?.sessionId ?? this._session.value?.id ?? null;
523
+ return sessionId ? ((await getTemplateToken(this.fapiClient, sessionId, options.template))?.jwt ?? null) : null;
524
+ }
488
525
  const usable = this.cache.get(5_000);
489
526
  if (usable)
490
527
  return usable.jwt;
@@ -498,6 +535,10 @@ class AtlasService {
498
535
  * kept none. The server refreshes a stale token on read; the refresh token is
499
536
  * never exposed.
500
537
  */
538
+ /** Listen for sign-out on every path (see {@link signOut$}); returns the unsubscribe. */
539
+ onSignOut(listener) {
540
+ return this.notifier.subscribe(listener);
541
+ }
501
542
  getProviderToken(provider) {
502
543
  return getProviderToken(this.fapiClient, provider);
503
544
  }
@@ -521,8 +562,9 @@ class AtlasService {
521
562
  // With no cached token the guard finds the cookie session itself
522
563
  // (GET /v1/client), so a missing JWT never skips the revoke.
523
564
  const sessionId = this.cache.peek()?.sessionId ?? this._session.value?.id ?? null;
565
+ this.notifier.expect('user');
524
566
  const attempt = await this.guard.revoke(sessionId, options);
525
- this.clearLocalState();
567
+ this.clearLocalState('user');
526
568
  if (this.guard.promoted(attempt))
527
569
  await this.reload();
528
570
  return this.guard.settle(attempt, options);
@@ -556,9 +598,10 @@ class AtlasService {
556
598
  // A session this browser signed out of is never re-adopted from the
557
599
  // refresh cookie; the guard re-tries its revoke in the background.
558
600
  if (!body.session || !body.user || this.guard.refuse(body.session.id)) {
559
- this.clearLocalState();
601
+ this.clearLocalState(body.session ? 'other_tab' : signOutReasonFromClient(body));
560
602
  return;
561
603
  }
604
+ this.notifier.clear();
562
605
  this._user.next(body.user);
563
606
  this._session.next(body.session);
564
607
  this._memberships.next(body.organization_memberships ?? []);
@@ -626,18 +669,22 @@ class AtlasService {
626
669
  ? { id: result.attemptId, status: result.status }
627
670
  : null;
628
671
  }
629
- call(path, init) {
630
- return this.doFetch(`${this.baseUrl}${path}`, {
672
+ async call(path, init) {
673
+ const response = await this.doFetch(`${this.baseUrl}${path}`, {
631
674
  ...init,
632
675
  // Cookies carry the session; a request without this is indistinguishable
633
676
  // from being signed out.
634
677
  credentials: 'include',
635
678
  headers: {
636
679
  'x-publishable-key': this.publishableKey,
680
+ // Names this SDK so the Frontend API can flag a version with a known bug.
681
+ [SDK_HEADER]: sdkHeaderValue(ATLAS_ANGULAR_SDK),
637
682
  ...(init?.body ? { 'content-type': 'application/json' } : {}),
638
683
  ...init?.headers,
639
684
  },
640
685
  });
686
+ reportSdkAdvisory(response, ATLAS_ANGULAR_SDK);
687
+ return response;
641
688
  }
642
689
  applyToken(jwt, sessionId) {
643
690
  // An in-flight refresh must not resurrect a session signed out meanwhile.
@@ -668,7 +715,11 @@ class AtlasService {
668
715
  await this.reload();
669
716
  }
670
717
  }
671
- clearLocalState() {
718
+ clearLocalState(reason) {
719
+ // A signed-in → signed-out transition tells the sign-out listeners why.
720
+ const ended = this._status.value === 'signed_in'
721
+ ? (this._session.value?.id ?? this.cache.peek()?.sessionId ?? null)
722
+ : undefined;
672
723
  this.clearRefreshTimer();
673
724
  this.cache.clear();
674
725
  this._status.next('signed_out');
@@ -676,6 +727,10 @@ class AtlasService {
676
727
  this._session.next(null);
677
728
  this._claims.next(null);
678
729
  this._memberships.next([]);
730
+ if (ended !== undefined)
731
+ this.notifier.signedOut(ended, reason);
732
+ else
733
+ this.notifier.clear();
679
734
  }
680
735
  /** §7.4: proactive, jittered refresh scheduled from the token's own expiry. */
681
736
  scheduleRefresh() {
@@ -1318,9 +1373,14 @@ class AtlasFlowController {
1318
1373
  */
1319
1374
  settings = signal(null);
1320
1375
  /** The sign-in captcha (identifier step / `needs_captcha` challenge). */
1321
- captcha = createCaptchaController();
1376
+ captcha = createCaptchaController(undefined, {
1377
+ // The Atlas captcha's words follow the form's catalog (read when drawn).
1378
+ atlasLabels: () => atlasCaptchaLabels(this.t),
1379
+ });
1322
1380
  /** The sign-up captcha (the create-account step). */
1323
- signUpCaptcha = createCaptchaController();
1381
+ signUpCaptcha = createCaptchaController(undefined, {
1382
+ atlasLabels: () => atlasCaptchaLabels(this.t),
1383
+ });
1324
1384
  /** Whether {@link captcha} currently draws a widget (for templates). */
1325
1385
  captchaDrawn = signal(false);
1326
1386
  /** Whether {@link signUpCaptcha} currently draws a widget (for templates). */
@@ -2248,6 +2308,43 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2248
2308
  */
2249
2309
  class UserProfileComponent {
2250
2310
  atlas = inject(AtlasService);
2311
+ // An unverified address (added and never confirmed, or the sign-up address on
2312
+ // an app that did not require verification) can be proven here: send a code,
2313
+ // then enter it.
2314
+ verifying = signal(null);
2315
+ code = signal('');
2316
+ busy = signal(false);
2317
+ verifyError = signal(null);
2318
+ client = this.atlas.createFapiClient();
2319
+ async sendCode(id) {
2320
+ this.verifyError.set(null);
2321
+ this.busy.set(true);
2322
+ const res = await this.client.post(`/v1/client/me/email_addresses/${id}/prepare_verification`);
2323
+ this.busy.set(false);
2324
+ if (!res.ok) {
2325
+ this.verifyError.set(this.t('userProfile.verifySendFailed'));
2326
+ return;
2327
+ }
2328
+ this.code.set('');
2329
+ this.verifying.set(id);
2330
+ }
2331
+ async confirmCode() {
2332
+ const id = this.verifying();
2333
+ if (!id)
2334
+ return;
2335
+ this.verifyError.set(null);
2336
+ this.busy.set(true);
2337
+ const res = await this.client.post(`/v1/client/me/email_addresses/${id}/attempt_verification`, {
2338
+ code: this.code(),
2339
+ });
2340
+ this.busy.set(false);
2341
+ if (!res.ok) {
2342
+ this.verifyError.set(this.t('userProfile.verifyCodeRejected'));
2343
+ return;
2344
+ }
2345
+ this.verifying.set(null);
2346
+ await this.atlas.reload();
2347
+ }
2251
2348
  t(key) {
2252
2349
  return translate(this.atlas.localization, key);
2253
2350
  }
@@ -2268,10 +2365,42 @@ class UserProfileComponent {
2268
2365
  {{ email.email_address }}
2269
2366
  @if (!email.verified) {
2270
2367
  <em> — {{ t('userProfile.unverified') }}</em>
2368
+ @if (verifying() === email.id) {
2369
+ <span data-atlas="email-verify">
2370
+ <input
2371
+ [class]="cls('formFieldInput', 'atlas-account-input')"
2372
+ inputmode="numeric"
2373
+ autocomplete="one-time-code"
2374
+ [placeholder]="t('userProfile.verifyCodePlaceholder')"
2375
+ [value]="code()"
2376
+ (input)="code.set($any($event.target).value)"
2377
+ />
2378
+ <button
2379
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
2380
+ type="button"
2381
+ [disabled]="busy()"
2382
+ (click)="confirmCode()"
2383
+ >
2384
+ {{ t('userProfile.verifyConfirm') }}
2385
+ </button>
2386
+ </span>
2387
+ } @else {
2388
+ <button
2389
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
2390
+ type="button"
2391
+ [disabled]="busy()"
2392
+ (click)="sendCode(email.id)"
2393
+ >
2394
+ {{ t('userProfile.verify') }}
2395
+ </button>
2396
+ }
2271
2397
  }
2272
2398
  </li>
2273
2399
  }
2274
2400
  </ul>
2401
+ @if (verifyError(); as message) {
2402
+ <p role="alert">{{ message }}</p>
2403
+ }
2275
2404
  </section>
2276
2405
 
2277
2406
  <section>
@@ -2301,10 +2430,42 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2301
2430
  {{ email.email_address }}
2302
2431
  @if (!email.verified) {
2303
2432
  <em> — {{ t('userProfile.unverified') }}</em>
2433
+ @if (verifying() === email.id) {
2434
+ <span data-atlas="email-verify">
2435
+ <input
2436
+ [class]="cls('formFieldInput', 'atlas-account-input')"
2437
+ inputmode="numeric"
2438
+ autocomplete="one-time-code"
2439
+ [placeholder]="t('userProfile.verifyCodePlaceholder')"
2440
+ [value]="code()"
2441
+ (input)="code.set($any($event.target).value)"
2442
+ />
2443
+ <button
2444
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
2445
+ type="button"
2446
+ [disabled]="busy()"
2447
+ (click)="confirmCode()"
2448
+ >
2449
+ {{ t('userProfile.verifyConfirm') }}
2450
+ </button>
2451
+ </span>
2452
+ } @else {
2453
+ <button
2454
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
2455
+ type="button"
2456
+ [disabled]="busy()"
2457
+ (click)="sendCode(email.id)"
2458
+ >
2459
+ {{ t('userProfile.verify') }}
2460
+ </button>
2461
+ }
2304
2462
  }
2305
2463
  </li>
2306
2464
  }
2307
2465
  </ul>
2466
+ @if (verifyError(); as message) {
2467
+ <p role="alert">{{ message }}</p>
2468
+ }
2308
2469
  </section>
2309
2470
 
2310
2471
  <section>
@@ -2538,5 +2699,5 @@ function atlasChildGuard(options = {}) {
2538
2699
  * Generated bundle index. Do not edit.
2539
2700
  */
2540
2701
 
2541
- export { ATLAS_OPTIONS, ATLAS_UI, AtlasCaptchaSlotDirective, AtlasFlowController, AtlasLoadedDirective, AtlasLoadingDirective, AtlasModule, AtlasProtectDirective, AtlasRootDirective, AtlasService, AtlasSignedInDirective, AtlasSignedOutDirective, AttemptFlowComponent, DEFAULT_TOKENS, EN_US, OrganizationProfileComponent, OrganizationSwitcherComponent, SignInComponent, SignUpComponent, SignUpFormComponent, THEMES, UserButtonComponent, UserProfileComponent, atlasChildGuard, atlasGuard, classFor, cssVariables, explicitTokens, provideAtlas, renderAppearance, resolveTokens, translate, withFallback };
2702
+ export { ATLAS_ANGULAR_SDK, ATLAS_ANGULAR_VERSION, ATLAS_OPTIONS, ATLAS_UI, AtlasCaptchaSlotDirective, AtlasFlowController, AtlasLoadedDirective, AtlasLoadingDirective, AtlasModule, AtlasProtectDirective, AtlasRootDirective, AtlasService, AtlasSignedInDirective, AtlasSignedOutDirective, AttemptFlowComponent, DEFAULT_TOKENS, EN_US, OrganizationProfileComponent, OrganizationSwitcherComponent, SignInComponent, SignUpComponent, SignUpFormComponent, THEMES, UserButtonComponent, UserProfileComponent, atlasChildGuard, atlasGuard, classFor, cssVariables, explicitTokens, provideAtlas, renderAppearance, resolveTokens, translate, withFallback };
2542
2703
  //# sourceMappingURL=atlasauth-angular.mjs.map