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