@atlasauth/vue 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/dist/appearance.d.ts +1 -1
- package/dist/appearance.js.map +1 -1
- package/dist/components.d.ts +71 -4
- package/dist/components.js +759 -40
- package/dist/components.js.map +1 -1
- package/dist/composables.d.ts +22 -1
- package/dist/composables.js +41 -0
- package/dist/composables.js.map +1 -1
- package/dist/flow.d.ts +61 -1
- package/dist/flow.js +87 -2
- package/dist/flow.js.map +1 -1
- package/dist/i18n.js +58 -0
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/plugin.d.ts +9 -1
- package/dist/plugin.js +15 -1
- package/dist/plugin.js.map +1 -1
- package/dist/policies.d.ts +125 -0
- package/dist/policies.js +218 -0
- package/dist/policies.js.map +1 -0
- package/dist/types.d.ts +6 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +3 -3
package/dist/components.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.OrganizationProfile = exports.UserProfile = exports.OrganizationSwitcher = exports.UserButton = exports.SignUp = exports.SignIn = exports.Protect = exports.AtlasLoaded = exports.AtlasLoading = exports.SignedOut = exports.SignedIn = void 0;
|
|
3
|
+
exports.OrganizationAuditLog = exports.OrganizationProfile = exports.UserProfile = exports.OrganizationSwitcher = exports.ImpersonationBanner = exports.UserButton = exports.SignUp = exports.SignIn = exports.Protect = exports.AtlasLoaded = exports.AtlasLoading = exports.SignedOut = exports.SignedIn = void 0;
|
|
4
4
|
const vue_1 = require("vue");
|
|
5
5
|
const js_1 = require("@atlasauth/js");
|
|
6
6
|
const context_1 = require("./context");
|
|
@@ -11,6 +11,7 @@ const flow_1 = require("./flow");
|
|
|
11
11
|
const appearance_1 = require("./appearance");
|
|
12
12
|
const i18n_1 = require("./i18n");
|
|
13
13
|
const protect_1 = require("./protect");
|
|
14
|
+
const policies_1 = require("./policies");
|
|
14
15
|
/**
|
|
15
16
|
* §10.2 components — the Vue peer of React's `components.tsx`.
|
|
16
17
|
*
|
|
@@ -141,6 +142,17 @@ function slotRef(controller) {
|
|
|
141
142
|
* Every branch comes from `nextStep`, which is exhaustive — including the
|
|
142
143
|
* `unknown` case, which renders an honest message instead of nothing.
|
|
143
144
|
*/
|
|
145
|
+
/** The recovery codes in two columns, monospaced. */
|
|
146
|
+
const RECOVERY_LIST_STYLE = {
|
|
147
|
+
listStyle: 'none',
|
|
148
|
+
padding: '0',
|
|
149
|
+
margin: '8px 0 12px',
|
|
150
|
+
display: 'grid',
|
|
151
|
+
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
|
|
152
|
+
gap: '6px',
|
|
153
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
154
|
+
textAlign: 'center',
|
|
155
|
+
};
|
|
144
156
|
const AttemptFlow = (0, vue_1.defineComponent)({
|
|
145
157
|
name: 'AttemptFlow',
|
|
146
158
|
props: {
|
|
@@ -171,6 +183,45 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
171
183
|
type: Function,
|
|
172
184
|
default: undefined,
|
|
173
185
|
},
|
|
186
|
+
/**
|
|
187
|
+
* "Email me a code instead" at the second factor (the instance's
|
|
188
|
+
* `account.emailSecondFactor`). Without it the step asks for the code only.
|
|
189
|
+
*/
|
|
190
|
+
onEmailSecondFactor: {
|
|
191
|
+
type: Function,
|
|
192
|
+
default: undefined,
|
|
193
|
+
},
|
|
194
|
+
/**
|
|
195
|
+
* "Text me a code instead" at the second factor (where the app offers
|
|
196
|
+
* texted codes). Without it the step offers no texted code.
|
|
197
|
+
*/
|
|
198
|
+
onSmsSecondFactor: {
|
|
199
|
+
type: Function,
|
|
200
|
+
default: undefined,
|
|
201
|
+
},
|
|
202
|
+
/**
|
|
203
|
+
* The MFA enrolment step (`useFlow().enrollment` and its state): the
|
|
204
|
+
* authenticator app's QR code and setup key, or a texted code. Without it
|
|
205
|
+
* the step asks for two authenticator codes only.
|
|
206
|
+
*/
|
|
207
|
+
enrollment: { type: Object, default: null },
|
|
208
|
+
enrollmentState: { type: Object, default: null },
|
|
209
|
+
/**
|
|
210
|
+
* The instance's `account.emailVerification`. On `link` a sign-up's
|
|
211
|
+
* verification step has nothing to type: it says to open the emailed link
|
|
212
|
+
* while the flow polls, and offers a resend ({@link onResend}).
|
|
213
|
+
*/
|
|
214
|
+
emailVerification: { type: String, default: 'code' },
|
|
215
|
+
onResend: { type: Function, default: undefined },
|
|
216
|
+
/**
|
|
217
|
+
* The recovery codes an MFA enrolment just completed the sign-in with
|
|
218
|
+
* (`pendingRecoveryCodes(state)`): drawn once instead of the form, and
|
|
219
|
+
* "I've saved my codes" calls {@link onRecoveryCodesSaved} to finish.
|
|
220
|
+
*/
|
|
221
|
+
recoveryCodes: { type: Array, default: null },
|
|
222
|
+
onRecoveryCodesSaved: { type: Function, default: undefined },
|
|
223
|
+
/** The user declined a policy the sign-in was waiting on (`needs_policy_acceptance`): cancel the attempt. */
|
|
224
|
+
onPolicyDecline: { type: Function, default: undefined },
|
|
174
225
|
},
|
|
175
226
|
setup(props) {
|
|
176
227
|
const t = useText();
|
|
@@ -180,8 +231,14 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
180
231
|
// The first factor the user picked: the password field by default, or the
|
|
181
232
|
// code field / "check your email" once an emailed code / link was sent.
|
|
182
233
|
const factor = (0, vue_1.ref)('password');
|
|
234
|
+
// "Email me a code instead" at the second factor: where it went, or why not.
|
|
235
|
+
const emailed = (0, vue_1.ref)({});
|
|
236
|
+
// "Text me a code instead": the same, for the texted code.
|
|
237
|
+
const texted = (0, vue_1.ref)({});
|
|
183
238
|
(0, vue_1.watch)(() => props.attempt?.id, () => {
|
|
184
239
|
factor.value = 'password';
|
|
240
|
+
emailed.value = {};
|
|
241
|
+
texted.value = {};
|
|
185
242
|
});
|
|
186
243
|
// The first factors on offer for the current step, and the plan for them:
|
|
187
244
|
// a password field only when the server offers `password` (planFirstFactor).
|
|
@@ -218,12 +275,13 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
218
275
|
void send(strategy);
|
|
219
276
|
},
|
|
220
277
|
}, t(labelKey));
|
|
221
|
-
const field = (name, labelKey, type = 'text') => (0, vue_1.h)('label', { class: 'atlas-field', key: name }, [
|
|
278
|
+
const field = (name, labelKey, type = 'text', autocomplete) => (0, vue_1.h)('label', { class: 'atlas-field', key: name }, [
|
|
222
279
|
(0, vue_1.h)('span', t(labelKey)),
|
|
223
280
|
(0, vue_1.h)('input', {
|
|
224
281
|
class: cls('formFieldInput', 'atlas-input'),
|
|
225
282
|
type,
|
|
226
283
|
name,
|
|
284
|
+
...(autocomplete ? { autocomplete } : {}),
|
|
227
285
|
value: values[name] ?? '',
|
|
228
286
|
onInput: (event) => {
|
|
229
287
|
values[name] = event.target.value;
|
|
@@ -233,6 +291,69 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
233
291
|
.filter((error) => error.param === name)
|
|
234
292
|
.map((error) => (0, vue_1.h)('span', { class: cls('formFieldError', 'atlas-error'), key: error.code }, errorText(error))),
|
|
235
293
|
]);
|
|
294
|
+
/**
|
|
295
|
+
* The enrolment step: the method choice, the authenticator's QR code and
|
|
296
|
+
* setup key with its code field(s), or the phone number then the texted code.
|
|
297
|
+
*/
|
|
298
|
+
const enrollmentFields = (enrol, s) => {
|
|
299
|
+
const out = [];
|
|
300
|
+
const methodButton = (m, key) => (0, vue_1.h)('button', {
|
|
301
|
+
key,
|
|
302
|
+
class: cls('formButtonSecondary', 'atlas-button-secondary'),
|
|
303
|
+
type: 'button',
|
|
304
|
+
'data-atlas-mfa-method': m,
|
|
305
|
+
disabled: props.busy || s.busy,
|
|
306
|
+
onClick: () => {
|
|
307
|
+
for (const k of Object.keys(values))
|
|
308
|
+
delete values[k];
|
|
309
|
+
void enrol.choose(m);
|
|
310
|
+
},
|
|
311
|
+
}, t(m === 'totp' ? 'mfa.enrollTotpOption' : 'mfa.enrollSmsOption'));
|
|
312
|
+
if (s.method === null) {
|
|
313
|
+
out.push((0, vue_1.h)('p', { key: 'choose', class: 'atlas-text' }, t('mfa.enrollChoose')));
|
|
314
|
+
for (const m of s.options)
|
|
315
|
+
out.push(methodButton(m, `method-${m}`));
|
|
316
|
+
}
|
|
317
|
+
else if (s.method === 'totp') {
|
|
318
|
+
const e = s.enrollment;
|
|
319
|
+
if (!e) {
|
|
320
|
+
out.push((0, vue_1.h)('p', { key: 'preparing', class: 'atlas-text', 'aria-live': 'polite' }, s.busy ? t('mfa.enrollPreparing') : ''));
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
out.push((0, vue_1.h)('p', { key: 'scan', class: 'atlas-text' }, t('mfa.enrollScan')));
|
|
324
|
+
if (e.qrCode) {
|
|
325
|
+
out.push((0, vue_1.h)('img', { key: 'qr', class: 'atlas-mfa-qr', 'data-atlas': 'mfa-qr', src: e.qrCode, alt: t('mfa.enrollQrAlt'), width: 180, height: 180 }));
|
|
326
|
+
}
|
|
327
|
+
if (e.secret) {
|
|
328
|
+
out.push((0, vue_1.h)('p', { key: 'secret', class: 'atlas-text', 'data-atlas': 'mfa-setup-key' }, [`${t('mfa.enrollSetupKey')}: `, (0, vue_1.h)('code', (0, js_1.groupSetupKey)(e.secret))]));
|
|
329
|
+
}
|
|
330
|
+
out.push(field('code', 'mfa.enrollFirstCodeLabel', 'text', 'one-time-code'));
|
|
331
|
+
if (s.totpCodes >= 2)
|
|
332
|
+
out.push(field('secondCode', 'mfa.enrollSecondCodeLabel', 'text', 'one-time-code'));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
else if (!s.enrollment) {
|
|
336
|
+
out.push(field('phone', 'mfa.phoneLabel', 'tel', 'tel'), (0, vue_1.h)('p', { key: 'phone-hint', class: 'atlas-text' }, t('mfa.phoneHint')));
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
out.push((0, vue_1.h)('p', { key: 'texted', class: 'atlas-text', 'data-atlas': 'mfa-code-texted', 'aria-live': 'polite' }, t('mfa.codeTextedTo', { phone: s.enrollment.phone ?? '' })), field('code', 'mfa.codeLabel', 'text', 'one-time-code'), (0, vue_1.h)('button', {
|
|
340
|
+
key: 'resend',
|
|
341
|
+
class: cls('formButtonSecondary', 'atlas-button-secondary'),
|
|
342
|
+
type: 'button',
|
|
343
|
+
'data-atlas-resend': '',
|
|
344
|
+
disabled: props.busy || s.busy,
|
|
345
|
+
onClick: () => void enrol.resend(),
|
|
346
|
+
}, t('signIn.resendCode')));
|
|
347
|
+
}
|
|
348
|
+
for (const error of s.errors) {
|
|
349
|
+
out.push((0, vue_1.h)('p', { key: `enrol-error-${error.code}-${error.param ?? ''}`, class: cls('formFieldError', 'atlas-error'), role: 'alert' }, error.message));
|
|
350
|
+
}
|
|
351
|
+
if (s.method !== null && s.options.length > 1) {
|
|
352
|
+
const other = s.options.find((m) => m !== s.method);
|
|
353
|
+
out.push(methodButton(other, 'switch'));
|
|
354
|
+
}
|
|
355
|
+
return out;
|
|
356
|
+
};
|
|
236
357
|
const fields = (step) => {
|
|
237
358
|
switch (step.kind) {
|
|
238
359
|
case 'collect_identifier':
|
|
@@ -287,9 +408,59 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
287
408
|
offered.includes('email_link') ? emailChoice('email_link', 'signIn.useEmailLink') : null,
|
|
288
409
|
].filter((node) => node !== null);
|
|
289
410
|
}
|
|
290
|
-
case 'collect_second_factor':
|
|
291
|
-
|
|
411
|
+
case 'collect_second_factor': {
|
|
412
|
+
const out = [field('code', 'mfa.codeLabel')];
|
|
413
|
+
if (props.onSmsSecondFactor) {
|
|
414
|
+
const state = texted.value;
|
|
415
|
+
if (state.sentTo !== undefined) {
|
|
416
|
+
out.push((0, vue_1.h)('p', { key: 'sms-2fa-sent', class: 'atlas-text', 'data-atlas': 'sms-second-factor-sent', 'aria-live': 'polite' }, t('mfa.codeTextedTo', { phone: state.sentTo })));
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
out.push((0, vue_1.h)('button', {
|
|
420
|
+
key: 'sms-2fa',
|
|
421
|
+
class: cls('formButtonSecondary', 'atlas-button-secondary'),
|
|
422
|
+
type: 'button',
|
|
423
|
+
'data-atlas': 'sms-second-factor',
|
|
424
|
+
disabled: props.busy || state.busy,
|
|
425
|
+
onClick: () => {
|
|
426
|
+
texted.value = { busy: true };
|
|
427
|
+
void props.onSmsSecondFactor().then((r) => {
|
|
428
|
+
texted.value = r.error ? { error: r.error } : { sentTo: r.sentTo ?? '' };
|
|
429
|
+
});
|
|
430
|
+
},
|
|
431
|
+
}, state.busy ? t('signIn.sending') : t('mfa.smsCodeOption')));
|
|
432
|
+
if (state.error)
|
|
433
|
+
out.push((0, vue_1.h)('p', { key: 'sms-2fa-error', class: cls('formFieldError', 'atlas-error'), role: 'alert' }, state.error));
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (props.onEmailSecondFactor) {
|
|
437
|
+
const state = emailed.value;
|
|
438
|
+
if (state.sentTo !== undefined) {
|
|
439
|
+
out.push((0, vue_1.h)('p', { key: 'email-2fa-sent', class: 'atlas-text', 'data-atlas': 'email-second-factor-sent', 'aria-live': 'polite' }, t('signIn.codeSentTo', { email: state.sentTo })));
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
out.push((0, vue_1.h)('button', {
|
|
443
|
+
key: 'email-2fa',
|
|
444
|
+
class: cls('formButtonSecondary', 'atlas-button-secondary'),
|
|
445
|
+
type: 'button',
|
|
446
|
+
'data-atlas': 'email-second-factor',
|
|
447
|
+
disabled: props.busy || state.busy,
|
|
448
|
+
onClick: () => {
|
|
449
|
+
emailed.value = { busy: true };
|
|
450
|
+
void props.onEmailSecondFactor().then((r) => {
|
|
451
|
+
emailed.value = r.error ? { error: r.error } : { sentTo: r.sentTo ?? '' };
|
|
452
|
+
});
|
|
453
|
+
},
|
|
454
|
+
}, state.busy ? t('signIn.sending') : t('signIn.emailCodeOption')));
|
|
455
|
+
if (state.error)
|
|
456
|
+
out.push((0, vue_1.h)('p', { key: 'email-2fa-error', class: cls('formFieldError', 'atlas-error'), role: 'alert' }, state.error));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return out;
|
|
460
|
+
}
|
|
292
461
|
case 'enroll_second_factor':
|
|
462
|
+
if (props.enrollment && props.enrollmentState)
|
|
463
|
+
return enrollmentFields(props.enrollment, props.enrollmentState);
|
|
293
464
|
// Two codes, not one. §5.6 wants consecutive codes so a phone with a
|
|
294
465
|
// fast clock fails here rather than on every sign-in afterwards.
|
|
295
466
|
return [
|
|
@@ -297,9 +468,36 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
297
468
|
field('secondCode', 'mfa.enrollSecondCodeLabel'),
|
|
298
469
|
];
|
|
299
470
|
case 'collect_email_code':
|
|
471
|
+
if ((0, js_1.awaitingSignUpLink)(props.attempt, props.emailVerification)) {
|
|
472
|
+
// Nothing to type: the flow polls until the emailed link is opened (anywhere).
|
|
473
|
+
return [
|
|
474
|
+
(0, vue_1.h)('p', { key: 'link-sent', class: 'atlas-text', 'data-atlas-link-sent': '', 'aria-live': 'polite' }, t('signUp.linkSentTo', { email: props.attempt?.identifier ?? '' })),
|
|
475
|
+
props.onResend
|
|
476
|
+
? (0, vue_1.h)('button', {
|
|
477
|
+
key: 'resend',
|
|
478
|
+
class: cls('formButtonSecondary', 'atlas-button-secondary'),
|
|
479
|
+
type: 'button',
|
|
480
|
+
'data-atlas-resend': '',
|
|
481
|
+
disabled: props.busy,
|
|
482
|
+
onClick: () => void props.onResend(),
|
|
483
|
+
}, t('signIn.resendLink'))
|
|
484
|
+
: null,
|
|
485
|
+
].filter((node) => node !== null);
|
|
486
|
+
}
|
|
300
487
|
return [field('code', 'signIn.emailCodeLabel')];
|
|
301
488
|
case 'collect_new_password':
|
|
302
489
|
return [field('password', 'reset.newPasswordLabel', 'password')];
|
|
490
|
+
case 'accept_policies':
|
|
491
|
+
// A required policy changed: scroll-mode ones in their dialog, the rest as checkboxes.
|
|
492
|
+
return [
|
|
493
|
+
(0, vue_1.h)(policies_1.PolicyAcceptStep, {
|
|
494
|
+
key: 'policies',
|
|
495
|
+
attempt: props.attempt,
|
|
496
|
+
busy: props.busy,
|
|
497
|
+
onSubmit: props.onSubmit,
|
|
498
|
+
onDecline: (policy) => props.onPolicyDecline?.(policy.title),
|
|
499
|
+
}),
|
|
500
|
+
];
|
|
303
501
|
case 'collect_captcha':
|
|
304
502
|
// §5 the server withheld the attempt until a captcha clears. Solving
|
|
305
503
|
// the widget resubmits the identifier on its own; the button retries.
|
|
@@ -325,7 +523,53 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
325
523
|
];
|
|
326
524
|
}
|
|
327
525
|
};
|
|
526
|
+
// The recovery codes: copied once ("Copied."), reset for a new set.
|
|
527
|
+
const codesCopied = (0, vue_1.ref)(false);
|
|
528
|
+
(0, vue_1.watch)(() => props.recoveryCodes, () => {
|
|
529
|
+
codesCopied.value = false;
|
|
530
|
+
});
|
|
531
|
+
const recoveryCodesCard = (codes, onSaved) => {
|
|
532
|
+
const canCopy = typeof navigator !== 'undefined' && !!navigator.clipboard?.writeText;
|
|
533
|
+
const secondary = cls('formButtonSecondary', 'atlas-button-secondary');
|
|
534
|
+
return (0, vue_1.h)('div', { class: cls('card', 'atlas-card'), 'data-atlas': 'recovery-codes' }, [
|
|
535
|
+
(0, vue_1.h)('h1', { class: cls('headerTitle', 'atlas-title') }, t('mfa.recoveryCodesTitle')),
|
|
536
|
+
(0, vue_1.h)('p', { class: 'atlas-text' }, t('mfa.recoveryCodesHelp')),
|
|
537
|
+
(0, vue_1.h)('ul', {
|
|
538
|
+
class: 'atlas-recovery-codes',
|
|
539
|
+
'data-atlas': 'recovery-codes-list',
|
|
540
|
+
style: RECOVERY_LIST_STYLE,
|
|
541
|
+
}, codes.map((code) => (0, vue_1.h)('li', { key: code }, [(0, vue_1.h)('code', null, code)]))),
|
|
542
|
+
(0, vue_1.h)('div', { class: 'atlas-alternatives' }, [
|
|
543
|
+
canCopy
|
|
544
|
+
? (0, vue_1.h)('button', {
|
|
545
|
+
class: secondary,
|
|
546
|
+
type: 'button',
|
|
547
|
+
'data-atlas': 'recovery-codes-copy',
|
|
548
|
+
onClick: () => void (0, js_1.copyRecoveryCodes)(codes).then((ok) => {
|
|
549
|
+
codesCopied.value = ok;
|
|
550
|
+
}),
|
|
551
|
+
}, t('mfa.recoveryCodesCopy'))
|
|
552
|
+
: null,
|
|
553
|
+
(0, js_1.canDownloadRecoveryCodes)()
|
|
554
|
+
? (0, vue_1.h)('button', { class: secondary, type: 'button', 'data-atlas': 'recovery-codes-download', onClick: () => (0, js_1.downloadRecoveryCodes)(codes) }, t('mfa.recoveryCodesDownload'))
|
|
555
|
+
: null,
|
|
556
|
+
]),
|
|
557
|
+
codesCopied.value
|
|
558
|
+
? (0, vue_1.h)('p', { class: 'atlas-text', 'aria-live': 'polite', 'data-atlas': 'recovery-codes-copied' }, t('mfa.recoveryCodesCopied'))
|
|
559
|
+
: null,
|
|
560
|
+
(0, vue_1.h)('button', {
|
|
561
|
+
class: cls('formButtonPrimary', 'atlas-button'),
|
|
562
|
+
type: 'button',
|
|
563
|
+
'data-atlas': 'recovery-codes-saved',
|
|
564
|
+
disabled: props.busy,
|
|
565
|
+
onClick: onSaved,
|
|
566
|
+
}, t('mfa.recoveryCodesSaved')),
|
|
567
|
+
]);
|
|
568
|
+
};
|
|
328
569
|
return () => {
|
|
570
|
+
if (props.recoveryCodes?.length && props.onRecoveryCodesSaved) {
|
|
571
|
+
return recoveryCodesCard(props.recoveryCodes, props.onRecoveryCodesSaved);
|
|
572
|
+
}
|
|
329
573
|
const step = props.attempt
|
|
330
574
|
? (0, js_1.nextStep)(props.attempt)
|
|
331
575
|
: { kind: 'collect_identifier' };
|
|
@@ -344,6 +588,14 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
344
588
|
props.onSubmit(factor.value === 'email_code' ? { code: values.code ?? '' } : { password: values.password ?? '' });
|
|
345
589
|
return;
|
|
346
590
|
}
|
|
591
|
+
// Enrolment: text the code to the typed number, or confirm the factor.
|
|
592
|
+
if (current?.kind === 'enroll_second_factor' && props.enrollment) {
|
|
593
|
+
void props.enrollment.submit({ ...values }).then((next) => {
|
|
594
|
+
if (next)
|
|
595
|
+
props.onSubmit(next);
|
|
596
|
+
});
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
347
599
|
props.onSubmit({ ...values });
|
|
348
600
|
},
|
|
349
601
|
}, [
|
|
@@ -353,13 +605,18 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
353
605
|
: null,
|
|
354
606
|
step.kind !== 'done' &&
|
|
355
607
|
step.kind !== 'await_oauth' &&
|
|
608
|
+
step.kind !== 'accept_policies' &&
|
|
609
|
+
!(0, js_1.awaitingSignUpLink)(props.attempt, props.emailVerification) &&
|
|
356
610
|
!(step.kind === 'collect_first_factor' && factor.value === 'email_link') &&
|
|
357
|
-
!(step.kind === 'collect_first_factor' && factor.value === 'password' && firstFactorPlan()?.password === false && (firstFactorPlan()?.email.length ?? 0) > 0)
|
|
611
|
+
!(step.kind === 'collect_first_factor' && factor.value === 'password' && firstFactorPlan()?.password === false && (firstFactorPlan()?.email.length ?? 0) > 0) &&
|
|
612
|
+
!(step.kind === 'enroll_second_factor' && props.enrollmentState && !(0, js_1.enrollmentSubmitKey)(props.enrollmentState))
|
|
358
613
|
? (0, vue_1.h)('button', {
|
|
359
614
|
class: cls('formButtonPrimary', 'atlas-button'),
|
|
360
|
-
disabled: props.busy,
|
|
615
|
+
disabled: props.busy || (step.kind === 'enroll_second_factor' && !!props.enrollmentState?.busy),
|
|
361
616
|
type: 'submit',
|
|
362
|
-
}, t('
|
|
617
|
+
}, t(step.kind === 'enroll_second_factor' && props.enrollmentState
|
|
618
|
+
? ((0, js_1.enrollmentSubmitKey)(props.enrollmentState) ?? 'signIn.submit')
|
|
619
|
+
: 'signIn.submit'))
|
|
363
620
|
: null,
|
|
364
621
|
]),
|
|
365
622
|
]);
|
|
@@ -379,7 +636,7 @@ exports.SignIn = (0, vue_1.defineComponent)({
|
|
|
379
636
|
signUpUrl: { type: null, default: undefined },
|
|
380
637
|
},
|
|
381
638
|
setup(props) {
|
|
382
|
-
const { state, submit, prepare, captcha, settings } = (0, flow_1.useFlow)();
|
|
639
|
+
const { state, submit, prepare, captcha, settings, lastMethod, sendEmailSecondFactor, sendSmsSecondFactor, mfaChoices, enrollment, enrollmentState, saveRecoveryCodes, declinePolicy } = (0, flow_1.useFlow)();
|
|
383
640
|
const atlas = (0, context_1.useAtlas)();
|
|
384
641
|
const t = useText();
|
|
385
642
|
const cls = useClass();
|
|
@@ -415,10 +672,24 @@ exports.SignIn = (0, vue_1.defineComponent)({
|
|
|
415
672
|
captcha: captcha.needed ? captcha : null,
|
|
416
673
|
emailDelivery: settings.value?.emailDelivery ?? null,
|
|
417
674
|
onPrepare: prepare,
|
|
675
|
+
onEmailSecondFactor: settings.value?.account.emailSecondFactor ? sendEmailSecondFactor : undefined,
|
|
676
|
+
onSmsSecondFactor: mfaChoices().sms ? sendSmsSecondFactor : undefined,
|
|
677
|
+
enrollment,
|
|
678
|
+
enrollmentState: enrollmentState.value,
|
|
679
|
+
recoveryCodes: (0, js_1.pendingRecoveryCodes)(state.value),
|
|
680
|
+
onRecoveryCodesSaved: saveRecoveryCodes,
|
|
681
|
+
onPolicyDecline: declinePolicy,
|
|
418
682
|
});
|
|
419
|
-
|
|
683
|
+
const lastLabel = !state.value.attempt ? (0, js_1.lastUsedMethodLabel)(lastMethod.value, t) : null;
|
|
684
|
+
const hint = lastLabel
|
|
685
|
+
? (0, vue_1.h)('p', { key: 'last-used', class: cls('badge', 'atlas-text atlas-last-used-hint'), 'data-atlas': 'last-used', 'data-atlas-method': lastMethod.value }, `${t('signIn.lastUsed')}: ${lastLabel}`)
|
|
686
|
+
: null;
|
|
687
|
+
if (!href && !hint)
|
|
420
688
|
return flow;
|
|
689
|
+
if (!href)
|
|
690
|
+
return [hint, flow];
|
|
421
691
|
return [
|
|
692
|
+
...(hint ? [hint] : []),
|
|
422
693
|
flow,
|
|
423
694
|
(0, vue_1.h)('p', { key: 'sign-up-prompt', class: cls('footerAction', 'atlas-footer'), 'data-atlas-sign-up-prompt': '' }, [
|
|
424
695
|
`${t('signIn.noAccount')} `,
|
|
@@ -445,6 +716,12 @@ const SignUpForm = (0, vue_1.defineComponent)({
|
|
|
445
716
|
type: Function,
|
|
446
717
|
required: true,
|
|
447
718
|
},
|
|
719
|
+
/** Passkey-only sign-up: no password field; the email per the instance's mode. */
|
|
720
|
+
passkey: { type: Object, default: null },
|
|
721
|
+
/** Toggle between the password and passkey forms (only when both are offered). */
|
|
722
|
+
onSwitchMode: { type: Function, default: undefined },
|
|
723
|
+
/** The instance's policies (terms, privacy, …); they replace the legacy consent row. */
|
|
724
|
+
policies: { type: Object, default: null },
|
|
448
725
|
},
|
|
449
726
|
setup(props) {
|
|
450
727
|
const t = useText();
|
|
@@ -452,6 +729,9 @@ const SignUpForm = (0, vue_1.defineComponent)({
|
|
|
452
729
|
const cls = useClass();
|
|
453
730
|
const values = (0, vue_1.reactive)({});
|
|
454
731
|
const agreed = (0, vue_1.ref)(false);
|
|
732
|
+
const policyTick = props.policies ? (0, policies_1.usePolicyState)(props.policies) : (0, vue_1.ref)(0);
|
|
733
|
+
const declined = (0, vue_1.ref)(null);
|
|
734
|
+
const policyT = (0, js_1.policyTranslator)((key, vars) => t(key, vars));
|
|
455
735
|
const fieldErrors = (name) => props.errors
|
|
456
736
|
.filter((error) => error.param === name)
|
|
457
737
|
.map((error) => (0, vue_1.h)('span', { class: cls('formFieldError', 'atlas-error'), key: error.code }, errorText(error)));
|
|
@@ -527,14 +807,14 @@ const SignUpForm = (0, vue_1.defineComponent)({
|
|
|
527
807
|
}
|
|
528
808
|
}
|
|
529
809
|
};
|
|
530
|
-
const textField = (name, labelKey, type, autocomplete) => (0, vue_1.h)('label', { key: name, class: cls('formField', 'atlas-field') }, [
|
|
531
|
-
(0, vue_1.h)('span', { class: cls('formFieldLabel', 'atlas-field-label') }, t(labelKey)),
|
|
810
|
+
const textField = (name, labelKey, type, autocomplete, required = true) => (0, vue_1.h)('label', { key: name, class: cls('formField', 'atlas-field') }, [
|
|
811
|
+
(0, vue_1.h)('span', { class: cls('formFieldLabel', 'atlas-field-label') }, required ? t(labelKey) : t('signUp.optionalField', { label: t(labelKey) })),
|
|
532
812
|
(0, vue_1.h)('input', {
|
|
533
813
|
class: cls('formFieldInput', 'atlas-input'),
|
|
534
814
|
type,
|
|
535
815
|
name,
|
|
536
816
|
autocomplete,
|
|
537
|
-
required
|
|
817
|
+
required,
|
|
538
818
|
value: values[name] ?? '',
|
|
539
819
|
onInput: (e) => {
|
|
540
820
|
values[name] = e.target.value;
|
|
@@ -543,9 +823,12 @@ const SignUpForm = (0, vue_1.defineComponent)({
|
|
|
543
823
|
...fieldErrors(name),
|
|
544
824
|
]);
|
|
545
825
|
return () => {
|
|
546
|
-
|
|
826
|
+
void policyTick.value;
|
|
827
|
+
const hasPolicies = !!props.policies && props.policies.policies.length > 0;
|
|
828
|
+
// Instance policies replace the legacy single "I agree to the Terms" row.
|
|
829
|
+
const legal = hasPolicies ? undefined : props.settings?.legal;
|
|
547
830
|
const showLegal = !!legal && (!!legal.termsUrl || !!legal.privacyUrl);
|
|
548
|
-
const consentOk = !legal?.required || agreed.value;
|
|
831
|
+
const consentOk = (!legal?.required || agreed.value) && (!hasPolicies || props.policies.canSubmit());
|
|
549
832
|
const links = showLegal
|
|
550
833
|
? [
|
|
551
834
|
legal.termsUrl
|
|
@@ -559,7 +842,8 @@ const SignUpForm = (0, vue_1.defineComponent)({
|
|
|
559
842
|
// The app can't deliver the verification email, so an email + password
|
|
560
843
|
// sign-up could never complete: say so up front instead of a form that
|
|
561
844
|
// fails on submit.
|
|
562
|
-
|
|
845
|
+
const passkey = props.passkey;
|
|
846
|
+
if (props.settings?.emailDelivery?.passwordSignUp === false && !(passkey && passkey.email !== 'required')) {
|
|
563
847
|
return (0, vue_1.h)('div', { class: cls('card', 'atlas-card') }, [
|
|
564
848
|
(0, vue_1.h)('h1', { class: cls('headerTitle', 'atlas-title') }, t('signUp.title')),
|
|
565
849
|
(0, vue_1.h)('p', { class: cls('formFieldError', 'atlas-error'), role: 'alert', 'data-atlas-email-unavailable': '' }, t('signUp.emailUnavailable')),
|
|
@@ -575,6 +859,17 @@ const SignUpForm = (0, vue_1.defineComponent)({
|
|
|
575
859
|
event.preventDefault();
|
|
576
860
|
if (!consentOk)
|
|
577
861
|
return;
|
|
862
|
+
declined.value = null;
|
|
863
|
+
if (hasPolicies) {
|
|
864
|
+
// Scroll-mode policies are read in their unskippable dialog first.
|
|
865
|
+
void (0, js_1.confirmPolicies)(props.policies, { t: policyT }).then((res) => {
|
|
866
|
+
if (res.ok)
|
|
867
|
+
props.onSubmit({ ...values }, true);
|
|
868
|
+
else
|
|
869
|
+
declined.value = res.declined;
|
|
870
|
+
});
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
578
873
|
props.onSubmit({ ...values }, legal?.required ? agreed.value : true);
|
|
579
874
|
},
|
|
580
875
|
}, [
|
|
@@ -585,8 +880,13 @@ const SignUpForm = (0, vue_1.defineComponent)({
|
|
|
585
880
|
control(f),
|
|
586
881
|
...fieldErrors(f.key),
|
|
587
882
|
])),
|
|
588
|
-
|
|
589
|
-
|
|
883
|
+
passkey
|
|
884
|
+
? passkey.email === 'off'
|
|
885
|
+
? null
|
|
886
|
+
: textField('email', 'signUp.emailLabel', 'email', 'email', passkey.email === 'required')
|
|
887
|
+
: textField('email', 'signUp.emailLabel', 'email', 'email'),
|
|
888
|
+
passkey ? null : textField('password', 'signUp.passwordLabel', 'password', 'new-password'),
|
|
889
|
+
passkey ? (0, vue_1.h)('p', { key: 'passkey-intro', class: 'atlas-text' }, t('signUp.passkeyIntro')) : null,
|
|
590
890
|
showLegal
|
|
591
891
|
? (0, vue_1.h)('label', { key: 'consent', class: cls('formField', 'atlas-consent') }, [
|
|
592
892
|
legal.required
|
|
@@ -608,17 +908,53 @@ const SignUpForm = (0, vue_1.defineComponent)({
|
|
|
608
908
|
...fieldErrors('consent'),
|
|
609
909
|
])
|
|
610
910
|
: null,
|
|
911
|
+
hasPolicies ? (0, vue_1.h)(policies_1.PolicyConsent, { key: 'policies', controller: props.policies }) : null,
|
|
912
|
+
declined.value
|
|
913
|
+
? (0, vue_1.h)('span', { key: 'declined', class: cls('formFieldError', 'atlas-error'), role: 'alert', 'data-atlas-policy-declined': '' }, t('policy.declined', { policy: declined.value }))
|
|
914
|
+
: null,
|
|
611
915
|
captchaSlot(props.captcha),
|
|
612
916
|
(0, vue_1.h)('button', {
|
|
613
917
|
class: cls('formButtonPrimary', 'atlas-button'),
|
|
614
918
|
disabled: props.busy || !consentOk,
|
|
615
919
|
type: 'submit',
|
|
616
920
|
}, t('signUp.submit')),
|
|
921
|
+
hasPolicies ? (0, vue_1.h)(policies_1.PolicyClickwrap, { key: 'clickwrap', controller: props.policies }) : null,
|
|
922
|
+
props.onSwitchMode
|
|
923
|
+
? (0, vue_1.h)('div', { key: 'alternatives', class: 'atlas-alternatives' }, [
|
|
924
|
+
(0, vue_1.h)('button', {
|
|
925
|
+
type: 'button',
|
|
926
|
+
class: cls('formButtonSecondary', 'atlas-button-secondary'),
|
|
927
|
+
'data-atlas-sign-up-mode': passkey ? 'password' : 'passkey',
|
|
928
|
+
disabled: props.busy,
|
|
929
|
+
onClick: () => props.onSwitchMode?.(),
|
|
930
|
+
}, t(passkey ? 'signUp.passwordOption' : 'signUp.passkeyOption')),
|
|
931
|
+
])
|
|
932
|
+
: null,
|
|
617
933
|
]),
|
|
618
934
|
]);
|
|
619
935
|
};
|
|
620
936
|
},
|
|
621
937
|
});
|
|
938
|
+
/** The passkey-only sign-up's last step: create the passkey (which creates the account). */
|
|
939
|
+
const PasskeySignUpStep = (0, vue_1.defineComponent)({
|
|
940
|
+
name: 'PasskeySignUpStep',
|
|
941
|
+
props: {
|
|
942
|
+
busy: { type: Boolean, default: false },
|
|
943
|
+
errors: { type: Array, default: () => [] },
|
|
944
|
+
onCreate: { type: Function, required: true },
|
|
945
|
+
},
|
|
946
|
+
setup(props) {
|
|
947
|
+
const t = useText();
|
|
948
|
+
const errorText = useErrorText();
|
|
949
|
+
const cls = useClass();
|
|
950
|
+
return () => (0, vue_1.h)('div', { class: cls('card', 'atlas-card'), 'data-atlas-passkey-sign-up': '' }, [
|
|
951
|
+
(0, vue_1.h)('h1', { class: cls('headerTitle', 'atlas-title') }, t('signUp.title')),
|
|
952
|
+
...props.errors.map((error) => (0, vue_1.h)('p', { class: cls('formFieldError', 'atlas-error'), role: 'alert', key: error.code }, errorText(error))),
|
|
953
|
+
(0, vue_1.h)('p', { class: 'atlas-text' }, t('signUp.passkeyPrompt')),
|
|
954
|
+
(0, vue_1.h)('button', { class: cls('formButtonPrimary', 'atlas-button'), type: 'button', disabled: props.busy, onClick: () => props.onCreate() }, t('signUp.passkeyCreate')),
|
|
955
|
+
]);
|
|
956
|
+
},
|
|
957
|
+
});
|
|
622
958
|
/**
|
|
623
959
|
* §10.2 `<SignUp/>` — creates an account. The first step is the create-account
|
|
624
960
|
* form, which POSTs `/v1/client/sign_ups`; from there the flow is server-driven
|
|
@@ -632,23 +968,45 @@ exports.SignUp = (0, vue_1.defineComponent)({
|
|
|
632
968
|
afterUrl: { type: String, default: undefined },
|
|
633
969
|
},
|
|
634
970
|
setup() {
|
|
635
|
-
const { state, submit, create, settings, signUpCaptcha } = (0, flow_1.useFlow)();
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
971
|
+
const { state, submit, create, createWithPasskey, createPasskey, resendSignUp, settings, signUpCaptcha, policies, loadPolicies } = (0, flow_1.useFlow)();
|
|
972
|
+
const mode = (0, vue_1.ref)('password');
|
|
973
|
+
(0, vue_1.onMounted)(() => {
|
|
974
|
+
void loadPolicies();
|
|
975
|
+
});
|
|
976
|
+
return () => {
|
|
977
|
+
const offer = settings.value?.account.passkeySignUp ?? null;
|
|
978
|
+
const canPasskey = !!offer && (0, js_1.passkeysSupported)();
|
|
979
|
+
const usingPasskey = mode.value === 'passkey' && canPasskey;
|
|
980
|
+
if (state.value.attempt && (0, js_1.awaitingPasskey)(state.value.attempt)) {
|
|
981
|
+
return (0, vue_1.h)(PasskeySignUpStep, { busy: state.value.busy, errors: state.value.errors, onCreate: createPasskey });
|
|
982
|
+
}
|
|
983
|
+
return state.value.attempt
|
|
984
|
+
? (0, vue_1.h)(AttemptFlow, {
|
|
985
|
+
attempt: state.value.attempt,
|
|
986
|
+
busy: state.value.busy,
|
|
987
|
+
errors: state.value.errors,
|
|
988
|
+
sessionKept: state.value.sessionKept,
|
|
989
|
+
titleKey: 'signUp.title',
|
|
990
|
+
onSubmit: submit,
|
|
991
|
+
emailVerification: settings.value?.account.emailVerification ?? 'code',
|
|
992
|
+
onResend: resendSignUp,
|
|
993
|
+
})
|
|
994
|
+
: (0, vue_1.h)(SignUpForm, {
|
|
995
|
+
settings: settings.value,
|
|
996
|
+
busy: state.value.busy,
|
|
997
|
+
errors: state.value.errors,
|
|
998
|
+
captcha: signUpCaptcha.needed ? signUpCaptcha : null,
|
|
999
|
+
policies,
|
|
1000
|
+
onSubmit: usingPasskey ? createWithPasskey : create,
|
|
1001
|
+
passkey: usingPasskey ? { email: offer.email } : null,
|
|
1002
|
+
onSwitchMode: canPasskey
|
|
1003
|
+
? () => {
|
|
1004
|
+
state.value = { ...state.value, errors: [] };
|
|
1005
|
+
mode.value = usingPasskey ? 'password' : 'passkey';
|
|
1006
|
+
}
|
|
1007
|
+
: undefined,
|
|
1008
|
+
});
|
|
1009
|
+
};
|
|
652
1010
|
},
|
|
653
1011
|
});
|
|
654
1012
|
exports.UserButton = (0, vue_1.defineComponent)({
|
|
@@ -684,6 +1042,46 @@ exports.UserButton = (0, vue_1.defineComponent)({
|
|
|
684
1042
|
};
|
|
685
1043
|
},
|
|
686
1044
|
});
|
|
1045
|
+
/**
|
|
1046
|
+
* A banner shown only while the active session is an impersonation, naming the
|
|
1047
|
+
* user acted as, with "Return to admin" (or "End impersonation" when there is no
|
|
1048
|
+
* session of the impersonator's to resume). Emits `ended` with the outcome.
|
|
1049
|
+
*/
|
|
1050
|
+
exports.ImpersonationBanner = (0, vue_1.defineComponent)({
|
|
1051
|
+
name: 'ImpersonationBanner',
|
|
1052
|
+
emits: ['ended'],
|
|
1053
|
+
setup(_props, { emit }) {
|
|
1054
|
+
const { user } = (0, context_1.useAtlas)();
|
|
1055
|
+
const { isImpersonating, canReturnToAdmin, endImpersonation } = (0, composables_1.useImpersonation)();
|
|
1056
|
+
const t = useText();
|
|
1057
|
+
const cls = useClass();
|
|
1058
|
+
const busy = (0, vue_1.ref)(false);
|
|
1059
|
+
const end = async () => {
|
|
1060
|
+
busy.value = true;
|
|
1061
|
+
try {
|
|
1062
|
+
emit('ended', await endImpersonation());
|
|
1063
|
+
}
|
|
1064
|
+
finally {
|
|
1065
|
+
busy.value = false;
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
return () => {
|
|
1069
|
+
if (!isImpersonating.value)
|
|
1070
|
+
return null;
|
|
1071
|
+
const u = user.value;
|
|
1072
|
+
const name = [u?.first_name, u?.last_name].filter(Boolean).join(' ') ||
|
|
1073
|
+
u?.username ||
|
|
1074
|
+
u?.email_addresses?.find((e) => e.primary)?.email_address ||
|
|
1075
|
+
u?.id ||
|
|
1076
|
+
'';
|
|
1077
|
+
return (0, vue_1.h)('div', { class: cls('card', 'atlas-alert atlas-impersonation-banner'), role: 'status' }, [
|
|
1078
|
+
(0, vue_1.h)('span', { class: cls('headerSubtitle', 'atlas-text') }, t('impersonation.banner', { name })),
|
|
1079
|
+
' ',
|
|
1080
|
+
(0, vue_1.h)('button', { type: 'button', class: cls('formButtonPrimary', 'atlas-button'), disabled: busy.value, onClick: () => void end() }, canReturnToAdmin.value ? t('impersonation.returnToAdmin') : t('impersonation.end')),
|
|
1081
|
+
]);
|
|
1082
|
+
};
|
|
1083
|
+
},
|
|
1084
|
+
});
|
|
687
1085
|
exports.OrganizationSwitcher = (0, vue_1.defineComponent)({
|
|
688
1086
|
name: 'OrganizationSwitcher',
|
|
689
1087
|
setup() {
|
|
@@ -798,20 +1196,341 @@ exports.UserProfile = (0, vue_1.defineComponent)({
|
|
|
798
1196
|
});
|
|
799
1197
|
exports.OrganizationProfile = (0, vue_1.defineComponent)({
|
|
800
1198
|
name: 'OrganizationProfile',
|
|
801
|
-
|
|
1199
|
+
emits: ['exit'],
|
|
1200
|
+
setup(_props, { emit }) {
|
|
1201
|
+
const atlas = (0, context_1.useAtlas)();
|
|
802
1202
|
const { organization, membership } = (0, composables_1.useOrganization)();
|
|
1203
|
+
const { userId, has } = (0, composables_1.useAuth)();
|
|
803
1204
|
const t = useText();
|
|
804
1205
|
const cls = useClass();
|
|
1206
|
+
const client = new fapi_1.FapiClient({
|
|
1207
|
+
publishableKey: atlas.publishableKey,
|
|
1208
|
+
baseUrl: atlas.frontendApi,
|
|
1209
|
+
locale: atlas.locale,
|
|
1210
|
+
sdk: version_1.ATLAS_VUE_SDK,
|
|
1211
|
+
});
|
|
1212
|
+
const errorText = (err) => (0, js_1.localizedErrorMessage)(err, atlas.errorCatalog ?? atlas.catalog, atlas.catalog);
|
|
1213
|
+
const state = (0, vue_1.shallowRef)(null);
|
|
1214
|
+
const tab = (0, vue_1.ref)('members');
|
|
1215
|
+
// The custom-role editor: null = creating, else the id of the role being edited.
|
|
1216
|
+
const editingRole = (0, vue_1.ref)(null);
|
|
1217
|
+
const roleKey = (0, vue_1.ref)('');
|
|
1218
|
+
const roleName = (0, vue_1.ref)('');
|
|
1219
|
+
const rolePerms = (0, vue_1.ref)([]);
|
|
1220
|
+
const resetRoleForm = () => {
|
|
1221
|
+
editingRole.value = null;
|
|
1222
|
+
roleKey.value = '';
|
|
1223
|
+
roleName.value = '';
|
|
1224
|
+
rolePerms.value = [];
|
|
1225
|
+
};
|
|
1226
|
+
const email = (0, vue_1.ref)('');
|
|
1227
|
+
const inviteRole = (0, vue_1.ref)('org:member');
|
|
1228
|
+
const confirm = (0, vue_1.ref)(null);
|
|
1229
|
+
const needsPassword = (0, vue_1.ref)(false);
|
|
1230
|
+
const password = (0, vue_1.ref)('');
|
|
1231
|
+
let controller = null;
|
|
1232
|
+
let unsubscribe = null;
|
|
1233
|
+
(0, vue_1.watch)(() => organization.value?.id ?? null, (id) => {
|
|
1234
|
+
unsubscribe?.();
|
|
1235
|
+
controller = null;
|
|
1236
|
+
state.value = null;
|
|
1237
|
+
if (!id)
|
|
1238
|
+
return;
|
|
1239
|
+
controller = new js_1.OrganizationProfileController(client, id, {
|
|
1240
|
+
canManage: has({ permission: 'org:sys_memberships:manage' }),
|
|
1241
|
+
onChange: async () => {
|
|
1242
|
+
await atlas.reload();
|
|
1243
|
+
},
|
|
1244
|
+
});
|
|
1245
|
+
unsubscribe = controller.subscribe((next) => {
|
|
1246
|
+
state.value = next;
|
|
1247
|
+
});
|
|
1248
|
+
state.value = controller.getState();
|
|
1249
|
+
void controller.load();
|
|
1250
|
+
}, { immediate: true });
|
|
1251
|
+
(0, vue_1.onUnmounted)(() => unsubscribe?.());
|
|
1252
|
+
const leave = async () => {
|
|
1253
|
+
if (controller && (await controller.leave()))
|
|
1254
|
+
emit('exit', 'left');
|
|
1255
|
+
confirm.value = null;
|
|
1256
|
+
};
|
|
1257
|
+
const remove = async () => {
|
|
1258
|
+
if (!controller)
|
|
1259
|
+
return;
|
|
1260
|
+
if (needsPassword.value) {
|
|
1261
|
+
try {
|
|
1262
|
+
await (0, js_1.reauthenticate)({ strategy: 'password', password: password.value }, { api: atlas.frontendApi, publishableKey: atlas.publishableKey, locale: atlas.locale });
|
|
1263
|
+
}
|
|
1264
|
+
catch {
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
password.value = '';
|
|
1268
|
+
needsPassword.value = false;
|
|
1269
|
+
}
|
|
1270
|
+
if (await controller.deleteOrganization()) {
|
|
1271
|
+
emit('exit', 'deleted');
|
|
1272
|
+
confirm.value = null;
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
const err = controller.getState().error;
|
|
1276
|
+
if (err && err.needsReverification)
|
|
1277
|
+
needsPassword.value = true;
|
|
1278
|
+
};
|
|
805
1279
|
return () => {
|
|
806
1280
|
const org = organization.value;
|
|
807
|
-
|
|
1281
|
+
const st = state.value;
|
|
1282
|
+
if (!org || !controller || !st)
|
|
808
1283
|
return null;
|
|
809
|
-
|
|
1284
|
+
const ctl = controller;
|
|
1285
|
+
const canManage = has({ permission: 'org:sys_memberships:manage' });
|
|
1286
|
+
const isAdmin = membership.value?.role === 'org:admin';
|
|
1287
|
+
const roles = st.roles.length > 0 ? st.roles : [{ key: 'org:member', name: 'org:member' }];
|
|
1288
|
+
const roleOptions = (selected) => roles.map((r) => (0, vue_1.h)('option', { value: r.key, selected: r.key === selected }, r.name));
|
|
1289
|
+
const tabs = ['members', ...(canManage ? ['invitations', 'roles'] : []), 'settings'];
|
|
1290
|
+
const customRoles = st.roles.filter((r) => Boolean(r.organization_id));
|
|
1291
|
+
const secondary = cls('formButtonSecondary', 'atlas-button-secondary');
|
|
1292
|
+
const rolesPanel = (0, vue_1.h)('div', { 'data-atlas': 'organization-roles' }, [
|
|
1293
|
+
customRoles.length === 0
|
|
1294
|
+
? (0, vue_1.h)('p', { class: 'atlas-text' }, t('organization.noCustomRoles'))
|
|
1295
|
+
: (0, vue_1.h)('ul', { class: 'atlas-list' }, customRoles.map((role) => (0, vue_1.h)('li', { key: role.id ?? role.key, 'data-atlas': 'organization-role' }, [
|
|
1296
|
+
(0, vue_1.h)('span', `${role.name} · ${role.key}`),
|
|
1297
|
+
(0, vue_1.h)('button', {
|
|
1298
|
+
type: 'button',
|
|
1299
|
+
class: secondary,
|
|
1300
|
+
onClick: () => {
|
|
1301
|
+
editingRole.value = role.id ?? null;
|
|
1302
|
+
roleKey.value = role.key;
|
|
1303
|
+
roleName.value = role.name;
|
|
1304
|
+
rolePerms.value = [...(role.permissions ?? [])];
|
|
1305
|
+
},
|
|
1306
|
+
}, t('organization.editRole')),
|
|
1307
|
+
(0, vue_1.h)('button', {
|
|
1308
|
+
type: 'button',
|
|
1309
|
+
class: secondary,
|
|
1310
|
+
disabled: !role.id || st.busy === `role:delete:${role.id}`,
|
|
1311
|
+
onClick: () => role.id && void ctl.deleteRole(role.id),
|
|
1312
|
+
}, t('organization.deleteRole')),
|
|
1313
|
+
]))),
|
|
1314
|
+
(0, vue_1.h)('form', {
|
|
1315
|
+
'data-atlas': 'organization-role-form',
|
|
1316
|
+
onSubmit: async (e) => {
|
|
1317
|
+
e.preventDefault();
|
|
1318
|
+
const out = editingRole.value
|
|
1319
|
+
? await ctl.updateRole(editingRole.value, { name: roleName.value.trim(), permissions: rolePerms.value })
|
|
1320
|
+
: await ctl.createRole({ key: roleKey.value.trim(), name: roleName.value.trim(), permissions: rolePerms.value });
|
|
1321
|
+
if (out)
|
|
1322
|
+
resetRoleForm();
|
|
1323
|
+
},
|
|
1324
|
+
}, [
|
|
1325
|
+
editingRole.value
|
|
1326
|
+
? null
|
|
1327
|
+
: (0, vue_1.h)('label', { class: cls('formField', 'atlas-field') }, [
|
|
1328
|
+
(0, vue_1.h)('span', { class: cls('formFieldLabel', 'atlas-field-label') }, t('organization.roleKey')),
|
|
1329
|
+
(0, vue_1.h)('input', {
|
|
1330
|
+
name: 'role_key',
|
|
1331
|
+
class: cls('formFieldInput', 'atlas-input'),
|
|
1332
|
+
value: roleKey.value,
|
|
1333
|
+
onInput: (e) => (roleKey.value = e.target.value),
|
|
1334
|
+
}),
|
|
1335
|
+
]),
|
|
1336
|
+
(0, vue_1.h)('label', { class: cls('formField', 'atlas-field') }, [
|
|
1337
|
+
(0, vue_1.h)('span', { class: cls('formFieldLabel', 'atlas-field-label') }, t('organization.roleName')),
|
|
1338
|
+
(0, vue_1.h)('input', {
|
|
1339
|
+
name: 'role_name',
|
|
1340
|
+
class: cls('formFieldInput', 'atlas-input'),
|
|
1341
|
+
value: roleName.value,
|
|
1342
|
+
onInput: (e) => (roleName.value = e.target.value),
|
|
1343
|
+
}),
|
|
1344
|
+
]),
|
|
1345
|
+
(0, vue_1.h)('fieldset', { class: cls('formField', 'atlas-field') }, [
|
|
1346
|
+
(0, vue_1.h)('legend', { class: cls('formFieldLabel', 'atlas-field-label') }, t('organization.rolePermissions')),
|
|
1347
|
+
...st.permissions.map((p) => (0, vue_1.h)('label', { key: p.key, 'data-atlas': 'organization-role-permission' }, [
|
|
1348
|
+
(0, vue_1.h)('input', {
|
|
1349
|
+
type: 'checkbox',
|
|
1350
|
+
value: p.key,
|
|
1351
|
+
checked: rolePerms.value.includes(p.key),
|
|
1352
|
+
onChange: (e) => {
|
|
1353
|
+
const on = e.target.checked;
|
|
1354
|
+
rolePerms.value = on
|
|
1355
|
+
? [...rolePerms.value, p.key]
|
|
1356
|
+
: rolePerms.value.filter((k) => k !== p.key);
|
|
1357
|
+
},
|
|
1358
|
+
}),
|
|
1359
|
+
` ${p.name}`,
|
|
1360
|
+
])),
|
|
1361
|
+
]),
|
|
1362
|
+
(0, vue_1.h)('button', {
|
|
1363
|
+
type: 'submit',
|
|
1364
|
+
class: cls('formButtonPrimary', 'atlas-button'),
|
|
1365
|
+
disabled: !roleName.value.trim() || (!editingRole.value && !roleKey.value.trim()) || Boolean(st.busy?.startsWith('role:')),
|
|
1366
|
+
}, editingRole.value ? t('organization.saveRole') : t('organization.createRole')),
|
|
1367
|
+
editingRole.value
|
|
1368
|
+
? (0, vue_1.h)('button', { type: 'button', class: secondary, onClick: resetRoleForm }, t('organization.cancel'))
|
|
1369
|
+
: null,
|
|
1370
|
+
]),
|
|
1371
|
+
]);
|
|
1372
|
+
const members = (0, vue_1.h)('ul', { class: 'atlas-list', 'data-atlas': 'organization-members' }, st.members.map((m) => {
|
|
1373
|
+
const id = m.user?.id ?? m.id;
|
|
1374
|
+
const self = m.user?.id === userId.value;
|
|
1375
|
+
const name = [m.user?.first_name, m.user?.last_name].filter(Boolean).join(' ') || t('organization.unnamedMember');
|
|
1376
|
+
return (0, vue_1.h)('li', { key: m.id, 'data-atlas': 'organization-member' }, [
|
|
1377
|
+
(0, vue_1.h)('span', self ? `${name} ${t('organization.you')}` : name),
|
|
1378
|
+
canManage && !self && m.user
|
|
1379
|
+
? [
|
|
1380
|
+
(0, vue_1.h)('select', {
|
|
1381
|
+
'aria-label': t('organization.roleLabel'),
|
|
1382
|
+
class: cls('formFieldInput', 'atlas-input'),
|
|
1383
|
+
disabled: st.busy === `role:${id}`,
|
|
1384
|
+
onChange: (e) => void ctl.changeRole(id, e.target.value),
|
|
1385
|
+
}, roleOptions(m.role)),
|
|
1386
|
+
(0, vue_1.h)('button', {
|
|
1387
|
+
type: 'button',
|
|
1388
|
+
class: cls('formButtonSecondary', 'atlas-button-secondary'),
|
|
1389
|
+
disabled: st.busy === `remove:${id}`,
|
|
1390
|
+
onClick: () => void ctl.removeMember(id),
|
|
1391
|
+
}, t('organization.remove')),
|
|
1392
|
+
]
|
|
1393
|
+
: (0, vue_1.h)('span', ` · ${m.role}`),
|
|
1394
|
+
]);
|
|
1395
|
+
}));
|
|
1396
|
+
const invitations = (0, vue_1.h)('div', { 'data-atlas': 'organization-invitations' }, [
|
|
1397
|
+
(0, vue_1.h)('form', {
|
|
1398
|
+
onSubmit: async (e) => {
|
|
1399
|
+
e.preventDefault();
|
|
1400
|
+
const address = email.value.trim();
|
|
1401
|
+
if (address && (await ctl.invite(address, inviteRole.value)))
|
|
1402
|
+
email.value = '';
|
|
1403
|
+
},
|
|
1404
|
+
}, [
|
|
1405
|
+
(0, vue_1.h)('label', { class: cls('formField', 'atlas-field') }, [
|
|
1406
|
+
(0, vue_1.h)('span', { class: cls('formFieldLabel', 'atlas-field-label') }, t('organization.emailLabel')),
|
|
1407
|
+
(0, vue_1.h)('input', {
|
|
1408
|
+
type: 'email',
|
|
1409
|
+
class: cls('formFieldInput', 'atlas-input'),
|
|
1410
|
+
value: email.value,
|
|
1411
|
+
onInput: (e) => (email.value = e.target.value),
|
|
1412
|
+
}),
|
|
1413
|
+
]),
|
|
1414
|
+
(0, vue_1.h)('label', { class: cls('formField', 'atlas-field') }, [
|
|
1415
|
+
(0, vue_1.h)('span', { class: cls('formFieldLabel', 'atlas-field-label') }, t('organization.roleLabel')),
|
|
1416
|
+
(0, vue_1.h)('select', {
|
|
1417
|
+
class: cls('formFieldInput', 'atlas-input'),
|
|
1418
|
+
onChange: (e) => (inviteRole.value = e.target.value),
|
|
1419
|
+
}, roleOptions(inviteRole.value)),
|
|
1420
|
+
]),
|
|
1421
|
+
(0, vue_1.h)('button', { type: 'submit', class: cls('formButtonPrimary', 'atlas-button'), disabled: st.busy === 'invite' || !email.value.trim() }, t('organization.sendInvite')),
|
|
1422
|
+
]),
|
|
1423
|
+
st.invitations.length === 0
|
|
1424
|
+
? (0, vue_1.h)('p', { class: 'atlas-text' }, t('organization.noInvitations'))
|
|
1425
|
+
: (0, vue_1.h)('ul', { class: 'atlas-list' }, st.invitations.map((inv) => (0, vue_1.h)('li', { key: inv.id, 'data-atlas': 'organization-invitation' }, [
|
|
1426
|
+
(0, vue_1.h)('span', `${inv.email_address} · ${inv.role ?? ''}`),
|
|
1427
|
+
(0, vue_1.h)('button', { type: 'button', class: cls('formButtonSecondary', 'atlas-button-secondary'), onClick: () => void ctl.resendInvitation(inv.id) }, t('organization.resend')),
|
|
1428
|
+
(0, vue_1.h)('button', { type: 'button', class: cls('formButtonSecondary', 'atlas-button-secondary'), onClick: () => void ctl.revokeInvitation(inv.id) }, t('organization.revoke')),
|
|
1429
|
+
]))),
|
|
1430
|
+
]);
|
|
1431
|
+
const settings = (0, vue_1.h)('div', { 'data-atlas': 'organization-settings' }, confirm.value
|
|
1432
|
+
? [
|
|
1433
|
+
(0, vue_1.h)('p', { class: 'atlas-text' }, (0, i18n_1.translate)(atlas.catalog, confirm.value === 'leave' ? 'organization.leaveConfirm' : 'organization.deleteConfirm', { name: org.name })),
|
|
1434
|
+
needsPassword.value
|
|
1435
|
+
? (0, vue_1.h)('label', { class: cls('formField', 'atlas-field') }, [
|
|
1436
|
+
(0, vue_1.h)('span', { class: cls('formFieldLabel', 'atlas-field-label') }, t('organization.passwordPrompt')),
|
|
1437
|
+
(0, vue_1.h)('input', {
|
|
1438
|
+
type: 'password',
|
|
1439
|
+
autocomplete: 'current-password',
|
|
1440
|
+
class: cls('formFieldInput', 'atlas-input'),
|
|
1441
|
+
value: password.value,
|
|
1442
|
+
onInput: (e) => (password.value = e.target.value),
|
|
1443
|
+
}),
|
|
1444
|
+
])
|
|
1445
|
+
: null,
|
|
1446
|
+
(0, vue_1.h)('button', {
|
|
1447
|
+
type: 'button',
|
|
1448
|
+
class: cls('formButtonPrimary', 'atlas-button'),
|
|
1449
|
+
disabled: st.busy === 'leave' || st.busy === 'delete',
|
|
1450
|
+
onClick: () => void (confirm.value === 'leave' ? leave() : remove()),
|
|
1451
|
+
}, t('organization.confirm')),
|
|
1452
|
+
(0, vue_1.h)('button', {
|
|
1453
|
+
type: 'button',
|
|
1454
|
+
class: cls('formButtonSecondary', 'atlas-button-secondary'),
|
|
1455
|
+
onClick: () => {
|
|
1456
|
+
confirm.value = null;
|
|
1457
|
+
needsPassword.value = false;
|
|
1458
|
+
password.value = '';
|
|
1459
|
+
},
|
|
1460
|
+
}, t('organization.cancel')),
|
|
1461
|
+
]
|
|
1462
|
+
: [
|
|
1463
|
+
(0, vue_1.h)('button', { type: 'button', class: cls('formButtonSecondary', 'atlas-button-secondary'), onClick: () => (confirm.value = 'leave') }, t('organization.leave')),
|
|
1464
|
+
isAdmin
|
|
1465
|
+
? (0, vue_1.h)('button', { type: 'button', class: cls('formButtonSecondary', 'atlas-button-secondary'), onClick: () => (confirm.value = 'delete') }, t('organization.delete'))
|
|
1466
|
+
: null,
|
|
1467
|
+
]);
|
|
1468
|
+
return (0, vue_1.h)('div', { class: cls('card', 'atlas-card'), 'data-atlas': 'organization-profile' }, [
|
|
810
1469
|
(0, vue_1.h)('h1', org.name),
|
|
811
1470
|
(0, vue_1.h)('p', `${t('organization.roleLabel')}: ${membership.value?.role ?? ''}`),
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1471
|
+
(0, vue_1.h)('div', { class: cls('menu', 'atlas-menu'), role: 'tablist' }, tabs.map((key) => (0, vue_1.h)('button', {
|
|
1472
|
+
key,
|
|
1473
|
+
type: 'button',
|
|
1474
|
+
role: 'tab',
|
|
1475
|
+
'aria-selected': tab.value === key,
|
|
1476
|
+
class: cls('menuItem', 'atlas-menu-item'),
|
|
1477
|
+
'data-atlas': `organization-tab-${key}`,
|
|
1478
|
+
onClick: () => (tab.value = key),
|
|
1479
|
+
}, t(`organization.${key}`)))),
|
|
1480
|
+
st.error && !st.error.needsReverification
|
|
1481
|
+
? (0, vue_1.h)('p', { class: cls('formFieldError', 'atlas-error'), role: 'alert' }, errorText(st.error))
|
|
1482
|
+
: null,
|
|
1483
|
+
tab.value === 'members' ? members : null,
|
|
1484
|
+
tab.value === 'invitations' && canManage ? invitations : null,
|
|
1485
|
+
tab.value === 'roles' && canManage ? rolesPanel : null,
|
|
1486
|
+
tab.value === 'settings' ? settings : null,
|
|
1487
|
+
]);
|
|
1488
|
+
};
|
|
1489
|
+
},
|
|
1490
|
+
});
|
|
1491
|
+
/**
|
|
1492
|
+
* `<OrganizationAuditLog/>` — the active organization's audit trail for its
|
|
1493
|
+
* admins, next to `<OrganizationProfile/>`. Renders nothing for a member who may
|
|
1494
|
+
* not read it (the server is the authority; the component just follows it).
|
|
1495
|
+
* Labels are props so an app can localise them.
|
|
1496
|
+
*/
|
|
1497
|
+
exports.OrganizationAuditLog = (0, vue_1.defineComponent)({
|
|
1498
|
+
name: 'OrganizationAuditLog',
|
|
1499
|
+
props: {
|
|
1500
|
+
pageSize: { type: Number, default: undefined },
|
|
1501
|
+
action: { type: String, default: undefined },
|
|
1502
|
+
labels: { type: Object, default: () => ({}) },
|
|
1503
|
+
formatDate: {
|
|
1504
|
+
type: Function,
|
|
1505
|
+
default: (ms) => new Date(ms).toLocaleString(),
|
|
1506
|
+
},
|
|
1507
|
+
},
|
|
1508
|
+
setup(props) {
|
|
1509
|
+
const { organization } = (0, composables_1.useOrganization)();
|
|
1510
|
+
const log = (0, composables_1.useOrganizationAuditLog)({ pageSize: props.pageSize, action: props.action });
|
|
1511
|
+
const cls = useClass();
|
|
1512
|
+
return () => {
|
|
1513
|
+
const state = log.state.value;
|
|
1514
|
+
if (!organization.value || state.forbidden)
|
|
1515
|
+
return null;
|
|
1516
|
+
const labels = props.labels;
|
|
1517
|
+
return (0, vue_1.h)('div', { class: cls('card', 'atlas-card'), 'data-atlas': 'organization-audit-log' }, [
|
|
1518
|
+
(0, vue_1.h)('h2', { class: cls('headerTitle', 'atlas-title') }, labels.title ?? 'Audit log'),
|
|
1519
|
+
state.entries.length === 0 && !state.loading
|
|
1520
|
+
? (0, vue_1.h)('p', { class: cls('headerSubtitle', 'atlas-subtitle') }, labels.empty ?? 'No activity recorded yet.')
|
|
1521
|
+
: (0, vue_1.h)('ul', { class: cls('list', 'atlas-audit-list') }, state.entries.map((entry) => (0, vue_1.h)('li', { key: entry.id, class: cls('listItem', 'atlas-audit-item'), 'data-action': entry.action }, [
|
|
1522
|
+
(0, vue_1.h)('strong', (0, js_1.describeAuditAction)(entry.action)),
|
|
1523
|
+
(0, vue_1.h)('span', { class: 'atlas-audit-meta' }, ` · ${props.formatDate(entry.created_at)}` +
|
|
1524
|
+
(entry.actor.id ? ` · ${labels.actor ?? 'by'} ${entry.actor.type} ${entry.actor.id}` : '')),
|
|
1525
|
+
]))),
|
|
1526
|
+
state.hasMore
|
|
1527
|
+
? (0, vue_1.h)('button', {
|
|
1528
|
+
type: 'button',
|
|
1529
|
+
class: cls('formButtonSecondary', 'atlas-button-secondary'),
|
|
1530
|
+
disabled: state.loading,
|
|
1531
|
+
onClick: () => void log.loadMore(),
|
|
1532
|
+
}, labels.loadMore ?? 'Load more')
|
|
1533
|
+
: null,
|
|
815
1534
|
]);
|
|
816
1535
|
};
|
|
817
1536
|
},
|