@atlasauth/angular 0.2.1 → 0.3.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, combineLatest, firstValueFrom } from 'rxjs';
6
6
  import { map, distinctUntilChanged, filter, take } from 'rxjs/operators';
7
- import { TokenCache, FapiClient, getProviderToken, runFrontChannelLogoutFromResponse, readRedirectResult, stripRedirectParams, readExpiry, msUntilRefresh, nextStep, flowFromPending, initialFlow, advance, shouldKeepPolling, pollAttempt } from '@atlasauth/js';
7
+ import { TokenCache, resolveFrontendApi, FapiClient, getProviderToken, runFrontChannelLogoutFromResponse, readRedirectResult, stripRedirectParams, readExpiry, msUntilRefresh, nextStep, createCaptchaController, flowFromPending, initialFlow, fetchInstanceSettings, signInCaptchaOptions, flowNeedsCaptcha, advanceWithCaptcha, createSignUp, shouldKeepPolling, pollAttempt } from '@atlasauth/js';
8
8
  export { FapiClient, 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';
@@ -173,6 +173,18 @@ const EN_US = {
173
173
  'signUp.submit': 'Continue',
174
174
  'signUp.haveAccount': 'Already have an account?',
175
175
  'signUp.signInLink': 'Sign in',
176
+ 'signUp.emailLabel': 'Email address',
177
+ 'signUp.passwordLabel': 'Password',
178
+ 'signUp.optionalField': '{label} (optional)',
179
+ 'signUp.selectPlaceholder': 'Select…',
180
+ 'signUp.consentAgree': 'I agree to the',
181
+ 'signUp.consentNotice': 'By continuing you agree to the',
182
+ 'signUp.terms': 'Terms of Service',
183
+ 'signUp.privacy': 'Privacy Policy',
184
+ 'signUp.and': 'and',
185
+ 'captcha.prompt': 'Complete the security check to continue.',
186
+ 'captcha.required': 'Please complete the security check, then try again.',
187
+ 'captcha.failed': "The security check didn't pass. Please try again.",
176
188
  'mfa.title': 'Two-step verification',
177
189
  'mfa.subtitle': 'Enter the code from your authenticator app.',
178
190
  'mfa.codeLabel': 'Code',
@@ -299,7 +311,11 @@ class AtlasService {
299
311
  constructor() {
300
312
  const options = this.options;
301
313
  this.publishableKey = options.publishableKey;
302
- this.baseUrl = options.frontendApi ?? '';
314
+ // Throws an `AtlasConfigError` on a missing/invalid value rather than quietly
315
+ // sending every request to a relative path on the app's origin.
316
+ this.baseUrl = resolveFrontendApi(options.frontendApi, {
317
+ component: 'provideAtlas() / AtlasModule.forRoot()',
318
+ });
303
319
  this.doFetch = options.fetchImpl ?? ((...args) => fetch(...args));
304
320
  this.catalog = options.localization ? withFallback(options.localization) : EN_US;
305
321
  this.fapiClient = new FapiClient({
@@ -823,6 +839,46 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
823
839
  }]
824
840
  }] });
825
841
 
842
+ /**
843
+ * `[atlasCaptchaSlot]="controller"` — the element a flow's captcha widget draws
844
+ * into (see `createCaptchaController` in `@atlasauth/js`). Attaches the
845
+ * controller to the host element while it is rendered and detaches it (removing
846
+ * the widget) when it goes away.
847
+ */
848
+ class AtlasCaptchaSlotDirective {
849
+ host = inject(ElementRef);
850
+ current = null;
851
+ initialized = false;
852
+ set controller(next) {
853
+ if (next === this.current)
854
+ return;
855
+ if (this.initialized)
856
+ this.current?.attach(null);
857
+ this.current = next;
858
+ if (this.initialized)
859
+ this.current?.attach(this.host.nativeElement);
860
+ }
861
+ ngOnInit() {
862
+ this.initialized = true;
863
+ this.current?.attach(this.host.nativeElement);
864
+ }
865
+ ngOnDestroy() {
866
+ this.current?.attach(null);
867
+ }
868
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasCaptchaSlotDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
869
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "17.3.12", type: AtlasCaptchaSlotDirective, isStandalone: true, selector: "[atlasCaptchaSlot]", inputs: { controller: ["atlasCaptchaSlot", "controller"] }, ngImport: i0 });
870
+ }
871
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasCaptchaSlotDirective, decorators: [{
872
+ type: Directive,
873
+ args: [{
874
+ selector: '[atlasCaptchaSlot]',
875
+ standalone: true,
876
+ }]
877
+ }], propDecorators: { controller: [{
878
+ type: Input,
879
+ args: ['atlasCaptchaSlot']
880
+ }] } });
881
+
826
882
  /**
827
883
  * The server-driven flow shell, shared by `<atlas-sign-in>` and
828
884
  * `<atlas-sign-up>`. The Angular peer of React's internal `AttemptFlow`.
@@ -839,6 +895,12 @@ class AttemptFlowComponent {
839
895
  busy = false;
840
896
  errors = [];
841
897
  titleKey = 'signIn.title';
898
+ /**
899
+ * The sign-in captcha, drawn above the submit button on the identifier step
900
+ * (an always-on sign-in captcha) and on the challenge step the server demands
901
+ * with `needs_captcha`. Null when the flow draws none.
902
+ */
903
+ captcha = null;
842
904
  /** Emits the collected field values when the form is submitted. */
843
905
  submitFlow = new EventEmitter();
844
906
  values = {};
@@ -864,6 +926,10 @@ class AttemptFlowComponent {
864
926
  step() {
865
927
  return this.attempt ? nextStep(this.attempt) : { kind: 'collect_identifier' };
866
928
  }
929
+ showCaptcha() {
930
+ const kind = this.step().kind;
931
+ return !!this.captcha && (kind === 'collect_identifier' || kind === 'collect_captcha');
932
+ }
867
933
  showSubmit() {
868
934
  const kind = this.step().kind;
869
935
  return kind !== 'done' && kind !== 'await_oauth';
@@ -888,6 +954,10 @@ class AttemptFlowComponent {
888
954
  return [{ kind: 'input', name: 'code', labelKey: 'signIn.emailCodeLabel', type: 'text' }];
889
955
  case 'collect_new_password':
890
956
  return [{ kind: 'input', name: 'password', labelKey: 'reset.newPasswordLabel', type: 'password' }];
957
+ case 'collect_captcha':
958
+ // §5 the server withheld the attempt until a captcha clears. Solving the
959
+ // widget resubmits the identifier on its own; the button retries.
960
+ return [msg('captcha.prompt')];
891
961
  case 'await_oauth':
892
962
  return [msg('signIn.magicLinkSent')];
893
963
  case 'done':
@@ -901,7 +971,7 @@ class AttemptFlowComponent {
901
971
  }
902
972
  }
903
973
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AttemptFlowComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
904
- 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" }, outputs: { submitFlow: "submitFlow" }, ngImport: i0, template: `
974
+ 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" }, outputs: { submitFlow: "submitFlow" }, ngImport: i0, template: `
905
975
  <div [class]="cls('card', 'atlas-card')">
906
976
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t(titleKey) }}</h1>
907
977
 
@@ -917,6 +987,7 @@ class AttemptFlowComponent {
917
987
  <input
918
988
  [class]="cls('formFieldInput', 'atlas-input')"
919
989
  [type]="f.type"
990
+ [attr.name]="f.name"
920
991
  [value]="values[f.name] ?? ''"
921
992
  (input)="setValue(f.name, $any($event.target).value)"
922
993
  />
@@ -929,6 +1000,10 @@ class AttemptFlowComponent {
929
1000
  }
930
1001
  }
931
1002
 
1003
+ @if (showCaptcha()) {
1004
+ <div class="atlas-captcha" data-atlas-captcha="" [atlasCaptchaSlot]="captcha"></div>
1005
+ }
1006
+
932
1007
  @if (showSubmit()) {
933
1008
  <button [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy" type="submit">
934
1009
  {{ t('signIn.submit') }}
@@ -936,7 +1011,7 @@ class AttemptFlowComponent {
936
1011
  }
937
1012
  </form>
938
1013
  </div>
939
- `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1014
+ `, isInline: true, dependencies: [{ kind: "directive", type: AtlasCaptchaSlotDirective, selector: "[atlasCaptchaSlot]", inputs: ["atlasCaptchaSlot"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
940
1015
  }
941
1016
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AttemptFlowComponent, decorators: [{
942
1017
  type: Component,
@@ -944,6 +1019,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
944
1019
  selector: 'atlas-attempt-flow',
945
1020
  standalone: true,
946
1021
  changeDetection: ChangeDetectionStrategy.OnPush,
1022
+ imports: [AtlasCaptchaSlotDirective],
947
1023
  template: `
948
1024
  <div [class]="cls('card', 'atlas-card')">
949
1025
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t(titleKey) }}</h1>
@@ -960,6 +1036,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
960
1036
  <input
961
1037
  [class]="cls('formFieldInput', 'atlas-input')"
962
1038
  [type]="f.type"
1039
+ [attr.name]="f.name"
963
1040
  [value]="values[f.name] ?? ''"
964
1041
  (input)="setValue(f.name, $any($event.target).value)"
965
1042
  />
@@ -972,6 +1049,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
972
1049
  }
973
1050
  }
974
1051
 
1052
+ @if (showCaptcha()) {
1053
+ <div class="atlas-captcha" data-atlas-captcha="" [atlasCaptchaSlot]="captcha"></div>
1054
+ }
1055
+
975
1056
  @if (showSubmit()) {
976
1057
  <button [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy" type="submit">
977
1058
  {{ t('signIn.submit') }}
@@ -989,6 +1070,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
989
1070
  type: Input
990
1071
  }], titleKey: [{
991
1072
  type: Input
1073
+ }], captcha: [{
1074
+ type: Input
992
1075
  }], submitFlow: [{
993
1076
  type: Output
994
1077
  }] } });
@@ -1005,18 +1088,68 @@ function msg(labelKey) {
1005
1088
  * server via `advance`, and this controller only posts whatever the current step
1006
1089
  * needs and re-renders the returned status. State lives in a signal so templates
1007
1090
  * update whether the app runs zoned or zoneless.
1091
+ *
1092
+ * `<atlas-sign-up>` starts the attempt with {@link create}
1093
+ * (`POST /v1/client/sign_ups`); the emailed-code step then runs through the same
1094
+ * {@link submit}. Both flows draw the instance's captcha (from
1095
+ * {@link loadSettings}) when it is required.
1008
1096
  */
1009
1097
  class AtlasFlowController {
1010
1098
  client;
1011
1099
  reload;
1100
+ t;
1012
1101
  state;
1102
+ /**
1103
+ * The instance's public sign-in / sign-up settings (captcha, sign-up fields,
1104
+ * legal consent) from `GET /v1/appearance`; null until {@link loadSettings}
1105
+ * resolves, or when the server could not be reached.
1106
+ */
1107
+ settings = signal(null);
1108
+ /** The sign-in captcha (identifier step / `needs_captcha` challenge). */
1109
+ captcha = createCaptchaController();
1110
+ /** The sign-up captcha (the create-account step). */
1111
+ signUpCaptcha = createCaptchaController();
1112
+ /** Whether {@link captcha} currently draws a widget (for templates). */
1113
+ captchaDrawn = signal(false);
1114
+ /** Whether {@link signUpCaptcha} currently draws a widget (for templates). */
1115
+ signUpCaptchaDrawn = signal(false);
1013
1116
  pollTimer = null;
1014
- constructor(client, reload, pending) {
1117
+ identifier = '';
1118
+ constructor(client, reload, pending,
1119
+ /** Resolves the captcha messages' catalog keys. Defaults to en-US. */
1120
+ t = (key) => translate(EN_US, key)) {
1015
1121
  this.client = client;
1016
1122
  this.reload = reload;
1123
+ this.t = t;
1017
1124
  // Seeded from a redirect that came back mid-flow (e.g. OAuth needing a second
1018
1125
  // factor) so the user resumes at the demanded step instead of starting over.
1019
1126
  this.state = signal(pending ? flowFromPending(pending) : initialFlow);
1127
+ this.syncCaptcha();
1128
+ }
1129
+ /** Fetch the instance's public settings and draw whatever captcha they require. */
1130
+ async loadSettings() {
1131
+ const loaded = await fetchInstanceSettings(this.client);
1132
+ if (loaded) {
1133
+ this.settings.set(loaded);
1134
+ this.syncCaptcha();
1135
+ }
1136
+ }
1137
+ /** Keep both captchas in step with the settings and the flow's current step. */
1138
+ syncCaptcha() {
1139
+ const settings = this.settings();
1140
+ const state = this.state();
1141
+ this.captcha.configure(signInCaptchaOptions(settings?.captchaFlows.signIn, state, () => void this.submit({})));
1142
+ const signUpFlow = settings?.captchaFlows.signUp;
1143
+ this.signUpCaptcha.configure({
1144
+ flow: signUpFlow,
1145
+ active: !state.attempt && flowNeedsCaptcha(signUpFlow),
1146
+ });
1147
+ this.captchaDrawn.set(this.captcha.needed);
1148
+ this.signUpCaptchaDrawn.set(this.signUpCaptcha.needed);
1149
+ }
1150
+ setState(next) {
1151
+ this.state.set(next);
1152
+ this.syncCaptcha();
1020
1153
  }
1021
1154
  /**
1022
1155
  * Advance the flow with the values the user typed. On completion, exchange the
@@ -1026,8 +1159,31 @@ class AtlasFlowController {
1026
1159
  */
1027
1160
  async submit(values) {
1028
1161
  this.state.update((current) => ({ ...current, busy: true, errors: [] }));
1029
- const next = await advance(this.client, this.state(), values);
1030
- this.state.set(next);
1162
+ const out = await advanceWithCaptcha(this.client, this.state(), values, {
1163
+ captcha: this.captcha,
1164
+ identifier: this.identifier,
1165
+ t: this.t,
1166
+ });
1167
+ this.identifier = out.identifier;
1168
+ await this.finish(out.state);
1169
+ }
1170
+ /**
1171
+ * Create an account (`POST /v1/client/sign_ups`) from a create-account form's
1172
+ * values — email, password and any configured sign-up fields — carrying the
1173
+ * sign-up captcha's token when the instance requires one. The emailed code is
1174
+ * then posted with {@link submit}.
1175
+ */
1176
+ async create(values, consent = true) {
1177
+ this.state.update((current) => ({ ...current, busy: true, errors: [] }));
1178
+ const next = await createSignUp(this.client, values, {
1179
+ consent,
1180
+ captcha: this.signUpCaptcha,
1181
+ t: this.t,
1182
+ });
1183
+ await this.finish(next);
1184
+ }
1185
+ async finish(next) {
1186
+ this.setState(next);
1031
1187
  if (next.attempt?.status === 'complete') {
1032
1188
  if (next.ticket) {
1033
1189
  await this.client.post('/v1/client/tickets/exchange', {
@@ -1057,7 +1213,7 @@ class AtlasFlowController {
1057
1213
  this.pollTimer = setInterval(() => {
1058
1214
  void (async () => {
1059
1215
  const { state: next, ticket } = await pollAttempt(this.client, this.state());
1060
- this.state.set(next);
1216
+ this.setState(next);
1061
1217
  if (!ticket || !next.attempt)
1062
1218
  return;
1063
1219
  // The ticket is exchanged by a normal request, so no session token ever
@@ -1077,15 +1233,19 @@ class AtlasFlowController {
1077
1233
  this.pollTimer = null;
1078
1234
  }
1079
1235
  }
1080
- /** Release the poll timer — call from the component's `ngOnDestroy`. */
1236
+ /** Release the poll timer and the captcha widgets — call from the component's `ngOnDestroy`. */
1081
1237
  destroy() {
1082
1238
  this.stopPolling();
1239
+ this.captcha.destroy();
1240
+ this.signUpCaptcha.destroy();
1083
1241
  }
1084
1242
  }
1085
1243
 
1086
1244
  /**
1087
1245
  * `<atlas-sign-in>` — the full, multi-step sign-in flow driven entirely by the
1088
- * server-side attempt status. The Angular peer of React's `<SignIn/>`.
1246
+ * server-side attempt status. The Angular peer of React's `<SignIn/>`. Draws the
1247
+ * instance's sign-in captcha when it is required (always-on, or a
1248
+ * `needs_captcha` challenge).
1089
1249
  *
1090
1250
  * ```html
1091
1251
  * <atlas-sign-in *atlasSignedOut afterUrl="/dashboard"></atlas-sign-in>
@@ -1097,10 +1257,14 @@ class SignInComponent {
1097
1257
  afterUrl;
1098
1258
  flow = new AtlasFlowController(this.atlas.createFapiClient(), () => this.atlas.reload(),
1099
1259
  // Resume mid-flow if an OAuth redirect came back owing a second factor.
1100
- this.atlas.pendingAttempt);
1260
+ this.atlas.pendingAttempt, (key) => translate(this.atlas.localization, key));
1101
1261
  ngOnInit() {
1102
1262
  // If seeded from a pending attempt that is already pollable, start polling.
1103
1263
  this.flow.syncPolling();
1264
+ // §5 the instance's captcha settings: switching captcha on in the dashboard
1265
+ // draws it here with no code change.
1266
+ if (typeof window !== 'undefined')
1267
+ void this.flow.loadSettings();
1104
1268
  }
1105
1269
  ngOnDestroy() {
1106
1270
  this.flow.destroy();
@@ -1112,9 +1276,10 @@ class SignInComponent {
1112
1276
  [busy]="flow.state().busy"
1113
1277
  [errors]="flow.state().errors"
1114
1278
  titleKey="signIn.title"
1279
+ [captcha]="flow.captchaDrawn() ? flow.captcha : null"
1115
1280
  (submitFlow)="flow.submit($event)"
1116
1281
  ></atlas-attempt-flow>
1117
- `, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "titleKey"], outputs: ["submitFlow"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1282
+ `, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "titleKey", "captcha"], outputs: ["submitFlow"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1118
1283
  }
1119
1284
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignInComponent, decorators: [{
1120
1285
  type: Component,
@@ -1129,6 +1294,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1129
1294
  [busy]="flow.state().busy"
1130
1295
  [errors]="flow.state().errors"
1131
1296
  titleKey="signIn.title"
1297
+ [captcha]="flow.captchaDrawn() ? flow.captcha : null"
1132
1298
  (submitFlow)="flow.submit($event)"
1133
1299
  ></atlas-attempt-flow>
1134
1300
  `,
@@ -1138,9 +1304,416 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1138
1304
  }] } });
1139
1305
 
1140
1306
  /**
1141
- * `<atlas-sign-up>` — the full, server-driven sign-up flow. The Angular peer of
1142
- * React's `<SignUp/>`. It shares the exact flow engine with `<atlas-sign-in>`;
1143
- * only the title differs, because the sequence of steps is decided by the server.
1307
+ * The create-account step of `<atlas-sign-up>`: the instance's configured extra
1308
+ * fields, email + password, the legal-consent row and — when the instance
1309
+ * requires one on sign-up — the captcha widget. Values live on the component so
1310
+ * a refused submit never clears them.
1311
+ */
1312
+ class SignUpFormComponent {
1313
+ atlas = inject(AtlasService);
1314
+ settings = null;
1315
+ busy = false;
1316
+ errors = [];
1317
+ captcha = null;
1318
+ /** Emits the typed values and the consent tick when the form is submitted. */
1319
+ submitSignUp = new EventEmitter();
1320
+ values = {};
1321
+ agreed = false;
1322
+ t(key, vars) {
1323
+ return translate(this.atlas.localization, key, vars);
1324
+ }
1325
+ cls(key, base) {
1326
+ return classFor(key, this.atlas.appearance, base);
1327
+ }
1328
+ setValue(name, value) {
1329
+ this.values = { ...this.values, [name]: value };
1330
+ }
1331
+ fields() {
1332
+ return this.settings?.signUpFields ?? [];
1333
+ }
1334
+ label(f) {
1335
+ return f.required ? f.label : this.t('signUp.optionalField', { label: f.label });
1336
+ }
1337
+ inputType(f) {
1338
+ return ['email', 'tel', 'url', 'number', 'date'].includes(f.type) ? f.type : 'text';
1339
+ }
1340
+ /** Same encoding as React, the embed widget and the hosted page: one value,
1341
+ * or a comma-joined list for multiselect. */
1342
+ picked(key) {
1343
+ const value = this.values[key];
1344
+ return value ? value.split(', ') : [];
1345
+ }
1346
+ toggle(f, option, on) {
1347
+ if (f.type === 'radio') {
1348
+ this.setValue(f.key, option);
1349
+ return;
1350
+ }
1351
+ const current = this.picked(f.key);
1352
+ this.setValue(f.key, (on ? [...current, option] : current.filter((p) => p !== option)).join(', '));
1353
+ }
1354
+ showLegal() {
1355
+ const legal = this.settings?.legal;
1356
+ return !!legal && (!!legal.termsUrl || !!legal.privacyUrl);
1357
+ }
1358
+ consentOk() {
1359
+ return !this.settings?.legal?.required || this.agreed;
1360
+ }
1361
+ formErrors() {
1362
+ return this.errors.filter((error) => !error.param);
1363
+ }
1364
+ fieldErrorsFor(name) {
1365
+ return this.errors.filter((error) => error.param === name);
1366
+ }
1367
+ onSubmit(event) {
1368
+ event.preventDefault();
1369
+ if (!this.consentOk())
1370
+ return;
1371
+ this.submitSignUp.emit({
1372
+ values: { ...this.values },
1373
+ consent: this.settings?.legal?.required ? this.agreed : true,
1374
+ });
1375
+ }
1376
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1377
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: SignUpFormComponent, isStandalone: true, selector: "atlas-sign-up-form", inputs: { settings: "settings", busy: "busy", errors: "errors", captcha: "captcha" }, outputs: { submitSignUp: "submitSignUp" }, ngImport: i0, template: `
1378
+ <div [class]="cls('card', 'atlas-card')">
1379
+ <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
1380
+
1381
+ @for (error of formErrors(); track error.code) {
1382
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ error.message }}</p>
1383
+ }
1384
+
1385
+ <form (submit)="onSubmit($event)">
1386
+ @for (f of fields(); track f.key) {
1387
+ @if (f.type === 'radio' || f.type === 'multiselect') {
1388
+ <div [class]="cls('formField', 'atlas-field')">
1389
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ label(f) }}</span>
1390
+ <span class="atlas-choices">
1391
+ @for (o of f.options; track o) {
1392
+ <label class="atlas-choice">
1393
+ <input
1394
+ [type]="f.type === 'radio' ? 'radio' : 'checkbox'"
1395
+ [attr.name]="f.key"
1396
+ [value]="o"
1397
+ [checked]="picked(f.key).includes(o)"
1398
+ (change)="toggle(f, o, $any($event.target).checked)"
1399
+ />
1400
+ {{ o }}
1401
+ </label>
1402
+ }
1403
+ </span>
1404
+ @for (error of fieldErrorsFor(f.key); track error.code) {
1405
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1406
+ }
1407
+ </div>
1408
+ } @else {
1409
+ <label [class]="cls('formField', 'atlas-field')">
1410
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ label(f) }}</span>
1411
+ @if (f.type === 'checkbox') {
1412
+ <input
1413
+ type="checkbox"
1414
+ [attr.name]="f.key"
1415
+ [checked]="values[f.key] === 'true'"
1416
+ (change)="setValue(f.key, $any($event.target).checked ? 'true' : '')"
1417
+ />
1418
+ } @else if (f.type === 'select') {
1419
+ <select
1420
+ [class]="cls('formFieldInput', 'atlas-input')"
1421
+ [attr.name]="f.key"
1422
+ [value]="values[f.key] ?? ''"
1423
+ [required]="f.required"
1424
+ (change)="setValue(f.key, $any($event.target).value)"
1425
+ >
1426
+ <option value="">{{ f.placeholder ?? t('signUp.selectPlaceholder') }}</option>
1427
+ @for (o of f.options; track o) {
1428
+ <option [value]="o">{{ o }}</option>
1429
+ }
1430
+ </select>
1431
+ } @else if (f.type === 'textarea') {
1432
+ <textarea
1433
+ [class]="cls('formFieldInput', 'atlas-input')"
1434
+ [attr.name]="f.key"
1435
+ rows="3"
1436
+ [value]="values[f.key] ?? ''"
1437
+ [required]="f.required"
1438
+ [attr.placeholder]="f.placeholder"
1439
+ (input)="setValue(f.key, $any($event.target).value)"
1440
+ ></textarea>
1441
+ } @else {
1442
+ <input
1443
+ [class]="cls('formFieldInput', 'atlas-input')"
1444
+ [type]="inputType(f)"
1445
+ [attr.name]="f.key"
1446
+ [value]="values[f.key] ?? ''"
1447
+ [required]="f.required"
1448
+ [attr.placeholder]="f.placeholder"
1449
+ (input)="setValue(f.key, $any($event.target).value)"
1450
+ />
1451
+ }
1452
+ @for (error of fieldErrorsFor(f.key); track error.code) {
1453
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1454
+ }
1455
+ </label>
1456
+ }
1457
+ }
1458
+
1459
+ <label [class]="cls('formField', 'atlas-field')">
1460
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('signUp.emailLabel') }}</span>
1461
+ <input
1462
+ [class]="cls('formFieldInput', 'atlas-input')"
1463
+ type="email"
1464
+ name="email"
1465
+ autocomplete="email"
1466
+ required
1467
+ [value]="values['email'] ?? ''"
1468
+ (input)="setValue('email', $any($event.target).value)"
1469
+ />
1470
+ @for (error of fieldErrorsFor('email'); track error.code) {
1471
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1472
+ }
1473
+ </label>
1474
+
1475
+ <label [class]="cls('formField', 'atlas-field')">
1476
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('signUp.passwordLabel') }}</span>
1477
+ <input
1478
+ [class]="cls('formFieldInput', 'atlas-input')"
1479
+ type="password"
1480
+ name="password"
1481
+ autocomplete="new-password"
1482
+ required
1483
+ [value]="values['password'] ?? ''"
1484
+ (input)="setValue('password', $any($event.target).value)"
1485
+ />
1486
+ @for (error of fieldErrorsFor('password'); track error.code) {
1487
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1488
+ }
1489
+ </label>
1490
+
1491
+ @if (showLegal()) {
1492
+ <label [class]="cls('formField', 'atlas-consent')">
1493
+ @if (settings?.legal?.required) {
1494
+ <input
1495
+ type="checkbox"
1496
+ name="consent"
1497
+ [checked]="agreed"
1498
+ (change)="agreed = $any($event.target).checked"
1499
+ />
1500
+ }
1501
+ <span class="atlas-text">
1502
+ {{ t(settings?.legal?.required ? 'signUp.consentAgree' : 'signUp.consentNotice') }}
1503
+ @if (settings?.legal?.termsUrl; as terms) {
1504
+ <a class="atlas-link" [href]="terms" target="_blank" rel="noopener noreferrer">{{ t('signUp.terms') }}</a>
1505
+ }
1506
+ @if (settings?.legal?.termsUrl && settings?.legal?.privacyUrl) {
1507
+ {{ t('signUp.and') }}
1508
+ }
1509
+ @if (settings?.legal?.privacyUrl; as privacy) {
1510
+ <a class="atlas-link" [href]="privacy" target="_blank" rel="noopener noreferrer">{{ t('signUp.privacy') }}</a>
1511
+ }.
1512
+ </span>
1513
+ @for (error of fieldErrorsFor('consent'); track error.code) {
1514
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1515
+ }
1516
+ </label>
1517
+ }
1518
+
1519
+ @if (captcha) {
1520
+ <div class="atlas-captcha" data-atlas-captcha="" [atlasCaptchaSlot]="captcha"></div>
1521
+ }
1522
+
1523
+ <button
1524
+ [class]="cls('formButtonPrimary', 'atlas-button')"
1525
+ [disabled]="busy || !consentOk()"
1526
+ type="submit"
1527
+ >
1528
+ {{ t('signUp.submit') }}
1529
+ </button>
1530
+ </form>
1531
+ </div>
1532
+ `, isInline: true, dependencies: [{ kind: "directive", type: AtlasCaptchaSlotDirective, selector: "[atlasCaptchaSlot]", inputs: ["atlasCaptchaSlot"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1533
+ }
1534
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpFormComponent, decorators: [{
1535
+ type: Component,
1536
+ args: [{
1537
+ selector: 'atlas-sign-up-form',
1538
+ standalone: true,
1539
+ changeDetection: ChangeDetectionStrategy.OnPush,
1540
+ imports: [AtlasCaptchaSlotDirective],
1541
+ template: `
1542
+ <div [class]="cls('card', 'atlas-card')">
1543
+ <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
1544
+
1545
+ @for (error of formErrors(); track error.code) {
1546
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ error.message }}</p>
1547
+ }
1548
+
1549
+ <form (submit)="onSubmit($event)">
1550
+ @for (f of fields(); track f.key) {
1551
+ @if (f.type === 'radio' || f.type === 'multiselect') {
1552
+ <div [class]="cls('formField', 'atlas-field')">
1553
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ label(f) }}</span>
1554
+ <span class="atlas-choices">
1555
+ @for (o of f.options; track o) {
1556
+ <label class="atlas-choice">
1557
+ <input
1558
+ [type]="f.type === 'radio' ? 'radio' : 'checkbox'"
1559
+ [attr.name]="f.key"
1560
+ [value]="o"
1561
+ [checked]="picked(f.key).includes(o)"
1562
+ (change)="toggle(f, o, $any($event.target).checked)"
1563
+ />
1564
+ {{ o }}
1565
+ </label>
1566
+ }
1567
+ </span>
1568
+ @for (error of fieldErrorsFor(f.key); track error.code) {
1569
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1570
+ }
1571
+ </div>
1572
+ } @else {
1573
+ <label [class]="cls('formField', 'atlas-field')">
1574
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ label(f) }}</span>
1575
+ @if (f.type === 'checkbox') {
1576
+ <input
1577
+ type="checkbox"
1578
+ [attr.name]="f.key"
1579
+ [checked]="values[f.key] === 'true'"
1580
+ (change)="setValue(f.key, $any($event.target).checked ? 'true' : '')"
1581
+ />
1582
+ } @else if (f.type === 'select') {
1583
+ <select
1584
+ [class]="cls('formFieldInput', 'atlas-input')"
1585
+ [attr.name]="f.key"
1586
+ [value]="values[f.key] ?? ''"
1587
+ [required]="f.required"
1588
+ (change)="setValue(f.key, $any($event.target).value)"
1589
+ >
1590
+ <option value="">{{ f.placeholder ?? t('signUp.selectPlaceholder') }}</option>
1591
+ @for (o of f.options; track o) {
1592
+ <option [value]="o">{{ o }}</option>
1593
+ }
1594
+ </select>
1595
+ } @else if (f.type === 'textarea') {
1596
+ <textarea
1597
+ [class]="cls('formFieldInput', 'atlas-input')"
1598
+ [attr.name]="f.key"
1599
+ rows="3"
1600
+ [value]="values[f.key] ?? ''"
1601
+ [required]="f.required"
1602
+ [attr.placeholder]="f.placeholder"
1603
+ (input)="setValue(f.key, $any($event.target).value)"
1604
+ ></textarea>
1605
+ } @else {
1606
+ <input
1607
+ [class]="cls('formFieldInput', 'atlas-input')"
1608
+ [type]="inputType(f)"
1609
+ [attr.name]="f.key"
1610
+ [value]="values[f.key] ?? ''"
1611
+ [required]="f.required"
1612
+ [attr.placeholder]="f.placeholder"
1613
+ (input)="setValue(f.key, $any($event.target).value)"
1614
+ />
1615
+ }
1616
+ @for (error of fieldErrorsFor(f.key); track error.code) {
1617
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1618
+ }
1619
+ </label>
1620
+ }
1621
+ }
1622
+
1623
+ <label [class]="cls('formField', 'atlas-field')">
1624
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('signUp.emailLabel') }}</span>
1625
+ <input
1626
+ [class]="cls('formFieldInput', 'atlas-input')"
1627
+ type="email"
1628
+ name="email"
1629
+ autocomplete="email"
1630
+ required
1631
+ [value]="values['email'] ?? ''"
1632
+ (input)="setValue('email', $any($event.target).value)"
1633
+ />
1634
+ @for (error of fieldErrorsFor('email'); track error.code) {
1635
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1636
+ }
1637
+ </label>
1638
+
1639
+ <label [class]="cls('formField', 'atlas-field')">
1640
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('signUp.passwordLabel') }}</span>
1641
+ <input
1642
+ [class]="cls('formFieldInput', 'atlas-input')"
1643
+ type="password"
1644
+ name="password"
1645
+ autocomplete="new-password"
1646
+ required
1647
+ [value]="values['password'] ?? ''"
1648
+ (input)="setValue('password', $any($event.target).value)"
1649
+ />
1650
+ @for (error of fieldErrorsFor('password'); track error.code) {
1651
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1652
+ }
1653
+ </label>
1654
+
1655
+ @if (showLegal()) {
1656
+ <label [class]="cls('formField', 'atlas-consent')">
1657
+ @if (settings?.legal?.required) {
1658
+ <input
1659
+ type="checkbox"
1660
+ name="consent"
1661
+ [checked]="agreed"
1662
+ (change)="agreed = $any($event.target).checked"
1663
+ />
1664
+ }
1665
+ <span class="atlas-text">
1666
+ {{ t(settings?.legal?.required ? 'signUp.consentAgree' : 'signUp.consentNotice') }}
1667
+ @if (settings?.legal?.termsUrl; as terms) {
1668
+ <a class="atlas-link" [href]="terms" target="_blank" rel="noopener noreferrer">{{ t('signUp.terms') }}</a>
1669
+ }
1670
+ @if (settings?.legal?.termsUrl && settings?.legal?.privacyUrl) {
1671
+ {{ t('signUp.and') }}
1672
+ }
1673
+ @if (settings?.legal?.privacyUrl; as privacy) {
1674
+ <a class="atlas-link" [href]="privacy" target="_blank" rel="noopener noreferrer">{{ t('signUp.privacy') }}</a>
1675
+ }.
1676
+ </span>
1677
+ @for (error of fieldErrorsFor('consent'); track error.code) {
1678
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
1679
+ }
1680
+ </label>
1681
+ }
1682
+
1683
+ @if (captcha) {
1684
+ <div class="atlas-captcha" data-atlas-captcha="" [atlasCaptchaSlot]="captcha"></div>
1685
+ }
1686
+
1687
+ <button
1688
+ [class]="cls('formButtonPrimary', 'atlas-button')"
1689
+ [disabled]="busy || !consentOk()"
1690
+ type="submit"
1691
+ >
1692
+ {{ t('signUp.submit') }}
1693
+ </button>
1694
+ </form>
1695
+ </div>
1696
+ `,
1697
+ }]
1698
+ }], propDecorators: { settings: [{
1699
+ type: Input
1700
+ }], busy: [{
1701
+ type: Input
1702
+ }], errors: [{
1703
+ type: Input
1704
+ }], captcha: [{
1705
+ type: Input
1706
+ }], submitSignUp: [{
1707
+ type: Output
1708
+ }] } });
1709
+
1710
+ /**
1711
+ * `<atlas-sign-up>` — creates an account. The Angular peer of React's
1712
+ * `<SignUp/>`. The first step is the create-account form (the instance's
1713
+ * configured fields, email, password, legal consent and — when required — the
1714
+ * captcha), which POSTs `/v1/client/sign_ups`; from there it shares the flow
1715
+ * engine with `<atlas-sign-in>` (the emailed-code step, then the ticket
1716
+ * exchange), because the sequence of steps is decided by the server.
1144
1717
  *
1145
1718
  * ```html
1146
1719
  * <atlas-sign-up *atlasSignedOut></atlas-sign-up>
@@ -1150,20 +1723,35 @@ class SignUpComponent {
1150
1723
  atlas = inject(AtlasService);
1151
1724
  /** Where to send the user once the flow completes. Reserved for host routing. */
1152
1725
  afterUrl;
1153
- flow = new AtlasFlowController(this.atlas.createFapiClient(), () => this.atlas.reload(), this.atlas.pendingAttempt);
1726
+ flow = new AtlasFlowController(this.atlas.createFapiClient(), () => this.atlas.reload(), this.atlas.pendingAttempt, (key) => translate(this.atlas.localization, key));
1727
+ ngOnInit() {
1728
+ // §5 the instance's sign-up fields, consent policy and captcha settings.
1729
+ if (typeof window !== 'undefined')
1730
+ void this.flow.loadSettings();
1731
+ }
1154
1732
  ngOnDestroy() {
1155
1733
  this.flow.destroy();
1156
1734
  }
1157
1735
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1158
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: SignUpComponent, isStandalone: true, selector: "atlas-sign-up", inputs: { afterUrl: "afterUrl" }, ngImport: i0, template: `
1159
- <atlas-attempt-flow
1160
- [attempt]="flow.state().attempt"
1161
- [busy]="flow.state().busy"
1162
- [errors]="flow.state().errors"
1163
- titleKey="signUp.title"
1164
- (submitFlow)="flow.submit($event)"
1165
- ></atlas-attempt-flow>
1166
- `, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "titleKey"], outputs: ["submitFlow"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1736
+ 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: `
1737
+ @if (flow.state().attempt) {
1738
+ <atlas-attempt-flow
1739
+ [attempt]="flow.state().attempt"
1740
+ [busy]="flow.state().busy"
1741
+ [errors]="flow.state().errors"
1742
+ titleKey="signUp.title"
1743
+ (submitFlow)="flow.submit($event)"
1744
+ ></atlas-attempt-flow>
1745
+ } @else {
1746
+ <atlas-sign-up-form
1747
+ [settings]="flow.settings()"
1748
+ [busy]="flow.state().busy"
1749
+ [errors]="flow.state().errors"
1750
+ [captcha]="flow.signUpCaptchaDrawn() ? flow.signUpCaptcha : null"
1751
+ (submitSignUp)="flow.create($event.values, $event.consent)"
1752
+ ></atlas-sign-up-form>
1753
+ }
1754
+ `, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "titleKey", "captcha"], outputs: ["submitFlow"] }, { kind: "component", type: SignUpFormComponent, selector: "atlas-sign-up-form", inputs: ["settings", "busy", "errors", "captcha"], outputs: ["submitSignUp"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1167
1755
  }
1168
1756
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpComponent, decorators: [{
1169
1757
  type: Component,
@@ -1171,15 +1759,25 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1171
1759
  selector: 'atlas-sign-up',
1172
1760
  standalone: true,
1173
1761
  changeDetection: ChangeDetectionStrategy.OnPush,
1174
- imports: [AttemptFlowComponent],
1762
+ imports: [AttemptFlowComponent, SignUpFormComponent],
1175
1763
  template: `
1176
- <atlas-attempt-flow
1177
- [attempt]="flow.state().attempt"
1178
- [busy]="flow.state().busy"
1179
- [errors]="flow.state().errors"
1180
- titleKey="signUp.title"
1181
- (submitFlow)="flow.submit($event)"
1182
- ></atlas-attempt-flow>
1764
+ @if (flow.state().attempt) {
1765
+ <atlas-attempt-flow
1766
+ [attempt]="flow.state().attempt"
1767
+ [busy]="flow.state().busy"
1768
+ [errors]="flow.state().errors"
1769
+ titleKey="signUp.title"
1770
+ (submitFlow)="flow.submit($event)"
1771
+ ></atlas-attempt-flow>
1772
+ } @else {
1773
+ <atlas-sign-up-form
1774
+ [settings]="flow.settings()"
1775
+ [busy]="flow.state().busy"
1776
+ [errors]="flow.state().errors"
1777
+ [captcha]="flow.signUpCaptchaDrawn() ? flow.signUpCaptcha : null"
1778
+ (submitSignUp)="flow.create($event.values, $event.consent)"
1779
+ ></atlas-sign-up-form>
1780
+ }
1183
1781
  `,
1184
1782
  }]
1185
1783
  }], propDecorators: { afterUrl: [{
@@ -1630,5 +2228,5 @@ function atlasChildGuard(options = {}) {
1630
2228
  * Generated bundle index. Do not edit.
1631
2229
  */
1632
2230
 
1633
- export { ATLAS_OPTIONS, ATLAS_UI, AtlasFlowController, AtlasLoadedDirective, AtlasLoadingDirective, AtlasModule, AtlasProtectDirective, AtlasRootDirective, AtlasService, AtlasSignedInDirective, AtlasSignedOutDirective, AttemptFlowComponent, DEFAULT_TOKENS, EN_US, OrganizationProfileComponent, OrganizationSwitcherComponent, SignInComponent, SignUpComponent, THEMES, UserButtonComponent, UserProfileComponent, atlasChildGuard, atlasGuard, classFor, cssVariables, explicitTokens, provideAtlas, renderAppearance, resolveTokens, translate, withFallback };
2231
+ 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 };
1634
2232
  //# sourceMappingURL=atlasauth-angular.mjs.map