@atlasauth/vue 0.8.0 → 0.10.0

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