@atlasauth/angular 0.4.0 → 0.5.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.
@@ -4,7 +4,7 @@ import { isPlatformBrowser, DOCUMENT } from '@angular/common';
4
4
  import { toSignal } from '@angular/core/rxjs-interop';
5
5
  import { BehaviorSubject, combineLatest, firstValueFrom } from 'rxjs';
6
6
  import { map, distinctUntilChanged, filter, take } from 'rxjs/operators';
7
- import { TokenCache, resolveFrontendApi, FapiClient, SignOutGuard, getProviderToken, readRedirectResult, completeOAuthPopupLanding, mapOAuthPopupPayload, stripRedirectParams, closeOAuthPopupAfterSignIn, readExpiry, msUntilRefresh, nextStep, createCaptchaController, flowFromPending, initialFlow, fetchInstanceSettings, offeredFirstFactors, prepareFactor, signInCaptchaOptions, flowNeedsCaptcha, advanceWithCaptcha, createSignUp, shouldKeepPolling, pollAttempt } from '@atlasauth/js';
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
8
  export { FapiClient, SignOutError, advance, flowFromPending, getProviderToken, initialFlow, pollAttempt, prepareFactor, requestForStep, shouldKeepPolling } from '@atlasauth/js';
9
9
  import { evaluate } from '@atlasauth/authz';
10
10
  export { evaluate, hasFromClaims } from '@atlasauth/authz';
@@ -168,6 +168,8 @@ const EN_US = {
168
168
  'signIn.socialButton': 'Continue with {provider}',
169
169
  'signIn.emailCodeLabel': 'Verification code',
170
170
  'signIn.useEmailCode': 'Email me a code',
171
+ 'signIn.codeSentTo': 'We sent a code to {email}. Enter it below.',
172
+ 'signIn.resendCode': 'Resend code',
171
173
  'signIn.magicLinkSent': 'Check your email for a sign-in link.',
172
174
  'signIn.checkOtherTab': 'You can close this tab — you are signed in where you started.',
173
175
  'signUp.title': 'Create your account',
@@ -220,6 +222,46 @@ const EN_US = {
220
222
  'organization.roleLabel': 'Role',
221
223
  'error.generic': 'Something went wrong. Please try again.',
222
224
  'error.network': 'We could not reach the server. Check your connection.',
225
+ // Server errors, by the stable `reason` the Frontend API sends. Shown instead
226
+ // of the server's `message` (see `localizedErrorMessage` in @atlasauth/js).
227
+ 'error.form_password_incorrect': 'Those credentials are incorrect.',
228
+ 'error.form_code_incorrect': 'That code is incorrect.',
229
+ 'error.form_code_already_used': 'That code has already been used. Wait for a new one.',
230
+ 'error.form_code_expired': 'That code has expired. Request a new one.',
231
+ 'error.form_code_max_attempts': 'Too many incorrect codes. Request a new code.',
232
+ 'error.form_link_invalid': 'That sign-in link is invalid or has already been used.',
233
+ 'error.form_identifier_not_found': "We couldn't find an account with those details.",
234
+ 'error.form_identifier_exists': "There's already an account for this email. Sign in with {methods}.",
235
+ 'error.form_password_pwned': 'This password has appeared in a known data breach. Please choose a different one.',
236
+ 'error.form_password_too_short': 'Password must be at least {min_length} characters.',
237
+ 'error.form_password_too_long': 'Password must be at most {max_length} characters.',
238
+ 'error.form_password_requires_uppercase': 'Password must contain an uppercase letter.',
239
+ 'error.form_password_requires_lowercase': 'Password must contain a lowercase letter.',
240
+ 'error.form_password_requires_number': 'Password must contain a number.',
241
+ 'error.form_password_requires_symbol': 'Password must contain a symbol.',
242
+ 'error.form_password_not_allowed': 'That password is not allowed. Choose a different one.',
243
+ 'error.form_password_contains_identifier': 'Password must not contain your email or username.',
244
+ 'error.form_password_same_as_current': 'Your new password must be different from your current one.',
245
+ 'error.form_password_reused': 'Your new password must not match any of your last {history_count} passwords.',
246
+ 'error.password_expired': 'Your password has expired. Reset it to continue.',
247
+ 'error.email_not_verified': 'Your email address is not verified yet. Verify it before signing in with your password.',
248
+ 'error.sign_in_token_invalid': 'That sign-in token is invalid or has expired.',
249
+ 'error.too_many_attempts': 'Too many failed attempts. Try again later.',
250
+ 'error.too_many_requests': 'Too many requests. Please try again later.',
251
+ 'error.captcha_required': 'Please complete the security check, then try again.',
252
+ 'error.captcha_failed': "The security check didn't pass. Please try again.",
253
+ 'error.captcha_unavailable': 'The security check is unavailable right now. Please try again later.',
254
+ 'error.email_delivery_unavailable': "This app can't send email right now. Try another sign-in method or contact the app owner.",
255
+ 'error.strategy_disabled': 'This sign-in method is not available for this app.',
256
+ 'error.sign_up_restricted': 'Sign-up on this app is by invitation only.',
257
+ 'error.waitlisted': "You're on the waitlist. We'll email you when a place opens up.",
258
+ 'error.identifier_not_allowed': 'This email or phone number is not allowed on this app.',
259
+ 'error.disposable_email_blocked': 'Sign-up with a disposable or temporary email address is not allowed.',
260
+ 'error.sso_required': 'This organization requires signing in through single sign-on.',
261
+ 'error.sign_ins_disabled': 'Sign-ins are temporarily disabled.',
262
+ 'error.sign_ups_disabled': 'Sign-ups are temporarily disabled.',
263
+ 'error.high_risk_blocked': 'This sign-in was blocked because it looked unusual.',
264
+ 'error.sms_region_blocked': 'Text messages are not available for this phone number.',
223
265
  };
224
266
  /**
225
267
  * Look up a key and interpolate `{placeholders}`.
@@ -274,6 +316,10 @@ class AtlasService {
274
316
  guard;
275
317
  stopWatchingSignOut = null;
276
318
  catalog;
319
+ /** The language layer of `catalog` (the `localization` bundle alone; `EN_US` when none). */
320
+ errorCatalog;
321
+ /** Sent to the Frontend API as `Accept-Language`: the `localization` bundle's `locale` tag. */
322
+ requestLocale;
277
323
  /**
278
324
  * A sign-in attempt handed back by an OAuth redirect that still needs a second
279
325
  * factor (no session was issued). Null in the ordinary case; the `<atlas-sign-in>`
@@ -329,10 +375,13 @@ class AtlasService {
329
375
  });
330
376
  this.doFetch = options.fetchImpl ?? ((...args) => fetch(...args));
331
377
  this.catalog = options.localization ? withFallback(options.localization) : EN_US;
378
+ this.errorCatalog = options.localization ?? EN_US;
379
+ this.requestLocale = catalogLocale(options.localization);
332
380
  this.fapiClient = new FapiClient({
333
381
  publishableKey: this.publishableKey,
334
382
  baseUrl: this.baseUrl,
335
383
  fetchImpl: this.doFetch,
384
+ locale: this.requestLocale,
336
385
  });
337
386
  this.guard = new SignOutGuard({
338
387
  request: (path, init) => this.call(path, init),
@@ -375,6 +424,22 @@ class AtlasService {
375
424
  get frontendApi() {
376
425
  return this.baseUrl;
377
426
  }
427
+ /** The `Accept-Language` sent to the Frontend API: the `localization` bundle's `locale` tag, if any. */
428
+ get locale() {
429
+ return this.requestLocale;
430
+ }
431
+ /**
432
+ * The text to show for a server error, in the app's language: the catalog's
433
+ * `error.<reason>` (looked up in the `localization` bundle, or `EN_US` when
434
+ * none is set), else the server's message, else `error.generic`.
435
+ */
436
+ errorText(error) {
437
+ return localizedErrorMessage(error, this.errorCatalog, this.catalog);
438
+ }
439
+ /** The catalog server errors are looked up in (see {@link errorText}). */
440
+ get errorLocalization() {
441
+ return this.errorCatalog;
442
+ }
378
443
  /** Tokens as CSS custom properties, for a host element to inherit. */
379
444
  appearanceVariables() {
380
445
  return cssVariables(resolveTokens(this.options.appearance));
@@ -408,7 +473,11 @@ class AtlasService {
408
473
  }
409
474
  /** A fresh {@link FapiClient} for driving a sign-in/sign-up flow. */
410
475
  createFapiClient() {
411
- return new FapiClient({ publishableKey: this.publishableKey, baseUrl: this.baseUrl });
476
+ return new FapiClient({
477
+ publishableKey: this.publishableKey,
478
+ baseUrl: this.baseUrl,
479
+ locale: this.requestLocale,
480
+ });
412
481
  }
413
482
  // --- Imperative API (mirrors the React hooks' actions) ----------------------
414
483
  /**
@@ -975,6 +1044,12 @@ class AttemptFlowComponent {
975
1044
  t(key, vars) {
976
1045
  return translate(this.atlas.localization, key, vars);
977
1046
  }
1047
+ /** A server error in the app's language (`error.<reason>`, else the server's message). */
1048
+ errorText(error) {
1049
+ // `errorLocalization` is read defensively: a stand-in service may lack it.
1050
+ const layer = this.atlas.errorLocalization;
1051
+ return localizedErrorMessage(error, layer ?? this.atlas.localization, this.atlas.localization);
1052
+ }
978
1053
  cls(key, base) {
979
1054
  return classFor(key, this.atlas.appearance, base);
980
1055
  }
@@ -1006,7 +1081,15 @@ class AttemptFlowComponent {
1006
1081
  return this.firstFactors ?? step.strategies;
1007
1082
  }
1008
1083
  showEmailCodeOption() {
1009
- return this.preparedFactor !== 'email_code' && this.offered().includes('email_code');
1084
+ // Before the code is sent: the choice (or the retry of a failed automatic
1085
+ // send). After: "Resend code".
1086
+ return this.offered().includes('email_code');
1087
+ }
1088
+ /** The "we sent a code to …" line on the code step. */
1089
+ codeSentText() {
1090
+ if (this.step().kind !== 'collect_first_factor' || this.preparedFactor !== 'email_code')
1091
+ return null;
1092
+ return this.t('signIn.codeSentTo', { email: this.attempt?.identifier ?? '' });
1010
1093
  }
1011
1094
  showSubmit() {
1012
1095
  const kind = this.step().kind;
@@ -1067,10 +1150,13 @@ class AttemptFlowComponent {
1067
1150
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t(titleKey) }}</h1>
1068
1151
 
1069
1152
  @for (error of formErrors(); track error.code) {
1070
- <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ error.message }}</p>
1153
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ errorText(error) }}</p>
1071
1154
  }
1072
1155
 
1073
1156
  <form (submit)="onSubmit($event)">
1157
+ @if (codeSentText(); as sentText) {
1158
+ <p class="atlas-text" data-atlas-code-sent="">{{ sentText }}</p>
1159
+ }
1074
1160
  @for (f of fields(); track $index) {
1075
1161
  @if (f.kind === 'input') {
1076
1162
  <label class="atlas-field">
@@ -1083,7 +1169,7 @@ class AttemptFlowComponent {
1083
1169
  (input)="setValue(f.name, $any($event.target).value)"
1084
1170
  />
1085
1171
  @for (error of fieldErrorsFor(f.name); track error.code) {
1086
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1172
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1087
1173
  }
1088
1174
  </label>
1089
1175
  } @else {
@@ -1106,9 +1192,11 @@ class AttemptFlowComponent {
1106
1192
  [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1107
1193
  [disabled]="busy"
1108
1194
  type="button"
1195
+ [attr.data-strategy]="preparedFactor === 'email_code' ? null : 'email_code'"
1196
+ [attr.data-atlas-resend]="preparedFactor === 'email_code' ? '' : null"
1109
1197
  (click)="chooseFactor.emit('email_code')"
1110
1198
  >
1111
- {{ t('signIn.useEmailCode') }}
1199
+ {{ t(preparedFactor === 'email_code' ? 'signIn.resendCode' : 'signIn.useEmailCode') }}
1112
1200
  </button>
1113
1201
  }
1114
1202
  </form>
@@ -1127,10 +1215,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1127
1215
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t(titleKey) }}</h1>
1128
1216
 
1129
1217
  @for (error of formErrors(); track error.code) {
1130
- <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ error.message }}</p>
1218
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ errorText(error) }}</p>
1131
1219
  }
1132
1220
 
1133
1221
  <form (submit)="onSubmit($event)">
1222
+ @if (codeSentText(); as sentText) {
1223
+ <p class="atlas-text" data-atlas-code-sent="">{{ sentText }}</p>
1224
+ }
1134
1225
  @for (f of fields(); track $index) {
1135
1226
  @if (f.kind === 'input') {
1136
1227
  <label class="atlas-field">
@@ -1143,7 +1234,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1143
1234
  (input)="setValue(f.name, $any($event.target).value)"
1144
1235
  />
1145
1236
  @for (error of fieldErrorsFor(f.name); track error.code) {
1146
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1237
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1147
1238
  }
1148
1239
  </label>
1149
1240
  } @else {
@@ -1166,9 +1257,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1166
1257
  [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1167
1258
  [disabled]="busy"
1168
1259
  type="button"
1260
+ [attr.data-strategy]="preparedFactor === 'email_code' ? null : 'email_code'"
1261
+ [attr.data-atlas-resend]="preparedFactor === 'email_code' ? '' : null"
1169
1262
  (click)="chooseFactor.emit('email_code')"
1170
1263
  >
1171
- {{ t('signIn.useEmailCode') }}
1264
+ {{ t(preparedFactor === 'email_code' ? 'signIn.resendCode' : 'signIn.useEmailCode') }}
1172
1265
  </button>
1173
1266
  }
1174
1267
  </form>
@@ -1316,6 +1409,22 @@ class AtlasFlowController {
1316
1409
  if (next.attempt?.status !== 'needs_first_factor')
1317
1410
  this.preparedFactor.set(null);
1318
1411
  this.syncCaptcha();
1412
+ this.autoSend();
1413
+ }
1414
+ autoSentFor = null;
1415
+ /**
1416
+ * With no password on offer, email the code as soon as the server asks for a
1417
+ * first factor — once per attempt, so a failed send waits for the user's retry
1418
+ * (the "Email me a code" button) instead of looping.
1419
+ */
1420
+ autoSend() {
1421
+ const id = this.state().attempt?.id ?? null;
1422
+ if (!id || this.autoSentFor === id || this.preparedFactor())
1423
+ return;
1424
+ if (planFirstFactor(this.firstFactors()).autoSend !== 'email_code')
1425
+ return;
1426
+ this.autoSentFor = id;
1427
+ void this.prepare('email_code');
1319
1428
  }
1320
1429
  /**
1321
1430
  * Advance the flow with the values the user typed. On completion, exchange the
@@ -1497,6 +1606,12 @@ class SignUpFormComponent {
1497
1606
  t(key, vars) {
1498
1607
  return translate(this.atlas.localization, key, vars);
1499
1608
  }
1609
+ /** A server error in the app's language (`error.<reason>`, else the server's message). */
1610
+ errorText(error) {
1611
+ // `errorLocalization` is read defensively: a stand-in service may lack it.
1612
+ const layer = this.atlas.errorLocalization;
1613
+ return localizedErrorMessage(error, layer ?? this.atlas.localization, this.atlas.localization);
1614
+ }
1500
1615
  cls(key, base) {
1501
1616
  return classFor(key, this.atlas.appearance, base);
1502
1617
  }
@@ -1565,7 +1680,7 @@ class SignUpFormComponent {
1565
1680
  </p>
1566
1681
  } @else {
1567
1682
  @for (error of formErrors(); track error.code) {
1568
- <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ error.message }}</p>
1683
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ errorText(error) }}</p>
1569
1684
  }
1570
1685
 
1571
1686
  <form (submit)="onSubmit($event)">
@@ -1588,7 +1703,7 @@ class SignUpFormComponent {
1588
1703
  }
1589
1704
  </span>
1590
1705
  @for (error of fieldErrorsFor(f.key); track error.code) {
1591
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1706
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1592
1707
  }
1593
1708
  </div>
1594
1709
  } @else {
@@ -1636,7 +1751,7 @@ class SignUpFormComponent {
1636
1751
  />
1637
1752
  }
1638
1753
  @for (error of fieldErrorsFor(f.key); track error.code) {
1639
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1754
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1640
1755
  }
1641
1756
  </label>
1642
1757
  }
@@ -1654,7 +1769,7 @@ class SignUpFormComponent {
1654
1769
  (input)="setValue('email', $any($event.target).value)"
1655
1770
  />
1656
1771
  @for (error of fieldErrorsFor('email'); track error.code) {
1657
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1772
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1658
1773
  }
1659
1774
  </label>
1660
1775
 
@@ -1670,7 +1785,7 @@ class SignUpFormComponent {
1670
1785
  (input)="setValue('password', $any($event.target).value)"
1671
1786
  />
1672
1787
  @for (error of fieldErrorsFor('password'); track error.code) {
1673
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1788
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1674
1789
  }
1675
1790
  </label>
1676
1791
 
@@ -1697,7 +1812,7 @@ class SignUpFormComponent {
1697
1812
  }.
1698
1813
  </span>
1699
1814
  @for (error of fieldErrorsFor('consent'); track error.code) {
1700
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1815
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1701
1816
  }
1702
1817
  </label>
1703
1818
  }
@@ -1737,7 +1852,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1737
1852
  </p>
1738
1853
  } @else {
1739
1854
  @for (error of formErrors(); track error.code) {
1740
- <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ error.message }}</p>
1855
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ errorText(error) }}</p>
1741
1856
  }
1742
1857
 
1743
1858
  <form (submit)="onSubmit($event)">
@@ -1760,7 +1875,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1760
1875
  }
1761
1876
  </span>
1762
1877
  @for (error of fieldErrorsFor(f.key); track error.code) {
1763
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1878
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1764
1879
  }
1765
1880
  </div>
1766
1881
  } @else {
@@ -1808,7 +1923,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1808
1923
  />
1809
1924
  }
1810
1925
  @for (error of fieldErrorsFor(f.key); track error.code) {
1811
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1926
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1812
1927
  }
1813
1928
  </label>
1814
1929
  }
@@ -1826,7 +1941,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1826
1941
  (input)="setValue('email', $any($event.target).value)"
1827
1942
  />
1828
1943
  @for (error of fieldErrorsFor('email'); track error.code) {
1829
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1944
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1830
1945
  }
1831
1946
  </label>
1832
1947
 
@@ -1842,7 +1957,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1842
1957
  (input)="setValue('password', $any($event.target).value)"
1843
1958
  />
1844
1959
  @for (error of fieldErrorsFor('password'); track error.code) {
1845
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1960
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1846
1961
  }
1847
1962
  </label>
1848
1963
 
@@ -1869,7 +1984,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1869
1984
  }.
1870
1985
  </span>
1871
1986
  @for (error of fieldErrorsFor('consent'); track error.code) {
1872
- <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1987
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1873
1988
  }
1874
1989
  </label>
1875
1990
  }