@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.
@@ -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
- return [field('code', 'mfa.codeLabel')];
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('signIn.submit'))
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
- if (!href)
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: true,
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
- if (props.settings?.emailDelivery?.passwordSignUp === false) {
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
- textField('email', 'signUp.emailLabel', 'email', 'email'),
589
- textField('password', 'signUp.passwordLabel', 'password', 'new-password'),
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
- return () => state.value.attempt
637
- ? (0, vue_1.h)(AttemptFlow, {
638
- attempt: state.value.attempt,
639
- busy: state.value.busy,
640
- errors: state.value.errors,
641
- sessionKept: state.value.sessionKept,
642
- titleKey: 'signUp.title',
643
- onSubmit: submit,
644
- })
645
- : (0, vue_1.h)(SignUpForm, {
646
- settings: settings.value,
647
- busy: state.value.busy,
648
- errors: state.value.errors,
649
- captcha: signUpCaptcha.needed ? signUpCaptcha : null,
650
- onSubmit: create,
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
- setup() {
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
- if (!org)
1237
+ const st = state.value;
1238
+ if (!org || !controller || !st)
808
1239
  return null;
809
- return (0, vue_1.h)('div', { class: cls('card', 'atlas-card') }, [
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
- // Management controls are gated on the ACTIVE org role, matching the
813
- // server's §9.2 rule rather than duplicating a looser one.
814
- (0, vue_1.h)(exports.Protect, { permission: 'org:sys_memberships:manage' }, () => (0, vue_1.h)('button', t('organization.invite'))),
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
  },