@atlasauth/vue 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/dist/appearance.d.ts +1 -1
- package/dist/appearance.js.map +1 -1
- package/dist/components.d.ts +90 -2
- package/dist/components.js +760 -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 +95 -12
- package/dist/flow.js.map +1 -1
- package/dist/i18n.js +58 -0
- package/dist/i18n.js.map +1 -1
- package/dist/plugin.d.ts +10 -2
- package/dist/plugin.js +19 -2
- 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: {
|
|
@@ -148,6 +159,9 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
148
159
|
busy: { type: Boolean, default: false },
|
|
149
160
|
errors: { type: Array, default: () => [] },
|
|
150
161
|
titleKey: { type: String, required: true },
|
|
162
|
+
/** `FlowState.sessionKept`: whether this browser kept the session the sign-in created. */
|
|
163
|
+
// Untyped on purpose: Vue casts an absent Boolean prop to false, and absent means "unknown" here.
|
|
164
|
+
sessionKept: { type: null, default: undefined },
|
|
151
165
|
onSubmit: { type: Function, required: true },
|
|
152
166
|
/**
|
|
153
167
|
* The sign-in captcha, drawn above the submit button on the identifier step
|
|
@@ -168,6 +182,43 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
168
182
|
type: Function,
|
|
169
183
|
default: undefined,
|
|
170
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 },
|
|
171
222
|
},
|
|
172
223
|
setup(props) {
|
|
173
224
|
const t = useText();
|
|
@@ -177,8 +228,14 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
177
228
|
// The first factor the user picked: the password field by default, or the
|
|
178
229
|
// code field / "check your email" once an emailed code / link was sent.
|
|
179
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)({});
|
|
180
235
|
(0, vue_1.watch)(() => props.attempt?.id, () => {
|
|
181
236
|
factor.value = 'password';
|
|
237
|
+
emailed.value = {};
|
|
238
|
+
texted.value = {};
|
|
182
239
|
});
|
|
183
240
|
// The first factors on offer for the current step, and the plan for them:
|
|
184
241
|
// a password field only when the server offers `password` (planFirstFactor).
|
|
@@ -215,12 +272,13 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
215
272
|
void send(strategy);
|
|
216
273
|
},
|
|
217
274
|
}, t(labelKey));
|
|
218
|
-
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 }, [
|
|
219
276
|
(0, vue_1.h)('span', t(labelKey)),
|
|
220
277
|
(0, vue_1.h)('input', {
|
|
221
278
|
class: cls('formFieldInput', 'atlas-input'),
|
|
222
279
|
type,
|
|
223
280
|
name,
|
|
281
|
+
...(autocomplete ? { autocomplete } : {}),
|
|
224
282
|
value: values[name] ?? '',
|
|
225
283
|
onInput: (event) => {
|
|
226
284
|
values[name] = event.target.value;
|
|
@@ -230,6 +288,69 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
230
288
|
.filter((error) => error.param === name)
|
|
231
289
|
.map((error) => (0, vue_1.h)('span', { class: cls('formFieldError', 'atlas-error'), key: error.code }, errorText(error))),
|
|
232
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
|
+
};
|
|
233
354
|
const fields = (step) => {
|
|
234
355
|
switch (step.kind) {
|
|
235
356
|
case 'collect_identifier':
|
|
@@ -284,9 +405,59 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
284
405
|
offered.includes('email_link') ? emailChoice('email_link', 'signIn.useEmailLink') : null,
|
|
285
406
|
].filter((node) => node !== null);
|
|
286
407
|
}
|
|
287
|
-
case 'collect_second_factor':
|
|
288
|
-
|
|
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
|
+
}
|
|
289
458
|
case 'enroll_second_factor':
|
|
459
|
+
if (props.enrollment && props.enrollmentState)
|
|
460
|
+
return enrollmentFields(props.enrollment, props.enrollmentState);
|
|
290
461
|
// Two codes, not one. §5.6 wants consecutive codes so a phone with a
|
|
291
462
|
// fast clock fails here rather than on every sign-in afterwards.
|
|
292
463
|
return [
|
|
@@ -294,6 +465,22 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
294
465
|
field('secondCode', 'mfa.enrollSecondCodeLabel'),
|
|
295
466
|
];
|
|
296
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
|
+
}
|
|
297
484
|
return [field('code', 'signIn.emailCodeLabel')];
|
|
298
485
|
case 'collect_new_password':
|
|
299
486
|
return [field('password', 'reset.newPasswordLabel', 'password')];
|
|
@@ -304,6 +491,13 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
304
491
|
case 'await_oauth':
|
|
305
492
|
return [(0, vue_1.h)('p', { key: 'oauth' }, t('signIn.magicLinkSent'))];
|
|
306
493
|
case 'done':
|
|
494
|
+
// Signed in only when this browser holds the session; a blocked cookie is said
|
|
495
|
+
// plainly. "Check the other tab" is for a sign-in completed on another device.
|
|
496
|
+
if (props.sessionKept === true)
|
|
497
|
+
return [(0, vue_1.h)('p', { key: 'done', class: 'atlas-text' }, t('signIn.signedIn'))];
|
|
498
|
+
if (props.sessionKept === false) {
|
|
499
|
+
return [(0, vue_1.h)('p', { key: 'done', class: cls('formFieldError', 'atlas-error'), role: 'alert' }, t('signIn.sessionNotKept'))];
|
|
500
|
+
}
|
|
307
501
|
return [(0, vue_1.h)('p', { key: 'done' }, t('signIn.checkOtherTab'))];
|
|
308
502
|
case 'restart':
|
|
309
503
|
return [(0, vue_1.h)('p', { key: 'restart' }, t('error.generic'))];
|
|
@@ -315,7 +509,53 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
315
509
|
];
|
|
316
510
|
}
|
|
317
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
|
+
};
|
|
318
555
|
return () => {
|
|
556
|
+
if (props.recoveryCodes?.length && props.onRecoveryCodesSaved) {
|
|
557
|
+
return recoveryCodesCard(props.recoveryCodes, props.onRecoveryCodesSaved);
|
|
558
|
+
}
|
|
319
559
|
const step = props.attempt
|
|
320
560
|
? (0, js_1.nextStep)(props.attempt)
|
|
321
561
|
: { kind: 'collect_identifier' };
|
|
@@ -334,6 +574,14 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
334
574
|
props.onSubmit(factor.value === 'email_code' ? { code: values.code ?? '' } : { password: values.password ?? '' });
|
|
335
575
|
return;
|
|
336
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
|
+
}
|
|
337
585
|
props.onSubmit({ ...values });
|
|
338
586
|
},
|
|
339
587
|
}, [
|
|
@@ -343,13 +591,17 @@ const AttemptFlow = (0, vue_1.defineComponent)({
|
|
|
343
591
|
: null,
|
|
344
592
|
step.kind !== 'done' &&
|
|
345
593
|
step.kind !== 'await_oauth' &&
|
|
594
|
+
!(0, js_1.awaitingSignUpLink)(props.attempt, props.emailVerification) &&
|
|
346
595
|
!(step.kind === 'collect_first_factor' && factor.value === 'email_link') &&
|
|
347
|
-
!(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))
|
|
348
598
|
? (0, vue_1.h)('button', {
|
|
349
599
|
class: cls('formButtonPrimary', 'atlas-button'),
|
|
350
|
-
disabled: props.busy,
|
|
600
|
+
disabled: props.busy || (step.kind === 'enroll_second_factor' && !!props.enrollmentState?.busy),
|
|
351
601
|
type: 'submit',
|
|
352
|
-
}, t('
|
|
602
|
+
}, t(step.kind === 'enroll_second_factor' && props.enrollmentState
|
|
603
|
+
? ((0, js_1.enrollmentSubmitKey)(props.enrollmentState) ?? 'signIn.submit')
|
|
604
|
+
: 'signIn.submit'))
|
|
353
605
|
: null,
|
|
354
606
|
]),
|
|
355
607
|
]);
|
|
@@ -361,24 +613,73 @@ exports.SignIn = (0, vue_1.defineComponent)({
|
|
|
361
613
|
props: {
|
|
362
614
|
/** Where to send the user once the flow completes. */
|
|
363
615
|
afterUrl: { type: String, default: undefined },
|
|
616
|
+
/**
|
|
617
|
+
* Where "No account? Sign up" goes. Default: the instance's hosted sign-up
|
|
618
|
+
* page (carrying the visitor's referral). `null` hides it. Always shown, never
|
|
619
|
+
* only after a failed sign-in, so it says nothing about whether an email has an account.
|
|
620
|
+
*/
|
|
621
|
+
signUpUrl: { type: null, default: undefined },
|
|
364
622
|
},
|
|
365
|
-
setup() {
|
|
366
|
-
const { state, submit, prepare, captcha, settings } = (0, flow_1.useFlow)();
|
|
623
|
+
setup(props) {
|
|
624
|
+
const { state, submit, prepare, captcha, settings, lastMethod, sendEmailSecondFactor, sendSmsSecondFactor, mfaChoices, enrollment, enrollmentState, saveRecoveryCodes } = (0, flow_1.useFlow)();
|
|
625
|
+
const atlas = (0, context_1.useAtlas)();
|
|
626
|
+
const t = useText();
|
|
627
|
+
const cls = useClass();
|
|
628
|
+
const signUpHref = () => {
|
|
629
|
+
if (props.signUpUrl === null)
|
|
630
|
+
return null;
|
|
631
|
+
if (props.signUpUrl)
|
|
632
|
+
return props.signUpUrl;
|
|
633
|
+
try {
|
|
634
|
+
return (0, js_1.signUp)({
|
|
635
|
+
frontendApi: atlas.frontendApi,
|
|
636
|
+
redirectUrl: props.afterUrl ?? (typeof window !== 'undefined' ? window.location.href : undefined),
|
|
637
|
+
navigate: false,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
};
|
|
367
644
|
return () => {
|
|
368
645
|
// Read the settings so the form re-renders (drawing the captcha slot)
|
|
369
646
|
// once the instance's captcha config has loaded; the controller is passed
|
|
370
647
|
// only while it draws, so the child sees a changed prop.
|
|
371
648
|
void settings.value;
|
|
372
|
-
|
|
649
|
+
const href = state.value.attempt?.status !== 'complete' ? signUpHref() : null;
|
|
650
|
+
const flow = (0, vue_1.h)(AttemptFlow, {
|
|
373
651
|
attempt: state.value.attempt,
|
|
374
652
|
busy: state.value.busy,
|
|
375
653
|
errors: state.value.errors,
|
|
654
|
+
sessionKept: state.value.sessionKept,
|
|
376
655
|
titleKey: 'signIn.title',
|
|
377
656
|
onSubmit: submit,
|
|
378
657
|
captcha: captcha.needed ? captcha : null,
|
|
379
658
|
emailDelivery: settings.value?.emailDelivery ?? null,
|
|
380
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,
|
|
381
666
|
});
|
|
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)
|
|
672
|
+
return flow;
|
|
673
|
+
if (!href)
|
|
674
|
+
return [hint, flow];
|
|
675
|
+
return [
|
|
676
|
+
...(hint ? [hint] : []),
|
|
677
|
+
flow,
|
|
678
|
+
(0, vue_1.h)('p', { key: 'sign-up-prompt', class: cls('footerAction', 'atlas-footer'), 'data-atlas-sign-up-prompt': '' }, [
|
|
679
|
+
`${t('signIn.noAccount')} `,
|
|
680
|
+
(0, vue_1.h)('a', { class: 'atlas-link', href }, t('signIn.signUpLink')),
|
|
681
|
+
]),
|
|
682
|
+
];
|
|
382
683
|
};
|
|
383
684
|
},
|
|
384
685
|
});
|
|
@@ -399,6 +700,10 @@ const SignUpForm = (0, vue_1.defineComponent)({
|
|
|
399
700
|
type: Function,
|
|
400
701
|
required: true,
|
|
401
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 },
|
|
402
707
|
},
|
|
403
708
|
setup(props) {
|
|
404
709
|
const t = useText();
|
|
@@ -481,14 +786,14 @@ const SignUpForm = (0, vue_1.defineComponent)({
|
|
|
481
786
|
}
|
|
482
787
|
}
|
|
483
788
|
};
|
|
484
|
-
const textField = (name, labelKey, type, autocomplete) => (0, vue_1.h)('label', { key: name, class: cls('formField', 'atlas-field') }, [
|
|
485
|
-
(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) })),
|
|
486
791
|
(0, vue_1.h)('input', {
|
|
487
792
|
class: cls('formFieldInput', 'atlas-input'),
|
|
488
793
|
type,
|
|
489
794
|
name,
|
|
490
795
|
autocomplete,
|
|
491
|
-
required
|
|
796
|
+
required,
|
|
492
797
|
value: values[name] ?? '',
|
|
493
798
|
onInput: (e) => {
|
|
494
799
|
values[name] = e.target.value;
|
|
@@ -513,7 +818,8 @@ const SignUpForm = (0, vue_1.defineComponent)({
|
|
|
513
818
|
// The app can't deliver the verification email, so an email + password
|
|
514
819
|
// sign-up could never complete: say so up front instead of a form that
|
|
515
820
|
// fails on submit.
|
|
516
|
-
|
|
821
|
+
const passkey = props.passkey;
|
|
822
|
+
if (props.settings?.emailDelivery?.passwordSignUp === false && !(passkey && passkey.email !== 'required')) {
|
|
517
823
|
return (0, vue_1.h)('div', { class: cls('card', 'atlas-card') }, [
|
|
518
824
|
(0, vue_1.h)('h1', { class: cls('headerTitle', 'atlas-title') }, t('signUp.title')),
|
|
519
825
|
(0, vue_1.h)('p', { class: cls('formFieldError', 'atlas-error'), role: 'alert', 'data-atlas-email-unavailable': '' }, t('signUp.emailUnavailable')),
|
|
@@ -539,8 +845,13 @@ const SignUpForm = (0, vue_1.defineComponent)({
|
|
|
539
845
|
control(f),
|
|
540
846
|
...fieldErrors(f.key),
|
|
541
847
|
])),
|
|
542
|
-
|
|
543
|
-
|
|
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,
|
|
544
855
|
showLegal
|
|
545
856
|
? (0, vue_1.h)('label', { key: 'consent', class: cls('formField', 'atlas-consent') }, [
|
|
546
857
|
legal.required
|
|
@@ -568,11 +879,42 @@ const SignUpForm = (0, vue_1.defineComponent)({
|
|
|
568
879
|
disabled: props.busy || !consentOk,
|
|
569
880
|
type: 'submit',
|
|
570
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,
|
|
571
893
|
]),
|
|
572
894
|
]);
|
|
573
895
|
};
|
|
574
896
|
},
|
|
575
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
|
+
});
|
|
576
918
|
/**
|
|
577
919
|
* §10.2 `<SignUp/>` — creates an account. The first step is the create-account
|
|
578
920
|
* form, which POSTs `/v1/client/sign_ups`; from there the flow is server-driven
|
|
@@ -586,22 +928,41 @@ exports.SignUp = (0, vue_1.defineComponent)({
|
|
|
586
928
|
afterUrl: { type: String, default: undefined },
|
|
587
929
|
},
|
|
588
930
|
setup() {
|
|
589
|
-
const { state, submit, create, settings, signUpCaptcha } = (0, flow_1.useFlow)();
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
+
};
|
|
605
966
|
},
|
|
606
967
|
});
|
|
607
968
|
exports.UserButton = (0, vue_1.defineComponent)({
|
|
@@ -637,6 +998,46 @@ exports.UserButton = (0, vue_1.defineComponent)({
|
|
|
637
998
|
};
|
|
638
999
|
},
|
|
639
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
|
+
});
|
|
640
1041
|
exports.OrganizationSwitcher = (0, vue_1.defineComponent)({
|
|
641
1042
|
name: 'OrganizationSwitcher',
|
|
642
1043
|
setup() {
|
|
@@ -751,20 +1152,341 @@ exports.UserProfile = (0, vue_1.defineComponent)({
|
|
|
751
1152
|
});
|
|
752
1153
|
exports.OrganizationProfile = (0, vue_1.defineComponent)({
|
|
753
1154
|
name: 'OrganizationProfile',
|
|
754
|
-
|
|
1155
|
+
emits: ['exit'],
|
|
1156
|
+
setup(_props, { emit }) {
|
|
1157
|
+
const atlas = (0, context_1.useAtlas)();
|
|
755
1158
|
const { organization, membership } = (0, composables_1.useOrganization)();
|
|
1159
|
+
const { userId, has } = (0, composables_1.useAuth)();
|
|
756
1160
|
const t = useText();
|
|
757
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
|
+
};
|
|
758
1235
|
return () => {
|
|
759
1236
|
const org = organization.value;
|
|
760
|
-
|
|
1237
|
+
const st = state.value;
|
|
1238
|
+
if (!org || !controller || !st)
|
|
761
1239
|
return null;
|
|
762
|
-
|
|
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' }, [
|
|
763
1425
|
(0, vue_1.h)('h1', org.name),
|
|
764
1426
|
(0, vue_1.h)('p', `${t('organization.roleLabel')}: ${membership.value?.role ?? ''}`),
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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,
|
|
768
1490
|
]);
|
|
769
1491
|
};
|
|
770
1492
|
},
|