// screens/admin.jsx — Administration: onboard companies (tenants) and the user
// accounts that sign in to them. Admin-only (gated in app.jsx AND server-side —
// every /api/admin route refuses non-admin tokens with a 403).
// New users start unactivated. Creating one shows the admin its one-time activation
// token (0032), once; the person enters it at their first sign-in and never again.

// Value strings follow the existing company_type convention in the DB ("oem",
// "tier1", …) — the meta endpoint's Brands dropdown filters on lower() = 'oem'.
const ADMIN_COMPANY_TYPES = [
  ["oem", "OEM"],
  ["tier1", "Tier-1 supplier"],
  ["tier2", "Tier-2 supplier"],
  ["fleet", "Fleet operator"],
  ["iot", "IoT vendor"],
  ["other", "Other"],
];

// Password strength check — mirrors the server-side PasswordPolicy so the admin
// gets instant feedback. The backend re-validates and is authoritative; this is
// purely UX. Returns an error string, or "" when the password is strong enough.
const PW_MIN_LENGTH = 8;
function checkPasswordStrength(pw, username) {
  if (!pw) return "Password is required.";
  const missing = [];
  if (pw.length < PW_MIN_LENGTH) missing.push(`at least ${PW_MIN_LENGTH} characters`);
  if (!/[A-Z]/.test(pw)) missing.push("an uppercase letter");
  if (!/[a-z]/.test(pw)) missing.push("a lowercase letter");
  if (!/[0-9]/.test(pw)) missing.push("a number");
  if (!/[^A-Za-z0-9]/.test(pw)) missing.push("a symbol (e.g. ! @ # $ %)");
  if (missing.length) {
    const list = missing.length === 1 ? missing[0]
      : missing.slice(0, -1).join(", ") + " and " + missing[missing.length - 1];
    return "Password must contain " + list + ".";
  }
  if (username && username.trim() &&
      pw.toLowerCase().includes(username.trim().toLowerCase()))
    return "Password must not contain the username.";
  return "";
}

// Friendly label for a stored company_type value ("tier1" → "Tier-1 supplier").
function companyTypeLabel(t) {
  const hit = ADMIN_COMPANY_TYPES.find(([v]) => v === (t || "").toLowerCase());
  return hit ? hit[1] : (t || "—");
}

// What a company is called in a <select>. The browser opens the list as wide as its longest
// option, past the edge of the window if it has to, and CSS has no say in it: one long name
// clipped every row and blanked the short ones. company_name has no limit. The whole name
// stays in the option's title and everywhere else on the page. The id keeps two long names
// apart, should they share the part that fits.
const COMPANY_OPTION_CHARS = 56;
function companyOption(c) {
  const chars = Array.from(c.companyName || `Company ${c.companyId}`);
  if (chars.length <= COMPANY_OPTION_CHARS) return chars.join("");
  const tail = ` #${c.companyId}`;
  return chars.slice(0, COMPANY_OPTION_CHARS - 1 - tail.length).join("").trimEnd() + "…" + tail;
}

// One inline form feedback line (error or success) under a card's form.
function AdminNote({ err, ok }) {
  if (!err && !ok) return null;
  return err ? (
    <div className="login-err"><Icon name="alert" size={14} />{err}</div>
  ) : (
    <div className="admin-ok"><Icon name="check" size={14} />{ok}</div>
  );
}

// One confirm dialog for both deletes. Neither has a soft-delete behind it, so the
// dialog spells out what actually goes rather than asking "are you sure?". It owns the
// busy flag too: both requests end in a refetch, and a second click would fire at an id
// the server has already forgotten.
function DeleteConfirm({ eyebrow, title, busy, error, onCancel, onConfirm, children }) {
  const cancelRef = useRef(null);
  // Focus. Opening this used to leave it on the row's trash button BEHIND the scrim, so
  // the first Tab walked straight back into the table the dialog is about to mutate. It
  // lands on Cancel and never on Delete: this is the one dialog in the product where a
  // stray Enter cannot be taken back.
  useEffect(() => {
    const opener = document.activeElement;
    if (cancelRef.current) cancelRef.current.focus();
    // Hand focus back on cancel. After a successful delete the row button is already
    // unmounted and .focus() on a detached node is a harmless no-op.
    return () => { if (opener && opener.focus) opener.focus(); };
  }, []);
  // Escape, the way AssigneeModal in ui/assignee.jsx already does it — but gated on
  // `busy`, because once the request is at the server, closing the dialog would only
  // hide a delete that is still happening.
  useEffect(() => {
    function key(e) { if (e.key === "Escape" && !busy) onCancel(); }
    document.addEventListener("keydown", key);
    return () => document.removeEventListener("keydown", key);
  }, [busy, onCancel]);
  // Portalled for the reason AssigneeModal documents. Its `.page` containing-block note
  // is only half the story here: the fade keyframes end on `transform: none`, so once
  // the 0.35s entry animation has filled, .page is no longer a containing block for a
  // position:fixed child. What it IS, for as long as the animation stays in its filling
  // state, is a stacking context at z-index auto — and .topbar is sticky at z-index 15,
  // so rendered in place this z-index:300 scrim paints UNDERNEATH the topbar. Hosted on
  // `.app` rather than document.body so data-theme and the variables set there still
  // reach it.
  const host = (typeof document !== "undefined" && document.querySelector(".app")) || document.body;
  return ReactDOM.createPortal(
    <div className="usm-scrim" onClick={() => !busy && onCancel()}>
      <div className={"usm usm-narrow usm-danger" + (busy ? " is-busy" : "")}
        role="alertdialog" aria-modal="true" aria-label={eyebrow + ": " + title}
        onClick={(e) => e.stopPropagation()}>
        <div className="usm-head">
          <span className="usm-danger-ic" aria-hidden="true"><Icon name="alert" size={18} /></span>
          <div>
            <span className="eyebrow">{eyebrow}</span>
            <h3>{title}</h3>
          </div>
          <button className="iconbtn" disabled={busy} onClick={onCancel} title="Close">
            <Icon name="close" size={16} />
          </button>
        </div>
        <div className="usm-body">
          {children}
          <p className="card-sub">There is no undo.</p>
          {error && <AdminNote err={error} />}
        </div>
        <div className="usm-foot">
          <span className="wiz-foot-sp" />
          <button className="btn btn-ghost" ref={cancelRef} disabled={busy} onClick={onCancel}>Cancel</button>
          <button className="btn btn-danger" disabled={busy} onClick={onConfirm}>
            {busy ? <span className="btn-spin" /> : <Icon name="trash" size={15} />}
            {busy ? "Deleting…" : "Delete permanently"}
          </button>
        </div>
      </div>
    </div>,
    host
  );
}


// The signed-in user, from the shell's localStorage record - the same source
// ui/collaboration.jsx reads. It decides WHICH admin page is drawn; every route behind
// either page re-checks the caller server-side, so editing this record buys nothing
// but a broken-looking screen.
function signedInUser() {
  try { return JSON.parse(localStorage.getItem("vc_auth_v1")) || {}; } catch (_) { return {}; }
}

// The roles an account can be created with, most-privileged last. Values are the
// canonical strings UserRoles stores in user.type; "admin" (the pre-0010 spelling of
// superadmin) is never OFFERED, only understood when reading an existing row back.
const ADMIN_USER_ROLES = [
  ["user", "Security analyst"],
  ["company_admin", "Company administrator"],
  ["superadmin", "Super administrator (all tenants)"],
];

// Reading a stored role back. "admin" is here because a database that has not had
// migration 0010 applied still spells superadmin that way, and the backend still signs
// those accounts in as superadmins - so the listing must not call them analysts.
function roleLabel(t) {
  const v = (t || "").trim().toLowerCase();
  if (v === "superadmin" || v === "admin") return "Super admin";
  if (v === "company_admin") return "Company admin";
  return "Analyst";
}
function isAdminRole(t) { return roleLabel(t) !== "Analyst"; }
function isCompanyAdminRole(t) { return roleLabel(t) === "Company admin"; }

// The three chips of a user row. Extracted because each one is now rendered TWICE
// - once in its own column, once in the block it folds into when the table is
// narrow - and two hand-maintained copies of a chip whose ink is a measured
// color-mix() would drift the first time one of them is edited.
function RoleChip({ type }) {
  return (
    <span className={"admin-role" + (isAdminRole(type) ? " is-admin" : "")}>
      {roleLabel(type)}
    </span>
  );
}
// Activated is the end state. Before it, whether the account's one-time token is still
// good (0032), so an admin can see at a glance which accounts need a new one.
function VerifyChip({ u }) {
  if (u.isVerified)
    return <span className="admin-verify is-ok"><span className="chip-dot" />Activated</span>;
  const exp = u.activationExpiresAt ? new Date(u.activationExpiresAt) : null;
  const live = exp && exp > new Date();
  return (
    <span className={"admin-verify" + (live ? "" : " is-off")}
      title={live ? "Activation token good until " + exp.toLocaleString()
        : exp ? "Its activation token expired " + exp.toLocaleString() + ". Generate a new one."
        : "No unused activation token. Generate one."}>
      <span className="chip-dot" />{live ? "Awaiting activation" : exp ? "Token expired" : "No token"}
    </span>
  );
}

// The one-time activation token, shown right after it is generated. This is the only
// time it exists in the clear: the server keeps a fingerprint of it, not the token.
function TokenReveal({ username, token, expiresAt }) {
  const [copied, setCopied] = useState(false);
  function copy() {
    if (!(navigator.clipboard && navigator.clipboard.writeText)) return;
    navigator.clipboard.writeText(token).then(() => {
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    }, () => {});
  }
  return (
    <div className="admin-token" role="status">
      <span className="admin-token-lab">Activation token for {username}</span>
      <div className="admin-token-row">
        <code className="admin-token-val mono" aria-label={"Activation token for " + username}>{token}</code>
        <button type="button" className="btn btn-ghost" onClick={copy}>
          <Icon name={copied ? "check" : "copy"} size={14} />{copied ? "Copied" : "Copy"}
        </button>
      </div>
      <p className="admin-token-note">
        Shown once. It cannot be shown again, so copy it now and give it to {username} privately.
        They enter it at their first sign-in. It works once, until {new Date(expiresAt).toLocaleString()}.
      </p>
    </div>
  );
}

// An unactivated account's token: generate a new one (the unused one stops working) or
// withdraw it. Portalled and keyed like DeleteConfirm, for the reasons noted there.
function ActivationDialog({ user, onClose, onChanged }) {
  const [busy, setBusy] = useState("");
  const [err, setErr] = useState("");
  const [issued, setIssued] = useState(null);
  const [expiresAt, setExpiresAt] = useState(user.activationExpiresAt || null);
  const closeRef = useRef(null);
  useEffect(() => { if (closeRef.current) closeRef.current.focus(); }, []);
  useEffect(() => {
    function key(e) { if (e.key === "Escape" && !busy) onClose(); }
    document.addEventListener("keydown", key);
    return () => document.removeEventListener("keydown", key);
  }, [busy, onClose]);

  const exp = expiresAt ? new Date(expiresAt) : null;
  const live = exp && exp > new Date();

  function generate() {
    setBusy("issue"); setErr("");
    window.VCApi.adminIssueActivationToken(user.userId).then(
      (r) => {
        setBusy(""); setIssued(r); setExpiresAt(r.activationExpiresAt);
        onChanged && onChanged();
      },
      (e) => { setBusy(""); setErr(e.message || "Could not generate a token."); },
    );
  }
  function revoke() {
    setBusy("revoke"); setErr("");
    window.VCApi.adminRevokeActivationToken(user.userId).then(
      () => { setBusy(""); setIssued(null); setExpiresAt(null); onChanged && onChanged(); },
      (e) => { setBusy(""); setErr(e.message || "Could not revoke the token."); },
    );
  }

  const host = (typeof document !== "undefined" && document.querySelector(".app")) || document.body;
  return ReactDOM.createPortal(
    <div className="usm-scrim" onClick={() => !busy && onClose()}>
      <div className={"usm usm-narrow" + (busy ? " is-busy" : "")}
        role="dialog" aria-modal="true" aria-label={"Activation token: " + user.username}
        onClick={(e) => e.stopPropagation()}>
        <div className="usm-head">
          <div>
            <span className="eyebrow">Activation token</span>
            <h3>{user.username}</h3>
          </div>
          <button className="iconbtn" ref={closeRef} disabled={!!busy} onClick={onClose} title="Close">
            <Icon name="close" size={16} />
          </button>
        </div>
        <div className="usm-body">
          {issued ? (
            <TokenReveal username={user.username} token={issued.activationToken} expiresAt={issued.activationExpiresAt} />
          ) : (
            <p>
              This account is not activated yet.{" "}
              {live ? `Its activation token is good until ${exp.toLocaleString()}.`
                : exp ? `Its activation token expired ${exp.toLocaleString()}.`
                : "It has no unused activation token."}
            </p>
          )}
          <p className="card-sub">
            A new token replaces the current one, which stops working at once. Tokens are
            shown only when generated.
          </p>
          {err && <AdminNote err={err} />}
        </div>
        <div className="usm-foot">
          {live && !issued && (
            <button className="btn btn-ghost" disabled={!!busy} onClick={revoke}>
              {busy === "revoke" ? <span className="btn-spin" /> : <Icon name="xcircle" size={15} />}
              Revoke token
            </button>
          )}
          <span className="wiz-foot-sp" />
          <button className="btn btn-accent" disabled={!!busy} onClick={generate}>
            {busy === "issue" ? <span className="btn-spin" /> : <Icon name="refresh" size={15} />}
            {exp || issued ? "Generate new token" : "Generate token"}
          </button>
        </div>
      </div>
    </div>,
    host
  );
}
// Which tenant an account belongs to, as a sub-line rather than a column. Only
// ever rendered under the same !fixedCompanyId guard that adds the Company
// column, so a company admin never sees a tag naming their only company.
function CompanyTag({ name }) {
  return <span className="admintbl-sub admintbl-fold">{name}</span>;
}

// Two initials for a tenant marker. Upper-cased here rather than with
// text-transform: the .admintbl .entcard-avatar rule is documented as
// geometry-only precisely so the light-theme ink fix in browse.css keeps
// outranking it, and adding a text property there would muddy that boundary.
function initialsOf(name) {
  return (name || "?").split(" ").map((w) => w[0]).join("").slice(0, 2).toUpperCase();
}

// Does this list need a filter bar? Below this many rows the chrome costs more
// than the scanning it saves.
const ADMIN_FILTER_FROM = 8;

// ---------------------------------------------------------------------------
// Create + list user accounts. Shared by both admin pages, because it is the same job
// at two scopes:
//   - a superadmin picks the company and may mint any role, including another
//     superadmin;
//   - a company admin's company is FIXED to their own and the superadmin role is not
//     offered.
// The server enforces both independently (AdminController.CreateUser refuses a company
// that is not yours, and a superadmin grant from anyone who is not one) - this simply
// does not dangle what would be refused.
// ---------------------------------------------------------------------------
function UsersCard({ companies, users, canGrantSuperAdmin, canDelete, fixedCompanyId, companyLabel, onChanged }) {
  const roleChoices = canGrantSuperAdmin
    ? ADMIN_USER_ROLES
    : ADMIN_USER_ROLES.filter(([v]) => v !== "superadmin");

  // Deleting your own account is refused server-side; the button is withheld here so
  // the only account you cannot delete never offers one. The other refusal the server
  // makes -- the last superadmin -- cannot be decided from this list alone (it spans
  // every tenant, and a company admin only ever sees their own), so that one is left to
  // come back as an error in the dialog.
  const meId = Number(signedInUser().userId) || 0;
  const [delUser, setDelUser] = useState(null);
  const [delBusy, setDelBusy] = useState(false);
  const [delErr, setDelErr] = useState("");

  function reallyDeleteUser() {
    setDelBusy(true); setDelErr("");
    window.VCApi.adminDeleteUser(delUser.userId).then(
      (r) => {
        setDelBusy(false); setDelUser(null);
        // What became of the Jira project role this product gave them, when it gave one:
        // taken back, or kept because Jira could not be asked and must be removed there.
        const jira = r && r.jira;
        setUErr(jira && !jira.ok ? jira.detail : ""); setUOk(jira && jira.ok ? jira.detail : "");
        onChanged && onChanged();
      },
      (e) => { setDelBusy(false); setDelErr(e.message || "Could not delete that user."); },
    );
  }

  const [uName, setUName] = useState("");
  const [uEmail, setUEmail] = useState("");
  const [uPw, setUPw] = useState("");
  const [uShow, setUShow] = useState(false);
  const [uRole, setURole] = useState("user");
  const [uCompany, setUCompany] = useState(fixedCompanyId ? String(fixedCompanyId) : "");
  const [uBusy, setUBusy] = useState(false);
  const [uErr, setUErr] = useState("");
  const [uOk, setUOk] = useState("");
  // The account just created and its activation token, held until the admin dismisses
  // it: the server will not show that token again.
  const [issued, setIssued] = useState(null);
  // The unactivated account whose token dialog is open.
  const [actUser, setActUser] = useState(null);

  // The create form is closed by default and opened from the card head. All of its
  // state is held HERE, in the parent, so unmounting the <form> subtree loses
  // nothing but DOM focus - which is materially different from the band-unmounting
  // the section rail refuses (there, a half-typed Jira API token could not be
  // restored, because the server never reads one back).
  //
  // Deliberately NOT initialised from users.length: this component renders once
  // with an empty list before the fetch lands, so `useState(users.length === 0)`
  // would latch open and stay open forever. The zero state carries its own button
  // instead, which is a better prompt than a form that is already open.
  const [adding, setAdding] = useState(false);
  const firstFieldRef = useRef(null);
  function openAdd() {
    setAdding(true);
    // After the panel has mounted. The form is the thing being opened, so the
    // caret belongs in its first field rather than on the button that revealed it.
    setTimeout(() => { if (firstFieldRef.current) firstFieldRef.current.focus(); }, 0);
  }

  // ---- filtering. Entirely client-side over the array already fetched: there is
  // no search route in core/api.js, and the only server-side narrowing that exists
  // is adminUsers(companyId). Said out loud so a later "move this to the server"
  // edit knows there is no endpoint to land on.
  const [q, setQ] = useState("");
  const [onlyUnverified, setOnlyUnverified] = useState(false);
  const [coFilter, setCoFilter] = useState("");
  const unverifiedCount = users.filter((u) => !u.isVerified).length;
  // The actions cell: delete (superadmin) and, for an account not activated yet, its
  // activation token (any admin who can see it).
  const showActions = canDelete || unverifiedCount > 0;
  const shown = users.filter((u) => {
    if (onlyUnverified && u.isVerified) return false;
    if (coFilter && String(u.companyId) !== coFilter) return false;
    if (!q.trim()) return true;
    const hay = (u.username + " " + (u.email || "") + " " + (u.companyName || "")).toLowerCase();
    return hay.includes(q.trim().toLowerCase());
  });
  const filtering = !!(q.trim() || onlyUnverified || coFilter);
  const showBar = users.length > ADMIN_FILTER_FROM;
  function clearFilters() { setQ(""); setOnlyUnverified(false); setCoFilter(""); }

  // Keep the company select pointing at a real company once they load. A fixed company
  // never moves.
  useEffect(() => {
    if (fixedCompanyId) { setUCompany(String(fixedCompanyId)); return; }
    if (!uCompany && companies.length) setUCompany(String(companies[0].companyId));
  }, [companies, fixedCompanyId]);

  function addUser(e) {
    e.preventDefault();
    setUErr(""); setUOk(""); setIssued(null);
    if (!uName.trim()) { setUErr("Enter a username."); return; }
    if (!uEmail.trim() || uEmail.indexOf("@") < 1) { setUErr("Enter a valid e-mail address."); return; }
    { const pw = checkPasswordStrength(uPw, uName); if (pw) { setUErr(pw); return; } }
    if (!uCompany) { setUErr("Pick the company this user belongs to."); return; }
    setUBusy(true);
    window.VCApi.adminCreateUser({
      username: uName.trim(),
      email: uEmail.trim(),
      password: uPw,
      type: uRole,
      companyId: Number(uCompany),
    })
      .then((created) => {
        setUBusy(false);
        setUName(""); setUEmail(""); setUPw(""); setURole("user");
        if (created.activationToken) {
          setUOk(`${created.username} created. Give them the activation token below for their first sign-in.`);
          setIssued({ username: created.username, token: created.activationToken, expiresAt: created.activationExpiresAt });
        } else {
          setUOk(`${created.username} created.`);
          setUErr(created.activationError || "No activation token was generated. Generate one from the list.");
        }
        if (onChanged) onChanged();
      })
      .catch((error) => {
        setUBusy(false);
        setUErr((error && error.message) || "Could not create the user.");
      });
  }

  return (
    <section className="card admin-card">
      <div className="card-head">
        <h3><Icon name="user" size={16} /> Users</h3>
        <div className="admin-head-act">
          <span className="card-sub">
            {filtering ? `${shown.length} of ${users.length}` : users.length} account{users.length === 1 ? "" : "s"}
          </span>
          <button type="button" className={"admin-head-btn" + (adding ? " is-on" : "")}
            aria-expanded={adding} onClick={() => (adding ? setAdding(false) : openAdd())}>
            <Icon name={adding ? "close" : "plus"} size={13} />{adding ? "Cancel" : "Add user"}
          </button>
        </div>
      </div>

      {adding && (
      <form className="admin-form" onSubmit={addUser}>
        <label className="field">
          <span className="field-lab">Username</span>
          <div className="field-wrap">
            <Icon name="user" size={16} className="field-ic" />
            <input ref={firstFieldRef} value={uName} onChange={(e) => setUName(e.target.value)}
              placeholder="analyst handle" spellCheck="false" autoComplete="off" />
          </div>
        </label>
        <label className="field">
          <span className="field-lab">E-mail</span>
          <div className="field-wrap">
            <Icon name="bell" size={16} className="field-ic" />
            <input type="email" value={uEmail} onChange={(e) => setUEmail(e.target.value)}
              placeholder="name@company.com" spellCheck="false" autoComplete="off" />
          </div>
        </label>
        <label className="field">
          <span className="field-lab">Temporary password</span>
          <div className="field-wrap">
            <Icon name="shield" size={16} className="field-ic" />
            <input type={uShow ? "text" : "password"} value={uPw} onChange={(e) => setUPw(e.target.value)}
              placeholder="8+ chars, upper, lower, number & symbol" autoComplete="new-password" />
            <button type="button" className="field-toggle" onClick={() => setUShow((s) => !s)}>
              {uShow ? "Hide" : "Show"}
            </button>
          </div>
          {/* Only once there is something to judge: an empty field is not a weak
              password, and the placeholder already states the policy. This calls the
              SAME pure function addUser() calls, so the hint and the refusal cannot
              disagree — nothing is sent, no state is added, and the server still
              re-validates and is still the authority. */}
          {uPw !== "" && (() => {
            const gap = checkPasswordStrength(uPw, uName);
            return (
              <span className={"field-note " + (gap ? "is-bad" : "is-ok")}>
                <Icon name={gap ? "alert" : "check"} size={12} />
                {gap || "Meets the policy. The server checks it again on create."}
              </span>
            );
          })()}
        </label>
        <label className="field">
          <span className="field-lab">Role</span>
          <div className="field-wrap">
            <Icon name="shield" size={16} className="field-ic" />
            <select value={uRole} onChange={(e) => setURole(e.target.value)}>
              {roleChoices.map(([v, label]) => <option key={v} value={v}>{label}</option>)}
            </select>
          </div>
        </label>
        {/* A company admin has exactly one company to put an account in, so the
            select is replaced by the name rather than a one-option menu. The id the
            form submits comes from the same place either way. */}
        {fixedCompanyId ? (
          // .is-static is what stops this reading as an editable field. The rule
          // was written for exactly this input and the class was never applied,
          // so the "small lie told 44px tall" its comment describes has been
          // shipping ever since.
          <label className="field is-static">
            <span className="field-lab">Company</span>
            <div className="field-wrap">
              <Icon name="layers" size={16} className="field-ic" />
              <input value={companyLabel} readOnly tabIndex={-1} />
            </div>
          </label>
        ) : (
          <label className="field">
            <span className="field-lab">Company</span>
            <div className="field-wrap">
              <Icon name="layers" size={16} className="field-ic" />
              <select value={uCompany} onChange={(e) => setUCompany(e.target.value)} disabled={!companies.length}>
                {!companies.length && <option value="">Onboard a company first</option>}
                {companies.map((c) => (
                  <option key={c.companyId} value={String(c.companyId)} title={c.companyName}>{companyOption(c)}</option>
                ))}
              </select>
            </div>
          </label>
        )}
        <div className="admin-form-act">
          <button className="btn btn-accent" type="submit" disabled={uBusy || !companies.length}>
            {uBusy ? <span className="btn-spin" /> : <><Icon name="plus" size={15} />Add user</>}
          </button>
        </div>
      </form>
      )}

      {/* Hoisted out of the form. It has to outlive the panel: the success line
          names the account that was just created, which is precisely the moment
          the form closes. AdminNote renders null when there is nothing to say and
          React spends no flex gap on a null child, so the card's 16px gap is not
          paid for an absent note - which was all the .admin-span2:empty wrapper
          was buying it inside the grid. */}
      <AdminNote err={uErr} ok={uOk} />
      {issued && (
        <div className="admin-token-wrap">
          <TokenReveal username={issued.username} token={issued.token} expiresAt={issued.expiresAt} />
          <button type="button" className="btn btn-ghost" onClick={() => { setIssued(null); setUOk(""); }}>
            <Icon name="check" size={15} />Done, I have shared it
          </button>
        </div>
      )}

      {showBar && (
        <div className="toolbar admin-listbar">
          <div className="search">
            <Icon name="search" size={16} className="search-ic" />
            <input value={q} onChange={(e) => setQ(e.target.value)}
              placeholder="Search name, e-mail or company" spellCheck="false" aria-label="Search accounts" />
            {q && <button className="search-clear" aria-label="Clear search" onClick={() => setQ("")}><Icon name="close" size={14} /></button>}
          </div>
          {companies.length > 1 && (
            <label className="field">
              <div className="field-wrap">
                <Icon name="layers" size={15} className="field-ic" />
                <select value={coFilter} onChange={(e) => setCoFilter(e.target.value)} aria-label="Filter by company">
                  <option value="">All companies</option>
                  {companies.map((c) => (
                    <option key={c.companyId} value={String(c.companyId)} title={c.companyName}>{companyOption(c)}</option>
                  ))}
                </select>
              </div>
            </label>
          )}
          {unverifiedCount > 0 && (
            <button type="button" aria-pressed={onlyUnverified}
              className={"admin-filter" + (onlyUnverified ? " is-on" : "")}
              onClick={() => setOnlyUnverified((v) => !v)}>
              Not activated<span className="admin-filter-n">{unverifiedCount}</span>
            </button>
          )}
        </div>
      )}

      {users.length === 0 ? (
        <div className="empty">
          <Icon name="user" size={24} />
          <p>No users yet</p>
          {companies.length ? (
            <>
              <span className="empty-sub">Every account signs in to one company and is activated with a one-time token on first use.</span>
              <button className="btn btn-accent" type="button" onClick={openAdd}>
                <Icon name="plus" size={15} />Add the first user
              </button>
            </>
          ) : (
            // No button, because there is nothing it could submit. The old copy
            // here pointed at "the form above", whose submit is disabled and
            // whose company select reads "Onboard a company first" - the
            // instruction and the control contradicted each other.
            <span className="empty-sub">Onboard a company first — every account belongs to one.</span>
          )}
        </div>
      ) : shown.length === 0 ? (
        <div className="empty">
          <Icon name="search" size={24} />
          <p>No account matches</p>
          <span className="empty-sub">Nothing in this company's roster matches the current filter.</span>
          <button className="btn btn-ghost" type="button" onClick={clearFilters}>Clear filters</button>
        </div>
      ) : (
        <div className="admintbl-wrap">
          <table className="admintbl">
            {/* Column classes, not :nth-child. The note in admin.css rules out
                index-based rules here for a concrete reason: this table has TWO
                conditional columns (Company only for a superadmin, the actions
                cell only when canDelete), so the same index is a different column
                for the two callers. Each class is on the th and the td alike -
                hiding only the cell would shear the row off its header. */}
            <thead>
              <tr>
                <th className="admintbl-col-id">User</th>
                <th>Role</th>
                {!fixedCompanyId && <th className="admintbl-col-co">Company</th>}
                <th className="admintbl-col-state">Status</th>
                <th className="admintbl-num admintbl-col-when">Created</th>
                {showActions && <th className="admintbl-num"><span className="vc-sr-only">Actions</span></th>}
              </tr>
            </thead>
            <tbody>
              {shown.map((u) => (
                <tr key={u.userId}>
                  <td className="admintbl-col-id">
                    <span className="admintbl-user">
                      <strong>{u.username}</strong>
                      {/* One ellipsised line now, so the full address goes on the
                          title where it stays recoverable. */}
                      <span className="admintbl-sub" title={u.email || ""}>{u.email || "—"}</span>
                      {!fixedCompanyId && <CompanyTag name={u.companyName || `#${u.companyId}`} />}
                    </span>
                  </td>
                  <td>
                    <span className="admintbl-access">
                      <RoleChip type={u.type} />
                      <span className="admintbl-fold"><VerifyChip u={u} /></span>
                    </span>
                  </td>
                  {!fixedCompanyId && <td className="admintbl-col-co">{u.companyName || `#${u.companyId}`}</td>}
                  <td className="admintbl-col-state"><VerifyChip u={u} /></td>
                  <td className="admintbl-num mono admintbl-col-when">{u.createdAt || "—"}</td>
                  {showActions && (
                    <td className="admintbl-num">
                      <span className="admintbl-acts">
                        {!u.isVerified && (
                          <button className="iconbtn" title={"Activation token for " + u.username}
                            onClick={() => setActUser(u)}>
                            <Icon name="shield" size={14} />
                          </button>
                        )}
                        {canDelete && u.userId !== meId && (
                          <button className="iconbtn iconbtn-danger" title={"Delete " + u.username}
                            onClick={() => { setDelErr(""); setDelUser(u); }}>
                            <Icon name="trash" size={14} />
                          </button>
                        )}
                      </span>
                    </td>
                  )}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}

      {actUser && (
        <ActivationDialog user={actUser} onClose={() => setActUser(null)} onChanged={onChanged} />
      )}

      {delUser && (
        <DeleteConfirm
          eyebrow="Delete user" title={delUser.username}
          busy={delBusy} error={delErr}
          onCancel={() => { if (!delBusy) { setDelUser(null); setDelErr(""); } }}
          onConfirm={reallyDeleteUser}>
          <p>This permanently removes the account. They can no longer sign in.</p>
          <ul className="del-list">
            <li>Any vulnerability assigned to them becomes <b>unassigned</b></li>
            <li>Their comments and evidence <b>stay</b>, with the author removed</li>
          </ul>
          <p className="card-sub">
            The work they did is kept on purpose — a thread that erases itself when
            someone leaves the company is not an audit trail.
          </p>
        </DeleteConfirm>
      )}
    </section>
  );
}

// ---------------------------------------------------------------------------
// What one company gets: a checkbox per feature, superadmin only.
//
// PER COMPANY, NOT PLATFORM-WIDE. The superadmin picks the tenant it applies to. A
// company admin is not shown this card, and AdminController refuses them the routes
// behind it even for their own company.
//
// EACH SWITCH KEEPS ITS OWN DEFAULT. Comments are on until switched off, and off HIDES
// AND FREEZES, IT NEVER DELETES: the rows in company_vuln_comment are untouched and the
// threads come back intact when it is switched on again, which is the rule the comment
// routes already follow: a comment is corrected, never removed. Binary analysis is the
// reverse: off until ticked, because it is granted company by company. So is Jira, except
// that a company already connected reads as ticked. Off keeps its connection and stops
// using it, and the Jira band below is only drawn for a company that has it.
//
// One thing lags, deliberately, and the card says so rather than pretending otherwise: a
// colleague already signed in keeps the old answer until the shell's pull of /api/config
// next reads past its cache, within five minutes, and the server refuses them meanwhile.
//
// ONLY THE NAME AND THE TICK TOGGLE. Each of these changes what a whole company can do, with
// nothing to confirm it. The row was one <label> once, and a click in its empty middle, or
// on a word of the hint while reading it to someone, turned a feature off for that tenant.
//
// A FAILED READ DRAWS NO TICKS. featureOn() turns a missing field into that feature's
// default, so ticks drawn from an empty object would state "Comments: On" for a company
// nobody has heard back about. settings stays null, the boxes stay locked and the card
// offers Try again.
// ---------------------------------------------------------------------------
const COMPANY_FEATURES = [
  { field: "commentsEnabled", defaultOn: true, save: "adminSetCommentsEnabled",
    label: "Comments",
    hint: "Off hides the thread for everyone in the company, refuses new posts and does not copy comments made in Jira meanwhile. Existing comments are kept and reappear when turned back on." },
  { field: "binaryAnalysisEnabled", defaultOn: false, save: "adminSetBinaryAnalysisEnabled",
    label: "Binary analysis",
    hint: "Generates an SBOM from firmware images and compiled files. Off until ticked; while off, the company can generate from a source .zip only." },
  { field: "jiraEnabled", defaultOn: false, save: "adminSetJiraEnabled",
    label: "Jira",
    hint: "Files vulnerabilities as issues in the company's Jira and keeps them in sync. Off until ticked; while off, a saved connection is kept but not used." },
];

function featureOn(settings, f) {
  return f.defaultOn ? settings[f.field] !== false : settings[f.field] === true;
}

// A name can carry a right-to-left override, which would mirror the rest of the sentence.
const isolated = (name) => `\u2068${name}\u2069`;

// onJiraFor names the company whose Jira band the page should draw, or null for none. onGone
// says the company on show no longer exists, so the page can read the list again.
function CompanyFeatures({ companies, onJiraFor, onGone }) {
  const [companyId, setCompanyId] = useState("");
  const [settings, setSettings] = useState(null); // null until the first read lands
  const [busy, setBusy] = useState(null);         // the save in flight: { field, tenant }
  const [msg, setMsg] = useState(null);           // { err } | { ok }
  const [reads, setReads] = useState(0);          // Try again re-runs the read
  const [readFailed, setReadFailed] = useState(false); // only the read's own failure says "Not available"
  const showing = useRef(companyId);              // what a landing save checks itself against
  const carried = useRef(null);                   // a message that outlives the move to another company
  const picker = useRef(null);
  showing.current = companyId;

  const nameOf = (c) => c.companyName || `Company ${c.companyId}`;
  const company = companies.find((c) => String(c.companyId) === companyId);
  const companyName = company ? nameOf(company) : "";
  // A save belongs to the company it started on. The Companies card can delete that one
  // under it, and the next company must then show as itself.
  const saving = busy && busy.tenant === companyId ? busy.field : null;
  const locked = settings === null || !!saving;
  const jiraOn = settings !== null && settings.jiraEnabled === true;

  useEffect(() => { onJiraFor(jiraOn && company ? company : null); }, [jiraOn, companyId]);

  // A tenant has to be selected before the switches mean anything, and it has to still
  // exist: the Companies card above can delete the one on show, and a <select> whose
  // value has gone falls back to its first option while the ticks stay on the old tenant.
  useEffect(() => {
    if (!company) setCompanyId(companies.length ? String(companies[0].companyId) : "");
  }, [companies]);

  useEffect(() => {
    let live = true;
    // A carried line describes the company before this one, not this read: it waits under
    // "Reading…" and stays once the read lands.
    setSettings(null); setReadFailed(false); setMsg(carried.current); carried.current = null;
    if (!companyId) return;
    window.VCApi.adminSettings(companyId).then(
      (s) => { if (live) setSettings(s || {}); },
      (e) => {
        if (!live) return;
        setReadFailed(true);
        setMsg({ err: `Could not read features for ${isolated(companyName)}. ${(e && e.message) || "Try again."}` });
      });
    return () => { live = false; };
  }, [companyId, reads]);

  // Stays until the next tick or company: it is the one line that proves which company
  // changed, and its place is reserved, so a timer would buy nothing. It takes the lag
  // note's place, so it says what the note said.
  const savedLine = (f, on) => `${f.label} turned ${on ? "on" : "off"} for ${isolated(companyName)}. Colleagues pick this up within five minutes.`;

  // The box stays enabled through a save: a disabled one is near invisible in light theme, so
  // it could not show where it is going, and it would drop keyboard focus. React puts a
  // click back when this ignores it.
  function toggle(f, next) {
    if (saving) return;
    const tenant = companyId;
    const finished = () => setBusy((b) => (b && b.tenant === tenant ? null : b));
    setBusy({ field: f.field, tenant }); setMsg(null);
    // What a save that landed does besides saying so, whether its answer arrived or a
    // re-read found it stored. This session should not have to wait out its own config
    // cache to see the change it just made. applyAppConfig leaves alone everything it is
    // not given, so passing the one flag does not disturb statuses or severity bands.
    // Binary analysis needs no such thing: a superadmin always has it.
    const landed = (on) => {
      if (f.field === "commentsEnabled" && String(tenant) === String(signedInUser().companyId))
        window.applyAppConfig({ commentsEnabled: on });
      window.VCApi.invalidate("/api/admin/settings");
      window.VCApi.invalidate("/api/config");
      if (f.field === "jiraEnabled") window.VCApi.invalidate("/api/integrations/jira/status");
    };
    window.VCApi[f.save](tenant, next).then(
      (saved) => {
        const now = saved || { ...settings, [f.field]: next };
        const on = featureOn(now, f);
        finished();
        landed(on);
        // The picker is locked for the length of a save, but the Companies card can still
        // delete this tenant under it. What came back describes the company that was
        // saved, so it must not land on whichever one is showing by now.
        if (tenant !== showing.current) return;
        setSettings(now);
        setMsg({ ok: savedLine(f, on) });
      },
      async (e) => {
        // No answer is not a refusal: the write may have landed. Ask what is stored rather
        // than say it was not saved, on a switch that decides what a company can do.
        const stored = e && e.status === 0 ? await window.VCApi.adminSettings(tenant).catch(() => null) : null;
        finished();
        if (stored && featureOn(stored, f) === next) landed(next);
        if (tenant !== showing.current) return;
        if (e && e.status === 409) {
          carried.current = { err: `${f.label} not saved: ${isolated(companyName)} no longer exists.` };
          setMsg(carried.current);
          onGone();
        } else if (stored) {
          setSettings(stored);
          setMsg(featureOn(stored, f) === next ? { ok: savedLine(f, next) } : { err: `${f.label} not saved. ${e.message}` });
        } else if (e && e.status === 0) {
          setMsg({ err: `No answer from the server, so ${f.label} may or may not have saved. Reload the page to see what is stored.` });
        } else {
          setMsg({ err: `${f.label} not saved. ${(e && e.message) || "Try again."}` });
        }
      });
  }

  // Try again goes the moment it is pressed, and keyboard focus would go with it.
  function retry() {
    picker.current.focus();
    setReads((n) => n + 1);
  }

  // Under the title, where the eye starts, and a second sign that a failed read is not "all off".
  const onCount = settings === null ? 0 : COMPANY_FEATURES.filter((f) => featureOn(settings, f) !== (saving === f.field)).length;
  const summary = !companies.length ? "No companies yet"
    : settings !== null ? `${onCount} of ${COMPANY_FEATURES.length} features on`
    : readFailed ? "Not available" : "Reading…";

  return (
    <section className="card admin-card">
      <div className="card-head admin-head-split">
        <div className="admin-head-txt">
          <h3><Icon name="doc" size={16} /> Company features</h3>
          <span className="card-sub">{summary}</span>
        </div>
        <label className="field admin-feat-pick">
          <span className="field-lab">Company</span>
          <div className="field-wrap">
            <Icon name="layers" size={16} className="field-ic" />
            <select ref={picker} value={companyId} onChange={(e) => setCompanyId(e.target.value)} disabled={!companies.length || !!saving}>
              {!companies.length && <option value="">Onboard a company first</option>}
              {companies.map((c) => (
                <option key={c.companyId} value={String(c.companyId)} title={c.companyName}>{companyOption(c)}</option>
              ))}
            </select>
          </div>
        </label>
      </div>
      <div className="admin-feats" role="group" aria-label={`Features for ${companyName || "the selected company"}`}>
        {COMPANY_FEATURES.map((f) => {
          const on = settings !== null && featureOn(settings, f);
          const flipping = saving === f.field;
          const id = `feat-${f.field}`;
          return (
            <div key={f.field} className={"admin-feat" + (settings === null ? " is-unknown" : "")}>
              <label className="admin-feat-tick">
                {/* A save always flips the box, so while one runs it shows where it is
                    going and the chip says that is not settled yet. */}
                <input id={id} type="checkbox" checked={flipping ? !on : on}
                  disabled={settings === null} aria-disabled={locked}
                  aria-describedby={`${id}-hint`} onChange={(e) => toggle(f, e.target.checked)} />
              </label>
              <label className="admin-feat-name" htmlFor={id}>{f.label}</label>
              <span className="admin-feat-hint" id={`${id}-hint`}>{f.hint}</span>
              <span className="admin-feat-state">
                {flipping
                  ? <span className="admin-verify is-off"><span className="btn-spin btn-spin-sm" />Saving</span>
                  : settings !== null && (
                    <span className={"admin-verify " + (on ? "is-ok" : "is-off")} aria-hidden="true">
                      <span className="chip-dot" />{on ? "On" : "Off"}
                    </span>
                  )}
              </span>
            </div>
          );
        })}
      </div>
      <div className="admin-feat-status" role="status">
        {msg
          ? <AdminNote err={msg.err} ok={msg.ok} />
          : companies.length > 0 && (
            <p className="admin-feat-lag">
              <Icon name="clock" size={13} />Colleagues already signed in pick up a change within five minutes.
            </p>
          )}
        {settings === null && readFailed && (
          <button type="button" className="admin-head-btn" onClick={retry}>
            <Icon name="refresh" size={13} />Try again
          </button>
        )}
      </div>
    </section>
  );
}

// ---------------------------------------------------------------------------
// The page's bands, in layout order. The rail is generated from these so a band
// cannot be renamed in one place and not the other, and so the company-admin page
// - which has no tenant list, no feature switches and no taxonomy - gets a shorter
// rail out of the same component instead of a second copy of it. The icon is the
// one the band's own card-head already wears, so a rail chip and the heading it
// lands on match.
// ---------------------------------------------------------------------------
const ADMIN_SECTIONS_SUPER = [
  ["admin-tenancy", "Companies & users", "layers"],
  ["admin-features", "Features", "doc"],
  ["admin-jira", "Jira", "link"],
  // Hidden 2026-11-10 on request, with its band below. Uncomment both to bring it back;
  // TaxonomyManager and the /api/admin/taxonomy routes are untouched.
  // ["admin-taxonomy", "Taxonomy", "sparkle"],
];
const ADMIN_SECTIONS_COMPANY = [
  ["admin-tenancy", "Users", "user"],
  ["admin-jira", "Jira", "link"],
];
// For a company without Jira. Constants, not a filter at render: the rail re-observes
// its bands whenever this array is a new one.
const withoutJira = (sections) => sections.filter(([id]) => id !== "admin-jira");
const ADMIN_SECTIONS_SUPER_NO_JIRA = withoutJira(ADMIN_SECTIONS_SUPER);

// A contents bar for a page that is four bands long. It moves the viewport and
// nothing else: no band is unmounted, no fetch is re-run, and no form loses what
// was typed into it - which is the whole reason this is not a tab strip. The
// current band is tracked with IntersectionObserver because it is native (there is
// no build step here to bring a scrollspy library through) and it does not run
// script on every scroll frame.
function AdminSectionNav({ sections }) {
  const [active, setActive] = useState(sections[0] ? sections[0][0] : "");
  const seen = useRef({});

  useEffect(() => {
    const els = sections.map(([id]) => document.getElementById(id)).filter(Boolean);
    if (!els.length || typeof IntersectionObserver !== "function") return;
    // The watched strip is the space just under the rail. When two bands are in it
    // at once the earlier one wins, so the highlight walks down the list in
    // document order rather than flickering between neighbours.
    const io = new IntersectionObserver((entries) => {
      entries.forEach((e) => { seen.current[e.target.id] = e.isIntersecting; });
      const hit = sections.map(([id]) => id).find((id) => seen.current[id]);
      if (hit) setActive(hit);
    }, { rootMargin: "-108px 0px -62% 0px" });
    els.forEach((el) => io.observe(el));
    return () => io.disconnect();
  }, [sections]);

  function jump(id) {
    const el = document.getElementById(id);
    if (!el) return;
    setActive(id);
    // The topbar + rail offset is scroll-margin-top on .admin-sec, so the number
    // stays in the stylesheet next to the rail's own height rather than here.
    const still = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    el.scrollIntoView({ behavior: still ? "auto" : "smooth", block: "start" });
  }

  return (
    <nav className="admin-nav" aria-label="Administration sections">
      {sections.map(([id, label, icon]) => (
        <button key={id} type="button"
          className={"admin-nav-btn" + (active === id ? " is-on" : "")}
          aria-current={active === id ? "true" : undefined}
          onClick={() => jump(id)}>
          <Icon name={icon} size={14} />{label}
        </button>
      ))}
    </nav>
  );
}

// ---------------------------------------------------------------------------
// The Administration page for a COMPANY ADMIN: their own company's accounts and its
// Jira connection. No company list, no taxonomy, no alert machinery, no feature switches -
// those belong to the platform, not to one tenant.
// ---------------------------------------------------------------------------
function CompanyAdminScreen({ me }) {
  const companyId = Number(me.companyId) || 0;
  const companyName = me.companyName || "your company";
  // Scoped server-side to this company whatever is asked for; the id is passed so the
  // response caches under its own key rather than sharing a superadmin's.
  const usersApi = useApi((signal) => window.VCApi.adminUsers(companyId, signal));
  const users = usersApi.data || [];
  const company = { companyId, companyName };

  if (usersApi.loading && !usersApi.data) return <PageLoading label="Loading your company…" />;
  if (usersApi.error && !usersApi.data) return <PageError message={usersApi.error} onRetry={usersApi.refetch} />;

  return (
    <div className="page">
      <div className="page-head">
        <div>
          <span className="eyebrow">Administration</span>
          <h1 className="page-title">{companyName}</h1>
          <p className="page-desc">Create the accounts that sign in to your company{FEATURES.jira ? ", and connect it to Jira" : ""}. Everything here is scoped to your company alone.</p>
        </div>
      </div>

      {/* Two bands, not four: this page has no companies list, no features and no
          taxonomy, and the rail says so rather than dangling entries that go nowhere.
          One band for a company without Jira, which leaves nothing for a rail to do. */}
      {FEATURES.jira && <AdminSectionNav sections={ADMIN_SECTIONS_COMPANY} />}

      {/* One card, but still a band. .admin-grid's auto-fit collapses the empty
          second track, so it fills the row instead of sitting at half width beside
          nothing - which is what the fixed two-up track used to give it. No
          .admin-stack here: with a single child auto-fit already produces one
          full-width track, and leaving the modifier off keeps that documented
          behaviour byte-identical. */}
      <div className="admin-grid admin-sec" id="admin-tenancy">
        <UsersCard
          companies={[company]}
          users={users}
          canGrantSuperAdmin={false}
          canDelete={false}
          fixedCompanyId={companyId}
          companyLabel={companyName}
          onChanged={usersApi.refetch}
        />
      </div>

      {FEATURES.jira && (
        <div className="admin-sec" id="admin-jira">
          <JiraIntegrationPanel fixedCompanyId={companyId} />
        </div>
      )}
    </div>
  );
}

// ---------------------------------------------------------------------------
// The Administration page. Which one you get is the whole of the branch below: a
// superadmin runs the platform, a company admin runs one tenant.
// ---------------------------------------------------------------------------
function AdminScreen() {
  const me = signedInUser();
  return me.isAdmin ? <SuperAdminScreen /> : <CompanyAdminScreen me={me} />;
}

// Every tenant, every account, and the platform-wide vocabulary.
function SuperAdminScreen() {
  const companiesApi = useApi((signal) => window.VCApi.adminCompanies(signal));
  const usersApi = useApi((signal) => window.VCApi.adminUsers("", signal));
  const companies = companiesApi.data || [];
  const users = usersApi.data || [];
  const [jiraFor, setJiraFor] = useState(null); // whose Jira band to draw (see CompanyFeatures)

  // ---- add-company form ----
  const [cName, setCName] = useState("");
  const [cType, setCType] = useState(ADMIN_COMPANY_TYPES[0][0]);
  const [cBusy, setCBusy] = useState(false);
  const [cErr, setCErr] = useState("");
  const [cOk, setCOk] = useState("");

  function refetchAll() {
    companiesApi.refetch(); // user counts change when a user is added too
    usersApi.refetch();
  }

  // Every company is meant to have an admin of its own, so the listing names them:
  // a tenant that still has none is visible at a glance rather than discovered when
  // its people ask why they cannot manage anything.
  //
  // One pass over users rather than one per company row. It also stops producing
  // the comma-joined STRING the old version returned: measured, that string took
  // 41% of the table's width - nearly double what the tenant's own name got - so
  // the cell renders the first name plus a "+N" counter and keeps the full list on
  // the title attribute.
  const adminsByCompany = React.useMemo(() => {
    const m = new Map();
    users.forEach((u) => {
      if (!isCompanyAdminRole(u.type)) return;
      const list = m.get(u.companyId) || [];
      list.push(u.username);
      m.set(u.companyId, list);
    });
    return m;
  }, [users]);
  const noAdminCount = companies.filter((c) => !adminsByCompany.has(c.companyId)).length;
  const unverifiedTotal = users.filter((u) => !u.isVerified).length;

  // ---- the Companies list's own disclosure + filter. Same shape as UsersCard's,
  // and client-side for the same reason: /api/admin/companies returns everything.
  const [cAdding, setCAdding] = useState(false);
  const cFirstRef = useRef(null);
  function openAddCompany() {
    setCAdding(true);
    setTimeout(() => { if (cFirstRef.current) cFirstRef.current.focus(); }, 0);
  }
  const [cq, setCq] = useState("");
  const [onlyNoAdmin, setOnlyNoAdmin] = useState(false);
  const shownCompanies = companies.filter((c) => {
    if (onlyNoAdmin && adminsByCompany.has(c.companyId)) return false;
    if (!cq.trim()) return true;
    const hay = ((c.companyName || "") + " " + companyTypeLabel(c.companyType) + " #" + c.companyId).toLowerCase();
    return hay.includes(cq.trim().toLowerCase());
  });
  const cFiltering = !!(cq.trim() || onlyNoAdmin);
  const showCBar = companies.length > ADMIN_FILTER_FROM;

  // A superadmin cannot delete the tenant their own account belongs to -- doing so
  // would delete themselves and every other superadmin sharing it, leaving an admin
  // surface nobody can reach. Refused server-side; the button is withheld here so the
  // one company that cannot go never offers one.
  const myCompanyId = Number(signedInUser().companyId) || 0;
  const [delCo, setDelCo] = useState(null);
  const [delCoBusy, setDelCoBusy] = useState(false);
  const [delCoErr, setDelCoErr] = useState("");

  function reallyDeleteCompany() {
    setDelCoBusy(true); setDelCoErr("");
    window.VCApi.adminDeleteCompany(delCo.companyId).then(
      () => { setDelCoBusy(false); setDelCo(null); refetchAll(); },
      (e) => { setDelCoBusy(false); setDelCoErr(e.message || "Could not delete that company."); },
    );
  }

  function addCompany(e) {
    e.preventDefault();
    setCErr(""); setCOk("");
    if (!cName.trim()) { setCErr("Enter the company name."); return; }
    setCBusy(true);
    window.VCApi.adminCreateCompany({ companyName: cName.trim(), companyType: cType })
      .then((created) => {
        setCBusy(false);
        setCName("");
        setCOk(`${created.companyName} onboarded (id ${created.companyId}). Give it a company administrator next.`);
        refetchAll();
      })
      .catch((error) => {
        setCBusy(false);
        setCErr((error && error.message) || "Could not create the company.");
      });
  }

  // By the company list alone: without it every card below would state there are no companies.
  if (companiesApi.loading && !companiesApi.data)
    return <PageLoading label="Loading companies and users…" />;
  if (companiesApi.error && !companiesApi.data)
    return <PageError message={companiesApi.error} onRetry={refetchAll} />;

  return (
    <div className="page">
      <div className="page-head">
        <div>
          <span className="eyebrow">Administration</span>
          {/* The old title, "Companies & users", named one of the four bands below
              and left the other three - the comment switch, Jira and the whole
              classification taxonomy - undeclared. The rail names the bands now, so
              the title is free to name the page, and the description below it is
              free to say what the page actually contains rather than describing
              only the first card on it. */}
          <h1 className="page-title">Platform administration</h1>
          {/* Two lines, not four. The rail below names the four bands, so the
              description no longer has to list them - it says what the page is
              for and leaves the contents to the contents bar. */}
          <p className="page-desc">Onboard tenant companies and the accounts that sign in to them, choose each company's features, and edit the vocabulary the whole catalog is scored against.</p>
        </div>
      </div>

      {/* The Platform screen's own stat strip, borrowed back. Every number is
          already in scope - no extra fetch - and it gives the band above the
          first card something to be other than 90px of near-empty canvas. Not
          interactive: the filter pills on each card do the narrowing, and the
          strip's separators are border-lefts that a <button> reset would fight. */}
      <div className="plat-strip admin-strip">
        <div className="plat-stat">
          <b>{companies.length}</b><span>tenant{companies.length === 1 ? "" : "s"}</span>
        </div>
        <div className="plat-stat">
          <b>{users.length}</b><span>account{users.length === 1 ? "" : "s"}</span>
        </div>
        <div className="plat-stat">
          <b className={unverifiedTotal ? "is-warn" : ""}>{unverifiedTotal}</b><span>not activated</span>
        </div>
        <div className="plat-stat">
          <b className={noAdminCount ? "is-warn" : ""}>{noAdminCount}</b><span>without an admin</span>
        </div>
      </div>

      <AdminSectionNav sections={jiraFor ? ADMIN_SECTIONS_SUPER : ADMIN_SECTIONS_SUPER_NO_JIRA} />

      {/* The band, not the two cards, is the navigable unit: two rail entries that
          scroll to the same y-position would be a rail you cannot trust.
          .admin-stack makes it one column. Side by side was not merely tight here,
          it was arithmetically impossible - see the note on .admin-stack in
          admin.css for the two numbers. */}
      <div className="admin-grid admin-stack admin-sec" id="admin-tenancy">

        {/* ------------------------------------------------------ companies */}
        <section className="card admin-card">
          <div className="card-head">
            <h3><Icon name="layers" size={16} /> Companies</h3>
            <div className="admin-head-act">
              <span className="card-sub">
                {cFiltering ? `${shownCompanies.length} of ${companies.length}` : companies.length} tenant{companies.length === 1 ? "" : "s"}
              </span>
              <button type="button" className={"admin-head-btn" + (cAdding ? " is-on" : "")}
                aria-expanded={cAdding} onClick={() => (cAdding ? setCAdding(false) : openAddCompany())}>
                <Icon name={cAdding ? "close" : "plus"} size={13} />{cAdding ? "Cancel" : "Add company"}
              </button>
            </div>
          </div>

          {cAdding && (
          <form className="admin-form" onSubmit={addCompany}>
            <label className="field admin-span2">
              <span className="field-lab">Company name</span>
              <div className="field-wrap">
                <Icon name="layers" size={16} className="field-ic" />
                <input ref={cFirstRef} value={cName} onChange={(e) => setCName(e.target.value)}
                  placeholder="e.g. Acme Motors" spellCheck="false" />
              </div>
            </label>
            <label className="field">
              <span className="field-lab">Type</span>
              <div className="field-wrap">
                <Icon name="chip" size={16} className="field-ic" />
                <select value={cType} onChange={(e) => setCType(e.target.value)}>
                  {ADMIN_COMPANY_TYPES.map(([v, label]) => <option key={v} value={v}>{label}</option>)}
                </select>
              </div>
            </label>
            <div className="admin-form-act">
              <button className="btn btn-accent" type="submit" disabled={cBusy}>
                {cBusy ? <span className="btn-spin" /> : <><Icon name="plus" size={15} />Add company</>}
              </button>
            </div>
          </form>
          )}

          <AdminNote err={cErr} ok={cOk} />

          {showCBar && (
            <div className="toolbar admin-listbar">
              <div className="search">
                <Icon name="search" size={16} className="search-ic" />
                <input value={cq} onChange={(e) => setCq(e.target.value)}
                  placeholder="Search tenant, type or id" spellCheck="false" aria-label="Search companies" />
                {cq && <button className="search-clear" aria-label="Clear search" onClick={() => setCq("")}><Icon name="close" size={14} /></button>}
              </div>
              {noAdminCount > 0 && (
                <button type="button" aria-pressed={onlyNoAdmin}
                  className={"admin-filter" + (onlyNoAdmin ? " is-on" : "")}
                  onClick={() => setOnlyNoAdmin((v) => !v)}>
                  Without an admin<span className="admin-filter-n">{noAdminCount}</span>
                </button>
              )}
            </div>
          )}

          {companies.length === 0 ? (
            <div className="empty">
              <Icon name="layers" size={24} />
              <p>No companies yet</p>
              <span className="empty-sub">A tenant owns its own SBOMs, accounts and Jira connection.</span>
              <button className="btn btn-accent" type="button" onClick={openAddCompany}>
                <Icon name="plus" size={15} />Onboard the first company
              </button>
            </div>
          ) : shownCompanies.length === 0 ? (
            <div className="empty">
              <Icon name="search" size={24} />
              <p>No tenant matches</p>
              <span className="empty-sub">Nothing in the tenant list matches the current filter.</span>
              <button className="btn btn-ghost" type="button" onClick={() => { setCq(""); setOnlyNoAdmin(false); }}>Clear filters</button>
            </div>
          ) : (
            <div className="admintbl-wrap">
              <table className="admintbl">
                <thead>
                  <tr>
                    <th className="admintbl-col-id">Company</th>
                    <th className="admintbl-col-type">Type</th>
                    <th className="admintbl-col-admin">Admin</th>
                    <th className="admintbl-num admintbl-col-count">Users</th>
                    <th className="admintbl-num"><span className="vc-sr-only">Actions</span></th>
                  </tr>
                </thead>
                <tbody>
                  {shownCompanies.map((c) => {
                    const admins = adminsByCompany.get(c.companyId) || [];
                    return (
                    <tr key={c.companyId}>
                      {/* Same identity cell as the Users table below it: marker, name,
                          quiet second line. The tenant id lives here rather than in a
                          column of its own — it is an attribute of the company, the way
                          the e-mail is of the user, and giving it a column cost this
                          table the width it needed for the rest. Type and the user
                          count join it on that same argument when the table folds. */}
                      <td className="admintbl-col-id">
                        <span className="admintbl-id">
                          <span className="entcard-avatar">{initialsOf(c.companyName)}</span>
                          <span className="admintbl-user">
                            <strong>{c.companyName || "—"}</strong>
                            <span className="admintbl-sub mono">#{c.companyId}</span>
                            <span className="admintbl-sub admintbl-fold">
                              {companyTypeLabel(c.companyType)} · {c.userCount} user{c.userCount === 1 ? "" : "s"}
                            </span>
                          </span>
                        </span>
                      </td>
                      <td className="admintbl-col-type">{companyTypeLabel(c.companyType)}</td>
                      <td className="admintbl-col-admin">
                        {admins.length ? (
                          // .admintbl-clip finally used. Its comment is right that
                          // text-overflow on a bare <td> does nothing under auto
                          // table layout — the markup simply never had the inner
                          // block to clip against.
                          <span className="admintbl-clip" title={admins.join(", ")}>
                            {admins[0]}
                            {admins.length > 1 && <span className="admintbl-more">+{admins.length - 1}</span>}
                          </span>
                        ) : <span className="admin-noadmin">None yet</span>}
                      </td>
                      <td className="admintbl-num admintbl-col-count">{c.userCount}</td>
                      <td className="admintbl-num">
                        {c.companyId !== myCompanyId && (
                          <button className="iconbtn iconbtn-danger" title={"Delete " + (c.companyName || "company")}
                            onClick={() => { setDelCoErr(""); setDelCo(c); }}>
                            <Icon name="trash" size={14} />
                          </button>
                        )}
                      </td>
                    </tr>
                    );
                  })}
                </tbody>
              </table>
            </div>
          )}
        </section>

        {delCo && (
          <DeleteConfirm
            eyebrow="Delete company" title={delCo.companyName || ("#" + delCo.companyId)}
            busy={delCoBusy} error={delCoErr}
            onCancel={() => { if (!delCoBusy) { setDelCo(null); setDelCoErr(""); } }}
            onConfirm={reallyDeleteCompany}>
            <p>This permanently removes the tenant and <b>everything filed under it</b>:</p>
            <ul className="del-list">
              <li><b>{delCo.userCount}</b> user {delCo.userCount === 1 ? "account" : "accounts"}</li>
              <li>every uploaded SBOM, and every vulnerability matched against it</li>
              <li>all triage — status, owners and deadlines</li>
              <li>the whole comment history and every evidence file</li>
              <li>their Jira connection, issue links and sync log</li>
            </ul>
            <p className="card-sub">
              This is the most destructive action in the product. Nothing is archived
              first, and re-creating a company of the same name does not bring any of
              it back.
            </p>
          </DeleteConfirm>
        )}

        <UsersCard
          companies={companies}
          users={users}
          canGrantSuperAdmin={true}
          canDelete={true}
          fixedCompanyId={null}
          companyLabel=""
          onChanged={refetchAll}
        />
      </div>

      {/* .admin-stack is the tenancy band's single column. */}
      <div className="admin-grid admin-stack admin-sec" id="admin-features">
        <CompanyFeatures companies={companies} onJiraFor={setJiraFor}
          onGone={() => { window.VCApi.invalidate("/api/admin/companies"); refetchAll(); }} />
      </div>

      {/* Wrapped rather than given the id on their own <section>, so both admin
          pages anchor these bands the same way and neither component has to know
          it is being navigated to. */}
      {jiraFor && (
        <div className="admin-sec" id="admin-jira">
          <JiraIntegrationPanel fixedCompanyId={jiraFor.companyId}
            onBehalfOf={jiraFor.companyName || `Company ${jiraFor.companyId}`} />
        </div>
      )}
      {/* Classification taxonomy: hidden 2026-11-10 on request, with its rail entry in
          ADMIN_SECTIONS_SUPER. Uncomment both to bring it back.
      <div className="admin-sec" id="admin-taxonomy">
        <TaxonomyManager />
      </div>
      */}
    </div>
  );
}

// ---------------------------------------------------------------------------
// Classification taxonomy editor. The OEM/vendor derivation rules, triage
// statuses and CVSS severity bands used to be hard-coded in the backend; they now
// live in ThreatCore tables and are edited here. Every save re-attributes the
// catalog (server evicts its cache), so the ledger/browse pivots reflect edits.
// ---------------------------------------------------------------------------
// Jira connection, per company.
//
// THE COMPANY COMES FROM THE CALLER. A COMPANY ADMIN configures one connection, their
// own. A SUPERADMIN configures a customer's connection on their behalf, the same way
// tenants and their users are onboarded here, and it is the company on show in Company
// features: this band only exists while Jira is on for it, so a picker of its own could
// only disagree. Either way, once connected any user in that company files issues from
// the ledger without ever seeing this panel.
//
// The API token is write-only from the UI's point of view: it is sent once, encrypted
// server-side, and never returned. Leaving the field blank on a later save keeps the
// stored token, which is why the field is empty (not masked) when one already exists.
// ---------------------------------------------------------------------------
const JIRA_BLANK = {
  baseUrl: "", authEmail: "", apiToken: "", defaultProjectKey: "", defaultIssueType: "Task",
  pushComments: false, addToActiveSprint: false, pushStatus: false, statusMap: {},
  // Inbound (Jira -> here) and the field sync, all off until a person turns them on.
  acceptWebhook: false, reverseStatusMap: {}, acceptComments: false, pushFields: false, priorityMap: {},
  // The other half of the fields, and evidence both ways (0022) — off for the same reason.
  acceptFields: false, pushEvidence: false, acceptEvidence: false,
  // Inviting people costs the customer a licence seat each (0025): never on by default.
  // Revoking acts on the customer's project (0026), so it is its own choice.
  autoProvisionUsers: false, revokeJiraAccess: false, commentAsUser: false,
  enabled: true,
};

// The severities the priority map offers rows for. Taxonomy-driven like everything
// else severity-shaped (ui/tags.jsx mutates SEV_ORDER in place when the config lands).
const jiraPriorityRows = () => (typeof SEV_ORDER !== "undefined" && SEV_ORDER.length ? SEV_ORDER : ["Critical", "High", "Medium", "Low"]);

// The reverse map is stored with lower-cased Jira names (the webhook matches that
// way), while the editor shows the project's real names — look up case-blind.
const reverseFor = (map, jiraName) => (map && map[String(jiraName || "").toLowerCase()]) || "";

// A STARTING POINT for an empty reverse map: the forward map turned around. If
// "Remediated" moves the issue to "Done", then "Done" arriving from Jira most likely
// means remediated. Only that — a Jira status the forward map never targets is left
// on "Ignore" for a person to decide. Never applied without a Save.
function suggestJiraReverseMap(statusMap) {
  const out = {};
  Object.entries(statusMap || {}).forEach(([vc, jira]) => {
    const key = String(jira || "").trim().toLowerCase();
    if (key && !out[key]) out[key] = vc;
  });
  return out;
}

// "Accepted risk" and "Risk Accepted" are the same status to a person; comparing the
// sorted words makes them the same here too.
const jiraWords = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim()
  .split(" ").filter(Boolean).sort().join(" ");

// A STARTING POINT for an empty status map, never applied without the admin saving it.
// A triage status is suggested a Jira status only when the names match word for word,
// or — for the three statuses whose meaning is unambiguous — when the project has
// exactly ONE status in the matching Jira category (To Do / In Progress / Done on a
// default workflow). Triaged and Accepted risk have no category equivalent, so they
// are left for a person to decide rather than guessed.
function suggestJiraStatusMap(jiraStatuses, keys) {
  const byWords = new Map();
  jiraStatuses.forEach((s) => { const w = jiraWords(s.name); if (w && !byWords.has(w)) byWords.set(w, s.name); });
  const categoryOf = { new: "new", "in-progress": "indeterminate", remediated: "done" };
  const out = {};
  keys.forEach((k) => {
    const hit = byWords.get(jiraWords(STATUS_META[k].label)) || byWords.get(jiraWords(k));
    if (hit) { out[k] = hit; return; }
    const cat = categoryOf[k];
    if (!cat) return;
    const inCat = jiraStatuses.filter((s) => s.category === cat);
    if (inCat.length === 1) out[k] = inCat[0].name;
  });
  return out;
}

// The rows the status map editor shows: the taxonomy's selectable statuses, narrowed to
// the keys the server says a status write can actually store (cfg.mappableStatuses).
// Without the narrowing, a status added in the Taxonomy tab would appear here, be
// suggested by name, and then fail the whole save — for a status the write path would
// coerce to "new" anyway. The fallback is the server's own list, for a config read that
// predates the field.
const JIRA_WRITABLE_FALLBACK = ["new", "triaged", "in-progress", "remediated", "accepted"];
function jiraMapRows(cfg) {
  const allowed = cfg && Array.isArray(cfg.mappableStatuses) ? cfg.mappableStatuses : JIRA_WRITABLE_FALLBACK;
  return STATUS_SELECTABLE.filter((k) => allowed.includes(k));
}

// The five panes of the Jira panel. Order is the order of the work: connect, decide
// what this product pushes, decide what it accepts, map the people, then read what
// actually happened. "To Jira" / "From Jira" rather than outbound/inbound because the
// switch copy inside them is written in those words too.
const JIRA_TABS = [
  ["connection", "Connection"],
  ["out", "To Jira"],
  ["in", "From Jira"],
  ["people", "People"],
  ["log", "Sync log"],
];

// The count on a tab. Hiding ten switches behind five tabs costs an admin the one
// thing the long scroll did give them — being able to see at a glance what is on —
// so each pane carries "on of total" and the log carries its row count. Connection
// gets none: the banner above the strip already says whether there is a connection.
function jiraTabBadge(id, f, log) {
  const on = (keys) => keys.filter((k) => f[k]).length + "/" + keys.length;
  if (id === "out") return on(["pushComments", "addToActiveSprint", "pushStatus", "pushFields", "pushEvidence"]);
  if (id === "in") return on(["acceptWebhook", "acceptComments", "acceptFields", "acceptEvidence"]);
  if (id === "people") return on(["autoProvisionUsers", "revokeJiraAccess", "commentAsUser"]);
  if (id === "log") return log.length ? String(log.length) : "";
  return "";
}

function JiraIntegrationPanel({ fixedCompanyId, onBehalfOf }) {
  const [companyId, setCompanyId] = useState(String(fixedCompanyId));
  const [cfg, setCfg] = useState(null);          // JiraConfigViewDto | null
  const [loading, setLoading] = useState(false);
  const [f, setF] = useState(JIRA_BLANK);
  const [projects, setProjects] = useState(null);   // null = not fetched yet
  const [issueTypes, setIssueTypes] = useState(null);
  const [jiraStatuses, setJiraStatuses] = useState(null);   // the project's real statuses; null = not fetched
  const [suggested, setSuggested] = useState(false);        // the map on screen came from suggestJiraStatusMap
  const [priorities, setPriorities] = useState(null);       // the site's priority names; null = not fetched
  const [reverseSuggested, setReverseSuggested] = useState(false);
  const [copied, setCopied] = useState(false);              // the webhook URL just went to the clipboard
  const [hook, setHook] = useState(null);                   // last registration outcome { state, message } | null
  const [manual, setManual] = useState(false);              // the by-hand URL box is open
  const [people, setPeople] = useState(null);               // { tier, users[], message } | null = not fetched
  const [oauthPeople, setOauthPeople] = useState(null);     // userId -> grant (0027) | null = not fetched
  // Whether this SERVICE can offer per-user Atlassian OAuth at all. Read from
  // /oauth/me, which answers for the signed-in caller but whose `available` and
  // `configError` describe the deployment, not the person: with
  // Jira__OAuth__ClientId / ClientSecret unset nobody in any company can connect.
  const [oauthCfg, setOauthCfg] = useState(null);           // { available, configError } | null
  const [peopleBusy, setPeopleBusy] = useState(0);          // the user id being provisioned, or 0
  const [busy, setBusy] = useState("");             // "" | save | test | projects | types | statuses | priorities | rotate | disconnect
  const [msg, setMsg] = useState(null);             // { err } | { ok }
  const [log, setLog] = useState([]);
  const [tab, setTab] = useState("connection");   // which pane of the five is open
  // The connection could not be read. Not "never connected": a form saved from the blank
  // one would keep the stored token and wipe every sync switch and map the company has.
  const [readErr, setReadErr] = useState("");
  const [reads, setReads] = useState(0);          // Try again re-runs the read
  const fRef = useRef(f);
  fRef.current = f;
  const cfgRef = useRef(cfg);
  cfgRef.current = cfg;
  const companyRef = useRef(companyId);
  companyRef.current = companyId;
  // Bumped whenever the company or project changes. A statuses fetch remembers the value
  // it started under and drops its answer if it moved on — otherwise a slow reply for
  // company A lands in company B's editor (and, with suggestions, in B's saved map).
  const statusReq = useRef(0);

  // A superadmin's moves with the Company features picker. A company admin's never does.
  useEffect(() => { setCompanyId(String(fixedCompanyId)); }, [fixedCompanyId]);

  useEffect(() => {
    if (!companyId) return;
    let live = true;
    setLoading(true); setMsg(null); setProjects(null); setIssueTypes(null);
    statusReq.current++; setJiraStatuses(null); setSuggested(false);
    setPriorities(null); setReverseSuggested(false); setCopied(false); setHook(null); setManual(false);
    // Back to the first pane too: a superadmin who switches tenant is looking at a
    // different company's connection, and the pane they were on described the old one.
    setPeople(null); setOauthPeople(null); setOauthCfg(null); setTab("connection"); setReadErr("");
    Promise.all([
      window.VCApi.adminJiraConfig(companyId).then((c) => ({ c }), (e) => ({ failed: e || {} })),
      window.VCApi.adminJiraLog(companyId, false).catch(() => []),
    ]).then(([read, l]) => {
      if (!live) return;
      const c = read.c || null;
      if (read.failed)
        setReadErr(`This company's Jira connection could not be read. ${read.failed.message || ""}`.trim());
      setCfg(c);
      setLog(Array.isArray(l) ? l : []);
      setF({
        baseUrl: (c && c.baseUrl) || "",
        authEmail: (c && c.authEmail) || "",
        apiToken: "",   // never round-trips; blank means "keep the stored one"
        defaultProjectKey: (c && c.defaultProjectKey) || "",
        defaultIssueType: (c && c.defaultIssueType) || "Task",
        // Off unless this company has explicitly turned it on — never inherited
        // from a default, because turning it on widens who can read the thread.
        pushComments: !!(c && c.pushComments),
        addToActiveSprint: !!(c && c.addToActiveSprint),
        pushStatus: !!(c && c.pushStatus),
        statusMap: (c && c.statusMap && typeof c.statusMap === "object") ? { ...c.statusMap } : {},
        acceptWebhook: !!(c && c.acceptWebhook),
        reverseStatusMap: (c && c.reverseStatusMap && typeof c.reverseStatusMap === "object") ? { ...c.reverseStatusMap } : {},
        acceptComments: !!(c && c.acceptComments),
        pushFields: !!(c && c.pushFields),
        priorityMap: (c && c.priorityMap && typeof c.priorityMap === "object") ? { ...c.priorityMap } : {},
        acceptFields: !!(c && c.acceptFields),
        pushEvidence: !!(c && c.pushEvidence),
        acceptEvidence: !!(c && c.acceptEvidence),
        autoProvisionUsers: !!(c && c.autoProvisionUsers),
        revokeJiraAccess: !!(c && c.revokeJiraAccess),
        commentAsUser: !!(c && c.commentAsUser),
        enabled: c ? c.enabled !== false : true,
      });
      setLoading(false);
      // A company that already syncs statuses (either way) gets the editors' dropdowns
      // filled on open, so the saved maps are shown against the project's real names.
      if (c && c.connected && (c.pushStatus || c.acceptWebhook) && c.defaultProjectKey)
        fetchJiraStatuses(c.defaultProjectKey, false);
      if (c && c.connected && c.pushFields) fetchPriorities();
      // "Post comments as the person who wrote them" is the one switch that can be ON
      // and still do nothing: it needs the service to carry an Atlassian OAuth app AND
      // each teammate to have consented in their own browser. Both answers are read
      // here, on open, so the switch can say which of the two is missing instead of
      // leaving an admin to discover it from the author of a Jira comment. Neither read
      // returns a token, and the roster is ids and states only.
      if (c && c.connected && c.commentAsUser) readOAuthHealth();
    });
    return () => { live = false; };
  }, [companyId, reads]);

  // The two reads the readiness line is built from. Extracted from the effect above
  // because the effect is keyed on [companyId] ALONE: turning the switch on and saving
  // does not change the company, so it never re-ran, and the line stayed silent at the
  // exact moment it had something to say — the admin ticks the box, saves, and gets no
  // answer about whether anybody can actually use it. The effect below re-runs this
  // whenever the SAVED flag becomes true.
  function readOAuthHealth() {
    if (!companyId) return;
    const forCompany = String(companyId);
    loadOAuthPeople(forCompany);
    window.VCApi.jiraOAuthStatus().then(
      (s) => { if (companyRef.current === forCompany) setOauthCfg(s || null); },
      () => { if (companyRef.current === forCompany) setOauthCfg(null); });
  }

  // Keyed on the SAVED flag, not on the checkbox: /oauth/me reports what the server
  // stored, so asking it about a draft would answer about the previous state. cfg is
  // replaced by save(), so this fires once the save lands.
  useEffect(() => {
    if (cfg && cfg.connected && cfg.commentAsUser) readOAuthHealth();
  }, [cfg && cfg.commentAsUser, cfg && cfg.connected, companyId]);

  const set = (k, v) => setF((prev) => ({ ...prev, [k]: v }));
  const draft = () => ({ ...f, defaultProjectKey: (f.defaultProjectKey || "").trim().toUpperCase() });

  function reload() {
    window.VCApi.invalidate("/api/integrations/jira");
    return Promise.all([
      window.VCApi.adminJiraConfig(companyId).then(setCfg).catch(() => {}),
      window.VCApi.adminJiraLog(companyId, false).then((l) => setLog(Array.isArray(l) ? l : [])).catch(() => {}),
    ]);
  }

  async function run(kind, fn) {
    setBusy(kind); setMsg(null);
    try {
      const r = await fn();
      setBusy("");
      return r;
    } catch (e) {
      setBusy("");
      setMsg({ err: (e && e.message) || "That did not work." });
      return null;
    }
  }

  // The pickers read through the SAVED connection, so they only work once one exists.
  // Before that an admin types the project key by hand — which is why the field falls
  // back to a text input rather than an empty dropdown.
  const loadProjects = () => run("projects", async () => {
    const list = await window.VCApi.adminJiraProjects(companyId);
    setProjects(Array.isArray(list) ? list : []);
    if (!list || !list.length) setMsg({ err: "That account cannot see any Jira projects." });
  });

  // The right-hand side of the status map: the project's REAL statuses, read through the
  // saved connection. Fills an EMPTY map with suggestions when asked to — never
  // overwrites a map someone already chose, and never saves anything by itself.
  function fetchJiraStatuses(projectKey, suggestIntoEmpty) {
    const key = String(projectKey || "").trim().toUpperCase();
    if (!key) { setMsg({ err: "Choose a project first — statuses belong to a project." }); return Promise.resolve(); }
    const seq = ++statusReq.current;
    const forCompany = companyId;
    const stillCurrent = () => seq === statusReq.current && companyRef.current === forCompany;
    return run("statuses", async () => {
      let list;
      try {
        list = await window.VCApi.adminJiraProjectStatuses(forCompany, key);
      } catch (e) {
        if (stillCurrent()) throw e;   // a stale failure is not this screen's error either
        return;
      }
      if (!stillCurrent()) return;
      const arr = Array.isArray(list) ? list : [];
      setJiraStatuses(arr);
      if (!arr.length) { setMsg({ err: `${key} reports no statuses this Atlassian account can see.` }); return; }
      if (!suggestIntoEmpty) return;
      // Read the map as it is NOW (the admin may have picked something while the
      // statuses were loading), not as it was when the fetch started.
      const current = fRef.current.statusMap || {};
      if (Object.values(current).some((v) => v && String(v).trim())) return;
      const s = suggestJiraStatusMap(arr, jiraMapRows(cfgRef.current));
      if (!Object.keys(s).length) return;
      setF((prev) => ({ ...prev, statusMap: s }));
      setSuggested(true);
    });
  }

  const setMapped = (statusKey, jiraName) => {
    setSuggested(false);
    setF((prev) => {
      const next = { ...(prev.statusMap || {}) };
      if (jiraName) next[statusKey] = jiraName; else delete next[statusKey];
      return { ...prev, statusMap: next };
    });
  };

  // The reverse map, keyed by the lower-cased Jira name the webhook will match on.
  const setReverse = (jiraName, statusKey) => {
    setReverseSuggested(false);
    const key = String(jiraName || "").trim().toLowerCase();
    setF((prev) => {
      const next = { ...(prev.reverseStatusMap || {}) };
      if (statusKey) next[key] = statusKey; else delete next[key];
      return { ...prev, reverseStatusMap: next };
    });
  };
  // Fills an EMPTY reverse map from the forward one — a suggestion, applied by Save.
  function suggestReverse() {
    const current = fRef.current.reverseStatusMap || {};
    if (Object.values(current).some((v) => v)) return;
    const s = suggestJiraReverseMap(fRef.current.statusMap);
    if (!Object.keys(s).length) return;
    setF((prev) => ({ ...prev, reverseStatusMap: s }));
    setReverseSuggested(true);
  }


  // ---- People (0025) -------------------------------------------------------
  // The roster is fetched on demand, not with the config: it costs a Jira call per
  // linked teammate when probing, and most visits to this panel are not about people.

  const loadPeople = (probe) => run("people", async () => {
    const r = await window.VCApi.adminJiraUsers(companyId, probe !== false);
    setPeople(r || null);
  });

  // `invite` is the decision to spend one of the customer's Jira licence seats. It is
  // never implied: the caller asks, and for a real invitation the admin confirms first.
  // Who has connected their own Atlassian account, keyed by user id. Read with the
  // roster rather than per row, and only ids and states come back — never a token.
  async function loadOAuthPeople(forCompany) {
    if (!companyId) return;
    const want = forCompany || String(companyId);
    try {
      const rows = await window.VCApi.adminJiraOAuthPeople(want);
      const map = {};
      (Array.isArray(rows) ? rows : []).forEach((r) => { if (r && r.connected) map[r.userId] = r; });
      // A superadmin can switch tenant while this is in flight; without the guard the
      // previous company's grants would be shown against the new company's roster.
      if (companyRef.current === want) setOauthPeople(map);
    } catch (_) { if (companyRef.current === want) setOauthPeople(null); }
  }

  function provisionOne(u, invite) {
    if (invite && !window.confirm(
      "Invite " + (u.email || u.username) + " to Jira?\n\n"
      + "This creates an Atlassian account on the customer's site, uses one of its licence seats, "
      + "and Atlassian will e-mail them.")) return;
    setPeopleBusy(u.userId);
    return window.VCApi.adminProvisionJiraUser(companyId, u.userId, invite).then(
      (r) => {
        setPeopleBusy(0);
        setMsg(r && r.ok ? { ok: r.detail } : { err: (r && r.detail) || "Jira did not accept that." });
        return loadPeople(true);
      },
      (e) => { setPeopleBusy(0); setMsg({ err: (e && e.message) || "That teammate could not be provisioned." }); });
  }

  function provisionAll(invite) {
    const unlinked = ((people && people.users) || []).filter((u) => u.state === "unlinked").length;
    if (invite && !window.confirm(
      "Invite every teammate who has no Jira account?\n\n"
      + unlinked + " teammate(s) have none. Each invitation uses one of the site's Jira licence seats "
      + "and Atlassian will e-mail them.")) return;
    return run("people", async () => {
      const r = await window.VCApi.adminProvisionJiraUsers(companyId, invite);
      setMsg(r && r.failed ? { err: r.failed + " of " + (r.results || []).length + " could not be provisioned — see the sync log." }
        : { ok: (r && r.changed ? r.changed : 0) + " teammate(s) updated in Jira." });
      const fresh = await window.VCApi.adminJiraUsers(companyId, true);
      setPeople(fresh || null);
    });
  }

  function linkByHand(u) {
    const id = window.prompt(
      "Jira account id for " + u.username + "\n\n"
      + "Open the person's profile in Jira; the id is the last part of the URL "
      + "(.../jira/people/712020:xxxxxxxx).", "");
    if (!id) return;
    setPeopleBusy(u.userId);
    return window.VCApi.adminLinkJiraUser(companyId, u.userId, id.trim()).then(
      (r) => {
        setPeopleBusy(0);
        setMsg(r && r.ok ? { ok: r.detail } : { err: (r && r.detail) || "That account could not be linked." });
        return loadPeople(true);
      },
      (e) => { setPeopleBusy(0); setMsg({ err: (e && e.message) || "That account could not be linked." }); });
  }

  function unlinkPerson(u) {
    if (!window.confirm("Forget the Jira account for " + u.username + "?\n\nTheir Jira account is not changed — only the link recorded here.")) return;
    setPeopleBusy(u.userId);
    return window.VCApi.adminUnlinkJiraUser(companyId, u.userId).then(
      () => { setPeopleBusy(0); return loadPeople(true); },
      (e) => { setPeopleBusy(0); setMsg({ err: (e && e.message) || "The link could not be removed." }); });
  }

  const setPriority = (severity, name) => setF((prev) => {
    const next = { ...(prev.priorityMap || {}) };
    const key = String(severity).toLowerCase();
    if (name) next[key] = name; else delete next[key];
    return { ...prev, priorityMap: next };
  });

  // The site's priorities, read through the saved connection like the statuses.
  function fetchPriorities() {
    const forCompany = companyRef.current;
    return run("priorities", async () => {
      let list;
      try { list = await window.VCApi.adminJiraPriorities(forCompany); }
      catch (e) { if (companyRef.current === forCompany) throw e; return; }
      if (companyRef.current !== forCompany) return;
      setPriorities(Array.isArray(list) ? list : []);
    });
  }

  // A new secret. The URL Jira was given dies the moment this returns, which is the
  // point — it is the "someone saw the URL" remedy — so the admin is asked first.
  const registered = !!(cfg && cfg.webhookRegistrationId);

  const rotate = () => {
    const warn = registered
      ? "Issue a new webhook secret? The current URL stops working immediately; the webhook registered in Jira is updated to the new one in the same step."
      : "Issue a new webhook secret? The current URL stops working immediately, and a webhook registered by hand in Jira must be updated to the new one.";
    if (!window.confirm(warn)) return;
    return run("rotate", async () => {
      const r = await window.VCApi.adminRotateJiraWebhookSecret(companyId);
      // The new URL is shown from the rotate response itself: the old one is already
      // dead, and a reload that fails must not leave it on screen under a success note.
      if (r && r.webhookUrl) setCfg((c) => ({ ...(c || {}), webhookUrl: r.webhookUrl, webhookLastAt: null }));
      setCopied(false);
      if (r && r.jiraState) setHook({ state: r.jiraState, message: r.jiraMessage });
      setMsg(r && r.jiraUpdated
        ? { ok: "New secret issued and the webhook in Jira now points at the new URL." }
        : r && r.jiraState
          ? { err: "New secret issued, but the webhook in Jira could not be updated: " + (r.jiraMessage || "") }
          : { ok: "New webhook URL issued. Update the webhook in Jira (System → WebHooks) to the URL below, or click Connect to Jira." });
      await reload();
    });
  };

  // The one button. Registers our webhook on the Jira site through its admin API —
  // URL, events, JQL — or repairs one that drifted. A token that is not a Jira
  // administrator cannot, and the answer opens the by-hand box with the steps.
  const connectWebhook = () => run("hook", async () => {
    const r = await window.VCApi.adminRegisterJiraWebhook(companyId);
    setHook(r ? { state: r.state, message: r.message } : null);
    if (r && r.webhookUrl) setCfg((c) => ({ ...(c || {}), webhookUrl: r.webhookUrl }));
    if (r && r.ok) setMsg({ ok: r.message });
    else { setMsg({ err: (r && r.message) || "Jira did not accept the webhook." }); setManual(true); }
    await reload();
  });

  const checkWebhook = () => run("hook", async () => {
    const r = await window.VCApi.adminCheckJiraWebhook(companyId);
    setHook(r ? { state: r.state, message: r.message } : null);
    if (r && r.state === "not-admin") setManual(true);
  });

  const removeWebhook = () => {
    if (!window.confirm("Remove the webhook from Jira? Changes made in Jira stop arriving until it is connected again (the periodic check still covers statuses).")) return;
    return run("hook", async () => {
      const r = await window.VCApi.adminUnregisterJiraWebhook(companyId);
      setHook(null);
      setMsg(r && r.ok ? { ok: r.message } : { err: (r && r.message) || "The webhook could not be removed." });
      await reload();
    });
  };

  const copyWebhookUrl = async () => {
    if (!cfg || !cfg.webhookUrl) return;
    try {
      await navigator.clipboard.writeText(cfg.webhookUrl);
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    } catch (_) {
      setMsg({ err: "The browser refused the clipboard — select the URL and copy it by hand." });
    }
  };

  const loadIssueTypes = () => run("types", async () => {
    const key = (f.defaultProjectKey || "").trim().toUpperCase();
    if (!key) { setMsg({ err: "Choose a project first." }); return; }
    const list = await window.VCApi.adminJiraIssueTypes(companyId, key);
    setIssueTypes(Array.isArray(list) ? list : []);
  });

  const test = () => run("test", async () => {
    const r = await window.VCApi.adminTestJira(companyId, draft());
    setMsg(r && r.ok ? { ok: r.message } : { err: (r && r.message) || "Jira rejected the connection." });
    await reload();
  });

  const save = (e) => {
    e.preventDefault();
    if (readErr) return undefined;   // Enter in a field submits the form too
    return run("save", async () => {
      const saved = await window.VCApi.adminSaveJiraConfig(companyId, draft());
      setCfg(saved);
      setF((prev) => ({ ...prev, apiToken: "" }));
      setSuggested(false); setReverseSuggested(false);   // saved now — the "check, then Save" notes no longer apply
      setMsg({ ok: saved && saved.jiraTurnedOff
        ? "Connection saved. Jira is turned off for this company, so nothing is filed until it is turned on in Company features."
        : saved && saved.enabled === false
          ? "Connection saved. Nothing is filed until “Integration active” is ticked."
          : "Connection saved. Users in this company can now file issues from the ledger." });
      await reload();
    });
  };

  const disconnect = () => run("disconnect", async () => {
    await window.VCApi.adminDisconnectJira(companyId);
    setCfg(null);
    setF(JIRA_BLANK); setJiraStatuses(null); setSuggested(false); setPriorities(null); setReverseSuggested(false);
    setMsg({ ok: "Disconnected. Existing issue links were kept, so reconnecting the same site restores them." });
    await reload();
  });

  const connected = !!(cfg && cfg.connected);
  const tokenBroken = connected && cfg.hasToken && !cfg.tokenReadable;

  return (
    <section className="card admin-card admin-span-full">
      <div className="card-head">
        <h3><Icon name="link" size={16} /> Jira</h3>
        <span className="card-sub">File a vulnerability as an issue in the customer's tracker</span>
      </div>

      <div className="jira-panel">
        {/* A superadmin is setting a connection up on some customer's behalf, so the
            card says whose. Everything below this strip belongs to that company, and
            moving the picker reloads all of it, including a token field that may be
            half-typed. Worth one sentence. */}
        {onBehalfOf && (
          <div className="jira-tenant">
            <div className="jira-tenant-name">
              <span className="field-lab">Configuring Jira for</span>
              <b>{onBehalfOf}</b>
            </div>
            <p className="jira-tenant-note">Credentials are stored against this tenant only. Changing the company in Company features reloads the form below.</p>
          </div>
        )}

        {/* Server-side key problems are diagnosed here rather than as a failed save:
            without Jira:TokenKey no token can be stored at all. */}
        {cfg && cfg.keyReady === false &&
          <div className="jira-state is-warn">
            <Icon name="alert" size={15} />
            <span>{cfg.keyError} Set <b>Jira__TokenKey</b> on the app service, then reload this page.</span>
          </div>}

        {tokenBroken &&
          <div className="jira-state is-warn">
            <Icon name="alert" size={15} />
            <span>The saved API token cannot be read — this happens when the server encryption key is rotated. Enter a fresh token below.</span>
          </div>}

        {/* Five states, not two. "Never configured" and "saved but switched off" both
            used to render as nothing and as green respectively — the first left an
            admin staring at an unexplained form, the second called a dead integration
            connected. The last branch is guarded three ways on purpose: tokenBroken and
            a missing server key have each already drawn their own banner above, and a
            null cfg means the config fetch FAILED (an unconfigured tenant still returns
            a dto), so none of those may be reported as "nothing set up yet". */}
        {loading
          ? <div className="jira-state">
              <Icon name="refresh" size={15} className="jira-state-spin" />
              <span>Reading this company's connection…</span>
            </div>
          : readErr
            ? <div className="jira-state is-warn">
                <Icon name="alert" size={15} />
                <span>{readErr} Nothing here can be saved until it has been read.</span>
                <button type="button" className="admin-head-btn" onClick={() => setReads((n) => n + 1)}>
                  <Icon name="refresh" size={13} />Try again
                </button>
              </div>
          : connected && !tokenBroken
            ? <div className={"jira-state " + (cfg.jiraTurnedOff || cfg.enabled === false ? "is-off" : "is-ok")}>
                <Icon name={cfg.jiraTurnedOff || cfg.enabled === false ? "clock" : "check"} size={15} />
                {cfg.jiraTurnedOff
                  ? <span>Configured for <b>{cfg.baseUrl}</b>, but Jira is <b>turned off</b> for this company in Company features — nothing is filed or synced until it is turned on there. “Integration active” is kept as saved, and existing issue links are untouched.</span>
                  : cfg.enabled === false
                  ? <span>Configured for <b>{cfg.baseUrl}</b> but <b>switched off</b> — nothing new is filed until “Integration active” is ticked again. Existing issue links are untouched.</span>
                  : <span>Connected to <b>{cfg.baseUrl}</b> — new issues go to <b>{cfg.defaultProjectKey}</b> as <b>{cfg.defaultIssueType}</b>.</span>}
              </div>
            : !tokenBroken && cfg && cfg.keyReady !== false &&
              <div className="jira-state is-new">
                <Icon name="link" size={15} />
                <span>No connection for this company yet. Fill in the site, account and token, then <b>Save connection</b> — the project and issue-type pickers read through the saved connection, so they stay disabled until one exists.</span>
              </div>}


        {/* Five tabs, not one long scroll. The strip is the .tax-tabs recipe the
            taxonomy manager further down this same screen already uses, which brings
            the whole tablist keyboard contract with it; it is deliberately NOT sticky,
            because the Administration section rail above it already owns z-index 5.
            The panes are conditionally MOUNTED, as the taxonomy manager mounts its
            editors: every input in this panel is controlled off `f`, so an unmounted
            pane keeps its draft — a half-typed token included — and Save posts the
            whole of `f` from whichever tab is open. What must NOT move into a pane is
            the footer and its note: `msg` is one panel-level slot written by every
            tab, so a result raised on one must stay readable from another. */}
        <div className="tax-tabs jira-tabs" role="tablist" aria-label="Jira settings sections"
          onKeyDown={(e) => {
            const cur = JIRA_TABS.findIndex(([id]) => id === tab);
            let next = -1;
            if (e.key === "ArrowRight") next = (cur + 1) % JIRA_TABS.length;
            else if (e.key === "ArrowLeft") next = (cur - 1 + JIRA_TABS.length) % JIRA_TABS.length;
            else if (e.key === "Home") next = 0;
            else if (e.key === "End") next = JIRA_TABS.length - 1;
            if (next < 0) return;
            e.preventDefault();
            const btns = e.currentTarget.querySelectorAll(".tax-tab");
            setTab(JIRA_TABS[next][0]);
            if (btns[next]) btns[next].focus();
          }}>
          {JIRA_TABS.map(([id, label]) => {
            const badge = jiraTabBadge(id, f, log);
            return (
              <button key={id} type="button" id={"jira-tab-" + id}
                role="tab" aria-selected={tab === id}
                aria-controls={tab === id ? "jira-pane-" + id : undefined}
                tabIndex={tab === id ? 0 : -1}
                className={"tax-tab" + (tab === id ? " is-on" : "")}
                onClick={() => setTab(id)}>
                {label}
                {badge && <span className="tax-tab-count">{badge}</span>}
              </button>
            );
          })}
        </div>

        <form className="jira-panel" onSubmit={save}>
          {/* Only the current pane is mounted, so the pane id is built from `tab` and
              only the selected tab points at it — a dangling aria-controls on the other
              four would be worse than none. */}
          <div className="jira-pane" role="tabpanel" id={"jira-pane-" + tab}
            aria-labelledby={"jira-tab-" + tab}>

            {tab === "connection" &&
              <>
                <div className="jira-grid">
                  <label className="field field-wide">
                    <span className="field-lab">Jira site URL</span>
                    <div className="field-wrap">
                      <Icon name="link" size={16} className="field-ic" />
                      <input value={f.baseUrl} onChange={(e) => set("baseUrl", e.target.value)}
                        placeholder="https://your-site.atlassian.net" spellCheck="false" />
                    </div>
                  </label>
                  <label className="field">
                    <span className="field-lab">Atlassian account e-mail</span>
                    <div className="field-wrap">
                      <Icon name="user" size={16} className="field-ic" />
                      <input value={f.authEmail} onChange={(e) => set("authEmail", e.target.value)}
                        placeholder="integrations@customer.com" spellCheck="false" />
                    </div>
                  </label>
                  <label className="field">
                    <span className="field-lab">API token{cfg && cfg.hasToken && !tokenBroken ? " (leave blank to keep)" : ""}</span>
                    <div className="field-wrap">
                      <Icon name="shield" size={16} className="field-ic" />
                      <input type="password" value={f.apiToken} onChange={(e) => set("apiToken", e.target.value)}
                        placeholder={cfg && cfg.hasToken && !tokenBroken ? "•••••••• stored" : "Paste the API token"}
                        spellCheck="false" autoComplete="new-password" />
                    </div>
                  </label>
                  <label className="field">
                    <span className="field-lab">Project</span>
                    <div className="field-wrap">
                      <Icon name="target" size={16} className="field-ic" />
                      {projects && projects.length
                        ? <select value={f.defaultProjectKey} onChange={(e) => { set("defaultProjectKey", e.target.value); setIssueTypes(null); statusReq.current++; setJiraStatuses(null); setSuggested(false); }}>
                            <option value="">Choose a project…</option>
                            {projects.map((p) => <option key={p.key} value={p.key}>{p.name} ({p.key})</option>)}
                          </select>
                        : <input value={f.defaultProjectKey} onChange={(e) => { set("defaultProjectKey", e.target.value); setIssueTypes(null); statusReq.current++; setJiraStatuses(null); setSuggested(false); }}
                            placeholder="SEC" spellCheck="false" />}
                      {/* The picker is fetched THROUGH the saved connection, so it is inert
                          until the first save. Said on the control, where it is read, rather
                          than left to be guessed from a greyed-out button at the far end of
                          the card. Same nesting as the login card's Show/Hide: a <button> is
                          interactive content, so the <label> does not forward the click. */}
                      <span className="jira-load-wrap" title={connected ? "Fetch the projects this Atlassian account can see" : "Save the connection first — the list is fetched through it"}>
                        <button className="jira-load" type="button" onClick={loadProjects} disabled={!!busy || !connected}>
                          <Icon name="refresh" size={12} className={busy === "projects" ? "jira-load-spin" : ""} />
                          {projects ? "Reload" : "Load"}
                        </button>
                      </span>
                    </div>
                  </label>
                  <label className="field">
                    <span className="field-lab">Issue type</span>
                    <div className="field-wrap">
                      <Icon name="flag" size={16} className="field-ic" />
                      {issueTypes && issueTypes.length
                        ? <select value={f.defaultIssueType} onChange={(e) => set("defaultIssueType", e.target.value)}>
                            {issueTypes.map((t) => <option key={t.id} value={t.name}>{t.name}</option>)}
                          </select>
                        : <input value={f.defaultIssueType} onChange={(e) => set("defaultIssueType", e.target.value)}
                            placeholder="Task" spellCheck="false" />}
                      <span className="jira-load-wrap" title={connected ? "Fetch the issue types available on the chosen project" : "Save the connection first — the list is fetched through it"}>
                        <button className="jira-load" type="button" onClick={loadIssueTypes} disabled={!!busy || !connected}>
                          <Icon name="refresh" size={12} className={busy === "types" ? "jira-load-spin" : ""} />
                          {issueTypes ? "Reload" : "Load"}
                        </button>
                      </span>
                    </div>
                  </label>
                </div>
                <p className="jira-hint">
                  Create the token from the Atlassian account security settings. It needs
                  <b> Browse Projects</b> and <b>Create Issues</b> on the chosen project — Test connection checks both.
                  The token is encrypted before it is stored and is never shown again.
                </p>
                <div className="jira-switches">
                  <div className="jira-switch">
                    <input type="checkbox" checked={f.enabled} onChange={(e) => set("enabled", e.target.checked)} id="jsw-enabled" />
                    <span className="jira-switch-txt">
                      <label className="jira-sw-lab" htmlFor="jsw-enabled">
                        <b>Integration active</b>
                      </label>
                      <details className="jira-why">
                        <summary>What this does</summary>
                          <span>Untick to stop anything new being filed. The connection and every existing issue link are kept, so ticking it again resumes where this left off.</span>
                      </details>
                    </span>
                  </div>
                </div>
              </>}

            {tab === "out" &&
              <div className="jira-switches">
                {/* Said plainly, because this is the one switch here that changes who can
                    read something people wrote expecting a smaller audience. */}
                <div className="jira-switch is-privacy">
                  <input type="checkbox" checked={f.pushComments} onChange={(e) => set("pushComments", e.target.checked)} id="jsw-pushComments" />
                  <span className="jira-switch-txt">
                    <label className="jira-sw-lab" htmlFor="jsw-pushComments">
                      <b>Copy comments to the linked Jira issue</b>
                    </label>
                    <details className="jira-why">
                      <summary>What this does</summary>
                        <span>
                          A comment written on a vulnerability that has been filed to Jira is posted onto that
                          issue, and editing it rewrites the Jira comment. <b>Anyone who can see the issue can
                          read it</b> — usually a wider group than your VulnerabilityCore users. Comments on
                          components, and on vulnerabilities with no issue, are never copied, and existing
                          comments are not sent when a vulnerability is first filed.
                        </span>
                    </details>
                  </span>
                </div>

                {/* A Scrum board shows only the running sprint, so an issue filed through
                    the API — which has no sprint — lands in the backlog and the board a
                    customer is looking at stays empty. This is the switch that makes filing
                    visible; it is off by default because it is a scheduling decision. */}
                <div className="jira-switch">
                  <input type="checkbox" checked={f.addToActiveSprint} onChange={(e) => set("addToActiveSprint", e.target.checked)} id="jsw-addToActiveSprint" />
                  <span className="jira-switch-txt">
                    <label className="jira-sw-lab" htmlFor="jsw-addToActiveSprint">
                      <b>Add new issues to the active sprint</b>
                    </label>
                    <details className="jira-why">
                      <summary>What this does</summary>
                        <span>
                          A Scrum board only shows the sprint that is running; anything filed without one
                          sits in the backlog. With this on, each issue is moved into the project's active
                          sprint the moment it is created. It needs a Scrum board with a sprint started — a
                          Kanban project, or a gap between sprints, leaves the issue in the backlog and says
                          so in the sync log. The token needs <b>Schedule Issues</b> on the project; Test
                          connection reports which sprint new issues would go into.
                        </span>
                    </details>
                  </span>
                </div>

                {/* Status sync is the one switch that ACTS on the customer's workflow, so it
                    carries its own editor: nothing moves until a person has chosen, per
                    status, which Jira status it means. Ticking the box fetches the project's
                    real statuses and suggests matches into an empty map — suggestions only;
                    Save is still what makes them real. */}
                <div className="jira-switch">
                  <input type="checkbox" checked={f.pushStatus} onChange={(e) => {
                    const on = e.target.checked;
                    set("pushStatus", on);
                    if (on && connected && !jiraStatuses) fetchJiraStatuses(f.defaultProjectKey, true);
                  }} id="jsw-pushStatus" />
                  <span className="jira-switch-txt">
                    <label className="jira-sw-lab" htmlFor="jsw-pushStatus">
                      <b>Move the Jira issue when the status changes here</b>
                    </label>
                    <details className="jira-why">
                      <summary>What this does</summary>
                        <span>
                          Setting a vulnerability to a status below moves its linked issue to the Jira status
                          you map it to, and a newly filed issue starts in the vulnerability's current status.
                          A status left on <b>Don't move</b> never changes the issue. Jira only moves an issue
                          along its own workflow, so a jump the workflow forbids is reported in the sync log
                          rather than forced. This is the outbound half — the switch below handles a move
                          made in Jira. The token needs <b>Transition Issues</b> on the project.
                        </span>
                    </details>
                  </span>
                </div>

                {f.pushStatus &&
                  <div className="jira-map" role="group" aria-label="Triage status to Jira status">
                    <div className="jira-map-head">
                      <span className="jira-map-title">
                        {jiraStatuses
                          ? <>Statuses in <b>{(f.defaultProjectKey || "").toUpperCase()}</b></>
                          : connected
                            ? "Load the project's statuses to choose from them"
                            : "Save the connection first — the project's statuses are read through it"}
                      </span>
                      <span className="jira-load-wrap" title={connected ? "Fetch the statuses of the chosen project" : "Save the connection first — the list is fetched through it"}>
                        <button className="jira-load" type="button" disabled={!!busy || !connected}
                          onClick={() => fetchJiraStatuses(f.defaultProjectKey, true)}>
                          <Icon name="refresh" size={12} className={busy === "statuses" ? "jira-load-spin" : ""} />
                          {jiraStatuses ? "Reload" : "Load"}
                        </button>
                      </span>
                    </div>
                    {suggested &&
                      <p className="jira-map-note">Suggested by matching names — check each row, then <b>Save connection</b>.</p>}
                    {jiraMapRows(cfg).map((k) => {
                      const current = (f.statusMap && f.statusMap[k]) || "";
                      const names = jiraStatuses ? [...new Set(jiraStatuses.map((s) => s.name))] : [];
                      // The push matches names case-insensitively, so a saved "In progress"
                      // IS Jira's "In Progress" — show it selected as that, not as "Don't
                      // move" (which would also make clearing it impossible: re-picking the
                      // option already shown fires no change). Only a name with no match at
                      // all is stale.
                      const canon = current ? names.find((n) => n.toLowerCase() === current.toLowerCase()) : undefined;
                      const stale = !!(current && jiraStatuses && !canon);
                      const value = canon || current;
                      return (
                        <label className="jira-map-row" key={k}>
                          <span className="jira-map-from">
                            <span className="chip-dot" style={{ background: STATUS_META[k].dot }} />
                            {STATUS_META[k].label}
                          </span>
                          <Icon name="arrowR" size={14} className="jira-map-arrow" />
                          <span className="field-wrap jira-map-to">
                            <select value={value} disabled={!jiraStatuses && !current}
                              onChange={(e) => setMapped(k, e.target.value)}>
                              <option value="">Don't move</option>
                              {!jiraStatuses && current && <option value={current}>{current}</option>}
                              {stale && <option value={current}>{current} (not in this project)</option>}
                              {names.map((n) => <option key={n} value={n}>{n}</option>)}
                            </select>
                          </span>
                        </label>
                      );
                    })}
                  </div>}

                {/* The field sync: three real Jira fields that follow the record. Off by
                    default because the assignee half writes a PERSON onto the customer's
                    issue, found by e-mail — a lookup that fails quietly on a site that
                    hides addresses, which is why the copy says what happens then. */}
                <div className="jira-switch">
                  <input type="checkbox" checked={f.pushFields} onChange={(e) => {
                    const on = e.target.checked;
                    set("pushFields", on);
                    if (on && connected && !priorities) fetchPriorities();
                  }} id="jsw-pushFields" />
                  <span className="jira-switch-txt">
                    <label className="jira-sw-lab" htmlFor="jsw-pushFields">
                      <b>Keep the issue's assignee, due date and priority in step</b>
                    </label>
                    <details className="jira-why">
                      <summary>What this does</summary>
                        <span>
                          The vulnerability's owner becomes the issue's assignee (found in Jira by e-mail; with
                          several owners, the most recently added), the remediation deadline becomes its due date,
                          and the severity sets its priority through the map below. Set on filing and pushed on
                          every change here; clearing the owner or deadline here clears the field there. An owner
                          whose e-mail Jira cannot see is left unassigned there and noted in the sync log — the
                          other two fields still go. The token needs <b>Assign Issues</b> and <b>Edit Issues</b>.
                        </span>
                    </details>
                  </span>
                </div>

                {f.pushFields &&
                  <div className="jira-map" role="group" aria-label="Severity to Jira priority">
                    <div className="jira-map-head">
                      <span className="jira-map-title">
                        {priorities
                          ? <>Priorities on <b>{(f.baseUrl || "").replace(/^https?:\/\//, "")}</b></>
                          : connected ? "Load the site's priorities to choose from them"
                            : "Save the connection first — the site's priorities are read through it"}
                      </span>
                      <span className="jira-load-wrap" title={connected ? "Fetch the site's priority names" : "Save the connection first — the list is fetched through it"}>
                        <button className="jira-load" type="button" disabled={!!busy || !connected} onClick={fetchPriorities}>
                          <Icon name="refresh" size={12} className={busy === "priorities" ? "jira-load-spin" : ""} />
                          {priorities ? "Reload" : "Load"}
                        </button>
                      </span>
                    </div>
                    {jiraPriorityRows().map((sev) => {
                      const current = (f.priorityMap && f.priorityMap[String(sev).toLowerCase()]) || "";
                      const names = priorities || [];
                      const canon = current ? names.find((n) => n.toLowerCase() === current.toLowerCase()) : undefined;
                      const stale = !!(current && priorities && !canon);
                      const value = canon || current;
                      const dot = "var(--" + sevClass(sev) + ", var(--text-faint))";
                      return (
                        <label className="jira-map-row" key={sev}>
                          <span className="jira-map-from">
                            <span className="chip-dot" style={{ background: dot }} />
                            {sev}
                          </span>
                          <Icon name="arrowR" size={14} className="jira-map-arrow" />
                          <span className="field-wrap jira-map-to">
                            <select value={value} disabled={!priorities && !current} onChange={(e) => setPriority(sev, e.target.value)}>
                              <option value="">Leave as it is</option>
                              {!priorities && current && <option value={current}>{current}</option>}
                              {stale && <option value={current}>{current} (not on this site)</option>}
                              {names.map((n) => <option key={n} value={n}>{n}</option>)}
                            </select>
                          </span>
                        </label>
                      );
                    })}
                  </div>}

                {/* Evidence, both ways. Each half is its own choice, like comments: attaching
                    internal test reports to a customer's tracker is a decision, and so is
                    letting a Jira user's screenshots into the audit record here. */}
                <div className="jira-switch is-privacy">
                  <input type="checkbox" checked={f.pushEvidence} onChange={(e) => set("pushEvidence", e.target.checked)} id="jsw-pushEvidence" />
                  <span className="jira-switch-txt">
                    <label className="jira-sw-lab" htmlFor="jsw-pushEvidence">
                      <b>Attach evidence filed here to the issue</b>
                    </label>
                    <details className="jira-why">
                      <summary>What this does</summary>
                        <span>
                          A document filed against the vulnerability is attached to the linked issue, and replacing
                          it here replaces the attachment there.{" "}
                          <b>Everyone who can open the issue in Jira can download it.</b> Files that arrived from
                          Jira are not sent back. The token needs <b>Create Attachments</b> and <b>Delete Own Attachments</b>.
                        </span>
                    </details>
                  </span>
                </div>

              </div>}

            {tab === "in" &&
              <div className="jira-switches">
                {connected &&
                  <div className="jira-hook" role="group" aria-label="Webhook">
                    <div className="jira-hook-head">
                      <span className="jira-map-title">
                        <b>Webhook</b> — how Jira tells this product about a move, a comment, an assignee, a due date or an attachment.
                        One click registers it on the Jira site (events <b>Issue: updated</b>, <b>Comment: created, updated, deleted</b>,
                        only issues labelled <span className="mono">vulncore</span>).
                      </span>
                      <span className="jira-hook-last">
                        {cfg && cfg.webhookLastAt
                          ? <>Last delivery {timeAgo(cfg.webhookLastAt)}</>
                          : "No delivery received yet"}
                      </span>
                    </div>

                    {/* The state line: what the site has, as far as this product knows. A
                        live check overrides the stored fact, because a registration can be
                        deleted or disabled in Jira without telling us. */}
                    <div className={"jira-hook-state is-" + (hook ? hook.state : registered ? "registered" : "none")}>
                      <Icon name={hook && (hook.state === "registered" || hook.state === "current") ? "check"
                        : hook && hook.state !== "missing" ? "alert"
                        : registered && !hook ? "check" : "link"} size={14} />
                      <span>
                        {hook
                          ? hook.message
                          : registered
                            ? <>Registered in Jira as webhook <b>#{cfg.webhookRegistrationId}</b>{cfg.webhookRegisteredAt ? <> · {timeAgo(cfg.webhookRegisteredAt)}</> : null}</>
                            : "Not connected to Jira yet."}
                      </span>
                    </div>

                    <div className="jira-hook-row">
                      {!registered || (hook && (hook.state === "stale" || hook.state === "missing"))
                        ? <button className="btn btn-accent btn-sm" type="button" onClick={connectWebhook} disabled={!!busy}>
                            {busy === "hook" ? <span className="btn-spin" /> : <Icon name="bolt" size={14} />}
                            {registered ? "Repair in Jira" : "Connect to Jira"}
                          </button>
                        : <button className="jira-load" type="button" onClick={checkWebhook} disabled={!!busy} title="Ask the Jira site whether the registration is still there and current">
                            <Icon name="refresh" size={12} className={busy === "hook" ? "jira-load-spin" : ""} />Check
                          </button>}
                      {registered &&
                        <button className="jira-load" type="button" onClick={removeWebhook} disabled={!!busy} title="Delete the registration from the Jira site">
                          <Icon name="trash" size={12} />Remove from Jira
                        </button>}
                      {cfg && cfg.webhookUrl &&
                        <button className="jira-load" type="button" onClick={rotate} disabled={!!busy} title="Issue a new secret; the current URL stops working">
                          <Icon name="refresh" size={12} className={busy === "rotate" ? "jira-load-spin" : ""} />Rotate secret
                        </button>}
                      <span className="jira-acts-sp" />
                      <button className="jira-hook-manual" type="button" onClick={() => setManual((m) => !m)} aria-expanded={manual}>
                        {manual ? "Hide the URL" : "Register by hand instead"}
                      </button>
                    </div>

                    {/* The by-hand path — the same registration, typed into Jira's form by a
                        site admin. Opened automatically when the token cannot do it. */}
                    {manual && (cfg && cfg.webhookUrl
                      ? <div className="jira-hook-manual-box">
                          <p className="jira-map-note">In Jira: <b>Settings → System → WebHooks → Create a WebHook</b>. Paste the URL below; under
                            <b> Issue related events</b> set the JQL to <span className="mono">labels = vulncore</span> and tick <b>Issue: updated</b>,
                            <b> Comment: created</b>, <b>Comment: updated</b> and <b>Comment: deleted</b>; leave <b>Exclude body</b> unticked; Create.</p>
                          <div className="jira-hook-row">
                            <input className="jira-hook-url mono" readOnly value={cfg.webhookUrl} onFocus={(e) => e.target.select()} aria-label="Webhook URL" />
                            <button className="jira-load" type="button" onClick={copyWebhookUrl} disabled={!!busy}>
                              <Icon name={copied ? "check" : "copy"} size={12} />{copied ? "Copied" : "Copy"}
                            </button>
                          </div>
                          <p className="jira-map-note">The URL carries a secret that is the only thing standing between the internet and this
                            company's statuses. Treat it like a password: paste it into Jira and nowhere else, and rotate it if it was ever
                            seen by someone who should not have it.</p>
                        </div>
                      : <p className="jira-map-note">The URL is issued by <b>Connect to Jira</b>, or when this is saved — <b>Save connection</b>, then come back for it.</p>)}
                  </div>}

                {/* The inbound half. Jira tells this product about a move through a webhook
                    the customer registers (the URL below), with a ten-minute poll behind it
                    for a site that never does. The reverse map is its own editor: the
                    forward map cannot simply be turned around, because a workflow has more
                    statuses than this product and several of them may mean the same thing
                    here. Suggestions come from the forward map; Save makes them real. */}
                <div className="jira-switch">
                  <input type="checkbox" checked={f.acceptWebhook} onChange={(e) => {
                    const on = e.target.checked;
                    set("acceptWebhook", on);
                    if (on && connected && !jiraStatuses) fetchJiraStatuses(f.defaultProjectKey, false);
                    if (on) suggestReverse();
                  }} id="jsw-acceptWebhook" />
                  <span className="jira-switch-txt">
                    <label className="jira-sw-lab" htmlFor="jsw-acceptWebhook">
                      <b>Change the status here when the Jira issue moves</b>
                    </label>
                    <details className="jira-why">
                      <summary>What this does</summary>
                        <span>
                          When a linked issue is moved in Jira, the vulnerability takes the status you map
                          that Jira status to below. A Jira status left on <b>Ignore</b> changes nothing.
                          Jira reports the move through a webhook you register on the site (the URL appears
                          below once this is saved); if none is registered, the project is checked for changed
                          issues every ten minutes instead. A move this product made itself is recognised on
                          the way back and not applied twice. The token needs <b>Browse Projects</b>.
                        </span>
                    </details>
                  </span>
                </div>

                {f.acceptWebhook &&
                  <div className="jira-map" role="group" aria-label="Jira status to triage status">
                    <div className="jira-map-head">
                      <span className="jira-map-title">
                        {jiraStatuses
                          ? <>When <b>{(f.defaultProjectKey || "").toUpperCase()}</b> moves an issue to…</>
                          : connected
                            ? "Load the project's statuses to map them"
                            : "Save the connection first — the project's statuses are read through it"}
                      </span>
                      <span className="jira-load-wrap" title={connected ? "Fetch the statuses of the chosen project" : "Save the connection first — the list is fetched through it"}>
                        <button className="jira-load" type="button" disabled={!!busy || !connected}
                          onClick={() => fetchJiraStatuses(f.defaultProjectKey, false)}>
                          <Icon name="refresh" size={12} className={busy === "statuses" ? "jira-load-spin" : ""} />
                          {jiraStatuses ? "Reload" : "Load"}
                        </button>
                      </span>
                    </div>
                    {reverseSuggested &&
                      <p className="jira-map-note">Suggested from the map above — check each row, then <b>Save connection</b>.</p>}
                    {(() => {
                      // Rows: the project's real statuses, plus any saved name the project
                      // no longer has (so a stale entry can still be seen and cleared).
                      const names = jiraStatuses ? [...new Set(jiraStatuses.map((s) => s.name))] : [];
                      const saved = Object.keys(f.reverseStatusMap || {});
                      const rows = names.slice();
                      saved.forEach((k) => { if (!rows.some((n) => n.toLowerCase() === k)) rows.push(k); });
                      if (!rows.length)
                        return <p className="jira-map-note">No statuses loaded yet.</p>;
                      return rows.map((name) => {
                        const stale = !!(jiraStatuses && !names.includes(name));
                        const value = reverseFor(f.reverseStatusMap, name);
                        return (
                          <label className="jira-map-row" key={name}>
                            <span className="jira-map-from">
                              <span className="chip-dot" style={{ background: "var(--text-faint)" }} />
                              {name}{stale && <span className="jira-map-stale"> (not in this project)</span>}
                            </span>
                            <Icon name="arrowR" size={14} className="jira-map-arrow" />
                            <span className="field-wrap jira-map-to">
                              <select value={value} onChange={(e) => setReverse(name, e.target.value)}>
                                <option value="">Ignore</option>
                                {jiraMapRows(cfg).map((k) => <option key={k} value={k}>{STATUS_META[k].label}</option>)}
                              </select>
                            </span>
                          </label>
                        );
                      });
                    })()}
                  </div>}

                {/* Comments the other way. Webhook-only: the poll fallback reads statuses,
                    not comment bodies, and this copy says so. */}
                <div className="jira-switch is-privacy">
                  <input type="checkbox" checked={f.acceptComments} onChange={(e) => set("acceptComments", e.target.checked)} id="jsw-acceptComments" />
                  <span className="jira-switch-txt">
                    <label className="jira-sw-lab" htmlFor="jsw-acceptComments">
                      <b>Copy Jira comments onto the vulnerability here</b>
                    </label>
                    <details className="jira-why">
                      <summary>What this does</summary>
                        <span>
                          A comment written on a linked issue in Jira appears on the vulnerability's thread,
                          marked <b>via Jira</b> with the Jira author's name; an edit in Jira updates the copy.{" "}
                          <b>Everyone in this company who can open the vulnerability can read it.</b> Comments
                          this product posted to Jira are not copied back, and Jira comments cannot be edited
                          here. This needs the webhook registered — the ten-minute check does not fetch comments.
                        </span>
                    </details>
                  </span>
                </div>

                {/* The fields, the other way. Its own switch, not a mode of the one above:
                    a company may want Jira to follow the record without letting a Jira
                    user reassign work here. Off by default because it writes a PERSON onto
                    the record, chosen in Jira. */}
                <div className="jira-switch">
                  <input type="checkbox" checked={f.acceptFields} onChange={(e) => set("acceptFields", e.target.checked)} id="jsw-acceptFields" />
                  <span className="jira-switch-txt">
                    <label className="jira-sw-lab" htmlFor="jsw-acceptFields">
                      <b>Take the assignee and due date from Jira too</b>
                    </label>
                    <details className="jira-why">
                      <summary>What this does</summary>
                        <span>
                          When a linked issue is reassigned or its due date moves in Jira, the vulnerability's owner
                          and remediation deadline follow. The Jira assignee is matched to a teammate by the e-mail
                          on their account here; a Jira user nobody here has is noted in the sync log and changes
                          nothing. Reassigning replaces the owner Jira mirrored (the most recently added) and leaves
                          any other owners; unassigning removes that one. Needs the webhook — the ten-minute check
                          does not carry field changes.
                        </span>
                    </details>
                  </span>
                </div>

                <div className="jira-switch">
                  <input type="checkbox" checked={f.acceptEvidence} onChange={(e) => set("acceptEvidence", e.target.checked)} id="jsw-acceptEvidence" />
                  <span className="jira-switch-txt">
                    <label className="jira-sw-lab" htmlFor="jsw-acceptEvidence">
                      <b>File attachments added in Jira as evidence here</b>
                    </label>
                    <details className="jira-why">
                      <summary>What this does</summary>
                        <span>
                          A file attached to a linked issue appears in the vulnerability's evidence, marked <b>via Jira</b>{" "}
                          with the Jira author's name. Same rules as an upload: PDF, CSV, DOCX, JPG or PNG, up to 15 MB —
                          anything else is noted in the sync log and skipped. A file from Jira is replaced in Jira, not
                          here. Needs the webhook.
                        </span>
                    </details>
                  </span>
                </div>

              </div>}

            {tab === "people" &&
              <div className="jira-switches">
                {/* People. The one part of this panel that acts on the customer's Atlassian
                    ORGANISATION rather than on an issue, which is why the invite path
                    confirms and says what it costs before it does anything. */}
                  <div className="jira-people" role="group" aria-label="Teammates in Jira">
                    <div className="jira-hook-head">
                      <span className="jira-map-title">
                        <b>Teammates in Jira</b> — who here is who there. The assignee sync, the comment mention and
                        the inbound assignee change all use this; a teammate with no Jira account is skipped by all three.
                      </span>
                      <span className="jira-load-wrap" title="Ask Jira who these teammates are and what this token may do">
                        <button className="jira-load" type="button" disabled={!!busy || !connected} onClick={() => { loadOAuthPeople(); return loadPeople(true); }}>
                          <Icon name="refresh" size={12} className={busy === "people" ? "jira-load-spin" : ""} />
                          {people ? "Reload" : "Load"}
                        </button>
                      </span>
                    </div>

                    {!connected &&
                      <p className="jira-map-note">Save the connection first — the roster is read through
                        it. The two switches below are saved with the rest of this form either way.</p>}

                    {connected && !people && <p className="jira-map-note">Not loaded yet.</p>}

                    {connected && people &&
                      <>
                        <div className={"jira-hook-state is-" + (people.tier === "invite" ? "registered" : people.tier === "grant" ? "stale" : "none")}>
                          <Icon name={people.tier === "invite" ? "check" : people.tier === "grant" ? "alert" : "link"} size={14} />
                          <span>{people.message}</span>
                        </div>

                        <div className="jira-people-list">
                          {(people.users || []).map((u) => {
                            const linked = u.state !== "unlinked";
                            return (
                              <div className={"jira-person" + (linked ? " is-linked" : "")} key={u.userId}>
                                <span className="jira-person-who">
                                  <b>{u.username}</b>
                                  <span className="jira-person-mail">{u.email || "no e-mail address"}</span>
                                </span>
                                <span className="jira-person-state">
                                  {linked
                                    ? <>
                                        <span className="cmt-jira" title={"Jira account " + u.jiraAccountId}>
                                          {u.jiraDisplayName || u.jiraAccountId}
                                        </span>
                                        {u.state === "invited" && <span className="jira-person-tag">invited</span>}
                                        {u.assignable === false && <span className="jira-person-tag is-warn">no project access</span>}
                                      </>
                                    : <span className="jira-person-none">not in Jira</span>}
                                  {oauthPeople && oauthPeople[u.userId] &&
                              <span className="jira-person-tag" title="This teammate connected their own Atlassian account, so their comments are posted as them">
                                own account
                              </span>}
                            {u.note && <span className="jira-person-note">{u.note}</span>}
                                </span>
                                <span className="jira-person-acts">
                                  {peopleBusy === u.userId
                                    ? <span className="btn-spin" />
                                    : <>
                                        {!linked && u.email &&
                                          <button className="jira-load" type="button" disabled={!!busy}
                                            onClick={() => provisionOne(u, false)}
                                            title="Find this person's existing Jira account and give it access to the project">
                                            Match
                                          </button>}
                                        {!linked && u.email && people.canInvite &&
                                          <button className="jira-load is-spend" type="button" disabled={!!busy}
                                            onClick={() => provisionOne(u, true)}
                                            title="Create an Atlassian account for this person — uses one of the site's Jira licence seats">
                                            Invite
                                          </button>}
                                        {!linked &&
                                          <button className="jira-load" type="button" disabled={!!busy} onClick={() => linkByHand(u)}
                                            title="Type the Jira account id yourself — for a site that hides e-mail addresses">
                                            Link by id
                                          </button>}
                                        {linked && u.assignable === false &&
                                          <button className="jira-load" type="button" disabled={!!busy} onClick={() => provisionOne(u, false)}
                                            title={"Add them to " + (f.defaultProjectKey || "the project") + " so they can be assigned work"}>
                                            Fix access
                                          </button>}
                                        {linked &&
                                          <button className="jira-load" type="button" disabled={!!busy} onClick={() => unlinkPerson(u)}
                                            title="Forget this link here; the Jira account is not changed">
                                            Unlink
                                          </button>}
                                      </>}
                                </span>
                              </div>
                            );
                          })}
                        </div>

                        <div className="jira-hook-row">
                          <button className="jira-load" type="button" disabled={!!busy} onClick={() => provisionAll(false)}>
                            Match everyone
                          </button>
                          {people.canInvite &&
                            <button className="jira-load is-spend" type="button" disabled={!!busy} onClick={() => provisionAll(true)}
                              title="Invite every teammate who has no Jira account — one licence seat each">
                              Invite everyone missing
                            </button>}
                        </div>
                      </>}

                    {/* The switch that makes this happen without anyone clicking. Last, and
                        phrased as the spend it is. */}
                    <div className="jira-switch is-privacy">
                      <input type="checkbox" checked={f.autoProvisionUsers} onChange={(e) => set("autoProvisionUsers", e.target.checked)} id="jsw-autoProvisionUsers" />
                      <span className="jira-switch-txt">
                        <label className="jira-sw-lab" htmlFor="jsw-autoProvisionUsers">
                          <b>Provision teammates into Jira automatically</b>
                        </label>
                        <details className="jira-why">
                          <summary>What this does</summary>
                            <span>
                              A teammate added here is matched to their Jira account, given access to{" "}
                              {(f.defaultProjectKey || "the project").toUpperCase()}, and — if they have no account —
                              invited. The same happens when a vulnerability is assigned to someone who has none.{" "}
                              <b>Each invitation uses one of this Jira site's licence seats and Atlassian e-mails the person.</b>{" "}
                              Off unless you choose it. The connected account must be a Jira administrator; without that
                              the person is still added here and the sync log says what was left undone.
                            </span>
                        </details>
                      </span>
                    </div>

                    {/* The other end. Its own switch because granting and revoking are done
                        by different people at most customers, and because this one reaches
                        into the customer's project rather than into ours. */}
                    <div className="jira-switch">
                      <input type="checkbox" checked={f.revokeJiraAccess} onChange={(e) => set("revokeJiraAccess", e.target.checked)} id="jsw-revokeJiraAccess" />
                      <span className="jira-switch-txt">
                        <label className="jira-sw-lab" htmlFor="jsw-revokeJiraAccess">
                          <b>Take Jira access back when a teammate is removed here</b>
                        </label>
                        <details className="jira-why">
                          <summary>What this does</summary>
                            <span>
                              Deleting a teammate removes them from the{" "}
                              {(f.defaultProjectKey || "project")}{" "}project role <b>this product put them in</b> — someone who
                              already worked on that project keeps the access you gave them.{" "}
                              <b>Their Atlassian account is never touched</b>: it is their identity across your whole
                              organisation, and the licence seat is yours to reclaim in Jira's own admin.
                            </span>
                        </details>
                      </span>
                    </div>

                    {/* The one switch on this panel an admin cannot make true on their
                        own: it needs each teammate to consent in their own browser, and
                        the roster above is where that shows up. */}
                    <div className="jira-switch is-privacy">
                      <input type="checkbox" checked={f.commentAsUser} onChange={(e) => set("commentAsUser", e.target.checked)} id="jsw-commentAsUser" />
                      <span className="jira-switch-txt">
                        <label className="jira-sw-lab" htmlFor="jsw-commentAsUser">
                          <b>Post comments as the person who wrote them</b>
                        </label>
                        {/* Whether the switch is actually DOING anything. This is the
                            only switch on the panel that can be on, saved and still have
                            no effect, and until this line existed the only way to find
                            out was to read the author of a comment in Jira. The two ways
                            it stays inert are reported separately because the fix is
                            different: one is a service setting an operator applies once,
                            the other is a consent each teammate gives themselves. */}
                        {f.commentAsUser && cfg && cfg.commentAsUser && (() => {
                          const connectedCount = oauthPeople ? Object.keys(oauthPeople).length : null;
                          if (oauthCfg && oauthCfg.available === false) return (
                            <span className="jira-ready is-bad">
                              <Icon name="alert" size={12} />
                              {oauthCfg.configError
                                || "Per-user Atlassian OAuth is not configured on this service, so nobody can connect and every comment keeps going out as the integration account."}
                            </span>
                          );
                          if (connectedCount === null) return null;   // not read yet — say nothing rather than guess
                          if (connectedCount === 0) return (
                            <span className="jira-ready is-warn">
                              <Icon name="alert" size={12} />
                              On, but nobody has connected their own Atlassian account yet, so comments are still
                              posted by the integration account. Each person connects it themselves from My account
                              (the menu under their name at the foot of the sidebar) — an admin cannot do it for them.
                            </span>
                          );
                          return (
                            <span className="jira-ready is-ok">
                              <Icon name="check" size={12} />
                              {connectedCount} {connectedCount === 1 ? "teammate has" : "teammates have"} connected their
                              own Atlassian account; their comments post as them. Anyone who has not is unchanged.
                            </span>
                          );
                        })()}
                        {/* Saved state, not the checkbox: until this is saved the switch
                            is a draft, and /oauth/me reports on what the server stored. */}
                        {f.commentAsUser && !(cfg && cfg.commentAsUser) && (
                          <span className="jira-ready is-warn">
                            <Icon name="alert" size={12} />
                            Not saved yet — <b>Save connection</b> to turn this on. Teammates cannot connect their
                            own Atlassian account until it is saved.
                          </span>
                        )}
                        <details className="jira-why">
                          <summary>What this does</summary>
                            <span>
                              A comment copied to Jira is normally authored by the connected Atlassian account —
                              the one in <b>Connection</b> — with the real author named in the comment. With this on,
                              a teammate who has connected their <b>own</b> Atlassian account (they do it themselves,
                              from My account; nobody can do it for them) has their comments posted as
                              <b> them</b>: their name, their avatar, no attribution line. Everyone else is unchanged.{" "}
                              <b>Jira does not notify you about your own comment</b>, so the author stops being
                              notified of their own — which is the one thing this trades away. Needs
                              Jira__OAuth__ClientId and Jira__OAuth__ClientSecret on this service; without them
                              nobody can connect and every comment keeps going out as the connected account.
                            </span>
                        </details>
                      </span>
                    </div>
                  </div>
              </div>}

            {tab === "log" &&
              <>
            {/* The audit trail. Jira errors are swallowed on the request path so a tracker
                outage cannot fail a triage action — this is where they stay visible, so the
                heading says that out loud instead of leaving it to this comment. */}
            {log.length > 0 &&
              <div className="jira-logsec">
                <div className="jira-logsec-head">
                  <h4>Sync log</h4>
                  <span className="card-sub">{log.length} recent attempt{log.length === 1 ? "" : "s"} — a Jira error never fails a triage action, so it surfaces here instead</span>
                </div>
                <div className="admintbl-wrap admintbl-scroll">
                  <table className="admintbl jira-log">
                    <thead><tr><th>When</th><th>Who</th><th>Direction</th><th>Action</th><th>Issue</th><th>Result</th><th className="admintbl-num">HTTP</th><th>Detail</th></tr></thead>
                    <tbody>
                      {log.map((r) => (
                        /* Outcome drives the row rail as well as the chip: a run of failures
                           is findable down the left edge without reading a word. */
                        <tr key={r.logId} className={"jira-logrow is-" + (r.outcome || "ignored")}>
                          <td className="jira-log-when mono">{r.createdAt ? new Date(r.createdAt).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "—"}</td>
                          <td className="jira-log-who">{r.user || "—"}</td>
                          {/* Which way the fact travelled. "in" rows are Jira acting on this
                              product (a move, a comment); everything else is this product
                              acting on Jira. Since 0020 the log holds both. */}
                          <td className={"jira-log-dir is-" + (r.direction === "in" ? "in" : "out")}
                            title={r.direction === "in" ? "Jira → here" : "here → Jira"}>
                            {r.direction === "in" ? "← from Jira" : "→ to Jira"}
                          </td>
                          <td>{r.action}</td>
                          <td className="mono">{r.issueKey || r.vulnId || "—"}</td>
                          <td><span className="jira-log-outcome">{r.outcome || "ignored"}</span></td>
                          {/* Already on the payload (JiraSyncLogDto.httpStatus) and never
                              drawn, which left a 401 and a 403 telling the same story. */}
                          <td className="admintbl-num mono">{r.httpStatus || "—"}</td>
                          <td className="jira-log-detail" title={r.detail || ""}>{r.detail || "—"}</td>
                        </tr>
                      ))}
                    </tbody>
                  </table>
                </div>
              </div>}
                {log.length === 0 &&
                  <p className="jira-map-note">No attempts yet — every push and every arrival lands
                    here, successful or not.</p>}
              </>}
          </div>

          {/* Three weights, not five equal blocks. The two Load buttons moved onto the
              fields they fill. Disconnect takes .btn-danger-outline -- the same
              destructive treatment as the component detail rail -- because .tax-del
              only recoloured its label to --sev-critical ON HOVER, over a solid indigo
              fill, which made the one irreversible action here least readable at the
              moment the pointer was on it. */}
          <div className="jira-acts">
            <button className="btn jira-quiet" type="button" onClick={test} disabled={!!busy || !!readErr}>
              {busy === "test" ? <span className="btn-spin" /> : <Icon name="pulse" size={15} />}Test connection
            </button>
            <span className="jira-acts-sp" />
            {connected &&
              <button className="btn btn-danger-outline" type="button" onClick={disconnect} disabled={!!busy || !!readErr}>
                {busy === "disconnect" ? <span className="btn-spin" /> : <Icon name="trash" size={15} />}Disconnect
              </button>}
            <button className="btn btn-accent" type="submit" disabled={!!busy || !!readErr}>
              {busy === "save" ? <span className="btn-spin" /> : <><Icon name="check" size={15} />Save connection</>}
            </button>
          </div>

          <AdminNote err={msg && msg.err} ok={msg && msg.ok} />
        </form>

      </div>
    </section>
  );
}

// ---------------------------------------------------------------------------
const TAX_TABS = [
  ["oems", "OEM marques"],
  ["vendors", "Vendor rules"],
  ["statuses", "Triage statuses"],
  ["bands", "Severity bands"],
];

// Comma-separated text <-> string[] for the needle/token list fields.
const csvToList = (s) => (s || "").split(",").map((x) => x.trim()).filter(Boolean);
const listToCsv = (a) => (a || []).join(", ");

function TaxonomyManager() {
  const taxApi = useApi((signal) => window.VCApi.adminTaxonomy(signal));
  const tax = taxApi.data || { oems: [], vendors: [], statuses: [], severityBands: [] };
  const [tab, setTab] = useState("oems");
  const [msg, setMsg] = useState(null); // { err } | { ok }
  const [busy, setBusy] = useState(false);

  // Run a write, surface the outcome, refetch on success. Resolves true on success
  // and false on failure so callers only clear their form when the save took.
  function run(promise, okMsg) {
    setBusy(true); setMsg(null);
    return promise
      .then(() => { setBusy(false); setMsg({ ok: okMsg }); taxApi.refetch(); return true; })
      .catch((e) => { setBusy(false); setMsg({ err: (e && e.message) || "Save failed." }); return false; });
  }
  function del(promise, okMsg) {
    setMsg(null);
    return promise
      .then(() => { setMsg({ ok: okMsg }); taxApi.refetch(); })
      .catch((e) => { setMsg({ err: (e && e.message) || "Delete failed." }); });
  }

  if (taxApi.loading && !taxApi.data) return (
    <section className="card admin-card admin-span-full"><PageLoading label="Loading taxonomy…" /></section>
  );

  return (
    <section className="card admin-card admin-span-full">
      <div className="card-head">
        <h3><Icon name="sparkle" size={16} /> Classification taxonomy</h3>
        <span className="card-sub">OEM &amp; vendor rules, statuses, severity bands</span>
      </div>
      {/* .tax-note, not .page-desc: this is a card explaining itself, and it was
          the only place in the app where the page head's own subtitle class - at
          page-head weight, with an inline style patching its margin - turned up
          inside a card. The other five notes on this screen already read this way. */}
      <p className="tax-note">
        <Icon name="alert" size={13} />
        Edit how findings are classified — no redeploy. Changes re-attribute the whole
        catalog on the next load. OEM/vendor needles match the (lower-cased) company or
        product text: a needle with a space or hyphen is matched as a substring, a single
        word is matched as a whole token.
      </p>

      {/* A real tablist: roving tabindex (only the current tab is a tab stop) with
          arrows/Home/End moving between them, which is what someone who has just
          Tabbed off the description expects from four buttons that look like tabs.
          The next button is found by querying the track rather than by holding four
          refs — they are rendered right here and never reorder, so a ref array
          would be ceremony. The NodeList is captured before setTab so nothing
          depends on the synthetic event surviving the state update. */}
      <div className="tax-tabs" role="tablist" aria-label="Taxonomy sections"
        onKeyDown={(e) => {
          const cur = TAX_TABS.findIndex(([id]) => id === tab);
          let next = -1;
          if (e.key === "ArrowRight") next = (cur + 1) % TAX_TABS.length;
          else if (e.key === "ArrowLeft") next = (cur - 1 + TAX_TABS.length) % TAX_TABS.length;
          else if (e.key === "Home") next = 0;
          else if (e.key === "End") next = TAX_TABS.length - 1;
          if (next < 0) return;
          e.preventDefault();
          const btns = e.currentTarget.querySelectorAll(".tax-tab");
          setTab(TAX_TABS[next][0]); setMsg(null);
          if (btns[next]) btns[next].focus();
        }}>
        {TAX_TABS.map(([id, label]) => (
          <button key={id} type="button" id={"tax-tab-" + id}
            role="tab" aria-selected={tab === id}
            aria-controls={tab === id ? "tax-panel-" + id : undefined}
            tabIndex={tab === id ? 0 : -1}
            className={"tax-tab" + (tab === id ? " is-on" : "")}
            onClick={() => { setTab(id); setMsg(null); }}>
            {label}
            <span className="tax-tab-count">{((id === "bands" ? tax.severityBands : tax[id]) || []).length}</span>
          </button>
        ))}
      </div>

      {/* .admin-span2, not .admin-span-full: AdminNote renders null most of the
          time, and only span2 carries the :empty rule that collapses the row when
          it does. Without it this is an empty flex child holding a 16px gap open
          between the tab strip and the editor. */}
      <div className="admin-span2"><AdminNote err={msg && msg.err} ok={msg && msg.ok} /></div>

      {/* Only the current panel is mounted, so the tabpanel id is built from `tab`
          and only the selected tab points at it — a dangling aria-controls on the
          other three would be worse than none. */}
      <div role="tabpanel" id={"tax-panel-" + tab} aria-labelledby={"tax-tab-" + tab}>
        {tab === "oems" && <OemEditor oems={tax.oems} busy={busy} run={run} del={del} />}
        {tab === "vendors" && <VendorEditor vendors={tax.vendors} busy={busy} run={run} del={del} />}
        {tab === "statuses" && <StatusEditor statuses={tax.statuses} busy={busy} run={run} del={del} />}
        {tab === "bands" && <BandEditor bands={tax.severityBands} busy={busy} run={run} del={del} />}
      </div>
    </section>
  );
}

// ---- OEM marque editor ------------------------------------------------------
function OemEditor({ oems, busy, run, del }) {
  const blank = { canonical: "", needles: "", disqualifiers: "", supersedes: "", proseUnsafe: false, sortOrder: 0 };
  const [f, setF] = useState(blank);
  const set = (k, v) => setF((p) => ({ ...p, [k]: v }));
  const editing = oems.some((o) => o.canonical === f.canonical);

  function loadRow(o) {
    setF({
      canonical: o.canonical,
      needles: listToCsv(o.needles),
      disqualifiers: listToCsv(o.disqualifiers),
      supersedes: listToCsv(o.supersedes),
      proseUnsafe: !!o.proseUnsafe,
      sortOrder: o.sortOrder || 0,
    });
  }
  function save(e) {
    e.preventDefault();
    if (!f.canonical.trim() || csvToList(f.needles).length === 0) return;
    run(window.VCApi.adminUpsertOem({
      canonical: f.canonical.trim(),
      needles: csvToList(f.needles),
      disqualifiers: csvToList(f.disqualifiers),
      supersedes: csvToList(f.supersedes),
      proseUnsafe: !!f.proseUnsafe,
      sortOrder: Number(f.sortOrder) || 0,
    }), `Saved marque “${f.canonical.trim()}”.`).then((ok) => { if (ok) setF(blank); });
  }

  return (
    <div className="tax-panel">
      <form className="admin-form tax-form" onSubmit={save}>
        <label className="field">
          <span className="field-lab">Canonical name <span className="field-req">required</span></span>
          <div className="field-wrap"><input value={f.canonical} onChange={(e) => set("canonical", e.target.value)} placeholder="e.g. Porsche" spellCheck="false" /></div>
        </label>
        <label className="field">
          <span className="field-lab">Sort order</span>
          <div className="field-wrap"><input type="number" value={f.sortOrder} onChange={(e) => set("sortOrder", e.target.value)} /></div>
        </label>
        <label className="field admin-span2">
          <span className="field-lab">Needles (comma-separated) <span className="field-req">required</span></span>
          <div className="field-wrap"><input value={f.needles} onChange={(e) => set("needles", e.target.value)} placeholder="porsche, 911" spellCheck="false" /></div>
        </label>
        <label className="field">
          <span className="field-lab">Disqualifiers</span>
          <div className="field-wrap"><input value={f.disqualifiers} onChange={(e) => set("disqualifiers", e.target.value)} placeholder="design, consulting" spellCheck="false" /></div>
        </label>
        <label className="field">
          <span className="field-lab">Supersedes</span>
          <div className="field-wrap"><input value={f.supersedes} onChange={(e) => set("supersedes", e.target.value)} placeholder="another marque" spellCheck="false" /></div>
        </label>
        <label className="field tax-check">
          <input type="checkbox" checked={f.proseUnsafe} onChange={(e) => set("proseUnsafe", e.target.checked)} />
          <span className="field-lab">Prose-unsafe (skip in free-text matching)</span>
        </label>
        <div className="admin-form-act">
          {editing && <button type="button" className="btn btn-ghost" onClick={() => setF(blank)}>Cancel</button>}
          <button className="btn btn-accent" type="submit" disabled={busy}>
            {busy ? <span className="btn-spin" /> : <><Icon name="check" size={15} />{editing ? "Update" : "Add"} marque</>}
          </button>
        </div>
      </form>

      <div className="admintbl-wrap">
        <table className="admintbl">
          <thead><tr><th>Marque</th><th>Needles</th><th>Flags</th><th className="admintbl-num"></th></tr></thead>
          <tbody>
            {oems.map((o) => (
              <tr key={o.canonical} className={f.canonical === o.canonical ? "is-editing" : ""}>
                <td><strong>{o.canonical}</strong></td>
                <td>
                  <div className="tax-toks">
                    {(o.needles || []).map((n, i) => <span key={n + "|" + i} className="tax-tok" title={n}>{n}</span>)}
                  </div>
                  {o.disqualifiers.length > 0 && (
                    <div className="tax-toks"><span className="tax-toks-lab">except</span>
                      {o.disqualifiers.map((n, i) => <span key={n + "|" + i} className="tax-tok is-not" title={n}>{n}</span>)}
                    </div>
                  )}
                  {o.supersedes.length > 0 && (
                    <div className="tax-toks"><span className="tax-toks-lab">beats</span>
                      {o.supersedes.map((n, i) => <span key={n + "|" + i} className="tax-tok is-sup" title={n}>{n}</span>)}
                    </div>
                  )}
                </td>
                <td>{o.proseUnsafe ? <span className="tax-flag">prose-unsafe</span> : ""}</td>
                <td className="admintbl-num tax-actions">
                  <button className="btn btn-ghost btn-sm" onClick={() => loadRow(o)} type="button"><Icon name="pencil" size={14} /></button>
                  <button className="btn btn-ghost btn-sm tax-del" onClick={() => del(window.VCApi.adminDeleteOem(o.canonical), `Deleted “${o.canonical}”.`)} type="button"><Icon name="trash" size={14} /></button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

// ---- Vendor rule editor -----------------------------------------------------
function VendorEditor({ vendors, busy, run, del }) {
  const blank = { vendor: "", needles: "", sortOrder: 0 };
  const [f, setF] = useState(blank);
  const set = (k, v) => setF((p) => ({ ...p, [k]: v }));
  const editing = vendors.some((v) => v.vendor === f.vendor);

  function save(e) {
    e.preventDefault();
    if (!f.vendor.trim() || csvToList(f.needles).length === 0) return;
    run(window.VCApi.adminUpsertVendor({
      vendor: f.vendor.trim(), needles: csvToList(f.needles), sortOrder: Number(f.sortOrder) || 0,
    }), `Saved vendor “${f.vendor.trim()}”.`).then((ok) => { if (ok) setF(blank); });
  }

  return (
    <div className="tax-panel">
      <p className="tax-note"><Icon name="alert" size={13} /> Rules apply first-match-wins in sort order — put distinctive product names above broad ones.</p>
      <form className="admin-form tax-form" onSubmit={save}>
        <label className="field">
          <span className="field-lab">Vendor <span className="field-req">required</span></span>
          <div className="field-wrap"><input value={f.vendor} onChange={(e) => set("vendor", e.target.value)} placeholder="e.g. Bosch" spellCheck="false" /></div>
        </label>
        <label className="field">
          <span className="field-lab">Sort order</span>
          <div className="field-wrap"><input type="number" value={f.sortOrder} onChange={(e) => set("sortOrder", e.target.value)} /></div>
        </label>
        <label className="field admin-span2">
          <span className="field-lab">Needles (comma-separated) <span className="field-req">required</span></span>
          <div className="field-wrap"><input value={f.needles} onChange={(e) => set("needles", e.target.value)} placeholder="bosch, esitronic" spellCheck="false" /></div>
        </label>
        <div className="admin-form-act">
          {editing && <button type="button" className="btn btn-ghost" onClick={() => setF(blank)}>Cancel</button>}
          <button className="btn btn-accent" type="submit" disabled={busy}>
            {busy ? <span className="btn-spin" /> : <><Icon name="check" size={15} />{editing ? "Update" : "Add"} vendor</>}
          </button>
        </div>
      </form>
      <div className="admintbl-wrap">
        <table className="admintbl">
          <thead><tr><th className="admintbl-num">#</th><th>Vendor</th><th>Needles</th><th className="admintbl-num"></th></tr></thead>
          <tbody>
            {vendors.map((v) => (
              <tr key={v.vendor} className={f.vendor === v.vendor ? "is-editing" : ""}>
                <td className="admintbl-num mono">{v.sortOrder}</td>
                <td><strong>{v.vendor}</strong></td>
                <td><div className="tax-toks">{(v.needles || []).map((n, i) => <span key={n + "|" + i} className="tax-tok" title={n}>{n}</span>)}</div></td>
                <td className="admintbl-num tax-actions">
                  <button className="btn btn-ghost btn-sm" onClick={() => setF({ vendor: v.vendor, needles: listToCsv(v.needles), sortOrder: v.sortOrder || 0 })} type="button"><Icon name="pencil" size={14} /></button>
                  <button className="btn btn-ghost btn-sm tax-del" onClick={() => del(window.VCApi.adminDeleteVendor(v.vendor), `Deleted “${v.vendor}”.`)} type="button"><Icon name="trash" size={14} /></button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

// ---- Triage status editor ---------------------------------------------------
function StatusEditor({ statuses, busy, run, del }) {
  const blank = { key: "", label: "", dot: "var(--st-new)", sortOrder: 0, selectable: true };
  const [f, setF] = useState(blank);
  const set = (k, v) => setF((p) => ({ ...p, [k]: v }));
  const editing = statuses.some((s) => s.key === f.key);

  function save(e) {
    e.preventDefault();
    if (!f.key.trim()) return;
    run(window.VCApi.adminUpsertStatus({
      key: f.key.trim(), label: f.label.trim() || f.key.trim(), dot: f.dot.trim() || "var(--st-new)",
      sortOrder: Number(f.sortOrder) || 0, selectable: !!f.selectable,
    }), `Saved status “${f.key.trim()}”.`).then((ok) => { if (ok) setF(blank); });
  }

  return (
    <div className="tax-panel">
      <p className="tax-note"><Icon name="alert" size={13} /> The <code>dot</code> is a CSS colour (e.g. <code>var(--st-new)</code> or <code>#5b8def</code>). Non-selectable states (like auto_enriched) show on findings but aren't offered in the triage menu.</p>
      <form className="admin-form tax-form" onSubmit={save}>
        <label className="field">
          <span className="field-lab">Key <span className="field-req">required</span></span>
          <div className="field-wrap"><input value={f.key} onChange={(e) => set("key", e.target.value)} placeholder="in-review" spellCheck="false" /></div>
        </label>
        <label className="field">
          <span className="field-lab">Label</span>
          <div className="field-wrap"><input value={f.label} onChange={(e) => set("label", e.target.value)} placeholder="In review" /></div>
        </label>
        <label className="field">
          <span className="field-lab">Dot colour</span>
          <div className="field-wrap"><input value={f.dot} onChange={(e) => set("dot", e.target.value)} spellCheck="false" /></div>
        </label>
        <label className="field">
          <span className="field-lab">Sort order</span>
          <div className="field-wrap"><input type="number" value={f.sortOrder} onChange={(e) => set("sortOrder", e.target.value)} /></div>
        </label>
        <label className="field tax-check">
          <input type="checkbox" checked={f.selectable} onChange={(e) => set("selectable", e.target.checked)} />
          <span className="field-lab">User-selectable</span>
        </label>
        <div className="admin-form-act">
          {editing && <button type="button" className="btn btn-ghost" onClick={() => setF(blank)}>Cancel</button>}
          <button className="btn btn-accent" type="submit" disabled={busy}>
            {busy ? <span className="btn-spin" /> : <><Icon name="check" size={15} />{editing ? "Update" : "Add"} status</>}
          </button>
        </div>
      </form>
      <div className="admintbl-wrap">
        <table className="admintbl">
          <thead><tr><th className="admintbl-num">#</th><th>Status</th><th>Selectable</th><th className="admintbl-num"></th></tr></thead>
          <tbody>
            {statuses.map((s) => (
              <tr key={s.key} className={f.key === s.key ? "is-editing" : ""}>
                <td className="admintbl-num mono">{s.sortOrder}</td>
                <td><span className="admintbl-id"><span className="statuspill-dot" style={{ background: s.dot }} /><strong>{s.label}</strong><span className="tax-sub mono">{s.key}</span></span></td>
                <td>{s.selectable ? "Yes" : <span className="tax-sub">pipeline-only</span>}</td>
                <td className="admintbl-num tax-actions">
                  <button className="btn btn-ghost btn-sm" onClick={() => setF({ key: s.key, label: s.label, dot: s.dot, sortOrder: s.sortOrder || 0, selectable: s.selectable !== false })} type="button"><Icon name="pencil" size={14} /></button>
                  <button className="btn btn-ghost btn-sm tax-del" onClick={() => del(window.VCApi.adminDeleteStatus(s.key), `Deleted “${s.key}”.`)} type="button"><Icon name="trash" size={14} /></button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

// ---- Severity band editor ---------------------------------------------------
function BandEditor({ bands, busy, run, del }) {
  const blank = { name: "", minCvss: 0, sortOrder: 0 };
  const [f, setF] = useState(blank);
  const set = (k, v) => setF((p) => ({ ...p, [k]: v }));
  const editing = bands.some((b) => b.name === f.name);

  function save(e) {
    e.preventDefault();
    if (!f.name.trim()) return;
    run(window.VCApi.adminUpsertBand({
      name: f.name.trim(), minCvss: Number(f.minCvss) || 0, sortOrder: Number(f.sortOrder) || 0,
    }), `Saved band “${f.name.trim()}”.`).then((ok) => { if (ok) setF(blank); });
  }

  return (
    <div className="tax-panel">
      <p className="tax-note"><Icon name="alert" size={13} /> A score gets the highest band whose minimum it meets. The four defaults (9 / 7 / 4 / 0) are the CVSS v3 standard — changing them re-labels findings everywhere.</p>
      <form className="admin-form tax-form" onSubmit={save}>
        <label className="field">
          <span className="field-lab">Name <span className="field-req">required</span></span>
          <div className="field-wrap"><input value={f.name} onChange={(e) => set("name", e.target.value)} placeholder="Critical" spellCheck="false" /></div>
        </label>
        <label className="field">
          <span className="field-lab">Min CVSS</span>
          <div className="field-wrap"><input type="number" step="0.1" min="0" max="10" value={f.minCvss} onChange={(e) => set("minCvss", e.target.value)} /></div>
        </label>
        <label className="field">
          <span className="field-lab">Sort order</span>
          <div className="field-wrap"><input type="number" value={f.sortOrder} onChange={(e) => set("sortOrder", e.target.value)} /></div>
        </label>
        <div className="admin-form-act">
          {editing && <button type="button" className="btn btn-ghost" onClick={() => setF(blank)}>Cancel</button>}
          <button className="btn btn-accent" type="submit" disabled={busy}>
            {busy ? <span className="btn-spin" /> : <><Icon name="check" size={15} />{editing ? "Update" : "Add"} band</>}
          </button>
        </div>
      </form>
      <div className="admintbl-wrap">
        <table className="admintbl">
          <thead><tr><th className="admintbl-num">#</th><th>Band</th><th className="admintbl-num">Min CVSS</th><th className="admintbl-num"></th></tr></thead>
          <tbody>
            {bands.map((b) => {
              // One slug, used twice. On the ROW it sets --sc, which the rail and the
              // Min CVSS figure both read; on the pill it keeps the .sevtag treatment
              // every other severity chip in the product uses. A band whose name has no
              // .sev-* rule falls back to the neutral --sc set on .tax-panel .admintbl.
              const sev = "sev-" + b.name.toLowerCase().replace(/\s+/g, "-");
              return (
              <tr key={b.name} className={"band-row " + sev + (f.name === b.name ? " is-editing" : "")}>
                <td className="admintbl-num mono">{b.sortOrder}</td>
                <td><span className={"sevtag " + sev + " sevtag-sm"}><span className="sevtag-dot" /><span className="sevtag-label">{b.name}</span></span></td>
                <td className="admintbl-num mono band-cvss">{b.minCvss.toFixed(1)}</td>
                <td className="admintbl-num tax-actions">
                  <button className="btn btn-ghost btn-sm" onClick={() => setF({ name: b.name, minCvss: b.minCvss, sortOrder: b.sortOrder || 0 })} type="button"><Icon name="pencil" size={14} /></button>
                  <button className="btn btn-ghost btn-sm tax-del" onClick={() => del(window.VCApi.adminDeleteBand(b.name), `Deleted “${b.name}”.`)} type="button"><Icon name="trash" size={14} /></button>
                </td>
              </tr>);
            })}
          </tbody>
        </table>
      </div>
    </div>
  );
}

window.AdminScreen = AdminScreen;
