@atlasauth/angular 0.8.0 → 0.10.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 (41) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/esm2022/lib/appearance.mjs +1 -1
  3. package/dist/esm2022/lib/atlas.service.mjs +40 -3
  4. package/dist/esm2022/lib/atlas.ui.mjs +7 -1
  5. package/dist/esm2022/lib/components/attempt-flow.component.mjs +419 -11
  6. package/dist/esm2022/lib/components/flow-controller.mjs +132 -2
  7. package/dist/esm2022/lib/components/impersonation-banner.component.mjs +75 -0
  8. package/dist/esm2022/lib/components/organization-audit-log.component.mjs +115 -0
  9. package/dist/esm2022/lib/components/organization-audit-log.mjs +40 -0
  10. package/dist/esm2022/lib/components/organization-profile.component.mjs +602 -27
  11. package/dist/esm2022/lib/components/policy-host.mjs +254 -0
  12. package/dist/esm2022/lib/components/recovery-codes.component.mjs +122 -0
  13. package/dist/esm2022/lib/components/sign-in.component.mjs +47 -4
  14. package/dist/esm2022/lib/components/sign-up-form.component.mjs +136 -10
  15. package/dist/esm2022/lib/components/sign-up.component.mjs +86 -8
  16. package/dist/esm2022/lib/i18n.mjs +61 -1
  17. package/dist/esm2022/lib/provide-atlas.mjs +4 -2
  18. package/dist/esm2022/lib/types.mjs +1 -1
  19. package/dist/esm2022/lib/version.mjs +2 -2
  20. package/dist/esm2022/public-api.mjs +6 -1
  21. package/dist/fesm2022/atlasauth-angular.mjs +2129 -95
  22. package/dist/fesm2022/atlasauth-angular.mjs.map +1 -1
  23. package/dist/lib/appearance.d.ts +1 -1
  24. package/dist/lib/atlas.service.d.ts +23 -1
  25. package/dist/lib/atlas.ui.d.ts +3 -1
  26. package/dist/lib/components/attempt-flow.component.d.ts +61 -2
  27. package/dist/lib/components/flow-controller.d.ts +73 -1
  28. package/dist/lib/components/impersonation-banner.component.d.ts +27 -0
  29. package/dist/lib/components/organization-audit-log.component.d.ts +34 -0
  30. package/dist/lib/components/organization-audit-log.d.ts +25 -0
  31. package/dist/lib/components/organization-profile.component.d.ts +55 -6
  32. package/dist/lib/components/policy-host.d.ts +88 -0
  33. package/dist/lib/components/recovery-codes.component.d.ts +26 -0
  34. package/dist/lib/components/sign-in.component.d.ts +2 -0
  35. package/dist/lib/components/sign-up-form.component.d.ts +22 -4
  36. package/dist/lib/components/sign-up.component.d.ts +13 -0
  37. package/dist/lib/provide-atlas.d.ts +3 -1
  38. package/dist/lib/types.d.ts +6 -0
  39. package/dist/lib/version.d.ts +1 -1
  40. package/dist/public-api.d.ts +5 -0
  41. package/package.json +3 -3
@@ -1,10 +1,10 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, inject, PLATFORM_ID, computed, Injectable, TemplateRef, ViewContainerRef, Directive, Input, ElementRef, Renderer2, EventEmitter, Output, ChangeDetectionStrategy, Component, signal, makeEnvironmentProviders, ENVIRONMENT_INITIALIZER, NgModule } from '@angular/core';
2
+ import { InjectionToken, signal, inject, PLATFORM_ID, computed, Injectable, TemplateRef, ViewContainerRef, Directive, Input, ElementRef, Renderer2, EventEmitter, Output, ChangeDetectionStrategy, Component, ChangeDetectorRef, effect, untracked, DestroyRef, makeEnvironmentProviders, ENVIRONMENT_INITIALIZER, NgModule } from '@angular/core';
3
3
  import { isPlatformBrowser, DOCUMENT } from '@angular/common';
4
4
  import { toSignal } from '@angular/core/rxjs-interop';
5
5
  import { BehaviorSubject, Subject, combineLatest, firstValueFrom } from 'rxjs';
6
6
  import { map, distinctUntilChanged, filter, take } from 'rxjs/operators';
7
- import { TokenCache, SignOutNotifier, resolveFrontendApi, withDevBrowser, catalogLocale, configureReferralCapture, FapiClient, SignOutGuard, localizedErrorMessage, getTemplateToken, getProviderToken, signOutReasonFromClient, readRedirectResult, completeOAuthPopupLanding, mapOAuthPopupPayload, stripRedirectParams, closeOAuthPopupAfterSignIn, SDK_HEADER, sdkHeaderValue, reportSdkAdvisory, readExpiry, msUntilRefresh, nextStep, createCaptchaController, atlasCaptchaLabels, flowFromPending, initialFlow, fetchInstanceSettings, offeredFirstFactors, prepareFactor, signInCaptchaOptions, flowNeedsCaptcha, planFirstFactor, advanceWithCaptcha, createSignUp, shouldKeepPolling, pollAttempt, signUp } from '@atlasauth/js';
7
+ import { createOrganizationAuditLog, POLICY_STRINGS, TokenCache, SignOutNotifier, resolveFrontendApi, withDevBrowser, catalogLocale, configureReferralCapture, FapiClient, SignOutGuard, localizedErrorMessage, reauthenticate, getTemplateToken, getProviderToken, signOutReasonFromClient, readRedirectResult, completeOAuthPopupLanding, mapOAuthPopupPayload, stripRedirectParams, closeOAuthPopupAfterSignIn, SDK_HEADER, sdkHeaderValue, reportSdkAdvisory, readExpiry, msUntilRefresh, policyTranslator, injectPolicyStyles, mountPolicyClickwrap, mountPolicyConsent, controllerForPending, mountPolicyAcceptStep, joinTitles, formatPolicyDate, PolicyGateController, groupSetupKey, enrollmentSubmitKey, nextStep, awaitingSignUpLink, canDownloadRecoveryCodes, copyRecoveryCodes, downloadRecoveryCodes, createCaptchaController, atlasCaptchaLabels, PolicyAcceptanceController, flowFromPending, initialFlow, createMfaEnrollment, mfaChoicesFrom, fetchInstanceSettings, fetchLastSignInMethod, offeredFirstFactors, prepareFactor, signInCaptchaOptions, flowNeedsCaptcha, planFirstFactor, advanceWithCaptcha, loadPolicies, createSignUp, prepareSecondFactorCode, resendSignUpVerification, createPasskeySignUp, finishPasskeySignUp, pendingRecoveryCodes, acknowledgeRecoveryCodes, shouldKeepPolling, pollAttempt, lastUsedMethodLabel, signUp, confirmPolicies, passkeysSupported, awaitingPasskey, OrganizationProfileController, describeAuditAction, actorFromClaims } from '@atlasauth/js';
8
8
  export { FapiClient, SIGN_OUT_EVENT, SignOutError, advance, flowFromPending, getProviderToken, initialFlow, onAtlasSignOut, pollAttempt, prepareFactor, requestForStep, shouldKeepPolling } from '@atlasauth/js';
9
9
  import { evaluate } from '@atlasauth/authz';
10
10
  export { evaluate, hasFromClaims } from '@atlasauth/authz';
@@ -19,6 +19,43 @@ import { Router } from '@angular/router';
19
19
  */
20
20
  const ATLAS_OPTIONS = new InjectionToken('ATLAS_OPTIONS');
21
21
 
22
+ /**
23
+ * The active organization's audit trail, for its admins — the engine behind
24
+ * `<atlas-organization-audit-log>` and `AtlasService.organizationAuditLog()`
25
+ * (the Angular peer of React's `useOrganizationAuditLog`). `state` is a signal
26
+ * of `{ entries, loading, forbidden, hasMore }`, one page at a time, newest
27
+ * first. It follows the active organization until {@link destroy}.
28
+ */
29
+ class AtlasOrganizationAuditLog {
30
+ _state;
31
+ log;
32
+ unsubscribe;
33
+ organizationSub;
34
+ state;
35
+ constructor(client, organization$, options = {}) {
36
+ this.log = createOrganizationAuditLog(client, options);
37
+ this._state = signal(this.log.getState());
38
+ this.state = this._state.asReadonly();
39
+ this.unsubscribe = this.log.subscribe((next) => this._state.set(next));
40
+ this.organizationSub = organization$
41
+ .pipe(map((membership) => membership?.organization.id ?? null), distinctUntilChanged())
42
+ .subscribe((id) => void this.log.load(id));
43
+ }
44
+ /** Append the next page, when there is one. */
45
+ loadMore() {
46
+ return this.log.loadMore();
47
+ }
48
+ /** Re-read from the newest entry. */
49
+ reload() {
50
+ return this.log.reload();
51
+ }
52
+ /** Stop following the active organization. */
53
+ destroy() {
54
+ this.organizationSub.unsubscribe();
55
+ this.unsubscribe();
56
+ }
57
+ }
58
+
22
59
  /**
23
60
  * §10.3 theming.
24
61
  *
@@ -163,6 +200,7 @@ const EN_US = {
163
200
  'signIn.submit': 'Continue',
164
201
  'signIn.forgotPassword': 'Forgot password?',
165
202
  'signIn.noAccount': 'No account?',
203
+ 'signIn.lastUsed': 'Last used',
166
204
  'signIn.signUpLink': 'Sign up',
167
205
  'signIn.orDivider': 'or',
168
206
  'signIn.socialButton': 'Continue with {provider}',
@@ -170,6 +208,8 @@ const EN_US = {
170
208
  'signIn.useEmailCode': 'Email me a code',
171
209
  'signIn.codeSentTo': 'We sent a code to {email}. Enter it below.',
172
210
  'signIn.resendCode': 'Resend code',
211
+ 'signIn.sending': 'Sending…',
212
+ 'signIn.emailCodeOption': 'Email me a code instead',
173
213
  'signIn.magicLinkSent': 'Check your email for a sign-in link.',
174
214
  'signIn.signedIn': 'You\'re signed in.',
175
215
  'signIn.sessionNotKept': 'You signed in, but this browser didn\'t keep the session. If it blocks third-party cookies, allow cookies for this site and try again.',
@@ -189,6 +229,13 @@ const EN_US = {
189
229
  'signUp.and': 'and',
190
230
  // = EMAIL_UNAVAILABLE_MESSAGE from @atlasauth/js.
191
231
  'signUp.emailUnavailable': "This app can't send email right now. Try another sign-in method or contact the app owner.",
232
+ 'signUp.passkeyOption': 'Sign up with a passkey instead',
233
+ 'signUp.passwordOption': 'Sign up with a password instead',
234
+ 'signUp.passkeyIntro': "No password needed: you'll sign in with a passkey saved on this device or in your password manager.",
235
+ 'signUp.passkeyPrompt': 'Create a passkey to finish setting up your account.',
236
+ 'signUp.passkeyCreate': 'Create passkey',
237
+ 'signUp.passkeyCancelled': "The passkey wasn't created. Try again when you're ready.",
238
+ 'signUp.linkSentTo': 'We sent a confirmation link to {email}. Open it on any device; this page continues on its own.',
192
239
  'captcha.prompt': 'Complete the security check to continue.',
193
240
  'captcha.required': 'Please complete the security check, then try again.',
194
241
  'captcha.failed': "The security check didn't pass. Please try again.",
@@ -206,6 +253,24 @@ const EN_US = {
206
253
  'mfa.usePasskey': 'Use a passkey instead',
207
254
  'mfa.useBackupCode': 'Use a recovery code',
208
255
  'mfa.submit': 'Verify',
256
+ 'mfa.enrollChoose': 'Set up a second step for signing in.',
257
+ 'mfa.enrollTotpOption': 'Use an authenticator app',
258
+ 'mfa.enrollSmsOption': 'Use text messages',
259
+ 'mfa.enrollScan': 'Scan this QR code with your authenticator app, or enter the setup key.',
260
+ 'mfa.enrollQrAlt': 'QR code for your authenticator app',
261
+ 'mfa.enrollSetupKey': 'Setup key',
262
+ 'mfa.enrollPreparing': 'Setting up…',
263
+ 'mfa.phoneLabel': 'Mobile number',
264
+ 'mfa.phoneHint': 'Include the country code, for example +14155550101.',
265
+ 'mfa.sendCode': 'Text me a code',
266
+ 'mfa.codeTextedTo': 'Enter the code we texted to {phone}.',
267
+ 'mfa.smsCodeOption': 'Text me a code instead',
268
+ 'mfa.recoveryCodesTitle': 'Save your recovery codes',
269
+ 'mfa.recoveryCodesHelp': "Each code works once. If you can't use your second step, sign in with one of these. Keep them somewhere safe: they won't be shown again.",
270
+ 'mfa.recoveryCodesCopy': 'Copy codes',
271
+ 'mfa.recoveryCodesCopied': 'Copied.',
272
+ 'mfa.recoveryCodesDownload': 'Download as .txt',
273
+ 'mfa.recoveryCodesSaved': "I've saved my codes",
209
274
  'reset.title': 'Reset your password',
210
275
  'reset.submit': 'Send reset code',
211
276
  'reset.newPasswordLabel': 'New password',
@@ -233,6 +298,37 @@ const EN_US = {
233
298
  'organization.members': 'Members',
234
299
  'organization.invite': 'Invite member',
235
300
  'organization.roleLabel': 'Role',
301
+ 'organization.invitations': 'Invitations',
302
+ 'organization.settings': 'Settings',
303
+ 'organization.emailLabel': 'Email address',
304
+ 'organization.sendInvite': 'Send invitation',
305
+ 'organization.noInvitations': 'No pending invitations.',
306
+ 'organization.remove': 'Remove',
307
+ 'organization.revoke': 'Revoke',
308
+ 'organization.resend': 'Resend',
309
+ 'organization.you': '(you)',
310
+ 'organization.leave': 'Leave organization',
311
+ 'organization.leaveConfirm': 'Leave {name}? You will lose access until you are invited again.',
312
+ 'organization.delete': 'Delete organization',
313
+ 'organization.deleteConfirm': 'Delete {name} for every member? This cannot be undone.',
314
+ 'organization.confirm': 'Confirm',
315
+ 'organization.cancel': 'Cancel',
316
+ 'organization.unnamedMember': 'Unnamed member',
317
+ 'organization.passwordPrompt': 'Enter your password to confirm.',
318
+ 'organization.roles': 'Roles',
319
+ 'organization.roleName': 'Role name',
320
+ 'organization.roleKey': 'Role key (for example org:auditor)',
321
+ 'organization.rolePermissions': 'Permissions',
322
+ 'organization.createRole': 'Create role',
323
+ 'organization.saveRole': 'Save role',
324
+ 'organization.editRole': 'Edit',
325
+ 'organization.deleteRole': 'Delete',
326
+ 'organization.noCustomRoles': 'This organization has no roles of its own yet.',
327
+ 'impersonation.banner': 'You are signed in as {name} on someone else\'s behalf.',
328
+ 'impersonation.returnToAdmin': 'Return to admin',
329
+ 'impersonation.end': 'End impersonation',
330
+ // Policy acceptance (terms, privacy, any policy).
331
+ ...POLICY_STRINGS,
236
332
  'error.generic': 'Something went wrong. Please try again.',
237
333
  'error.network': 'We could not reach the server. Check your connection.',
238
334
  // Server errors, by the stable `reason` the Frontend API sends. Shown instead
@@ -293,7 +389,7 @@ function withFallback(locale) {
293
389
  }
294
390
 
295
391
  /** This package's version, sent as `X-Atlas-SDK` (kept in step with package.json by a test). */
296
- const ATLAS_ANGULAR_VERSION = '0.8.0';
392
+ const ATLAS_ANGULAR_VERSION = '0.9.0';
297
393
  const ATLAS_ANGULAR_SDK = { name: '@atlasauth/angular', version: ATLAS_ANGULAR_VERSION };
298
394
 
299
395
  /**
@@ -507,6 +603,23 @@ class AtlasService {
507
603
  return null;
508
604
  }
509
605
  }
606
+ /**
607
+ * The active organization's audit trail, for its admins: `state` is a signal
608
+ * of `{ entries, loading, forbidden, hasMore }`, one page at a time with
609
+ * `loadMore()`. `action` narrows it (exact, or a prefix ending in `.`). It
610
+ * follows the active organization; call `destroy()` when done. The peer of
611
+ * React's `useOrganizationAuditLog`.
612
+ */
613
+ organizationAuditLog(options = {}) {
614
+ return new AtlasOrganizationAuditLog(this.createFapiClient(), this.organization$, options);
615
+ }
616
+ /**
617
+ * Re-authenticate the signed-in user with their password (a step-up), for an
618
+ * action the server answered with STEP_UP_REQUIRED. Rejects when refused.
619
+ */
620
+ reauthenticate(password) {
621
+ return reauthenticate({ strategy: 'password', password }, { api: this.baseUrl, publishableKey: this.publishableKey, fetchImpl: this.doFetch, locale: this.requestLocale });
622
+ }
510
623
  /** A fresh {@link FapiClient} for driving a sign-in/sign-up flow. */
511
624
  createFapiClient() {
512
625
  return new FapiClient({
@@ -574,6 +687,25 @@ class AtlasService {
574
687
  await this.reload();
575
688
  return this.guard.settle(attempt, options);
576
689
  }
690
+ /**
691
+ * End the active impersonation ("return to admin"): the impersonator's own
692
+ * session on this browser becomes active again with no sign-in
693
+ * (`resumed: true`); with none, another account signed in here is adopted,
694
+ * or this browser is signed out.
695
+ */
696
+ async endImpersonation() {
697
+ const outcome = await this.guard.endImpersonation(this.cache.peek()?.sessionId ?? this._session.value?.id ?? null);
698
+ if (!outcome.ended)
699
+ return outcome;
700
+ if (!outcome.activeSessionId) {
701
+ this.clearLocalState('user');
702
+ return outcome;
703
+ }
704
+ if (outcome.jwt)
705
+ this.applyToken(outcome.jwt, outcome.activeSessionId);
706
+ await this.reload();
707
+ return outcome;
708
+ }
577
709
  /**
578
710
  * Switch the active organization (or `null` for the personal account). The
579
711
  * new token carries the new org claims, so the app's permission checks update
@@ -674,7 +806,7 @@ class AtlasService {
674
806
  readPendingAttempt() {
675
807
  const result = readRedirectResult(window.location.search);
676
808
  return result?.status && !result.ticket
677
- ? { id: result.attemptId, status: result.status }
809
+ ? { id: result.attemptId, status: result.status, ...(result.policyToken ? { policyToken: result.policyToken } : {}) }
678
810
  : null;
679
811
  }
680
812
  async call(path, init) {
@@ -1069,6 +1201,254 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1069
1201
  args: ['atlasCaptchaSlot']
1070
1202
  }] } });
1071
1203
 
1204
+ /** The policy UI's translator (the localization bundle, else the English policy strings) for the current Atlas service. */
1205
+ function policyUi(atlas) {
1206
+ return {
1207
+ t: policyTranslator((key, vars) => translate(atlas.localization, key, vars)),
1208
+ classes: {
1209
+ link: 'atlas-link',
1210
+ text: 'atlas-text',
1211
+ primaryButton: classFor('formButtonPrimary', atlas.appearance, 'atlas-button'),
1212
+ error: classFor('formFieldError', atlas.appearance, 'atlas-error'),
1213
+ },
1214
+ };
1215
+ }
1216
+ /**
1217
+ * `[atlasPolicies]="controller"` — draws the instance's policies (terms, privacy,
1218
+ * …) into the host element: `kind="consent"` (default) is the checkbox block with
1219
+ * its in-page viewer, `kind="clickwrap"` the "By continuing you agree to …" line.
1220
+ * The state is the controller's (`AtlasFlowController.policies`); the DOM comes
1221
+ * from `@atlasauth/js`, shared with every SDK.
1222
+ */
1223
+ class AtlasPoliciesDirective {
1224
+ host = inject(ElementRef);
1225
+ atlas = inject(AtlasService);
1226
+ mounted = null;
1227
+ ready = false;
1228
+ controller = null;
1229
+ atlasPolicyKind = 'consent';
1230
+ ngOnInit() {
1231
+ this.ready = true;
1232
+ this.draw();
1233
+ }
1234
+ ngOnChanges(changes) {
1235
+ if (this.ready && (changes['controller'] || changes['atlasPolicyKind']))
1236
+ this.draw();
1237
+ }
1238
+ ngOnDestroy() {
1239
+ this.mounted?.destroy();
1240
+ }
1241
+ draw() {
1242
+ this.mounted?.destroy();
1243
+ this.mounted = null;
1244
+ if (!this.controller)
1245
+ return;
1246
+ const el = this.host.nativeElement;
1247
+ injectPolicyStyles(el.ownerDocument);
1248
+ const ui = policyUi(this.atlas);
1249
+ this.mounted =
1250
+ this.atlasPolicyKind === 'clickwrap'
1251
+ ? mountPolicyClickwrap(el, this.controller, ui)
1252
+ : mountPolicyConsent(el, this.controller, ui);
1253
+ }
1254
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasPoliciesDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1255
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "17.3.12", type: AtlasPoliciesDirective, isStandalone: true, selector: "[atlasPolicies]", inputs: { controller: ["atlasPolicies", "controller"], atlasPolicyKind: "atlasPolicyKind" }, usesOnChanges: true, ngImport: i0 });
1256
+ }
1257
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasPoliciesDirective, decorators: [{
1258
+ type: Directive,
1259
+ args: [{ selector: '[atlasPolicies]', standalone: true }]
1260
+ }], propDecorators: { controller: [{
1261
+ type: Input,
1262
+ args: ['atlasPolicies']
1263
+ }], atlasPolicyKind: [{
1264
+ type: Input
1265
+ }] } });
1266
+ /**
1267
+ * `<atlas-policy-accept-step>` — the sign-in step for a `needs_policy_acceptance`
1268
+ * attempt: scroll-mode policies in their unskippable dialog, the rest as
1269
+ * checkboxes, then "Accept and continue". Emits `submitFlow` with the
1270
+ * `policy_acceptances` JSON for the flow, and `declined` with the policy title
1271
+ * when the user declines (cancel the attempt).
1272
+ */
1273
+ class PolicyAcceptStepComponent {
1274
+ host = inject(ElementRef);
1275
+ atlas = inject(AtlasService);
1276
+ step = null;
1277
+ alive = true;
1278
+ attempt = null;
1279
+ busy = false;
1280
+ submitFlow = new EventEmitter();
1281
+ declined = new EventEmitter();
1282
+ loading = signal(true);
1283
+ loadingText = policyUi(this.atlas).t('policy.loading');
1284
+ ngOnInit() {
1285
+ const client = this.atlas.createFapiClient();
1286
+ void controllerForPending(client, this.attempt, { locale: this.atlas.locale ?? undefined }).then((controller) => {
1287
+ if (!this.alive)
1288
+ return;
1289
+ this.loading.set(false);
1290
+ const el = this.host.nativeElement.querySelector('.atlas-policy-step-host');
1291
+ if (!el)
1292
+ return;
1293
+ injectPolicyStyles(el.ownerDocument);
1294
+ this.step = mountPolicyAcceptStep(el, controller, {
1295
+ ...policyUi(this.atlas),
1296
+ onSubmit: (acceptances) => this.submitFlow.emit({ policy_acceptances: JSON.stringify(acceptances) }),
1297
+ onDecline: (policy) => this.declined.emit(policy.title),
1298
+ });
1299
+ this.step.setBusy(this.busy);
1300
+ });
1301
+ }
1302
+ ngOnChanges(changes) {
1303
+ if (changes['busy'])
1304
+ this.step?.setBusy(this.busy);
1305
+ }
1306
+ ngOnDestroy() {
1307
+ this.alive = false;
1308
+ this.step?.destroy();
1309
+ }
1310
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: PolicyAcceptStepComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1311
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: PolicyAcceptStepComponent, isStandalone: true, selector: "atlas-policy-accept-step", inputs: { attempt: "attempt", busy: "busy" }, outputs: { submitFlow: "submitFlow", declined: "declined" }, usesOnChanges: true, ngImport: i0, template: `@if (loading()) {<p class="atlas-text">{{ loadingText }}</p>}<div class="atlas-policy-step-host"></div>`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1312
+ }
1313
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: PolicyAcceptStepComponent, decorators: [{
1314
+ type: Component,
1315
+ args: [{
1316
+ selector: 'atlas-policy-accept-step',
1317
+ standalone: true,
1318
+ changeDetection: ChangeDetectionStrategy.OnPush,
1319
+ template: `@if (loading()) {<p class="atlas-text">{{ loadingText }}</p>}<div class="atlas-policy-step-host"></div>`,
1320
+ }]
1321
+ }], propDecorators: { attempt: [{
1322
+ type: Input
1323
+ }], busy: [{
1324
+ type: Input
1325
+ }], submitFlow: [{
1326
+ type: Output
1327
+ }], declined: [{
1328
+ type: Output
1329
+ }] } });
1330
+ /**
1331
+ * `<atlas-policy-gate>` — in-app re-acceptance. For a signed-in user, a banner
1332
+ * when a policy needs re-acceptance; "Review and accept" opens the unskippable
1333
+ * dialog / checkboxes and posts `/v1/client/me/policies/accept`. Projects its
1334
+ * content always.
1335
+ */
1336
+ class PolicyGateComponent {
1337
+ host = inject(ElementRef);
1338
+ atlas = inject(AtlasService);
1339
+ ui = policyUi(this.atlas);
1340
+ gate;
1341
+ off = null;
1342
+ step = null;
1343
+ version = signal(0);
1344
+ accepted = new EventEmitter();
1345
+ reviewing = signal(false);
1346
+ error = signal('');
1347
+ title = this.ui.t('policy.bannerTitle');
1348
+ reviewText = this.ui.t('policy.bannerReview');
1349
+ linkClass = this.ui.classes.link ?? '';
1350
+ errorClass = this.ui.classes.error ?? '';
1351
+ bannerClass = 'atlas-policy-banner';
1352
+ cardClass = classFor('card', this.atlas.appearance, 'atlas-card');
1353
+ bannerText() {
1354
+ this.version();
1355
+ if (!this.gate || this.gate.pending.length === 0 || this.reviewing())
1356
+ return null;
1357
+ const t = this.ui.t;
1358
+ const grace = this.gate.graceEndsAt;
1359
+ return (t('policy.bannerBody', { policies: joinTitles(this.gate.pending.map((p) => p.title), t('policy.and')) }) +
1360
+ (grace ? ` ${t('policy.bannerGrace', { date: formatPolicyDate(grace) })}` : ''));
1361
+ }
1362
+ ngOnInit() {
1363
+ this.gate = new PolicyGateController(this.atlas.createFapiClient(), this.atlas.locale ?? undefined);
1364
+ this.off = this.gate.subscribe(() => this.version.update((n) => n + 1));
1365
+ if (this.atlas.status() === 'signed_in')
1366
+ void this.gate.load();
1367
+ }
1368
+ async begin() {
1369
+ const controller = await this.gate.begin();
1370
+ this.reviewing.set(true);
1371
+ await Promise.resolve();
1372
+ const el = this.host.nativeElement.querySelector('.atlas-policy-gate-host');
1373
+ if (!el)
1374
+ return;
1375
+ injectPolicyStyles(el.ownerDocument);
1376
+ this.step = mountPolicyAcceptStep(el, controller, {
1377
+ ...this.ui,
1378
+ onSubmit: async () => {
1379
+ this.step?.setBusy(true);
1380
+ const res = await this.gate.accept(controller);
1381
+ this.step?.setBusy(false);
1382
+ if (!res.ok) {
1383
+ this.error.set(res.errors[0]?.message ?? this.ui.t('policy.requiredGeneric'));
1384
+ return;
1385
+ }
1386
+ this.close();
1387
+ this.accepted.emit();
1388
+ },
1389
+ onDecline: () => this.close(),
1390
+ });
1391
+ }
1392
+ close() {
1393
+ this.step?.destroy();
1394
+ this.step = null;
1395
+ this.reviewing.set(false);
1396
+ }
1397
+ ngOnDestroy() {
1398
+ this.off?.();
1399
+ this.step?.destroy();
1400
+ }
1401
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: PolicyGateComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1402
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: PolicyGateComponent, isStandalone: true, selector: "atlas-policy-gate", outputs: { accepted: "accepted" }, ngImport: i0, template: `
1403
+ @if (bannerText(); as text) {
1404
+ <div [class]="bannerClass" role="status" data-atlas-policy-banner="">
1405
+ <span
1406
+ ><strong>{{ title }}. </strong>{{ text }}</span
1407
+ >
1408
+ <button type="button" [class]="linkClass" (click)="begin()">{{ reviewText }}</button>
1409
+ </div>
1410
+ }
1411
+ @if (reviewing()) {
1412
+ <div [class]="cardClass" data-atlas-policy-gate="">
1413
+ <div class="atlas-policy-gate-host"></div>
1414
+ @if (error()) {
1415
+ <p [class]="errorClass" role="alert">{{ error() }}</p>
1416
+ }
1417
+ </div>
1418
+ }
1419
+ <ng-content></ng-content>
1420
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1421
+ }
1422
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: PolicyGateComponent, decorators: [{
1423
+ type: Component,
1424
+ args: [{
1425
+ selector: 'atlas-policy-gate',
1426
+ standalone: true,
1427
+ changeDetection: ChangeDetectionStrategy.OnPush,
1428
+ template: `
1429
+ @if (bannerText(); as text) {
1430
+ <div [class]="bannerClass" role="status" data-atlas-policy-banner="">
1431
+ <span
1432
+ ><strong>{{ title }}. </strong>{{ text }}</span
1433
+ >
1434
+ <button type="button" [class]="linkClass" (click)="begin()">{{ reviewText }}</button>
1435
+ </div>
1436
+ }
1437
+ @if (reviewing()) {
1438
+ <div [class]="cardClass" data-atlas-policy-gate="">
1439
+ <div class="atlas-policy-gate-host"></div>
1440
+ @if (error()) {
1441
+ <p [class]="errorClass" role="alert">{{ error() }}</p>
1442
+ }
1443
+ </div>
1444
+ }
1445
+ <ng-content></ng-content>
1446
+ `,
1447
+ }]
1448
+ }], propDecorators: { accepted: [{
1449
+ type: Output
1450
+ }] } });
1451
+
1072
1452
  /**
1073
1453
  * The server-driven flow shell, shared by `<atlas-sign-in>` and
1074
1454
  * `<atlas-sign-up>`. The Angular peer of React's internal `AttemptFlow`.
@@ -1105,6 +1485,40 @@ class AttemptFlowComponent {
1105
1485
  submitFlow = new EventEmitter();
1106
1486
  /** Emits when the user picks a first factor the server must send first (an emailed code). */
1107
1487
  chooseFactor = new EventEmitter();
1488
+ /**
1489
+ * Offer "Email me a code instead" at the second factor (the instance's
1490
+ * `account.emailSecondFactor`); `emailSecondFactor` asks for it and
1491
+ * `emailedSecondFactor` reports how it went.
1492
+ */
1493
+ offerEmailSecondFactor = false;
1494
+ /** `AtlasFlowController.emailSecondFactor()`: in flight, where it went, or why not. */
1495
+ emailedSecondFactor = null;
1496
+ emailSecondFactor = new EventEmitter();
1497
+ /**
1498
+ * Offer "Text me a code instead" at the second factor (where the app offers
1499
+ * texted codes, `AtlasFlowController.mfaChoices().sms`); `smsSecondFactor`
1500
+ * asks for it and `textedSecondFactor` reports how it went.
1501
+ */
1502
+ offerSmsSecondFactor = false;
1503
+ /** `AtlasFlowController.smsSecondFactor()`: in flight, where it went, or why not. */
1504
+ textedSecondFactor = null;
1505
+ smsSecondFactor = new EventEmitter();
1506
+ /**
1507
+ * The MFA enrolment step (`AtlasFlowController.enrollment` and
1508
+ * `enrollmentState()`): the authenticator app's QR code and setup key, or a
1509
+ * texted code. Null: the step asks for two authenticator codes only.
1510
+ */
1511
+ enrollment = null;
1512
+ enrollmentState = null;
1513
+ /**
1514
+ * The instance's `account.emailVerification`. On `link` a sign-up's
1515
+ * verification step has nothing to type: it says to open the emailed link
1516
+ * while the flow polls, and offers a resend (`resend`).
1517
+ */
1518
+ emailVerification = 'code';
1519
+ resend = new EventEmitter();
1520
+ /** The user declined a policy the sign-in was waiting on (`needs_policy_acceptance`): cancel the attempt. */
1521
+ policyDeclined = new EventEmitter();
1108
1522
  values = {};
1109
1523
  t(key, vars) {
1110
1524
  return translate(this.atlas.localization, key, vars);
@@ -1123,8 +1537,45 @@ class AttemptFlowComponent {
1123
1537
  }
1124
1538
  onSubmit(event) {
1125
1539
  event.preventDefault();
1540
+ // Enrolment: text the code to the typed number, or confirm the factor.
1541
+ if (this.enrolling() && this.enrollment) {
1542
+ void this.enrollment.submit(this.values).then((next) => {
1543
+ if (next)
1544
+ this.submitFlow.emit(next);
1545
+ });
1546
+ return;
1547
+ }
1126
1548
  this.submitFlow.emit(this.values);
1127
1549
  }
1550
+ /** The shared enrolment step is drawn (a controller was passed and the flow is enrolling). */
1551
+ enrolling() {
1552
+ return this.step().kind === 'enroll_second_factor' && !!this.enrollment && !!this.enrollmentState;
1553
+ }
1554
+ chooseMethod(method) {
1555
+ this.values = {};
1556
+ void this.enrollment?.choose(method);
1557
+ }
1558
+ resendEnrollment() {
1559
+ void this.enrollment?.resend();
1560
+ }
1561
+ otherMethods(state) {
1562
+ return state.options.filter((m) => m !== state.method);
1563
+ }
1564
+ setupKey(secret) {
1565
+ return groupSetupKey(secret);
1566
+ }
1567
+ /** The submit button's label key (enrolment: "Text me a code" before the number is sent). */
1568
+ submitKey() {
1569
+ return (this.enrolling() && this.enrollmentState && enrollmentSubmitKey(this.enrollmentState)) || 'signIn.submit';
1570
+ }
1571
+ showSmsSecondFactor() {
1572
+ return this.offerSmsSecondFactor && this.step().kind === 'collect_second_factor';
1573
+ }
1574
+ /** Where the texted second-factor code went (masked), once it was sent. */
1575
+ smsSecondFactorSentTo() {
1576
+ const sent = this.textedSecondFactor?.sentTo;
1577
+ return typeof sent === 'string' ? sent || ' ' : null;
1578
+ }
1128
1579
  formErrors() {
1129
1580
  return this.errors.filter((error) => !error.param);
1130
1581
  }
@@ -1145,6 +1596,17 @@ class AttemptFlowComponent {
1145
1596
  return [];
1146
1597
  return this.firstFactors ?? step.strategies;
1147
1598
  }
1599
+ linkOnly() {
1600
+ return awaitingSignUpLink(this.attempt, this.emailVerification);
1601
+ }
1602
+ showEmailSecondFactor() {
1603
+ return this.offerEmailSecondFactor && this.step().kind === 'collect_second_factor';
1604
+ }
1605
+ /** Where the emailed second-factor code went (masked), once it was sent. */
1606
+ emailSecondFactorSentTo() {
1607
+ const sent = this.emailedSecondFactor?.sentTo;
1608
+ return typeof sent === 'string' ? sent || ' ' : null;
1609
+ }
1148
1610
  showEmailCodeOption() {
1149
1611
  // Before the code is sent: the choice (or the retry of a failed automatic
1150
1612
  // send). After: "Resend code".
@@ -1156,13 +1618,22 @@ class AttemptFlowComponent {
1156
1618
  return null;
1157
1619
  return this.t('signIn.codeSentTo', { email: this.attempt?.identifier ?? '' });
1158
1620
  }
1621
+ /** The attempt is `needs_policy_acceptance`. */
1622
+ awaitingPolicies() {
1623
+ return this.step().kind === 'accept_policies';
1624
+ }
1159
1625
  showSubmit() {
1160
1626
  const kind = this.step().kind;
1161
1627
  // Nothing to submit on a first-factor step that only offers an emailed code
1162
1628
  // until that code is sent.
1163
1629
  if (kind === 'collect_first_factor')
1164
1630
  return this.fields().length > 0;
1165
- return kind !== 'done' && kind !== 'await_oauth';
1631
+ if (this.linkOnly())
1632
+ return false;
1633
+ // Enrolment: nothing to submit while choosing a method or fetching the setup.
1634
+ if (this.enrolling() && this.enrollmentState && !enrollmentSubmitKey(this.enrollmentState))
1635
+ return false;
1636
+ return kind !== 'done' && kind !== 'await_oauth' && kind !== 'accept_policies';
1166
1637
  }
1167
1638
  fields() {
1168
1639
  const step = this.step();
@@ -1183,6 +1654,9 @@ class AttemptFlowComponent {
1183
1654
  case 'collect_second_factor':
1184
1655
  return [{ kind: 'input', name: 'code', labelKey: 'mfa.codeLabel', type: 'text' }];
1185
1656
  case 'enroll_second_factor':
1657
+ // The shared enrolment step draws its own fields (above).
1658
+ if (this.enrolling())
1659
+ return [];
1186
1660
  // Two consecutive codes (§5.6): a phone with a fast clock fails here
1187
1661
  // rather than on every sign-in afterwards.
1188
1662
  return [
@@ -1190,9 +1664,12 @@ class AttemptFlowComponent {
1190
1664
  { kind: 'input', name: 'secondCode', labelKey: 'mfa.enrollSecondCodeLabel', type: 'text' },
1191
1665
  ];
1192
1666
  case 'collect_email_code':
1193
- return [{ kind: 'input', name: 'code', labelKey: 'signIn.emailCodeLabel', type: 'text' }];
1667
+ return this.linkOnly() ? [] : [{ kind: 'input', name: 'code', labelKey: 'signIn.emailCodeLabel', type: 'text' }];
1194
1668
  case 'collect_new_password':
1195
1669
  return [{ kind: 'input', name: 'password', labelKey: 'reset.newPasswordLabel', type: 'password' }];
1670
+ case 'accept_policies':
1671
+ // Drawn by <atlas-policy-accept-step> above.
1672
+ return [];
1196
1673
  case 'collect_captcha':
1197
1674
  // §5 the server withheld the attempt until a captcha clears. Solving the
1198
1675
  // widget resubmits the identifier on its own; the button retries.
@@ -1216,7 +1693,7 @@ class AttemptFlowComponent {
1216
1693
  }
1217
1694
  }
1218
1695
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AttemptFlowComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1219
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: AttemptFlowComponent, isStandalone: true, selector: "atlas-attempt-flow", inputs: { attempt: "attempt", busy: "busy", errors: "errors", sessionKept: "sessionKept", titleKey: "titleKey", captcha: "captcha", firstFactors: "firstFactors", preparedFactor: "preparedFactor" }, outputs: { submitFlow: "submitFlow", chooseFactor: "chooseFactor" }, ngImport: i0, template: `
1696
+ 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", policyDeclined: "policyDeclined" }, ngImport: i0, template: `
1220
1697
  <div [class]="cls('card', 'atlas-card')">
1221
1698
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t(titleKey) }}</h1>
1222
1699
 
@@ -1228,6 +1705,98 @@ class AttemptFlowComponent {
1228
1705
  @if (codeSentText(); as sentText) {
1229
1706
  <p class="atlas-text" data-atlas-code-sent="">{{ sentText }}</p>
1230
1707
  }
1708
+ @if (linkOnly()) {
1709
+ <!-- Nothing to type: the flow polls until the emailed link is opened (anywhere). -->
1710
+ <p class="atlas-text" data-atlas-link-sent="" aria-live="polite">
1711
+ {{ t('signUp.linkSentTo', { email: attempt?.identifier ?? '' }) }}
1712
+ </p>
1713
+ <button
1714
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1715
+ type="button"
1716
+ data-atlas-resend=""
1717
+ [disabled]="busy"
1718
+ (click)="resend.emit()"
1719
+ >
1720
+ {{ t('signIn.resendLink') }}
1721
+ </button>
1722
+ }
1723
+ @if (enrolling() && enrollmentState; as enrol) {
1724
+ @if (enrol.method === null) {
1725
+ <p class="atlas-text">{{ t('mfa.enrollChoose') }}</p>
1726
+ @for (m of enrol.options; track m) {
1727
+ <button
1728
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1729
+ type="button"
1730
+ [attr.data-atlas-mfa-method]="m"
1731
+ [disabled]="busy || enrol.busy"
1732
+ (click)="chooseMethod(m)"
1733
+ >
1734
+ {{ t(m === 'totp' ? 'mfa.enrollTotpOption' : 'mfa.enrollSmsOption') }}
1735
+ </button>
1736
+ }
1737
+ } @else if (enrol.method === 'totp') {
1738
+ @if (enrol.enrollment; as setup) {
1739
+ <p class="atlas-text">{{ t('mfa.enrollScan') }}</p>
1740
+ @if (setup.qrCode) {
1741
+ <img class="atlas-mfa-qr" data-atlas="mfa-qr" [src]="setup.qrCode" [alt]="t('mfa.enrollQrAlt')" width="180" height="180" />
1742
+ }
1743
+ @if (setup.secret) {
1744
+ <p class="atlas-text" data-atlas="mfa-setup-key">{{ t('mfa.enrollSetupKey') }}: <code>{{ setupKey(setup.secret) }}</code></p>
1745
+ }
1746
+ <label class="atlas-field">
1747
+ <span>{{ t('mfa.enrollFirstCodeLabel') }}</span>
1748
+ <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)" />
1749
+ </label>
1750
+ @if (enrol.totpCodes >= 2) {
1751
+ <label class="atlas-field">
1752
+ <span>{{ t('mfa.enrollSecondCodeLabel') }}</span>
1753
+ <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)" />
1754
+ </label>
1755
+ }
1756
+ } @else {
1757
+ <p class="atlas-text" aria-live="polite">{{ enrol.busy ? t('mfa.enrollPreparing') : '' }}</p>
1758
+ }
1759
+ } @else {
1760
+ @if (enrol.enrollment; as texted) {
1761
+ <p class="atlas-text" data-atlas="mfa-code-texted" aria-live="polite">{{ t('mfa.codeTextedTo', { phone: texted.phone ?? '' }) }}</p>
1762
+ <label class="atlas-field">
1763
+ <span>{{ t('mfa.codeLabel') }}</span>
1764
+ <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)" />
1765
+ </label>
1766
+ <button
1767
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1768
+ type="button"
1769
+ data-atlas-resend=""
1770
+ [disabled]="busy || enrol.busy"
1771
+ (click)="resendEnrollment()"
1772
+ >
1773
+ {{ t('signIn.resendCode') }}
1774
+ </button>
1775
+ } @else {
1776
+ <label class="atlas-field">
1777
+ <span>{{ t('mfa.phoneLabel') }}</span>
1778
+ <input [class]="cls('formFieldInput', 'atlas-input')" type="tel" name="phone" autocomplete="tel" [value]="values['phone'] ?? ''" (input)="setValue('phone', $any($event.target).value)" />
1779
+ </label>
1780
+ <p class="atlas-text">{{ t('mfa.phoneHint') }}</p>
1781
+ }
1782
+ }
1783
+ @for (error of enrol.errors; track $index) {
1784
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ error.message }}</p>
1785
+ }
1786
+ @if (enrol.method !== null && enrol.options.length > 1) {
1787
+ @for (other of otherMethods(enrol); track other) {
1788
+ <button
1789
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1790
+ type="button"
1791
+ [attr.data-atlas-mfa-method]="other"
1792
+ [disabled]="busy || enrol.busy"
1793
+ (click)="chooseMethod(other)"
1794
+ >
1795
+ {{ t(other === 'totp' ? 'mfa.enrollTotpOption' : 'mfa.enrollSmsOption') }}
1796
+ </button>
1797
+ }
1798
+ }
1799
+ }
1231
1800
  @for (f of fields(); track $index) {
1232
1801
  @if (f.kind === 'input') {
1233
1802
  <label class="atlas-field">
@@ -1250,16 +1819,68 @@ class AttemptFlowComponent {
1250
1819
  }
1251
1820
  }
1252
1821
 
1822
+ @if (awaitingPolicies()) {
1823
+ <!-- A required policy changed: scroll-mode ones in their dialog, the rest as checkboxes. -->
1824
+ <atlas-policy-accept-step
1825
+ [attempt]="attempt"
1826
+ [busy]="busy"
1827
+ (submitFlow)="submitFlow.emit($event)"
1828
+ (declined)="policyDeclined.emit($event)"
1829
+ ></atlas-policy-accept-step>
1830
+ }
1831
+
1253
1832
  @if (showCaptcha()) {
1254
1833
  <div class="atlas-captcha" data-atlas-captcha="" [atlasCaptchaSlot]="captcha"></div>
1255
1834
  }
1256
1835
 
1257
1836
  @if (showSubmit()) {
1258
- <button [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy" type="submit">
1259
- {{ t('signIn.submit') }}
1837
+ <button [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy || (enrolling() && !!enrollmentState?.busy)" type="submit">
1838
+ {{ t(submitKey()) }}
1260
1839
  </button>
1261
1840
  }
1262
1841
 
1842
+ @if (showSmsSecondFactor()) {
1843
+ @if (smsSecondFactorSentTo(); as sentTo) {
1844
+ <p class="atlas-text" data-atlas="sms-second-factor-sent" aria-live="polite">
1845
+ {{ t('mfa.codeTextedTo', { phone: sentTo }) }}
1846
+ </p>
1847
+ } @else {
1848
+ <button
1849
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1850
+ type="button"
1851
+ data-atlas="sms-second-factor"
1852
+ [disabled]="busy || !!textedSecondFactor?.busy"
1853
+ (click)="smsSecondFactor.emit()"
1854
+ >
1855
+ {{ textedSecondFactor?.busy ? t('signIn.sending') : t('mfa.smsCodeOption') }}
1856
+ </button>
1857
+ @if (textedSecondFactor?.error; as message) {
1858
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ message }}</p>
1859
+ }
1860
+ }
1861
+ }
1862
+
1863
+ @if (showEmailSecondFactor()) {
1864
+ @if (emailSecondFactorSentTo(); as sentTo) {
1865
+ <p class="atlas-text" data-atlas="email-second-factor-sent" aria-live="polite">
1866
+ {{ t('signIn.codeSentTo', { email: sentTo }) }}
1867
+ </p>
1868
+ } @else {
1869
+ <button
1870
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1871
+ type="button"
1872
+ data-atlas="email-second-factor"
1873
+ [disabled]="busy || !!emailedSecondFactor?.busy"
1874
+ (click)="emailSecondFactor.emit()"
1875
+ >
1876
+ {{ emailedSecondFactor?.busy ? t('signIn.sending') : t('signIn.emailCodeOption') }}
1877
+ </button>
1878
+ @if (emailedSecondFactor?.error; as message) {
1879
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ message }}</p>
1880
+ }
1881
+ }
1882
+ }
1883
+
1263
1884
  @if (showEmailCodeOption()) {
1264
1885
  <button
1265
1886
  [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
@@ -1274,7 +1895,7 @@ class AttemptFlowComponent {
1274
1895
  }
1275
1896
  </form>
1276
1897
  </div>
1277
- `, isInline: true, dependencies: [{ kind: "directive", type: AtlasCaptchaSlotDirective, selector: "[atlasCaptchaSlot]", inputs: ["atlasCaptchaSlot"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1898
+ `, isInline: true, dependencies: [{ kind: "directive", type: AtlasCaptchaSlotDirective, selector: "[atlasCaptchaSlot]", inputs: ["atlasCaptchaSlot"] }, { kind: "component", type: PolicyAcceptStepComponent, selector: "atlas-policy-accept-step", inputs: ["attempt", "busy"], outputs: ["submitFlow", "declined"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1278
1899
  }
1279
1900
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AttemptFlowComponent, decorators: [{
1280
1901
  type: Component,
@@ -1282,7 +1903,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1282
1903
  selector: 'atlas-attempt-flow',
1283
1904
  standalone: true,
1284
1905
  changeDetection: ChangeDetectionStrategy.OnPush,
1285
- imports: [AtlasCaptchaSlotDirective],
1906
+ imports: [AtlasCaptchaSlotDirective, PolicyAcceptStepComponent],
1286
1907
  template: `
1287
1908
  <div [class]="cls('card', 'atlas-card')">
1288
1909
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t(titleKey) }}</h1>
@@ -1295,6 +1916,98 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1295
1916
  @if (codeSentText(); as sentText) {
1296
1917
  <p class="atlas-text" data-atlas-code-sent="">{{ sentText }}</p>
1297
1918
  }
1919
+ @if (linkOnly()) {
1920
+ <!-- Nothing to type: the flow polls until the emailed link is opened (anywhere). -->
1921
+ <p class="atlas-text" data-atlas-link-sent="" aria-live="polite">
1922
+ {{ t('signUp.linkSentTo', { email: attempt?.identifier ?? '' }) }}
1923
+ </p>
1924
+ <button
1925
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1926
+ type="button"
1927
+ data-atlas-resend=""
1928
+ [disabled]="busy"
1929
+ (click)="resend.emit()"
1930
+ >
1931
+ {{ t('signIn.resendLink') }}
1932
+ </button>
1933
+ }
1934
+ @if (enrolling() && enrollmentState; as enrol) {
1935
+ @if (enrol.method === null) {
1936
+ <p class="atlas-text">{{ t('mfa.enrollChoose') }}</p>
1937
+ @for (m of enrol.options; track m) {
1938
+ <button
1939
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1940
+ type="button"
1941
+ [attr.data-atlas-mfa-method]="m"
1942
+ [disabled]="busy || enrol.busy"
1943
+ (click)="chooseMethod(m)"
1944
+ >
1945
+ {{ t(m === 'totp' ? 'mfa.enrollTotpOption' : 'mfa.enrollSmsOption') }}
1946
+ </button>
1947
+ }
1948
+ } @else if (enrol.method === 'totp') {
1949
+ @if (enrol.enrollment; as setup) {
1950
+ <p class="atlas-text">{{ t('mfa.enrollScan') }}</p>
1951
+ @if (setup.qrCode) {
1952
+ <img class="atlas-mfa-qr" data-atlas="mfa-qr" [src]="setup.qrCode" [alt]="t('mfa.enrollQrAlt')" width="180" height="180" />
1953
+ }
1954
+ @if (setup.secret) {
1955
+ <p class="atlas-text" data-atlas="mfa-setup-key">{{ t('mfa.enrollSetupKey') }}: <code>{{ setupKey(setup.secret) }}</code></p>
1956
+ }
1957
+ <label class="atlas-field">
1958
+ <span>{{ t('mfa.enrollFirstCodeLabel') }}</span>
1959
+ <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)" />
1960
+ </label>
1961
+ @if (enrol.totpCodes >= 2) {
1962
+ <label class="atlas-field">
1963
+ <span>{{ t('mfa.enrollSecondCodeLabel') }}</span>
1964
+ <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)" />
1965
+ </label>
1966
+ }
1967
+ } @else {
1968
+ <p class="atlas-text" aria-live="polite">{{ enrol.busy ? t('mfa.enrollPreparing') : '' }}</p>
1969
+ }
1970
+ } @else {
1971
+ @if (enrol.enrollment; as texted) {
1972
+ <p class="atlas-text" data-atlas="mfa-code-texted" aria-live="polite">{{ t('mfa.codeTextedTo', { phone: texted.phone ?? '' }) }}</p>
1973
+ <label class="atlas-field">
1974
+ <span>{{ t('mfa.codeLabel') }}</span>
1975
+ <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)" />
1976
+ </label>
1977
+ <button
1978
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
1979
+ type="button"
1980
+ data-atlas-resend=""
1981
+ [disabled]="busy || enrol.busy"
1982
+ (click)="resendEnrollment()"
1983
+ >
1984
+ {{ t('signIn.resendCode') }}
1985
+ </button>
1986
+ } @else {
1987
+ <label class="atlas-field">
1988
+ <span>{{ t('mfa.phoneLabel') }}</span>
1989
+ <input [class]="cls('formFieldInput', 'atlas-input')" type="tel" name="phone" autocomplete="tel" [value]="values['phone'] ?? ''" (input)="setValue('phone', $any($event.target).value)" />
1990
+ </label>
1991
+ <p class="atlas-text">{{ t('mfa.phoneHint') }}</p>
1992
+ }
1993
+ }
1994
+ @for (error of enrol.errors; track $index) {
1995
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ error.message }}</p>
1996
+ }
1997
+ @if (enrol.method !== null && enrol.options.length > 1) {
1998
+ @for (other of otherMethods(enrol); track other) {
1999
+ <button
2000
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
2001
+ type="button"
2002
+ [attr.data-atlas-mfa-method]="other"
2003
+ [disabled]="busy || enrol.busy"
2004
+ (click)="chooseMethod(other)"
2005
+ >
2006
+ {{ t(other === 'totp' ? 'mfa.enrollTotpOption' : 'mfa.enrollSmsOption') }}
2007
+ </button>
2008
+ }
2009
+ }
2010
+ }
1298
2011
  @for (f of fields(); track $index) {
1299
2012
  @if (f.kind === 'input') {
1300
2013
  <label class="atlas-field">
@@ -1317,16 +2030,68 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1317
2030
  }
1318
2031
  }
1319
2032
 
2033
+ @if (awaitingPolicies()) {
2034
+ <!-- A required policy changed: scroll-mode ones in their dialog, the rest as checkboxes. -->
2035
+ <atlas-policy-accept-step
2036
+ [attempt]="attempt"
2037
+ [busy]="busy"
2038
+ (submitFlow)="submitFlow.emit($event)"
2039
+ (declined)="policyDeclined.emit($event)"
2040
+ ></atlas-policy-accept-step>
2041
+ }
2042
+
1320
2043
  @if (showCaptcha()) {
1321
2044
  <div class="atlas-captcha" data-atlas-captcha="" [atlasCaptchaSlot]="captcha"></div>
1322
2045
  }
1323
2046
 
1324
2047
  @if (showSubmit()) {
1325
- <button [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy" type="submit">
1326
- {{ t('signIn.submit') }}
2048
+ <button [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy || (enrolling() && !!enrollmentState?.busy)" type="submit">
2049
+ {{ t(submitKey()) }}
1327
2050
  </button>
1328
2051
  }
1329
2052
 
2053
+ @if (showSmsSecondFactor()) {
2054
+ @if (smsSecondFactorSentTo(); as sentTo) {
2055
+ <p class="atlas-text" data-atlas="sms-second-factor-sent" aria-live="polite">
2056
+ {{ t('mfa.codeTextedTo', { phone: sentTo }) }}
2057
+ </p>
2058
+ } @else {
2059
+ <button
2060
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
2061
+ type="button"
2062
+ data-atlas="sms-second-factor"
2063
+ [disabled]="busy || !!textedSecondFactor?.busy"
2064
+ (click)="smsSecondFactor.emit()"
2065
+ >
2066
+ {{ textedSecondFactor?.busy ? t('signIn.sending') : t('mfa.smsCodeOption') }}
2067
+ </button>
2068
+ @if (textedSecondFactor?.error; as message) {
2069
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ message }}</p>
2070
+ }
2071
+ }
2072
+ }
2073
+
2074
+ @if (showEmailSecondFactor()) {
2075
+ @if (emailSecondFactorSentTo(); as sentTo) {
2076
+ <p class="atlas-text" data-atlas="email-second-factor-sent" aria-live="polite">
2077
+ {{ t('signIn.codeSentTo', { email: sentTo }) }}
2078
+ </p>
2079
+ } @else {
2080
+ <button
2081
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
2082
+ type="button"
2083
+ data-atlas="email-second-factor"
2084
+ [disabled]="busy || !!emailedSecondFactor?.busy"
2085
+ (click)="emailSecondFactor.emit()"
2086
+ >
2087
+ {{ emailedSecondFactor?.busy ? t('signIn.sending') : t('signIn.emailCodeOption') }}
2088
+ </button>
2089
+ @if (emailedSecondFactor?.error; as message) {
2090
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ message }}</p>
2091
+ }
2092
+ }
2093
+ }
2094
+
1330
2095
  @if (showEmailCodeOption()) {
1331
2096
  <button
1332
2097
  [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
@@ -1363,6 +2128,28 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1363
2128
  type: Output
1364
2129
  }], chooseFactor: [{
1365
2130
  type: Output
2131
+ }], offerEmailSecondFactor: [{
2132
+ type: Input
2133
+ }], emailedSecondFactor: [{
2134
+ type: Input
2135
+ }], emailSecondFactor: [{
2136
+ type: Output
2137
+ }], offerSmsSecondFactor: [{
2138
+ type: Input
2139
+ }], textedSecondFactor: [{
2140
+ type: Input
2141
+ }], smsSecondFactor: [{
2142
+ type: Output
2143
+ }], enrollment: [{
2144
+ type: Input
2145
+ }], enrollmentState: [{
2146
+ type: Input
2147
+ }], emailVerification: [{
2148
+ type: Input
2149
+ }], resend: [{
2150
+ type: Output
2151
+ }], policyDeclined: [{
2152
+ type: Output
1366
2153
  }] } });
1367
2154
  /** A read-only message row: `labelKey` holds the i18n key of the text to show. */
1368
2155
  function msg(labelKey) {
@@ -1370,49 +2157,190 @@ function msg(labelKey) {
1370
2157
  }
1371
2158
 
1372
2159
  /**
1373
- * The server-driven sign-in / sign-up engine, shared by `<atlas-sign-in>` and
1374
- * `<atlas-sign-up>` — the Angular counterpart of React's `useFlow` hook.
1375
- *
1376
- * It owns no decision about what comes next: every status comes back from the
1377
- * server via `advance`, and this controller only posts whatever the current step
1378
- * needs and re-renders the returned status. State lives in a signal so templates
1379
- * update whether the app runs zoned or zoneless.
1380
- *
1381
- * `<atlas-sign-up>` starts the attempt with {@link create}
1382
- * (`POST /v1/client/sign_ups`); the emailed-code step then runs through the same
1383
- * {@link submit}. Both flows draw the instance's captcha (from
1384
- * {@link loadSettings}) when it is required.
2160
+ * The recovery codes a mid-sign-in MFA enrolment came with, shown once before
2161
+ * the sign-in finishes: copy them, download them as a .txt file, then
2162
+ * "I've saved my codes" (`saved`) exchanges the ticket. The codes live only in
2163
+ * the flow controller's memory and are forgotten once saved.
1385
2164
  */
1386
- class AtlasFlowController {
1387
- client;
1388
- reload;
1389
- t;
1390
- state;
1391
- /**
1392
- * The instance's public sign-in / sign-up settings (captcha, sign-up fields,
1393
- * legal consent) from `GET /v1/appearance`; null until {@link loadSettings}
1394
- * resolves, or when the server could not be reached.
1395
- */
1396
- settings = signal(null);
1397
- /** The sign-in captcha (identifier step / `needs_captcha` challenge). */
1398
- captcha = createCaptchaController(undefined, {
1399
- // The Atlas captcha's words follow the form's catalog (read when drawn).
1400
- atlasLabels: () => atlasCaptchaLabels(this.t),
1401
- });
1402
- /** The sign-up captcha (the create-account step). */
1403
- signUpCaptcha = createCaptchaController(undefined, {
1404
- atlasLabels: () => atlasCaptchaLabels(this.t),
1405
- });
1406
- /** Whether {@link captcha} currently draws a widget (for templates). */
1407
- captchaDrawn = signal(false);
1408
- /** Whether {@link signUpCaptcha} currently draws a widget (for templates). */
1409
- signUpCaptchaDrawn = signal(false);
1410
- /**
1411
- * The first factor the server was asked to send ({@link prepare}) on the
1412
- * current `needs_first_factor` step — the form then collects its code. Null
1413
- * otherwise.
1414
- */
1415
- preparedFactor = signal(null);
2165
+ class RecoveryCodesComponent {
2166
+ atlas = inject(AtlasService);
2167
+ cdr = inject(ChangeDetectorRef);
2168
+ codes = [];
2169
+ busy = false;
2170
+ saved = new EventEmitter();
2171
+ canCopy = typeof navigator !== 'undefined' && !!navigator.clipboard?.writeText;
2172
+ canDownload = canDownloadRecoveryCodes();
2173
+ copied = false;
2174
+ ngOnChanges() {
2175
+ this.copied = false;
2176
+ }
2177
+ t(key) {
2178
+ return translate(this.atlas.localization, key);
2179
+ }
2180
+ cls(key, base) {
2181
+ return classFor(key, this.atlas.appearance, base);
2182
+ }
2183
+ copy() {
2184
+ void copyRecoveryCodes(this.codes).then((ok) => {
2185
+ this.copied = ok;
2186
+ this.cdr.markForCheck();
2187
+ });
2188
+ }
2189
+ download() {
2190
+ downloadRecoveryCodes(this.codes);
2191
+ }
2192
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: RecoveryCodesComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2193
+ 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: `
2194
+ <div [class]="cls('card', 'atlas-card')" data-atlas="recovery-codes">
2195
+ <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('mfa.recoveryCodesTitle') }}</h1>
2196
+ <p class="atlas-text">{{ t('mfa.recoveryCodesHelp') }}</p>
2197
+ <ul
2198
+ class="atlas-recovery-codes"
2199
+ data-atlas="recovery-codes-list"
2200
+ 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"
2201
+ >
2202
+ @for (code of codes; track code) {
2203
+ <li><code>{{ code }}</code></li>
2204
+ }
2205
+ </ul>
2206
+ <div class="atlas-alternatives">
2207
+ @if (canCopy) {
2208
+ <button [class]="cls('formButtonSecondary', 'atlas-button-secondary')" type="button" data-atlas="recovery-codes-copy" (click)="copy()">
2209
+ {{ t('mfa.recoveryCodesCopy') }}
2210
+ </button>
2211
+ }
2212
+ @if (canDownload) {
2213
+ <button [class]="cls('formButtonSecondary', 'atlas-button-secondary')" type="button" data-atlas="recovery-codes-download" (click)="download()">
2214
+ {{ t('mfa.recoveryCodesDownload') }}
2215
+ </button>
2216
+ }
2217
+ </div>
2218
+ @if (copied) {
2219
+ <p class="atlas-text" aria-live="polite" data-atlas="recovery-codes-copied">{{ t('mfa.recoveryCodesCopied') }}</p>
2220
+ }
2221
+ <button [class]="cls('formButtonPrimary', 'atlas-button')" type="button" data-atlas="recovery-codes-saved" [disabled]="busy" (click)="saved.emit()">
2222
+ {{ t('mfa.recoveryCodesSaved') }}
2223
+ </button>
2224
+ </div>
2225
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
2226
+ }
2227
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: RecoveryCodesComponent, decorators: [{
2228
+ type: Component,
2229
+ args: [{
2230
+ selector: 'atlas-recovery-codes',
2231
+ standalone: true,
2232
+ changeDetection: ChangeDetectionStrategy.OnPush,
2233
+ template: `
2234
+ <div [class]="cls('card', 'atlas-card')" data-atlas="recovery-codes">
2235
+ <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('mfa.recoveryCodesTitle') }}</h1>
2236
+ <p class="atlas-text">{{ t('mfa.recoveryCodesHelp') }}</p>
2237
+ <ul
2238
+ class="atlas-recovery-codes"
2239
+ data-atlas="recovery-codes-list"
2240
+ 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"
2241
+ >
2242
+ @for (code of codes; track code) {
2243
+ <li><code>{{ code }}</code></li>
2244
+ }
2245
+ </ul>
2246
+ <div class="atlas-alternatives">
2247
+ @if (canCopy) {
2248
+ <button [class]="cls('formButtonSecondary', 'atlas-button-secondary')" type="button" data-atlas="recovery-codes-copy" (click)="copy()">
2249
+ {{ t('mfa.recoveryCodesCopy') }}
2250
+ </button>
2251
+ }
2252
+ @if (canDownload) {
2253
+ <button [class]="cls('formButtonSecondary', 'atlas-button-secondary')" type="button" data-atlas="recovery-codes-download" (click)="download()">
2254
+ {{ t('mfa.recoveryCodesDownload') }}
2255
+ </button>
2256
+ }
2257
+ </div>
2258
+ @if (copied) {
2259
+ <p class="atlas-text" aria-live="polite" data-atlas="recovery-codes-copied">{{ t('mfa.recoveryCodesCopied') }}</p>
2260
+ }
2261
+ <button [class]="cls('formButtonPrimary', 'atlas-button')" type="button" data-atlas="recovery-codes-saved" [disabled]="busy" (click)="saved.emit()">
2262
+ {{ t('mfa.recoveryCodesSaved') }}
2263
+ </button>
2264
+ </div>
2265
+ `,
2266
+ }]
2267
+ }], propDecorators: { codes: [{
2268
+ type: Input
2269
+ }], busy: [{
2270
+ type: Input
2271
+ }], saved: [{
2272
+ type: Output
2273
+ }] } });
2274
+
2275
+ /**
2276
+ * The server-driven sign-in / sign-up engine, shared by `<atlas-sign-in>` and
2277
+ * `<atlas-sign-up>` — the Angular counterpart of React's `useFlow` hook.
2278
+ *
2279
+ * It owns no decision about what comes next: every status comes back from the
2280
+ * server via `advance`, and this controller only posts whatever the current step
2281
+ * needs and re-renders the returned status. State lives in a signal so templates
2282
+ * update whether the app runs zoned or zoneless.
2283
+ *
2284
+ * `<atlas-sign-up>` starts the attempt with {@link create}
2285
+ * (`POST /v1/client/sign_ups`); the emailed-code step then runs through the same
2286
+ * {@link submit}. Both flows draw the instance's captcha (from
2287
+ * {@link loadSettings}) when it is required.
2288
+ */
2289
+ class AtlasFlowController {
2290
+ client;
2291
+ reload;
2292
+ t;
2293
+ state;
2294
+ /**
2295
+ * The instance's public sign-in / sign-up settings (captcha, sign-up fields,
2296
+ * legal consent) from `GET /v1/appearance`; null until {@link loadSettings}
2297
+ * resolves, or when the server could not be reached.
2298
+ */
2299
+ settings = signal(null);
2300
+ /**
2301
+ * The method this browser last signed in with (`password`, `oauth_github`, …)
2302
+ * for the "Last used" hint; null when none, or the instance turned it off.
2303
+ */
2304
+ lastMethod = signal(null);
2305
+ /** The sign-in captcha (identifier step / `needs_captcha` challenge). */
2306
+ captcha = createCaptchaController(undefined, {
2307
+ // The Atlas captcha's words follow the form's catalog (read when drawn).
2308
+ atlasLabels: () => atlasCaptchaLabels(this.t),
2309
+ });
2310
+ /** The sign-up captcha (the create-account step). */
2311
+ signUpCaptcha = createCaptchaController(undefined, {
2312
+ atlasLabels: () => atlasCaptchaLabels(this.t),
2313
+ });
2314
+ /** Whether {@link captcha} currently draws a widget (for templates). */
2315
+ captchaDrawn = signal(false);
2316
+ /** Whether {@link signUpCaptcha} currently draws a widget (for templates). */
2317
+ signUpCaptchaDrawn = signal(false);
2318
+ /**
2319
+ * The first factor the server was asked to send ({@link prepare}) on the
2320
+ * current `needs_first_factor` step — the form then collects its code. Null
2321
+ * otherwise.
2322
+ */
2323
+ preparedFactor = signal(null);
2324
+ /**
2325
+ * "Email me a code instead" at the second factor ({@link sendEmailSecondFactor}):
2326
+ * in flight, where the code went, or why it was not sent. Null until asked,
2327
+ * and again once the flow leaves that step.
2328
+ */
2329
+ emailSecondFactor = signal(null);
2330
+ /** "Text me a code instead" at the second factor ({@link sendSmsSecondFactor}): as {@link emailSecondFactor}. */
2331
+ smsSecondFactor = signal(null);
2332
+ /**
2333
+ * The MFA enrolment step (`needs_mfa_enrollment`): the authenticator app's
2334
+ * setup (QR code, setup key) or a texted code. Started and reset with the flow.
2335
+ */
2336
+ enrollment;
2337
+ /** {@link enrollment}'s state, for templates. */
2338
+ enrollmentState;
2339
+ /**
2340
+ * The instance's policies (terms, privacy, …) for the create-account form:
2341
+ * {@link create} sends their acceptances and reloads them after a refusal.
2342
+ */
2343
+ policies = new PolicyAcceptanceController([]);
1416
2344
  pollTimer = null;
1417
2345
  identifier = '';
1418
2346
  constructor(client, reload, pending,
@@ -1424,7 +2352,23 @@ class AtlasFlowController {
1424
2352
  // Seeded from a redirect that came back mid-flow (e.g. OAuth needing a second
1425
2353
  // factor) so the user resumes at the demanded step instead of starting over.
1426
2354
  this.state = signal(pending ? flowFromPending(pending) : initialFlow);
2355
+ this.enrollment = createMfaEnrollment(this.client, () => this.mfaChoices());
2356
+ this.enrollmentState = signal(this.enrollment.state);
2357
+ this.enrollment.subscribe((next) => this.enrollmentState.set(next));
1427
2358
  this.syncCaptcha();
2359
+ this.syncEnrollment();
2360
+ }
2361
+ /** What the app lets the MFA steps offer (from {@link settings}). */
2362
+ mfaChoices() {
2363
+ return mfaChoicesFrom(this.settings()?.account);
2364
+ }
2365
+ /** Start the enrolment step for its attempt, or reset it once the flow leaves. */
2366
+ syncEnrollment() {
2367
+ const attempt = this.state().attempt;
2368
+ if (attempt?.status === 'needs_mfa_enrollment' && attempt.id)
2369
+ this.enrollment.start(attempt.id);
2370
+ else
2371
+ this.enrollment.reset();
1428
2372
  }
1429
2373
  /** Fetch the instance's public settings and draw whatever captcha they require. */
1430
2374
  async loadSettings() {
@@ -1432,6 +2376,8 @@ class AtlasFlowController {
1432
2376
  if (loaded) {
1433
2377
  this.settings.set(loaded);
1434
2378
  this.syncCaptcha();
2379
+ if (loaded.account.lastSignInMethod)
2380
+ this.lastMethod.set(await fetchLastSignInMethod(this.client));
1435
2381
  }
1436
2382
  }
1437
2383
  /**
@@ -1486,11 +2432,18 @@ class AtlasFlowController {
1486
2432
  this.signUpCaptchaDrawn.set(this.signUpCaptcha.needed);
1487
2433
  }
1488
2434
  setState(next) {
2435
+ const previousId = this.state().attempt?.id ?? null;
1489
2436
  this.state.set(next);
1490
2437
  // A prepared factor belongs to the first-factor step it was sent for.
1491
2438
  if (next.attempt?.status !== 'needs_first_factor')
1492
2439
  this.preparedFactor.set(null);
2440
+ // An emailed second-factor code belongs to the second-factor step of this attempt.
2441
+ if (next.attempt?.status !== 'needs_second_factor' || (next.attempt?.id ?? null) !== previousId) {
2442
+ this.emailSecondFactor.set(null);
2443
+ this.smsSecondFactor.set(null);
2444
+ }
1493
2445
  this.syncCaptcha();
2446
+ this.syncEnrollment();
1494
2447
  this.autoSend();
1495
2448
  }
1496
2449
  autoSentFor = null;
@@ -1524,6 +2477,17 @@ class AtlasFlowController {
1524
2477
  this.identifier = out.identifier;
1525
2478
  await this.finish(out.state);
1526
2479
  }
2480
+ /** Load the sign-up's policies into {@link policies} (`<atlas-sign-up>` calls it on init). */
2481
+ async loadPolicies(locale) {
2482
+ this.policies.setPolicies(await loadPolicies(this.client, { locale }));
2483
+ }
2484
+ /** The user declined a policy a sign-in was waiting on: cancel the attempt and say why. */
2485
+ declinePolicy(title) {
2486
+ this.state.set({
2487
+ ...initialFlow,
2488
+ errors: [{ code: 'POLICY_DECLINED', message: this.t('policy.declined').replace('{policy}', title) }],
2489
+ });
2490
+ }
1527
2491
  /**
1528
2492
  * Create an account (`POST /v1/client/sign_ups`) from a create-account form's
1529
2493
  * values — email, password and any configured sign-up fields — carrying the
@@ -1536,10 +2500,76 @@ class AtlasFlowController {
1536
2500
  consent,
1537
2501
  captcha: this.signUpCaptcha,
1538
2502
  t: this.t,
2503
+ policies: this.policies,
1539
2504
  });
1540
2505
  await this.finish(next);
1541
2506
  }
2507
+ /**
2508
+ * "Email me a code instead" at the second factor (the instance's
2509
+ * `account.emailSecondFactor`): mails a code to the account's verified email;
2510
+ * it is then posted with {@link submit} like any 2FA code.
2511
+ */
2512
+ async sendEmailSecondFactor() {
2513
+ await this.sendSecondFactorCode('email_code', this.emailSecondFactor);
2514
+ }
2515
+ /**
2516
+ * "Text me a code instead" at the second factor (where the app offers texted
2517
+ * codes, {@link mfaChoices}`().sms`): texts a code to the account's enrolled
2518
+ * phone; it is then posted with {@link submit} like any 2FA code.
2519
+ */
2520
+ async sendSmsSecondFactor() {
2521
+ await this.sendSecondFactorCode('sms', this.smsSecondFactor);
2522
+ }
2523
+ async sendSecondFactorCode(strategy, out) {
2524
+ const id = this.state().attempt?.id;
2525
+ if (!id)
2526
+ return;
2527
+ out.set({ busy: true });
2528
+ try {
2529
+ const { sentTo } = await prepareSecondFactorCode(this.client, id, strategy);
2530
+ out.set({ sentTo });
2531
+ }
2532
+ catch (error) {
2533
+ out.set({ error: error instanceof Error ? error.message : this.t('error.generic') });
2534
+ }
2535
+ }
2536
+ /** Resend a sign-up's emailed code / link (the earlier one stops working). */
2537
+ async resendSignUp() {
2538
+ this.state.update((current) => ({ ...current, busy: true, errors: [] }));
2539
+ this.state.set(await resendSignUpVerification(this.client, this.state()));
2540
+ }
2541
+ /**
2542
+ * Start a passkey-only sign-up (`POST /v1/client/sign_ups/passkey`) from the
2543
+ * create-account form's values — no password. Offered when the instance's
2544
+ * `account.passkeySignUp` is set.
2545
+ */
2546
+ async createWithPasskey(values, consent = true) {
2547
+ this.state.update((current) => ({ ...current, busy: true, errors: [] }));
2548
+ await this.finish(await createPasskeySignUp(this.client, values, { consent, captcha: this.signUpCaptcha, t: this.t, policies: this.policies }));
2549
+ }
2550
+ /** The passkey-only sign-up's last step: create the passkey, and with it the account. */
2551
+ async createPasskey() {
2552
+ this.state.update((current) => ({ ...current, busy: true, errors: [] }));
2553
+ await this.finish(await finishPasskeySignUp(this.client, this.state(), { t: this.t }));
2554
+ }
2555
+ /**
2556
+ * The recovery codes an MFA enrolment just completed the sign-in with, shown
2557
+ * once before it finishes ({@link saveRecoveryCodes}); null otherwise. They
2558
+ * live only in this controller's memory.
2559
+ */
2560
+ recoveryCodes() {
2561
+ return pendingRecoveryCodes(this.state());
2562
+ }
2563
+ /** "I've saved my codes": forget the recovery codes and finish the sign-in. */
2564
+ async saveRecoveryCodes() {
2565
+ await this.finish(acknowledgeRecoveryCodes(this.state()));
2566
+ }
1542
2567
  async finish(next) {
2568
+ // An enrolment's recovery codes are shown first; `saveRecoveryCodes` finishes.
2569
+ if (pendingRecoveryCodes(next)) {
2570
+ this.setState({ ...next, busy: false });
2571
+ return;
2572
+ }
1543
2573
  if (next.attempt?.status === 'complete') {
1544
2574
  // Stays busy until this browser actually holds the session: the completion
1545
2575
  // alone says nothing about whether the cookie was kept.
@@ -1577,6 +2607,9 @@ class AtlasFlowController {
1577
2607
  void (async () => {
1578
2608
  const { state: next, ticket } = await pollAttempt(this.client, this.state());
1579
2609
  this.setState(next);
2610
+ // e.g. a passkey sign-up whose email was just proven: only this tab can go on.
2611
+ if (!ticket && !shouldKeepPolling(next))
2612
+ this.stopPolling();
1580
2613
  if (!ticket || !next.attempt)
1581
2614
  return;
1582
2615
  // The ticket is exchanged by a normal request, so no session token ever
@@ -1628,6 +2661,12 @@ class SignInComponent {
1628
2661
  t(key) {
1629
2662
  return translate(this.atlas.localization, key);
1630
2663
  }
2664
+ /** "Last used: Password" on the identifier step, when the instance shows the hint. */
2665
+ lastUsedLabel() {
2666
+ if (this.flow.state().attempt)
2667
+ return null;
2668
+ return lastUsedMethodLabel(this.flow.lastMethod(), (key) => this.t(key));
2669
+ }
1631
2670
  signUpHref() {
1632
2671
  if (this.signUpUrl === null)
1633
2672
  return null;
@@ -1661,6 +2700,14 @@ class SignInComponent {
1661
2700
  }
1662
2701
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignInComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1663
2702
  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: `
2703
+ @if (lastUsedLabel(); as label) {
2704
+ <p class="atlas-text atlas-last-used-hint" data-atlas="last-used" [attr.data-atlas-method]="flow.lastMethod()">
2705
+ {{ t('signIn.lastUsed') }}: {{ label }}
2706
+ </p>
2707
+ }
2708
+ @if (flow.recoveryCodes(); as codes) {
2709
+ <atlas-recovery-codes [codes]="codes" [busy]="flow.state().busy" (saved)="flow.saveRecoveryCodes()"></atlas-recovery-codes>
2710
+ } @else {
1664
2711
  <atlas-attempt-flow
1665
2712
  [attempt]="flow.state().attempt"
1666
2713
  [busy]="flow.state().busy"
@@ -1670,15 +2717,25 @@ class SignInComponent {
1670
2717
  [captcha]="flow.captchaDrawn() ? flow.captcha : null"
1671
2718
  [firstFactors]="flow.firstFactors()"
1672
2719
  [preparedFactor]="flow.preparedFactor()"
2720
+ [offerEmailSecondFactor]="flow.settings()?.account?.emailSecondFactor === true"
2721
+ [emailedSecondFactor]="flow.emailSecondFactor()"
1673
2722
  (submitFlow)="flow.submit($event)"
1674
2723
  (chooseFactor)="flow.prepare($event)"
2724
+ [offerSmsSecondFactor]="flow.settings() !== null && flow.mfaChoices().sms"
2725
+ [textedSecondFactor]="flow.smsSecondFactor()"
2726
+ [enrollment]="flow.enrollment"
2727
+ [enrollmentState]="flow.enrollmentState()"
2728
+ (emailSecondFactor)="flow.sendEmailSecondFactor()"
2729
+ (smsSecondFactor)="flow.sendSmsSecondFactor()"
2730
+ (policyDeclined)="flow.declinePolicy($event)"
1675
2731
  ></atlas-attempt-flow>
2732
+ }
1676
2733
  @if (flow.state().attempt?.status !== 'complete' && signUpHref(); as href) {
1677
2734
  <p class="atlas-footer" data-atlas-sign-up-prompt="">
1678
2735
  {{ t('signIn.noAccount') }} <a class="atlas-link" [href]="href">{{ t('signIn.signUpLink') }}</a>
1679
2736
  </p>
1680
2737
  }
1681
- `, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "sessionKept", "titleKey", "captcha", "firstFactors", "preparedFactor"], outputs: ["submitFlow", "chooseFactor"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2738
+ `, 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", "policyDeclined"] }, { kind: "component", type: RecoveryCodesComponent, selector: "atlas-recovery-codes", inputs: ["codes", "busy"], outputs: ["saved"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1682
2739
  }
1683
2740
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignInComponent, decorators: [{
1684
2741
  type: Component,
@@ -1686,8 +2743,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1686
2743
  selector: 'atlas-sign-in',
1687
2744
  standalone: true,
1688
2745
  changeDetection: ChangeDetectionStrategy.OnPush,
1689
- imports: [AttemptFlowComponent],
2746
+ imports: [AttemptFlowComponent, RecoveryCodesComponent],
1690
2747
  template: `
2748
+ @if (lastUsedLabel(); as label) {
2749
+ <p class="atlas-text atlas-last-used-hint" data-atlas="last-used" [attr.data-atlas-method]="flow.lastMethod()">
2750
+ {{ t('signIn.lastUsed') }}: {{ label }}
2751
+ </p>
2752
+ }
2753
+ @if (flow.recoveryCodes(); as codes) {
2754
+ <atlas-recovery-codes [codes]="codes" [busy]="flow.state().busy" (saved)="flow.saveRecoveryCodes()"></atlas-recovery-codes>
2755
+ } @else {
1691
2756
  <atlas-attempt-flow
1692
2757
  [attempt]="flow.state().attempt"
1693
2758
  [busy]="flow.state().busy"
@@ -1697,9 +2762,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1697
2762
  [captcha]="flow.captchaDrawn() ? flow.captcha : null"
1698
2763
  [firstFactors]="flow.firstFactors()"
1699
2764
  [preparedFactor]="flow.preparedFactor()"
2765
+ [offerEmailSecondFactor]="flow.settings()?.account?.emailSecondFactor === true"
2766
+ [emailedSecondFactor]="flow.emailSecondFactor()"
1700
2767
  (submitFlow)="flow.submit($event)"
1701
2768
  (chooseFactor)="flow.prepare($event)"
2769
+ [offerSmsSecondFactor]="flow.settings() !== null && flow.mfaChoices().sms"
2770
+ [textedSecondFactor]="flow.smsSecondFactor()"
2771
+ [enrollment]="flow.enrollment"
2772
+ [enrollmentState]="flow.enrollmentState()"
2773
+ (emailSecondFactor)="flow.sendEmailSecondFactor()"
2774
+ (smsSecondFactor)="flow.sendSmsSecondFactor()"
2775
+ (policyDeclined)="flow.declinePolicy($event)"
1702
2776
  ></atlas-attempt-flow>
2777
+ }
1703
2778
  @if (flow.state().attempt?.status !== 'complete' && signUpHref(); as href) {
1704
2779
  <p class="atlas-footer" data-atlas-sign-up-prompt="">
1705
2780
  {{ t('signIn.noAccount') }} <a class="atlas-link" [href]="href">{{ t('signIn.signUpLink') }}</a>
@@ -1723,14 +2798,37 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1723
2798
  */
1724
2799
  class SignUpFormComponent {
1725
2800
  atlas = inject(AtlasService);
2801
+ cdr = inject(ChangeDetectorRef);
2802
+ unwatch = null;
1726
2803
  settings = null;
1727
2804
  busy = false;
1728
2805
  errors = [];
1729
2806
  captcha = null;
2807
+ /** Passkey-only sign-up: no password field; the email per the instance's mode. */
2808
+ passkey = null;
2809
+ /** Whether the password / passkey toggle is shown (both are offered). */
2810
+ switchable = false;
2811
+ /** The instance's policies (terms, privacy, …); they replace the legacy consent row. */
2812
+ policies = null;
2813
+ /** The user asked for the other sign-up method. */
2814
+ switchMode = new EventEmitter();
1730
2815
  /** Emits the typed values and the consent tick when the form is submitted. */
1731
2816
  submitSignUp = new EventEmitter();
1732
2817
  values = {};
1733
2818
  agreed = false;
2819
+ declined = null;
2820
+ ngOnChanges(changes) {
2821
+ if (!changes['policies'])
2822
+ return;
2823
+ this.unwatch?.();
2824
+ this.unwatch = this.policies?.subscribe(() => this.cdr.markForCheck()) ?? null;
2825
+ }
2826
+ ngOnDestroy() {
2827
+ this.unwatch?.();
2828
+ }
2829
+ hasPolicies() {
2830
+ return !!this.policies && this.policies.policies.length > 0;
2831
+ }
1734
2832
  t(key, vars) {
1735
2833
  return translate(this.atlas.localization, key, vars);
1736
2834
  }
@@ -1748,8 +2846,17 @@ class SignUpFormComponent {
1748
2846
  }
1749
2847
  /** Email + password sign-up cannot complete: the app cannot send the verification email. */
1750
2848
  emailUnavailable() {
2849
+ // A passkey sign-up that collects no email (or an unproven one) sends no mail.
2850
+ if (this.passkey && this.passkey.email !== 'required')
2851
+ return false;
1751
2852
  return this.settings?.emailDelivery?.passwordSignUp === false;
1752
2853
  }
2854
+ showEmail() {
2855
+ return !this.passkey || this.passkey.email !== 'off';
2856
+ }
2857
+ emailRequired() {
2858
+ return !this.passkey || this.passkey.email === 'required';
2859
+ }
1753
2860
  fields() {
1754
2861
  return this.settings?.signUpFields ?? [];
1755
2862
  }
@@ -1774,10 +2881,15 @@ class SignUpFormComponent {
1774
2881
  this.setValue(f.key, (on ? [...current, option] : current.filter((p) => p !== option)).join(', '));
1775
2882
  }
1776
2883
  showLegal() {
2884
+ // Instance policies replace the legacy single "I agree to the Terms" row.
2885
+ if (this.hasPolicies())
2886
+ return false;
1777
2887
  const legal = this.settings?.legal;
1778
2888
  return !!legal && (!!legal.termsUrl || !!legal.privacyUrl);
1779
2889
  }
1780
2890
  consentOk() {
2891
+ if (this.hasPolicies())
2892
+ return this.policies.canSubmit();
1781
2893
  return !this.settings?.legal?.required || this.agreed;
1782
2894
  }
1783
2895
  formErrors() {
@@ -1790,13 +2902,27 @@ class SignUpFormComponent {
1790
2902
  event.preventDefault();
1791
2903
  if (this.emailUnavailable() || !this.consentOk())
1792
2904
  return;
2905
+ this.declined = null;
2906
+ if (this.hasPolicies()) {
2907
+ // Scroll-mode policies are read in their unskippable dialog first.
2908
+ const values = { ...this.values };
2909
+ void confirmPolicies(this.policies, { t: policyUi(this.atlas).t }).then((res) => {
2910
+ if (res.ok)
2911
+ this.submitSignUp.emit({ values, consent: true });
2912
+ else {
2913
+ this.declined = res.declined;
2914
+ this.cdr.markForCheck();
2915
+ }
2916
+ });
2917
+ return;
2918
+ }
1793
2919
  this.submitSignUp.emit({
1794
2920
  values: { ...this.values },
1795
2921
  consent: this.settings?.legal?.required ? this.agreed : true,
1796
2922
  });
1797
2923
  }
1798
2924
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1799
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: SignUpFormComponent, isStandalone: true, selector: "atlas-sign-up-form", inputs: { settings: "settings", busy: "busy", errors: "errors", captcha: "captcha" }, outputs: { submitSignUp: "submitSignUp" }, ngImport: i0, template: `
2925
+ 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", policies: "policies" }, outputs: { switchMode: "switchMode", submitSignUp: "submitSignUp" }, usesOnChanges: true, ngImport: i0, template: `
1800
2926
  <div [class]="cls('card', 'atlas-card')">
1801
2927
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
1802
2928
 
@@ -1885,14 +3011,17 @@ class SignUpFormComponent {
1885
3011
  }
1886
3012
  }
1887
3013
 
3014
+ @if (showEmail()) {
1888
3015
  <label [class]="cls('formField', 'atlas-field')">
1889
- <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('signUp.emailLabel') }}</span>
3016
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{
3017
+ emailRequired() ? t('signUp.emailLabel') : t('signUp.optionalField', { label: t('signUp.emailLabel') })
3018
+ }}</span>
1890
3019
  <input
1891
3020
  [class]="cls('formFieldInput', 'atlas-input')"
1892
3021
  type="email"
1893
3022
  name="email"
1894
3023
  autocomplete="email"
1895
- required
3024
+ [required]="emailRequired()"
1896
3025
  [value]="values['email'] ?? ''"
1897
3026
  (input)="setValue('email', $any($event.target).value)"
1898
3027
  />
@@ -1900,7 +3029,11 @@ class SignUpFormComponent {
1900
3029
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1901
3030
  }
1902
3031
  </label>
3032
+ }
1903
3033
 
3034
+ @if (passkey) {
3035
+ <p class="atlas-text">{{ t('signUp.passkeyIntro') }}</p>
3036
+ } @else {
1904
3037
  <label [class]="cls('formField', 'atlas-field')">
1905
3038
  <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('signUp.passwordLabel') }}</span>
1906
3039
  <input
@@ -1916,6 +3049,16 @@ class SignUpFormComponent {
1916
3049
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
1917
3050
  }
1918
3051
  </label>
3052
+ }
3053
+
3054
+ @if (hasPolicies()) {
3055
+ <div [atlasPolicies]="policies"></div>
3056
+ @if (declined) {
3057
+ <span [class]="cls('formFieldError', 'atlas-error')" role="alert" data-atlas-policy-declined="">
3058
+ {{ t('policy.declined', { policy: declined }) }}
3059
+ </span>
3060
+ }
3061
+ }
1919
3062
 
1920
3063
  @if (showLegal()) {
1921
3064
  <label [class]="cls('formField', 'atlas-consent')">
@@ -1956,10 +3099,26 @@ class SignUpFormComponent {
1956
3099
  >
1957
3100
  {{ t('signUp.submit') }}
1958
3101
  </button>
3102
+ @if (hasPolicies()) {
3103
+ <p atlasPolicyKind="clickwrap" [atlasPolicies]="policies"></p>
3104
+ }
3105
+ @if (switchable) {
3106
+ <div class="atlas-alternatives">
3107
+ <button
3108
+ type="button"
3109
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
3110
+ [attr.data-atlas-sign-up-mode]="passkey ? 'password' : 'passkey'"
3111
+ [disabled]="busy"
3112
+ (click)="switchMode.emit()"
3113
+ >
3114
+ {{ t(passkey ? 'signUp.passwordOption' : 'signUp.passkeyOption') }}
3115
+ </button>
3116
+ </div>
3117
+ }
1959
3118
  </form>
1960
3119
  }
1961
3120
  </div>
1962
- `, isInline: true, dependencies: [{ kind: "directive", type: AtlasCaptchaSlotDirective, selector: "[atlasCaptchaSlot]", inputs: ["atlasCaptchaSlot"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3121
+ `, isInline: true, dependencies: [{ kind: "directive", type: AtlasCaptchaSlotDirective, selector: "[atlasCaptchaSlot]", inputs: ["atlasCaptchaSlot"] }, { kind: "directive", type: AtlasPoliciesDirective, selector: "[atlasPolicies]", inputs: ["atlasPolicies", "atlasPolicyKind"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1963
3122
  }
1964
3123
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpFormComponent, decorators: [{
1965
3124
  type: Component,
@@ -1967,7 +3126,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1967
3126
  selector: 'atlas-sign-up-form',
1968
3127
  standalone: true,
1969
3128
  changeDetection: ChangeDetectionStrategy.OnPush,
1970
- imports: [AtlasCaptchaSlotDirective],
3129
+ imports: [AtlasCaptchaSlotDirective, AtlasPoliciesDirective],
1971
3130
  template: `
1972
3131
  <div [class]="cls('card', 'atlas-card')">
1973
3132
  <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
@@ -2057,14 +3216,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2057
3216
  }
2058
3217
  }
2059
3218
 
3219
+ @if (showEmail()) {
2060
3220
  <label [class]="cls('formField', 'atlas-field')">
2061
- <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('signUp.emailLabel') }}</span>
3221
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{
3222
+ emailRequired() ? t('signUp.emailLabel') : t('signUp.optionalField', { label: t('signUp.emailLabel') })
3223
+ }}</span>
2062
3224
  <input
2063
3225
  [class]="cls('formFieldInput', 'atlas-input')"
2064
3226
  type="email"
2065
3227
  name="email"
2066
3228
  autocomplete="email"
2067
- required
3229
+ [required]="emailRequired()"
2068
3230
  [value]="values['email'] ?? ''"
2069
3231
  (input)="setValue('email', $any($event.target).value)"
2070
3232
  />
@@ -2072,7 +3234,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2072
3234
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
2073
3235
  }
2074
3236
  </label>
3237
+ }
2075
3238
 
3239
+ @if (passkey) {
3240
+ <p class="atlas-text">{{ t('signUp.passkeyIntro') }}</p>
3241
+ } @else {
2076
3242
  <label [class]="cls('formField', 'atlas-field')">
2077
3243
  <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('signUp.passwordLabel') }}</span>
2078
3244
  <input
@@ -2088,6 +3254,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2088
3254
  <span [class]="cls('formFieldError', 'atlas-error')">{{ errorText(error) }}</span>
2089
3255
  }
2090
3256
  </label>
3257
+ }
3258
+
3259
+ @if (hasPolicies()) {
3260
+ <div [atlasPolicies]="policies"></div>
3261
+ @if (declined) {
3262
+ <span [class]="cls('formFieldError', 'atlas-error')" role="alert" data-atlas-policy-declined="">
3263
+ {{ t('policy.declined', { policy: declined }) }}
3264
+ </span>
3265
+ }
3266
+ }
2091
3267
 
2092
3268
  @if (showLegal()) {
2093
3269
  <label [class]="cls('formField', 'atlas-consent')">
@@ -2128,6 +3304,22 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2128
3304
  >
2129
3305
  {{ t('signUp.submit') }}
2130
3306
  </button>
3307
+ @if (hasPolicies()) {
3308
+ <p atlasPolicyKind="clickwrap" [atlasPolicies]="policies"></p>
3309
+ }
3310
+ @if (switchable) {
3311
+ <div class="atlas-alternatives">
3312
+ <button
3313
+ type="button"
3314
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
3315
+ [attr.data-atlas-sign-up-mode]="passkey ? 'password' : 'passkey'"
3316
+ [disabled]="busy"
3317
+ (click)="switchMode.emit()"
3318
+ >
3319
+ {{ t(passkey ? 'signUp.passwordOption' : 'signUp.passkeyOption') }}
3320
+ </button>
3321
+ </div>
3322
+ }
2131
3323
  </form>
2132
3324
  }
2133
3325
  </div>
@@ -2141,6 +3333,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2141
3333
  type: Input
2142
3334
  }], captcha: [{
2143
3335
  type: Input
3336
+ }], passkey: [{
3337
+ type: Input
3338
+ }], switchable: [{
3339
+ type: Input
3340
+ }], policies: [{
3341
+ type: Input
3342
+ }], switchMode: [{
3343
+ type: Output
2144
3344
  }], submitSignUp: [{
2145
3345
  type: Output
2146
3346
  }] } });
@@ -2162,24 +3362,70 @@ class SignUpComponent {
2162
3362
  /** Where to send the user once the flow completes. Reserved for host routing. */
2163
3363
  afterUrl;
2164
3364
  flow = new AtlasFlowController(this.atlas.createFapiClient(), () => this.atlas.reload(), this.atlas.pendingAttempt, (key) => translate(this.atlas.localization, key));
3365
+ /** Password or passkey-only sign-up (the latter when the instance offers it). */
3366
+ mode = signal('password');
3367
+ canPasskey() {
3368
+ return !!this.flow.settings()?.account.passkeySignUp && passkeysSupported();
3369
+ }
3370
+ passkeyForm() {
3371
+ const offer = this.flow.settings()?.account.passkeySignUp ?? null;
3372
+ return this.mode() === 'passkey' && offer && this.canPasskey() ? { email: offer.email } : null;
3373
+ }
3374
+ toggleMode() {
3375
+ this.flow.state.update((current) => ({ ...current, errors: [] }));
3376
+ this.mode.set(this.passkeyForm() ? 'password' : 'passkey');
3377
+ }
3378
+ awaitingPasskey() {
3379
+ return awaitingPasskey(this.flow.state().attempt);
3380
+ }
3381
+ t(key) {
3382
+ return translate(this.atlas.localization, key);
3383
+ }
3384
+ cls(key, base) {
3385
+ return classFor(key, this.atlas.appearance, base);
3386
+ }
3387
+ errorText(error) {
3388
+ const layer = this.atlas.errorLocalization;
3389
+ return localizedErrorMessage(error, layer ?? this.atlas.localization, this.atlas.localization);
3390
+ }
2165
3391
  ngOnInit() {
2166
3392
  // §5 the instance's sign-up fields, consent policy and captcha settings.
2167
- if (typeof window !== 'undefined')
3393
+ if (typeof window !== 'undefined') {
2168
3394
  void this.flow.loadSettings();
3395
+ void this.flow.loadPolicies(this.atlas.locale ?? undefined);
3396
+ }
2169
3397
  }
2170
3398
  ngOnDestroy() {
2171
3399
  this.flow.destroy();
2172
3400
  }
2173
3401
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2174
3402
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: SignUpComponent, isStandalone: true, selector: "atlas-sign-up", inputs: { afterUrl: "afterUrl" }, ngImport: i0, template: `
2175
- @if (flow.state().attempt) {
3403
+ @if (awaitingPasskey()) {
3404
+ <div [class]="cls('card', 'atlas-card')" data-atlas-passkey-sign-up="">
3405
+ <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
3406
+ @for (error of flow.state().errors; track error.code) {
3407
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ errorText(error) }}</p>
3408
+ }
3409
+ <p class="atlas-text">{{ t('signUp.passkeyPrompt') }}</p>
3410
+ <button
3411
+ [class]="cls('formButtonPrimary', 'atlas-button')"
3412
+ type="button"
3413
+ [disabled]="flow.state().busy"
3414
+ (click)="flow.createPasskey()"
3415
+ >
3416
+ {{ t('signUp.passkeyCreate') }}
3417
+ </button>
3418
+ </div>
3419
+ } @else if (flow.state().attempt) {
2176
3420
  <atlas-attempt-flow
2177
3421
  [attempt]="flow.state().attempt"
2178
3422
  [busy]="flow.state().busy"
2179
3423
  [errors]="flow.state().errors"
2180
3424
  [sessionKept]="flow.state().sessionKept"
2181
3425
  titleKey="signUp.title"
3426
+ [emailVerification]="flow.settings()?.account?.emailVerification ?? 'code'"
2182
3427
  (submitFlow)="flow.submit($event)"
3428
+ (resend)="flow.resendSignUp()"
2183
3429
  ></atlas-attempt-flow>
2184
3430
  } @else {
2185
3431
  <atlas-sign-up-form
@@ -2187,10 +3433,16 @@ class SignUpComponent {
2187
3433
  [busy]="flow.state().busy"
2188
3434
  [errors]="flow.state().errors"
2189
3435
  [captcha]="flow.signUpCaptchaDrawn() ? flow.signUpCaptcha : null"
2190
- (submitSignUp)="flow.create($event.values, $event.consent)"
3436
+ [policies]="flow.policies"
3437
+ [passkey]="passkeyForm()"
3438
+ [switchable]="canPasskey()"
3439
+ (switchMode)="toggleMode()"
3440
+ (submitSignUp)="
3441
+ passkeyForm() ? flow.createWithPasskey($event.values, $event.consent) : flow.create($event.values, $event.consent)
3442
+ "
2191
3443
  ></atlas-sign-up-form>
2192
3444
  }
2193
- `, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "sessionKept", "titleKey", "captcha", "firstFactors", "preparedFactor"], outputs: ["submitFlow", "chooseFactor"] }, { kind: "component", type: SignUpFormComponent, selector: "atlas-sign-up-form", inputs: ["settings", "busy", "errors", "captcha"], outputs: ["submitSignUp"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3445
+ `, 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", "policyDeclined"] }, { kind: "component", type: SignUpFormComponent, selector: "atlas-sign-up-form", inputs: ["settings", "busy", "errors", "captcha", "passkey", "switchable", "policies"], outputs: ["switchMode", "submitSignUp"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2194
3446
  }
2195
3447
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpComponent, decorators: [{
2196
3448
  type: Component,
@@ -2200,14 +3452,32 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2200
3452
  changeDetection: ChangeDetectionStrategy.OnPush,
2201
3453
  imports: [AttemptFlowComponent, SignUpFormComponent],
2202
3454
  template: `
2203
- @if (flow.state().attempt) {
3455
+ @if (awaitingPasskey()) {
3456
+ <div [class]="cls('card', 'atlas-card')" data-atlas-passkey-sign-up="">
3457
+ <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t('signUp.title') }}</h1>
3458
+ @for (error of flow.state().errors; track error.code) {
3459
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ errorText(error) }}</p>
3460
+ }
3461
+ <p class="atlas-text">{{ t('signUp.passkeyPrompt') }}</p>
3462
+ <button
3463
+ [class]="cls('formButtonPrimary', 'atlas-button')"
3464
+ type="button"
3465
+ [disabled]="flow.state().busy"
3466
+ (click)="flow.createPasskey()"
3467
+ >
3468
+ {{ t('signUp.passkeyCreate') }}
3469
+ </button>
3470
+ </div>
3471
+ } @else if (flow.state().attempt) {
2204
3472
  <atlas-attempt-flow
2205
3473
  [attempt]="flow.state().attempt"
2206
3474
  [busy]="flow.state().busy"
2207
3475
  [errors]="flow.state().errors"
2208
3476
  [sessionKept]="flow.state().sessionKept"
2209
3477
  titleKey="signUp.title"
3478
+ [emailVerification]="flow.settings()?.account?.emailVerification ?? 'code'"
2210
3479
  (submitFlow)="flow.submit($event)"
3480
+ (resend)="flow.resendSignUp()"
2211
3481
  ></atlas-attempt-flow>
2212
3482
  } @else {
2213
3483
  <atlas-sign-up-form
@@ -2215,7 +3485,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2215
3485
  [busy]="flow.state().busy"
2216
3486
  [errors]="flow.state().errors"
2217
3487
  [captcha]="flow.signUpCaptchaDrawn() ? flow.signUpCaptcha : null"
2218
- (submitSignUp)="flow.create($event.values, $event.consent)"
3488
+ [policies]="flow.policies"
3489
+ [passkey]="passkeyForm()"
3490
+ [switchable]="canPasskey()"
3491
+ (switchMode)="toggleMode()"
3492
+ (submitSignUp)="
3493
+ passkeyForm() ? flow.createWithPasskey($event.values, $event.consent) : flow.create($event.values, $event.consent)
3494
+ "
2219
3495
  ></atlas-sign-up-form>
2220
3496
  }
2221
3497
  `,
@@ -2549,32 +3825,392 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2549
3825
  }] });
2550
3826
 
2551
3827
  /**
2552
- * `<atlas-organization-profile>` — the active organization's surface. The
2553
- * Angular peer of React's `<OrganizationProfile/>`. Management controls are
2554
- * gated on the ACTIVE org role via `*atlasProtect`, matching the server's rule
2555
- * rather than duplicating a looser one. Renders nothing with no active org.
3828
+ * `<atlas-organization-profile>` — the active organization's members (admins
3829
+ * change roles and remove), invitations (admins invite, revoke and resend) and
3830
+ * settings (leave; an admin deletes after confirming their password). The
3831
+ * Angular peer of React's `<OrganizationProfile/>`, on the same headless
3832
+ * controller. Every control is gated on the ACTIVE org role, matching the
3833
+ * server's rule. Renders nothing with no active org. Emits `exit` with `left`
3834
+ * or `deleted`.
2556
3835
  */
2557
3836
  class OrganizationProfileComponent {
2558
3837
  atlas = inject(AtlasService);
2559
- t(key) {
2560
- return translate(this.atlas.localization, key);
3838
+ exit = new EventEmitter();
3839
+ client = this.atlas.createFapiClient();
3840
+ controller = null;
3841
+ unsubscribe = null;
3842
+ state = signal(null);
3843
+ tab = signal('members');
3844
+ email = signal('');
3845
+ inviteRole = signal('org:member');
3846
+ confirm = signal(null);
3847
+ needsPassword = signal(false);
3848
+ password = signal('');
3849
+ // The custom-role editor: null = creating, else the id of the role being edited.
3850
+ editingRole = signal(null);
3851
+ roleKey = signal('');
3852
+ roleName = signal('');
3853
+ rolePerms = signal([]);
3854
+ customRoles = computed(() => (this.state()?.roles ?? []).filter((r) => Boolean(r.organization_id)));
3855
+ userId = computed(() => this.atlas.claims()?.sub ?? null);
3856
+ canManage = computed(() => {
3857
+ this.atlas.claims();
3858
+ return this.atlas.has({ permission: 'org:sys_memberships:manage' });
3859
+ });
3860
+ isAdmin = computed(() => this.atlas.organization()?.role === 'org:admin');
3861
+ tabs = computed(() => ['members', ...(this.canManage() ? ['invitations', 'roles'] : []), 'settings']);
3862
+ roles = computed(() => {
3863
+ const roles = this.state()?.roles ?? [];
3864
+ return roles.length > 0 ? roles : [{ key: 'org:member', name: 'org:member' }];
3865
+ });
3866
+ errorMessage = computed(() => {
3867
+ const err = this.state()?.error;
3868
+ if (!err || err.needsReverification)
3869
+ return null;
3870
+ return localizedErrorMessage(err, this.atlas.errorLocalization, this.atlas.localization);
3871
+ });
3872
+ constructor() {
3873
+ effect(() => {
3874
+ const id = this.atlas.organization()?.organization.id ?? null;
3875
+ untracked(() => this.bind(id));
3876
+ });
3877
+ inject(DestroyRef).onDestroy(() => this.unsubscribe?.());
3878
+ }
3879
+ bind(id) {
3880
+ if (this.controller?.organizationId === id)
3881
+ return;
3882
+ this.unsubscribe?.();
3883
+ this.controller = null;
3884
+ this.state.set(null);
3885
+ if (!id)
3886
+ return;
3887
+ const controller = new OrganizationProfileController(this.client, id, {
3888
+ canManage: this.canManage(),
3889
+ onChange: async () => {
3890
+ await this.atlas.reload();
3891
+ },
3892
+ });
3893
+ this.controller = controller;
3894
+ this.unsubscribe = controller.subscribe((next) => this.state.set(next));
3895
+ this.state.set(controller.getState());
3896
+ void controller.load();
3897
+ }
3898
+ t(key, vars) {
3899
+ return translate(this.atlas.localization, key, vars);
2561
3900
  }
2562
3901
  cls(key, base) {
2563
3902
  return classFor(key, this.atlas.appearance, base);
2564
3903
  }
3904
+ memberName(m) {
3905
+ return [m.user?.first_name, m.user?.last_name].filter(Boolean).join(' ') || this.t('organization.unnamedMember');
3906
+ }
3907
+ changeRole(userId, role) {
3908
+ void this.controller?.changeRole(userId, role);
3909
+ }
3910
+ removeMember(userId) {
3911
+ void this.controller?.removeMember(userId);
3912
+ }
3913
+ async sendInvite() {
3914
+ const address = this.email().trim();
3915
+ if (this.controller && address && (await this.controller.invite(address, this.inviteRole())))
3916
+ this.email.set('');
3917
+ }
3918
+ resend(id) {
3919
+ void this.controller?.resendInvitation(id);
3920
+ }
3921
+ revoke(id) {
3922
+ void this.controller?.revokeInvitation(id);
3923
+ }
3924
+ async leave() {
3925
+ if (this.controller && (await this.controller.leave()))
3926
+ this.exit.emit('left');
3927
+ this.confirm.set(null);
3928
+ }
3929
+ async remove() {
3930
+ const controller = this.controller;
3931
+ if (!controller)
3932
+ return;
3933
+ if (this.needsPassword()) {
3934
+ try {
3935
+ await this.atlas.reauthenticate(this.password());
3936
+ }
3937
+ catch {
3938
+ return;
3939
+ }
3940
+ this.password.set('');
3941
+ this.needsPassword.set(false);
3942
+ }
3943
+ if (await controller.deleteOrganization()) {
3944
+ this.exit.emit('deleted');
3945
+ this.confirm.set(null);
3946
+ return;
3947
+ }
3948
+ const err = controller.getState().error;
3949
+ if (err?.needsReverification)
3950
+ this.needsPassword.set(true);
3951
+ }
3952
+ editRole(role) {
3953
+ this.editingRole.set(role.id ?? null);
3954
+ this.roleKey.set(role.key);
3955
+ this.roleName.set(role.name);
3956
+ this.rolePerms.set([...(role.permissions ?? [])]);
3957
+ }
3958
+ resetRoleForm() {
3959
+ this.editingRole.set(null);
3960
+ this.roleKey.set('');
3961
+ this.roleName.set('');
3962
+ this.rolePerms.set([]);
3963
+ }
3964
+ togglePermission(key, on) {
3965
+ this.rolePerms.update((current) => (on ? [...current, key] : current.filter((k) => k !== key)));
3966
+ }
3967
+ async saveRole() {
3968
+ const controller = this.controller;
3969
+ if (!controller)
3970
+ return;
3971
+ const editing = this.editingRole();
3972
+ const out = editing
3973
+ ? await controller.updateRole(editing, { name: this.roleName().trim(), permissions: this.rolePerms() })
3974
+ : await controller.createRole({ key: this.roleKey().trim(), name: this.roleName().trim(), permissions: this.rolePerms() });
3975
+ if (out)
3976
+ this.resetRoleForm();
3977
+ }
3978
+ deleteRole(role) {
3979
+ if (role.id)
3980
+ void this.controller?.deleteRole(role.id);
3981
+ }
3982
+ cancel() {
3983
+ this.confirm.set(null);
3984
+ this.needsPassword.set(false);
3985
+ this.password.set('');
3986
+ }
2565
3987
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: OrganizationProfileComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2566
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: OrganizationProfileComponent, isStandalone: true, selector: "atlas-organization-profile", ngImport: i0, template: `
3988
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: OrganizationProfileComponent, isStandalone: true, selector: "atlas-organization-profile", outputs: { exit: "exit" }, ngImport: i0, template: `
2567
3989
  @if (atlas.organization(); as membership) {
2568
- <div [class]="cls('card', 'atlas-card')">
2569
- <h1>{{ membership.organization.name }}</h1>
2570
- <p>{{ t('organization.roleLabel') }}: {{ membership.role }}</p>
3990
+ @if (state(); as st) {
3991
+ <div [class]="cls('card', 'atlas-card')" data-atlas="organization-profile">
3992
+ <h1>{{ membership.organization.name }}</h1>
3993
+ <p>{{ t('organization.roleLabel') }}: {{ membership.role }}</p>
2571
3994
 
2572
- <button *atlasProtect="{ permission: 'org:sys_memberships:manage' }">
2573
- {{ t('organization.invite') }}
2574
- </button>
2575
- </div>
3995
+ <div [class]="cls('menu', 'atlas-menu')" role="tablist">
3996
+ @for (key of tabs(); track key) {
3997
+ <button
3998
+ type="button"
3999
+ role="tab"
4000
+ [attr.aria-selected]="tab() === key"
4001
+ [class]="cls('menuItem', 'atlas-menu-item')"
4002
+ [attr.data-atlas]="'organization-tab-' + key"
4003
+ (click)="tab.set(key)"
4004
+ >
4005
+ {{ t('organization.' + key) }}
4006
+ </button>
4007
+ }
4008
+ </div>
4009
+
4010
+ @if (errorMessage(); as message) {
4011
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ message }}</p>
4012
+ }
4013
+
4014
+ @if (tab() === 'members') {
4015
+ <ul class="atlas-list" data-atlas="organization-members">
4016
+ @for (m of st.members; track m.id) {
4017
+ <li data-atlas="organization-member">
4018
+ <span>{{ memberName(m) }}{{ m.user?.id === userId() ? ' ' + t('organization.you') : '' }}</span>
4019
+ @if (canManage() && m.user && m.user.id !== userId()) {
4020
+ <select
4021
+ [attr.aria-label]="t('organization.roleLabel')"
4022
+ [class]="cls('formFieldInput', 'atlas-input')"
4023
+ [disabled]="st.busy === 'role:' + m.user.id"
4024
+ (change)="changeRole(m.user.id, $any($event.target).value)"
4025
+ >
4026
+ @for (r of roles(); track r.key) {
4027
+ <option [value]="r.key" [selected]="r.key === m.role">{{ r.name }}</option>
4028
+ }
4029
+ </select>
4030
+ <button
4031
+ type="button"
4032
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
4033
+ [disabled]="st.busy === 'remove:' + m.user.id"
4034
+ (click)="removeMember(m.user.id)"
4035
+ >
4036
+ {{ t('organization.remove') }}
4037
+ </button>
4038
+ } @else {
4039
+ <span> · {{ m.role }}</span>
4040
+ }
4041
+ </li>
4042
+ }
4043
+ </ul>
4044
+ }
4045
+
4046
+ @if (tab() === 'invitations' && canManage()) {
4047
+ <div data-atlas="organization-invitations">
4048
+ <form (submit)="$event.preventDefault(); sendInvite()">
4049
+ <label [class]="cls('formField', 'atlas-field')">
4050
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.emailLabel') }}</span>
4051
+ <input
4052
+ type="email"
4053
+ [class]="cls('formFieldInput', 'atlas-input')"
4054
+ [value]="email()"
4055
+ (input)="email.set($any($event.target).value)"
4056
+ />
4057
+ </label>
4058
+ <label [class]="cls('formField', 'atlas-field')">
4059
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.roleLabel') }}</span>
4060
+ <select [class]="cls('formFieldInput', 'atlas-input')" (change)="inviteRole.set($any($event.target).value)">
4061
+ @for (r of roles(); track r.key) {
4062
+ <option [value]="r.key" [selected]="r.key === inviteRole()">{{ r.name }}</option>
4063
+ }
4064
+ </select>
4065
+ </label>
4066
+ <button
4067
+ type="submit"
4068
+ [class]="cls('formButtonPrimary', 'atlas-button')"
4069
+ [disabled]="st.busy === 'invite' || !email().trim()"
4070
+ >
4071
+ {{ t('organization.sendInvite') }}
4072
+ </button>
4073
+ </form>
4074
+ @if (st.invitations.length === 0) {
4075
+ <p class="atlas-text">{{ t('organization.noInvitations') }}</p>
4076
+ } @else {
4077
+ <ul class="atlas-list">
4078
+ @for (inv of st.invitations; track inv.id) {
4079
+ <li data-atlas="organization-invitation">
4080
+ <span>{{ inv.email_address }} · {{ inv.role }}</span>
4081
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="resend(inv.id)">
4082
+ {{ t('organization.resend') }}
4083
+ </button>
4084
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="revoke(inv.id)">
4085
+ {{ t('organization.revoke') }}
4086
+ </button>
4087
+ </li>
4088
+ }
4089
+ </ul>
4090
+ }
4091
+ </div>
4092
+ }
4093
+
4094
+ @if (tab() === 'roles' && canManage()) {
4095
+ <div data-atlas="organization-roles">
4096
+ @if (customRoles().length === 0) {
4097
+ <p class="atlas-text">{{ t('organization.noCustomRoles') }}</p>
4098
+ } @else {
4099
+ <ul class="atlas-list">
4100
+ @for (role of customRoles(); track role.key) {
4101
+ <li data-atlas="organization-role">
4102
+ <span>{{ role.name }} · {{ role.key }}</span>
4103
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="editRole(role)">
4104
+ {{ t('organization.editRole') }}
4105
+ </button>
4106
+ <button
4107
+ type="button"
4108
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
4109
+ [disabled]="!role.id || st.busy === 'role:delete:' + role.id"
4110
+ (click)="deleteRole(role)"
4111
+ >
4112
+ {{ t('organization.deleteRole') }}
4113
+ </button>
4114
+ </li>
4115
+ }
4116
+ </ul>
4117
+ }
4118
+ <form data-atlas="organization-role-form" (submit)="$event.preventDefault(); saveRole()">
4119
+ @if (!editingRole()) {
4120
+ <label [class]="cls('formField', 'atlas-field')">
4121
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.roleKey') }}</span>
4122
+ <input
4123
+ name="role_key"
4124
+ [class]="cls('formFieldInput', 'atlas-input')"
4125
+ [value]="roleKey()"
4126
+ (input)="roleKey.set($any($event.target).value)"
4127
+ />
4128
+ </label>
4129
+ }
4130
+ <label [class]="cls('formField', 'atlas-field')">
4131
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.roleName') }}</span>
4132
+ <input
4133
+ name="role_name"
4134
+ [class]="cls('formFieldInput', 'atlas-input')"
4135
+ [value]="roleName()"
4136
+ (input)="roleName.set($any($event.target).value)"
4137
+ />
4138
+ </label>
4139
+ <fieldset [class]="cls('formField', 'atlas-field')">
4140
+ <legend [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.rolePermissions') }}</legend>
4141
+ @for (p of st.permissions; track p.key) {
4142
+ <label data-atlas="organization-role-permission">
4143
+ <input
4144
+ type="checkbox"
4145
+ [value]="p.key"
4146
+ [checked]="rolePerms().includes(p.key)"
4147
+ (change)="togglePermission(p.key, $any($event.target).checked)"
4148
+ />
4149
+ {{ p.name }}
4150
+ </label>
4151
+ }
4152
+ </fieldset>
4153
+ <button
4154
+ type="submit"
4155
+ [class]="cls('formButtonPrimary', 'atlas-button')"
4156
+ [disabled]="!roleName().trim() || (!editingRole() && !roleKey().trim()) || (st.busy ?? '').startsWith('role:')"
4157
+ >
4158
+ {{ editingRole() ? t('organization.saveRole') : t('organization.createRole') }}
4159
+ </button>
4160
+ @if (editingRole()) {
4161
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="resetRoleForm()">
4162
+ {{ t('organization.cancel') }}
4163
+ </button>
4164
+ }
4165
+ </form>
4166
+ </div>
4167
+ }
4168
+
4169
+ @if (tab() === 'settings') {
4170
+ <div data-atlas="organization-settings">
4171
+ @if (confirm(); as action) {
4172
+ <p class="atlas-text">
4173
+ {{ t(action === 'leave' ? 'organization.leaveConfirm' : 'organization.deleteConfirm', { name: membership.organization.name }) }}
4174
+ </p>
4175
+ @if (needsPassword()) {
4176
+ <label [class]="cls('formField', 'atlas-field')">
4177
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.passwordPrompt') }}</span>
4178
+ <input
4179
+ type="password"
4180
+ autocomplete="current-password"
4181
+ [class]="cls('formFieldInput', 'atlas-input')"
4182
+ [value]="password()"
4183
+ (input)="password.set($any($event.target).value)"
4184
+ />
4185
+ </label>
4186
+ }
4187
+ <button
4188
+ type="button"
4189
+ [class]="cls('formButtonPrimary', 'atlas-button')"
4190
+ [disabled]="st.busy === 'leave' || st.busy === 'delete'"
4191
+ (click)="action === 'leave' ? leave() : remove()"
4192
+ >
4193
+ {{ t('organization.confirm') }}
4194
+ </button>
4195
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="cancel()">
4196
+ {{ t('organization.cancel') }}
4197
+ </button>
4198
+ } @else {
4199
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="confirm.set('leave')">
4200
+ {{ t('organization.leave') }}
4201
+ </button>
4202
+ @if (isAdmin()) {
4203
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="confirm.set('delete')">
4204
+ {{ t('organization.delete') }}
4205
+ </button>
4206
+ }
4207
+ }
4208
+ </div>
4209
+ }
4210
+ </div>
4211
+ }
2576
4212
  }
2577
- `, isInline: true, dependencies: [{ kind: "directive", type: AtlasProtectDirective, selector: "[atlasProtect]", inputs: ["atlasProtect", "atlasProtectElse"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
4213
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
2578
4214
  }
2579
4215
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: OrganizationProfileComponent, decorators: [{
2580
4216
  type: Component,
@@ -2582,21 +4218,346 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2582
4218
  selector: 'atlas-organization-profile',
2583
4219
  standalone: true,
2584
4220
  changeDetection: ChangeDetectionStrategy.OnPush,
2585
- imports: [AtlasProtectDirective],
2586
4221
  template: `
2587
4222
  @if (atlas.organization(); as membership) {
2588
- <div [class]="cls('card', 'atlas-card')">
2589
- <h1>{{ membership.organization.name }}</h1>
2590
- <p>{{ t('organization.roleLabel') }}: {{ membership.role }}</p>
4223
+ @if (state(); as st) {
4224
+ <div [class]="cls('card', 'atlas-card')" data-atlas="organization-profile">
4225
+ <h1>{{ membership.organization.name }}</h1>
4226
+ <p>{{ t('organization.roleLabel') }}: {{ membership.role }}</p>
2591
4227
 
2592
- <button *atlasProtect="{ permission: 'org:sys_memberships:manage' }">
2593
- {{ t('organization.invite') }}
2594
- </button>
4228
+ <div [class]="cls('menu', 'atlas-menu')" role="tablist">
4229
+ @for (key of tabs(); track key) {
4230
+ <button
4231
+ type="button"
4232
+ role="tab"
4233
+ [attr.aria-selected]="tab() === key"
4234
+ [class]="cls('menuItem', 'atlas-menu-item')"
4235
+ [attr.data-atlas]="'organization-tab-' + key"
4236
+ (click)="tab.set(key)"
4237
+ >
4238
+ {{ t('organization.' + key) }}
4239
+ </button>
4240
+ }
4241
+ </div>
4242
+
4243
+ @if (errorMessage(); as message) {
4244
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ message }}</p>
4245
+ }
4246
+
4247
+ @if (tab() === 'members') {
4248
+ <ul class="atlas-list" data-atlas="organization-members">
4249
+ @for (m of st.members; track m.id) {
4250
+ <li data-atlas="organization-member">
4251
+ <span>{{ memberName(m) }}{{ m.user?.id === userId() ? ' ' + t('organization.you') : '' }}</span>
4252
+ @if (canManage() && m.user && m.user.id !== userId()) {
4253
+ <select
4254
+ [attr.aria-label]="t('organization.roleLabel')"
4255
+ [class]="cls('formFieldInput', 'atlas-input')"
4256
+ [disabled]="st.busy === 'role:' + m.user.id"
4257
+ (change)="changeRole(m.user.id, $any($event.target).value)"
4258
+ >
4259
+ @for (r of roles(); track r.key) {
4260
+ <option [value]="r.key" [selected]="r.key === m.role">{{ r.name }}</option>
4261
+ }
4262
+ </select>
4263
+ <button
4264
+ type="button"
4265
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
4266
+ [disabled]="st.busy === 'remove:' + m.user.id"
4267
+ (click)="removeMember(m.user.id)"
4268
+ >
4269
+ {{ t('organization.remove') }}
4270
+ </button>
4271
+ } @else {
4272
+ <span> · {{ m.role }}</span>
4273
+ }
4274
+ </li>
4275
+ }
4276
+ </ul>
4277
+ }
4278
+
4279
+ @if (tab() === 'invitations' && canManage()) {
4280
+ <div data-atlas="organization-invitations">
4281
+ <form (submit)="$event.preventDefault(); sendInvite()">
4282
+ <label [class]="cls('formField', 'atlas-field')">
4283
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.emailLabel') }}</span>
4284
+ <input
4285
+ type="email"
4286
+ [class]="cls('formFieldInput', 'atlas-input')"
4287
+ [value]="email()"
4288
+ (input)="email.set($any($event.target).value)"
4289
+ />
4290
+ </label>
4291
+ <label [class]="cls('formField', 'atlas-field')">
4292
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.roleLabel') }}</span>
4293
+ <select [class]="cls('formFieldInput', 'atlas-input')" (change)="inviteRole.set($any($event.target).value)">
4294
+ @for (r of roles(); track r.key) {
4295
+ <option [value]="r.key" [selected]="r.key === inviteRole()">{{ r.name }}</option>
4296
+ }
4297
+ </select>
4298
+ </label>
4299
+ <button
4300
+ type="submit"
4301
+ [class]="cls('formButtonPrimary', 'atlas-button')"
4302
+ [disabled]="st.busy === 'invite' || !email().trim()"
4303
+ >
4304
+ {{ t('organization.sendInvite') }}
4305
+ </button>
4306
+ </form>
4307
+ @if (st.invitations.length === 0) {
4308
+ <p class="atlas-text">{{ t('organization.noInvitations') }}</p>
4309
+ } @else {
4310
+ <ul class="atlas-list">
4311
+ @for (inv of st.invitations; track inv.id) {
4312
+ <li data-atlas="organization-invitation">
4313
+ <span>{{ inv.email_address }} · {{ inv.role }}</span>
4314
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="resend(inv.id)">
4315
+ {{ t('organization.resend') }}
4316
+ </button>
4317
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="revoke(inv.id)">
4318
+ {{ t('organization.revoke') }}
4319
+ </button>
4320
+ </li>
4321
+ }
4322
+ </ul>
4323
+ }
4324
+ </div>
4325
+ }
4326
+
4327
+ @if (tab() === 'roles' && canManage()) {
4328
+ <div data-atlas="organization-roles">
4329
+ @if (customRoles().length === 0) {
4330
+ <p class="atlas-text">{{ t('organization.noCustomRoles') }}</p>
4331
+ } @else {
4332
+ <ul class="atlas-list">
4333
+ @for (role of customRoles(); track role.key) {
4334
+ <li data-atlas="organization-role">
4335
+ <span>{{ role.name }} · {{ role.key }}</span>
4336
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="editRole(role)">
4337
+ {{ t('organization.editRole') }}
4338
+ </button>
4339
+ <button
4340
+ type="button"
4341
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
4342
+ [disabled]="!role.id || st.busy === 'role:delete:' + role.id"
4343
+ (click)="deleteRole(role)"
4344
+ >
4345
+ {{ t('organization.deleteRole') }}
4346
+ </button>
4347
+ </li>
4348
+ }
4349
+ </ul>
4350
+ }
4351
+ <form data-atlas="organization-role-form" (submit)="$event.preventDefault(); saveRole()">
4352
+ @if (!editingRole()) {
4353
+ <label [class]="cls('formField', 'atlas-field')">
4354
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.roleKey') }}</span>
4355
+ <input
4356
+ name="role_key"
4357
+ [class]="cls('formFieldInput', 'atlas-input')"
4358
+ [value]="roleKey()"
4359
+ (input)="roleKey.set($any($event.target).value)"
4360
+ />
4361
+ </label>
4362
+ }
4363
+ <label [class]="cls('formField', 'atlas-field')">
4364
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.roleName') }}</span>
4365
+ <input
4366
+ name="role_name"
4367
+ [class]="cls('formFieldInput', 'atlas-input')"
4368
+ [value]="roleName()"
4369
+ (input)="roleName.set($any($event.target).value)"
4370
+ />
4371
+ </label>
4372
+ <fieldset [class]="cls('formField', 'atlas-field')">
4373
+ <legend [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.rolePermissions') }}</legend>
4374
+ @for (p of st.permissions; track p.key) {
4375
+ <label data-atlas="organization-role-permission">
4376
+ <input
4377
+ type="checkbox"
4378
+ [value]="p.key"
4379
+ [checked]="rolePerms().includes(p.key)"
4380
+ (change)="togglePermission(p.key, $any($event.target).checked)"
4381
+ />
4382
+ {{ p.name }}
4383
+ </label>
4384
+ }
4385
+ </fieldset>
4386
+ <button
4387
+ type="submit"
4388
+ [class]="cls('formButtonPrimary', 'atlas-button')"
4389
+ [disabled]="!roleName().trim() || (!editingRole() && !roleKey().trim()) || (st.busy ?? '').startsWith('role:')"
4390
+ >
4391
+ {{ editingRole() ? t('organization.saveRole') : t('organization.createRole') }}
4392
+ </button>
4393
+ @if (editingRole()) {
4394
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="resetRoleForm()">
4395
+ {{ t('organization.cancel') }}
4396
+ </button>
4397
+ }
4398
+ </form>
4399
+ </div>
4400
+ }
4401
+
4402
+ @if (tab() === 'settings') {
4403
+ <div data-atlas="organization-settings">
4404
+ @if (confirm(); as action) {
4405
+ <p class="atlas-text">
4406
+ {{ t(action === 'leave' ? 'organization.leaveConfirm' : 'organization.deleteConfirm', { name: membership.organization.name }) }}
4407
+ </p>
4408
+ @if (needsPassword()) {
4409
+ <label [class]="cls('formField', 'atlas-field')">
4410
+ <span [class]="cls('formFieldLabel', 'atlas-field-label')">{{ t('organization.passwordPrompt') }}</span>
4411
+ <input
4412
+ type="password"
4413
+ autocomplete="current-password"
4414
+ [class]="cls('formFieldInput', 'atlas-input')"
4415
+ [value]="password()"
4416
+ (input)="password.set($any($event.target).value)"
4417
+ />
4418
+ </label>
4419
+ }
4420
+ <button
4421
+ type="button"
4422
+ [class]="cls('formButtonPrimary', 'atlas-button')"
4423
+ [disabled]="st.busy === 'leave' || st.busy === 'delete'"
4424
+ (click)="action === 'leave' ? leave() : remove()"
4425
+ >
4426
+ {{ t('organization.confirm') }}
4427
+ </button>
4428
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="cancel()">
4429
+ {{ t('organization.cancel') }}
4430
+ </button>
4431
+ } @else {
4432
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="confirm.set('leave')">
4433
+ {{ t('organization.leave') }}
4434
+ </button>
4435
+ @if (isAdmin()) {
4436
+ <button type="button" [class]="cls('formButtonSecondary', 'atlas-button-secondary')" (click)="confirm.set('delete')">
4437
+ {{ t('organization.delete') }}
4438
+ </button>
4439
+ }
4440
+ }
4441
+ </div>
4442
+ }
4443
+ </div>
4444
+ }
4445
+ }
4446
+ `,
4447
+ }]
4448
+ }], ctorParameters: () => [], propDecorators: { exit: [{
4449
+ type: Output
4450
+ }] } });
4451
+
4452
+ /**
4453
+ * `<atlas-organization-audit-log>` — the active organization's audit trail for
4454
+ * its admins, next to `<atlas-organization-profile>`. The Angular peer of
4455
+ * React's `<OrganizationAuditLog/>`. Renders nothing for a member who may not
4456
+ * read it (the server is the authority; the component just follows it). Labels
4457
+ * are inputs so an app can localise them.
4458
+ */
4459
+ class OrganizationAuditLogComponent {
4460
+ /** Entries per page (the server caps it). */
4461
+ pageSize;
4462
+ /** Narrow to one action, or a prefix ending in `.`. */
4463
+ action;
4464
+ labels = {};
4465
+ formatDate = (ms) => new Date(ms).toLocaleString();
4466
+ atlas = inject(AtlasService);
4467
+ /** Created in ngOnInit, once the inputs are set (templates render after it). */
4468
+ log;
4469
+ ngOnInit() {
4470
+ this.log = this.atlas.organizationAuditLog({ pageSize: this.pageSize, action: this.action });
4471
+ }
4472
+ ngOnDestroy() {
4473
+ this.log.destroy();
4474
+ }
4475
+ describe(action) {
4476
+ return describeAuditAction(action);
4477
+ }
4478
+ cls(key, base) {
4479
+ return classFor(key, this.atlas.appearance, base);
4480
+ }
4481
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: OrganizationAuditLogComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4482
+ 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: `
4483
+ @if (atlas.organization() && !log.state().forbidden) {
4484
+ <div [class]="cls('card', 'atlas-card')" data-atlas="organization-audit-log">
4485
+ <h2 [class]="cls('headerTitle', 'atlas-title')">{{ labels.title ?? 'Audit log' }}</h2>
4486
+ @if (log.state().entries.length === 0 && !log.state().loading) {
4487
+ <p [class]="cls('headerSubtitle', 'atlas-subtitle')">{{ labels.empty ?? 'No activity recorded yet.' }}</p>
4488
+ } @else {
4489
+ <ul [class]="cls('list', 'atlas-audit-list')">
4490
+ @for (entry of log.state().entries; track entry.id) {
4491
+ <li [class]="cls('listItem', 'atlas-audit-item')" [attr.data-action]="entry.action">
4492
+ <strong>{{ describe(entry.action) }}</strong>
4493
+ <span class="atlas-audit-meta">
4494
+ · {{ formatDate(entry.created_at) }}{{ entry.actor.id ? ' · ' + (labels.actor ?? 'by') + ' ' + entry.actor.type + ' ' + entry.actor.id : '' }}
4495
+ </span>
4496
+ </li>
4497
+ }
4498
+ </ul>
4499
+ }
4500
+ @if (log.state().hasMore) {
4501
+ <button
4502
+ type="button"
4503
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
4504
+ [disabled]="log.state().loading"
4505
+ (click)="log.loadMore()"
4506
+ >
4507
+ {{ labels.loadMore ?? 'Load more' }}
4508
+ </button>
4509
+ }
4510
+ </div>
4511
+ }
4512
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
4513
+ }
4514
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: OrganizationAuditLogComponent, decorators: [{
4515
+ type: Component,
4516
+ args: [{
4517
+ selector: 'atlas-organization-audit-log',
4518
+ standalone: true,
4519
+ changeDetection: ChangeDetectionStrategy.OnPush,
4520
+ template: `
4521
+ @if (atlas.organization() && !log.state().forbidden) {
4522
+ <div [class]="cls('card', 'atlas-card')" data-atlas="organization-audit-log">
4523
+ <h2 [class]="cls('headerTitle', 'atlas-title')">{{ labels.title ?? 'Audit log' }}</h2>
4524
+ @if (log.state().entries.length === 0 && !log.state().loading) {
4525
+ <p [class]="cls('headerSubtitle', 'atlas-subtitle')">{{ labels.empty ?? 'No activity recorded yet.' }}</p>
4526
+ } @else {
4527
+ <ul [class]="cls('list', 'atlas-audit-list')">
4528
+ @for (entry of log.state().entries; track entry.id) {
4529
+ <li [class]="cls('listItem', 'atlas-audit-item')" [attr.data-action]="entry.action">
4530
+ <strong>{{ describe(entry.action) }}</strong>
4531
+ <span class="atlas-audit-meta">
4532
+ · {{ formatDate(entry.created_at) }}{{ entry.actor.id ? ' · ' + (labels.actor ?? 'by') + ' ' + entry.actor.type + ' ' + entry.actor.id : '' }}
4533
+ </span>
4534
+ </li>
4535
+ }
4536
+ </ul>
4537
+ }
4538
+ @if (log.state().hasMore) {
4539
+ <button
4540
+ type="button"
4541
+ [class]="cls('formButtonSecondary', 'atlas-button-secondary')"
4542
+ [disabled]="log.state().loading"
4543
+ (click)="log.loadMore()"
4544
+ >
4545
+ {{ labels.loadMore ?? 'Load more' }}
4546
+ </button>
4547
+ }
2595
4548
  </div>
2596
4549
  }
2597
4550
  `,
2598
4551
  }]
2599
- }] });
4552
+ }], propDecorators: { pageSize: [{
4553
+ type: Input
4554
+ }], action: [{
4555
+ type: Input
4556
+ }], labels: [{
4557
+ type: Input
4558
+ }], formatDate: [{
4559
+ type: Input
4560
+ }] } });
2600
4561
 
2601
4562
  /**
2602
4563
  * Every standalone directive and component the SDK ships. Import the array in a
@@ -2620,6 +4581,10 @@ const ATLAS_UI = [
2620
4581
  OrganizationSwitcherComponent,
2621
4582
  UserProfileComponent,
2622
4583
  OrganizationProfileComponent,
4584
+ OrganizationAuditLogComponent,
4585
+ AtlasPoliciesDirective,
4586
+ PolicyAcceptStepComponent,
4587
+ PolicyGateComponent,
2623
4588
  ];
2624
4589
 
2625
4590
  /**
@@ -2677,7 +4642,7 @@ class AtlasModule {
2677
4642
  return { ngModule: AtlasModule, providers };
2678
4643
  }
2679
4644
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
2680
- static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "17.3.12", ngImport: i0, type: AtlasModule, imports: [AtlasSignedInDirective, AtlasSignedOutDirective, AtlasLoadingDirective, AtlasLoadedDirective, AtlasProtectDirective, AtlasRootDirective, SignInComponent, SignUpComponent, UserButtonComponent, OrganizationSwitcherComponent, UserProfileComponent, OrganizationProfileComponent], exports: [AtlasSignedInDirective, AtlasSignedOutDirective, AtlasLoadingDirective, AtlasLoadedDirective, AtlasProtectDirective, AtlasRootDirective, SignInComponent, SignUpComponent, UserButtonComponent, OrganizationSwitcherComponent, UserProfileComponent, OrganizationProfileComponent] });
4645
+ 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, AtlasPoliciesDirective, PolicyAcceptStepComponent, PolicyGateComponent], exports: [AtlasSignedInDirective, AtlasSignedOutDirective, AtlasLoadingDirective, AtlasLoadedDirective, AtlasProtectDirective, AtlasRootDirective, SignInComponent, SignUpComponent, UserButtonComponent, OrganizationSwitcherComponent, UserProfileComponent, OrganizationProfileComponent, OrganizationAuditLogComponent, AtlasPoliciesDirective, PolicyAcceptStepComponent, PolicyGateComponent] });
2681
4646
  static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasModule });
2682
4647
  }
2683
4648
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasModule, decorators: [{
@@ -2690,6 +4655,75 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2690
4655
  }]
2691
4656
  }] });
2692
4657
 
4658
+ /**
4659
+ * `<atlas-impersonation-banner>` — shown only while the active session is an
4660
+ * impersonation, naming the user acted as, with "Return to admin" (or "End
4661
+ * impersonation" when there is no session of the impersonator's to resume).
4662
+ * Emits `ended` with the outcome. The Angular peer of React's
4663
+ * `<ImpersonationBanner/>`.
4664
+ */
4665
+ class ImpersonationBannerComponent {
4666
+ atlas = inject(AtlasService);
4667
+ ended = new EventEmitter();
4668
+ busy = signal(false);
4669
+ actor = computed(() => actorFromClaims(this.atlas.claims()) ?? this.atlas.session()?.actor ?? null);
4670
+ canReturn = computed(() => this.actor() !== null && Boolean(this.atlas.session()?.actor_session_id));
4671
+ name = computed(() => {
4672
+ const u = this.atlas.user();
4673
+ return ([u?.first_name, u?.last_name].filter(Boolean).join(' ') ||
4674
+ u?.username ||
4675
+ u?.email_addresses?.find((e) => e.primary)?.email_address ||
4676
+ u?.id ||
4677
+ '');
4678
+ });
4679
+ t(key, vars) {
4680
+ return translate(this.atlas.localization, key, vars);
4681
+ }
4682
+ cls(key, base) {
4683
+ return classFor(key, this.atlas.appearance, base);
4684
+ }
4685
+ async end() {
4686
+ this.busy.set(true);
4687
+ try {
4688
+ this.ended.emit(await this.atlas.endImpersonation());
4689
+ }
4690
+ finally {
4691
+ this.busy.set(false);
4692
+ }
4693
+ }
4694
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ImpersonationBannerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4695
+ 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: `
4696
+ @if (actor()) {
4697
+ <div [class]="cls('card', 'atlas-alert atlas-impersonation-banner')" role="status">
4698
+ <span [class]="cls('headerSubtitle', 'atlas-text')">{{ t('impersonation.banner', { name: name() }) }}</span>
4699
+ <button type="button" [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy()" (click)="end()">
4700
+ {{ canReturn() ? t('impersonation.returnToAdmin') : t('impersonation.end') }}
4701
+ </button>
4702
+ </div>
4703
+ }
4704
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
4705
+ }
4706
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ImpersonationBannerComponent, decorators: [{
4707
+ type: Component,
4708
+ args: [{
4709
+ selector: 'atlas-impersonation-banner',
4710
+ standalone: true,
4711
+ changeDetection: ChangeDetectionStrategy.OnPush,
4712
+ template: `
4713
+ @if (actor()) {
4714
+ <div [class]="cls('card', 'atlas-alert atlas-impersonation-banner')" role="status">
4715
+ <span [class]="cls('headerSubtitle', 'atlas-text')">{{ t('impersonation.banner', { name: name() }) }}</span>
4716
+ <button type="button" [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy()" (click)="end()">
4717
+ {{ canReturn() ? t('impersonation.returnToAdmin') : t('impersonation.end') }}
4718
+ </button>
4719
+ </div>
4720
+ }
4721
+ `,
4722
+ }]
4723
+ }], propDecorators: { ended: [{
4724
+ type: Output
4725
+ }] } });
4726
+
2693
4727
  /**
2694
4728
  * Route guard factory that gates a route on session — and optionally on a
2695
4729
  * permission/role. The Angular peer of using `<SignedIn>` / `<Protect>` to fence
@@ -2769,5 +4803,5 @@ function atlasChildGuard(options = {}) {
2769
4803
  * Generated bundle index. Do not edit.
2770
4804
  */
2771
4805
 
2772
- export { ATLAS_ANGULAR_SDK, ATLAS_ANGULAR_VERSION, ATLAS_OPTIONS, ATLAS_UI, AtlasCaptchaSlotDirective, AtlasFlowController, AtlasLoadedDirective, AtlasLoadingDirective, AtlasModule, AtlasProtectDirective, AtlasRootDirective, AtlasService, AtlasSignedInDirective, AtlasSignedOutDirective, AttemptFlowComponent, DEFAULT_TOKENS, EN_US, OrganizationProfileComponent, OrganizationSwitcherComponent, SignInComponent, SignUpComponent, SignUpFormComponent, THEMES, UserButtonComponent, UserProfileComponent, atlasChildGuard, atlasGuard, classFor, cssVariables, explicitTokens, provideAtlas, renderAppearance, resolveTokens, translate, withFallback };
4806
+ export { ATLAS_ANGULAR_SDK, ATLAS_ANGULAR_VERSION, ATLAS_OPTIONS, ATLAS_UI, AtlasCaptchaSlotDirective, AtlasFlowController, AtlasLoadedDirective, AtlasLoadingDirective, AtlasModule, AtlasOrganizationAuditLog, AtlasPoliciesDirective, AtlasProtectDirective, AtlasRootDirective, AtlasService, AtlasSignedInDirective, AtlasSignedOutDirective, AttemptFlowComponent, DEFAULT_TOKENS, EN_US, ImpersonationBannerComponent, OrganizationAuditLogComponent, OrganizationProfileComponent, OrganizationSwitcherComponent, PolicyAcceptStepComponent, PolicyGateComponent, SignInComponent, SignUpComponent, SignUpFormComponent, THEMES, UserButtonComponent, UserProfileComponent, atlasChildGuard, atlasGuard, classFor, cssVariables, explicitTokens, provideAtlas, renderAppearance, resolveTokens, translate, withFallback };
2773
4807
  //# sourceMappingURL=atlasauth-angular.mjs.map