@n24q02m/mcp-core 1.19.0 → 1.20.0-beta.2

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.
Files changed (40) hide show
  1. package/README.md +91 -1
  2. package/build/auth/credential-form.d.ts +49 -5
  3. package/build/auth/credential-form.d.ts.map +1 -1
  4. package/build/auth/credential-form.js +948 -7
  5. package/build/auth/credential-form.js.map +1 -1
  6. package/build/auth/local-oauth-app.js +1 -1
  7. package/build/auth/local-oauth-app.js.map +1 -1
  8. package/build/auth/relay-login.js +1 -1
  9. package/build/auth/relay-login.js.map +1 -1
  10. package/build/cli/build-cli.d.ts +84 -0
  11. package/build/cli/build-cli.d.ts.map +1 -0
  12. package/build/cli/build-cli.js +268 -0
  13. package/build/cli/build-cli.js.map +1 -0
  14. package/build/cli/index.d.ts +2 -0
  15. package/build/cli/index.d.ts.map +1 -0
  16. package/build/cli/index.js +2 -0
  17. package/build/cli/index.js.map +1 -0
  18. package/build/index.d.ts +3 -1
  19. package/build/index.d.ts.map +1 -1
  20. package/build/index.js +7 -1
  21. package/build/index.js.map +1 -1
  22. package/build/schema/types.d.ts +25 -0
  23. package/build/schema/types.d.ts.map +1 -1
  24. package/build/storage/credential-store.d.ts +71 -0
  25. package/build/storage/credential-store.d.ts.map +1 -0
  26. package/build/storage/credential-store.js +91 -0
  27. package/build/storage/credential-store.js.map +1 -0
  28. package/build/storage/index.d.ts +2 -1
  29. package/build/storage/index.d.ts.map +1 -1
  30. package/build/storage/index.js +5 -1
  31. package/build/storage/index.js.map +1 -1
  32. package/build/storage/per-plugin-store.d.ts +8 -0
  33. package/build/storage/per-plugin-store.d.ts.map +1 -1
  34. package/build/storage/per-plugin-store.js +10 -0
  35. package/build/storage/per-plugin-store.js.map +1 -1
  36. package/build/storage/resolver.js +3 -3
  37. package/build/storage/resolver.js.map +1 -1
  38. package/build/transport/local-server.js +2 -2
  39. package/build/transport/local-server.js.map +1 -1
  40. package/package.json +1 -1
@@ -388,14 +388,86 @@ const FORM_SHELL_CSS = ` *, *::before, *::after {
388
388
  font-size: 0.8125rem;
389
389
  color: #9ca3af;
390
390
  }
391
+
392
+ :root {
393
+ color-scheme: light dark;
394
+ }
395
+
396
+ @media (prefers-color-scheme: light) {
397
+ body {
398
+ background-color: #f4f5f7;
399
+ color: #1f2937;
400
+ }
401
+ .card {
402
+ background-color: #ffffff;
403
+ border-color: #e5e7eb;
404
+ }
405
+ .server-name {
406
+ color: #111827;
407
+ }
408
+ .server-id,
409
+ .server-description,
410
+ .help-text,
411
+ .mc-badge,
412
+ .form-title,
413
+ .capabilities-title,
414
+ .capability-desc {
415
+ color: #6b7280;
416
+ }
417
+ .field-label,
418
+ .capability-label {
419
+ color: #374151;
420
+ }
421
+ .field-input {
422
+ background-color: #ffffff;
423
+ border-color: #d1d5db;
424
+ color: #1f2937;
425
+ }
426
+ .field-input::placeholder {
427
+ color: #9ca3af;
428
+ }
429
+ .field-input:disabled {
430
+ background-color: #f3f4f6;
431
+ }
432
+ .optional-badge {
433
+ color: #6b7280;
434
+ background-color: #f3f4f6;
435
+ border-color: #d1d5db;
436
+ }
437
+ .capability-item {
438
+ background-color: #f9fafb;
439
+ border-color: #e5e7eb;
440
+ }
441
+ .model-chain {
442
+ background: #f9fafb;
443
+ border-color: #d1d5db;
444
+ }
445
+ .mc-chip {
446
+ background: #eef1f6;
447
+ border-color: #d1d5db;
448
+ }
449
+ .mc-dropdown {
450
+ background: #ffffff;
451
+ border-color: #d1d5db;
452
+ }
453
+ .mc-dropdown label:hover,
454
+ .mc-dropdown label:focus-within {
455
+ background: #eef1f6;
456
+ }
457
+ }
391
458
  `;
392
459
  /**
393
460
  * Wrap `bodyHtml` in the shared dark-theme HTML shell.
394
461
  *
395
- * The shell provides `<!DOCTYPE html>`, `<head>` (charset, viewport, escaped
396
- * `<title>`, embedded `FORM_SHELL_CSS`) and a `<body>` whose only child is
397
- * `bodyHtml`. `bodyHtml` is inserted verbatim, so callers MUST pre-escape any
398
- * untrusted values they interpolate.
462
+ * The shell provides `<!DOCTYPE html>`, `<head>` (charset, viewport, a
463
+ * Content-Security-Policy meta, escaped `<title>`, embedded `FORM_SHELL_CSS`)
464
+ * and a `<body>` whose only child is `bodyHtml`. `bodyHtml` is inserted
465
+ * verbatim, so callers MUST pre-escape any untrusted values they interpolate.
466
+ *
467
+ * The CSP (`default-src 'none'; style-src 'unsafe-inline'; script-src
468
+ * 'unsafe-inline'; connect-src 'self'`) permits the page's own inline
469
+ * `<style>`/`<script>` and same-origin `fetch` submits while blocking any
470
+ * external resource load.
399
471
  *
400
472
  * `title` is HTML-escaped before being placed in `<title>`.
401
473
  *
@@ -412,6 +484,7 @@ export function renderFormShell(title, bodyHtml) {
412
484
  <head>
413
485
  <meta charset="UTF-8" />
414
486
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
487
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'" />
415
488
  <title>${safeTitle}</title>
416
489
  <style>
417
490
  ${FORM_SHELL_CSS} </style>
@@ -434,6 +507,7 @@ function renderField(field, value = '') {
434
507
  ? '<span class="required-badge" aria-hidden="true">Required</span>'
435
508
  : '<span class="optional-badge" aria-hidden="true">Optional</span>';
436
509
  const valueAttr = value ? ` value="${escapeHtml(value)}"` : '';
510
+ const patternAttr = field.validation ? ` pattern="${escapeHtml(field.validation)}"` : '';
437
511
  let helpHtml = '';
438
512
  let ariaDescribedby = '';
439
513
  if (helpText) {
@@ -460,7 +534,7 @@ function renderField(field, value = '') {
460
534
  autocomplete="off"
461
535
  autocorrect="off"
462
536
  autocapitalize="off"
463
- spellcheck="false"${valueAttr}${requiredAttr}${ariaDescribedby}
537
+ spellcheck="false"${valueAttr}${patternAttr}${requiredAttr}${ariaDescribedby}
464
538
  />
465
539
  ${helpHtml}
466
540
  </div>`;
@@ -480,6 +554,858 @@ function renderCapability(cap) {
480
554
  ${descHtml}
481
555
  </li>`;
482
556
  }
557
+ // ===========================================================================
558
+ // Schema-level tabs + dynamic card group (W4.1)
559
+ // ---------------------------------------------------------------------------
560
+ // Two OPT-IN capabilities that let servers declare richer credential UIs
561
+ // through the schema alone (no forked renderer):
562
+ // * `tabs` -> mutually-exclusive credential modes (e.g. telegram
563
+ // bot-token vs phone/OTP), only the active tab submits.
564
+ // * `cardGroup` -> a repeatable field group with Add/Remove (e.g. email
565
+ // multi-account), submitted as a JSON array.
566
+ // A schema that declares NEITHER key renders through the unchanged flat-field
567
+ // path in `renderCredentialForm` below, byte-for-byte identical to before.
568
+ // The feature CSS ships as a `<style>` block inside the body — only when the
569
+ // feature is used — so the shared `FORM_SHELL_CSS` (and the flat form) stay
570
+ // untouched. Kept in parity with core-py `credential_form.py`.
571
+ // ===========================================================================
572
+ // Tab CSS (scoped `.tabs`/`.tab`/`.tab-panel`); emitted in-body only for
573
+ // tabbed forms. Palette mirrors the shared shell so tabs blend into the card.
574
+ const TABS_CSS = ` <style>
575
+ .tabs {
576
+ display: flex;
577
+ gap: 0;
578
+ margin-bottom: 1.5rem;
579
+ border-bottom: 1px solid #2a2a2a;
580
+ }
581
+ .tab {
582
+ flex: 1;
583
+ padding: 0.75rem 1rem;
584
+ background: transparent;
585
+ border: none;
586
+ color: #9ca3af;
587
+ cursor: pointer;
588
+ font-size: 0.9rem;
589
+ font-weight: 500;
590
+ border-bottom: 2px solid transparent;
591
+ transition: color 0.15s ease, border-color 0.15s ease;
592
+ font-family: inherit;
593
+ }
594
+ .tab:not(:disabled):hover {
595
+ color: #ccc;
596
+ }
597
+ .tab:focus-visible {
598
+ outline: 2px solid #4a6fa5;
599
+ outline-offset: -2px;
600
+ border-radius: 4px;
601
+ }
602
+ .tab.active {
603
+ color: #fff;
604
+ border-bottom-color: #4a6fa5;
605
+ }
606
+ .tab:disabled {
607
+ cursor: not-allowed;
608
+ opacity: 0.5;
609
+ }
610
+ .tab-panel {
611
+ display: none;
612
+ }
613
+ .tab-panel.active {
614
+ display: block;
615
+ }
616
+ @media (prefers-color-scheme: light) {
617
+ .tabs {
618
+ border-bottom-color: #e5e7eb;
619
+ }
620
+ .tab {
621
+ color: #6b7280;
622
+ }
623
+ .tab:not(:disabled):hover {
624
+ color: #374151;
625
+ }
626
+ .tab.active {
627
+ color: #111827;
628
+ }
629
+ }
630
+ </style>
631
+ `;
632
+ // Card-group CSS (scoped `.card-group-*`); emitted in-body only for card
633
+ // forms. `.card-group-item` avoids the shell's `.card` name deliberately.
634
+ const CARD_GROUP_CSS = ` <style>
635
+ .card-group-item {
636
+ border: 1px solid #2a2a2a;
637
+ border-radius: 10px;
638
+ padding: 1rem;
639
+ margin-bottom: 0.875rem;
640
+ background-color: #121212;
641
+ }
642
+ .card-group-header {
643
+ display: flex;
644
+ justify-content: space-between;
645
+ align-items: center;
646
+ margin-bottom: 0.75rem;
647
+ }
648
+ .card-group-title {
649
+ font-size: 0.95rem;
650
+ font-weight: 600;
651
+ color: #ddd;
652
+ max-width: 300px;
653
+ white-space: nowrap;
654
+ overflow: hidden;
655
+ text-overflow: ellipsis;
656
+ }
657
+ .card-group-remove {
658
+ background: transparent;
659
+ color: #f87171;
660
+ border: 1px solid rgba(248, 113, 113, 0.3);
661
+ border-radius: 6px;
662
+ padding: 0.25rem 0.6rem;
663
+ cursor: pointer;
664
+ font-size: 0.75rem;
665
+ font-family: inherit;
666
+ }
667
+ .card-group-remove:hover:not(:disabled) {
668
+ background-color: rgba(248, 113, 113, 0.08);
669
+ }
670
+ .card-group-remove:focus-visible {
671
+ outline: 2px solid #f87171;
672
+ outline-offset: 2px;
673
+ }
674
+ .card-group-add {
675
+ width: 100%;
676
+ background-color: transparent;
677
+ color: #6c9bd2;
678
+ border: 1px dashed #3a5a8a;
679
+ border-radius: 8px;
680
+ padding: 0.625rem 1rem;
681
+ cursor: pointer;
682
+ font-size: 0.875rem;
683
+ margin-bottom: 1rem;
684
+ font-family: inherit;
685
+ transition: background-color 0.15s ease, border-color 0.15s ease;
686
+ }
687
+ .card-group-add:hover:not(:disabled) {
688
+ background-color: rgba(108, 155, 210, 0.08);
689
+ border-color: #4a6fa5;
690
+ }
691
+ .card-group-add:focus-visible {
692
+ outline: 2px solid #6c9bd2;
693
+ outline-offset: 2px;
694
+ }
695
+ .card-group-add:disabled {
696
+ opacity: 0.5;
697
+ cursor: not-allowed;
698
+ border-color: #2a3a4a;
699
+ }
700
+ @media (prefers-color-scheme: light) {
701
+ .card-group-item {
702
+ background-color: #f9fafb;
703
+ border-color: #e5e7eb;
704
+ }
705
+ .card-group-title {
706
+ color: #374151;
707
+ }
708
+ }
709
+ </style>
710
+ `;
711
+ // Multi-step (OTP / 2FA) step-input UI, shared by tab forms. Mirrors the flat
712
+ // form's `showStepInput`/`submitStep` semantics (same `/otp` POST, same
713
+ // redirect-follow on completion) so a tabbed server's phone->OTP->2FA chain
714
+ // behaves identically to the default form.
715
+ const STEP_UI_JS = `
716
+ function showStepInput(ns) {
717
+ if (form && form.style.display !== "none") { form.style.display = "none"; }
718
+ var tabsEl = document.querySelector(".tabs");
719
+ if (tabsEl) { tabsEl.style.display = "none"; }
720
+ var container = document.getElementById("step-container");
721
+ var promptEl, inputEl, buttonEl, errorEl;
722
+ if (container) {
723
+ promptEl = document.getElementById("step-prompt");
724
+ inputEl = document.getElementById("step-input");
725
+ buttonEl = document.getElementById("step-submit");
726
+ errorEl = document.getElementById("step-error");
727
+ errorEl.style.display = "none"; errorEl.textContent = "";
728
+ inputEl.value = ""; inputEl.disabled = false;
729
+ buttonEl.disabled = false; buttonEl.removeAttribute("aria-busy"); buttonEl.textContent = "Verify";
730
+ } else {
731
+ var card = form.parentNode;
732
+ container = document.createElement("div"); container.id = "step-container";
733
+ promptEl = document.createElement("label"); promptEl.id = "step-prompt";
734
+ promptEl.setAttribute("for", "step-input"); promptEl.className = "form-title";
735
+ container.appendChild(promptEl);
736
+ var fieldGroup = document.createElement("div"); fieldGroup.className = "field-group";
737
+ inputEl = document.createElement("input"); inputEl.id = "step-input"; inputEl.className = "field-input";
738
+ inputEl.setAttribute("autocomplete", "off"); inputEl.setAttribute("autocorrect", "off");
739
+ inputEl.setAttribute("autocapitalize", "off"); inputEl.setAttribute("spellcheck", "false");
740
+ fieldGroup.appendChild(inputEl); container.appendChild(fieldGroup);
741
+ buttonEl = document.createElement("button"); buttonEl.type = "button"; buttonEl.id = "step-submit";
742
+ buttonEl.className = "submit-btn"; buttonEl.textContent = "Verify"; container.appendChild(buttonEl);
743
+ errorEl = document.createElement("div"); errorEl.id = "step-error"; errorEl.className = "status-box error";
744
+ errorEl.setAttribute("role", "alert"); errorEl.setAttribute("aria-live", "polite"); errorEl.setAttribute("aria-atomic", "true"); errorEl.style.display = "none"; container.appendChild(errorEl);
745
+ card.appendChild(container);
746
+ buttonEl.addEventListener("click", function () { submitStep(); });
747
+ inputEl.addEventListener("keydown", function (evt) { if (evt.key === "Enter") { evt.preventDefault(); submitStep(); } });
748
+ inputEl.addEventListener("input", function () { inputEl.removeAttribute("aria-invalid"); inputEl.removeAttribute("aria-errormessage"); errorEl.style.display = "none"; });
749
+ }
750
+ promptEl.textContent = ns.text || "";
751
+ inputEl.setAttribute("type", ns.input_type || "text");
752
+ inputEl.setAttribute("placeholder", ns.placeholder || "");
753
+ inputEl.dataset.field = ns.field || "value";
754
+ inputEl.focus();
755
+ }
756
+ function submitStep() {
757
+ var inputEl = document.getElementById("step-input");
758
+ var buttonEl = document.getElementById("step-submit");
759
+ var errorEl = document.getElementById("step-error");
760
+ var fieldName = inputEl.dataset.field || "value";
761
+ var value = inputEl.value;
762
+ inputEl.removeAttribute("aria-invalid");
763
+ if (value.trim() === "") {
764
+ errorEl.textContent = "Please enter a value."; errorEl.style.display = "block";
765
+ inputEl.setAttribute("aria-invalid", "true"); inputEl.setAttribute("aria-errormessage", "step-error");
766
+ inputEl.focus(); return;
767
+ }
768
+ errorEl.style.display = "none"; errorEl.textContent = "";
769
+ buttonEl.disabled = true; buttonEl.textContent = "Verifying..."; buttonEl.setAttribute("aria-busy", "true");
770
+ inputEl.disabled = true;
771
+ var body = {}; body[fieldName] = value;
772
+ fetch(otpUrl(), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
773
+ .then(function (response) { return response.json().then(function (data) {
774
+ if (data.ok) {
775
+ if (data.next_step && (data.next_step.type === "otp_required" || data.next_step.type === "password_required")) {
776
+ showStepInput(data.next_step);
777
+ } else if (typeof data.redirect_url === "string" && data.redirect_url.length > 0) {
778
+ var c = document.getElementById("step-container"); while (c.firstChild) { c.removeChild(c.firstChild); }
779
+ var done = document.createElement("div"); done.className = "status-box success"; done.style.display = "block";
780
+ done.setAttribute("role", "alert"); done.setAttribute("aria-live", "polite"); done.setAttribute("aria-atomic", "true"); done.textContent = "Setup complete! Redirecting..."; c.appendChild(done);
781
+ window.location.replace(data.redirect_url);
782
+ } else {
783
+ var c2 = document.getElementById("step-container"); while (c2.firstChild) { c2.removeChild(c2.firstChild); }
784
+ var done2 = document.createElement("div"); done2.className = "status-box success"; done2.style.display = "block";
785
+ done2.setAttribute("role", "alert"); done2.setAttribute("aria-live", "polite"); done2.setAttribute("aria-atomic", "true"); done2.textContent = "Setup complete! You can close this tab."; c2.appendChild(done2);
786
+ }
787
+ } else {
788
+ errorEl.textContent = data.error || data.error_description || "Verification failed."; errorEl.style.display = "block";
789
+ inputEl.disabled = false; inputEl.setAttribute("aria-invalid", "true"); inputEl.setAttribute("aria-errormessage", "step-error");
790
+ buttonEl.disabled = false; buttonEl.textContent = "Verify"; buttonEl.removeAttribute("aria-busy"); inputEl.focus();
791
+ }
792
+ }); })
793
+ .catch(function (err) {
794
+ errorEl.textContent = "Network error: " + err.message; errorEl.style.display = "block";
795
+ inputEl.disabled = false; inputEl.setAttribute("aria-invalid", "true"); inputEl.setAttribute("aria-errormessage", "step-error");
796
+ buttonEl.disabled = false; buttonEl.textContent = "Verify"; buttonEl.removeAttribute("aria-busy"); inputEl.focus();
797
+ });
798
+ }
799
+ `;
800
+ // Tab form behaviour: tab switching (click + ARIA-tablist arrow keys), then a
801
+ // submit that collects ONLY the active panel's fields. `__SUBMIT_URL__` and
802
+ // `__INITIAL_TAB__` are substituted at render time; `STEP_UI_JS` is spliced in
803
+ // for OTP/2FA support.
804
+ const TABS_SCRIPT = ` <script>
805
+ (function () {
806
+ var form = document.getElementById("credential-form");
807
+ var submitBtn = document.getElementById("submit-btn");
808
+ var statusBox = document.getElementById("status-box");
809
+ var submitUrl = "__SUBMIT_URL__";
810
+ var activeTab = "__INITIAL_TAB__";
811
+ var tabs = document.querySelectorAll(".tab");
812
+ var tabsArray = Array.prototype.slice.call(tabs);
813
+ var pendingRedirectUrl = null;
814
+
815
+ function showStatus(type, message) {
816
+ statusBox.className = "status-box " + type;
817
+ statusBox.textContent = message;
818
+ statusBox.style.display = "block";
819
+ }
820
+ function otpUrl() {
821
+ return submitUrl.replace(/\\/authorize.*/, "/otp");
822
+ }
823
+ ${STEP_UI_JS}
824
+ tabs.forEach(function (tab, index) {
825
+ tab.addEventListener("click", function () {
826
+ if (tab.disabled) { return; }
827
+ activeTab = tab.dataset.tab;
828
+ tabs.forEach(function (t) {
829
+ t.classList.remove("active");
830
+ t.setAttribute("aria-selected", "false");
831
+ t.setAttribute("tabindex", "-1");
832
+ });
833
+ tab.classList.add("active");
834
+ tab.setAttribute("aria-selected", "true");
835
+ tab.setAttribute("tabindex", "0");
836
+ document.querySelectorAll(".tab-panel").forEach(function (p) { p.classList.remove("active"); });
837
+ var panel = document.querySelector('.tab-panel[data-panel="' + activeTab + '"]');
838
+ if (panel) { panel.classList.add("active"); }
839
+ statusBox.style.display = "none";
840
+ statusBox.textContent = "";
841
+ form.querySelectorAll(".field-input").forEach(function (i) { i.removeAttribute("aria-invalid"); });
842
+ });
843
+ tab.addEventListener("keydown", function (e) {
844
+ var targetIndex = -1;
845
+ if (e.key === "ArrowRight") { targetIndex = index + 1; if (targetIndex >= tabsArray.length) { targetIndex = 0; } }
846
+ else if (e.key === "ArrowLeft") { targetIndex = index - 1; if (targetIndex < 0) { targetIndex = tabsArray.length - 1; } }
847
+ if (targetIndex !== -1) { e.preventDefault(); tabsArray[targetIndex].focus(); tabsArray[targetIndex].click(); }
848
+ });
849
+ });
850
+
851
+ form.addEventListener("input", function (event) {
852
+ if (event.target.classList.contains("field-input")) {
853
+ event.target.removeAttribute("aria-invalid");
854
+ event.target.removeAttribute("aria-errormessage");
855
+ statusBox.style.display = "none";
856
+ }
857
+ });
858
+
859
+ form.addEventListener("submit", function (event) {
860
+ event.preventDefault();
861
+ var activePanel = document.querySelector(".tab-panel.active");
862
+ var inputs = activePanel ? activePanel.querySelectorAll(".field-input") : [];
863
+ var payload = {};
864
+ var valid = true;
865
+ var firstInvalid = null;
866
+ inputs.forEach(function (input) {
867
+ if (input.hasAttribute("required") && input.value.trim() === "") {
868
+ valid = false;
869
+ input.setAttribute("aria-invalid", "true");
870
+ input.setAttribute("aria-errormessage", "status-box");
871
+ if (!firstInvalid) { firstInvalid = input; }
872
+ } else {
873
+ input.removeAttribute("aria-invalid");
874
+ input.removeAttribute("aria-errormessage");
875
+ payload[input.name] = input.value;
876
+ }
877
+ });
878
+ if (!valid) {
879
+ showStatus("error", "Please fill in all required fields.");
880
+ if (firstInvalid) { firstInvalid.focus(); }
881
+ return;
882
+ }
883
+ submitBtn.disabled = true;
884
+ submitBtn.textContent = "Connecting...";
885
+ submitBtn.setAttribute("aria-busy", "true");
886
+ statusBox.style.display = "none";
887
+ tabs.forEach(function (t) { t.disabled = true; });
888
+ function reenable() {
889
+ submitBtn.disabled = false;
890
+ submitBtn.textContent = "Connect";
891
+ submitBtn.removeAttribute("aria-busy");
892
+ form.querySelectorAll(".field-input").forEach(function (i) { i.disabled = false; });
893
+ tabs.forEach(function (t) { t.disabled = false; });
894
+ }
895
+ function lockConnected() {
896
+ form.querySelectorAll(".field-input").forEach(function (i) { i.disabled = true; });
897
+ submitBtn.disabled = true;
898
+ submitBtn.removeAttribute("aria-busy");
899
+ submitBtn.textContent = "Connected";
900
+ }
901
+ fetch(submitUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) })
902
+ .then(function (response) { return response.json().then(function (data) {
903
+ if (data.ok) {
904
+ if (typeof data.redirect_url === "string" && data.redirect_url.length > 0) { pendingRedirectUrl = data.redirect_url; }
905
+ if (data.next_step && (data.next_step.type === "otp_required" || data.next_step.type === "password_required")) {
906
+ statusBox.style.display = "none";
907
+ showStepInput(data.next_step);
908
+ } else if (data.next_step && data.next_step.type === "info") {
909
+ lockConnected();
910
+ showStatus("success", data.next_step.message || "Setup saved. Additional steps may be required.");
911
+ } else if (pendingRedirectUrl) {
912
+ lockConnected();
913
+ showStatus("success", "Credentials saved. Redirecting...");
914
+ window.location.replace(pendingRedirectUrl);
915
+ } else {
916
+ lockConnected();
917
+ showStatus("success", data.message || "Connected successfully. You can close this window.");
918
+ }
919
+ } else {
920
+ showStatus("error", data.error || data.error_description || "Request failed.");
921
+ reenable();
922
+ }
923
+ }); })
924
+ .catch(function (err) {
925
+ showStatus("error", "Network error: " + err.message);
926
+ reenable();
927
+ });
928
+ });
929
+ })();
930
+ </script>`;
931
+ // Card-group behaviour: a JS builder that clones the declared fields per card,
932
+ // Add/Remove with focus management, and a submit that serialises every card into
933
+ // a JSON array under `__GROUP_KEY__`. All DOM is built with
934
+ // createElement/textContent/setAttribute (no innerHTML with user values).
935
+ const CARD_GROUP_SCRIPT = ` <script>
936
+ (function () {
937
+ var submitUrl = "__SUBMIT_URL__";
938
+ var CARD_FIELDS = __CARD_FIELDS__;
939
+ var GROUP_KEY = "__GROUP_KEY__";
940
+ var TITLE_FIELD = "__TITLE_FIELD__";
941
+ var ITEM_LABEL = "__ITEM_LABEL__";
942
+ var MIN_ITEMS = __MIN_ITEMS__;
943
+
944
+ var container = document.getElementById("card-group-container");
945
+ var addBtn = document.getElementById("card-group-add");
946
+ var form = document.getElementById("credential-form");
947
+ var submitBtn = document.getElementById("submit-btn");
948
+ var statusBox = document.getElementById("status-box");
949
+ var formFieldset = document.getElementById("form-fieldset");
950
+ var uid = 0;
951
+ var pendingRedirectUrl = null;
952
+
953
+ function showStatus(type, message) {
954
+ statusBox.className = "status-box " + type;
955
+ statusBox.textContent = message;
956
+ statusBox.style.display = "block";
957
+ }
958
+ function safeRedirect(url) {
959
+ try {
960
+ var parsed = new URL(url, window.location.origin);
961
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
962
+ window.location.replace(parsed.href);
963
+ return true;
964
+ }
965
+ } catch (e) { /* fail safe */ }
966
+ return false;
967
+ }
968
+
969
+ function buildField(spec, cardUid) {
970
+ var group = document.createElement("div");
971
+ group.className = "field-group";
972
+ var fid = "field-" + GROUP_KEY + "-" + spec.key + "-" + cardUid;
973
+
974
+ var label = document.createElement("label");
975
+ label.className = "field-label";
976
+ label.setAttribute("for", fid);
977
+ label.textContent = spec.label || "";
978
+ var badge = document.createElement("span");
979
+ badge.setAttribute("aria-hidden", "true");
980
+ if (spec.required) { badge.className = "required-badge"; badge.textContent = "Required"; }
981
+ else { badge.className = "optional-badge"; badge.textContent = "Optional"; }
982
+ label.appendChild(document.createTextNode(" "));
983
+ label.appendChild(badge);
984
+ group.appendChild(label);
985
+
986
+ var input = document.createElement("input");
987
+ input.id = fid;
988
+ input.className = "field-input";
989
+ input.setAttribute("type", spec.type || "text");
990
+ input.setAttribute("name", GROUP_KEY + "[" + cardUid + "]." + spec.key);
991
+ input.dataset.field = spec.key;
992
+ input.setAttribute("autocomplete", "off");
993
+ input.setAttribute("autocorrect", "off");
994
+ input.setAttribute("autocapitalize", "off");
995
+ input.setAttribute("spellcheck", "false");
996
+ if (spec.placeholder) { input.setAttribute("placeholder", spec.placeholder); }
997
+ if (spec.required) { input.setAttribute("required", "required"); }
998
+ group.appendChild(input);
999
+
1000
+ if (spec.helpText) {
1001
+ var help = document.createElement("p");
1002
+ help.className = "help-text";
1003
+ help.id = "help-" + GROUP_KEY + "-" + spec.key + "-" + cardUid;
1004
+ if (spec.helpUrl) {
1005
+ var a = document.createElement("a");
1006
+ a.setAttribute("href", spec.helpUrl);
1007
+ a.setAttribute("target", "_blank");
1008
+ a.setAttribute("rel", "noopener noreferrer");
1009
+ a.textContent = spec.helpText;
1010
+ help.appendChild(a);
1011
+ } else {
1012
+ help.textContent = spec.helpText;
1013
+ }
1014
+ input.setAttribute("aria-describedby", help.id);
1015
+ group.appendChild(help);
1016
+ }
1017
+
1018
+ input.addEventListener("input", function () {
1019
+ if (input.hasAttribute("aria-invalid")) { input.removeAttribute("aria-invalid"); }
1020
+ });
1021
+ return input;
1022
+ }
1023
+
1024
+ function updateTitles() {
1025
+ var cards = container.querySelectorAll(".card-group-item");
1026
+ for (var i = 0; i < cards.length; i++) {
1027
+ var titleEl = cards[i].querySelector(".card-group-title");
1028
+ var titleInput = TITLE_FIELD ? cards[i].querySelector('input[data-field="' + TITLE_FIELD + '"]') : null;
1029
+ var titleVal = titleInput && titleInput.value ? titleInput.value.trim() : "";
1030
+ var titleStr = titleVal ? titleVal : (ITEM_LABEL + " " + (i + 1));
1031
+ if (titleEl) { titleEl.textContent = titleStr; titleEl.title = titleStr; }
1032
+ var removeBtn = cards[i].querySelector(".card-group-remove");
1033
+ if (removeBtn) {
1034
+ removeBtn.style.display = cards.length > MIN_ITEMS ? "" : "none";
1035
+ removeBtn.setAttribute("aria-label", "Remove " + titleStr);
1036
+ }
1037
+ }
1038
+ }
1039
+
1040
+ function createCard() {
1041
+ var cardUid = uid++;
1042
+ var card = document.createElement("div");
1043
+ card.className = "card-group-item";
1044
+ card.dataset.uid = String(cardUid);
1045
+ card.setAttribute("role", "group");
1046
+ var titleId = "card-group-title-" + cardUid;
1047
+ card.setAttribute("aria-labelledby", titleId);
1048
+
1049
+ var header = document.createElement("div");
1050
+ header.className = "card-group-header";
1051
+ var title = document.createElement("h3");
1052
+ title.id = titleId;
1053
+ title.className = "card-group-title";
1054
+ title.textContent = ITEM_LABEL;
1055
+ header.appendChild(title);
1056
+
1057
+ var removeBtn = document.createElement("button");
1058
+ removeBtn.type = "button";
1059
+ removeBtn.className = "card-group-remove";
1060
+ removeBtn.textContent = "Remove";
1061
+ removeBtn.addEventListener("click", function () {
1062
+ var inputs = card.querySelectorAll("input");
1063
+ var hasData = false;
1064
+ for (var i = 0; i < inputs.length; i++) { if (inputs[i].value.trim() !== "") { hasData = true; break; } }
1065
+ if (hasData && !window.confirm("This " + ITEM_LABEL.toLowerCase() + " has unsaved data. Remove it?")) { return; }
1066
+ var prev = card.previousElementSibling;
1067
+ var next = card.nextElementSibling;
1068
+ var focusTarget = (prev && prev.classList && prev.classList.contains("card-group-item")) ? prev :
1069
+ (next && next.classList && next.classList.contains("card-group-item")) ? next : null;
1070
+ card.remove();
1071
+ updateTitles();
1072
+ if (focusTarget) {
1073
+ var fi = focusTarget.querySelector("input");
1074
+ if (fi) { fi.focus(); return; }
1075
+ }
1076
+ if (addBtn) { addBtn.focus(); }
1077
+ });
1078
+ header.appendChild(removeBtn);
1079
+ card.appendChild(header);
1080
+
1081
+ for (var f = 0; f < CARD_FIELDS.length; f++) {
1082
+ var input = buildField(CARD_FIELDS[f], cardUid);
1083
+ card.appendChild(input.parentNode);
1084
+ if (CARD_FIELDS[f].key === TITLE_FIELD) {
1085
+ input.addEventListener("input", updateTitles);
1086
+ }
1087
+ }
1088
+ return card;
1089
+ }
1090
+
1091
+ function collectCards() {
1092
+ var cards = container.querySelectorAll(".card-group-item");
1093
+ var arr = [];
1094
+ for (var i = 0; i < cards.length; i++) {
1095
+ var inputs = cards[i].querySelectorAll(".field-input");
1096
+ var obj = {};
1097
+ var hasAny = false;
1098
+ for (var j = 0; j < inputs.length; j++) {
1099
+ obj[inputs[j].dataset.field] = inputs[j].value;
1100
+ if (inputs[j].value.trim() !== "") { hasAny = true; }
1101
+ }
1102
+ if (hasAny) { arr.push(obj); }
1103
+ }
1104
+ return arr;
1105
+ }
1106
+
1107
+ function renderOAuthDeviceCode(nextStep) {
1108
+ statusBox.className = "status-box info";
1109
+ statusBox.style.display = "block";
1110
+ while (statusBox.firstChild) { statusBox.removeChild(statusBox.firstChild); }
1111
+ var strong = document.createElement("strong");
1112
+ strong.textContent = "Finish sign-in";
1113
+ statusBox.appendChild(strong);
1114
+ statusBox.appendChild(document.createElement("br"));
1115
+ statusBox.appendChild(document.createElement("br"));
1116
+ statusBox.appendChild(document.createTextNode("Visit:"));
1117
+ statusBox.appendChild(document.createElement("br"));
1118
+ var link = document.createElement("a");
1119
+ link.setAttribute("href", nextStep.verification_url);
1120
+ link.setAttribute("target", "_blank");
1121
+ link.setAttribute("rel", "noopener noreferrer");
1122
+ link.textContent = nextStep.verification_url;
1123
+ statusBox.appendChild(link);
1124
+ statusBox.appendChild(document.createElement("br"));
1125
+ statusBox.appendChild(document.createElement("br"));
1126
+ statusBox.appendChild(document.createTextNode("Enter code: "));
1127
+ var codeEl = document.createElement("strong");
1128
+ codeEl.style.fontSize = "1.2em";
1129
+ codeEl.style.letterSpacing = "0.1em";
1130
+ codeEl.textContent = nextStep.user_code;
1131
+ statusBox.appendChild(codeEl);
1132
+ statusBox.appendChild(document.createElement("br"));
1133
+ statusBox.appendChild(document.createElement("br"));
1134
+ var waiting = document.createElement("span");
1135
+ waiting.id = "device-waiting";
1136
+ waiting.setAttribute("role", "alert");
1137
+ waiting.style.color = "#9ca3af";
1138
+ waiting.textContent = "Waiting for authorization...";
1139
+ statusBox.appendChild(waiting);
1140
+ var statusUrl = submitUrl.replace(/\\/authorize.*/, "/setup-status");
1141
+ var pollId = setInterval(function () {
1142
+ fetch(statusUrl)
1143
+ .then(function (r) { return r.json(); })
1144
+ .then(function (s) {
1145
+ if (s && s.outlook === "complete") {
1146
+ clearInterval(pollId);
1147
+ statusBox.className = "status-box success";
1148
+ while (statusBox.firstChild) { statusBox.removeChild(statusBox.firstChild); }
1149
+ var done = document.createElement("strong");
1150
+ done.textContent = "Setup complete!";
1151
+ statusBox.appendChild(done);
1152
+ submitBtn.textContent = "Connected";
1153
+ if (typeof pendingRedirectUrl === "string" && pendingRedirectUrl.length > 0) {
1154
+ statusBox.appendChild(document.createElement("br"));
1155
+ statusBox.appendChild(document.createTextNode("Redirecting..."));
1156
+ safeRedirect(pendingRedirectUrl);
1157
+ } else {
1158
+ statusBox.appendChild(document.createElement("br"));
1159
+ statusBox.appendChild(document.createTextNode("You can close this tab."));
1160
+ }
1161
+ } else if (s && typeof s.outlook === "string" && s.outlook.indexOf("error:") === 0) {
1162
+ clearInterval(pollId);
1163
+ var w = document.getElementById("device-waiting");
1164
+ if (w) { w.style.color = "#ff453a"; w.textContent = "Authorization failed: " + s.outlook.slice(6) + ". Please retry setup."; }
1165
+ }
1166
+ })
1167
+ .catch(function () {});
1168
+ }, 3000);
1169
+ }
1170
+
1171
+ container.appendChild(createCard());
1172
+ for (var s = 1; s < MIN_ITEMS; s++) { container.appendChild(createCard()); }
1173
+ updateTitles();
1174
+
1175
+ addBtn.addEventListener("click", function () {
1176
+ var newCard = createCard();
1177
+ container.appendChild(newCard);
1178
+ updateTitles();
1179
+ var fi = newCard.querySelector("input");
1180
+ if (fi) { fi.focus(); }
1181
+ });
1182
+
1183
+ form.addEventListener("submit", function (evt) {
1184
+ evt.preventDefault();
1185
+ statusBox.style.display = "none";
1186
+ if (!form.checkValidity()) {
1187
+ var firstInvalid = form.querySelector(":invalid");
1188
+ if (firstInvalid) {
1189
+ firstInvalid.setAttribute("aria-invalid", "true");
1190
+ firstInvalid.focus();
1191
+ }
1192
+ return;
1193
+ }
1194
+ var items = collectCards();
1195
+ if (items.length === 0) {
1196
+ showStatus("error", "Please add at least one " + ITEM_LABEL.toLowerCase() + ".");
1197
+ return;
1198
+ }
1199
+ var payload = {};
1200
+ payload[GROUP_KEY] = items;
1201
+
1202
+ formFieldset.disabled = true;
1203
+ submitBtn.setAttribute("aria-busy", "true");
1204
+ submitBtn.textContent = "Connecting...";
1205
+
1206
+ fetch(submitUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) })
1207
+ .then(function (resp) { return resp.json().then(function (data) {
1208
+ if (!data.ok) {
1209
+ showStatus("error", data.error || data.error_description || "Request failed.");
1210
+ formFieldset.disabled = false;
1211
+ submitBtn.removeAttribute("aria-busy");
1212
+ submitBtn.textContent = "Connect";
1213
+ submitBtn.focus();
1214
+ return;
1215
+ }
1216
+ if (typeof data.redirect_url === "string" && data.redirect_url.length > 0) { pendingRedirectUrl = data.redirect_url; }
1217
+ if (data.next_step && data.next_step.type === "oauth_device_code") {
1218
+ submitBtn.textContent = "Awaiting authorization...";
1219
+ submitBtn.removeAttribute("aria-busy");
1220
+ renderOAuthDeviceCode(data.next_step);
1221
+ return;
1222
+ }
1223
+ if (pendingRedirectUrl) {
1224
+ showStatus("success", "Credentials saved. Redirecting...");
1225
+ submitBtn.textContent = "Connected";
1226
+ submitBtn.removeAttribute("aria-busy");
1227
+ if (!safeRedirect(pendingRedirectUrl)) {
1228
+ showStatus("error", "Setup complete, but refused to redirect to unsafe URL.");
1229
+ }
1230
+ return;
1231
+ }
1232
+ showStatus("success", data.message || "Setup complete! You can close this tab.");
1233
+ submitBtn.textContent = "Connected";
1234
+ submitBtn.removeAttribute("aria-busy");
1235
+ }); })
1236
+ .catch(function (err) {
1237
+ showStatus("error", "Network error: " + err.message);
1238
+ formFieldset.disabled = false;
1239
+ submitBtn.removeAttribute("aria-busy");
1240
+ submitBtn.textContent = "Connect";
1241
+ submitBtn.focus();
1242
+ });
1243
+ });
1244
+ })();
1245
+ </script>`;
1246
+ /** Escape a string for safe embedding inside a double-quoted JS literal. */
1247
+ function jsString(value) {
1248
+ return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('<', '\\u003c');
1249
+ }
1250
+ /**
1251
+ * Return the id of the tab that should render active on load. Defaults to the
1252
+ * first tab; a valid `initialTab` hint wins, an unknown hint falls back.
1253
+ */
1254
+ function resolveActiveTab(tabs, initialTab) {
1255
+ const ids = tabs.map((t) => String(t.id ?? ''));
1256
+ if (initialTab !== undefined && ids.includes(initialTab)) {
1257
+ return initialTab;
1258
+ }
1259
+ return ids.length > 0 ? ids[0] : '';
1260
+ }
1261
+ /**
1262
+ * Optional workspace-username field shared by the flat + feature forms. Carries
1263
+ * the `.field-input` class so the form collector picks it into the POST as
1264
+ * `__sub_username`. Optional, so it never blocks submit.
1265
+ */
1266
+ function usernameFieldHtml() {
1267
+ return ('<div class="field-group">' +
1268
+ '<label for="field-__sub_username" class="field-label">Workspace / username' +
1269
+ ' <span class="optional-badge" aria-hidden="true">Optional</span></label>' +
1270
+ '<input id="field-__sub_username" type="text" name="__sub_username"' +
1271
+ ' class="field-input" placeholder="e.g. alice"' +
1272
+ ' autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />' +
1273
+ '<p class="help-text">Leave blank for a one-off session. Set the same value on' +
1274
+ ' every device to keep your saved data (one shared bucket per username).</p>' +
1275
+ '</div>');
1276
+ }
1277
+ function renderCapabilitiesSection(capabilityInfo) {
1278
+ if (capabilityInfo.length === 0) {
1279
+ return '';
1280
+ }
1281
+ const itemsHtml = capabilityInfo.map(renderCapability).join('');
1282
+ return `
1283
+ <section class="capabilities-section" aria-labelledby="capabilities-title">
1284
+ <h2 class="capabilities-title" id="capabilities-title">Capabilities Requested</h2>
1285
+ <ul class="capabilities-list">${itemsHtml}
1286
+ </ul>
1287
+ </section>`;
1288
+ }
1289
+ /**
1290
+ * Render a credential form whose fields are split across mutually-exclusive
1291
+ * tabs. Only the active tab's fields are collected on submit. Multi-step
1292
+ * (OTP / 2FA) chaining works exactly as the flat form.
1293
+ */
1294
+ function renderTabbedCredentialForm(schema, options) {
1295
+ const displayName = escapeHtml(schema.displayName ?? schema.server ?? 'Configuration');
1296
+ const server = escapeHtml(schema.server ?? '');
1297
+ const description = escapeHtml(schema.description ?? '');
1298
+ const title = options.pageTitle !== undefined ? escapeHtml(options.pageTitle) : displayName;
1299
+ const submitUrlEscaped = escapeHtml(options.submitUrl);
1300
+ const prefill = options.prefill ?? {};
1301
+ const tabs = schema.tabs ?? [];
1302
+ const activeId = resolveActiveTab(tabs, options.initialTab);
1303
+ const tabButtons = [];
1304
+ const tabPanels = [];
1305
+ for (const tab of tabs) {
1306
+ const tid = escapeHtml(tab.id ?? '');
1307
+ const label = escapeHtml(tab.label ?? '');
1308
+ const isActive = (tab.id ?? '') === activeId;
1309
+ const activeCls = isActive ? ' active' : '';
1310
+ const ariaSelected = isActive ? 'true' : 'false';
1311
+ const tabindex = isActive ? '0' : '-1';
1312
+ tabButtons.push(`<button type="button" id="tab-${tid}" class="tab${activeCls}" data-tab="${tid}"` +
1313
+ ` role="tab" aria-selected="${ariaSelected}" tabindex="${tabindex}"` +
1314
+ ` aria-controls="panel-${tid}">${label}</button>`);
1315
+ const panelFields = (tab.fields ?? []).map((f) => renderField(f, prefill[f.key] ?? '')).join('');
1316
+ tabPanels.push(`<div id="panel-${tid}" class="tab-panel${activeCls}" data-panel="${tid}"` +
1317
+ ` role="tabpanel" aria-labelledby="tab-${tid}">${panelFields}\n </div>`);
1318
+ }
1319
+ const tabsHtml = tabButtons.join('\n ');
1320
+ const panelsHtml = tabPanels.join('\n ');
1321
+ const usernameHtml = options.includeUsernameField ? usernameFieldHtml() : '';
1322
+ const capabilitiesHtml = renderCapabilitiesSection(schema.capabilityInfo ?? []);
1323
+ const descriptionHtml = description ? `<p class="server-description" id="server-desc">${description}</p>` : '';
1324
+ const script = TABS_SCRIPT.replaceAll('__SUBMIT_URL__', submitUrlEscaped).replaceAll('__INITIAL_TAB__', activeId);
1325
+ const bodyHtml = `${TABS_CSS} <div class="container">
1326
+ <div class="card">
1327
+ <div class="server-header">
1328
+ <h1 class="server-name">${displayName}</h1>
1329
+ <div class="server-id">${server}</div>
1330
+ ${descriptionHtml}
1331
+ </div>
1332
+
1333
+ <div class="tabs" role="tablist" aria-label="Credential mode">
1334
+ ${tabsHtml}
1335
+ </div>
1336
+
1337
+ <form id="credential-form" novalidate>
1338
+ ${usernameHtml}${panelsHtml}
1339
+
1340
+ <button type="submit" class="submit-btn" id="submit-btn">Connect</button>
1341
+
1342
+ <div class="status-box" id="status-box" role="alert" aria-live="polite" aria-atomic="true"></div>
1343
+ </form>
1344
+ </div>
1345
+ ${capabilitiesHtml}
1346
+ </div>
1347
+ ${script}`;
1348
+ return renderFormShell(title, bodyHtml);
1349
+ }
1350
+ /**
1351
+ * Render a credential form built around one repeatable card group. The group's
1352
+ * fields are cloned per card (Add/Remove) and submitted as a JSON array under
1353
+ * the group `key`. An Outlook-style device-code follow-up is supported.
1354
+ */
1355
+ function renderCardGroupCredentialForm(schema, options) {
1356
+ const displayName = escapeHtml(schema.displayName ?? schema.server ?? 'Configuration');
1357
+ const server = escapeHtml(schema.server ?? '');
1358
+ const description = escapeHtml(schema.description ?? '');
1359
+ const title = options.pageTitle !== undefined ? escapeHtml(options.pageTitle) : displayName;
1360
+ const submitUrlEscaped = escapeHtml(options.submitUrl);
1361
+ const group = schema.cardGroup;
1362
+ const groupKey = String(group.key ?? 'items');
1363
+ const itemLabel = String(group.itemLabel ?? 'Item');
1364
+ const addLabel = escapeHtml(group.addButtonLabel ?? '+ Add');
1365
+ const minItems = Number(group.minItems ?? 1);
1366
+ const titleField = String(group.titleField ?? '');
1367
+ const groupHeading = escapeHtml(group.heading ?? `${itemLabel}s`);
1368
+ const fields = group.fields ?? [];
1369
+ // `<` is escaped to `<` so the JSON cannot terminate the <script> early.
1370
+ const fieldsJson = JSON.stringify(fields).replaceAll('<', '\\u003c');
1371
+ const usernameHtml = options.includeUsernameField ? usernameFieldHtml() : '';
1372
+ const capabilitiesHtml = renderCapabilitiesSection(schema.capabilityInfo ?? []);
1373
+ const descriptionHtml = description ? `<p class="server-description" id="server-desc">${description}</p>` : '';
1374
+ // `__CARD_FIELDS__` (the only placeholder carrying user-controlled JSON) is
1375
+ // substituted LAST so a crafted field value cannot masquerade as another token.
1376
+ const script = CARD_GROUP_SCRIPT.replaceAll('__SUBMIT_URL__', submitUrlEscaped)
1377
+ .replaceAll('__GROUP_KEY__', jsString(groupKey))
1378
+ .replaceAll('__TITLE_FIELD__', jsString(titleField))
1379
+ .replaceAll('__ITEM_LABEL__', jsString(itemLabel))
1380
+ .replaceAll('__MIN_ITEMS__', String(minItems))
1381
+ .replaceAll('__CARD_FIELDS__', fieldsJson);
1382
+ const bodyHtml = `${CARD_GROUP_CSS} <div class="container">
1383
+ <div class="card">
1384
+ <div class="server-header">
1385
+ <h1 class="server-name">${displayName}</h1>
1386
+ <div class="server-id">${server}</div>
1387
+ ${descriptionHtml}
1388
+ </div>
1389
+
1390
+ <h2 class="form-title" id="form-title">${groupHeading}</h2>
1391
+
1392
+ <form id="credential-form" aria-labelledby="form-title" novalidate>
1393
+ <fieldset id="form-fieldset" style="border: none; padding: 0; margin: 0;">
1394
+ ${usernameHtml}<div id="card-group-container"></div>
1395
+
1396
+ <button type="button" class="card-group-add" id="card-group-add">${addLabel}</button>
1397
+
1398
+ <button type="submit" class="submit-btn" id="submit-btn">Connect</button>
1399
+ </fieldset>
1400
+
1401
+ <div class="status-box" id="status-box" role="alert" aria-live="polite" aria-atomic="true"></div>
1402
+ </form>
1403
+ </div>
1404
+ ${capabilitiesHtml}
1405
+ </div>
1406
+ ${script}`;
1407
+ return renderFormShell(title, bodyHtml);
1408
+ }
483
1409
  /**
484
1410
  * Render a dark-themed HTML credential form from a RelayConfigSchema.
485
1411
  *
@@ -490,6 +1416,14 @@ function renderCapability(cap) {
490
1416
  * @returns Complete HTML document string, XSS-safe with all dynamic content escaped.
491
1417
  */
492
1418
  export function renderCredentialForm(schema, options) {
1419
+ // Schema-level capabilities (opt-in): dispatch to the dedicated renderer.
1420
+ // A schema declaring neither key falls through to the unchanged flat form.
1421
+ if (schema.tabs && schema.tabs.length > 0) {
1422
+ return renderTabbedCredentialForm(schema, options);
1423
+ }
1424
+ if (schema.cardGroup) {
1425
+ return renderCardGroupCredentialForm(schema, options);
1426
+ }
493
1427
  const displayName = escapeHtml(schema.displayName ?? schema.server ?? 'Configuration');
494
1428
  const server = escapeHtml(schema.server ?? '');
495
1429
  const description = escapeHtml(schema.description ?? '');
@@ -499,6 +1433,7 @@ export function renderCredentialForm(schema, options) {
499
1433
  const capabilityInfo = schema.capabilityInfo ?? [];
500
1434
  const prefill = options.prefill ?? {};
501
1435
  const fieldsHtml = fields.map((f) => renderField(f, prefill[f.key] ?? '')).join('');
1436
+ const usernameHtml = options.includeUsernameField ? usernameFieldHtml() : '';
502
1437
  let capabilitiesHtml = '';
503
1438
  if (capabilityInfo.length > 0) {
504
1439
  const itemsHtml = capabilityInfo.map(renderCapability).join('');
@@ -525,13 +1460,13 @@ export function renderCredentialForm(schema, options) {
525
1460
  <p class="form-title" id="form-title">Enter your credentials</p>
526
1461
 
527
1462
  <form id="credential-form" aria-labelledby="form-title"${formAria} novalidate>
528
- ${fieldsHtml}
1463
+ ${usernameHtml}${fieldsHtml}
529
1464
 
530
1465
  <button type="submit" class="submit-btn" id="submit-btn">
531
1466
  Connect
532
1467
  </button>
533
1468
 
534
- <div class="status-box" id="status-box" role="alert"></div>
1469
+ <div class="status-box" id="status-box" role="alert" aria-live="polite" aria-atomic="true"></div>
535
1470
  </form>
536
1471
  </div>
537
1472
  ${capabilitiesHtml}
@@ -614,6 +1549,8 @@ export function renderCredentialForm(schema, options) {
614
1549
  errorEl.id = "step-error";
615
1550
  errorEl.className = "status-box error";
616
1551
  errorEl.setAttribute("role", "alert");
1552
+ errorEl.setAttribute("aria-live", "polite");
1553
+ errorEl.setAttribute("aria-atomic", "true");
617
1554
  errorEl.style.display = "none";
618
1555
  container.appendChild(errorEl);
619
1556
 
@@ -696,6 +1633,8 @@ export function renderCredentialForm(schema, options) {
696
1633
  done.className = "status-box success";
697
1634
  done.style.display = "block";
698
1635
  done.setAttribute("role", "alert");
1636
+ done.setAttribute("aria-live", "polite");
1637
+ done.setAttribute("aria-atomic", "true");
699
1638
  done.textContent = "Setup complete! Redirecting...";
700
1639
  container.appendChild(done);
701
1640
  window.location.replace(data.redirect_url);
@@ -709,6 +1648,8 @@ export function renderCredentialForm(schema, options) {
709
1648
  done.className = "status-box success";
710
1649
  done.style.display = "block";
711
1650
  done.setAttribute("role", "alert");
1651
+ done.setAttribute("aria-live", "polite");
1652
+ done.setAttribute("aria-atomic", "true");
712
1653
  done.textContent = "Setup complete! You can close this tab.";
713
1654
  container.appendChild(done);
714
1655
  }