@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.
@@ -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
- 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
+ }
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('signIn.submit'))
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
- return (0, vue_1.h)(AttemptFlow, {
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: true,
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
- if (props.settings?.emailDelivery?.passwordSignUp === false) {
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
- textField('email', 'signUp.emailLabel', 'email', 'email'),
543
- 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,
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
- return () => state.value.attempt
591
- ? (0, vue_1.h)(AttemptFlow, {
592
- attempt: state.value.attempt,
593
- busy: state.value.busy,
594
- errors: state.value.errors,
595
- titleKey: 'signUp.title',
596
- onSubmit: submit,
597
- })
598
- : (0, vue_1.h)(SignUpForm, {
599
- settings: settings.value,
600
- busy: state.value.busy,
601
- errors: state.value.errors,
602
- captcha: signUpCaptcha.needed ? signUpCaptcha : null,
603
- onSubmit: create,
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
- setup() {
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
- if (!org)
1237
+ const st = state.value;
1238
+ if (!org || !controller || !st)
761
1239
  return null;
762
- 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' }, [
763
1425
  (0, vue_1.h)('h1', org.name),
764
1426
  (0, vue_1.h)('p', `${t('organization.roleLabel')}: ${membership.value?.role ?? ''}`),
765
- // Management controls are gated on the ACTIVE org role, matching the
766
- // server's §9.2 rule rather than duplicating a looser one.
767
- (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,
768
1490
  ]);
769
1491
  };
770
1492
  },