@atlasauth/angular 0.7.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 +29 -0
  2. package/dist/esm2022/lib/appearance.mjs +1 -1
  3. package/dist/esm2022/lib/atlas.service.mjs +45 -4
  4. package/dist/esm2022/lib/atlas.ui.mjs +3 -1
  5. package/dist/esm2022/lib/components/attempt-flow.component.mjs +398 -8
  6. package/dist/esm2022/lib/components/flow-controller.mjs +127 -7
  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 +85 -4
  13. package/dist/esm2022/lib/components/sign-up-form.component.mjs +69 -6
  14. package/dist/esm2022/lib/components/sign-up.component.mjs +83 -7
  15. package/dist/esm2022/lib/i18n.mjs +60 -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 +1798 -65
  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 +24 -2
  24. package/dist/lib/atlas.ui.d.ts +2 -1
  25. package/dist/lib/components/attempt-flow.component.d.ts +60 -3
  26. package/dist/lib/components/flow-controller.d.ts +65 -2
  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 +11 -1
  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, catalogLocale, configureReferralCapture, FapiClient, SignOutGuard, localizedErrorMessage, getTemplateToken, getProviderToken, signOutReasonFromClient, readRedirectResult, completeOAuthPopupLanding, mapOAuthPopupPayload, stripRedirectParams, closeOAuthPopupAfterSignIn, SDK_HEADER, sdkHeaderValue, reportSdkAdvisory, readExpiry, msUntilRefresh, nextStep, createCaptchaController, atlasCaptchaLabels, flowFromPending, initialFlow, fetchInstanceSettings, offeredFirstFactors, prepareFactor, signInCaptchaOptions, flowNeedsCaptcha, planFirstFactor, advanceWithCaptcha, createSignUp, shouldKeepPolling, pollAttempt } from '@atlasauth/js';
7
+ import { 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,7 +208,11 @@ 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.',
214
+ 'signIn.signedIn': 'You\'re signed in.',
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.',
174
216
  'signIn.checkOtherTab': 'You can close this tab — you are signed in where you started.',
175
217
  'signUp.title': 'Create your account',
176
218
  'signUp.submit': 'Continue',
@@ -187,6 +229,13 @@ const EN_US = {
187
229
  'signUp.and': 'and',
188
230
  // = EMAIL_UNAVAILABLE_MESSAGE from @atlasauth/js.
189
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.',
190
239
  'captcha.prompt': 'Complete the security check to continue.',
191
240
  'captcha.required': 'Please complete the security check, then try again.',
192
241
  'captcha.failed': "The security check didn't pass. Please try again.",
@@ -204,6 +253,24 @@ const EN_US = {
204
253
  'mfa.usePasskey': 'Use a passkey instead',
205
254
  'mfa.useBackupCode': 'Use a recovery code',
206
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",
207
274
  'reset.title': 'Reset your password',
208
275
  'reset.submit': 'Send reset code',
209
276
  'reset.newPasswordLabel': 'New password',
@@ -231,6 +298,35 @@ const EN_US = {
231
298
  'organization.members': 'Members',
232
299
  'organization.invite': 'Invite member',
233
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',
234
330
  'error.generic': 'Something went wrong. Please try again.',
235
331
  'error.network': 'We could not reach the server. Check your connection.',
236
332
  // Server errors, by the stable `reason` the Frontend API sends. Shown instead
@@ -291,7 +387,7 @@ function withFallback(locale) {
291
387
  }
292
388
 
293
389
  /** This package's version, sent as `X-Atlas-SDK` (kept in step with package.json by a test). */
294
- const ATLAS_ANGULAR_VERSION = '0.7.0';
390
+ const ATLAS_ANGULAR_VERSION = '0.9.0';
295
391
  const ATLAS_ANGULAR_SDK = { name: '@atlasauth/angular', version: ATLAS_ANGULAR_VERSION };
296
392
 
297
393
  /**
@@ -398,7 +494,8 @@ class AtlasService {
398
494
  this.baseUrl = resolveFrontendApi(options.frontendApi, {
399
495
  component: 'provideAtlas() / AtlasModule.forRoot()',
400
496
  });
401
- this.doFetch = options.fetchImpl ?? ((...args) => fetch(...args));
497
+ // Development instances keep the session in a header where the browser blocks the cookie.
498
+ this.doFetch = withDevBrowser(options.publishableKey, options.fetchImpl ?? ((...args) => fetch(...args)));
402
499
  this.catalog = options.localization ? withFallback(options.localization) : EN_US;
403
500
  this.errorCatalog = options.localization ?? EN_US;
404
501
  this.requestLocale = catalogLocale(options.localization);
@@ -504,6 +601,23 @@ class AtlasService {
504
601
  return null;
505
602
  }
506
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
+ }
507
621
  /** A fresh {@link FapiClient} for driving a sign-in/sign-up flow. */
508
622
  createFapiClient() {
509
623
  return new FapiClient({
@@ -571,6 +685,25 @@ class AtlasService {
571
685
  await this.reload();
572
686
  return this.guard.settle(attempt, options);
573
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
+ }
574
707
  /**
575
708
  * Switch the active organization (or `null` for the personal account). The
576
709
  * new token carries the new org claims, so the app's permission checks update
@@ -591,6 +724,7 @@ class AtlasService {
591
724
  await this.reload();
592
725
  }
593
726
  /** Re-read `/v1/client`: refresh user, session, memberships and status. */
727
+ /** Re-boot from the Frontend API. Resolves true when a session now exists in this browser. */
594
728
  async reload() {
595
729
  try {
596
730
  const response = await this.call('/v1/client');
@@ -601,7 +735,7 @@ class AtlasService {
601
735
  // refresh cookie; the guard re-tries its revoke in the background.
602
736
  if (!body.session || !body.user || this.guard.refuse(body.session.id)) {
603
737
  this.clearLocalState(body.session ? 'other_tab' : signOutReasonFromClient(body));
604
- return;
738
+ return false;
605
739
  }
606
740
  this.notifier.clear();
607
741
  this._user.next(body.user);
@@ -611,6 +745,7 @@ class AtlasService {
611
745
  this.applyToken(body.jwt, body.session.id);
612
746
  this._status.next('signed_in');
613
747
  this.scheduleRefresh();
748
+ return true;
614
749
  }
615
750
  catch {
616
751
  // A failed boot is NOT signed-out. Rendering a sign-in form because the
@@ -618,6 +753,7 @@ class AtlasService {
618
753
  // needless re-authentication. Only promote loading → signed_out.
619
754
  if (this._status.value === 'loading')
620
755
  this._status.next('signed_out');
756
+ return false;
621
757
  }
622
758
  }
623
759
  // --- Internals --------------------------------------------------------------
@@ -1078,6 +1214,8 @@ class AttemptFlowComponent {
1078
1214
  attempt = null;
1079
1215
  busy = false;
1080
1216
  errors = [];
1217
+ /** `FlowState.sessionKept`: whether this browser kept the session the sign-in created. */
1218
+ sessionKept = undefined;
1081
1219
  titleKey = 'signIn.title';
1082
1220
  /**
1083
1221
  * The sign-in captcha, drawn above the submit button on the identifier step
@@ -1097,6 +1235,38 @@ class AttemptFlowComponent {
1097
1235
  submitFlow = new EventEmitter();
1098
1236
  /** Emits when the user picks a first factor the server must send first (an emailed code). */
1099
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();
1100
1270
  values = {};
1101
1271
  t(key, vars) {
1102
1272
  return translate(this.atlas.localization, key, vars);
@@ -1115,8 +1285,45 @@ class AttemptFlowComponent {
1115
1285
  }
1116
1286
  onSubmit(event) {
1117
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
+ }
1118
1296
  this.submitFlow.emit(this.values);
1119
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
+ }
1120
1327
  formErrors() {
1121
1328
  return this.errors.filter((error) => !error.param);
1122
1329
  }
@@ -1137,6 +1344,17 @@ class AttemptFlowComponent {
1137
1344
  return [];
1138
1345
  return this.firstFactors ?? step.strategies;
1139
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
+ }
1140
1358
  showEmailCodeOption() {
1141
1359
  // Before the code is sent: the choice (or the retry of a failed automatic
1142
1360
  // send). After: "Resend code".
@@ -1154,6 +1372,11 @@ class AttemptFlowComponent {
1154
1372
  // until that code is sent.
1155
1373
  if (kind === 'collect_first_factor')
1156
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;
1157
1380
  return kind !== 'done' && kind !== 'await_oauth';
1158
1381
  }
1159
1382
  fields() {
@@ -1175,6 +1398,9 @@ class AttemptFlowComponent {
1175
1398
  case 'collect_second_factor':
1176
1399
  return [{ kind: 'input', name: 'code', labelKey: 'mfa.codeLabel', type: 'text' }];
1177
1400
  case 'enroll_second_factor':
1401
+ // The shared enrolment step draws its own fields (above).
1402
+ if (this.enrolling())
1403
+ return [];
1178
1404
  // Two consecutive codes (§5.6): a phone with a fast clock fails here
1179
1405
  // rather than on every sign-in afterwards.
1180
1406
  return [
@@ -1182,7 +1408,7 @@ class AttemptFlowComponent {
1182
1408
  { kind: 'input', name: 'secondCode', labelKey: 'mfa.enrollSecondCodeLabel', type: 'text' },
1183
1409
  ];
1184
1410
  case 'collect_email_code':
1185
- return [{ kind: 'input', name: 'code', labelKey: 'signIn.emailCodeLabel', type: 'text' }];
1411
+ return this.linkOnly() ? [] : [{ kind: 'input', name: 'code', labelKey: 'signIn.emailCodeLabel', type: 'text' }];
1186
1412
  case 'collect_new_password':
1187
1413
  return [{ kind: 'input', name: 'password', labelKey: 'reset.newPasswordLabel', type: 'password' }];
1188
1414
  case 'collect_captcha':
@@ -1192,6 +1418,12 @@ class AttemptFlowComponent {
1192
1418
  case 'await_oauth':
1193
1419
  return [msg('signIn.magicLinkSent')];
1194
1420
  case 'done':
1421
+ // Signed in only when this browser holds the session; a blocked cookie is said
1422
+ // plainly. "Check the other tab" is for a sign-in completed on another device.
1423
+ if (this.sessionKept === true)
1424
+ return [msg('signIn.signedIn')];
1425
+ if (this.sessionKept === false)
1426
+ return [{ kind: 'alert', name: '', labelKey: 'signIn.sessionNotKept', type: 'text' }];
1195
1427
  return [msg('signIn.checkOtherTab')];
1196
1428
  case 'restart':
1197
1429
  return [msg('error.generic')];
@@ -1202,7 +1434,7 @@ class AttemptFlowComponent {
1202
1434
  }
1203
1435
  }
1204
1436
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AttemptFlowComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1205
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: AttemptFlowComponent, isStandalone: true, selector: "atlas-attempt-flow", inputs: { attempt: "attempt", busy: "busy", errors: "errors", titleKey: "titleKey", captcha: "captcha", firstFactors: "firstFactors", preparedFactor: "preparedFactor" }, outputs: { submitFlow: "submitFlow", chooseFactor: "chooseFactor" }, ngImport: i0, template: `
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: `
1206
1438
  <div [class]="cls('card', 'atlas-card')">
1207
1439
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t(titleKey) }}</h1>
1208
1440
 
@@ -1214,6 +1446,98 @@ class AttemptFlowComponent {
1214
1446
  @if (codeSentText(); as sentText) {
1215
1447
  <p class="atlas-text" data-atlas-code-sent="">{{ sentText }}</p>
1216
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
+ }
1217
1541
  @for (f of fields(); track $index) {
1218
1542
  @if (f.kind === 'input') {
1219
1543
  <label class="atlas-field">
@@ -1229,6 +1553,8 @@ class AttemptFlowComponent {
1229
1553
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1230
1554
  }
1231
1555
  </label>
1556
+ } @else if (f.kind === 'alert') {
1557
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ t(f.labelKey) }}</p>
1232
1558
  } @else {
1233
1559
  <p>{{ t(f.labelKey) }}</p>
1234
1560
  }
@@ -1239,11 +1565,53 @@ class AttemptFlowComponent {
1239
1565
  }
1240
1566
 
1241
1567
  @if (showSubmit()) {
1242
- <button [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy" type="submit">
1243
- {{ t('signIn.submit') }}
1568
+ <button [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy || (enrolling() && !!enrollmentState?.busy)" type="submit">
1569
+ {{ t(submitKey()) }}
1244
1570
  </button>
1245
1571
  }
1246
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
+
1247
1615
  @if (showEmailCodeOption()) {
1248
1616
  <button
1249
1617
  [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
@@ -1279,6 +1647,98 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1279
1647
  @if (codeSentText(); as sentText) {
1280
1648
  <p class="atlas-text" data-atlas-code-sent="">{{ sentText }}</p>
1281
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
+ }
1282
1742
  @for (f of fields(); track $index) {
1283
1743
  @if (f.kind === 'input') {
1284
1744
  <label class="atlas-field">
@@ -1294,6 +1754,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1294
1754
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1295
1755
  }
1296
1756
  </label>
1757
+ } @else if (f.kind === 'alert') {
1758
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ t(f.labelKey) }}</p>
1297
1759
  } @else {
1298
1760
  <p>{{ t(f.labelKey) }}</p>
1299
1761
  }
@@ -1304,11 +1766,53 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1304
1766
  }
1305
1767
 
1306
1768
  @if (showSubmit()) {
1307
- <button [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy" type="submit">
1308
- {{ t('signIn.submit') }}
1769
+ <button [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy || (enrolling() && !!enrollmentState?.busy)" type="submit">
1770
+ {{ t(submitKey()) }}
1309
1771
  </button>
1310
1772
  }
1311
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
+
1312
1816
  @if (showEmailCodeOption()) {
1313
1817
  <button
1314
1818
  [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
@@ -1331,6 +1835,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1331
1835
  type: Input
1332
1836
  }], errors: [{
1333
1837
  type: Input
1838
+ }], sessionKept: [{
1839
+ type: Input
1334
1840
  }], titleKey: [{
1335
1841
  type: Input
1336
1842
  }], captcha: [{
@@ -1343,12 +1849,148 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1343
1849
  type: Output
1344
1850
  }], chooseFactor: [{
1345
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
1346
1872
  }] } });
1347
1873
  /** A read-only message row: `labelKey` holds the i18n key of the text to show. */
1348
1874
  function msg(labelKey) {
1349
1875
  return { kind: 'message', name: '', labelKey, type: 'text' };
1350
1876
  }
1351
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
+
1352
1994
  /**
1353
1995
  * The server-driven sign-in / sign-up engine, shared by `<atlas-sign-in>` and
1354
1996
  * `<atlas-sign-up>` — the Angular counterpart of React's `useFlow` hook.
@@ -1374,6 +2016,11 @@ class AtlasFlowController {
1374
2016
  * resolves, or when the server could not be reached.
1375
2017
  */
1376
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);
1377
2024
  /** The sign-in captcha (identifier step / `needs_captcha` challenge). */
1378
2025
  captcha = createCaptchaController(undefined, {
1379
2026
  // The Atlas captcha's words follow the form's catalog (read when drawn).
@@ -1393,6 +2040,21 @@ class AtlasFlowController {
1393
2040
  * otherwise.
1394
2041
  */
1395
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;
1396
2058
  pollTimer = null;
1397
2059
  identifier = '';
1398
2060
  constructor(client, reload, pending,
@@ -1404,7 +2066,23 @@ class AtlasFlowController {
1404
2066
  // Seeded from a redirect that came back mid-flow (e.g. OAuth needing a second
1405
2067
  // factor) so the user resumes at the demanded step instead of starting over.
1406
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));
1407
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();
1408
2086
  }
1409
2087
  /** Fetch the instance's public settings and draw whatever captcha they require. */
1410
2088
  async loadSettings() {
@@ -1412,6 +2090,8 @@ class AtlasFlowController {
1412
2090
  if (loaded) {
1413
2091
  this.settings.set(loaded);
1414
2092
  this.syncCaptcha();
2093
+ if (loaded.account.lastSignInMethod)
2094
+ this.lastMethod.set(await fetchLastSignInMethod(this.client));
1415
2095
  }
1416
2096
  }
1417
2097
  /**
@@ -1466,11 +2146,18 @@ class AtlasFlowController {
1466
2146
  this.signUpCaptchaDrawn.set(this.signUpCaptcha.needed);
1467
2147
  }
1468
2148
  setState(next) {
2149
+ const previousId = this.state().attempt?.id ?? null;
1469
2150
  this.state.set(next);
1470
2151
  // A prepared factor belongs to the first-factor step it was sent for.
1471
2152
  if (next.attempt?.status !== 'needs_first_factor')
1472
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
+ }
1473
2159
  this.syncCaptcha();
2160
+ this.syncEnrollment();
1474
2161
  this.autoSend();
1475
2162
  }
1476
2163
  autoSentFor = null;
@@ -1519,18 +2206,89 @@ class AtlasFlowController {
1519
2206
  });
1520
2207
  await this.finish(next);
1521
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
+ }
1522
2269
  async finish(next) {
1523
- this.setState(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
+ }
1524
2275
  if (next.attempt?.status === 'complete') {
2276
+ // Stays busy until this browser actually holds the session: the completion
2277
+ // alone says nothing about whether the cookie was kept.
2278
+ this.setState({ ...next, busy: true });
2279
+ let exchanged = true;
1525
2280
  if (next.ticket) {
1526
- await this.client.post('/v1/client/tickets/exchange', {
2281
+ const res = await this.client.post('/v1/client/tickets/exchange', {
1527
2282
  attempt_id: next.attempt.id,
1528
2283
  ticket: next.ticket,
1529
2284
  });
2285
+ exchanged = res.ok;
1530
2286
  }
1531
- await this.reload();
2287
+ const kept = exchanged && (await this.reload());
2288
+ this.setState({ ...next, busy: false, sessionKept: kept });
1532
2289
  }
1533
2290
  else {
2291
+ this.setState(next);
1534
2292
  // A magic-link step may now be pollable (a link opened on another device).
1535
2293
  this.syncPolling();
1536
2294
  }
@@ -1551,15 +2309,19 @@ class AtlasFlowController {
1551
2309
  void (async () => {
1552
2310
  const { state: next, ticket } = await pollAttempt(this.client, this.state());
1553
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();
1554
2315
  if (!ticket || !next.attempt)
1555
2316
  return;
1556
2317
  // The ticket is exchanged by a normal request, so no session token ever
1557
2318
  // appears in a URL.
1558
- await this.client.post('/v1/client/tickets/exchange', {
2319
+ const res = await this.client.post('/v1/client/tickets/exchange', {
1559
2320
  attempt_id: next.attempt.id,
1560
2321
  ticket,
1561
2322
  });
1562
- await this.reload();
2323
+ const kept = res.ok && (await this.reload());
2324
+ this.setState({ ...this.state(), sessionKept: kept });
1563
2325
  this.stopPolling();
1564
2326
  })();
1565
2327
  }, 2_000);
@@ -1592,6 +2354,37 @@ class SignInComponent {
1592
2354
  atlas = inject(AtlasService);
1593
2355
  /** Where to send the user once the flow completes. Reserved for host routing. */
1594
2356
  afterUrl;
2357
+ /**
2358
+ * Where "No account? Sign up" goes. Default: the instance's hosted sign-up
2359
+ * page (carrying the visitor's referral). `null` hides it. Always shown, never
2360
+ * only after a failed sign-in, so it says nothing about whether an email has an account.
2361
+ */
2362
+ signUpUrl;
2363
+ t(key) {
2364
+ return translate(this.atlas.localization, key);
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
+ }
2372
+ signUpHref() {
2373
+ if (this.signUpUrl === null)
2374
+ return null;
2375
+ if (this.signUpUrl)
2376
+ return this.signUpUrl;
2377
+ try {
2378
+ return signUp({
2379
+ frontendApi: this.atlas.frontendApi,
2380
+ redirectUrl: this.afterUrl ?? (typeof window !== 'undefined' ? window.location.href : undefined),
2381
+ navigate: false,
2382
+ });
2383
+ }
2384
+ catch {
2385
+ return null;
2386
+ }
2387
+ }
1595
2388
  flow = new AtlasFlowController(this.atlas.createFapiClient(), () => this.atlas.reload(),
1596
2389
  // Resume mid-flow if an OAuth redirect came back owing a second factor.
1597
2390
  this.atlas.pendingAttempt, (key) => translate(this.atlas.localization, key));
@@ -1608,19 +2401,42 @@ class SignInComponent {
1608
2401
  this.flow.destroy();
1609
2402
  }
1610
2403
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignInComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1611
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: SignInComponent, isStandalone: true, selector: "atlas-sign-in", inputs: { afterUrl: "afterUrl" }, ngImport: i0, template: `
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 {
1612
2413
  <atlas-attempt-flow
1613
2414
  [attempt]="flow.state().attempt"
1614
2415
  [busy]="flow.state().busy"
1615
2416
  [errors]="flow.state().errors"
2417
+ [sessionKept]="flow.state().sessionKept"
1616
2418
  titleKey="signIn.title"
1617
2419
  [captcha]="flow.captchaDrawn() ? flow.captcha : null"
1618
2420
  [firstFactors]="flow.firstFactors()"
1619
2421
  [preparedFactor]="flow.preparedFactor()"
2422
+ [offerEmailSecondFactor]="flow.settings()?.account?.emailSecondFactor === true"
2423
+ [emailedSecondFactor]="flow.emailSecondFactor()"
1620
2424
  (submitFlow)="flow.submit($event)"
1621
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()"
1622
2432
  ></atlas-attempt-flow>
1623
- `, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "titleKey", "captcha", "firstFactors", "preparedFactor"], outputs: ["submitFlow", "chooseFactor"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2433
+ }
2434
+ @if (flow.state().attempt?.status !== 'complete' && signUpHref(); as href) {
2435
+ <p class="atlas-footer" data-atlas-sign-up-prompt="">
2436
+ {{ t('signIn.noAccount') }} <a class="atlas-link" [href]="href">{{ t('signIn.signUpLink') }}</a>
2437
+ </p>
2438
+ }
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 });
1624
2440
  }
1625
2441
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignInComponent, decorators: [{
1626
2442
  type: Component,
@@ -1628,23 +2444,48 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1628
2444
  selector: 'atlas-sign-in',
1629
2445
  standalone: true,
1630
2446
  changeDetection: ChangeDetectionStrategy.OnPush,
1631
- imports: [AttemptFlowComponent],
2447
+ imports: [AttemptFlowComponent, RecoveryCodesComponent],
1632
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 {
1633
2457
  <atlas-attempt-flow
1634
2458
  [attempt]="flow.state().attempt"
1635
2459
  [busy]="flow.state().busy"
1636
2460
  [errors]="flow.state().errors"
2461
+ [sessionKept]="flow.state().sessionKept"
1637
2462
  titleKey="signIn.title"
1638
2463
  [captcha]="flow.captchaDrawn() ? flow.captcha : null"
1639
2464
  [firstFactors]="flow.firstFactors()"
1640
2465
  [preparedFactor]="flow.preparedFactor()"
2466
+ [offerEmailSecondFactor]="flow.settings()?.account?.emailSecondFactor === true"
2467
+ [emailedSecondFactor]="flow.emailSecondFactor()"
1641
2468
  (submitFlow)="flow.submit($event)"
1642
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()"
1643
2476
  ></atlas-attempt-flow>
2477
+ }
2478
+ @if (flow.state().attempt?.status !== 'complete' && signUpHref(); as href) {
2479
+ <p class="atlas-footer" data-atlas-sign-up-prompt="">
2480
+ {{ t('signIn.noAccount') }} <a class="atlas-link" [href]="href">{{ t('signIn.signUpLink') }}</a>
2481
+ </p>
2482
+ }
1644
2483
  `,
1645
2484
  }]
1646
2485
  }], propDecorators: { afterUrl: [{
1647
2486
  type: Input
2487
+ }], signUpUrl: [{
2488
+ type: Input
1648
2489
  }] } });
1649
2490
 
1650
2491
  /**
@@ -1661,6 +2502,12 @@ class SignUpFormComponent {
1661
2502
  busy = false;
1662
2503
  errors = [];
1663
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();
1664
2511
  /** Emits the typed values and the consent tick when the form is submitted. */
1665
2512
  submitSignUp = new EventEmitter();
1666
2513
  values = {};
@@ -1682,8 +2529,17 @@ class SignUpFormComponent {
1682
2529
  }
1683
2530
  /** Email + password sign-up cannot complete: the app cannot send the verification email. */
1684
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;
1685
2535
  return this.settings?.emailDelivery?.passwordSignUp === false;
1686
2536
  }
2537
+ showEmail() {
2538
+ return !this.passkey || this.passkey.email !== 'off';
2539
+ }
2540
+ emailRequired() {
2541
+ return !this.passkey || this.passkey.email === 'required';
2542
+ }
1687
2543
  fields() {
1688
2544
  return this.settings?.signUpFields ?? [];
1689
2545
  }
@@ -1730,7 +2586,7 @@ class SignUpFormComponent {
1730
2586
  });
1731
2587
  }
1732
2588
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1733
- 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: `
1734
2590
  <div [class]="cls('card', 'atlas-card')">
1735
2591
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
1736
2592
 
@@ -1819,14 +2675,17 @@ class SignUpFormComponent {
1819
2675
  }
1820
2676
  }
1821
2677
 
2678
+ @if (showEmail()) {
1822
2679
  <label [class]="cls('formField', 'atlas-field')">
1823
- <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>
1824
2683
  <input
1825
2684
  [class]="cls('formFieldInput', 'atlas-input')"
1826
2685
  type="email"
1827
2686
  name="email"
1828
2687
  autocomplete="email"
1829
- required
2688
+ [required]="emailRequired()"
1830
2689
  [value]="values['email'] ?? ''"
1831
2690
  (input)="setValue('email', $any($event.target).value)"
1832
2691
  />
@@ -1834,7 +2693,11 @@ class SignUpFormComponent {
1834
2693
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1835
2694
  }
1836
2695
  </label>
2696
+ }
1837
2697
 
2698
+ @if (passkey) {
2699
+ <p class="atlas-text">{{ t('signUp.passkeyIntro') }}</p>
2700
+ } @else {
1838
2701
  <label [class]="cls('formField', 'atlas-field')">
1839
2702
  <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('signUp.passwordLabel') }}</span>
1840
2703
  <input
@@ -1850,6 +2713,7 @@ class SignUpFormComponent {
1850
2713
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1851
2714
  }
1852
2715
  </label>
2716
+ }
1853
2717
 
1854
2718
  @if (showLegal()) {
1855
2719
  <label [class]="cls('formField', 'atlas-consent')">
@@ -1890,6 +2754,19 @@ class SignUpFormComponent {
1890
2754
  >
1891
2755
  {{ t('signUp.submit') }}
1892
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
+ }
1893
2770
  </form>
1894
2771
  }
1895
2772
  </div>
@@ -1991,14 +2868,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1991
2868
  }
1992
2869
  }
1993
2870
 
2871
+ @if (showEmail()) {
1994
2872
  <label [class]="cls('formField', 'atlas-field')">
1995
- <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>
1996
2876
  <input
1997
2877
  [class]="cls('formFieldInput', 'atlas-input')"
1998
2878
  type="email"
1999
2879
  name="email"
2000
2880
  autocomplete="email"
2001
- required
2881
+ [required]="emailRequired()"
2002
2882
  [value]="values['email'] ?? ''"
2003
2883
  (input)="setValue('email', $any($event.target).value)"
2004
2884
  />
@@ -2006,7 +2886,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2006
2886
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
2007
2887
  }
2008
2888
  </label>
2889
+ }
2009
2890
 
2891
+ @if (passkey) {
2892
+ <p class="atlas-text">{{ t('signUp.passkeyIntro') }}</p>
2893
+ } @else {
2010
2894
  <label [class]="cls('formField', 'atlas-field')">
2011
2895
  <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('signUp.passwordLabel') }}</span>
2012
2896
  <input
@@ -2022,6 +2906,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2022
2906
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
2023
2907
  }
2024
2908
  </label>
2909
+ }
2025
2910
 
2026
2911
  @if (showLegal()) {
2027
2912
  <label [class]="cls('formField', 'atlas-consent')">
@@ -2062,6 +2947,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2062
2947
  >
2063
2948
  {{ t('signUp.submit') }}
2064
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
+ }
2065
2963
  </form>
2066
2964
  }
2067
2965
  </div>
@@ -2075,6 +2973,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2075
2973
  type: Input
2076
2974
  }], captcha: [{
2077
2975
  type: Input
2976
+ }], passkey: [{
2977
+ type: Input
2978
+ }], switchable: [{
2979
+ type: Input
2980
+ }], switchMode: [{
2981
+ type: Output
2078
2982
  }], submitSignUp: [{
2079
2983
  type: Output
2080
2984
  }] } });
@@ -2096,23 +3000,68 @@ class SignUpComponent {
2096
3000
  /** Where to send the user once the flow completes. Reserved for host routing. */
2097
3001
  afterUrl;
2098
3002
  flow = new AtlasFlowController(this.atlas.createFapiClient(), () => this.atlas.reload(), this.atlas.pendingAttempt, (key) => translate(this.atlas.localization, key));
2099
- ngOnInit() {
2100
- // §5 the instance's sign-up fields, consent policy and captcha settings.
2101
- if (typeof window !== 'undefined')
2102
- void this.flow.loadSettings();
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();
2103
3007
  }
2104
- ngOnDestroy() {
2105
- this.flow.destroy();
3008
+ passkeyForm() {
3009
+ const offer = this.flow.settings()?.account.passkeySignUp ?? null;
3010
+ return this.mode() === 'passkey' && offer && this.canPasskey() ? { email: offer.email } : null;
2106
3011
  }
2107
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2108
- 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: `
2109
- @if (flow.state().attempt) {
2110
- <atlas-attempt-flow
2111
- [attempt]="flow.state().attempt"
2112
- [busy]="flow.state().busy"
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
+ }
3029
+ ngOnInit() {
3030
+ // §5 the instance's sign-up fields, consent policy and captcha settings.
3031
+ if (typeof window !== 'undefined')
3032
+ void this.flow.loadSettings();
3033
+ }
3034
+ ngOnDestroy() {
3035
+ this.flow.destroy();
3036
+ }
3037
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
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: `
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) {
3056
+ <atlas-attempt-flow
3057
+ [attempt]="flow.state().attempt"
3058
+ [busy]="flow.state().busy"
2113
3059
  [errors]="flow.state().errors"
3060
+ [sessionKept]="flow.state().sessionKept"
2114
3061
  titleKey="signUp.title"
3062
+ [emailVerification]="flow.settings()?.account?.emailVerification ?? 'code'"
2115
3063
  (submitFlow)="flow.submit($event)"
3064
+ (resend)="flow.resendSignUp()"
2116
3065
  ></atlas-attempt-flow>
2117
3066
  } @else {
2118
3067
  <atlas-sign-up-form
@@ -2120,10 +3069,15 @@ class SignUpComponent {
2120
3069
  [busy]="flow.state().busy"
2121
3070
  [errors]="flow.state().errors"
2122
3071
  [captcha]="flow.signUpCaptchaDrawn() ? flow.signUpCaptcha : null"
2123
- (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
+ "
2124
3078
  ></atlas-sign-up-form>
2125
3079
  }
2126
- `, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "titleKey", "captcha", "firstFactors", "preparedFactor"], outputs: ["submitFlow", "chooseFactor"] }, { kind: "component", type: SignUpFormComponent, selector: "atlas-sign-up-form", inputs: ["settings", "busy", "errors", "captcha"], outputs: ["submitSignUp"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
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 });
2127
3081
  }
2128
3082
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpComponent, decorators: [{
2129
3083
  type: Component,
@@ -2133,13 +3087,32 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2133
3087
  changeDetection: ChangeDetectionStrategy.OnPush,
2134
3088
  imports: [AttemptFlowComponent, SignUpFormComponent],
2135
3089
  template: `
2136
- @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) {
2137
3107
  <atlas-attempt-flow
2138
3108
  [attempt]="flow.state().attempt"
2139
3109
  [busy]="flow.state().busy"
2140
3110
  [errors]="flow.state().errors"
3111
+ [sessionKept]="flow.state().sessionKept"
2141
3112
  titleKey="signUp.title"
3113
+ [emailVerification]="flow.settings()?.account?.emailVerification ?? 'code'"
2142
3114
  (submitFlow)="flow.submit($event)"
3115
+ (resend)="flow.resendSignUp()"
2143
3116
  ></atlas-attempt-flow>
2144
3117
  } @else {
2145
3118
  <atlas-sign-up-form
@@ -2147,7 +3120,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2147
3120
  [busy]="flow.state().busy"
2148
3121
  [errors]="flow.state().errors"
2149
3122
  [captcha]="flow.signUpCaptchaDrawn() ? flow.signUpCaptcha : null"
2150
- (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
+ "
2151
3129
  ></atlas-sign-up-form>
2152
3130
  }
2153
3131
  `,
@@ -2481,32 +3459,392 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2481
3459
  }] });
2482
3460
 
2483
3461
  /**
2484
- * `<atlas-organization-profile>` — the active organization's surface. The
2485
- * Angular peer of React's `<OrganizationProfile/>`. Management controls are
2486
- * gated on the ACTIVE org role via `*atlasProtect`, matching the server's rule
2487
- * 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`.
2488
3469
  */
2489
3470
  class OrganizationProfileComponent {
2490
3471
  atlas = inject(AtlasService);
2491
- t(key) {
2492
- 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);
2493
3534
  }
2494
3535
  cls(key, base) {
2495
3536
  return classFor(key, this.atlas.appearance, base);
2496
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
+ }
2497
3621
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: OrganizationProfileComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2498
- 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: `
2499
3623
  @if (atlas.organization(); as membership) {
2500
- <div [class]="cls('card', 'atlas-card')">
2501
- <h1>{{ membership.organization.name }}</h1>
2502
- <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>
2503
3628
 
2504
- <button *atlasProtect="{ permission: 'org:sys_memberships:manage' }">
2505
- {{ t('organization.invite') }}
2506
- </button>
2507
- </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
+ }
2508
3846
  }
2509
- `, isInline: true, dependencies: [{ kind: "directive", type: AtlasProtectDirective, selector: "[atlasProtect]", inputs: ["atlasProtect", "atlasProtectElse"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3847
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
2510
3848
  }
2511
3849
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: OrganizationProfileComponent, decorators: [{
2512
3850
  type: Component,
@@ -2514,21 +3852,346 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2514
3852
  selector: 'atlas-organization-profile',
2515
3853
  standalone: true,
2516
3854
  changeDetection: ChangeDetectionStrategy.OnPush,
2517
- imports: [AtlasProtectDirective],
2518
3855
  template: `
2519
3856
  @if (atlas.organization(); as membership) {
2520
- <div [class]="cls('card', 'atlas-card')">
2521
- <h1>{{ membership.organization.name }}</h1>
2522
- <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>
2523
3861
 
2524
- <button *atlasProtect="{ permission: 'org:sys_memberships:manage' }">
2525
- {{ t('organization.invite') }}
2526
- </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
+ }
2527
4182
  </div>
2528
4183
  }
2529
4184
  `,
2530
4185
  }]
2531
- }] });
4186
+ }], propDecorators: { pageSize: [{
4187
+ type: Input
4188
+ }], action: [{
4189
+ type: Input
4190
+ }], labels: [{
4191
+ type: Input
4192
+ }], formatDate: [{
4193
+ type: Input
4194
+ }] } });
2532
4195
 
2533
4196
  /**
2534
4197
  * Every standalone directive and component the SDK ships. Import the array in a
@@ -2552,6 +4215,7 @@ const ATLAS_UI = [
2552
4215
  OrganizationSwitcherComponent,
2553
4216
  UserProfileComponent,
2554
4217
  OrganizationProfileComponent,
4218
+ OrganizationAuditLogComponent,
2555
4219
  ];
2556
4220
 
2557
4221
  /**
@@ -2609,7 +4273,7 @@ class AtlasModule {
2609
4273
  return { ngModule: AtlasModule, providers };
2610
4274
  }
2611
4275
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
2612
- 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] });
2613
4277
  static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasModule });
2614
4278
  }
2615
4279
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasModule, decorators: [{
@@ -2622,6 +4286,75 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2622
4286
  }]
2623
4287
  }] });
2624
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
+
2625
4358
  /**
2626
4359
  * Route guard factory that gates a route on session — and optionally on a
2627
4360
  * permission/role. The Angular peer of using `<SignedIn>` / `<Protect>` to fence
@@ -2701,5 +4434,5 @@ function atlasChildGuard(options = {}) {
2701
4434
  * Generated bundle index. Do not edit.
2702
4435
  */
2703
4436
 
2704
- 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 };
2705
4438
  //# sourceMappingURL=atlasauth-angular.mjs.map