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