@atlasauth/angular 0.8.0 → 0.9.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/esm2022/lib/appearance.mjs +1 -1
  3. package/dist/esm2022/lib/atlas.service.mjs +39 -2
  4. package/dist/esm2022/lib/atlas.ui.mjs +3 -1
  5. package/dist/esm2022/lib/components/attempt-flow.component.mjs +384 -8
  6. package/dist/esm2022/lib/components/flow-controller.mjs +115 -2
  7. package/dist/esm2022/lib/components/impersonation-banner.component.mjs +75 -0
  8. package/dist/esm2022/lib/components/organization-audit-log.component.mjs +115 -0
  9. package/dist/esm2022/lib/components/organization-audit-log.mjs +40 -0
  10. package/dist/esm2022/lib/components/organization-profile.component.mjs +602 -27
  11. package/dist/esm2022/lib/components/recovery-codes.component.mjs +122 -0
  12. package/dist/esm2022/lib/components/sign-in.component.mjs +45 -4
  13. package/dist/esm2022/lib/components/sign-up-form.component.mjs +69 -6
  14. package/dist/esm2022/lib/components/sign-up.component.mjs +81 -7
  15. package/dist/esm2022/lib/i18n.mjs +58 -1
  16. package/dist/esm2022/lib/provide-atlas.mjs +3 -2
  17. package/dist/esm2022/lib/types.mjs +1 -1
  18. package/dist/esm2022/lib/version.mjs +2 -2
  19. package/dist/esm2022/public-api.mjs +4 -1
  20. package/dist/fesm2022/atlasauth-angular.mjs +1711 -46
  21. package/dist/fesm2022/atlasauth-angular.mjs.map +1 -1
  22. package/dist/lib/appearance.d.ts +1 -1
  23. package/dist/lib/atlas.service.d.ts +22 -1
  24. package/dist/lib/atlas.ui.d.ts +2 -1
  25. package/dist/lib/components/attempt-flow.component.d.ts +57 -2
  26. package/dist/lib/components/flow-controller.d.ts +64 -1
  27. package/dist/lib/components/impersonation-banner.component.d.ts +27 -0
  28. package/dist/lib/components/organization-audit-log.component.d.ts +34 -0
  29. package/dist/lib/components/organization-audit-log.d.ts +25 -0
  30. package/dist/lib/components/organization-profile.component.d.ts +55 -6
  31. package/dist/lib/components/recovery-codes.component.d.ts +26 -0
  32. package/dist/lib/components/sign-in.component.d.ts +2 -0
  33. package/dist/lib/components/sign-up-form.component.d.ts +11 -1
  34. package/dist/lib/components/sign-up.component.d.ts +13 -0
  35. package/dist/lib/provide-atlas.d.ts +2 -1
  36. package/dist/lib/types.d.ts +6 -0
  37. package/dist/lib/version.d.ts +1 -1
  38. package/dist/public-api.d.ts +4 -0
  39. package/package.json +3 -3
@@ -1,10 +1,10 @@
1
1
  import * as i0 from '@angular/core';
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';
2
+ import { InjectionToken, signal, inject, PLATFORM_ID, computed, Injectable, TemplateRef, ViewContainerRef, Directive, Input, ElementRef, Renderer2, EventEmitter, Output, ChangeDetectionStrategy, Component, ChangeDetectorRef, effect, untracked, DestroyRef, 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
5
  import { BehaviorSubject, Subject, combineLatest, firstValueFrom } from 'rxjs';
6
6
  import { map, distinctUntilChanged, filter, take } from 'rxjs/operators';
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';
7
+ import { createOrganizationAuditLog, TokenCache, SignOutNotifier, resolveFrontendApi, withDevBrowser, catalogLocale, configureReferralCapture, FapiClient, SignOutGuard, localizedErrorMessage, reauthenticate, getTemplateToken, getProviderToken, signOutReasonFromClient, readRedirectResult, completeOAuthPopupLanding, mapOAuthPopupPayload, stripRedirectParams, closeOAuthPopupAfterSignIn, SDK_HEADER, sdkHeaderValue, reportSdkAdvisory, readExpiry, msUntilRefresh, groupSetupKey, enrollmentSubmitKey, nextStep, awaitingSignUpLink, canDownloadRecoveryCodes, copyRecoveryCodes, downloadRecoveryCodes, createCaptchaController, atlasCaptchaLabels, flowFromPending, initialFlow, createMfaEnrollment, mfaChoicesFrom, fetchInstanceSettings, fetchLastSignInMethod, offeredFirstFactors, prepareFactor, signInCaptchaOptions, flowNeedsCaptcha, planFirstFactor, advanceWithCaptcha, createSignUp, prepareSecondFactorCode, resendSignUpVerification, createPasskeySignUp, finishPasskeySignUp, pendingRecoveryCodes, acknowledgeRecoveryCodes, shouldKeepPolling, pollAttempt, lastUsedMethodLabel, signUp, passkeysSupported, awaitingPasskey, OrganizationProfileController, describeAuditAction, actorFromClaims } 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';
@@ -19,6 +19,43 @@ import { Router } from '@angular/router';
19
19
  */
20
20
  const ATLAS_OPTIONS = new InjectionToken('ATLAS_OPTIONS');
21
21
 
22
+ /**
23
+ * The active organization's audit trail, for its admins — the engine behind
24
+ * `<atlas-organization-audit-log>` and `AtlasService.organizationAuditLog()`
25
+ * (the Angular peer of React's `useOrganizationAuditLog`). `state` is a signal
26
+ * of `{ entries, loading, forbidden, hasMore }`, one page at a time, newest
27
+ * first. It follows the active organization until {@link destroy}.
28
+ */
29
+ class AtlasOrganizationAuditLog {
30
+ _state;
31
+ log;
32
+ unsubscribe;
33
+ organizationSub;
34
+ state;
35
+ constructor(client, organization$, options = {}) {
36
+ this.log = createOrganizationAuditLog(client, options);
37
+ this._state = signal(this.log.getState());
38
+ this.state = this._state.asReadonly();
39
+ this.unsubscribe = this.log.subscribe((next) => this._state.set(next));
40
+ this.organizationSub = organization$
41
+ .pipe(map((membership) => membership?.organization.id ?? null), distinctUntilChanged())
42
+ .subscribe((id) => void this.log.load(id));
43
+ }
44
+ /** Append the next page, when there is one. */
45
+ loadMore() {
46
+ return this.log.loadMore();
47
+ }
48
+ /** Re-read from the newest entry. */
49
+ reload() {
50
+ return this.log.reload();
51
+ }
52
+ /** Stop following the active organization. */
53
+ destroy() {
54
+ this.organizationSub.unsubscribe();
55
+ this.unsubscribe();
56
+ }
57
+ }
58
+
22
59
  /**
23
60
  * §10.3 theming.
24
61
  *
@@ -163,6 +200,7 @@ const EN_US = {
163
200
  'signIn.submit': 'Continue',
164
201
  'signIn.forgotPassword': 'Forgot password?',
165
202
  'signIn.noAccount': 'No account?',
203
+ 'signIn.lastUsed': 'Last used',
166
204
  'signIn.signUpLink': 'Sign up',
167
205
  'signIn.orDivider': 'or',
168
206
  'signIn.socialButton': 'Continue with {provider}',
@@ -170,6 +208,8 @@ const EN_US = {
170
208
  'signIn.useEmailCode': 'Email me a code',
171
209
  'signIn.codeSentTo': 'We sent a code to {email}. Enter it below.',
172
210
  'signIn.resendCode': 'Resend code',
211
+ 'signIn.sending': 'Sending…',
212
+ 'signIn.emailCodeOption': 'Email me a code instead',
173
213
  'signIn.magicLinkSent': 'Check your email for a sign-in link.',
174
214
  'signIn.signedIn': 'You\'re signed in.',
175
215
  '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.',
@@ -189,6 +229,13 @@ const EN_US = {
189
229
  'signUp.and': 'and',
190
230
  // = EMAIL_UNAVAILABLE_MESSAGE from @atlasauth/js.
191
231
  'signUp.emailUnavailable': "This app can't send email right now. Try another sign-in method or contact the app owner.",
232
+ 'signUp.passkeyOption': 'Sign up with a passkey instead',
233
+ 'signUp.passwordOption': 'Sign up with a password instead',
234
+ 'signUp.passkeyIntro': "No password needed: you'll sign in with a passkey saved on this device or in your password manager.",
235
+ 'signUp.passkeyPrompt': 'Create a passkey to finish setting up your account.',
236
+ 'signUp.passkeyCreate': 'Create passkey',
237
+ 'signUp.passkeyCancelled': "The passkey wasn't created. Try again when you're ready.",
238
+ 'signUp.linkSentTo': 'We sent a confirmation link to {email}. Open it on any device; this page continues on its own.',
192
239
  'captcha.prompt': 'Complete the security check to continue.',
193
240
  'captcha.required': 'Please complete the security check, then try again.',
194
241
  'captcha.failed': "The security check didn't pass. Please try again.",
@@ -206,6 +253,24 @@ const EN_US = {
206
253
  'mfa.usePasskey': 'Use a passkey instead',
207
254
  'mfa.useBackupCode': 'Use a recovery code',
208
255
  'mfa.submit': 'Verify',
256
+ 'mfa.enrollChoose': 'Set up a second step for signing in.',
257
+ 'mfa.enrollTotpOption': 'Use an authenticator app',
258
+ 'mfa.enrollSmsOption': 'Use text messages',
259
+ 'mfa.enrollScan': 'Scan this QR code with your authenticator app, or enter the setup key.',
260
+ 'mfa.enrollQrAlt': 'QR code for your authenticator app',
261
+ 'mfa.enrollSetupKey': 'Setup key',
262
+ 'mfa.enrollPreparing': 'Setting up…',
263
+ 'mfa.phoneLabel': 'Mobile number',
264
+ 'mfa.phoneHint': 'Include the country code, for example +14155550101.',
265
+ 'mfa.sendCode': 'Text me a code',
266
+ 'mfa.codeTextedTo': 'Enter the code we texted to {phone}.',
267
+ 'mfa.smsCodeOption': 'Text me a code instead',
268
+ 'mfa.recoveryCodesTitle': 'Save your recovery codes',
269
+ 'mfa.recoveryCodesHelp': "Each code works once. If you can't use your second step, sign in with one of these. Keep them somewhere safe: they won't be shown again.",
270
+ 'mfa.recoveryCodesCopy': 'Copy codes',
271
+ 'mfa.recoveryCodesCopied': 'Copied.',
272
+ 'mfa.recoveryCodesDownload': 'Download as .txt',
273
+ 'mfa.recoveryCodesSaved': "I've saved my codes",
209
274
  'reset.title': 'Reset your password',
210
275
  'reset.submit': 'Send reset code',
211
276
  'reset.newPasswordLabel': 'New password',
@@ -233,6 +298,35 @@ const EN_US = {
233
298
  'organization.members': 'Members',
234
299
  'organization.invite': 'Invite member',
235
300
  'organization.roleLabel': 'Role',
301
+ 'organization.invitations': 'Invitations',
302
+ 'organization.settings': 'Settings',
303
+ 'organization.emailLabel': 'Email address',
304
+ 'organization.sendInvite': 'Send invitation',
305
+ 'organization.noInvitations': 'No pending invitations.',
306
+ 'organization.remove': 'Remove',
307
+ 'organization.revoke': 'Revoke',
308
+ 'organization.resend': 'Resend',
309
+ 'organization.you': '(you)',
310
+ 'organization.leave': 'Leave organization',
311
+ 'organization.leaveConfirm': 'Leave {name}? You will lose access until you are invited again.',
312
+ 'organization.delete': 'Delete organization',
313
+ 'organization.deleteConfirm': 'Delete {name} for every member? This cannot be undone.',
314
+ 'organization.confirm': 'Confirm',
315
+ 'organization.cancel': 'Cancel',
316
+ 'organization.unnamedMember': 'Unnamed member',
317
+ 'organization.passwordPrompt': 'Enter your password to confirm.',
318
+ 'organization.roles': 'Roles',
319
+ 'organization.roleName': 'Role name',
320
+ 'organization.roleKey': 'Role key (for example org:auditor)',
321
+ 'organization.rolePermissions': 'Permissions',
322
+ 'organization.createRole': 'Create role',
323
+ 'organization.saveRole': 'Save role',
324
+ 'organization.editRole': 'Edit',
325
+ 'organization.deleteRole': 'Delete',
326
+ 'organization.noCustomRoles': 'This organization has no roles of its own yet.',
327
+ 'impersonation.banner': 'You are signed in as {name} on someone else\'s behalf.',
328
+ 'impersonation.returnToAdmin': 'Return to admin',
329
+ 'impersonation.end': 'End impersonation',
236
330
  'error.generic': 'Something went wrong. Please try again.',
237
331
  'error.network': 'We could not reach the server. Check your connection.',
238
332
  // Server errors, by the stable `reason` the Frontend API sends. Shown instead
@@ -293,7 +387,7 @@ function withFallback(locale) {
293
387
  }
294
388
 
295
389
  /** This package's version, sent as `X-Atlas-SDK` (kept in step with package.json by a test). */
296
- const ATLAS_ANGULAR_VERSION = '0.8.0';
390
+ const ATLAS_ANGULAR_VERSION = '0.9.0';
297
391
  const ATLAS_ANGULAR_SDK = { name: '@atlasauth/angular', version: ATLAS_ANGULAR_VERSION };
298
392
 
299
393
  /**
@@ -507,6 +601,23 @@ class AtlasService {
507
601
  return null;
508
602
  }
509
603
  }
604
+ /**
605
+ * The active organization's audit trail, for its admins: `state` is a signal
606
+ * of `{ entries, loading, forbidden, hasMore }`, one page at a time with
607
+ * `loadMore()`. `action` narrows it (exact, or a prefix ending in `.`). It
608
+ * follows the active organization; call `destroy()` when done. The peer of
609
+ * React's `useOrganizationAuditLog`.
610
+ */
611
+ organizationAuditLog(options = {}) {
612
+ return new AtlasOrganizationAuditLog(this.createFapiClient(), this.organization$, options);
613
+ }
614
+ /**
615
+ * Re-authenticate the signed-in user with their password (a step-up), for an
616
+ * action the server answered with STEP_UP_REQUIRED. Rejects when refused.
617
+ */
618
+ reauthenticate(password) {
619
+ return reauthenticate({ strategy: 'password', password }, { api: this.baseUrl, publishableKey: this.publishableKey, fetchImpl: this.doFetch, locale: this.requestLocale });
620
+ }
510
621
  /** A fresh {@link FapiClient} for driving a sign-in/sign-up flow. */
511
622
  createFapiClient() {
512
623
  return new FapiClient({
@@ -574,6 +685,25 @@ class AtlasService {
574
685
  await this.reload();
575
686
  return this.guard.settle(attempt, options);
576
687
  }
688
+ /**
689
+ * End the active impersonation ("return to admin"): the impersonator's own
690
+ * session on this browser becomes active again with no sign-in
691
+ * (`resumed: true`); with none, another account signed in here is adopted,
692
+ * or this browser is signed out.
693
+ */
694
+ async endImpersonation() {
695
+ const outcome = await this.guard.endImpersonation(this.cache.peek()?.sessionId ?? this._session.value?.id ?? null);
696
+ if (!outcome.ended)
697
+ return outcome;
698
+ if (!outcome.activeSessionId) {
699
+ this.clearLocalState('user');
700
+ return outcome;
701
+ }
702
+ if (outcome.jwt)
703
+ this.applyToken(outcome.jwt, outcome.activeSessionId);
704
+ await this.reload();
705
+ return outcome;
706
+ }
577
707
  /**
578
708
  * Switch the active organization (or `null` for the personal account). The
579
709
  * new token carries the new org claims, so the app's permission checks update
@@ -1105,6 +1235,38 @@ class AttemptFlowComponent {
1105
1235
  submitFlow = new EventEmitter();
1106
1236
  /** Emits when the user picks a first factor the server must send first (an emailed code). */
1107
1237
  chooseFactor = new EventEmitter();
1238
+ /**
1239
+ * Offer "Email me a code instead" at the second factor (the instance's
1240
+ * `account.emailSecondFactor`); `emailSecondFactor` asks for it and
1241
+ * `emailedSecondFactor` reports how it went.
1242
+ */
1243
+ offerEmailSecondFactor = false;
1244
+ /** `AtlasFlowController.emailSecondFactor()`: in flight, where it went, or why not. */
1245
+ emailedSecondFactor = null;
1246
+ emailSecondFactor = new EventEmitter();
1247
+ /**
1248
+ * Offer "Text me a code instead" at the second factor (where the app offers
1249
+ * texted codes, `AtlasFlowController.mfaChoices().sms`); `smsSecondFactor`
1250
+ * asks for it and `textedSecondFactor` reports how it went.
1251
+ */
1252
+ offerSmsSecondFactor = false;
1253
+ /** `AtlasFlowController.smsSecondFactor()`: in flight, where it went, or why not. */
1254
+ textedSecondFactor = null;
1255
+ smsSecondFactor = new EventEmitter();
1256
+ /**
1257
+ * The MFA enrolment step (`AtlasFlowController.enrollment` and
1258
+ * `enrollmentState()`): the authenticator app's QR code and setup key, or a
1259
+ * texted code. Null: the step asks for two authenticator codes only.
1260
+ */
1261
+ enrollment = null;
1262
+ enrollmentState = null;
1263
+ /**
1264
+ * The instance's `account.emailVerification`. On `link` a sign-up's
1265
+ * verification step has nothing to type: it says to open the emailed link
1266
+ * while the flow polls, and offers a resend (`resend`).
1267
+ */
1268
+ emailVerification = 'code';
1269
+ resend = new EventEmitter();
1108
1270
  values = {};
1109
1271
  t(key, vars) {
1110
1272
  return translate(this.atlas.localization, key, vars);
@@ -1123,8 +1285,45 @@ class AttemptFlowComponent {
1123
1285
  }
1124
1286
  onSubmit(event) {
1125
1287
  event.preventDefault();
1288
+ // Enrolment: text the code to the typed number, or confirm the factor.
1289
+ if (this.enrolling() && this.enrollment) {
1290
+ void this.enrollment.submit(this.values).then((next) => {
1291
+ if (next)
1292
+ this.submitFlow.emit(next);
1293
+ });
1294
+ return;
1295
+ }
1126
1296
  this.submitFlow.emit(this.values);
1127
1297
  }
1298
+ /** The shared enrolment step is drawn (a controller was passed and the flow is enrolling). */
1299
+ enrolling() {
1300
+ return this.step().kind === 'enroll_second_factor' && !!this.enrollment && !!this.enrollmentState;
1301
+ }
1302
+ chooseMethod(method) {
1303
+ this.values = {};
1304
+ void this.enrollment?.choose(method);
1305
+ }
1306
+ resendEnrollment() {
1307
+ void this.enrollment?.resend();
1308
+ }
1309
+ otherMethods(state) {
1310
+ return state.options.filter((m) => m !== state.method);
1311
+ }
1312
+ setupKey(secret) {
1313
+ return groupSetupKey(secret);
1314
+ }
1315
+ /** The submit button's label key (enrolment: "Text me a code" before the number is sent). */
1316
+ submitKey() {
1317
+ return (this.enrolling() && this.enrollmentState && enrollmentSubmitKey(this.enrollmentState)) || 'signIn.submit';
1318
+ }
1319
+ showSmsSecondFactor() {
1320
+ return this.offerSmsSecondFactor && this.step().kind === 'collect_second_factor';
1321
+ }
1322
+ /** Where the texted second-factor code went (masked), once it was sent. */
1323
+ smsSecondFactorSentTo() {
1324
+ const sent = this.textedSecondFactor?.sentTo;
1325
+ return typeof sent === 'string' ? sent || ' ' : null;
1326
+ }
1128
1327
  formErrors() {
1129
1328
  return this.errors.filter((error) => !error.param);
1130
1329
  }
@@ -1145,6 +1344,17 @@ class AttemptFlowComponent {
1145
1344
  return [];
1146
1345
  return this.firstFactors ?? step.strategies;
1147
1346
  }
1347
+ linkOnly() {
1348
+ return awaitingSignUpLink(this.attempt, this.emailVerification);
1349
+ }
1350
+ showEmailSecondFactor() {
1351
+ return this.offerEmailSecondFactor && this.step().kind === 'collect_second_factor';
1352
+ }
1353
+ /** Where the emailed second-factor code went (masked), once it was sent. */
1354
+ emailSecondFactorSentTo() {
1355
+ const sent = this.emailedSecondFactor?.sentTo;
1356
+ return typeof sent === 'string' ? sent || ' ' : null;
1357
+ }
1148
1358
  showEmailCodeOption() {
1149
1359
  // Before the code is sent: the choice (or the retry of a failed automatic
1150
1360
  // send). After: "Resend code".
@@ -1162,6 +1372,11 @@ class AttemptFlowComponent {
1162
1372
  // until that code is sent.
1163
1373
  if (kind === 'collect_first_factor')
1164
1374
  return this.fields().length > 0;
1375
+ if (this.linkOnly())
1376
+ return false;
1377
+ // Enrolment: nothing to submit while choosing a method or fetching the setup.
1378
+ if (this.enrolling() && this.enrollmentState && !enrollmentSubmitKey(this.enrollmentState))
1379
+ return false;
1165
1380
  return kind !== 'done' && kind !== 'await_oauth';
1166
1381
  }
1167
1382
  fields() {
@@ -1183,6 +1398,9 @@ class AttemptFlowComponent {
1183
1398
  case 'collect_second_factor':
1184
1399
  return [{ kind: 'input', name: 'code', labelKey: 'mfa.codeLabel', type: 'text' }];
1185
1400
  case 'enroll_second_factor':
1401
+ // The shared enrolment step draws its own fields (above).
1402
+ if (this.enrolling())
1403
+ return [];
1186
1404
  // Two consecutive codes (§5.6): a phone with a fast clock fails here
1187
1405
  // rather than on every sign-in afterwards.
1188
1406
  return [
@@ -1190,7 +1408,7 @@ class AttemptFlowComponent {
1190
1408
  { kind: 'input', name: 'secondCode', labelKey: 'mfa.enrollSecondCodeLabel', type: 'text' },
1191
1409
  ];
1192
1410
  case 'collect_email_code':
1193
- return [{ kind: 'input', name: 'code', labelKey: 'signIn.emailCodeLabel', type: 'text' }];
1411
+ return this.linkOnly() ? [] : [{ kind: 'input', name: 'code', labelKey: 'signIn.emailCodeLabel', type: 'text' }];
1194
1412
  case 'collect_new_password':
1195
1413
  return [{ kind: 'input', name: 'password', labelKey: 'reset.newPasswordLabel', type: 'password' }];
1196
1414
  case 'collect_captcha':
@@ -1216,7 +1434,7 @@ class AttemptFlowComponent {
1216
1434
  }
1217
1435
  }
1218
1436
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AttemptFlowComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
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: `
1437
+ 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", offerEmailSecondFactor: "offerEmailSecondFactor", emailedSecondFactor: "emailedSecondFactor", offerSmsSecondFactor: "offerSmsSecondFactor", textedSecondFactor: "textedSecondFactor", enrollment: "enrollment", enrollmentState: "enrollmentState", emailVerification: "emailVerification" }, outputs: { submitFlow: "submitFlow", chooseFactor: "chooseFactor", emailSecondFactor: "emailSecondFactor", smsSecondFactor: "smsSecondFactor", resend: "resend" }, ngImport: i0, template: `
1220
1438
  <div [class]="cls('card', 'atlas-card')">
1221
1439
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t(titleKey) }}</h1>
1222
1440
 
@@ -1228,6 +1446,98 @@ class AttemptFlowComponent {
1228
1446
  @if (codeSentText(); as sentText) {
1229
1447
  <p class="atlas-text" data-atlas-code-sent="">{{ sentText }}</p>
1230
1448
  }
1449
+ @if (linkOnly()) {
1450
+ <!-- Nothing to type: the flow polls until the emailed link is opened (anywhere). -->
1451
+ <p class="atlas-text" data-atlas-link-sent="" aria-live="polite">
1452
+ {{ t('signUp.linkSentTo', { email: attempt?.identifier ?? '' }) }}
1453
+ </p>
1454
+ <button
1455
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1456
+ type="button"
1457
+ data-atlas-resend=""
1458
+ [disabled]="busy"
1459
+ (click)="resend.emit()"
1460
+ >
1461
+ {{ t('signIn.resendLink') }}
1462
+ </button>
1463
+ }
1464
+ @if (enrolling() && enrollmentState; as enrol) {
1465
+ @if (enrol.method === null) {
1466
+ <p class="atlas-text">{{ t('mfa.enrollChoose') }}</p>
1467
+ @for (m of enrol.options; track m) {
1468
+ <button
1469
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1470
+ type="button"
1471
+ [attr.data-atlas-mfa-method]="m"
1472
+ [disabled]="busy || enrol.busy"
1473
+ (click)="chooseMethod(m)"
1474
+ >
1475
+ {{ t(m === 'totp' ? 'mfa.enrollTotpOption' : 'mfa.enrollSmsOption') }}
1476
+ </button>
1477
+ }
1478
+ } @else if (enrol.method === 'totp') {
1479
+ @if (enrol.enrollment; as setup) {
1480
+ <p class="atlas-text">{{ t('mfa.enrollScan') }}</p>
1481
+ @if (setup.qrCode) {
1482
+ <img class="atlas-mfa-qr" data-atlas="mfa-qr" [src]="setup.qrCode" [alt]="t('mfa.enrollQrAlt')" width="180" height="180" />
1483
+ }
1484
+ @if (setup.secret) {
1485
+ <p class="atlas-text" data-atlas="mfa-setup-key">{{ t('mfa.enrollSetupKey') }}: <code>{{ setupKey(setup.secret) }}</code></p>
1486
+ }
1487
+ <label class="atlas-field">
1488
+ <span>{{ t('mfa.enrollFirstCodeLabel') }}</span>
1489
+ <input [class]="cls('formFieldInput', 'atlas-input')" type="text" name="code" autocomplete="one-time-code" inputmode="numeric" [value]="values['code'] ?? ''" (input)="setValue('code', $any($event.target).value)" />
1490
+ </label>
1491
+ @if (enrol.totpCodes >= 2) {
1492
+ <label class="atlas-field">
1493
+ <span>{{ t('mfa.enrollSecondCodeLabel') }}</span>
1494
+ <input [class]="cls('formFieldInput', 'atlas-input')" type="text" name="secondCode" autocomplete="one-time-code" inputmode="numeric" [value]="values['secondCode'] ?? ''" (input)="setValue('secondCode', $any($event.target).value)" />
1495
+ </label>
1496
+ }
1497
+ } @else {
1498
+ <p class="atlas-text" aria-live="polite">{{ enrol.busy ? t('mfa.enrollPreparing') : '' }}</p>
1499
+ }
1500
+ } @else {
1501
+ @if (enrol.enrollment; as texted) {
1502
+ <p class="atlas-text" data-atlas="mfa-code-texted" aria-live="polite">{{ t('mfa.codeTextedTo', { phone: texted.phone ?? '' }) }}</p>
1503
+ <label class="atlas-field">
1504
+ <span>{{ t('mfa.codeLabel') }}</span>
1505
+ <input [class]="cls('formFieldInput', 'atlas-input')" type="text" name="code" autocomplete="one-time-code" inputmode="numeric" [value]="values['code'] ?? ''" (input)="setValue('code', $any($event.target).value)" />
1506
+ </label>
1507
+ <button
1508
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1509
+ type="button"
1510
+ data-atlas-resend=""
1511
+ [disabled]="busy || enrol.busy"
1512
+ (click)="resendEnrollment()"
1513
+ >
1514
+ {{ t('signIn.resendCode') }}
1515
+ </button>
1516
+ } @else {
1517
+ <label class="atlas-field">
1518
+ <span>{{ t('mfa.phoneLabel') }}</span>
1519
+ <input [class]="cls('formFieldInput', 'atlas-input')" type="tel" name="phone" autocomplete="tel" [value]="values['phone'] ?? ''" (input)="setValue('phone', $any($event.target).value)" />
1520
+ </label>
1521
+ <p class="atlas-text">{{ t('mfa.phoneHint') }}</p>
1522
+ }
1523
+ }
1524
+ @for (error of enrol.errors; track $index) {
1525
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ error.message }}</p>
1526
+ }
1527
+ @if (enrol.method !== null && enrol.options.length > 1) {
1528
+ @for (other of otherMethods(enrol); track other) {
1529
+ <button
1530
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1531
+ type="button"
1532
+ [attr.data-atlas-mfa-method]="other"
1533
+ [disabled]="busy || enrol.busy"
1534
+ (click)="chooseMethod(other)"
1535
+ >
1536
+ {{ t(other === 'totp' ? 'mfa.enrollTotpOption' : 'mfa.enrollSmsOption') }}
1537
+ </button>
1538
+ }
1539
+ }
1540
+ }
1231
1541
  @for (f of fields(); track $index) {
1232
1542
  @if (f.kind === 'input') {
1233
1543
  <label class="atlas-field">
@@ -1255,11 +1565,53 @@ class AttemptFlowComponent {
1255
1565
  }
1256
1566
 
1257
1567
  @if (showSubmit()) {
1258
- <button [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy" type="submit">
1259
- {{ t('signIn.submit') }}
1568
+ <button [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy || (enrolling() && !!enrollmentState?.busy)" type="submit">
1569
+ {{ t(submitKey()) }}
1260
1570
  </button>
1261
1571
  }
1262
1572
 
1573
+ @if (showSmsSecondFactor()) {
1574
+ @if (smsSecondFactorSentTo(); as sentTo) {
1575
+ <p class="atlas-text" data-atlas="sms-second-factor-sent" aria-live="polite">
1576
+ {{ t('mfa.codeTextedTo', { phone: sentTo }) }}
1577
+ </p>
1578
+ } @else {
1579
+ <button
1580
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1581
+ type="button"
1582
+ data-atlas="sms-second-factor"
1583
+ [disabled]="busy || !!textedSecondFactor?.busy"
1584
+ (click)="smsSecondFactor.emit()"
1585
+ >
1586
+ {{ textedSecondFactor?.busy ? t('signIn.sending') : t('mfa.smsCodeOption') }}
1587
+ </button>
1588
+ @if (textedSecondFactor?.error; as message) {
1589
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ message }}</p>
1590
+ }
1591
+ }
1592
+ }
1593
+
1594
+ @if (showEmailSecondFactor()) {
1595
+ @if (emailSecondFactorSentTo(); as sentTo) {
1596
+ <p class="atlas-text" data-atlas="email-second-factor-sent" aria-live="polite">
1597
+ {{ t('signIn.codeSentTo', { email: sentTo }) }}
1598
+ </p>
1599
+ } @else {
1600
+ <button
1601
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1602
+ type="button"
1603
+ data-atlas="email-second-factor"
1604
+ [disabled]="busy || !!emailedSecondFactor?.busy"
1605
+ (click)="emailSecondFactor.emit()"
1606
+ >
1607
+ {{ emailedSecondFactor?.busy ? t('signIn.sending') : t('signIn.emailCodeOption') }}
1608
+ </button>
1609
+ @if (emailedSecondFactor?.error; as message) {
1610
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ message }}</p>
1611
+ }
1612
+ }
1613
+ }
1614
+
1263
1615
  @if (showEmailCodeOption()) {
1264
1616
  <button
1265
1617
  [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
@@ -1295,6 +1647,98 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1295
1647
  @if (codeSentText(); as sentText) {
1296
1648
  <p class="atlas-text" data-atlas-code-sent="">{{ sentText }}</p>
1297
1649
  }
1650
+ @if (linkOnly()) {
1651
+ <!-- Nothing to type: the flow polls until the emailed link is opened (anywhere). -->
1652
+ <p class="atlas-text" data-atlas-link-sent="" aria-live="polite">
1653
+ {{ t('signUp.linkSentTo', { email: attempt?.identifier ?? '' }) }}
1654
+ </p>
1655
+ <button
1656
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1657
+ type="button"
1658
+ data-atlas-resend=""
1659
+ [disabled]="busy"
1660
+ (click)="resend.emit()"
1661
+ >
1662
+ {{ t('signIn.resendLink') }}
1663
+ </button>
1664
+ }
1665
+ @if (enrolling() && enrollmentState; as enrol) {
1666
+ @if (enrol.method === null) {
1667
+ <p class="atlas-text">{{ t('mfa.enrollChoose') }}</p>
1668
+ @for (m of enrol.options; track m) {
1669
+ <button
1670
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1671
+ type="button"
1672
+ [attr.data-atlas-mfa-method]="m"
1673
+ [disabled]="busy || enrol.busy"
1674
+ (click)="chooseMethod(m)"
1675
+ >
1676
+ {{ t(m === 'totp' ? 'mfa.enrollTotpOption' : 'mfa.enrollSmsOption') }}
1677
+ </button>
1678
+ }
1679
+ } @else if (enrol.method === 'totp') {
1680
+ @if (enrol.enrollment; as setup) {
1681
+ <p class="atlas-text">{{ t('mfa.enrollScan') }}</p>
1682
+ @if (setup.qrCode) {
1683
+ <img class="atlas-mfa-qr" data-atlas="mfa-qr" [src]="setup.qrCode" [alt]="t('mfa.enrollQrAlt')" width="180" height="180" />
1684
+ }
1685
+ @if (setup.secret) {
1686
+ <p class="atlas-text" data-atlas="mfa-setup-key">{{ t('mfa.enrollSetupKey') }}: <code>{{ setupKey(setup.secret) }}</code></p>
1687
+ }
1688
+ <label class="atlas-field">
1689
+ <span>{{ t('mfa.enrollFirstCodeLabel') }}</span>
1690
+ <input [class]="cls('formFieldInput', 'atlas-input')" type="text" name="code" autocomplete="one-time-code" inputmode="numeric" [value]="values['code'] ?? ''" (input)="setValue('code', $any($event.target).value)" />
1691
+ </label>
1692
+ @if (enrol.totpCodes >= 2) {
1693
+ <label class="atlas-field">
1694
+ <span>{{ t('mfa.enrollSecondCodeLabel') }}</span>
1695
+ <input [class]="cls('formFieldInput', 'atlas-input')" type="text" name="secondCode" autocomplete="one-time-code" inputmode="numeric" [value]="values['secondCode'] ?? ''" (input)="setValue('secondCode', $any($event.target).value)" />
1696
+ </label>
1697
+ }
1698
+ } @else {
1699
+ <p class="atlas-text" aria-live="polite">{{ enrol.busy ? t('mfa.enrollPreparing') : '' }}</p>
1700
+ }
1701
+ } @else {
1702
+ @if (enrol.enrollment; as texted) {
1703
+ <p class="atlas-text" data-atlas="mfa-code-texted" aria-live="polite">{{ t('mfa.codeTextedTo', { phone: texted.phone ?? '' }) }}</p>
1704
+ <label class="atlas-field">
1705
+ <span>{{ t('mfa.codeLabel') }}</span>
1706
+ <input [class]="cls('formFieldInput', 'atlas-input')" type="text" name="code" autocomplete="one-time-code" inputmode="numeric" [value]="values['code'] ?? ''" (input)="setValue('code', $any($event.target).value)" />
1707
+ </label>
1708
+ <button
1709
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1710
+ type="button"
1711
+ data-atlas-resend=""
1712
+ [disabled]="busy || enrol.busy"
1713
+ (click)="resendEnrollment()"
1714
+ >
1715
+ {{ t('signIn.resendCode') }}
1716
+ </button>
1717
+ } @else {
1718
+ <label class="atlas-field">
1719
+ <span>{{ t('mfa.phoneLabel') }}</span>
1720
+ <input [class]="cls('formFieldInput', 'atlas-input')" type="tel" name="phone" autocomplete="tel" [value]="values['phone'] ?? ''" (input)="setValue('phone', $any($event.target).value)" />
1721
+ </label>
1722
+ <p class="atlas-text">{{ t('mfa.phoneHint') }}</p>
1723
+ }
1724
+ }
1725
+ @for (error of enrol.errors; track $index) {
1726
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ error.message }}</p>
1727
+ }
1728
+ @if (enrol.method !== null && enrol.options.length > 1) {
1729
+ @for (other of otherMethods(enrol); track other) {
1730
+ <button
1731
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1732
+ type="button"
1733
+ [attr.data-atlas-mfa-method]="other"
1734
+ [disabled]="busy || enrol.busy"
1735
+ (click)="chooseMethod(other)"
1736
+ >
1737
+ {{ t(other === 'totp' ? 'mfa.enrollTotpOption' : 'mfa.enrollSmsOption') }}
1738
+ </button>
1739
+ }
1740
+ }
1741
+ }
1298
1742
  @for (f of fields(); track $index) {
1299
1743
  @if (f.kind === 'input') {
1300
1744
  <label class="atlas-field">
@@ -1322,11 +1766,53 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1322
1766
  }
1323
1767
 
1324
1768
  @if (showSubmit()) {
1325
- <button [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy" type="submit">
1326
- {{ t('signIn.submit') }}
1769
+ <button [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy || (enrolling() && !!enrollmentState?.busy)" type="submit">
1770
+ {{ t(submitKey()) }}
1327
1771
  </button>
1328
1772
  }
1329
1773
 
1774
+ @if (showSmsSecondFactor()) {
1775
+ @if (smsSecondFactorSentTo(); as sentTo) {
1776
+ <p class="atlas-text" data-atlas="sms-second-factor-sent" aria-live="polite">
1777
+ {{ t('mfa.codeTextedTo', { phone: sentTo }) }}
1778
+ </p>
1779
+ } @else {
1780
+ <button
1781
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1782
+ type="button"
1783
+ data-atlas="sms-second-factor"
1784
+ [disabled]="busy || !!textedSecondFactor?.busy"
1785
+ (click)="smsSecondFactor.emit()"
1786
+ >
1787
+ {{ textedSecondFactor?.busy ? t('signIn.sending') : t('mfa.smsCodeOption') }}
1788
+ </button>
1789
+ @if (textedSecondFactor?.error; as message) {
1790
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ message }}</p>
1791
+ }
1792
+ }
1793
+ }
1794
+
1795
+ @if (showEmailSecondFactor()) {
1796
+ @if (emailSecondFactorSentTo(); as sentTo) {
1797
+ <p class="atlas-text" data-atlas="email-second-factor-sent" aria-live="polite">
1798
+ {{ t('signIn.codeSentTo', { email: sentTo }) }}
1799
+ </p>
1800
+ } @else {
1801
+ <button
1802
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1803
+ type="button"
1804
+ data-atlas="email-second-factor"
1805
+ [disabled]="busy || !!emailedSecondFactor?.busy"
1806
+ (click)="emailSecondFactor.emit()"
1807
+ >
1808
+ {{ emailedSecondFactor?.busy ? t('signIn.sending') : t('signIn.emailCodeOption') }}
1809
+ </button>
1810
+ @if (emailedSecondFactor?.error; as message) {
1811
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ message }}</p>
1812
+ }
1813
+ }
1814
+ }
1815
+
1330
1816
  @if (showEmailCodeOption()) {
1331
1817
  <button
1332
1818
  [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
@@ -1363,12 +1849,148 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1363
1849
  type: Output
1364
1850
  }], chooseFactor: [{
1365
1851
  type: Output
1852
+ }], offerEmailSecondFactor: [{
1853
+ type: Input
1854
+ }], emailedSecondFactor: [{
1855
+ type: Input
1856
+ }], emailSecondFactor: [{
1857
+ type: Output
1858
+ }], offerSmsSecondFactor: [{
1859
+ type: Input
1860
+ }], textedSecondFactor: [{
1861
+ type: Input
1862
+ }], smsSecondFactor: [{
1863
+ type: Output
1864
+ }], enrollment: [{
1865
+ type: Input
1866
+ }], enrollmentState: [{
1867
+ type: Input
1868
+ }], emailVerification: [{
1869
+ type: Input
1870
+ }], resend: [{
1871
+ type: Output
1366
1872
  }] } });
1367
1873
  /** A read-only message row: `labelKey` holds the i18n key of the text to show. */
1368
1874
  function msg(labelKey) {
1369
1875
  return { kind: 'message', name: '', labelKey, type: 'text' };
1370
1876
  }
1371
1877
 
1878
+ /**
1879
+ * The recovery codes a mid-sign-in MFA enrolment came with, shown once before
1880
+ * the sign-in finishes: copy them, download them as a .txt file, then
1881
+ * "I've saved my codes" (`saved`) exchanges the ticket. The codes live only in
1882
+ * the flow controller's memory and are forgotten once saved.
1883
+ */
1884
+ class RecoveryCodesComponent {
1885
+ atlas = inject(AtlasService);
1886
+ cdr = inject(ChangeDetectorRef);
1887
+ codes = [];
1888
+ busy = false;
1889
+ saved = new EventEmitter();
1890
+ canCopy = typeof navigator !== 'undefined' && !!navigator.clipboard?.writeText;
1891
+ canDownload = canDownloadRecoveryCodes();
1892
+ copied = false;
1893
+ ngOnChanges() {
1894
+ this.copied = false;
1895
+ }
1896
+ t(key) {
1897
+ return translate(this.atlas.localization, key);
1898
+ }
1899
+ cls(key, base) {
1900
+ return classFor(key, this.atlas.appearance, base);
1901
+ }
1902
+ copy() {
1903
+ void copyRecoveryCodes(this.codes).then((ok) => {
1904
+ this.copied = ok;
1905
+ this.cdr.markForCheck();
1906
+ });
1907
+ }
1908
+ download() {
1909
+ downloadRecoveryCodes(this.codes);
1910
+ }
1911
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: RecoveryCodesComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1912
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: RecoveryCodesComponent, isStandalone: true, selector: "atlas-recovery-codes", inputs: { codes: "codes", busy: "busy" }, outputs: { saved: "saved" }, usesOnChanges: true, ngImport: i0, template: `
1913
+ <div [class]="cls('card', 'atlas-card')" data-atlas="recovery-codes">
1914
+ <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('mfa.recoveryCodesTitle') }}</h1>
1915
+ <p class="atlas-text">{{ t('mfa.recoveryCodesHelp') }}</p>
1916
+ <ul
1917
+ class="atlas-recovery-codes"
1918
+ data-atlas="recovery-codes-list"
1919
+ style="list-style:none;padding:0;margin:8px 0 12px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;text-align:center"
1920
+ >
1921
+ @for (code of codes; track code) {
1922
+ <li><code>{{ code }}</code></li>
1923
+ }
1924
+ </ul>
1925
+ <div class="atlas-alternatives">
1926
+ @if (canCopy) {
1927
+ <button [class]="cls('formButtonSecondary', 'atlas-button-secondary')" type="button" data-atlas="recovery-codes-copy" (click)="copy()">
1928
+ {{ t('mfa.recoveryCodesCopy') }}
1929
+ </button>
1930
+ }
1931
+ @if (canDownload) {
1932
+ <button [class]="cls('formButtonSecondary', 'atlas-button-secondary')" type="button" data-atlas="recovery-codes-download" (click)="download()">
1933
+ {{ t('mfa.recoveryCodesDownload') }}
1934
+ </button>
1935
+ }
1936
+ </div>
1937
+ @if (copied) {
1938
+ <p class="atlas-text" aria-live="polite" data-atlas="recovery-codes-copied">{{ t('mfa.recoveryCodesCopied') }}</p>
1939
+ }
1940
+ <button [class]="cls('formButtonPrimary', 'atlas-button')" type="button" data-atlas="recovery-codes-saved" [disabled]="busy" (click)="saved.emit()">
1941
+ {{ t('mfa.recoveryCodesSaved') }}
1942
+ </button>
1943
+ </div>
1944
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1945
+ }
1946
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: RecoveryCodesComponent, decorators: [{
1947
+ type: Component,
1948
+ args: [{
1949
+ selector: 'atlas-recovery-codes',
1950
+ standalone: true,
1951
+ changeDetection: ChangeDetectionStrategy.OnPush,
1952
+ template: `
1953
+ <div [class]="cls('card', 'atlas-card')" data-atlas="recovery-codes">
1954
+ <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('mfa.recoveryCodesTitle') }}</h1>
1955
+ <p class="atlas-text">{{ t('mfa.recoveryCodesHelp') }}</p>
1956
+ <ul
1957
+ class="atlas-recovery-codes"
1958
+ data-atlas="recovery-codes-list"
1959
+ style="list-style:none;padding:0;margin:8px 0 12px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;text-align:center"
1960
+ >
1961
+ @for (code of codes; track code) {
1962
+ <li><code>{{ code }}</code></li>
1963
+ }
1964
+ </ul>
1965
+ <div class="atlas-alternatives">
1966
+ @if (canCopy) {
1967
+ <button [class]="cls('formButtonSecondary', 'atlas-button-secondary')" type="button" data-atlas="recovery-codes-copy" (click)="copy()">
1968
+ {{ t('mfa.recoveryCodesCopy') }}
1969
+ </button>
1970
+ }
1971
+ @if (canDownload) {
1972
+ <button [class]="cls('formButtonSecondary', 'atlas-button-secondary')" type="button" data-atlas="recovery-codes-download" (click)="download()">
1973
+ {{ t('mfa.recoveryCodesDownload') }}
1974
+ </button>
1975
+ }
1976
+ </div>
1977
+ @if (copied) {
1978
+ <p class="atlas-text" aria-live="polite" data-atlas="recovery-codes-copied">{{ t('mfa.recoveryCodesCopied') }}</p>
1979
+ }
1980
+ <button [class]="cls('formButtonPrimary', 'atlas-button')" type="button" data-atlas="recovery-codes-saved" [disabled]="busy" (click)="saved.emit()">
1981
+ {{ t('mfa.recoveryCodesSaved') }}
1982
+ </button>
1983
+ </div>
1984
+ `,
1985
+ }]
1986
+ }], propDecorators: { codes: [{
1987
+ type: Input
1988
+ }], busy: [{
1989
+ type: Input
1990
+ }], saved: [{
1991
+ type: Output
1992
+ }] } });
1993
+
1372
1994
  /**
1373
1995
  * The server-driven sign-in / sign-up engine, shared by `<atlas-sign-in>` and
1374
1996
  * `<atlas-sign-up>` — the Angular counterpart of React's `useFlow` hook.
@@ -1394,6 +2016,11 @@ class AtlasFlowController {
1394
2016
  * resolves, or when the server could not be reached.
1395
2017
  */
1396
2018
  settings = signal(null);
2019
+ /**
2020
+ * The method this browser last signed in with (`password`, `oauth_github`, …)
2021
+ * for the "Last used" hint; null when none, or the instance turned it off.
2022
+ */
2023
+ lastMethod = signal(null);
1397
2024
  /** The sign-in captcha (identifier step / `needs_captcha` challenge). */
1398
2025
  captcha = createCaptchaController(undefined, {
1399
2026
  // The Atlas captcha's words follow the form's catalog (read when drawn).
@@ -1413,6 +2040,21 @@ class AtlasFlowController {
1413
2040
  * otherwise.
1414
2041
  */
1415
2042
  preparedFactor = signal(null);
2043
+ /**
2044
+ * "Email me a code instead" at the second factor ({@link sendEmailSecondFactor}):
2045
+ * in flight, where the code went, or why it was not sent. Null until asked,
2046
+ * and again once the flow leaves that step.
2047
+ */
2048
+ emailSecondFactor = signal(null);
2049
+ /** "Text me a code instead" at the second factor ({@link sendSmsSecondFactor}): as {@link emailSecondFactor}. */
2050
+ smsSecondFactor = signal(null);
2051
+ /**
2052
+ * The MFA enrolment step (`needs_mfa_enrollment`): the authenticator app's
2053
+ * setup (QR code, setup key) or a texted code. Started and reset with the flow.
2054
+ */
2055
+ enrollment;
2056
+ /** {@link enrollment}'s state, for templates. */
2057
+ enrollmentState;
1416
2058
  pollTimer = null;
1417
2059
  identifier = '';
1418
2060
  constructor(client, reload, pending,
@@ -1424,7 +2066,23 @@ class AtlasFlowController {
1424
2066
  // Seeded from a redirect that came back mid-flow (e.g. OAuth needing a second
1425
2067
  // factor) so the user resumes at the demanded step instead of starting over.
1426
2068
  this.state = signal(pending ? flowFromPending(pending) : initialFlow);
2069
+ this.enrollment = createMfaEnrollment(this.client, () => this.mfaChoices());
2070
+ this.enrollmentState = signal(this.enrollment.state);
2071
+ this.enrollment.subscribe((next) => this.enrollmentState.set(next));
1427
2072
  this.syncCaptcha();
2073
+ this.syncEnrollment();
2074
+ }
2075
+ /** What the app lets the MFA steps offer (from {@link settings}). */
2076
+ mfaChoices() {
2077
+ return mfaChoicesFrom(this.settings()?.account);
2078
+ }
2079
+ /** Start the enrolment step for its attempt, or reset it once the flow leaves. */
2080
+ syncEnrollment() {
2081
+ const attempt = this.state().attempt;
2082
+ if (attempt?.status === 'needs_mfa_enrollment' && attempt.id)
2083
+ this.enrollment.start(attempt.id);
2084
+ else
2085
+ this.enrollment.reset();
1428
2086
  }
1429
2087
  /** Fetch the instance's public settings and draw whatever captcha they require. */
1430
2088
  async loadSettings() {
@@ -1432,6 +2090,8 @@ class AtlasFlowController {
1432
2090
  if (loaded) {
1433
2091
  this.settings.set(loaded);
1434
2092
  this.syncCaptcha();
2093
+ if (loaded.account.lastSignInMethod)
2094
+ this.lastMethod.set(await fetchLastSignInMethod(this.client));
1435
2095
  }
1436
2096
  }
1437
2097
  /**
@@ -1486,11 +2146,18 @@ class AtlasFlowController {
1486
2146
  this.signUpCaptchaDrawn.set(this.signUpCaptcha.needed);
1487
2147
  }
1488
2148
  setState(next) {
2149
+ const previousId = this.state().attempt?.id ?? null;
1489
2150
  this.state.set(next);
1490
2151
  // A prepared factor belongs to the first-factor step it was sent for.
1491
2152
  if (next.attempt?.status !== 'needs_first_factor')
1492
2153
  this.preparedFactor.set(null);
2154
+ // An emailed second-factor code belongs to the second-factor step of this attempt.
2155
+ if (next.attempt?.status !== 'needs_second_factor' || (next.attempt?.id ?? null) !== previousId) {
2156
+ this.emailSecondFactor.set(null);
2157
+ this.smsSecondFactor.set(null);
2158
+ }
1493
2159
  this.syncCaptcha();
2160
+ this.syncEnrollment();
1494
2161
  this.autoSend();
1495
2162
  }
1496
2163
  autoSentFor = null;
@@ -1539,7 +2206,72 @@ class AtlasFlowController {
1539
2206
  });
1540
2207
  await this.finish(next);
1541
2208
  }
2209
+ /**
2210
+ * "Email me a code instead" at the second factor (the instance's
2211
+ * `account.emailSecondFactor`): mails a code to the account's verified email;
2212
+ * it is then posted with {@link submit} like any 2FA code.
2213
+ */
2214
+ async sendEmailSecondFactor() {
2215
+ await this.sendSecondFactorCode('email_code', this.emailSecondFactor);
2216
+ }
2217
+ /**
2218
+ * "Text me a code instead" at the second factor (where the app offers texted
2219
+ * codes, {@link mfaChoices}`().sms`): texts a code to the account's enrolled
2220
+ * phone; it is then posted with {@link submit} like any 2FA code.
2221
+ */
2222
+ async sendSmsSecondFactor() {
2223
+ await this.sendSecondFactorCode('sms', this.smsSecondFactor);
2224
+ }
2225
+ async sendSecondFactorCode(strategy, out) {
2226
+ const id = this.state().attempt?.id;
2227
+ if (!id)
2228
+ return;
2229
+ out.set({ busy: true });
2230
+ try {
2231
+ const { sentTo } = await prepareSecondFactorCode(this.client, id, strategy);
2232
+ out.set({ sentTo });
2233
+ }
2234
+ catch (error) {
2235
+ out.set({ error: error instanceof Error ? error.message : this.t('error.generic') });
2236
+ }
2237
+ }
2238
+ /** Resend a sign-up's emailed code / link (the earlier one stops working). */
2239
+ async resendSignUp() {
2240
+ this.state.update((current) => ({ ...current, busy: true, errors: [] }));
2241
+ this.state.set(await resendSignUpVerification(this.client, this.state()));
2242
+ }
2243
+ /**
2244
+ * Start a passkey-only sign-up (`POST /v1/client/sign_ups/passkey`) from the
2245
+ * create-account form's values — no password. Offered when the instance's
2246
+ * `account.passkeySignUp` is set.
2247
+ */
2248
+ async createWithPasskey(values, consent = true) {
2249
+ this.state.update((current) => ({ ...current, busy: true, errors: [] }));
2250
+ await this.finish(await createPasskeySignUp(this.client, values, { consent, captcha: this.signUpCaptcha, t: this.t }));
2251
+ }
2252
+ /** The passkey-only sign-up's last step: create the passkey, and with it the account. */
2253
+ async createPasskey() {
2254
+ this.state.update((current) => ({ ...current, busy: true, errors: [] }));
2255
+ await this.finish(await finishPasskeySignUp(this.client, this.state(), { t: this.t }));
2256
+ }
2257
+ /**
2258
+ * The recovery codes an MFA enrolment just completed the sign-in with, shown
2259
+ * once before it finishes ({@link saveRecoveryCodes}); null otherwise. They
2260
+ * live only in this controller's memory.
2261
+ */
2262
+ recoveryCodes() {
2263
+ return pendingRecoveryCodes(this.state());
2264
+ }
2265
+ /** "I've saved my codes": forget the recovery codes and finish the sign-in. */
2266
+ async saveRecoveryCodes() {
2267
+ await this.finish(acknowledgeRecoveryCodes(this.state()));
2268
+ }
1542
2269
  async finish(next) {
2270
+ // An enrolment's recovery codes are shown first; `saveRecoveryCodes` finishes.
2271
+ if (pendingRecoveryCodes(next)) {
2272
+ this.setState({ ...next, busy: false });
2273
+ return;
2274
+ }
1543
2275
  if (next.attempt?.status === 'complete') {
1544
2276
  // Stays busy until this browser actually holds the session: the completion
1545
2277
  // alone says nothing about whether the cookie was kept.
@@ -1577,6 +2309,9 @@ class AtlasFlowController {
1577
2309
  void (async () => {
1578
2310
  const { state: next, ticket } = await pollAttempt(this.client, this.state());
1579
2311
  this.setState(next);
2312
+ // e.g. a passkey sign-up whose email was just proven: only this tab can go on.
2313
+ if (!ticket && !shouldKeepPolling(next))
2314
+ this.stopPolling();
1580
2315
  if (!ticket || !next.attempt)
1581
2316
  return;
1582
2317
  // The ticket is exchanged by a normal request, so no session token ever
@@ -1628,6 +2363,12 @@ class SignInComponent {
1628
2363
  t(key) {
1629
2364
  return translate(this.atlas.localization, key);
1630
2365
  }
2366
+ /** "Last used: Password" on the identifier step, when the instance shows the hint. */
2367
+ lastUsedLabel() {
2368
+ if (this.flow.state().attempt)
2369
+ return null;
2370
+ return lastUsedMethodLabel(this.flow.lastMethod(), (key) => this.t(key));
2371
+ }
1631
2372
  signUpHref() {
1632
2373
  if (this.signUpUrl === null)
1633
2374
  return null;
@@ -1661,6 +2402,14 @@ class SignInComponent {
1661
2402
  }
1662
2403
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignInComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1663
2404
  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: `
2405
+ @if (lastUsedLabel(); as label) {
2406
+ <p class="atlas-text atlas-last-used-hint" data-atlas="last-used" [attr.data-atlas-method]="flow.lastMethod()">
2407
+ {{ t('signIn.lastUsed') }}: {{ label }}
2408
+ </p>
2409
+ }
2410
+ @if (flow.recoveryCodes(); as codes) {
2411
+ <atlas-recovery-codes [codes]="codes" [busy]="flow.state().busy" (saved)="flow.saveRecoveryCodes()"></atlas-recovery-codes>
2412
+ } @else {
1664
2413
  <atlas-attempt-flow
1665
2414
  [attempt]="flow.state().attempt"
1666
2415
  [busy]="flow.state().busy"
@@ -1670,15 +2419,24 @@ class SignInComponent {
1670
2419
  [captcha]="flow.captchaDrawn() ? flow.captcha : null"
1671
2420
  [firstFactors]="flow.firstFactors()"
1672
2421
  [preparedFactor]="flow.preparedFactor()"
2422
+ [offerEmailSecondFactor]="flow.settings()?.account?.emailSecondFactor === true"
2423
+ [emailedSecondFactor]="flow.emailSecondFactor()"
1673
2424
  (submitFlow)="flow.submit($event)"
1674
2425
  (chooseFactor)="flow.prepare($event)"
2426
+ [offerSmsSecondFactor]="flow.settings() !== null && flow.mfaChoices().sms"
2427
+ [textedSecondFactor]="flow.smsSecondFactor()"
2428
+ [enrollment]="flow.enrollment"
2429
+ [enrollmentState]="flow.enrollmentState()"
2430
+ (emailSecondFactor)="flow.sendEmailSecondFactor()"
2431
+ (smsSecondFactor)="flow.sendSmsSecondFactor()"
1675
2432
  ></atlas-attempt-flow>
2433
+ }
1676
2434
  @if (flow.state().attempt?.status !== 'complete' && signUpHref(); as href) {
1677
2435
  <p class="atlas-footer" data-atlas-sign-up-prompt="">
1678
2436
  {{ t('signIn.noAccount') }} <a class="atlas-link" [href]="href">{{ t('signIn.signUpLink') }}</a>
1679
2437
  </p>
1680
2438
  }
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 });
2439
+ `, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "sessionKept", "titleKey", "captcha", "firstFactors", "preparedFactor", "offerEmailSecondFactor", "emailedSecondFactor", "offerSmsSecondFactor", "textedSecondFactor", "enrollment", "enrollmentState", "emailVerification"], outputs: ["submitFlow", "chooseFactor", "emailSecondFactor", "smsSecondFactor", "resend"] }, { kind: "component", type: RecoveryCodesComponent, selector: "atlas-recovery-codes", inputs: ["codes", "busy"], outputs: ["saved"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1682
2440
  }
1683
2441
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignInComponent, decorators: [{
1684
2442
  type: Component,
@@ -1686,8 +2444,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1686
2444
  selector: 'atlas-sign-in',
1687
2445
  standalone: true,
1688
2446
  changeDetection: ChangeDetectionStrategy.OnPush,
1689
- imports: [AttemptFlowComponent],
2447
+ imports: [AttemptFlowComponent, RecoveryCodesComponent],
1690
2448
  template: `
2449
+ @if (lastUsedLabel(); as label) {
2450
+ <p class="atlas-text atlas-last-used-hint" data-atlas="last-used" [attr.data-atlas-method]="flow.lastMethod()">
2451
+ {{ t('signIn.lastUsed') }}: {{ label }}
2452
+ </p>
2453
+ }
2454
+ @if (flow.recoveryCodes(); as codes) {
2455
+ <atlas-recovery-codes [codes]="codes" [busy]="flow.state().busy" (saved)="flow.saveRecoveryCodes()"></atlas-recovery-codes>
2456
+ } @else {
1691
2457
  <atlas-attempt-flow
1692
2458
  [attempt]="flow.state().attempt"
1693
2459
  [busy]="flow.state().busy"
@@ -1697,9 +2463,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1697
2463
  [captcha]="flow.captchaDrawn() ? flow.captcha : null"
1698
2464
  [firstFactors]="flow.firstFactors()"
1699
2465
  [preparedFactor]="flow.preparedFactor()"
2466
+ [offerEmailSecondFactor]="flow.settings()?.account?.emailSecondFactor === true"
2467
+ [emailedSecondFactor]="flow.emailSecondFactor()"
1700
2468
  (submitFlow)="flow.submit($event)"
1701
2469
  (chooseFactor)="flow.prepare($event)"
2470
+ [offerSmsSecondFactor]="flow.settings() !== null && flow.mfaChoices().sms"
2471
+ [textedSecondFactor]="flow.smsSecondFactor()"
2472
+ [enrollment]="flow.enrollment"
2473
+ [enrollmentState]="flow.enrollmentState()"
2474
+ (emailSecondFactor)="flow.sendEmailSecondFactor()"
2475
+ (smsSecondFactor)="flow.sendSmsSecondFactor()"
1702
2476
  ></atlas-attempt-flow>
2477
+ }
1703
2478
  @if (flow.state().attempt?.status !== 'complete' && signUpHref(); as href) {
1704
2479
  <p class="atlas-footer" data-atlas-sign-up-prompt="">
1705
2480
  {{ t('signIn.noAccount') }} <a class="atlas-link" [href]="href">{{ t('signIn.signUpLink') }}</a>
@@ -1727,6 +2502,12 @@ class SignUpFormComponent {
1727
2502
  busy = false;
1728
2503
  errors = [];
1729
2504
  captcha = null;
2505
+ /** Passkey-only sign-up: no password field; the email per the instance's mode. */
2506
+ passkey = null;
2507
+ /** Whether the password / passkey toggle is shown (both are offered). */
2508
+ switchable = false;
2509
+ /** The user asked for the other sign-up method. */
2510
+ switchMode = new EventEmitter();
1730
2511
  /** Emits the typed values and the consent tick when the form is submitted. */
1731
2512
  submitSignUp = new EventEmitter();
1732
2513
  values = {};
@@ -1748,8 +2529,17 @@ class SignUpFormComponent {
1748
2529
  }
1749
2530
  /** Email + password sign-up cannot complete: the app cannot send the verification email. */
1750
2531
  emailUnavailable() {
2532
+ // A passkey sign-up that collects no email (or an unproven one) sends no mail.
2533
+ if (this.passkey && this.passkey.email !== 'required')
2534
+ return false;
1751
2535
  return this.settings?.emailDelivery?.passwordSignUp === false;
1752
2536
  }
2537
+ showEmail() {
2538
+ return !this.passkey || this.passkey.email !== 'off';
2539
+ }
2540
+ emailRequired() {
2541
+ return !this.passkey || this.passkey.email === 'required';
2542
+ }
1753
2543
  fields() {
1754
2544
  return this.settings?.signUpFields ?? [];
1755
2545
  }
@@ -1796,7 +2586,7 @@ class SignUpFormComponent {
1796
2586
  });
1797
2587
  }
1798
2588
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1799
- 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: `
2589
+ 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", passkey: "passkey", switchable: "switchable" }, outputs: { switchMode: "switchMode", submitSignUp: "submitSignUp" }, ngImport: i0, template: `
1800
2590
  <div [class]="cls('card', 'atlas-card')">
1801
2591
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
1802
2592
 
@@ -1885,14 +2675,17 @@ class SignUpFormComponent {
1885
2675
  }
1886
2676
  }
1887
2677
 
2678
+ @if (showEmail()) {
1888
2679
  <label [class]="cls('formField', 'atlas-field')">
1889
- <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('signUp.emailLabel') }}</span>
2680
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{
2681
+ emailRequired() ? t('signUp.emailLabel') : t('signUp.optionalField', { label: t('signUp.emailLabel') })
2682
+ }}</span>
1890
2683
  <input
1891
2684
  [class]="cls('formFieldInput', 'atlas-input')"
1892
2685
  type="email"
1893
2686
  name="email"
1894
2687
  autocomplete="email"
1895
- required
2688
+ [required]="emailRequired()"
1896
2689
  [value]="values['email'] ?? ''"
1897
2690
  (input)="setValue('email', $any($event.target).value)"
1898
2691
  />
@@ -1900,7 +2693,11 @@ class SignUpFormComponent {
1900
2693
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1901
2694
  }
1902
2695
  </label>
2696
+ }
1903
2697
 
2698
+ @if (passkey) {
2699
+ <p class="atlas-text">{{ t('signUp.passkeyIntro') }}</p>
2700
+ } @else {
1904
2701
  <label [class]="cls('formField', 'atlas-field')">
1905
2702
  <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('signUp.passwordLabel') }}</span>
1906
2703
  <input
@@ -1916,6 +2713,7 @@ class SignUpFormComponent {
1916
2713
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1917
2714
  }
1918
2715
  </label>
2716
+ }
1919
2717
 
1920
2718
  @if (showLegal()) {
1921
2719
  <label [class]="cls('formField', 'atlas-consent')">
@@ -1956,6 +2754,19 @@ class SignUpFormComponent {
1956
2754
  >
1957
2755
  {{ t('signUp.submit') }}
1958
2756
  </button>
2757
+ @if (switchable) {
2758
+ <div class="atlas-alternatives">
2759
+ <button
2760
+ type="button"
2761
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
2762
+ [attr.data-atlas-sign-up-mode]="passkey ? 'password' : 'passkey'"
2763
+ [disabled]="busy"
2764
+ (click)="switchMode.emit()"
2765
+ >
2766
+ {{ t(passkey ? 'signUp.passwordOption' : 'signUp.passkeyOption') }}
2767
+ </button>
2768
+ </div>
2769
+ }
1959
2770
  </form>
1960
2771
  }
1961
2772
  </div>
@@ -2057,14 +2868,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2057
2868
  }
2058
2869
  }
2059
2870
 
2871
+ @if (showEmail()) {
2060
2872
  <label [class]="cls('formField', 'atlas-field')">
2061
- <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('signUp.emailLabel') }}</span>
2873
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{
2874
+ emailRequired() ? t('signUp.emailLabel') : t('signUp.optionalField', { label: t('signUp.emailLabel') })
2875
+ }}</span>
2062
2876
  <input
2063
2877
  [class]="cls('formFieldInput', 'atlas-input')"
2064
2878
  type="email"
2065
2879
  name="email"
2066
2880
  autocomplete="email"
2067
- required
2881
+ [required]="emailRequired()"
2068
2882
  [value]="values['email'] ?? ''"
2069
2883
  (input)="setValue('email', $any($event.target).value)"
2070
2884
  />
@@ -2072,7 +2886,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2072
2886
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
2073
2887
  }
2074
2888
  </label>
2889
+ }
2075
2890
 
2891
+ @if (passkey) {
2892
+ <p class="atlas-text">{{ t('signUp.passkeyIntro') }}</p>
2893
+ } @else {
2076
2894
  <label [class]="cls('formField', 'atlas-field')">
2077
2895
  <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('signUp.passwordLabel') }}</span>
2078
2896
  <input
@@ -2088,6 +2906,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2088
2906
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
2089
2907
  }
2090
2908
  </label>
2909
+ }
2091
2910
 
2092
2911
  @if (showLegal()) {
2093
2912
  <label [class]="cls('formField', 'atlas-consent')">
@@ -2128,6 +2947,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2128
2947
  >
2129
2948
  {{ t('signUp.submit') }}
2130
2949
  </button>
2950
+ @if (switchable) {
2951
+ <div class="atlas-alternatives">
2952
+ <button
2953
+ type="button"
2954
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
2955
+ [attr.data-atlas-sign-up-mode]="passkey ? 'password' : 'passkey'"
2956
+ [disabled]="busy"
2957
+ (click)="switchMode.emit()"
2958
+ >
2959
+ {{ t(passkey ? 'signUp.passwordOption' : 'signUp.passkeyOption') }}
2960
+ </button>
2961
+ </div>
2962
+ }
2131
2963
  </form>
2132
2964
  }
2133
2965
  </div>
@@ -2141,6 +2973,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2141
2973
  type: Input
2142
2974
  }], captcha: [{
2143
2975
  type: Input
2976
+ }], passkey: [{
2977
+ type: Input
2978
+ }], switchable: [{
2979
+ type: Input
2980
+ }], switchMode: [{
2981
+ type: Output
2144
2982
  }], submitSignUp: [{
2145
2983
  type: Output
2146
2984
  }] } });
@@ -2162,6 +3000,32 @@ class SignUpComponent {
2162
3000
  /** Where to send the user once the flow completes. Reserved for host routing. */
2163
3001
  afterUrl;
2164
3002
  flow = new AtlasFlowController(this.atlas.createFapiClient(), () => this.atlas.reload(), this.atlas.pendingAttempt, (key) => translate(this.atlas.localization, key));
3003
+ /** Password or passkey-only sign-up (the latter when the instance offers it). */
3004
+ mode = signal('password');
3005
+ canPasskey() {
3006
+ return !!this.flow.settings()?.account.passkeySignUp && passkeysSupported();
3007
+ }
3008
+ passkeyForm() {
3009
+ const offer = this.flow.settings()?.account.passkeySignUp ?? null;
3010
+ return this.mode() === 'passkey' && offer && this.canPasskey() ? { email: offer.email } : null;
3011
+ }
3012
+ toggleMode() {
3013
+ this.flow.state.update((current) => ({ ...current, errors: [] }));
3014
+ this.mode.set(this.passkeyForm() ? 'password' : 'passkey');
3015
+ }
3016
+ awaitingPasskey() {
3017
+ return awaitingPasskey(this.flow.state().attempt);
3018
+ }
3019
+ t(key) {
3020
+ return translate(this.atlas.localization, key);
3021
+ }
3022
+ cls(key, base) {
3023
+ return classFor(key, this.atlas.appearance, base);
3024
+ }
3025
+ errorText(error) {
3026
+ const layer = this.atlas.errorLocalization;
3027
+ return localizedErrorMessage(error, layer ?? this.atlas.localization, this.atlas.localization);
3028
+ }
2165
3029
  ngOnInit() {
2166
3030
  // §5 the instance's sign-up fields, consent policy and captcha settings.
2167
3031
  if (typeof window !== 'undefined')
@@ -2172,14 +3036,32 @@ class SignUpComponent {
2172
3036
  }
2173
3037
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2174
3038
  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: `
2175
- @if (flow.state().attempt) {
3039
+ @if (awaitingPasskey()) {
3040
+ <div [class]="cls('card', 'atlas-card')" data-atlas-passkey-sign-up="">
3041
+ <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
3042
+ @for (error of flow.state().errors; track error.code) {
3043
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ errorText(error) }}</p>
3044
+ }
3045
+ <p class="atlas-text">{{ t('signUp.passkeyPrompt') }}</p>
3046
+ <button
3047
+ [class]="cls('formButtonPrimary', 'atlas-button')"
3048
+ type="button"
3049
+ [disabled]="flow.state().busy"
3050
+ (click)="flow.createPasskey()"
3051
+ >
3052
+ {{ t('signUp.passkeyCreate') }}
3053
+ </button>
3054
+ </div>
3055
+ } @else if (flow.state().attempt) {
2176
3056
  <atlas-attempt-flow
2177
3057
  [attempt]="flow.state().attempt"
2178
3058
  [busy]="flow.state().busy"
2179
3059
  [errors]="flow.state().errors"
2180
3060
  [sessionKept]="flow.state().sessionKept"
2181
3061
  titleKey="signUp.title"
3062
+ [emailVerification]="flow.settings()?.account?.emailVerification ?? 'code'"
2182
3063
  (submitFlow)="flow.submit($event)"
3064
+ (resend)="flow.resendSignUp()"
2183
3065
  ></atlas-attempt-flow>
2184
3066
  } @else {
2185
3067
  <atlas-sign-up-form
@@ -2187,10 +3069,15 @@ class SignUpComponent {
2187
3069
  [busy]="flow.state().busy"
2188
3070
  [errors]="flow.state().errors"
2189
3071
  [captcha]="flow.signUpCaptchaDrawn() ? flow.signUpCaptcha : null"
2190
- (submitSignUp)="flow.create($event.values, $event.consent)"
3072
+ [passkey]="passkeyForm()"
3073
+ [switchable]="canPasskey()"
3074
+ (switchMode)="toggleMode()"
3075
+ (submitSignUp)="
3076
+ passkeyForm() ? flow.createWithPasskey($event.values, $event.consent) : flow.create($event.values, $event.consent)
3077
+ "
2191
3078
  ></atlas-sign-up-form>
2192
3079
  }
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 });
3080
+ `, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "sessionKept", "titleKey", "captcha", "firstFactors", "preparedFactor", "offerEmailSecondFactor", "emailedSecondFactor", "offerSmsSecondFactor", "textedSecondFactor", "enrollment", "enrollmentState", "emailVerification"], outputs: ["submitFlow", "chooseFactor", "emailSecondFactor", "smsSecondFactor", "resend"] }, { kind: "component", type: SignUpFormComponent, selector: "atlas-sign-up-form", inputs: ["settings", "busy", "errors", "captcha", "passkey", "switchable"], outputs: ["switchMode", "submitSignUp"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2194
3081
  }
2195
3082
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpComponent, decorators: [{
2196
3083
  type: Component,
@@ -2200,14 +3087,32 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2200
3087
  changeDetection: ChangeDetectionStrategy.OnPush,
2201
3088
  imports: [AttemptFlowComponent, SignUpFormComponent],
2202
3089
  template: `
2203
- @if (flow.state().attempt) {
3090
+ @if (awaitingPasskey()) {
3091
+ <div [class]="cls('card', 'atlas-card')" data-atlas-passkey-sign-up="">
3092
+ <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
3093
+ @for (error of flow.state().errors; track error.code) {
3094
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ errorText(error) }}</p>
3095
+ }
3096
+ <p class="atlas-text">{{ t('signUp.passkeyPrompt') }}</p>
3097
+ <button
3098
+ [class]="cls('formButtonPrimary', 'atlas-button')"
3099
+ type="button"
3100
+ [disabled]="flow.state().busy"
3101
+ (click)="flow.createPasskey()"
3102
+ >
3103
+ {{ t('signUp.passkeyCreate') }}
3104
+ </button>
3105
+ </div>
3106
+ } @else if (flow.state().attempt) {
2204
3107
  <atlas-attempt-flow
2205
3108
  [attempt]="flow.state().attempt"
2206
3109
  [busy]="flow.state().busy"
2207
3110
  [errors]="flow.state().errors"
2208
3111
  [sessionKept]="flow.state().sessionKept"
2209
3112
  titleKey="signUp.title"
3113
+ [emailVerification]="flow.settings()?.account?.emailVerification ?? 'code'"
2210
3114
  (submitFlow)="flow.submit($event)"
3115
+ (resend)="flow.resendSignUp()"
2211
3116
  ></atlas-attempt-flow>
2212
3117
  } @else {
2213
3118
  <atlas-sign-up-form
@@ -2215,7 +3120,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2215
3120
  [busy]="flow.state().busy"
2216
3121
  [errors]="flow.state().errors"
2217
3122
  [captcha]="flow.signUpCaptchaDrawn() ? flow.signUpCaptcha : null"
2218
- (submitSignUp)="flow.create($event.values, $event.consent)"
3123
+ [passkey]="passkeyForm()"
3124
+ [switchable]="canPasskey()"
3125
+ (switchMode)="toggleMode()"
3126
+ (submitSignUp)="
3127
+ passkeyForm() ? flow.createWithPasskey($event.values, $event.consent) : flow.create($event.values, $event.consent)
3128
+ "
2219
3129
  ></atlas-sign-up-form>
2220
3130
  }
2221
3131
  `,
@@ -2549,32 +3459,392 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2549
3459
  }] });
2550
3460
 
2551
3461
  /**
2552
- * `<atlas-organization-profile>` — the active organization's surface. The
2553
- * Angular peer of React's `<OrganizationProfile/>`. Management controls are
2554
- * gated on the ACTIVE org role via `*atlasProtect`, matching the server's rule
2555
- * rather than duplicating a looser one. Renders nothing with no active org.
3462
+ * `<atlas-organization-profile>` — the active organization's members (admins
3463
+ * change roles and remove), invitations (admins invite, revoke and resend) and
3464
+ * settings (leave; an admin deletes after confirming their password). The
3465
+ * Angular peer of React's `<OrganizationProfile/>`, on the same headless
3466
+ * controller. Every control is gated on the ACTIVE org role, matching the
3467
+ * server's rule. Renders nothing with no active org. Emits `exit` with `left`
3468
+ * or `deleted`.
2556
3469
  */
2557
3470
  class OrganizationProfileComponent {
2558
3471
  atlas = inject(AtlasService);
2559
- t(key) {
2560
- return translate(this.atlas.localization, key);
3472
+ exit = new EventEmitter();
3473
+ client = this.atlas.createFapiClient();
3474
+ controller = null;
3475
+ unsubscribe = null;
3476
+ state = signal(null);
3477
+ tab = signal('members');
3478
+ email = signal('');
3479
+ inviteRole = signal('org:member');
3480
+ confirm = signal(null);
3481
+ needsPassword = signal(false);
3482
+ password = signal('');
3483
+ // The custom-role editor: null = creating, else the id of the role being edited.
3484
+ editingRole = signal(null);
3485
+ roleKey = signal('');
3486
+ roleName = signal('');
3487
+ rolePerms = signal([]);
3488
+ customRoles = computed(() => (this.state()?.roles ?? []).filter((r) => Boolean(r.organization_id)));
3489
+ userId = computed(() => this.atlas.claims()?.sub ?? null);
3490
+ canManage = computed(() => {
3491
+ this.atlas.claims();
3492
+ return this.atlas.has({ permission: 'org:sys_memberships:manage' });
3493
+ });
3494
+ isAdmin = computed(() => this.atlas.organization()?.role === 'org:admin');
3495
+ tabs = computed(() => ['members', ...(this.canManage() ? ['invitations', 'roles'] : []), 'settings']);
3496
+ roles = computed(() => {
3497
+ const roles = this.state()?.roles ?? [];
3498
+ return roles.length > 0 ? roles : [{ key: 'org:member', name: 'org:member' }];
3499
+ });
3500
+ errorMessage = computed(() => {
3501
+ const err = this.state()?.error;
3502
+ if (!err || err.needsReverification)
3503
+ return null;
3504
+ return localizedErrorMessage(err, this.atlas.errorLocalization, this.atlas.localization);
3505
+ });
3506
+ constructor() {
3507
+ effect(() => {
3508
+ const id = this.atlas.organization()?.organization.id ?? null;
3509
+ untracked(() => this.bind(id));
3510
+ });
3511
+ inject(DestroyRef).onDestroy(() => this.unsubscribe?.());
3512
+ }
3513
+ bind(id) {
3514
+ if (this.controller?.organizationId === id)
3515
+ return;
3516
+ this.unsubscribe?.();
3517
+ this.controller = null;
3518
+ this.state.set(null);
3519
+ if (!id)
3520
+ return;
3521
+ const controller = new OrganizationProfileController(this.client, id, {
3522
+ canManage: this.canManage(),
3523
+ onChange: async () => {
3524
+ await this.atlas.reload();
3525
+ },
3526
+ });
3527
+ this.controller = controller;
3528
+ this.unsubscribe = controller.subscribe((next) => this.state.set(next));
3529
+ this.state.set(controller.getState());
3530
+ void controller.load();
3531
+ }
3532
+ t(key, vars) {
3533
+ return translate(this.atlas.localization, key, vars);
2561
3534
  }
2562
3535
  cls(key, base) {
2563
3536
  return classFor(key, this.atlas.appearance, base);
2564
3537
  }
3538
+ memberName(m) {
3539
+ return [m.user?.first_name, m.user?.last_name].filter(Boolean).join(' ') || this.t('organization.unnamedMember');
3540
+ }
3541
+ changeRole(userId, role) {
3542
+ void this.controller?.changeRole(userId, role);
3543
+ }
3544
+ removeMember(userId) {
3545
+ void this.controller?.removeMember(userId);
3546
+ }
3547
+ async sendInvite() {
3548
+ const address = this.email().trim();
3549
+ if (this.controller && address && (await this.controller.invite(address, this.inviteRole())))
3550
+ this.email.set('');
3551
+ }
3552
+ resend(id) {
3553
+ void this.controller?.resendInvitation(id);
3554
+ }
3555
+ revoke(id) {
3556
+ void this.controller?.revokeInvitation(id);
3557
+ }
3558
+ async leave() {
3559
+ if (this.controller && (await this.controller.leave()))
3560
+ this.exit.emit('left');
3561
+ this.confirm.set(null);
3562
+ }
3563
+ async remove() {
3564
+ const controller = this.controller;
3565
+ if (!controller)
3566
+ return;
3567
+ if (this.needsPassword()) {
3568
+ try {
3569
+ await this.atlas.reauthenticate(this.password());
3570
+ }
3571
+ catch {
3572
+ return;
3573
+ }
3574
+ this.password.set('');
3575
+ this.needsPassword.set(false);
3576
+ }
3577
+ if (await controller.deleteOrganization()) {
3578
+ this.exit.emit('deleted');
3579
+ this.confirm.set(null);
3580
+ return;
3581
+ }
3582
+ const err = controller.getState().error;
3583
+ if (err?.needsReverification)
3584
+ this.needsPassword.set(true);
3585
+ }
3586
+ editRole(role) {
3587
+ this.editingRole.set(role.id ?? null);
3588
+ this.roleKey.set(role.key);
3589
+ this.roleName.set(role.name);
3590
+ this.rolePerms.set([...(role.permissions ?? [])]);
3591
+ }
3592
+ resetRoleForm() {
3593
+ this.editingRole.set(null);
3594
+ this.roleKey.set('');
3595
+ this.roleName.set('');
3596
+ this.rolePerms.set([]);
3597
+ }
3598
+ togglePermission(key, on) {
3599
+ this.rolePerms.update((current) => (on ? [...current, key] : current.filter((k) => k !== key)));
3600
+ }
3601
+ async saveRole() {
3602
+ const controller = this.controller;
3603
+ if (!controller)
3604
+ return;
3605
+ const editing = this.editingRole();
3606
+ const out = editing
3607
+ ? await controller.updateRole(editing, { name: this.roleName().trim(), permissions: this.rolePerms() })
3608
+ : await controller.createRole({ key: this.roleKey().trim(), name: this.roleName().trim(), permissions: this.rolePerms() });
3609
+ if (out)
3610
+ this.resetRoleForm();
3611
+ }
3612
+ deleteRole(role) {
3613
+ if (role.id)
3614
+ void this.controller?.deleteRole(role.id);
3615
+ }
3616
+ cancel() {
3617
+ this.confirm.set(null);
3618
+ this.needsPassword.set(false);
3619
+ this.password.set('');
3620
+ }
2565
3621
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: OrganizationProfileComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2566
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: OrganizationProfileComponent, isStandalone: true, selector: "atlas-organization-profile", ngImport: i0, template: `
3622
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: OrganizationProfileComponent, isStandalone: true, selector: "atlas-organization-profile", outputs: { exit: "exit" }, ngImport: i0, template: `
2567
3623
  @if (atlas.organization(); as membership) {
2568
- <div [class]="cls('card', 'atlas-card')">
2569
- <h1>{{ membership.organization.name }}</h1>
2570
- <p>{{ t('organization.roleLabel') }}: {{ membership.role }}</p>
3624
+ @if (state(); as st) {
3625
+ <div [class]="cls('card', 'atlas-card')" data-atlas="organization-profile">
3626
+ <h1>{{ membership.organization.name }}</h1>
3627
+ <p>{{ t('organization.roleLabel') }}: {{ membership.role }}</p>
2571
3628
 
2572
- <button *atlasProtect="{ permission: 'org:sys_memberships:manage' }">
2573
- {{ t('organization.invite') }}
2574
- </button>
2575
- </div>
3629
+ <div [class]="cls('menu', 'atlas-menu')" role="tablist">
3630
+ @for (key of tabs(); track key) {
3631
+ <button
3632
+ type="button"
3633
+ role="tab"
3634
+ [attr.aria-selected]="tab() === key"
3635
+ [class]="cls('menuItem', 'atlas-menu-item')"
3636
+ [attr.data-atlas]="'organization-tab-' + key"
3637
+ (click)="tab.set(key)"
3638
+ >
3639
+ {{ t('organization.' + key) }}
3640
+ </button>
3641
+ }
3642
+ </div>
3643
+
3644
+ @if (errorMessage(); as message) {
3645
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ message }}</p>
3646
+ }
3647
+
3648
+ @if (tab() === 'members') {
3649
+ <ul class="atlas-list" data-atlas="organization-members">
3650
+ @for (m of st.members; track m.id) {
3651
+ <li data-atlas="organization-member">
3652
+ <span>{{ memberName(m) }}{{ m.user?.id === userId() ? ' ' + t('organization.you') : '' }}</span>
3653
+ @if (canManage() && m.user && m.user.id !== userId()) {
3654
+ <select
3655
+ [attr.aria-label]="t('organization.roleLabel')"
3656
+ [class]="cls('formFieldInput', 'atlas-input')"
3657
+ [disabled]="st.busy === 'role:' + m.user.id"
3658
+ (change)="changeRole(m.user.id, $any($event.target).value)"
3659
+ >
3660
+ @for (r of roles(); track r.key) {
3661
+ <option [value]="r.key" [selected]="r.key === m.role">{{ r.name }}</option>
3662
+ }
3663
+ </select>
3664
+ <button
3665
+ type="button"
3666
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
3667
+ [disabled]="st.busy === 'remove:' + m.user.id"
3668
+ (click)="removeMember(m.user.id)"
3669
+ >
3670
+ {{ t('organization.remove') }}
3671
+ </button>
3672
+ } @else {
3673
+ <span> · {{ m.role }}</span>
3674
+ }
3675
+ </li>
3676
+ }
3677
+ </ul>
3678
+ }
3679
+
3680
+ @if (tab() === 'invitations' && canManage()) {
3681
+ <div data-atlas="organization-invitations">
3682
+ <form (submit)="$event.preventDefault(); sendInvite()">
3683
+ <label [class]="cls('formField', 'atlas-field')">
3684
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.emailLabel') }}</span>
3685
+ <input
3686
+ type="email"
3687
+ [class]="cls('formFieldInput', 'atlas-input')"
3688
+ [value]="email()"
3689
+ (input)="email.set($any($event.target).value)"
3690
+ />
3691
+ </label>
3692
+ <label [class]="cls('formField', 'atlas-field')">
3693
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.roleLabel') }}</span>
3694
+ <select [class]="cls('formFieldInput', 'atlas-input')" (change)="inviteRole.set($any($event.target).value)">
3695
+ @for (r of roles(); track r.key) {
3696
+ <option [value]="r.key" [selected]="r.key === inviteRole()">{{ r.name }}</option>
3697
+ }
3698
+ </select>
3699
+ </label>
3700
+ <button
3701
+ type="submit"
3702
+ [class]="cls('formButtonPrimary', 'atlas-button')"
3703
+ [disabled]="st.busy === 'invite' || !email().trim()"
3704
+ >
3705
+ {{ t('organization.sendInvite') }}
3706
+ </button>
3707
+ </form>
3708
+ @if (st.invitations.length === 0) {
3709
+ <p class="atlas-text">{{ t('organization.noInvitations') }}</p>
3710
+ } @else {
3711
+ <ul class="atlas-list">
3712
+ @for (inv of st.invitations; track inv.id) {
3713
+ <li data-atlas="organization-invitation">
3714
+ <span>{{ inv.email_address }} · {{ inv.role }}</span>
3715
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="resend(inv.id)">
3716
+ {{ t('organization.resend') }}
3717
+ </button>
3718
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="revoke(inv.id)">
3719
+ {{ t('organization.revoke') }}
3720
+ </button>
3721
+ </li>
3722
+ }
3723
+ </ul>
3724
+ }
3725
+ </div>
3726
+ }
3727
+
3728
+ @if (tab() === 'roles' && canManage()) {
3729
+ <div data-atlas="organization-roles">
3730
+ @if (customRoles().length === 0) {
3731
+ <p class="atlas-text">{{ t('organization.noCustomRoles') }}</p>
3732
+ } @else {
3733
+ <ul class="atlas-list">
3734
+ @for (role of customRoles(); track role.key) {
3735
+ <li data-atlas="organization-role">
3736
+ <span>{{ role.name }} · {{ role.key }}</span>
3737
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="editRole(role)">
3738
+ {{ t('organization.editRole') }}
3739
+ </button>
3740
+ <button
3741
+ type="button"
3742
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
3743
+ [disabled]="!role.id || st.busy === 'role:delete:' + role.id"
3744
+ (click)="deleteRole(role)"
3745
+ >
3746
+ {{ t('organization.deleteRole') }}
3747
+ </button>
3748
+ </li>
3749
+ }
3750
+ </ul>
3751
+ }
3752
+ <form data-atlas="organization-role-form" (submit)="$event.preventDefault(); saveRole()">
3753
+ @if (!editingRole()) {
3754
+ <label [class]="cls('formField', 'atlas-field')">
3755
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.roleKey') }}</span>
3756
+ <input
3757
+ name="role_key"
3758
+ [class]="cls('formFieldInput', 'atlas-input')"
3759
+ [value]="roleKey()"
3760
+ (input)="roleKey.set($any($event.target).value)"
3761
+ />
3762
+ </label>
3763
+ }
3764
+ <label [class]="cls('formField', 'atlas-field')">
3765
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.roleName') }}</span>
3766
+ <input
3767
+ name="role_name"
3768
+ [class]="cls('formFieldInput', 'atlas-input')"
3769
+ [value]="roleName()"
3770
+ (input)="roleName.set($any($event.target).value)"
3771
+ />
3772
+ </label>
3773
+ <fieldset [class]="cls('formField', 'atlas-field')">
3774
+ <legend [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.rolePermissions') }}</legend>
3775
+ @for (p of st.permissions; track p.key) {
3776
+ <label data-atlas="organization-role-permission">
3777
+ <input
3778
+ type="checkbox"
3779
+ [value]="p.key"
3780
+ [checked]="rolePerms().includes(p.key)"
3781
+ (change)="togglePermission(p.key, $any($event.target).checked)"
3782
+ />
3783
+ {{ p.name }}
3784
+ </label>
3785
+ }
3786
+ </fieldset>
3787
+ <button
3788
+ type="submit"
3789
+ [class]="cls('formButtonPrimary', 'atlas-button')"
3790
+ [disabled]="!roleName().trim() || (!editingRole() && !roleKey().trim()) || (st.busy ?? '').startsWith('role:')"
3791
+ >
3792
+ {{ editingRole() ? t('organization.saveRole') : t('organization.createRole') }}
3793
+ </button>
3794
+ @if (editingRole()) {
3795
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="resetRoleForm()">
3796
+ {{ t('organization.cancel') }}
3797
+ </button>
3798
+ }
3799
+ </form>
3800
+ </div>
3801
+ }
3802
+
3803
+ @if (tab() === 'settings') {
3804
+ <div data-atlas="organization-settings">
3805
+ @if (confirm(); as action) {
3806
+ <p class="atlas-text">
3807
+ {{ t(action === 'leave' ? 'organization.leaveConfirm' : 'organization.deleteConfirm', { name: membership.organization.name }) }}
3808
+ </p>
3809
+ @if (needsPassword()) {
3810
+ <label [class]="cls('formField', 'atlas-field')">
3811
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.passwordPrompt') }}</span>
3812
+ <input
3813
+ type="password"
3814
+ autocomplete="current-password"
3815
+ [class]="cls('formFieldInput', 'atlas-input')"
3816
+ [value]="password()"
3817
+ (input)="password.set($any($event.target).value)"
3818
+ />
3819
+ </label>
3820
+ }
3821
+ <button
3822
+ type="button"
3823
+ [class]="cls('formButtonPrimary', 'atlas-button')"
3824
+ [disabled]="st.busy === 'leave' || st.busy === 'delete'"
3825
+ (click)="action === 'leave' ? leave() : remove()"
3826
+ >
3827
+ {{ t('organization.confirm') }}
3828
+ </button>
3829
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="cancel()">
3830
+ {{ t('organization.cancel') }}
3831
+ </button>
3832
+ } @else {
3833
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="confirm.set('leave')">
3834
+ {{ t('organization.leave') }}
3835
+ </button>
3836
+ @if (isAdmin()) {
3837
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="confirm.set('delete')">
3838
+ {{ t('organization.delete') }}
3839
+ </button>
3840
+ }
3841
+ }
3842
+ </div>
3843
+ }
3844
+ </div>
3845
+ }
2576
3846
  }
2577
- `, isInline: true, dependencies: [{ kind: "directive", type: AtlasProtectDirective, selector: "[atlasProtect]", inputs: ["atlasProtect", "atlasProtectElse"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3847
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
2578
3848
  }
2579
3849
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: OrganizationProfileComponent, decorators: [{
2580
3850
  type: Component,
@@ -2582,21 +3852,346 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2582
3852
  selector: 'atlas-organization-profile',
2583
3853
  standalone: true,
2584
3854
  changeDetection: ChangeDetectionStrategy.OnPush,
2585
- imports: [AtlasProtectDirective],
2586
3855
  template: `
2587
3856
  @if (atlas.organization(); as membership) {
2588
- <div [class]="cls('card', 'atlas-card')">
2589
- <h1>{{ membership.organization.name }}</h1>
2590
- <p>{{ t('organization.roleLabel') }}: {{ membership.role }}</p>
3857
+ @if (state(); as st) {
3858
+ <div [class]="cls('card', 'atlas-card')" data-atlas="organization-profile">
3859
+ <h1>{{ membership.organization.name }}</h1>
3860
+ <p>{{ t('organization.roleLabel') }}: {{ membership.role }}</p>
2591
3861
 
2592
- <button *atlasProtect="{ permission: 'org:sys_memberships:manage' }">
2593
- {{ t('organization.invite') }}
2594
- </button>
3862
+ <div [class]="cls('menu', 'atlas-menu')" role="tablist">
3863
+ @for (key of tabs(); track key) {
3864
+ <button
3865
+ type="button"
3866
+ role="tab"
3867
+ [attr.aria-selected]="tab() === key"
3868
+ [class]="cls('menuItem', 'atlas-menu-item')"
3869
+ [attr.data-atlas]="'organization-tab-' + key"
3870
+ (click)="tab.set(key)"
3871
+ >
3872
+ {{ t('organization.' + key) }}
3873
+ </button>
3874
+ }
3875
+ </div>
3876
+
3877
+ @if (errorMessage(); as message) {
3878
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ message }}</p>
3879
+ }
3880
+
3881
+ @if (tab() === 'members') {
3882
+ <ul class="atlas-list" data-atlas="organization-members">
3883
+ @for (m of st.members; track m.id) {
3884
+ <li data-atlas="organization-member">
3885
+ <span>{{ memberName(m) }}{{ m.user?.id === userId() ? ' ' + t('organization.you') : '' }}</span>
3886
+ @if (canManage() && m.user && m.user.id !== userId()) {
3887
+ <select
3888
+ [attr.aria-label]="t('organization.roleLabel')"
3889
+ [class]="cls('formFieldInput', 'atlas-input')"
3890
+ [disabled]="st.busy === 'role:' + m.user.id"
3891
+ (change)="changeRole(m.user.id, $any($event.target).value)"
3892
+ >
3893
+ @for (r of roles(); track r.key) {
3894
+ <option [value]="r.key" [selected]="r.key === m.role">{{ r.name }}</option>
3895
+ }
3896
+ </select>
3897
+ <button
3898
+ type="button"
3899
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
3900
+ [disabled]="st.busy === 'remove:' + m.user.id"
3901
+ (click)="removeMember(m.user.id)"
3902
+ >
3903
+ {{ t('organization.remove') }}
3904
+ </button>
3905
+ } @else {
3906
+ <span> · {{ m.role }}</span>
3907
+ }
3908
+ </li>
3909
+ }
3910
+ </ul>
3911
+ }
3912
+
3913
+ @if (tab() === 'invitations' && canManage()) {
3914
+ <div data-atlas="organization-invitations">
3915
+ <form (submit)="$event.preventDefault(); sendInvite()">
3916
+ <label [class]="cls('formField', 'atlas-field')">
3917
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.emailLabel') }}</span>
3918
+ <input
3919
+ type="email"
3920
+ [class]="cls('formFieldInput', 'atlas-input')"
3921
+ [value]="email()"
3922
+ (input)="email.set($any($event.target).value)"
3923
+ />
3924
+ </label>
3925
+ <label [class]="cls('formField', 'atlas-field')">
3926
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.roleLabel') }}</span>
3927
+ <select [class]="cls('formFieldInput', 'atlas-input')" (change)="inviteRole.set($any($event.target).value)">
3928
+ @for (r of roles(); track r.key) {
3929
+ <option [value]="r.key" [selected]="r.key === inviteRole()">{{ r.name }}</option>
3930
+ }
3931
+ </select>
3932
+ </label>
3933
+ <button
3934
+ type="submit"
3935
+ [class]="cls('formButtonPrimary', 'atlas-button')"
3936
+ [disabled]="st.busy === 'invite' || !email().trim()"
3937
+ >
3938
+ {{ t('organization.sendInvite') }}
3939
+ </button>
3940
+ </form>
3941
+ @if (st.invitations.length === 0) {
3942
+ <p class="atlas-text">{{ t('organization.noInvitations') }}</p>
3943
+ } @else {
3944
+ <ul class="atlas-list">
3945
+ @for (inv of st.invitations; track inv.id) {
3946
+ <li data-atlas="organization-invitation">
3947
+ <span>{{ inv.email_address }} · {{ inv.role }}</span>
3948
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="resend(inv.id)">
3949
+ {{ t('organization.resend') }}
3950
+ </button>
3951
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="revoke(inv.id)">
3952
+ {{ t('organization.revoke') }}
3953
+ </button>
3954
+ </li>
3955
+ }
3956
+ </ul>
3957
+ }
3958
+ </div>
3959
+ }
3960
+
3961
+ @if (tab() === 'roles' && canManage()) {
3962
+ <div data-atlas="organization-roles">
3963
+ @if (customRoles().length === 0) {
3964
+ <p class="atlas-text">{{ t('organization.noCustomRoles') }}</p>
3965
+ } @else {
3966
+ <ul class="atlas-list">
3967
+ @for (role of customRoles(); track role.key) {
3968
+ <li data-atlas="organization-role">
3969
+ <span>{{ role.name }} · {{ role.key }}</span>
3970
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="editRole(role)">
3971
+ {{ t('organization.editRole') }}
3972
+ </button>
3973
+ <button
3974
+ type="button"
3975
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
3976
+ [disabled]="!role.id || st.busy === 'role:delete:' + role.id"
3977
+ (click)="deleteRole(role)"
3978
+ >
3979
+ {{ t('organization.deleteRole') }}
3980
+ </button>
3981
+ </li>
3982
+ }
3983
+ </ul>
3984
+ }
3985
+ <form data-atlas="organization-role-form" (submit)="$event.preventDefault(); saveRole()">
3986
+ @if (!editingRole()) {
3987
+ <label [class]="cls('formField', 'atlas-field')">
3988
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.roleKey') }}</span>
3989
+ <input
3990
+ name="role_key"
3991
+ [class]="cls('formFieldInput', 'atlas-input')"
3992
+ [value]="roleKey()"
3993
+ (input)="roleKey.set($any($event.target).value)"
3994
+ />
3995
+ </label>
3996
+ }
3997
+ <label [class]="cls('formField', 'atlas-field')">
3998
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.roleName') }}</span>
3999
+ <input
4000
+ name="role_name"
4001
+ [class]="cls('formFieldInput', 'atlas-input')"
4002
+ [value]="roleName()"
4003
+ (input)="roleName.set($any($event.target).value)"
4004
+ />
4005
+ </label>
4006
+ <fieldset [class]="cls('formField', 'atlas-field')">
4007
+ <legend [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.rolePermissions') }}</legend>
4008
+ @for (p of st.permissions; track p.key) {
4009
+ <label data-atlas="organization-role-permission">
4010
+ <input
4011
+ type="checkbox"
4012
+ [value]="p.key"
4013
+ [checked]="rolePerms().includes(p.key)"
4014
+ (change)="togglePermission(p.key, $any($event.target).checked)"
4015
+ />
4016
+ {{ p.name }}
4017
+ </label>
4018
+ }
4019
+ </fieldset>
4020
+ <button
4021
+ type="submit"
4022
+ [class]="cls('formButtonPrimary', 'atlas-button')"
4023
+ [disabled]="!roleName().trim() || (!editingRole() && !roleKey().trim()) || (st.busy ?? '').startsWith('role:')"
4024
+ >
4025
+ {{ editingRole() ? t('organization.saveRole') : t('organization.createRole') }}
4026
+ </button>
4027
+ @if (editingRole()) {
4028
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="resetRoleForm()">
4029
+ {{ t('organization.cancel') }}
4030
+ </button>
4031
+ }
4032
+ </form>
4033
+ </div>
4034
+ }
4035
+
4036
+ @if (tab() === 'settings') {
4037
+ <div data-atlas="organization-settings">
4038
+ @if (confirm(); as action) {
4039
+ <p class="atlas-text">
4040
+ {{ t(action === 'leave' ? 'organization.leaveConfirm' : 'organization.deleteConfirm', { name: membership.organization.name }) }}
4041
+ </p>
4042
+ @if (needsPassword()) {
4043
+ <label [class]="cls('formField', 'atlas-field')">
4044
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.passwordPrompt') }}</span>
4045
+ <input
4046
+ type="password"
4047
+ autocomplete="current-password"
4048
+ [class]="cls('formFieldInput', 'atlas-input')"
4049
+ [value]="password()"
4050
+ (input)="password.set($any($event.target).value)"
4051
+ />
4052
+ </label>
4053
+ }
4054
+ <button
4055
+ type="button"
4056
+ [class]="cls('formButtonPrimary', 'atlas-button')"
4057
+ [disabled]="st.busy === 'leave' || st.busy === 'delete'"
4058
+ (click)="action === 'leave' ? leave() : remove()"
4059
+ >
4060
+ {{ t('organization.confirm') }}
4061
+ </button>
4062
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="cancel()">
4063
+ {{ t('organization.cancel') }}
4064
+ </button>
4065
+ } @else {
4066
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="confirm.set('leave')">
4067
+ {{ t('organization.leave') }}
4068
+ </button>
4069
+ @if (isAdmin()) {
4070
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="confirm.set('delete')">
4071
+ {{ t('organization.delete') }}
4072
+ </button>
4073
+ }
4074
+ }
4075
+ </div>
4076
+ }
4077
+ </div>
4078
+ }
4079
+ }
4080
+ `,
4081
+ }]
4082
+ }], ctorParameters: () => [], propDecorators: { exit: [{
4083
+ type: Output
4084
+ }] } });
4085
+
4086
+ /**
4087
+ * `<atlas-organization-audit-log>` — the active organization's audit trail for
4088
+ * its admins, next to `<atlas-organization-profile>`. The Angular peer of
4089
+ * React's `<OrganizationAuditLog/>`. Renders nothing for a member who may not
4090
+ * read it (the server is the authority; the component just follows it). Labels
4091
+ * are inputs so an app can localise them.
4092
+ */
4093
+ class OrganizationAuditLogComponent {
4094
+ /** Entries per page (the server caps it). */
4095
+ pageSize;
4096
+ /** Narrow to one action, or a prefix ending in `.`. */
4097
+ action;
4098
+ labels = {};
4099
+ formatDate = (ms) => new Date(ms).toLocaleString();
4100
+ atlas = inject(AtlasService);
4101
+ /** Created in ngOnInit, once the inputs are set (templates render after it). */
4102
+ log;
4103
+ ngOnInit() {
4104
+ this.log = this.atlas.organizationAuditLog({ pageSize: this.pageSize, action: this.action });
4105
+ }
4106
+ ngOnDestroy() {
4107
+ this.log.destroy();
4108
+ }
4109
+ describe(action) {
4110
+ return describeAuditAction(action);
4111
+ }
4112
+ cls(key, base) {
4113
+ return classFor(key, this.atlas.appearance, base);
4114
+ }
4115
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: OrganizationAuditLogComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4116
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: OrganizationAuditLogComponent, isStandalone: true, selector: "atlas-organization-audit-log", inputs: { pageSize: "pageSize", action: "action", labels: "labels", formatDate: "formatDate" }, ngImport: i0, template: `
4117
+ @if (atlas.organization() && !log.state().forbidden) {
4118
+ <div [class]="cls('card', 'atlas-card')" data-atlas="organization-audit-log">
4119
+ <h2 [class]="cls('headerTitle', 'atlas-title')">{{ labels.title ?? 'Audit log' }}</h2>
4120
+ @if (log.state().entries.length === 0 && !log.state().loading) {
4121
+ <p [class]="cls('headerSubtitle', 'atlas-subtitle')">{{ labels.empty ?? 'No activity recorded yet.' }}</p>
4122
+ } @else {
4123
+ <ul [class]="cls('list', 'atlas-audit-list')">
4124
+ @for (entry of log.state().entries; track entry.id) {
4125
+ <li [class]="cls('listItem', 'atlas-audit-item')" [attr.data-action]="entry.action">
4126
+ <strong>{{ describe(entry.action) }}</strong>
4127
+ <span class="atlas-audit-meta">
4128
+ · {{ formatDate(entry.created_at) }}{{ entry.actor.id ? ' · ' + (labels.actor ?? 'by') + ' ' + entry.actor.type + ' ' + entry.actor.id : '' }}
4129
+ </span>
4130
+ </li>
4131
+ }
4132
+ </ul>
4133
+ }
4134
+ @if (log.state().hasMore) {
4135
+ <button
4136
+ type="button"
4137
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
4138
+ [disabled]="log.state().loading"
4139
+ (click)="log.loadMore()"
4140
+ >
4141
+ {{ labels.loadMore ?? 'Load more' }}
4142
+ </button>
4143
+ }
4144
+ </div>
4145
+ }
4146
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
4147
+ }
4148
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: OrganizationAuditLogComponent, decorators: [{
4149
+ type: Component,
4150
+ args: [{
4151
+ selector: 'atlas-organization-audit-log',
4152
+ standalone: true,
4153
+ changeDetection: ChangeDetectionStrategy.OnPush,
4154
+ template: `
4155
+ @if (atlas.organization() && !log.state().forbidden) {
4156
+ <div [class]="cls('card', 'atlas-card')" data-atlas="organization-audit-log">
4157
+ <h2 [class]="cls('headerTitle', 'atlas-title')">{{ labels.title ?? 'Audit log' }}</h2>
4158
+ @if (log.state().entries.length === 0 && !log.state().loading) {
4159
+ <p [class]="cls('headerSubtitle', 'atlas-subtitle')">{{ labels.empty ?? 'No activity recorded yet.' }}</p>
4160
+ } @else {
4161
+ <ul [class]="cls('list', 'atlas-audit-list')">
4162
+ @for (entry of log.state().entries; track entry.id) {
4163
+ <li [class]="cls('listItem', 'atlas-audit-item')" [attr.data-action]="entry.action">
4164
+ <strong>{{ describe(entry.action) }}</strong>
4165
+ <span class="atlas-audit-meta">
4166
+ · {{ formatDate(entry.created_at) }}{{ entry.actor.id ? ' · ' + (labels.actor ?? 'by') + ' ' + entry.actor.type + ' ' + entry.actor.id : '' }}
4167
+ </span>
4168
+ </li>
4169
+ }
4170
+ </ul>
4171
+ }
4172
+ @if (log.state().hasMore) {
4173
+ <button
4174
+ type="button"
4175
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
4176
+ [disabled]="log.state().loading"
4177
+ (click)="log.loadMore()"
4178
+ >
4179
+ {{ labels.loadMore ?? 'Load more' }}
4180
+ </button>
4181
+ }
2595
4182
  </div>
2596
4183
  }
2597
4184
  `,
2598
4185
  }]
2599
- }] });
4186
+ }], propDecorators: { pageSize: [{
4187
+ type: Input
4188
+ }], action: [{
4189
+ type: Input
4190
+ }], labels: [{
4191
+ type: Input
4192
+ }], formatDate: [{
4193
+ type: Input
4194
+ }] } });
2600
4195
 
2601
4196
  /**
2602
4197
  * Every standalone directive and component the SDK ships. Import the array in a
@@ -2620,6 +4215,7 @@ const ATLAS_UI = [
2620
4215
  OrganizationSwitcherComponent,
2621
4216
  UserProfileComponent,
2622
4217
  OrganizationProfileComponent,
4218
+ OrganizationAuditLogComponent,
2623
4219
  ];
2624
4220
 
2625
4221
  /**
@@ -2677,7 +4273,7 @@ class AtlasModule {
2677
4273
  return { ngModule: AtlasModule, providers };
2678
4274
  }
2679
4275
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
2680
- static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "17.3.12", ngImport: i0, type: AtlasModule, imports: [AtlasSignedInDirective, AtlasSignedOutDirective, AtlasLoadingDirective, AtlasLoadedDirective, AtlasProtectDirective, AtlasRootDirective, SignInComponent, SignUpComponent, UserButtonComponent, OrganizationSwitcherComponent, UserProfileComponent, OrganizationProfileComponent], exports: [AtlasSignedInDirective, AtlasSignedOutDirective, AtlasLoadingDirective, AtlasLoadedDirective, AtlasProtectDirective, AtlasRootDirective, SignInComponent, SignUpComponent, UserButtonComponent, OrganizationSwitcherComponent, UserProfileComponent, OrganizationProfileComponent] });
4276
+ static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "17.3.12", ngImport: i0, type: AtlasModule, imports: [AtlasSignedInDirective, AtlasSignedOutDirective, AtlasLoadingDirective, AtlasLoadedDirective, AtlasProtectDirective, AtlasRootDirective, SignInComponent, SignUpComponent, UserButtonComponent, OrganizationSwitcherComponent, UserProfileComponent, OrganizationProfileComponent, OrganizationAuditLogComponent], exports: [AtlasSignedInDirective, AtlasSignedOutDirective, AtlasLoadingDirective, AtlasLoadedDirective, AtlasProtectDirective, AtlasRootDirective, SignInComponent, SignUpComponent, UserButtonComponent, OrganizationSwitcherComponent, UserProfileComponent, OrganizationProfileComponent, OrganizationAuditLogComponent] });
2681
4277
  static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasModule });
2682
4278
  }
2683
4279
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasModule, decorators: [{
@@ -2690,6 +4286,75 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2690
4286
  }]
2691
4287
  }] });
2692
4288
 
4289
+ /**
4290
+ * `<atlas-impersonation-banner>` — shown only while the active session is an
4291
+ * impersonation, naming the user acted as, with "Return to admin" (or "End
4292
+ * impersonation" when there is no session of the impersonator's to resume).
4293
+ * Emits `ended` with the outcome. The Angular peer of React's
4294
+ * `<ImpersonationBanner/>`.
4295
+ */
4296
+ class ImpersonationBannerComponent {
4297
+ atlas = inject(AtlasService);
4298
+ ended = new EventEmitter();
4299
+ busy = signal(false);
4300
+ actor = computed(() => actorFromClaims(this.atlas.claims()) ?? this.atlas.session()?.actor ?? null);
4301
+ canReturn = computed(() => this.actor() !== null && Boolean(this.atlas.session()?.actor_session_id));
4302
+ name = computed(() => {
4303
+ const u = this.atlas.user();
4304
+ return ([u?.first_name, u?.last_name].filter(Boolean).join(' ') ||
4305
+ u?.username ||
4306
+ u?.email_addresses?.find((e) => e.primary)?.email_address ||
4307
+ u?.id ||
4308
+ '');
4309
+ });
4310
+ t(key, vars) {
4311
+ return translate(this.atlas.localization, key, vars);
4312
+ }
4313
+ cls(key, base) {
4314
+ return classFor(key, this.atlas.appearance, base);
4315
+ }
4316
+ async end() {
4317
+ this.busy.set(true);
4318
+ try {
4319
+ this.ended.emit(await this.atlas.endImpersonation());
4320
+ }
4321
+ finally {
4322
+ this.busy.set(false);
4323
+ }
4324
+ }
4325
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ImpersonationBannerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4326
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: ImpersonationBannerComponent, isStandalone: true, selector: "atlas-impersonation-banner", outputs: { ended: "ended" }, ngImport: i0, template: `
4327
+ @if (actor()) {
4328
+ <div [class]="cls('card', 'atlas-alert atlas-impersonation-banner')" role="status">
4329
+ <span [class]="cls('headerSubtitle', 'atlas-text')">{{ t('impersonation.banner', { name: name() }) }}</span>
4330
+ <button type="button" [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy()" (click)="end()">
4331
+ {{ canReturn() ? t('impersonation.returnToAdmin') : t('impersonation.end') }}
4332
+ </button>
4333
+ </div>
4334
+ }
4335
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
4336
+ }
4337
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ImpersonationBannerComponent, decorators: [{
4338
+ type: Component,
4339
+ args: [{
4340
+ selector: 'atlas-impersonation-banner',
4341
+ standalone: true,
4342
+ changeDetection: ChangeDetectionStrategy.OnPush,
4343
+ template: `
4344
+ @if (actor()) {
4345
+ <div [class]="cls('card', 'atlas-alert atlas-impersonation-banner')" role="status">
4346
+ <span [class]="cls('headerSubtitle', 'atlas-text')">{{ t('impersonation.banner', { name: name() }) }}</span>
4347
+ <button type="button" [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy()" (click)="end()">
4348
+ {{ canReturn() ? t('impersonation.returnToAdmin') : t('impersonation.end') }}
4349
+ </button>
4350
+ </div>
4351
+ }
4352
+ `,
4353
+ }]
4354
+ }], propDecorators: { ended: [{
4355
+ type: Output
4356
+ }] } });
4357
+
2693
4358
  /**
2694
4359
  * Route guard factory that gates a route on session — and optionally on a
2695
4360
  * permission/role. The Angular peer of using `<SignedIn>` / `<Protect>` to fence
@@ -2769,5 +4434,5 @@ function atlasChildGuard(options = {}) {
2769
4434
  * Generated bundle index. Do not edit.
2770
4435
  */
2771
4436
 
2772
- 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 };
4437
+ export { ATLAS_ANGULAR_SDK, ATLAS_ANGULAR_VERSION, ATLAS_OPTIONS, ATLAS_UI, AtlasCaptchaSlotDirective, AtlasFlowController, AtlasLoadedDirective, AtlasLoadingDirective, AtlasModule, AtlasOrganizationAuditLog, AtlasProtectDirective, AtlasRootDirective, AtlasService, AtlasSignedInDirective, AtlasSignedOutDirective, AttemptFlowComponent, DEFAULT_TOKENS, EN_US, ImpersonationBannerComponent, OrganizationAuditLogComponent, OrganizationProfileComponent, OrganizationSwitcherComponent, SignInComponent, SignUpComponent, SignUpFormComponent, THEMES, UserButtonComponent, UserProfileComponent, atlasChildGuard, atlasGuard, classFor, cssVariables, explicitTokens, provideAtlas, renderAppearance, resolveTokens, translate, withFallback };
2773
4438
  //# sourceMappingURL=atlasauth-angular.mjs.map