@atlasauth/vue 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/appearance.d.ts +1 -1
- package/dist/appearance.js.map +1 -1
- package/dist/components.d.ts +70 -3
- package/dist/components.js +713 -38
- 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 +52 -1
- package/dist/flow.js +76 -1
- package/dist/flow.js.map +1 -1
- package/dist/i18n.js +56 -0
- package/dist/i18n.js.map +1 -1
- package/dist/plugin.d.ts +8 -1
- package/dist/plugin.js +14 -0
- package/dist/plugin.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +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");
|
|
@@ -141,6 +141,17 @@ function slotRef(controller) {
|
|
|
141
141
|
* Every branch comes from `nextStep`, which is exhaustive — including the
|
|
142
142
|
* `unknown` case, which renders an honest message instead of nothing.
|
|
143
143
|
*/
|
|
144
|
+
/** The recovery codes in two columns, monospaced. */
|
|
145
|
+
const RECOVERY_LIST_STYLE = {
|
|
146
|
+
listStyle: 'none',
|
|
147
|
+
padding: '0',
|
|
148
|
+
margin: '8px 0 12px',
|
|
149
|
+
display: 'grid',
|
|
150
|
+
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
|
|
151
|
+
gap: '6px',
|
|
152
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
153
|
+
textAlign: 'center',
|
|
154
|
+
};
|
|
144
155
|
const AttemptFlow = (0, vue_1.defineComponent)({
|
|
145
156
|
name: 'AttemptFlow',
|
|
146
157
|
props: {
|
|
@@ -171,6 +182,43 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
171
182
|
type: Function,
|
|
172
183
|
default: undefined,
|
|
173
184
|
},
|
|
185
|
+
/**
|
|
186
|
+
* "Email me a code instead" at the second factor (the instance's
|
|
187
|
+
* `account.emailSecondFactor`). Without it the step asks for the code only.
|
|
188
|
+
*/
|
|
189
|
+
onEmailSecondFactor: {
|
|
190
|
+
type: Function,
|
|
191
|
+
default: undefined,
|
|
192
|
+
},
|
|
193
|
+
/**
|
|
194
|
+
* "Text me a code instead" at the second factor (where the app offers
|
|
195
|
+
* texted codes). Without it the step offers no texted code.
|
|
196
|
+
*/
|
|
197
|
+
onSmsSecondFactor: {
|
|
198
|
+
type: Function,
|
|
199
|
+
default: undefined,
|
|
200
|
+
},
|
|
201
|
+
/**
|
|
202
|
+
* The MFA enrolment step (`useFlow().enrollment` and its state): the
|
|
203
|
+
* authenticator app's QR code and setup key, or a texted code. Without it
|
|
204
|
+
* the step asks for two authenticator codes only.
|
|
205
|
+
*/
|
|
206
|
+
enrollment: { type: Object, default: null },
|
|
207
|
+
enrollmentState: { type: Object, default: null },
|
|
208
|
+
/**
|
|
209
|
+
* The instance's `account.emailVerification`. On `link` a sign-up's
|
|
210
|
+
* verification step has nothing to type: it says to open the emailed link
|
|
211
|
+
* while the flow polls, and offers a resend ({@link onResend}).
|
|
212
|
+
*/
|
|
213
|
+
emailVerification: { type: String, default: 'code' },
|
|
214
|
+
onResend: { type: Function, default: undefined },
|
|
215
|
+
/**
|
|
216
|
+
* The recovery codes an MFA enrolment just completed the sign-in with
|
|
217
|
+
* (`pendingRecoveryCodes(state)`): drawn once instead of the form, and
|
|
218
|
+
* "I've saved my codes" calls {@link onRecoveryCodesSaved} to finish.
|
|
219
|
+
*/
|
|
220
|
+
recoveryCodes: { type: Array, default: null },
|
|
221
|
+
onRecoveryCodesSaved: { type: Function, default: undefined },
|
|
174
222
|
},
|
|
175
223
|
setup(props) {
|
|
176
224
|
const t = useText();
|
|
@@ -180,8 +228,14 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
180
228
|
// The first factor the user picked: the password field by default, or the
|
|
181
229
|
// code field / "check your email" once an emailed code / link was sent.
|
|
182
230
|
const factor = (0, vue_1.ref)('password');
|
|
231
|
+
// "Email me a code instead" at the second factor: where it went, or why not.
|
|
232
|
+
const emailed = (0, vue_1.ref)({});
|
|
233
|
+
// "Text me a code instead": the same, for the texted code.
|
|
234
|
+
const texted = (0, vue_1.ref)({});
|
|
183
235
|
(0, vue_1.watch)(() => props.attempt?.id, () => {
|
|
184
236
|
factor.value = 'password';
|
|
237
|
+
emailed.value = {};
|
|
238
|
+
texted.value = {};
|
|
185
239
|
});
|
|
186
240
|
// The first factors on offer for the current step, and the plan for them:
|
|
187
241
|
// a password field only when the server offers `password` (planFirstFactor).
|
|
@@ -218,12 +272,13 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
218
272
|
void send(strategy);
|
|
219
273
|
},
|
|
220
274
|
}, t(labelKey));
|
|
221
|
-
const field = (name, labelKey, type = 'text') => (0, vue_1.h)('label', { class: 'atlas-field', key: name }, [
|
|
275
|
+
const field = (name, labelKey, type = 'text', autocomplete) => (0, vue_1.h)('label', { class: 'atlas-field', key: name }, [
|
|
222
276
|
(0, vue_1.h)('span', t(labelKey)),
|
|
223
277
|
(0, vue_1.h)('input', {
|
|
224
278
|
class: cls('formFieldInput', 'atlas-input'),
|
|
225
279
|
type,
|
|
226
280
|
name,
|
|
281
|
+
...(autocomplete ? { autocomplete } : {}),
|
|
227
282
|
value: values[name] ?? '',
|
|
228
283
|
onInput: (event) => {
|
|
229
284
|
values[name] = event.target.value;
|
|
@@ -233,6 +288,69 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
233
288
|
.filter((error) => error.param === name)
|
|
234
289
|
.map((error) => (0, vue_1.h)('span', { class: cls('formFieldError', 'atlas-error'), key: error.code }, errorText(error))),
|
|
235
290
|
]);
|
|
291
|
+
/**
|
|
292
|
+
* The enrolment step: the method choice, the authenticator's QR code and
|
|
293
|
+
* setup key with its code field(s), or the phone number then the texted code.
|
|
294
|
+
*/
|
|
295
|
+
const enrollmentFields = (enrol, s) => {
|
|
296
|
+
const out = [];
|
|
297
|
+
const methodButton = (m, key) => (0, vue_1.h)('button', {
|
|
298
|
+
key,
|
|
299
|
+
class: cls('formButtonSecondary', 'atlas-button-secondary'),
|
|
300
|
+
type: 'button',
|
|
301
|
+
'data-atlas-mfa-method': m,
|
|
302
|
+
disabled: props.busy || s.busy,
|
|
303
|
+
onClick: () => {
|
|
304
|
+
for (const k of Object.keys(values))
|
|
305
|
+
delete values[k];
|
|
306
|
+
void enrol.choose(m);
|
|
307
|
+
},
|
|
308
|
+
}, t(m === 'totp' ? 'mfa.enrollTotpOption' : 'mfa.enrollSmsOption'));
|
|
309
|
+
if (s.method === null) {
|
|
310
|
+
out.push((0, vue_1.h)('p', { key: 'choose', class: 'atlas-text' }, t('mfa.enrollChoose')));
|
|
311
|
+
for (const m of s.options)
|
|
312
|
+
out.push(methodButton(m, `method-${m}`));
|
|
313
|
+
}
|
|
314
|
+
else if (s.method === 'totp') {
|
|
315
|
+
const e = s.enrollment;
|
|
316
|
+
if (!e) {
|
|
317
|
+
out.push((0, vue_1.h)('p', { key: 'preparing', class: 'atlas-text', 'aria-live': 'polite' }, s.busy ? t('mfa.enrollPreparing') : ''));
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
out.push((0, vue_1.h)('p', { key: 'scan', class: 'atlas-text' }, t('mfa.enrollScan')));
|
|
321
|
+
if (e.qrCode) {
|
|
322
|
+
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 }));
|
|
323
|
+
}
|
|
324
|
+
if (e.secret) {
|
|
325
|
+
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))]));
|
|
326
|
+
}
|
|
327
|
+
out.push(field('code', 'mfa.enrollFirstCodeLabel', 'text', 'one-time-code'));
|
|
328
|
+
if (s.totpCodes >= 2)
|
|
329
|
+
out.push(field('secondCode', 'mfa.enrollSecondCodeLabel', 'text', 'one-time-code'));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
else if (!s.enrollment) {
|
|
333
|
+
out.push(field('phone', 'mfa.phoneLabel', 'tel', 'tel'), (0, vue_1.h)('p', { key: 'phone-hint', class: 'atlas-text' }, t('mfa.phoneHint')));
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
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', {
|
|
337
|
+
key: 'resend',
|
|
338
|
+
class: cls('formButtonSecondary', 'atlas-button-secondary'),
|
|
339
|
+
type: 'button',
|
|
340
|
+
'data-atlas-resend': '',
|
|
341
|
+
disabled: props.busy || s.busy,
|
|
342
|
+
onClick: () => void enrol.resend(),
|
|
343
|
+
}, t('signIn.resendCode')));
|
|
344
|
+
}
|
|
345
|
+
for (const error of s.errors) {
|
|
346
|
+
out.push((0, vue_1.h)('p', { key: `enrol-error-${error.code}-${error.param ?? ''}`, class: cls('formFieldError', 'atlas-error'), role: 'alert' }, error.message));
|
|
347
|
+
}
|
|
348
|
+
if (s.method !== null && s.options.length > 1) {
|
|
349
|
+
const other = s.options.find((m) => m !== s.method);
|
|
350
|
+
out.push(methodButton(other, 'switch'));
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
};
|
|
236
354
|
const fields = (step) => {
|
|
237
355
|
switch (step.kind) {
|
|
238
356
|
case 'collect_identifier':
|
|
@@ -287,9 +405,59 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
287
405
|
offered.includes('email_link') ? emailChoice('email_link', 'signIn.useEmailLink') : null,
|
|
288
406
|
].filter((node) => node !== null);
|
|
289
407
|
}
|
|
290
|
-
case 'collect_second_factor':
|
|
291
|
-
|
|
408
|
+
case 'collect_second_factor': {
|
|
409
|
+
const out = [field('code', 'mfa.codeLabel')];
|
|
410
|
+
if (props.onSmsSecondFactor) {
|
|
411
|
+
const state = texted.value;
|
|
412
|
+
if (state.sentTo !== undefined) {
|
|
413
|
+
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 })));
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
out.push((0, vue_1.h)('button', {
|
|
417
|
+
key: 'sms-2fa',
|
|
418
|
+
class: cls('formButtonSecondary', 'atlas-button-secondary'),
|
|
419
|
+
type: 'button',
|
|
420
|
+
'data-atlas': 'sms-second-factor',
|
|
421
|
+
disabled: props.busy || state.busy,
|
|
422
|
+
onClick: () => {
|
|
423
|
+
texted.value = { busy: true };
|
|
424
|
+
void props.onSmsSecondFactor().then((r) => {
|
|
425
|
+
texted.value = r.error ? { error: r.error } : { sentTo: r.sentTo ?? '' };
|
|
426
|
+
});
|
|
427
|
+
},
|
|
428
|
+
}, state.busy ? t('signIn.sending') : t('mfa.smsCodeOption')));
|
|
429
|
+
if (state.error)
|
|
430
|
+
out.push((0, vue_1.h)('p', { key: 'sms-2fa-error', class: cls('formFieldError', 'atlas-error'), role: 'alert' }, state.error));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (props.onEmailSecondFactor) {
|
|
434
|
+
const state = emailed.value;
|
|
435
|
+
if (state.sentTo !== undefined) {
|
|
436
|
+
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 })));
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
out.push((0, vue_1.h)('button', {
|
|
440
|
+
key: 'email-2fa',
|
|
441
|
+
class: cls('formButtonSecondary', 'atlas-button-secondary'),
|
|
442
|
+
type: 'button',
|
|
443
|
+
'data-atlas': 'email-second-factor',
|
|
444
|
+
disabled: props.busy || state.busy,
|
|
445
|
+
onClick: () => {
|
|
446
|
+
emailed.value = { busy: true };
|
|
447
|
+
void props.onEmailSecondFactor().then((r) => {
|
|
448
|
+
emailed.value = r.error ? { error: r.error } : { sentTo: r.sentTo ?? '' };
|
|
449
|
+
});
|
|
450
|
+
},
|
|
451
|
+
}, state.busy ? t('signIn.sending') : t('signIn.emailCodeOption')));
|
|
452
|
+
if (state.error)
|
|
453
|
+
out.push((0, vue_1.h)('p', { key: 'email-2fa-error', class: cls('formFieldError', 'atlas-error'), role: 'alert' }, state.error));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
292
458
|
case 'enroll_second_factor':
|
|
459
|
+
if (props.enrollment && props.enrollmentState)
|
|
460
|
+
return enrollmentFields(props.enrollment, props.enrollmentState);
|
|
293
461
|
// Two codes, not one. §5.6 wants consecutive codes so a phone with a
|
|
294
462
|
// fast clock fails here rather than on every sign-in afterwards.
|
|
295
463
|
return [
|
|
@@ -297,6 +465,22 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
297
465
|
field('secondCode', 'mfa.enrollSecondCodeLabel'),
|
|
298
466
|
];
|
|
299
467
|
case 'collect_email_code':
|
|
468
|
+
if ((0, js_1.awaitingSignUpLink)(props.attempt, props.emailVerification)) {
|
|
469
|
+
// Nothing to type: the flow polls until the emailed link is opened (anywhere).
|
|
470
|
+
return [
|
|
471
|
+
(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 ?? '' })),
|
|
472
|
+
props.onResend
|
|
473
|
+
? (0, vue_1.h)('button', {
|
|
474
|
+
key: 'resend',
|
|
475
|
+
class: cls('formButtonSecondary', 'atlas-button-secondary'),
|
|
476
|
+
type: 'button',
|
|
477
|
+
'data-atlas-resend': '',
|
|
478
|
+
disabled: props.busy,
|
|
479
|
+
onClick: () => void props.onResend(),
|
|
480
|
+
}, t('signIn.resendLink'))
|
|
481
|
+
: null,
|
|
482
|
+
].filter((node) => node !== null);
|
|
483
|
+
}
|
|
300
484
|
return [field('code', 'signIn.emailCodeLabel')];
|
|
301
485
|
case 'collect_new_password':
|
|
302
486
|
return [field('password', 'reset.newPasswordLabel', 'password')];
|
|
@@ -325,7 +509,53 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
325
509
|
];
|
|
326
510
|
}
|
|
327
511
|
};
|
|
512
|
+
// The recovery codes: copied once ("Copied."), reset for a new set.
|
|
513
|
+
const codesCopied = (0, vue_1.ref)(false);
|
|
514
|
+
(0, vue_1.watch)(() => props.recoveryCodes, () => {
|
|
515
|
+
codesCopied.value = false;
|
|
516
|
+
});
|
|
517
|
+
const recoveryCodesCard = (codes, onSaved) => {
|
|
518
|
+
const canCopy = typeof navigator !== 'undefined' && !!navigator.clipboard?.writeText;
|
|
519
|
+
const secondary = cls('formButtonSecondary', 'atlas-button-secondary');
|
|
520
|
+
return (0, vue_1.h)('div', { class: cls('card', 'atlas-card'), 'data-atlas': 'recovery-codes' }, [
|
|
521
|
+
(0, vue_1.h)('h1', { class: cls('headerTitle', 'atlas-title') }, t('mfa.recoveryCodesTitle')),
|
|
522
|
+
(0, vue_1.h)('p', { class: 'atlas-text' }, t('mfa.recoveryCodesHelp')),
|
|
523
|
+
(0, vue_1.h)('ul', {
|
|
524
|
+
class: 'atlas-recovery-codes',
|
|
525
|
+
'data-atlas': 'recovery-codes-list',
|
|
526
|
+
style: RECOVERY_LIST_STYLE,
|
|
527
|
+
}, codes.map((code) => (0, vue_1.h)('li', { key: code }, [(0, vue_1.h)('code', null, code)]))),
|
|
528
|
+
(0, vue_1.h)('div', { class: 'atlas-alternatives' }, [
|
|
529
|
+
canCopy
|
|
530
|
+
? (0, vue_1.h)('button', {
|
|
531
|
+
class: secondary,
|
|
532
|
+
type: 'button',
|
|
533
|
+
'data-atlas': 'recovery-codes-copy',
|
|
534
|
+
onClick: () => void (0, js_1.copyRecoveryCodes)(codes).then((ok) => {
|
|
535
|
+
codesCopied.value = ok;
|
|
536
|
+
}),
|
|
537
|
+
}, t('mfa.recoveryCodesCopy'))
|
|
538
|
+
: null,
|
|
539
|
+
(0, js_1.canDownloadRecoveryCodes)()
|
|
540
|
+
? (0, vue_1.h)('button', { class: secondary, type: 'button', 'data-atlas': 'recovery-codes-download', onClick: () => (0, js_1.downloadRecoveryCodes)(codes) }, t('mfa.recoveryCodesDownload'))
|
|
541
|
+
: null,
|
|
542
|
+
]),
|
|
543
|
+
codesCopied.value
|
|
544
|
+
? (0, vue_1.h)('p', { class: 'atlas-text', 'aria-live': 'polite', 'data-atlas': 'recovery-codes-copied' }, t('mfa.recoveryCodesCopied'))
|
|
545
|
+
: null,
|
|
546
|
+
(0, vue_1.h)('button', {
|
|
547
|
+
class: cls('formButtonPrimary', 'atlas-button'),
|
|
548
|
+
type: 'button',
|
|
549
|
+
'data-atlas': 'recovery-codes-saved',
|
|
550
|
+
disabled: props.busy,
|
|
551
|
+
onClick: onSaved,
|
|
552
|
+
}, t('mfa.recoveryCodesSaved')),
|
|
553
|
+
]);
|
|
554
|
+
};
|
|
328
555
|
return () => {
|
|
556
|
+
if (props.recoveryCodes?.length && props.onRecoveryCodesSaved) {
|
|
557
|
+
return recoveryCodesCard(props.recoveryCodes, props.onRecoveryCodesSaved);
|
|
558
|
+
}
|
|
329
559
|
const step = props.attempt
|
|
330
560
|
? (0, js_1.nextStep)(props.attempt)
|
|
331
561
|
: { kind: 'collect_identifier' };
|
|
@@ -344,6 +574,14 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
344
574
|
props.onSubmit(factor.value === 'email_code' ? { code: values.code ?? '' } : { password: values.password ?? '' });
|
|
345
575
|
return;
|
|
346
576
|
}
|
|
577
|
+
// Enrolment: text the code to the typed number, or confirm the factor.
|
|
578
|
+
if (current?.kind === 'enroll_second_factor' && props.enrollment) {
|
|
579
|
+
void props.enrollment.submit({ ...values }).then((next) => {
|
|
580
|
+
if (next)
|
|
581
|
+
props.onSubmit(next);
|
|
582
|
+
});
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
347
585
|
props.onSubmit({ ...values });
|
|
348
586
|
},
|
|
349
587
|
}, [
|
|
@@ -353,13 +591,17 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
353
591
|
: null,
|
|
354
592
|
step.kind !== 'done' &&
|
|
355
593
|
step.kind !== 'await_oauth' &&
|
|
594
|
+
!(0, js_1.awaitingSignUpLink)(props.attempt, props.emailVerification) &&
|
|
356
595
|
!(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)
|
|
596
|
+
!(step.kind === 'collect_first_factor' && factor.value === 'password' && firstFactorPlan()?.password === false && (firstFactorPlan()?.email.length ?? 0) > 0) &&
|
|
597
|
+
!(step.kind === 'enroll_second_factor' && props.enrollmentState && !(0, js_1.enrollmentSubmitKey)(props.enrollmentState))
|
|
358
598
|
? (0, vue_1.h)('button', {
|
|
359
599
|
class: cls('formButtonPrimary', 'atlas-button'),
|
|
360
|
-
disabled: props.busy,
|
|
600
|
+
disabled: props.busy || (step.kind === 'enroll_second_factor' && !!props.enrollmentState?.busy),
|
|
361
601
|
type: 'submit',
|
|
362
|
-
}, t('
|
|
602
|
+
}, t(step.kind === 'enroll_second_factor' && props.enrollmentState
|
|
603
|
+
? ((0, js_1.enrollmentSubmitKey)(props.enrollmentState) ?? 'signIn.submit')
|
|
604
|
+
: 'signIn.submit'))
|
|
363
605
|
: null,
|
|
364
606
|
]),
|
|
365
607
|
]);
|
|
@@ -379,7 +621,7 @@ exports.SignIn = (0, vue_1.defineComponent)({
|
|
|
379
621
|
signUpUrl: { type: null, default: undefined },
|
|
380
622
|
},
|
|
381
623
|
setup(props) {
|
|
382
|
-
const { state, submit, prepare, captcha, settings } = (0, flow_1.useFlow)();
|
|
624
|
+
const { state, submit, prepare, captcha, settings, lastMethod, sendEmailSecondFactor, sendSmsSecondFactor, mfaChoices, enrollment, enrollmentState, saveRecoveryCodes } = (0, flow_1.useFlow)();
|
|
383
625
|
const atlas = (0, context_1.useAtlas)();
|
|
384
626
|
const t = useText();
|
|
385
627
|
const cls = useClass();
|
|
@@ -415,10 +657,23 @@ exports.SignIn = (0, vue_1.defineComponent)({
|
|
|
415
657
|
captcha: captcha.needed ? captcha : null,
|
|
416
658
|
emailDelivery: settings.value?.emailDelivery ?? null,
|
|
417
659
|
onPrepare: prepare,
|
|
660
|
+
onEmailSecondFactor: settings.value?.account.emailSecondFactor ? sendEmailSecondFactor : undefined,
|
|
661
|
+
onSmsSecondFactor: mfaChoices().sms ? sendSmsSecondFactor : undefined,
|
|
662
|
+
enrollment,
|
|
663
|
+
enrollmentState: enrollmentState.value,
|
|
664
|
+
recoveryCodes: (0, js_1.pendingRecoveryCodes)(state.value),
|
|
665
|
+
onRecoveryCodesSaved: saveRecoveryCodes,
|
|
418
666
|
});
|
|
419
|
-
|
|
667
|
+
const lastLabel = !state.value.attempt ? (0, js_1.lastUsedMethodLabel)(lastMethod.value, t) : null;
|
|
668
|
+
const hint = lastLabel
|
|
669
|
+
? (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}`)
|
|
670
|
+
: null;
|
|
671
|
+
if (!href && !hint)
|
|
420
672
|
return flow;
|
|
673
|
+
if (!href)
|
|
674
|
+
return [hint, flow];
|
|
421
675
|
return [
|
|
676
|
+
...(hint ? [hint] : []),
|
|
422
677
|
flow,
|
|
423
678
|
(0, vue_1.h)('p', { key: 'sign-up-prompt', class: cls('footerAction', 'atlas-footer'), 'data-atlas-sign-up-prompt': '' }, [
|
|
424
679
|
`${t('signIn.noAccount')} `,
|
|
@@ -445,6 +700,10 @@ const SignUpForm = (0, vue_1.defineComponent)({
|
|
|
445
700
|
type: Function,
|
|
446
701
|
required: true,
|
|
447
702
|
},
|
|
703
|
+
/** Passkey-only sign-up: no password field; the email per the instance's mode. */
|
|
704
|
+
passkey: { type: Object, default: null },
|
|
705
|
+
/** Toggle between the password and passkey forms (only when both are offered). */
|
|
706
|
+
onSwitchMode: { type: Function, default: undefined },
|
|
448
707
|
},
|
|
449
708
|
setup(props) {
|
|
450
709
|
const t = useText();
|
|
@@ -527,14 +786,14 @@ const SignUpForm = (0, vue_1.defineComponent)({
|
|
|
527
786
|
}
|
|
528
787
|
}
|
|
529
788
|
};
|
|
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)),
|
|
789
|
+
const textField = (name, labelKey, type, autocomplete, required = true) => (0, vue_1.h)('label', { key: name, class: cls('formField', 'atlas-field') }, [
|
|
790
|
+
(0, vue_1.h)('span', { class: cls('formFieldLabel', 'atlas-field-label') }, required ? t(labelKey) : t('signUp.optionalField', { label: t(labelKey) })),
|
|
532
791
|
(0, vue_1.h)('input', {
|
|
533
792
|
class: cls('formFieldInput', 'atlas-input'),
|
|
534
793
|
type,
|
|
535
794
|
name,
|
|
536
795
|
autocomplete,
|
|
537
|
-
required
|
|
796
|
+
required,
|
|
538
797
|
value: values[name] ?? '',
|
|
539
798
|
onInput: (e) => {
|
|
540
799
|
values[name] = e.target.value;
|
|
@@ -559,7 +818,8 @@ const SignUpForm = (0, vue_1.defineComponent)({
|
|
|
559
818
|
// The app can't deliver the verification email, so an email + password
|
|
560
819
|
// sign-up could never complete: say so up front instead of a form that
|
|
561
820
|
// fails on submit.
|
|
562
|
-
|
|
821
|
+
const passkey = props.passkey;
|
|
822
|
+
if (props.settings?.emailDelivery?.passwordSignUp === false && !(passkey && passkey.email !== 'required')) {
|
|
563
823
|
return (0, vue_1.h)('div', { class: cls('card', 'atlas-card') }, [
|
|
564
824
|
(0, vue_1.h)('h1', { class: cls('headerTitle', 'atlas-title') }, t('signUp.title')),
|
|
565
825
|
(0, vue_1.h)('p', { class: cls('formFieldError', 'atlas-error'), role: 'alert', 'data-atlas-email-unavailable': '' }, t('signUp.emailUnavailable')),
|
|
@@ -585,8 +845,13 @@ const SignUpForm = (0, vue_1.defineComponent)({
|
|
|
585
845
|
control(f),
|
|
586
846
|
...fieldErrors(f.key),
|
|
587
847
|
])),
|
|
588
|
-
|
|
589
|
-
|
|
848
|
+
passkey
|
|
849
|
+
? passkey.email === 'off'
|
|
850
|
+
? null
|
|
851
|
+
: textField('email', 'signUp.emailLabel', 'email', 'email', passkey.email === 'required')
|
|
852
|
+
: textField('email', 'signUp.emailLabel', 'email', 'email'),
|
|
853
|
+
passkey ? null : textField('password', 'signUp.passwordLabel', 'password', 'new-password'),
|
|
854
|
+
passkey ? (0, vue_1.h)('p', { key: 'passkey-intro', class: 'atlas-text' }, t('signUp.passkeyIntro')) : null,
|
|
590
855
|
showLegal
|
|
591
856
|
? (0, vue_1.h)('label', { key: 'consent', class: cls('formField', 'atlas-consent') }, [
|
|
592
857
|
legal.required
|
|
@@ -614,11 +879,42 @@ const SignUpForm = (0, vue_1.defineComponent)({
|
|
|
614
879
|
disabled: props.busy || !consentOk,
|
|
615
880
|
type: 'submit',
|
|
616
881
|
}, t('signUp.submit')),
|
|
882
|
+
props.onSwitchMode
|
|
883
|
+
? (0, vue_1.h)('div', { key: 'alternatives', class: 'atlas-alternatives' }, [
|
|
884
|
+
(0, vue_1.h)('button', {
|
|
885
|
+
type: 'button',
|
|
886
|
+
class: cls('formButtonSecondary', 'atlas-button-secondary'),
|
|
887
|
+
'data-atlas-sign-up-mode': passkey ? 'password' : 'passkey',
|
|
888
|
+
disabled: props.busy,
|
|
889
|
+
onClick: () => props.onSwitchMode?.(),
|
|
890
|
+
}, t(passkey ? 'signUp.passwordOption' : 'signUp.passkeyOption')),
|
|
891
|
+
])
|
|
892
|
+
: null,
|
|
617
893
|
]),
|
|
618
894
|
]);
|
|
619
895
|
};
|
|
620
896
|
},
|
|
621
897
|
});
|
|
898
|
+
/** The passkey-only sign-up's last step: create the passkey (which creates the account). */
|
|
899
|
+
const PasskeySignUpStep = (0, vue_1.defineComponent)({
|
|
900
|
+
name: 'PasskeySignUpStep',
|
|
901
|
+
props: {
|
|
902
|
+
busy: { type: Boolean, default: false },
|
|
903
|
+
errors: { type: Array, default: () => [] },
|
|
904
|
+
onCreate: { type: Function, required: true },
|
|
905
|
+
},
|
|
906
|
+
setup(props) {
|
|
907
|
+
const t = useText();
|
|
908
|
+
const errorText = useErrorText();
|
|
909
|
+
const cls = useClass();
|
|
910
|
+
return () => (0, vue_1.h)('div', { class: cls('card', 'atlas-card'), 'data-atlas-passkey-sign-up': '' }, [
|
|
911
|
+
(0, vue_1.h)('h1', { class: cls('headerTitle', 'atlas-title') }, t('signUp.title')),
|
|
912
|
+
...props.errors.map((error) => (0, vue_1.h)('p', { class: cls('formFieldError', 'atlas-error'), role: 'alert', key: error.code }, errorText(error))),
|
|
913
|
+
(0, vue_1.h)('p', { class: 'atlas-text' }, t('signUp.passkeyPrompt')),
|
|
914
|
+
(0, vue_1.h)('button', { class: cls('formButtonPrimary', 'atlas-button'), type: 'button', disabled: props.busy, onClick: () => props.onCreate() }, t('signUp.passkeyCreate')),
|
|
915
|
+
]);
|
|
916
|
+
},
|
|
917
|
+
});
|
|
622
918
|
/**
|
|
623
919
|
* §10.2 `<SignUp/>` — creates an account. The first step is the create-account
|
|
624
920
|
* form, which POSTs `/v1/client/sign_ups`; from there the flow is server-driven
|
|
@@ -632,23 +928,41 @@ exports.SignUp = (0, vue_1.defineComponent)({
|
|
|
632
928
|
afterUrl: { type: String, default: undefined },
|
|
633
929
|
},
|
|
634
930
|
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
|
-
|
|
931
|
+
const { state, submit, create, createWithPasskey, createPasskey, resendSignUp, settings, signUpCaptcha } = (0, flow_1.useFlow)();
|
|
932
|
+
const mode = (0, vue_1.ref)('password');
|
|
933
|
+
return () => {
|
|
934
|
+
const offer = settings.value?.account.passkeySignUp ?? null;
|
|
935
|
+
const canPasskey = !!offer && (0, js_1.passkeysSupported)();
|
|
936
|
+
const usingPasskey = mode.value === 'passkey' && canPasskey;
|
|
937
|
+
if (state.value.attempt && (0, js_1.awaitingPasskey)(state.value.attempt)) {
|
|
938
|
+
return (0, vue_1.h)(PasskeySignUpStep, { busy: state.value.busy, errors: state.value.errors, onCreate: createPasskey });
|
|
939
|
+
}
|
|
940
|
+
return state.value.attempt
|
|
941
|
+
? (0, vue_1.h)(AttemptFlow, {
|
|
942
|
+
attempt: state.value.attempt,
|
|
943
|
+
busy: state.value.busy,
|
|
944
|
+
errors: state.value.errors,
|
|
945
|
+
sessionKept: state.value.sessionKept,
|
|
946
|
+
titleKey: 'signUp.title',
|
|
947
|
+
onSubmit: submit,
|
|
948
|
+
emailVerification: settings.value?.account.emailVerification ?? 'code',
|
|
949
|
+
onResend: resendSignUp,
|
|
950
|
+
})
|
|
951
|
+
: (0, vue_1.h)(SignUpForm, {
|
|
952
|
+
settings: settings.value,
|
|
953
|
+
busy: state.value.busy,
|
|
954
|
+
errors: state.value.errors,
|
|
955
|
+
captcha: signUpCaptcha.needed ? signUpCaptcha : null,
|
|
956
|
+
onSubmit: usingPasskey ? createWithPasskey : create,
|
|
957
|
+
passkey: usingPasskey ? { email: offer.email } : null,
|
|
958
|
+
onSwitchMode: canPasskey
|
|
959
|
+
? () => {
|
|
960
|
+
state.value = { ...state.value, errors: [] };
|
|
961
|
+
mode.value = usingPasskey ? 'password' : 'passkey';
|
|
962
|
+
}
|
|
963
|
+
: undefined,
|
|
964
|
+
});
|
|
965
|
+
};
|
|
652
966
|
},
|
|
653
967
|
});
|
|
654
968
|
exports.UserButton = (0, vue_1.defineComponent)({
|
|
@@ -684,6 +998,46 @@ exports.UserButton = (0, vue_1.defineComponent)({
|
|
|
684
998
|
};
|
|
685
999
|
},
|
|
686
1000
|
});
|
|
1001
|
+
/**
|
|
1002
|
+
* A banner shown only while the active session is an impersonation, naming the
|
|
1003
|
+
* user acted as, with "Return to admin" (or "End impersonation" when there is no
|
|
1004
|
+
* session of the impersonator's to resume). Emits `ended` with the outcome.
|
|
1005
|
+
*/
|
|
1006
|
+
exports.ImpersonationBanner = (0, vue_1.defineComponent)({
|
|
1007
|
+
name: 'ImpersonationBanner',
|
|
1008
|
+
emits: ['ended'],
|
|
1009
|
+
setup(_props, { emit }) {
|
|
1010
|
+
const { user } = (0, context_1.useAtlas)();
|
|
1011
|
+
const { isImpersonating, canReturnToAdmin, endImpersonation } = (0, composables_1.useImpersonation)();
|
|
1012
|
+
const t = useText();
|
|
1013
|
+
const cls = useClass();
|
|
1014
|
+
const busy = (0, vue_1.ref)(false);
|
|
1015
|
+
const end = async () => {
|
|
1016
|
+
busy.value = true;
|
|
1017
|
+
try {
|
|
1018
|
+
emit('ended', await endImpersonation());
|
|
1019
|
+
}
|
|
1020
|
+
finally {
|
|
1021
|
+
busy.value = false;
|
|
1022
|
+
}
|
|
1023
|
+
};
|
|
1024
|
+
return () => {
|
|
1025
|
+
if (!isImpersonating.value)
|
|
1026
|
+
return null;
|
|
1027
|
+
const u = user.value;
|
|
1028
|
+
const name = [u?.first_name, u?.last_name].filter(Boolean).join(' ') ||
|
|
1029
|
+
u?.username ||
|
|
1030
|
+
u?.email_addresses?.find((e) => e.primary)?.email_address ||
|
|
1031
|
+
u?.id ||
|
|
1032
|
+
'';
|
|
1033
|
+
return (0, vue_1.h)('div', { class: cls('card', 'atlas-alert atlas-impersonation-banner'), role: 'status' }, [
|
|
1034
|
+
(0, vue_1.h)('span', { class: cls('headerSubtitle', 'atlas-text') }, t('impersonation.banner', { name })),
|
|
1035
|
+
' ',
|
|
1036
|
+
(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')),
|
|
1037
|
+
]);
|
|
1038
|
+
};
|
|
1039
|
+
},
|
|
1040
|
+
});
|
|
687
1041
|
exports.OrganizationSwitcher = (0, vue_1.defineComponent)({
|
|
688
1042
|
name: 'OrganizationSwitcher',
|
|
689
1043
|
setup() {
|
|
@@ -798,20 +1152,341 @@ exports.UserProfile = (0, vue_1.defineComponent)({
|
|
|
798
1152
|
});
|
|
799
1153
|
exports.OrganizationProfile = (0, vue_1.defineComponent)({
|
|
800
1154
|
name: 'OrganizationProfile',
|
|
801
|
-
|
|
1155
|
+
emits: ['exit'],
|
|
1156
|
+
setup(_props, { emit }) {
|
|
1157
|
+
const atlas = (0, context_1.useAtlas)();
|
|
802
1158
|
const { organization, membership } = (0, composables_1.useOrganization)();
|
|
1159
|
+
const { userId, has } = (0, composables_1.useAuth)();
|
|
803
1160
|
const t = useText();
|
|
804
1161
|
const cls = useClass();
|
|
1162
|
+
const client = new fapi_1.FapiClient({
|
|
1163
|
+
publishableKey: atlas.publishableKey,
|
|
1164
|
+
baseUrl: atlas.frontendApi,
|
|
1165
|
+
locale: atlas.locale,
|
|
1166
|
+
sdk: version_1.ATLAS_VUE_SDK,
|
|
1167
|
+
});
|
|
1168
|
+
const errorText = (err) => (0, js_1.localizedErrorMessage)(err, atlas.errorCatalog ?? atlas.catalog, atlas.catalog);
|
|
1169
|
+
const state = (0, vue_1.shallowRef)(null);
|
|
1170
|
+
const tab = (0, vue_1.ref)('members');
|
|
1171
|
+
// The custom-role editor: null = creating, else the id of the role being edited.
|
|
1172
|
+
const editingRole = (0, vue_1.ref)(null);
|
|
1173
|
+
const roleKey = (0, vue_1.ref)('');
|
|
1174
|
+
const roleName = (0, vue_1.ref)('');
|
|
1175
|
+
const rolePerms = (0, vue_1.ref)([]);
|
|
1176
|
+
const resetRoleForm = () => {
|
|
1177
|
+
editingRole.value = null;
|
|
1178
|
+
roleKey.value = '';
|
|
1179
|
+
roleName.value = '';
|
|
1180
|
+
rolePerms.value = [];
|
|
1181
|
+
};
|
|
1182
|
+
const email = (0, vue_1.ref)('');
|
|
1183
|
+
const inviteRole = (0, vue_1.ref)('org:member');
|
|
1184
|
+
const confirm = (0, vue_1.ref)(null);
|
|
1185
|
+
const needsPassword = (0, vue_1.ref)(false);
|
|
1186
|
+
const password = (0, vue_1.ref)('');
|
|
1187
|
+
let controller = null;
|
|
1188
|
+
let unsubscribe = null;
|
|
1189
|
+
(0, vue_1.watch)(() => organization.value?.id ?? null, (id) => {
|
|
1190
|
+
unsubscribe?.();
|
|
1191
|
+
controller = null;
|
|
1192
|
+
state.value = null;
|
|
1193
|
+
if (!id)
|
|
1194
|
+
return;
|
|
1195
|
+
controller = new js_1.OrganizationProfileController(client, id, {
|
|
1196
|
+
canManage: has({ permission: 'org:sys_memberships:manage' }),
|
|
1197
|
+
onChange: async () => {
|
|
1198
|
+
await atlas.reload();
|
|
1199
|
+
},
|
|
1200
|
+
});
|
|
1201
|
+
unsubscribe = controller.subscribe((next) => {
|
|
1202
|
+
state.value = next;
|
|
1203
|
+
});
|
|
1204
|
+
state.value = controller.getState();
|
|
1205
|
+
void controller.load();
|
|
1206
|
+
}, { immediate: true });
|
|
1207
|
+
(0, vue_1.onUnmounted)(() => unsubscribe?.());
|
|
1208
|
+
const leave = async () => {
|
|
1209
|
+
if (controller && (await controller.leave()))
|
|
1210
|
+
emit('exit', 'left');
|
|
1211
|
+
confirm.value = null;
|
|
1212
|
+
};
|
|
1213
|
+
const remove = async () => {
|
|
1214
|
+
if (!controller)
|
|
1215
|
+
return;
|
|
1216
|
+
if (needsPassword.value) {
|
|
1217
|
+
try {
|
|
1218
|
+
await (0, js_1.reauthenticate)({ strategy: 'password', password: password.value }, { api: atlas.frontendApi, publishableKey: atlas.publishableKey, locale: atlas.locale });
|
|
1219
|
+
}
|
|
1220
|
+
catch {
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
password.value = '';
|
|
1224
|
+
needsPassword.value = false;
|
|
1225
|
+
}
|
|
1226
|
+
if (await controller.deleteOrganization()) {
|
|
1227
|
+
emit('exit', 'deleted');
|
|
1228
|
+
confirm.value = null;
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
const err = controller.getState().error;
|
|
1232
|
+
if (err && err.needsReverification)
|
|
1233
|
+
needsPassword.value = true;
|
|
1234
|
+
};
|
|
805
1235
|
return () => {
|
|
806
1236
|
const org = organization.value;
|
|
807
|
-
|
|
1237
|
+
const st = state.value;
|
|
1238
|
+
if (!org || !controller || !st)
|
|
808
1239
|
return null;
|
|
809
|
-
|
|
1240
|
+
const ctl = controller;
|
|
1241
|
+
const canManage = has({ permission: 'org:sys_memberships:manage' });
|
|
1242
|
+
const isAdmin = membership.value?.role === 'org:admin';
|
|
1243
|
+
const roles = st.roles.length > 0 ? st.roles : [{ key: 'org:member', name: 'org:member' }];
|
|
1244
|
+
const roleOptions = (selected) => roles.map((r) => (0, vue_1.h)('option', { value: r.key, selected: r.key === selected }, r.name));
|
|
1245
|
+
const tabs = ['members', ...(canManage ? ['invitations', 'roles'] : []), 'settings'];
|
|
1246
|
+
const customRoles = st.roles.filter((r) => Boolean(r.organization_id));
|
|
1247
|
+
const secondary = cls('formButtonSecondary', 'atlas-button-secondary');
|
|
1248
|
+
const rolesPanel = (0, vue_1.h)('div', { 'data-atlas': 'organization-roles' }, [
|
|
1249
|
+
customRoles.length === 0
|
|
1250
|
+
? (0, vue_1.h)('p', { class: 'atlas-text' }, t('organization.noCustomRoles'))
|
|
1251
|
+
: (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' }, [
|
|
1252
|
+
(0, vue_1.h)('span', `${role.name} · ${role.key}`),
|
|
1253
|
+
(0, vue_1.h)('button', {
|
|
1254
|
+
type: 'button',
|
|
1255
|
+
class: secondary,
|
|
1256
|
+
onClick: () => {
|
|
1257
|
+
editingRole.value = role.id ?? null;
|
|
1258
|
+
roleKey.value = role.key;
|
|
1259
|
+
roleName.value = role.name;
|
|
1260
|
+
rolePerms.value = [...(role.permissions ?? [])];
|
|
1261
|
+
},
|
|
1262
|
+
}, t('organization.editRole')),
|
|
1263
|
+
(0, vue_1.h)('button', {
|
|
1264
|
+
type: 'button',
|
|
1265
|
+
class: secondary,
|
|
1266
|
+
disabled: !role.id || st.busy === `role:delete:${role.id}`,
|
|
1267
|
+
onClick: () => role.id && void ctl.deleteRole(role.id),
|
|
1268
|
+
}, t('organization.deleteRole')),
|
|
1269
|
+
]))),
|
|
1270
|
+
(0, vue_1.h)('form', {
|
|
1271
|
+
'data-atlas': 'organization-role-form',
|
|
1272
|
+
onSubmit: async (e) => {
|
|
1273
|
+
e.preventDefault();
|
|
1274
|
+
const out = editingRole.value
|
|
1275
|
+
? await ctl.updateRole(editingRole.value, { name: roleName.value.trim(), permissions: rolePerms.value })
|
|
1276
|
+
: await ctl.createRole({ key: roleKey.value.trim(), name: roleName.value.trim(), permissions: rolePerms.value });
|
|
1277
|
+
if (out)
|
|
1278
|
+
resetRoleForm();
|
|
1279
|
+
},
|
|
1280
|
+
}, [
|
|
1281
|
+
editingRole.value
|
|
1282
|
+
? null
|
|
1283
|
+
: (0, vue_1.h)('label', { class: cls('formField', 'atlas-field') }, [
|
|
1284
|
+
(0, vue_1.h)('span', { class: cls('formFieldLabel', 'atlas-field-label') }, t('organization.roleKey')),
|
|
1285
|
+
(0, vue_1.h)('input', {
|
|
1286
|
+
name: 'role_key',
|
|
1287
|
+
class: cls('formFieldInput', 'atlas-input'),
|
|
1288
|
+
value: roleKey.value,
|
|
1289
|
+
onInput: (e) => (roleKey.value = e.target.value),
|
|
1290
|
+
}),
|
|
1291
|
+
]),
|
|
1292
|
+
(0, vue_1.h)('label', { class: cls('formField', 'atlas-field') }, [
|
|
1293
|
+
(0, vue_1.h)('span', { class: cls('formFieldLabel', 'atlas-field-label') }, t('organization.roleName')),
|
|
1294
|
+
(0, vue_1.h)('input', {
|
|
1295
|
+
name: 'role_name',
|
|
1296
|
+
class: cls('formFieldInput', 'atlas-input'),
|
|
1297
|
+
value: roleName.value,
|
|
1298
|
+
onInput: (e) => (roleName.value = e.target.value),
|
|
1299
|
+
}),
|
|
1300
|
+
]),
|
|
1301
|
+
(0, vue_1.h)('fieldset', { class: cls('formField', 'atlas-field') }, [
|
|
1302
|
+
(0, vue_1.h)('legend', { class: cls('formFieldLabel', 'atlas-field-label') }, t('organization.rolePermissions')),
|
|
1303
|
+
...st.permissions.map((p) => (0, vue_1.h)('label', { key: p.key, 'data-atlas': 'organization-role-permission' }, [
|
|
1304
|
+
(0, vue_1.h)('input', {
|
|
1305
|
+
type: 'checkbox',
|
|
1306
|
+
value: p.key,
|
|
1307
|
+
checked: rolePerms.value.includes(p.key),
|
|
1308
|
+
onChange: (e) => {
|
|
1309
|
+
const on = e.target.checked;
|
|
1310
|
+
rolePerms.value = on
|
|
1311
|
+
? [...rolePerms.value, p.key]
|
|
1312
|
+
: rolePerms.value.filter((k) => k !== p.key);
|
|
1313
|
+
},
|
|
1314
|
+
}),
|
|
1315
|
+
` ${p.name}`,
|
|
1316
|
+
])),
|
|
1317
|
+
]),
|
|
1318
|
+
(0, vue_1.h)('button', {
|
|
1319
|
+
type: 'submit',
|
|
1320
|
+
class: cls('formButtonPrimary', 'atlas-button'),
|
|
1321
|
+
disabled: !roleName.value.trim() || (!editingRole.value && !roleKey.value.trim()) || Boolean(st.busy?.startsWith('role:')),
|
|
1322
|
+
}, editingRole.value ? t('organization.saveRole') : t('organization.createRole')),
|
|
1323
|
+
editingRole.value
|
|
1324
|
+
? (0, vue_1.h)('button', { type: 'button', class: secondary, onClick: resetRoleForm }, t('organization.cancel'))
|
|
1325
|
+
: null,
|
|
1326
|
+
]),
|
|
1327
|
+
]);
|
|
1328
|
+
const members = (0, vue_1.h)('ul', { class: 'atlas-list', 'data-atlas': 'organization-members' }, st.members.map((m) => {
|
|
1329
|
+
const id = m.user?.id ?? m.id;
|
|
1330
|
+
const self = m.user?.id === userId.value;
|
|
1331
|
+
const name = [m.user?.first_name, m.user?.last_name].filter(Boolean).join(' ') || t('organization.unnamedMember');
|
|
1332
|
+
return (0, vue_1.h)('li', { key: m.id, 'data-atlas': 'organization-member' }, [
|
|
1333
|
+
(0, vue_1.h)('span', self ? `${name} ${t('organization.you')}` : name),
|
|
1334
|
+
canManage && !self && m.user
|
|
1335
|
+
? [
|
|
1336
|
+
(0, vue_1.h)('select', {
|
|
1337
|
+
'aria-label': t('organization.roleLabel'),
|
|
1338
|
+
class: cls('formFieldInput', 'atlas-input'),
|
|
1339
|
+
disabled: st.busy === `role:${id}`,
|
|
1340
|
+
onChange: (e) => void ctl.changeRole(id, e.target.value),
|
|
1341
|
+
}, roleOptions(m.role)),
|
|
1342
|
+
(0, vue_1.h)('button', {
|
|
1343
|
+
type: 'button',
|
|
1344
|
+
class: cls('formButtonSecondary', 'atlas-button-secondary'),
|
|
1345
|
+
disabled: st.busy === `remove:${id}`,
|
|
1346
|
+
onClick: () => void ctl.removeMember(id),
|
|
1347
|
+
}, t('organization.remove')),
|
|
1348
|
+
]
|
|
1349
|
+
: (0, vue_1.h)('span', ` · ${m.role}`),
|
|
1350
|
+
]);
|
|
1351
|
+
}));
|
|
1352
|
+
const invitations = (0, vue_1.h)('div', { 'data-atlas': 'organization-invitations' }, [
|
|
1353
|
+
(0, vue_1.h)('form', {
|
|
1354
|
+
onSubmit: async (e) => {
|
|
1355
|
+
e.preventDefault();
|
|
1356
|
+
const address = email.value.trim();
|
|
1357
|
+
if (address && (await ctl.invite(address, inviteRole.value)))
|
|
1358
|
+
email.value = '';
|
|
1359
|
+
},
|
|
1360
|
+
}, [
|
|
1361
|
+
(0, vue_1.h)('label', { class: cls('formField', 'atlas-field') }, [
|
|
1362
|
+
(0, vue_1.h)('span', { class: cls('formFieldLabel', 'atlas-field-label') }, t('organization.emailLabel')),
|
|
1363
|
+
(0, vue_1.h)('input', {
|
|
1364
|
+
type: 'email',
|
|
1365
|
+
class: cls('formFieldInput', 'atlas-input'),
|
|
1366
|
+
value: email.value,
|
|
1367
|
+
onInput: (e) => (email.value = e.target.value),
|
|
1368
|
+
}),
|
|
1369
|
+
]),
|
|
1370
|
+
(0, vue_1.h)('label', { class: cls('formField', 'atlas-field') }, [
|
|
1371
|
+
(0, vue_1.h)('span', { class: cls('formFieldLabel', 'atlas-field-label') }, t('organization.roleLabel')),
|
|
1372
|
+
(0, vue_1.h)('select', {
|
|
1373
|
+
class: cls('formFieldInput', 'atlas-input'),
|
|
1374
|
+
onChange: (e) => (inviteRole.value = e.target.value),
|
|
1375
|
+
}, roleOptions(inviteRole.value)),
|
|
1376
|
+
]),
|
|
1377
|
+
(0, vue_1.h)('button', { type: 'submit', class: cls('formButtonPrimary', 'atlas-button'), disabled: st.busy === 'invite' || !email.value.trim() }, t('organization.sendInvite')),
|
|
1378
|
+
]),
|
|
1379
|
+
st.invitations.length === 0
|
|
1380
|
+
? (0, vue_1.h)('p', { class: 'atlas-text' }, t('organization.noInvitations'))
|
|
1381
|
+
: (0, vue_1.h)('ul', { class: 'atlas-list' }, st.invitations.map((inv) => (0, vue_1.h)('li', { key: inv.id, 'data-atlas': 'organization-invitation' }, [
|
|
1382
|
+
(0, vue_1.h)('span', `${inv.email_address} · ${inv.role ?? ''}`),
|
|
1383
|
+
(0, vue_1.h)('button', { type: 'button', class: cls('formButtonSecondary', 'atlas-button-secondary'), onClick: () => void ctl.resendInvitation(inv.id) }, t('organization.resend')),
|
|
1384
|
+
(0, vue_1.h)('button', { type: 'button', class: cls('formButtonSecondary', 'atlas-button-secondary'), onClick: () => void ctl.revokeInvitation(inv.id) }, t('organization.revoke')),
|
|
1385
|
+
]))),
|
|
1386
|
+
]);
|
|
1387
|
+
const settings = (0, vue_1.h)('div', { 'data-atlas': 'organization-settings' }, confirm.value
|
|
1388
|
+
? [
|
|
1389
|
+
(0, vue_1.h)('p', { class: 'atlas-text' }, (0, i18n_1.translate)(atlas.catalog, confirm.value === 'leave' ? 'organization.leaveConfirm' : 'organization.deleteConfirm', { name: org.name })),
|
|
1390
|
+
needsPassword.value
|
|
1391
|
+
? (0, vue_1.h)('label', { class: cls('formField', 'atlas-field') }, [
|
|
1392
|
+
(0, vue_1.h)('span', { class: cls('formFieldLabel', 'atlas-field-label') }, t('organization.passwordPrompt')),
|
|
1393
|
+
(0, vue_1.h)('input', {
|
|
1394
|
+
type: 'password',
|
|
1395
|
+
autocomplete: 'current-password',
|
|
1396
|
+
class: cls('formFieldInput', 'atlas-input'),
|
|
1397
|
+
value: password.value,
|
|
1398
|
+
onInput: (e) => (password.value = e.target.value),
|
|
1399
|
+
}),
|
|
1400
|
+
])
|
|
1401
|
+
: null,
|
|
1402
|
+
(0, vue_1.h)('button', {
|
|
1403
|
+
type: 'button',
|
|
1404
|
+
class: cls('formButtonPrimary', 'atlas-button'),
|
|
1405
|
+
disabled: st.busy === 'leave' || st.busy === 'delete',
|
|
1406
|
+
onClick: () => void (confirm.value === 'leave' ? leave() : remove()),
|
|
1407
|
+
}, t('organization.confirm')),
|
|
1408
|
+
(0, vue_1.h)('button', {
|
|
1409
|
+
type: 'button',
|
|
1410
|
+
class: cls('formButtonSecondary', 'atlas-button-secondary'),
|
|
1411
|
+
onClick: () => {
|
|
1412
|
+
confirm.value = null;
|
|
1413
|
+
needsPassword.value = false;
|
|
1414
|
+
password.value = '';
|
|
1415
|
+
},
|
|
1416
|
+
}, t('organization.cancel')),
|
|
1417
|
+
]
|
|
1418
|
+
: [
|
|
1419
|
+
(0, vue_1.h)('button', { type: 'button', class: cls('formButtonSecondary', 'atlas-button-secondary'), onClick: () => (confirm.value = 'leave') }, t('organization.leave')),
|
|
1420
|
+
isAdmin
|
|
1421
|
+
? (0, vue_1.h)('button', { type: 'button', class: cls('formButtonSecondary', 'atlas-button-secondary'), onClick: () => (confirm.value = 'delete') }, t('organization.delete'))
|
|
1422
|
+
: null,
|
|
1423
|
+
]);
|
|
1424
|
+
return (0, vue_1.h)('div', { class: cls('card', 'atlas-card'), 'data-atlas': 'organization-profile' }, [
|
|
810
1425
|
(0, vue_1.h)('h1', org.name),
|
|
811
1426
|
(0, vue_1.h)('p', `${t('organization.roleLabel')}: ${membership.value?.role ?? ''}`),
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1427
|
+
(0, vue_1.h)('div', { class: cls('menu', 'atlas-menu'), role: 'tablist' }, tabs.map((key) => (0, vue_1.h)('button', {
|
|
1428
|
+
key,
|
|
1429
|
+
type: 'button',
|
|
1430
|
+
role: 'tab',
|
|
1431
|
+
'aria-selected': tab.value === key,
|
|
1432
|
+
class: cls('menuItem', 'atlas-menu-item'),
|
|
1433
|
+
'data-atlas': `organization-tab-${key}`,
|
|
1434
|
+
onClick: () => (tab.value = key),
|
|
1435
|
+
}, t(`organization.${key}`)))),
|
|
1436
|
+
st.error && !st.error.needsReverification
|
|
1437
|
+
? (0, vue_1.h)('p', { class: cls('formFieldError', 'atlas-error'), role: 'alert' }, errorText(st.error))
|
|
1438
|
+
: null,
|
|
1439
|
+
tab.value === 'members' ? members : null,
|
|
1440
|
+
tab.value === 'invitations' && canManage ? invitations : null,
|
|
1441
|
+
tab.value === 'roles' && canManage ? rolesPanel : null,
|
|
1442
|
+
tab.value === 'settings' ? settings : null,
|
|
1443
|
+
]);
|
|
1444
|
+
};
|
|
1445
|
+
},
|
|
1446
|
+
});
|
|
1447
|
+
/**
|
|
1448
|
+
* `<OrganizationAuditLog/>` — the active organization's audit trail for its
|
|
1449
|
+
* admins, next to `<OrganizationProfile/>`. Renders nothing for a member who may
|
|
1450
|
+
* not read it (the server is the authority; the component just follows it).
|
|
1451
|
+
* Labels are props so an app can localise them.
|
|
1452
|
+
*/
|
|
1453
|
+
exports.OrganizationAuditLog = (0, vue_1.defineComponent)({
|
|
1454
|
+
name: 'OrganizationAuditLog',
|
|
1455
|
+
props: {
|
|
1456
|
+
pageSize: { type: Number, default: undefined },
|
|
1457
|
+
action: { type: String, default: undefined },
|
|
1458
|
+
labels: { type: Object, default: () => ({}) },
|
|
1459
|
+
formatDate: {
|
|
1460
|
+
type: Function,
|
|
1461
|
+
default: (ms) => new Date(ms).toLocaleString(),
|
|
1462
|
+
},
|
|
1463
|
+
},
|
|
1464
|
+
setup(props) {
|
|
1465
|
+
const { organization } = (0, composables_1.useOrganization)();
|
|
1466
|
+
const log = (0, composables_1.useOrganizationAuditLog)({ pageSize: props.pageSize, action: props.action });
|
|
1467
|
+
const cls = useClass();
|
|
1468
|
+
return () => {
|
|
1469
|
+
const state = log.state.value;
|
|
1470
|
+
if (!organization.value || state.forbidden)
|
|
1471
|
+
return null;
|
|
1472
|
+
const labels = props.labels;
|
|
1473
|
+
return (0, vue_1.h)('div', { class: cls('card', 'atlas-card'), 'data-atlas': 'organization-audit-log' }, [
|
|
1474
|
+
(0, vue_1.h)('h2', { class: cls('headerTitle', 'atlas-title') }, labels.title ?? 'Audit log'),
|
|
1475
|
+
state.entries.length === 0 && !state.loading
|
|
1476
|
+
? (0, vue_1.h)('p', { class: cls('headerSubtitle', 'atlas-subtitle') }, labels.empty ?? 'No activity recorded yet.')
|
|
1477
|
+
: (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 }, [
|
|
1478
|
+
(0, vue_1.h)('strong', (0, js_1.describeAuditAction)(entry.action)),
|
|
1479
|
+
(0, vue_1.h)('span', { class: 'atlas-audit-meta' }, ` · ${props.formatDate(entry.created_at)}` +
|
|
1480
|
+
(entry.actor.id ? ` · ${labels.actor ?? 'by'} ${entry.actor.type} ${entry.actor.id}` : '')),
|
|
1481
|
+
]))),
|
|
1482
|
+
state.hasMore
|
|
1483
|
+
? (0, vue_1.h)('button', {
|
|
1484
|
+
type: 'button',
|
|
1485
|
+
class: cls('formButtonSecondary', 'atlas-button-secondary'),
|
|
1486
|
+
disabled: state.loading,
|
|
1487
|
+
onClick: () => void log.loadMore(),
|
|
1488
|
+
}, labels.loadMore ?? 'Load more')
|
|
1489
|
+
: null,
|
|
815
1490
|
]);
|
|
816
1491
|
};
|
|
817
1492
|
},
|