// NO PROD JSON API (bucket C|E) — mock until backend (see PROD_API_INVENTORY.md)
// Represents: admin.oauth-clients.index · Admin/OAuthClientController — see docs/ISYSTEM_REFERENCE.md §Batch 4 "OAuth Clients"
/* CMS ▾ → OAuth Clients. The machine-to-machine credential store behind the Affiliator API and the
   other server-to-server integrations. Screen actions:
     admin.oauth-clients.index             GET  /oauth-clients                        routes/admin.php:L747
     admin.oauth-clients.create            GET  /oauth-clients/create                 L748
     admin.oauth-clients.store             POST /oauth-clients                        L749
     admin.oauth-clients.edit              GET  /oauth-clients/{oauth_client}/edit    L750
     admin.oauth-clients.update            PUT  /oauth-clients/{oauth_client}         L751
     admin.oauth-clients.regenerate-secret POST /oauth-clients/{oauth_client}/regenerate-secret  L752
   Controller App\Http\Controllers\Admin\OAuthClientController (index L24, create L33, store L38,
   edit L57, update L64, regenerateSecret L82) → App\Services\OAuthService (createClient L61,
   updateClient L80, regenerateSecret L93, issueToken L24). Views admin/oauth-clients/{index,create,
   edit}.blade.php on the `_paybo-head` partial. Data model: `oauth_clients` (id, name, type
   varchar(50) null, affiliate_tag varchar(100) null, client_id varchar(80) UNIQUE, client_secret
   bcrypt, scopes longText null cast to a JSON array, is_active bool default 1, timestamps) hasMany
   `oauth_access_tokens` (token = sha256 of the raw 64-char token, scopes, expires_at, revoked).

   ── WHAT THIS SCREEN IS, IN SECURITY TERMS ────────────────────────────────────────────────────
   It mints credentials for `POST /api/oauth/token` (OAuthTokenController::issueToken, routes/api.php
   L62, throttle:10,1) — client_credentials grant only, secret verified with Hash::check, raw token
   returned once and stored sha256-hashed, the client's expired tokens garbage-collected on each
   issuance (OAuthService L48-50). Token TTL is config('oauth.token_ttl', 3600) and NO config/oauth.php
   exists, so it is always the 3600s fallback. The only scope any route checks is `affiliator:read`,
   via `oauth_client:affiliator:read` on GET /api/affiliator/{customers,transactions,gaming-activity}
   (routes/api.php L65-68; alias `oauth_client` → App\Http\Middleware\OAuthClientAuth, Kernel.php L122).

   ── KNOWN-BUG DIVERGENCES (repo known-bug policy: implement the evident intent, record it) ─────
   1. "Empty scopes = All" is NOT honored at enforcement time. The form hint promises that leaving
      scopes empty grants all scopes, and index.blade renders such a client with a literal `All`
      chip — but OAuthClientAuth L48 reads `$tokenScopes = $accessToken->scopes ?? []`, so a
      scope-less client is refused with 403 insufficient_scope on EVERY scope-guarded route. This
      prototype implements the evident intent (empty → stored null → shown as `All` = all scopes)
      and, because this is a security trap rather than a cosmetic one, states the real outcome in
      the UI itself: the `All` chip carries a warning Tip and the scopes editor raises a red callout
      the moment the field is empty. See HoaScopeCallout and the Scopes column renderer.
   2. Scopes cannot be cleared through edit. UpdateOAuthClientDto::fromRequest() falls back to the
      client's CURRENT scopes when the field arrives empty (the controller passes
      $oauth_client->scopes at L74), so emptying the input silently keeps the old scopes instead of
      resetting to "All". Evident intent implemented here: clearing the field clears the scopes, and
      the editor says on screen that the real platform would not have done that.

   <!-- SUGGESTION: fix App\Http\Middleware\OAuthClientAuth L48 so an empty scope list means what the form says it means — e.g. treat a null/empty `scopes` as "all scopes" (`return $next($request)`), or drop the "leave empty for all scopes" hint and make `scopes` required. Today the UI, the `All` chip and the middleware disagree, and the failure mode is a silent 403 on every Affiliator endpoint for a client the operator believes is fully privileged. -->
   <!-- SUGGESTION: fix App\DTOs\OAuth\UpdateOAuthClientDto::fromRequest() (and OAuthClientController L74) so submitting an empty `scopes` field writes null instead of falling back to the stored value. As written, scopes can be widened or changed but never removed from the back office — the only way to de-scope a client is a direct DB update. -->
   <!-- SUGGESTION: add a role check to the /oauth-clients route group (or an isadmin() guard in OAuthClientController, matching every sibling CMS screen). Right now the sidebar link is the only gate: any authenticated, 2FA'd back-office user who types the URL can list, create, edit and re-secret every API client on the platform. -->
   <!-- SUGGESTION: register listeners for OAuthClientCreated / OAuthClientSecretRegenerated / OAuthTokenIssued (EventServiceProvider L56-58 registers all three with empty arrays). Credential minting and secret rotation currently leave no trace anywhere — no <feature>_logs table, no timeline job — so "who re-secreted the Track360 client at 03:00" is unanswerable. -->
   <!-- SUGGESTION: make affiliate_tag `required_if:type,affiliator` server-side. The form marks it with a `*` but both validate() calls declare it nullable, so an affiliator client saves happily without a tag and then AffiliatorService::getAffiliatedUserIds() returns [] — the partner sees an authenticated 200 with zero customers and no error to debug. -->
   <!-- SUGGESTION: ship a config/oauth.php. config('oauth.token_ttl', 3600) has no config file behind it, so the documented knob does nothing and every token lives exactly one hour. -->
   <!-- SUGGESTION: the index has no search box and no sortable column (fixed ORDER BY created_at DESC, paginate(20)). Once an operator has more than ~40 integrations, finding one client means paging. A single name/client_id search would be the cheapest possible fix — deliberately NOT built here, because the real screen has none. -->

   ── FAITHFUL TO THE REAL SCREEN, DELIBERATELY NOT ADDED ───────────────────────────────────────
   No filters and no search (the real index has none) · no sortable columns (fixed created_at DESC)
   · no page-size selector (paginate(20) is fixed) · no KPIs (the reference records "n/a — only the
   pagination record count") · no table export (only the one-time credentials modal exports .json)
   · no bulk actions · NO DELETE — there is no destroy route at all, a client can only be switched
   inactive or re-secreted · no "view secret" affordance anywhere outside the one-time modal (the
   secret is bcrypt-hashed the moment it is stored; nothing can reveal it again) · no rotation
   schedule, no per-token list, no revoke-single-token action — the backend has none of them.
   Copy-to-clipboard is confined to the credentials modal, the one place index.blade documents it;
   the Client ID cells in the table and the editor are plain monospace, exactly as rendered today. */

const { useState: hoaUseState, useMemo: hoaUseMemo } = React;

/* Deterministic PRNG (FNV-1a + mulberry32) — same convention as the sibling Host pages, so the
   client list, its UUIDs and its timestamps render identically on every load. */
const hoaHash = (s) => { let h = 2166136261 >>> 0; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; };
const hoaRng = pbRng;   // was a local copy of mulberry32 — see pbRng in src/data.jsx
/* oauth_clients.client_id — varchar(80) UNIQUE, a UUID in every row the reference shows. */
/* hoaUuid() and hoaSecret() minted a client id and a client secret in the
   browser and showed them to the operator as a credential. Nothing hashed them,
   nothing on the server ever saw them, and the "client" they belonged to was an
   entry in a seeded array.

   A secret minted in a page is not a secret. Even with crypto.getRandomValues
   the browser is the wrong place: the value has to be hashed and stored in the
   same transaction as the row it authenticates, and a client that mints its own
   can produce one for a row that never persists.

   create_oauth_client() and rotate_oauth_secret() (supabase/024) do both
   server-side, store only a bcrypt hash, and return the raw secret once. The
   column carries a column-level REVOKE, so a select list naming it fails rather
   than handing hashes to a screen. */

/* OAuthClient::TYPES — app/Models/OAuthClient.php L15-21. `type` is nullable; a null type renders
   as an em dash in the Type column and as "— Select Type —" in the form. */
const HOA_TYPES = {
  affiliator: "Affiliator",
  payment_provider: "Payment Provider",
  game_provider: "Game Provider",
  onaim: "OnAim",
  general: "General",
};
const HOA_TYPE_KEYS = Object.keys(HOA_TYPES);

/* The ONE scope any middleware actually reads: `oauth_client:affiliator:read` guards the three
   Affiliator endpoints. Every other string an operator types into the field is stored and displayed
   but never consulted — the screen says so rather than pretending a scope vocabulary exists. */
const HOA_ENFORCED_SCOPE = "affiliator:read";
const HOA_GUARDED_ENDPOINTS = [
  "GET /api/affiliator/customers",
  "GET /api/affiliator/transactions",
  "GET /api/affiliator/gaming-activity",
];

const HOA_PAGE_SIZE = 20;                 // controller L26: ->paginate(20), fixed, no length menu
const HOA_NAME_MAX = 255;                 // validate(): name required|string|max:255
const HOA_TAG_MAX = 100;                  // affiliate_tag nullable|string|max:100
const HOA_SCOPES_MAX = 500;               // scopes nullable|string|max:500


/* Returns [] for an empty field, never null. oauth_clients.scopes is
   text[] NOT NULL DEFAULT '{}' — there is no null that means "all scopes", and
   the schema's own comment says the empty default "states that plainly rather
   than implying 'all'". The screen used to model empty as null and label it
   "All", which is the opposite of what the column means. */
const hoaParseScopes = (raw) => String(raw || "").split(",").map(s => s.trim()).filter(Boolean);
const hoaScopesToInput = (scopes) => (scopes && scopes.length ? scopes.join(", ") : "");

const hoaPad2 = (n) => String(n).padStart(2, "0");
/* created_at is printed Y-m-d H:i on this screen (index.blade L124-131). */
const hoaNow = () => { const d = new Date(); return `${d.getFullYear()}-${hoaPad2(d.getMonth() + 1)}-${hoaPad2(d.getDate())} ${hoaPad2(d.getHours())}:${hoaPad2(d.getMinutes())}`; };

/* -------------------------------------------------------------------------------------------- *
 * Mock `oauth_clients` rows. Values are fake, shapes are real: nullable type, nullable
 * affiliate_tag, scopes either a JSON array or null, is_active boolean. The mix is chosen to make
 * every documented state reachable on screen — a correctly scoped affiliator, the scope-less
 * "All" trap, an affiliator with no tag (AffiliatorService returns [] for it), inert non-enforced
 * scope strings, inactive clients, and rows with a null type.
 * -------------------------------------------------------------------------------------------- */
/* HOA_SEED held 26 fabricated oauth_clients rows and hoaBuildClients() gave
   each an invented client_id.

   Three of its states cannot exist against this schema, worth recording rather
   than just deleting:
     * `scopes: null` meaning "All". The column is text[] NOT NULL DEFAULT '{}'.
       Empty means empty; there is no null that means everything.
     * two rows tagged `track360`. A partial unique index now forbids it — two
       clients on one tag makes "which partner sees these players" ambiguous,
       and upstream the answer is "both".
     * an affiliator with an empty tag. The table constraint requires one, and
       create_oauth_client() explains the consequence rather than naming a
       constraint: a tagless affiliator authenticates perfectly and returns no
       data, permanently. */

const hoaRowFromDb = (r) => ({
  id: r.id,
  name: r.name,
  type: r.client_type,
  affiliate_tag: r.affiliate_tag || "",
  client_id: r.client_id,
  /* Empty array, never null. The screen's All chip keyed off null; it now keys
     off length === 0, which is what the column actually says. */
  scopes: Array.isArray(r.scopes) ? r.scopes : [],
  is_active: !!r.is_active,
  created_at: r.created_at,
  tokens: Array.isArray(r.tokens) && r.tokens[0] ? Number(r.tokens[0].count) : 0,
});

/* Fixed ORDER BY created_at DESC (controller L26). No sortable header exists on this screen. */
const hoaOrder = (rows) => rows.slice().sort((a, b) => (a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : b.id - a.id));

/* -------------------------------------------------------------------------------------------- *
 * Small presentational pieces
 * -------------------------------------------------------------------------------------------- */
const HoaTypeChip = ({ type }) => {
  if (!type) return <span className="hoa-dash">—</span>;
  return <span className={`hoa-typechip hoa-typechip--${type}`}>{HOA_TYPES[type] || type}</span>;
};

/* Scopes column: one chip per entry of the JSON array, or the literal `All` chip when scopes is
   null (index.blade L124-131). The `All` chip is exactly the divergence-1 trap, so it carries the
   warning inline instead of quietly claiming full access. */
const HoaScopeChips = ({ scopes }) => {
  if (!scopes || !scopes.length) return (
    <span className="hoa-scope hoa-scope--all">
      All
      <Tip size={11}>
        <b>Reads as "all scopes", enforces as "none".</b> A client stored with <code>scopes = null</code> is refused
        with <code>403 insufficient_scope</code> on every scope-guarded route, because <code>OAuthClientAuth</code> L48
        reads a null token scope list as <code>[]</code>. To actually reach the Affiliator API the client must carry
        <code> {HOA_ENFORCED_SCOPE}</code> explicitly.
      </Tip>
    </span>
  );
  return (
    <span className="hoa-scopes">
      {scopes.map(s => (
        <span key={s} className={`hoa-scope${s === HOA_ENFORCED_SCOPE ? " hoa-scope--enforced" : " hoa-scope--inert"}`}>
          {s}
          {s !== HOA_ENFORCED_SCOPE && (
            <Tip size={11}>
              Stored and displayed, but <b>never checked</b>. <code>{HOA_ENFORCED_SCOPE}</code> is the only scope any
              middleware reads — <code>scopes</code> is free text, so anything else is inert.
            </Tip>
          )}
        </span>
      ))}
    </span>
  );
};

const HoaStatusChip = ({ on }) => (
  <span className={`hoa-status hoa-status--${on ? "on" : "off"}`}>{on ? "Active" : "Inactive"}</span>
);

/* Clipboard with the same locked-down-browser fallback as ui.jsx CopyableId. Used ONLY inside the
   one-time credentials modal — index.blade documents copy buttons there and nowhere else. */
const HoaCopyBtn = ({ value, label }) => {
  const [done, setDone] = hoaUseState(false);
  const copy = () => {
    const text = String(value);
    const ok = () => { setDone(true); setTimeout(() => setDone(false), 1400); };
    const fallback = () => {
      const ta = document.createElement("textarea");
      ta.value = text; ta.setAttribute("readonly", "");
      ta.style.position = "fixed"; ta.style.opacity = "0";
      document.body.appendChild(ta); ta.select();
      try { document.execCommand("copy"); ok(); } finally { document.body.removeChild(ta); }
    };
    if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(ok).catch(fallback);
    else fallback();
  };
  return (
    <button type="button" className={`hoa-copy${done ? " hoa-copy--done" : ""}`} onClick={copy}
      title={done ? "Copied" : `Copy ${label}`}>
      <Icon name={done ? "check" : "copy"} size={12} /> {done ? "Copied" : "Copy"}
    </button>
  );
};

const HoaCallout = ({ tone = "info", icon = "info", title, children }) => (
  <div className={`hoa-callout hoa-callout--${tone}`}>
    <Icon name={icon} size={14} />
    <div>
      {title && <div className="hoa-callout__t">{title}</div>}
      <div className="hoa-callout__b">{children}</div>
    </div>
  </div>
);

/* Modal chrome — shared .bp-modal-scrim / .bp-modal, full-screen on mobile (brief §11). */
const HoaModal = ({ title, onClose, children, footer, tone }) => (
  <div className="bp-modal-scrim hoa-scrim" onClick={onClose}>
    <div className={`bp-modal hoa-modal${tone ? ` hoa-modal--${tone}` : ""}`} onClick={e => e.stopPropagation()}>
      <div className="hoa-modal__head">
        <div className="hoa-modal__title">{title}</div>
        <button className="hoa-x" title="Close" onClick={onClose}><Icon name="x" size={14} /></button>
      </div>
      <div className="hoa-modal__body">{children}</div>
      {footer && <div className="hoa-modal__foot">{footer}</div>}
    </div>
  </div>
);

/* -------------------------------------------------------------------------------------------- *
 * One-time credentials modal — index.blade L13 renders it from session()->pull('oauth_credentials')
 * after store and after regenerateSecret, so it appears exactly once and can never be reopened.
 * Contents and the Export .json payload are the documented five fields (index.blade L74-89).
 * There is no "show secret" anywhere else in the platform: client_secret is bcrypt-hashed on write.
 * -------------------------------------------------------------------------------------------- */
const HoaCredentialsModal = ({ creds, onClose }) => {
  const json = {
    name: creds.name,
    client_id: creds.client_id,
    client_secret: creds.client_secret,
    grant_type: "client_credentials",
    created_at: creds.created_at,
  };
  const download = () => {
    const blob = new Blob([JSON.stringify(json, null, 2)], { type: "application/json;charset=utf-8" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `oauth-client-${creds.client_id}.json`;
    document.body.appendChild(a); a.click(); a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 250);
  };
  const rows = [
    { k: "Name", v: creds.name, copy: false },
    { k: "Client ID", v: creds.client_id, copy: true, mono: true },
    { k: "Client Secret", v: creds.client_secret, copy: true, mono: true, secret: true },
    { k: "Grant type", v: "client_credentials", copy: false, mono: true },
    { k: "Created at", v: creds.created_at, copy: false },
  ];
  return (
    <HoaModal
      tone="creds"
      title={creds.reason === "regenerated" ? "Secret regenerated" : "Client created"}
      onClose={onClose}
      footer={<>
        <button className="btn btn--secondary" onClick={onClose}>Close</button>
        <button className="btn btn--primary" onClick={download}><Icon name="download" size={13} /> Export .json</button>
      </>}>

      {creds.reason === "regenerated" && (
        /* Flash text quoted from OAuthService::regenerateSecret (L93-101): the rotation bulk-revokes
           the client's tokens with accessTokens()->update(['revoked' => true]) before returning. */
        <div className="hoa-flash"><Icon name="refresh" size={13} /> Secret regenerated. All existing tokens have been revoked.</div>
      )}

      <div className="hoa-once">
        <Icon name="lock" size={14} />
        <div>
          <b>This is the only time the secret is shown.</b> It arrives through
          <code> session()-&gt;pull('oauth_credentials')</code> and is stored bcrypt-hashed — nothing in the back office
          can reveal it again. Copy or export it now; if it is lost the only recovery is another regeneration, which
          revokes every token this client is currently using.
        </div>
      </div>

      <div className="hoa-creds">
        {rows.map(r => (
          <div key={r.k} className={`hoa-cred${r.secret ? " hoa-cred--secret" : ""}`}>
            <div className="hoa-cred__k">{r.k}</div>
            <div className={`hoa-cred__v${r.mono ? " mono" : ""}`}>{r.v}</div>
            {r.copy ? <HoaCopyBtn value={r.v} label={r.k} /> : <span />}
          </div>
        ))}
      </div>

      <div className="hoa-hint">
        Exchange these at <code>POST /api/oauth/token</code> (client_credentials only, rate-limited
        <code> throttle:10,1</code>). The response carries a raw 64-character bearer token, kept server-side as its
        sha256 hash and valid for <b>3600s</b> — <code>config('oauth.token_ttl', 3600)</code> has no config file behind
        it, so the fallback is always in force.
      </div>
    </HoaModal>
  );
};

/* Regenerate — inline POST form behind a JS confirm on the real screen (index.blade). */
const HoaRegenDialog = ({ client, onCancel, onConfirm }) => (
  <HoaModal
    title="Regenerate secret"
    onClose={onCancel}
    footer={<>
      <button className="btn btn--secondary" onClick={onCancel}>Cancel</button>
      <button className="btn btn--danger" onClick={onConfirm}><Icon name="refresh" size={13} /> Regenerate secret</button>
    </>}>
    <div className="hoa-dlgq">Regenerate secret? All existing tokens will be revoked.</div>
    <div className="hoa-hint">
      <b>{client.name}</b> · <span className="mono">{client.client_id}</span>
    </div>
    <HoaCallout tone="warn" icon="alert" title="This breaks the integration until the new secret is deployed">
      <code>OAuthService::regenerateSecret()</code> writes a fresh bcrypt secret and then runs
      <code> accessTokens()-&gt;update(['revoked' =&gt; true])</code> over <b>all</b> of this client's tokens (L101).
      Every request the partner has in flight starts failing immediately, and it stays broken until they take the new
      secret and call <code>POST /api/oauth/token</code> again. There is no grace period and no dual-secret window.
    </HoaCallout>
    <div className="hoa-hint">
      Nothing records that this happened: <code>OAuthClientSecretRegenerated</code> is dispatched but registered with an
      empty listener array (EventServiceProvider L56-58), so no audit row is written anywhere.
    </div>
  </HoaModal>
);

/* -------------------------------------------------------------------------------------------- *
 * Scopes callout — the security-honest core of this screen.
 *
 * KNOWN BUG — DIVERGENCE (1): the platform's own hint says an empty scopes field grants all scopes,
 * and the list backs that up with a literal `All` chip. OAuthClientAuth L48 disagrees
 * (`$tokenScopes = $accessToken->scopes ?? []`), so the client is refused everywhere instead. Per
 * the repo's known-bug policy the prototype implements the evident intent — empty stays null and
 * still means "all scopes" in the data model and the list — but the operator is told, at the moment
 * they leave the field empty, what today's platform will actually do with that choice.
 * -------------------------------------------------------------------------------------------- */
const HoaScopeCallout = ({ scopes, type }) => {
  if (!scopes) return (
    <HoaCallout tone="danger" icon="shield" title="No scopes: intended “all”, enforced as “none”">
      Saving with this field empty stores <code>scopes = null</code>. The form's own hint — and the <code>All</code>
      chip this client will get in the list — call that <b>all scopes</b>, and that is the intent this prototype keeps.
      <b> On the real platform it is the opposite:</b> <code>OAuthClientAuth</code> L48 reads a null token scope list as
      <code> []</code>, so this client is answered <code>403 insufficient_scope</code> on every scope-guarded route:
      <ul className="hoa-ul">{HOA_GUARDED_ENDPOINTS.map(e => <li key={e}><code>{e}</code></li>)}</ul>
      A client that must reach the Affiliator API has to carry <code>{HOA_ENFORCED_SCOPE}</code> spelled out.
      An empty field is safe only for a client that never touches a scope-guarded route.
    </HoaCallout>
  );
  if (type === "affiliator" && scopes.indexOf(HOA_ENFORCED_SCOPE) === -1) return (
    <HoaCallout tone="warn" icon="alert" title={`Affiliator client without ${HOA_ENFORCED_SCOPE}`}>
      This client is typed <b>Affiliator</b> but none of its scopes is <code>{HOA_ENFORCED_SCOPE}</code>, the only scope
      the Affiliator endpoints check. Its tokens will authenticate and then be refused
      <code> 403 insufficient_scope</code> on all three endpoints.
    </HoaCallout>
  );
  const inert = scopes.filter(s => s !== HOA_ENFORCED_SCOPE);
  return (
    <HoaCallout tone={scopes.indexOf(HOA_ENFORCED_SCOPE) !== -1 ? "ok" : "info"} icon={scopes.indexOf(HOA_ENFORCED_SCOPE) !== -1 ? "check" : "info"}
      title={scopes.indexOf(HOA_ENFORCED_SCOPE) !== -1 ? "Affiliator API reachable" : "None of these scopes is enforced"}>
      {scopes.indexOf(HOA_ENFORCED_SCOPE) !== -1
        ? <>Tokens issued to this client will pass the <code>oauth_client:{HOA_ENFORCED_SCOPE}</code> guard on all three Affiliator endpoints.</>
        : <>Nothing checks these strings. <code>{HOA_ENFORCED_SCOPE}</code> is the only scope any middleware reads.</>}
      {inert.length > 0 && (
        <> Stored but inert: {inert.map((s, i) => <React.Fragment key={s}>{i > 0 && ", "}<code>{s}</code></React.Fragment>)}
          {" "}— <code>scopes</code> is free text, so these are kept and displayed but never consulted.</>
      )}
    </HoaCallout>
  );
};

/* -------------------------------------------------------------------------------------------- *
 * Create / Edit — separate pages on the real platform (admin/oauth-clients/create.blade.php and
 * edit.blade.php, both extending admin.layouts.default), so this is a full view swap rather than a
 * modal. Sectioned config-panel shape, per the Settings.jsx exemplar.
 *
 * Validation mirrors the inline $request->validate() calls — there is no FormRequest for this
 * screen: store L40-45, update L66-72 (the same four rules plus is_active nullable|boolean).
 * -------------------------------------------------------------------------------------------- */
const HoaEditor = ({ client, onCancel, onSave }) => {
  const isNew = !client;
  const [name, setName] = hoaUseState(client ? client.name : "");
  const [type, setType] = hoaUseState(client && client.type ? client.type : "");
  const [tag, setTag] = hoaUseState(client ? (client.affiliate_tag || "") : "");
  const [scopesRaw, setScopesRaw] = hoaUseState(client ? hoaScopesToInput(client.scopes) : "");
  const [isActive, setIsActive] = hoaUseState(client ? !!client.is_active : true);
  const [errs, setErrs] = hoaUseState({});

  const scopes = hoaParseScopes(scopesRaw);
  const hadScopes = !!(client && client.scopes && client.scopes.length);
  const clearingScopes = !isNew && hadScopes && scopes.length === 0;

  const clearErr = (k) => setErrs(e => { const n = { ...e }; delete n[k]; return n; });

  const submit = () => {
    const e = {};
    if (!name.trim()) e.name = "The name field is required.";
    else if (name.trim().length > HOA_NAME_MAX) e.name = `The name may not be greater than ${HOA_NAME_MAX} characters.`;
    if (type && HOA_TYPE_KEYS.indexOf(type) === -1) e.type = "The selected type is invalid.";
    /* affiliate_tag is nullable server-side even though the form marks it with a `*` — an affiliator
       client saves happily without a tag (see the SUGGESTION in the header). Only max:100 is
       enforced here, exactly like the real validate() call. */
    if (tag.trim().length > HOA_TAG_MAX) e.affiliate_tag = `The affiliate tag may not be greater than ${HOA_TAG_MAX} characters.`;
    if (String(scopesRaw).length > HOA_SCOPES_MAX) e.scopes = `The scopes field may not be greater than ${HOA_SCOPES_MAX} characters.`;
    setErrs(e);
    if (Object.keys(e).length) return;
    onSave({
      id: isNew ? null : client.id,
      name: name.trim(),
      type: type || null,
      /* affiliate_tag is only collected while type = affiliator (the field is JS-hidden otherwise,
         create L101-111 / edit L147-157); switching type away leaves the input out of the payload. */
      affiliate_tag: type === "affiliator" ? tag.trim() : "",
      scopes,
      is_active: isNew ? true : isActive,
    });
  };

  return (
    <div className="hoa-editor">

      <div className="hoa-panel">
        <div className="hoa-panel__head">
          <div>
            <div className="hoa-panel__title">Identity</div>
            <div className="hoa-panel__sub">Who this credential belongs to. <code>name</code> and <code>type</code> are labels only — neither grants or restricts anything.</div>
          </div>
        </div>
        <div className="hoa-panel__body">
          <div className="hoa-grid hoa-grid--2">
            <div className="hoa-field">
              <label className="hoa-label" htmlFor="hoa-name">Name <span className="hoa-req">*</span></label>
              <input id="hoa-name" className={`input${errs.name ? " hoa-invalid" : ""}`} autoFocus maxLength={HOA_NAME_MAX}
                value={name} onChange={e => { setName(e.target.value); clearErr("name"); }} />
              {errs.name ? <div className="hoa-fielderr">{errs.name}</div>
                : <div className="hoa-hint">Free text, max {HOA_NAME_MAX}. Not unique and not used for lookups — <code>client_id</code> identifies the client.</div>}
            </div>
            <div className="hoa-field">
              <label className="hoa-label" htmlFor="hoa-type">Type</label>
              <select id="hoa-type" className={`select${errs.type ? " hoa-invalid" : ""}`}
                value={type} onChange={e => { setType(e.target.value); clearErr("type"); }}>
                <option value="">— Select Type —</option>
                {HOA_TYPE_KEYS.map(k => <option key={k} value={k}>{HOA_TYPES[k]}</option>)}
              </select>
              {errs.type ? <div className="hoa-fielderr">{errs.type}</div>
                : <div className="hoa-hint">Optional and nullable. Purely descriptive — no route or middleware branches on <code>type</code>; only the <b>Affiliator</b> value changes this form, by revealing the tag field below.</div>}
            </div>
          </div>

          {!isNew && (
            <div className="hoa-grid hoa-grid--2">
              {/* Edit-only read-only fields (edit.blade.php). Deliberately plain monospace: the real
                  screen shows no copy affordance here — copy buttons exist only in the one-time
                  credentials modal. */}
              <div className="hoa-field">
                <label className="hoa-label">Client ID</label>
                <div className="hoa-ro mono">{client.client_id}</div>
                <div className="hoa-hint">Immutable. Public half of the credential — safe to share with the partner.</div>
              </div>
              <div className="hoa-field">
                <label className="hoa-label">Created</label>
                <div className="hoa-ro">{client.created_at}</div>
                <div className="hoa-hint">There is no "secret last rotated" column — the platform does not record one.</div>
              </div>
            </div>
          )}
        </div>
      </div>

      {/* affiliate_tag is revealed by JS only when type = affiliator (create L101-111 / edit L147-157). */}
      {type === "affiliator" && (
        <div className="hoa-panel">
          <div className="hoa-panel__head">
            <div>
              <div className="hoa-panel__title">Affiliator binding</div>
              <div className="hoa-panel__sub">Which players this partner is allowed to see.</div>
            </div>
          </div>
          <div className="hoa-panel__body">
            <div className="hoa-field">
              <label className="hoa-label" htmlFor="hoa-tag">Affiliate tag <span className="hoa-req">*</span></label>
              <input id="hoa-tag" className={`input${errs.affiliate_tag ? " hoa-invalid" : ""}`} maxLength={HOA_TAG_MAX}
                value={tag} onChange={e => { setTag(e.target.value); clearErr("affiliate_tag"); }} />
              {errs.affiliate_tag
                ? <div className="hoa-fielderr">{errs.affiliate_tag}</div>
                : <div className="hoa-hint">Must match the <code>affiliate_source</code> value sent during user registration.</div>}
            </div>
            <div className="hoa-hint">
              <code>AffiliatorService::getAffiliatedUserIds()</code> (L25-35) selects players where
              <code> users.affiliate_source = oauth_clients.affiliate_tag</code>. Registration currently hardcodes
              <code> affiliate_source = 'track360'</code> (<code>AuthService::DEFAULT_AFFILIATE_SOURCE</code>) for every
              signup, so any tag other than <code>track360</code> matches no players today.
            </div>
            {!tag.trim() && (
              /* The `*` is faithful to the real form; the server rule is nullable, so this saves. */
              <HoaCallout tone="warn" icon="alert" title="Marked required, saved anyway">
                Both <code>validate()</code> calls declare <code>affiliate_tag</code> <b>nullable</b>, so the
                <code> *</code> above is cosmetic and this client will save with an empty tag. The consequence is silent:
                <code> getAffiliatedUserIds()</code> returns <code>[]</code>, so the partner authenticates fine and gets
                <code> 200</code> responses containing zero customers, zero transactions and zero gaming activity — with
                no error anywhere to explain it.
              </HoaCallout>
            )}
          </div>
        </div>
      )}

      <div className="hoa-panel hoa-panel--scopes">
        <div className="hoa-panel__head">
          <div>
            <div className="hoa-panel__title">Scopes</div>
            <div className="hoa-panel__sub">What a token issued to this client is allowed to reach.</div>
          </div>
        </div>
        <div className="hoa-panel__body">
          <div className="hoa-field">
            <label className="hoa-label" htmlFor="hoa-scopes">Scopes</label>
            <input id="hoa-scopes" className={`input${errs.scopes ? " hoa-invalid" : ""}`} maxLength={HOA_SCOPES_MAX}
              placeholder="e.g. affiliator:read"
              value={scopesRaw} onChange={e => { setScopesRaw(e.target.value); clearErr("scopes"); }} />
            {errs.scopes
              ? <div className="hoa-fielderr">{errs.scopes}</div>
              : <div className="hoa-hint">
                  Comma-separated, max {HOA_SCOPES_MAX} characters. Split on commas and trimmed on save; an empty result
                  is stored as <code>null</code> and shown as <code>All</code> in the list.
                  {" "}<code>{HOA_ENFORCED_SCOPE}</code> is the only value any middleware checks — anything else is free
                  text that is stored, displayed and ignored.
                </div>}
          </div>

          <HoaScopeCallout scopes={scopes} type={type} />

          {clearingScopes && (
            /* KNOWN BUG — DIVERGENCE (2): UpdateOAuthClientDto::fromRequest() falls back to the
               client's current scopes when the field arrives empty (controller passes
               $oauth_client->scopes at L74), so emptying the box changes nothing on the real
               platform. Evident intent implemented here: clearing the field clears the scopes. */
            <HoaCallout tone="warn" icon="alert" title="Clearing scopes does not work on the real platform">
              This prototype will save <b>{client.name}</b> with <code>scopes = null</code>, which is the evident intent
              of an emptied field. The live back office would not:
              <code> UpdateOAuthClientDto::fromRequest()</code> falls back to the client's current scopes whenever the
              field arrives empty, so <code>{hoaScopesToInput(client.scopes)}</code> would survive the save untouched.
              Scopes can be widened or replaced there, never removed — de-scoping a client needs a direct DB update.
            </HoaCallout>
          )}
        </div>
      </div>

      {/* is_active is an edit-only switch; create has no status control and the column defaults to 1. */}
      {!isNew && (
        <div className="hoa-panel">
          <div className="hoa-panel__head">
            <div>
              <div className="hoa-panel__title">Status</div>
              <div className="hoa-panel__sub">The only way to take a client out of service — there is no delete route on this screen.</div>
            </div>
          </div>
          <div className="hoa-panel__body">
            <div className="hoa-switchrow">
              <Toggle value={isActive} onChange={setIsActive} onLabel="Active" offLabel="Inactive" size="sm" />
              <div className="hoa-hint">
                Unchecking writes <code>false</code> (<code>UpdateOAuthClientDto</code> uses
                <code> isset(...) ? (bool) : false</code>). Deactivating stops new tokens being issued; it does not
                revoke tokens already in the partner's hands — only a secret regeneration does that.
              </div>
            </div>
          </div>
        </div>
      )}

      <div className="hoa-editfoot">
        <button className="btn btn--secondary" onClick={onCancel}><Icon name="chevron_left" size={13} /> Cancel</button>
        <button className="btn btn--primary" onClick={submit}>
          <Icon name="check" size={13} /> {isNew ? "Create client" : "Save changes"}
        </button>
      </div>

      {isNew && (
        <div className="hoa-hint hoa-hint--foot">
          Creating the client generates its <code>client_id</code> and a secret, then shows the secret exactly once.
          Have somewhere to paste it before you press Create.
        </div>
      )}
    </div>
  );
};

/* -------------------------------------------------------------------------------------------- */
const OAuthClients = () => {
  window.useLocale && window.useLocale();

  const feed = useHrsFetch(() => window.sb.list("oauthClients", { limit: 500 }), []);
  const rows = hoaUseMemo(() => (feed.data || []).map(hoaRowFromDb), [feed.data]);
  const save2 = useHrsSave([feed]);
  const [page, setPage] = hoaUseState(0);
  const [view, setView] = hoaUseState({ mode: "list" });   // { mode:"list" } | { mode:"create" } | { mode:"edit", id }
  const [creds, setCreds] = hoaUseState(null);             // the once-only session()->pull payload
  const [regen, setRegen] = hoaUseState(null);

  const ordered = hoaUseMemo(() => hoaOrder(rows), [rows]);
  const pageCount = Math.max(1, Math.ceil(ordered.length / HOA_PAGE_SIZE));
  const safePage = Math.min(page, pageCount - 1);
  const paged = ordered.slice(safePage * HOA_PAGE_SIZE, safePage * HOA_PAGE_SIZE + HOA_PAGE_SIZE);
  const editing = view.mode === "edit" ? rows.filter(r => r.id === view.id)[0] : null;

  const gate = (
    <>Real-platform access: <b>none beyond the admin panel's own stack</b>. The routes sit inside
      <code> Route::group(['middleware' =&gt; ['admin','adminsettings']])</code> and
      <code> Route::name('admin.')-&gt;middleware(['auth','admin','2fa','g2fa'])</code> and add <b>no role check</b>;
      neither the controller nor the service calls <code>isadmin()</code>. </>
  );
  const gateNote = (
    <>The only gate is <b>link hiding</b>: the sidebar entry lives in the <code>isadmin()</code> branch of the CMS ▾
      menu (sidebar.blade.php L733, link L874-879), and the non-admin CMS variant has no OAuth Clients entry. Any
      authenticated, 2FA'd back-office user who knows the URL <code>/oauth-clients</code> can list, create, edit and
      re-secret every API client. No skin feature flag is involved.</>
  );

  const save = (payload) => {
    if (payload.id == null) {
      /* create_oauth_client() mints the id and the secret, hashes the secret and
         returns the raw value once. The browser supplies a name, a type, a tag
         and scopes — nothing that authenticates anything. */
      return save2.run(
        () => window.sb.rpc("create_oauth_client", {
          p_name: payload.name,
          p_client_type: payload.type,
          p_affiliate_tag: payload.affiliate_tag || null,
          p_scopes: payload.scopes || [],
        }),
        {
          done: (row) => {
            setView({ mode: "list" });
            setPage(0);
            /* The one and only time this value exists here. It is held in
               component state for as long as the modal is open and is never
               written anywhere — not localStorage, not a toast body. */
            setCreds({ reason: "created", name: row.name, client_id: row.client_id,
                       client_secret: row.client_secret, created_at: row.created_at });
          },
        });
    }
    return save2.run(
      () => window.sb.update("oauthClients", payload.id, {
        name: payload.name,
        client_type: payload.type,
        affiliate_tag: payload.affiliate_tag || null,
        scopes: payload.scopes || [],
        is_active: payload.is_active,
      }),
      { done: () => setView({ mode: "list" }) });
  };

  const doRegen = () => {
    const c = regen;
    setRegen(null);
    return save2.run(
      () => window.sb.rpc("rotate_oauth_secret", { p_id: c.id }),
      {
        done: (row) => setCreds({
          reason: "regenerated", name: row.name, client_id: row.client_id,
          client_secret: row.client_secret, created_at: c.created_at,
        }),
      });
  };

  const acts = (r) => (
    <div className="hoa-acts">
      <button className="hoa-act hoa-act--edit" title="Edit"
        onClick={(e) => { e.stopPropagation(); setView({ mode: "edit", id: r.id }); }}>
        <Icon name="edit" size={13} />
      </button>
      <button className="hoa-act hoa-act--regen" title="Regenerate Secret"
        onClick={(e) => { e.stopPropagation(); setRegen(r); }}>
        <Icon name="refresh" size={13} />
      </button>
    </div>
  );

  /* Columns exactly as index.blade.php L124-131, in order. None is sortable — the query is a fixed
     ORDER BY created_at DESC, so no header offers a sort affordance. */
  const columns = [
    { key: "id", label: "ID", width: 74, render: r => <span className="hoa-id">{r.id}</span> },
    {
      key: "name", label: "Name", render: r => (
        <button className="hoa-namelink" title="Edit" onClick={(e) => { e.stopPropagation(); setView({ mode: "edit", id: r.id }); }}>
          <span>{r.name}</span><Icon name="chevron_right" size={12} />
        </button>
      )
    },
    {
      key: "type", label: "Type", width: 170, render: r => (
        <>
          <HoaTypeChip type={r.type} />
          {r.affiliate_tag ? <div className="hoa-tag">{r.affiliate_tag}</div> : null}
          {r.type === "affiliator" && !r.affiliate_tag ? (
            <div className="hoa-tag hoa-tag--missing">
              no tag
              <Tip size={11}>
                An <b>Affiliator</b> client with an empty <code>affiliate_tag</code>.
                <code> AffiliatorService::getAffiliatedUserIds()</code> returns <code>[]</code> for it, so the partner
                authenticates and receives empty <code>200</code> responses with nothing to indicate why.
              </Tip>
            </div>
          ) : null}
        </>
      )
    },
    { key: "client_id", label: "Client ID", width: 300, render: r => <span className="hoa-cid mono">{r.client_id}</span> },
    { key: "scopes", label: "Scopes", render: r => <HoaScopeChips scopes={r.scopes} /> },
    { key: "is_active", label: "Status", align: "center", width: 110, render: r => <HoaStatusChip on={r.is_active} /> },
    { key: "created_at", label: "Created", width: 150, render: r => <span className="hoa-when">{r.created_at}</span> },
    { key: "_acts", label: "Actions", align: "center", width: 110, render: acts },
  ];

  /* ---- editor view (create.blade.php / edit.blade.php are their own pages) ---- */
  if (view.mode === "create" || (view.mode === "edit" && editing)) {
    const isNew = view.mode === "create";
    return (
      <HrsShell
        title={isNew ? "New OAuth Client" : `Edit ${editing.name}`}
        /* Kept short on purpose: .page__subtitle is white-space:nowrap in the shared shell.css, so a
           long subtitle would scroll the page sideways on a phone. */
        subtitle={isNew ? "admin.oauth-clients.create → store" : "admin.oauth-clients.edit → update"}
        gate={gate} gateNote={gateNote}
        explainer={{
          title: "What you are about to mint, in plain English",
          bullets: [
            <>A client is a <b>machine account</b>. Its <code>client_id</code> + secret are exchanged at
              <code> POST /api/oauth/token</code> for a bearer token that is valid for one hour. There is no user behind
              it and no consent screen — whoever holds the secret is the client.</>,
            <>The secret is shown <b>once</b>, immediately after saving, and stored bcrypt-hashed. Nothing in the back
              office can show it again; the only recovery is a regeneration, which revokes every token already issued.</>,
            <><b>Scopes are the whole authorization model here</b>, and only <code>{HOA_ENFORCED_SCOPE}</code> is
              actually enforced. Leaving the field empty is documented as "all scopes" but currently behaves as "no
              scopes" — the Scopes panel spells out what that costs.</>,
            <>Nothing is logged. <code>OAuthClientCreated</code> and <code>OAuthClientSecretRegenerated</code> are
              dispatched into empty listener arrays, and there is no <code>oauth_client_logs</code> table, so creating or
              re-secreting a client leaves no trace.</>,
          ],
        }}
        actions={
          <button className="hrs-btn hrs-btn--search" onClick={() => setView({ mode: "list" })}>
            <Icon name="chevron_left" size={14} /> Back to list
          </button>
        }>
        {/* The credentials modal is deliberately NOT rendered here: store() redirects to the index and
            the modal is pulled from the session there (index.blade L13), so it can only ever appear
            on the list. Saving therefore always lands the operator back on the list first. */}
        <HoaEditor client={isNew ? null : editing} onCancel={() => setView({ mode: "list" })} onSave={save} />
      </HrsShell>
    );
  }

  /* ---- list view ---- */
  return (
    <HrsShell
      title="OAuth Clients"  /* hardcoded English in sidebar.blade.php L876 — no backend.* key exists */
      /* Short by necessity: .page__subtitle is white-space:nowrap in shell.css. */
      subtitle="Machine-to-machine API credentials"
      gate={gate} gateNote={gateNote}
      explainer={{
        title: "What this screen is, in plain English",
        bullets: [
          <>Every row is a <b>set of API credentials</b>. The partner posts <code>client_id</code> + secret to
            <code> POST /api/oauth/token</code> (client_credentials only, <code>throttle:10,1</code>) and gets a raw
            64-character bearer token back, kept server-side as a sha256 hash and valid for 3600s.</>,
          <><b>Only one scope is enforced anywhere:</b> <code>{HOA_ENFORCED_SCOPE}</code>, on
            {HOA_GUARDED_ENDPOINTS.map((e, i) => (
              <React.Fragment key={e}>{i === 0 ? " " : i === HOA_GUARDED_ENDPOINTS.length - 1 ? " and " : ", "}<code className="hoa-brk">{e}</code></React.Fragment>
            ))}. Any other scope string is stored and displayed but never checked.</>,
          <>An <code>All</code> chip means <code>scopes = null</code>. The platform documents that as "all scopes" and
            this prototype keeps that meaning — but <b>at enforcement time it is the reverse</b>:
            <code> OAuthClientAuth</code> reads null as <code>[]</code> and answers
            <code> 403 insufficient_scope</code> on every guarded route. Hover an <code>All</code> chip for the detail.</>,
          <><b>Clients cannot be deleted.</b> No destroy route exists — a client is retired by switching it
            <b> Inactive</b>, and compromised credentials are handled by <b>Regenerate Secret</b>, which revokes all of
            that client's tokens at once.</>,
          <>Nothing here is audited. The three OAuth events are dispatched into empty listener arrays and there is no
            log table, so credential creation and secret rotation leave no record of who or when.</>,
        ],
      }}
      actions={
        <button className="hrs-btn hrs-btn--filters" onClick={() => setView({ mode: "create" })}>
          <Icon name="plus" size={14} /> New Client
        </button>
      }>

      {/* Honest context strip in the exemplar's hero-card language. These are statements about the
          real screen, not controls: the index has no search box, no sortable header, no length menu
          and no export — see the SUGGESTION about search in the header comment. */}
      <div className="hoa-listbar">
        <div className="hrs-fcard hoa-fact">
          <div className="hrs-flab"><Icon name="sort" size={11} /> Order</div>
          <div className="hoa-fact__v"><code>created_at</code> DESC <span className="hoa-fact__x">fixed</span></div>
        </div>
        <div className="hrs-fcard hoa-fact">
          <div className="hrs-flab"><Icon name="filter" size={11} /> Filters</div>
          <div className="hoa-fact__v">None
            <Tip size={11}>The real index has no search box and no status or type filter, and no column is sortable. Nothing is hidden here — there is nothing to filter with.</Tip>
          </div>
        </div>
        <div className="hrs-fcard hoa-fact">
          <div className="hrs-flab"><Icon name="list" size={11} /> Page size</div>
          <div className="hoa-fact__v">{HOA_PAGE_SIZE} <span className="hoa-fact__x">fixed</span></div>
        </div>
        <div className="hrs-fcard hoa-fact hoa-fact--warn">
          <div className="hrs-flab"><Icon name="shield" size={11} /> Audit trail</div>
          <div className="hoa-fact__v">None
            <Tip size={11}>Created / secret-regenerated / token-issued events are all dispatched with empty listener arrays (EventServiceProvider L56-58), and no <code>oauth_client_logs</code> table exists.</Tip>
          </div>
        </div>
        <div className="hrs-fcard hrs-fcard--result">
          <div className="hrs-flab"><Icon name="chart" size={11} /> Results</div>
          <div className="hrs-fresult">{hrsInt(ordered.length)}</div>
        </div>
      </div>

      <HrsTable
        columns={columns} rows={paged} rowKey="id"
        onRowClick={(r) => setView({ mode: "edit", id: r.id })}   /* tr.is-clickable → the edit link */
        empty="No OAuth clients yet — create one with New Client."
        renderCard={r => (
          <>
            <div className="hoa-cardtop">
              <b>{r.name}</b>
              <HoaStatusChip on={r.is_active} />
            </div>
            <div className="hoa-cardchips">
              <HoaTypeChip type={r.type} />
              {r.affiliate_tag ? <span className="hoa-tag">{r.affiliate_tag}</span> : null}
              {r.type === "affiliator" && !r.affiliate_tag ? <span className="hoa-tag hoa-tag--missing">no tag</span> : null}
            </div>
            <div className="hoa-cardscopes"><HoaScopeChips scopes={r.scopes} /></div>
            <details className="hoa-details" onClick={e => e.stopPropagation()}>
              <summary>Details</summary>
              <div className="hrs-card__grid">
                <span>ID</span><b>{r.id}</b>
                <span>Client ID</span><b className="hoa-cid mono">{r.client_id}</b>
                <span>Created</span><b>{r.created_at}</b>
              </div>
            </details>
            <div className="hoa-cardacts" onClick={e => e.stopPropagation()}>
              <button className="btn btn--secondary btn--sm" onClick={() => setView({ mode: "edit", id: r.id })}>
                <Icon name="edit" size={12} /> Edit
              </button>
              <button className="btn btn--ghost btn--sm hoa-cardregen" onClick={() => setRegen(r)}>
                <Icon name="refresh" size={12} /> Regenerate Secret
              </button>
            </div>
          </>
        )} />

      {/* paginate(20) — the real footer is a custom Prev/Next pair plus "Showing X–Y of N, Page a / b"
          (index.blade L204-228). No length menu, so onPageSize is deliberately omitted. */}
      <HrsPager page={safePage} pageSize={HOA_PAGE_SIZE} total={ordered.length} onPage={setPage} />

      <div className="hoa-foot">
        <Icon name="lock" size={12} />
        <span>
          There is no delete route for OAuth clients — <code>index</code>, <code>create</code>, <code>store</code>,
          <code> edit</code>, <code>update</code> and <code>regenerate-secret</code> are the whole set. A client is
          retired by switching it <b>Inactive</b>; compromised credentials are handled with <b>Regenerate Secret</b>,
          which revokes all of that client's tokens in one write.
        </span>
      </div>

      {regen && <HoaRegenDialog client={regen} onCancel={() => setRegen(null)} onConfirm={doRegen} />}
      {creds && <HoaCredentialsModal creds={creds} onClose={() => setCreds(null)} />}
    </HrsShell>
  );
};

/* Route key "cms-oauth" → app.jsx renders <OAuthClients/>. This file replaces the placeholder
   OAuthClients that used to live inside the old src/pages/HostCmsGames.jsx CMS bundle; that bundle
   has since been split (HostCmsGameTaxonomy.jsx / HostCmsGamesImport.jsx) and no longer declares the
   name, so this is now the only definition. Explicit window assignment is the project convention. */
window.OAuthClients = OAuthClients;
