ソースを参照

♻️ refactor(oauth2): restructure OAuth2 client settings UI and extract modal components

- **UI Restructuring:**
  - Separate client info into individual table columns (name, ID, description)
  - Replace icon-only action buttons with text labels for better UX
  - Adjust table scroll width from 1000px to 1200px for new column layout
  - Remove unnecessary Tooltip wrappers and Lucide icons (Edit, Key, Trash2)

- **Component Architecture:**
  - Extract all modal dialogs into separate reusable components:
    * SecretDisplayModal.jsx - for displaying regenerated client secrets
    * ServerInfoModal.jsx - for OAuth2 server configuration info
    * JWKSInfoModal.jsx - for JWKS key set information
  - Simplify main component by removing ~60 lines of inline modal code
  - Implement proper state management for each modal component

- **Code Quality:**
  - Remove unused imports and clean up component dependencies
  - Consolidate modal logic into dedicated components with error handling
  - Improve code maintainability and reusability across the application

- **Internationalization:**
  - Add English translation for '客户端名称': 'Client Name'
  - Remove duplicate translation keys to fix linter warnings
  - Ensure all new components support full i18n functionality

- **User Experience:**
  - Enhance table readability with dedicated columns for each data type
  - Maintain copyable client ID functionality in separate column
  - Improve action button accessibility with clear text labels
  - Add loading states and proper error handling in modal components

This refactoring improves code organization, enhances user experience, and follows React best practices for component composition and separation of concerns.
t0ng7u 3 ヶ月 前
コミット
81272da9ac

+ 647 - 152
web/public/oauth-demo.html

@@ -1,167 +1,662 @@
 <!-- This file is a copy of examples/oauth-demo.html for direct serving under /oauth-demo.html -->
 <!doctype html>
 <html lang="zh-CN">
-<head>
-  <meta charset="utf-8" />
-  <meta name="viewport" content="width=device-width, initial-scale=1" />
-  <title>OAuth2/OIDC 授权码 + PKCE 前端演示</title>
-  <style>
-    :root { --bg:#0b0c10; --panel:#111317; --muted:#aab2bf; --accent:#3b82f6; --ok:#16a34a; --warn:#f59e0b; --err:#ef4444; --border:#1f2430; }
-    body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background: var(--bg); color:#e5e7eb; }
-    .wrap { max-width: 980px; margin: 32px auto; padding: 0 16px; }
-    h1 { font-size: 22px; margin:0 0 16px; }
-    .card { background: var(--panel); border:1px solid var(--border); border-radius: 10px; padding: 16px; margin: 12px 0; }
-    .row { display:flex; gap:12px; flex-wrap:wrap; }
-    .col { flex: 1 1 280px; display:flex; flex-direction:column; }
-    label { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
-    input, textarea, select { background:#0f1115; color:#e5e7eb; border:1px solid var(--border); padding:10px 12px; border-radius:8px; outline:none; }
-    textarea { min-height: 100px; resize: vertical; }
-    .btns { display:flex; gap:8px; flex-wrap:wrap; margin-top: 8px; }
-    button { background:#1a1f2b; color:#e5e7eb; border:1px solid var(--border); padding:8px 12px; border-radius:8px; cursor:pointer; }
-    button.primary { background: var(--accent); border-color: var(--accent); color:white; }
-    button.ok { background: var(--ok); border-color: var(--ok); color:white; }
-    button.warn { background: var(--warn); border-color: var(--warn); color:black; }
-    button.ghost { background: transparent; }
-    .muted { color: var(--muted); font-size: 12px; }
-    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
-    .grid2 { display:grid; grid-template-columns: 1fr 1fr; gap: 12px; }
-    @media (max-width: 880px){ .grid2 { grid-template-columns: 1fr; } }
-    .ok { color: #10b981; }
-    .err { color: #ef4444; }
-    .sep { height:1px; background: var(--border); margin: 12px 0; }
-  </style>
-</head>
-<body>
-  <div class="wrap">
-    <h1>OAuth2/OIDC 授权码 + PKCE 前端演示</h1>
-    <div class="card">
-      <div class="row">
-        <div class="col">
-          <label>Issuer(可选,用于自动发现 /.well-known/openid-configuration)</label>
-          <input id="issuer" placeholder="https://your-domain" />
-          <div class="btns"><button class="" id="btnDiscover">自动发现端点</button></div>
-          <div class="muted">提示:若未配置 Issuer,可直接填写下方端点。</div>
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <title>OAuth2/OIDC 授权码 + PKCE 前端演示</title>
+    <style>
+      :root {
+        --bg: #0b0c10;
+        --panel: #111317;
+        --muted: #aab2bf;
+        --accent: #3b82f6;
+        --ok: #16a34a;
+        --warn: #f59e0b;
+        --err: #ef4444;
+        --border: #1f2430;
+      }
+      body {
+        margin: 0;
+        font-family:
+          ui-sans-serif,
+          system-ui,
+          -apple-system,
+          Segoe UI,
+          Roboto,
+          Helvetica,
+          Arial;
+        background: var(--bg);
+        color: #e5e7eb;
+      }
+      .wrap {
+        max-width: 980px;
+        margin: 32px auto;
+        padding: 0 16px;
+      }
+      h1 {
+        font-size: 22px;
+        margin: 0 0 16px;
+      }
+      .card {
+        background: var(--panel);
+        border: 1px solid var(--border);
+        border-radius: 10px;
+        padding: 16px;
+        margin: 12px 0;
+      }
+      .row {
+        display: flex;
+        gap: 12px;
+        flex-wrap: wrap;
+      }
+      .col {
+        flex: 1 1 280px;
+        display: flex;
+        flex-direction: column;
+      }
+      label {
+        font-size: 12px;
+        color: var(--muted);
+        margin-bottom: 6px;
+      }
+      input,
+      textarea,
+      select {
+        background: #0f1115;
+        color: #e5e7eb;
+        border: 1px solid var(--border);
+        padding: 10px 12px;
+        border-radius: 8px;
+        outline: none;
+      }
+      textarea {
+        min-height: 100px;
+        resize: vertical;
+      }
+      .btns {
+        display: flex;
+        gap: 8px;
+        flex-wrap: wrap;
+        margin-top: 8px;
+      }
+      button {
+        background: #1a1f2b;
+        color: #e5e7eb;
+        border: 1px solid var(--border);
+        padding: 8px 12px;
+        border-radius: 8px;
+        cursor: pointer;
+      }
+      button.primary {
+        background: var(--accent);
+        border-color: var(--accent);
+        color: white;
+      }
+      button.ok {
+        background: var(--ok);
+        border-color: var(--ok);
+        color: white;
+      }
+      button.warn {
+        background: var(--warn);
+        border-color: var(--warn);
+        color: black;
+      }
+      button.ghost {
+        background: transparent;
+      }
+      .muted {
+        color: var(--muted);
+        font-size: 12px;
+      }
+      .mono {
+        font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
+          'Liberation Mono', 'Courier New', monospace;
+      }
+      .grid2 {
+        display: grid;
+        grid-template-columns: 1fr 1fr;
+        gap: 12px;
+      }
+      @media (max-width: 880px) {
+        .grid2 {
+          grid-template-columns: 1fr;
+        }
+      }
+      .ok {
+        color: #10b981;
+      }
+      .err {
+        color: #ef4444;
+      }
+      .sep {
+        height: 1px;
+        background: var(--border);
+        margin: 12px 0;
+      }
+    </style>
+  </head>
+  <body>
+    <div class="wrap">
+      <h1>OAuth2/OIDC 授权码 + PKCE 前端演示</h1>
+      <div class="card">
+        <div class="row">
+          <div class="col">
+            <label
+              >Issuer(可选,用于自动发现
+              /.well-known/openid-configuration)</label
+            >
+            <input id="issuer" placeholder="https://your-domain" />
+            <div class="btns">
+              <button class="" id="btnDiscover">自动发现端点</button>
+            </div>
+            <div class="muted">提示:若未配置 Issuer,可直接填写下方端点。</div>
+          </div>
         </div>
-      </div>
-      <div class="row">
-        <div class="col">
-          <label>Response Type</label>
-          <select id="response_type">
-            <option value="code" selected>code</option>
-            <option value="token">token</option>
-          </select>
+        <div class="row">
+          <div class="col">
+            <label>Response Type</label>
+            <select id="response_type">
+              <option value="code" selected>code</option>
+              <option value="token">token</option>
+            </select>
+          </div>
+          <div class="col">
+            <label>Authorization Endpoint</label
+            ><input
+              id="authorization_endpoint"
+              placeholder="https://domain/api/oauth/authorize"
+            />
+          </div>
+          <div class="col">
+            <label>Token Endpoint</label
+            ><input
+              id="token_endpoint"
+              placeholder="https://domain/api/oauth/token"
+            />
+          </div>
         </div>
-        <div class="col"><label>Authorization Endpoint</label><input id="authorization_endpoint" placeholder="https://domain/api/oauth/authorize" /></div>
-        <div class="col"><label>Token Endpoint</label><input id="token_endpoint" placeholder="https://domain/api/oauth/token" /></div>
-      </div>
-      <div class="row">
-        <div class="col"><label>UserInfo Endpoint(可选)</label><input id="userinfo_endpoint" placeholder="https://domain/api/oauth/userinfo" /></div>
-        <div class="col"><label>Client ID</label><input id="client_id" placeholder="your-public-client-id" /></div>
-      </div>
-      <div class="row">
-        <div class="col"><label>Client Secret(可选,机密客户端)</label><input id="client_secret" placeholder="留空表示公开客户端" /></div>
-      </div>
-      <div class="row">
-        <div class="col"><label>Redirect URI(当前页地址或你的回调)</label><input id="redirect_uri" /></div>
-        <div class="col"><label>Scope</label><input id="scope" value="openid profile email" /></div>
-      </div>
-      <div class="row">
-        <div class="col"><label>State</label><input id="state" /></div>
-        <div class="col"><label>Nonce</label><input id="nonce" /></div>
-      </div>
-      <div class="row">
-        <div class="col"><label>Code Verifier(自动生成,不会上送)</label><input id="code_verifier" class="mono" readonly /></div>
-        <div class="col"><label>Code Challenge(S256)</label><input id="code_challenge" class="mono" readonly /></div>
-      </div>
-      <div class="btns">
-        <button id="btnGenPkce">生成 PKCE</button>
-        <button id="btnRandomState">随机 State</button>
-        <button id="btnRandomNonce">随机 Nonce</button>
-        <button id="btnMakeAuthURL">生成授权链接</button>
-        <button id="btnAuthorize" class="primary">跳转授权</button>
-      </div>
-      <div class="row" style="margin-top:8px;">
-        <div class="col">
-          <label>授权链接(只生成不跳转)</label>
-          <textarea id="authorize_url" class="mono" placeholder="(空)"></textarea>
-          <div class="btns"><button id="btnCopyAuthURL">复制链接</button></div>
+        <div class="row">
+          <div class="col">
+            <label>UserInfo Endpoint(可选)</label
+            ><input
+              id="userinfo_endpoint"
+              placeholder="https://domain/api/oauth/userinfo"
+            />
+          </div>
+          <div class="col">
+            <label>Client ID</label
+            ><input id="client_id" placeholder="your-public-client-id" />
+          </div>
         </div>
-      </div>
-      <div class="sep"></div>
-      <div class="muted">说明:
-        <ul>
-          <li>本页为纯前端演示,适用于公开客户端(不需要 client_secret)。</li>
-          <li>如跨域调用 Token/UserInfo,需要服务端正确设置 CORS;建议将此 demo 部署到同源域名下。</li>
-        </ul>
-      </div>
-      <div class="sep"></div>
-      <div class="row">
-        <div class="col">
-          <label>粘贴 OIDC Discovery JSON(/.well-known/openid-configuration)</label>
-          <textarea id="conf_json" class="mono" placeholder='{"issuer":"https://...","authorization_endpoint":"...","token_endpoint":"...","userinfo_endpoint":"..."}'></textarea>
-          <div class="btns">
-            <button id="btnParseConf">解析并填充端点</button>
-            <button id="btnGenConf">用当前端点生成 JSON</button>
-          </div>
-          <div class="muted">可将服务端返回的 OIDC Discovery JSON 粘贴到此处,点击“解析并填充端点”。</div>
+        <div class="row">
+          <div class="col">
+            <label>Client Secret(可选,机密客户端)</label
+            ><input id="client_secret" placeholder="留空表示公开客户端" />
+          </div>
         </div>
-      </div>
-    </div>
-    <div class="card">
-      <div class="row"><div class="col"><label>授权结果</label><div id="authResult" class="muted">等待授权...</div></div></div>
-      <div class="grid2" style="margin-top:12px;">
-        <div>
-          <label>Access Token</label>
-          <textarea id="access_token" class="mono" placeholder="(空)"></textarea>
-          <div class="btns"><button id="btnCopyAT">复制</button><button id="btnCallUserInfo" class="ok">调用 UserInfo</button></div>
-          <div id="userinfoOut" class="muted" style="margin-top:6px;"></div>
+        <div class="row">
+          <div class="col">
+            <label>Redirect URI(当前页地址或你的回调)</label
+            ><input id="redirect_uri" />
+          </div>
+          <div class="col">
+            <label>Scope</label
+            ><input id="scope" value="openid profile email" />
+          </div>
+        </div>
+        <div class="row">
+          <div class="col"><label>State</label><input id="state" /></div>
+          <div class="col"><label>Nonce</label><input id="nonce" /></div>
+        </div>
+        <div class="row">
+          <div class="col">
+            <label>Code Verifier(自动生成,不会上送)</label
+            ><input id="code_verifier" class="mono" readonly />
+          </div>
+          <div class="col">
+            <label>Code Challenge(S256)</label
+            ><input id="code_challenge" class="mono" readonly />
+          </div>
+        </div>
+        <div class="btns">
+          <button id="btnGenPkce">生成 PKCE</button>
+          <button id="btnRandomState">随机 State</button>
+          <button id="btnRandomNonce">随机 Nonce</button>
+          <button id="btnMakeAuthURL">生成授权链接</button>
+          <button id="btnAuthorize" class="primary">跳转授权</button>
+        </div>
+        <div class="row" style="margin-top: 8px">
+          <div class="col">
+            <label>授权链接(只生成不跳转)</label>
+            <textarea
+              id="authorize_url"
+              class="mono"
+              placeholder="(空)"
+            ></textarea>
+            <div class="btns">
+              <button id="btnCopyAuthURL">复制链接</button>
+            </div>
+          </div>
+        </div>
+        <div class="sep"></div>
+        <div class="muted">
+          说明:
+          <ul>
+            <li>
+              本页为纯前端演示,适用于公开客户端(不需要 client_secret)。
+            </li>
+            <li>
+              如跨域调用 Token/UserInfo,需要服务端正确设置 CORS;建议将此 demo
+              部署到同源域名下。
+            </li>
+          </ul>
         </div>
-        <div>
-          <label>ID Token(JWT)</label>
-          <textarea id="id_token" class="mono" placeholder="(空)"></textarea>
-          <div class="btns"><button id="btnDecodeJWT">解码显示 Claims</button></div>
-          <pre id="jwtClaims" class="mono" style="white-space:pre-wrap; word-break:break-all; margin-top:6px;"></pre>
+        <div class="sep"></div>
+        <div class="row">
+          <div class="col">
+            <label
+              >粘贴 OIDC Discovery
+              JSON(/.well-known/openid-configuration)</label
+            >
+            <textarea
+              id="conf_json"
+              class="mono"
+              placeholder='{"issuer":"https://...","authorization_endpoint":"...","token_endpoint":"...","userinfo_endpoint":"..."}'
+            ></textarea>
+            <div class="btns">
+              <button id="btnParseConf">解析并填充端点</button>
+              <button id="btnGenConf">用当前端点生成 JSON</button>
+            </div>
+            <div class="muted">
+              可将服务端返回的 OIDC Discovery JSON
+              粘贴到此处,点击“解析并填充端点”。
+            </div>
+          </div>
         </div>
       </div>
-      <div class="grid2" style="margin-top:12px;">
-        <div>
-          <label>Refresh Token</label>
-          <textarea id="refresh_token" class="mono" placeholder="(空)"></textarea>
-          <div class="btns"><button id="btnRefreshToken">使用 Refresh Token 刷新</button></div>
+      <div class="card">
+        <div class="row">
+          <div class="col">
+            <label>授权结果</label>
+            <div id="authResult" class="muted">等待授权...</div>
+          </div>
         </div>
-        <div>
-          <label>原始 Token 响应</label>
-          <textarea id="token_raw" class="mono" placeholder="(空)"></textarea>
+        <div class="grid2" style="margin-top: 12px">
+          <div>
+            <label>Access Token</label>
+            <textarea
+              id="access_token"
+              class="mono"
+              placeholder="(空)"
+            ></textarea>
+            <div class="btns">
+              <button id="btnCopyAT">复制</button
+              ><button id="btnCallUserInfo" class="ok">调用 UserInfo</button>
+            </div>
+            <div id="userinfoOut" class="muted" style="margin-top: 6px"></div>
+          </div>
+          <div>
+            <label>ID Token(JWT)</label>
+            <textarea id="id_token" class="mono" placeholder="(空)"></textarea>
+            <div class="btns">
+              <button id="btnDecodeJWT">解码显示 Claims</button>
+            </div>
+            <pre
+              id="jwtClaims"
+              class="mono"
+              style="
+                white-space: pre-wrap;
+                word-break: break-all;
+                margin-top: 6px;
+              "
+            ></pre>
+          </div>
+        </div>
+        <div class="grid2" style="margin-top: 12px">
+          <div>
+            <label>Refresh Token</label>
+            <textarea
+              id="refresh_token"
+              class="mono"
+              placeholder="(空)"
+            ></textarea>
+            <div class="btns">
+              <button id="btnRefreshToken">使用 Refresh Token 刷新</button>
+            </div>
+          </div>
+          <div>
+            <label>原始 Token 响应</label>
+            <textarea id="token_raw" class="mono" placeholder="(空)"></textarea>
+          </div>
         </div>
       </div>
     </div>
-  </div>
-  <script>
-    const $ = (id) => document.getElementById(id);
-    const toB64Url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
-    async function sha256B64Url(str){ const data=new TextEncoder().encode(str); const digest=await crypto.subtle.digest('SHA-256', data); return toB64Url(digest); }
-    function randStr(len=64){ const chars='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; const arr=new Uint8Array(len); crypto.getRandomValues(arr); return Array.from(arr,v=>chars[v%chars.length]).join(''); }
-    function setAuthInfo(msg, ok=true){ const el=$('authResult'); el.textContent=msg; el.className = ok? 'ok':'err'; }
-    function qs(name){ const u=new URL(location.href); return u.searchParams.get(name); }
-    function persist(k,v){ sessionStorage.setItem('demo_'+k, v); }
-    function load(k){ return sessionStorage.getItem('demo_'+k) || ''; }
-    (function init(){ $('redirect_uri').value = window.location.origin + window.location.pathname; const iss=load('issuer'); if(iss) $('issuer').value=iss; const cid=load('client_id'); if(cid) $('client_id').value=cid; const scp=load('scope'); if(scp) $('scope').value=scp; })();
-    $('btnDiscover').onclick = async () => { const iss=$('issuer').value.trim(); if(!iss){ alert('请填写 Issuer'); return; } try{ persist('issuer', iss); const res=await fetch(iss.replace(/\/$/,'')+'/api/.well-known/openid-configuration'); const d=await res.json(); $('authorization_endpoint').value=d.authorization_endpoint||''; $('token_endpoint').value=d.token_endpoint||''; $('userinfo_endpoint').value=d.userinfo_endpoint||''; if(d.issuer){ $('issuer').value=d.issuer; persist('issuer', d.issuer);} $('conf_json').value=JSON.stringify(d,null,2); setAuthInfo('已从发现文档加载端点', true);}catch(e){ setAuthInfo('自动发现失败:'+e,false);} };
-    $('btnGenPkce').onclick = async () => { const v=randStr(64); const c=await sha256B64Url(v); $('code_verifier').value=v; $('code_challenge').value=c; persist('code_verifier',v); persist('code_challenge',c); setAuthInfo('已生成 PKCE 参数', true); };
-    $('btnRandomState').onclick = ()=>{ $('state').value=randStr(16); persist('state',$('state').value);} ;
-    $('btnRandomNonce').onclick = ()=>{ $('nonce').value=randStr(16); persist('nonce',$('nonce').value);} ;
-    function buildAuthorizeURLFromFields(){ const auth=$('authorization_endpoint').value.trim(); const token=$('token_endpoint').value.trim(); const cid=$('client_id').value.trim(); const red=$('redirect_uri').value.trim(); const scp=$('scope').value.trim()||'openid profile email'; const rt=$('response_type').value; const st=$('state').value.trim()||randStr(16); const no=$('nonce').value.trim()||randStr(16); const cc=$('code_challenge').value.trim(); const cv=$('code_verifier').value.trim(); if(!auth||!cid||!red){ throw new Error('请先完善端点/ClientID/RedirectURI'); } if(rt==='code' && (!cc||!cv)){ throw new Error('请先生成 PKCE'); } persist('authorization_endpoint', auth); persist('token_endpoint', token); persist('client_id', cid); persist('redirect_uri', red); persist('scope', scp); persist('state', st); persist('nonce', no); persist('code_verifier', cv); const u=new URL(auth); u.searchParams.set('response_type', rt); u.searchParams.set('client_id',cid); u.searchParams.set('redirect_uri',red); u.searchParams.set('scope',scp); u.searchParams.set('state',st); if(no) u.searchParams.set('nonce',no); if(rt==='code'){ u.searchParams.set('code_challenge',cc); u.searchParams.set('code_challenge_method','S256'); } return u.toString(); }
-    $('btnMakeAuthURL').onclick = ()=>{ try{ const url=buildAuthorizeURLFromFields(); $('authorize_url').value=url; setAuthInfo('已生成授权链接',true);}catch(e){ setAuthInfo(e.message,false);} };
-    $('btnAuthorize').onclick = ()=>{ try{ const url=buildAuthorizeURLFromFields(); location.href=url; }catch(e){ setAuthInfo(e.message,false);} };
-    $('btnCopyAuthURL').onclick = async()=>{ try{ await navigator.clipboard.writeText($('authorize_url').value);}catch{} };
-    async function postForm(url, data, basic){ const body=Object.entries(data).map(([k,v])=> `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&'); const headers={ 'Content-Type':'application/x-www-form-urlencoded' }; if(basic&&basic.id&&basic.secret){ headers['Authorization']='Basic '+btoa(`${basic.id}:${basic.secret}`);} const res=await fetch(url,{ method:'POST', headers, body }); if(!res.ok){ const t=await res.text(); throw new Error(`HTTP ${res.status} ${t}`);} return res.json(); }
-    async function handleCallback(){ const frag=location.hash&&location.hash.startsWith('#')?new URLSearchParams(location.hash.slice(1)):null; const at=frag?frag.get('access_token'):null; const err=qs('error')|| (frag?frag.get('error'):null); const state=qs('state')||(frag?frag.get('state'):null); if(err){ setAuthInfo('授权失败:'+err,false); return; } if(at){ $('access_token').value=at||''; $('token_raw').value=JSON.stringify({ access_token: at, token_type: frag.get('token_type'), expires_in: frag.get('expires_in'), scope: frag.get('scope'), state }, null, 2); setAuthInfo('隐式模式已获取 Access Token', true); return; } const code=qs('code'); if(!code){ setAuthInfo('等待授权...', true); return; } if(state && load('state') && state!==load('state')){ setAuthInfo('state 不匹配,已拒绝', false); return; } try{ const tokenEp=load('token_endpoint'); const cid=load('client_id'); const csec=$('client_secret').value.trim(); const basic=csec?{id:cid,secret:csec}:null; const data=await postForm(tokenEp,{ grant_type:'authorization_code', code, client_id:cid, redirect_uri:load('redirect_uri'), code_verifier:load('code_verifier') }, basic); $('access_token').value=data.access_token||''; $('id_token').value=data.id_token||''; $('refresh_token').value=data.refresh_token||''; $('token_raw').value=JSON.stringify(data,null,2); setAuthInfo('授权成功,已获取令牌', true);}catch(e){ setAuthInfo('交换令牌失败:'+e.message,false);} } handleCallback();
-    $('btnCopyAT').onclick = async ()=>{ try{ await navigator.clipboard.writeText($('access_token').value);}catch{} };
-    $('btnDecodeJWT').onclick = ()=>{ const t=$('id_token').value.trim(); if(!t){ $('jwtClaims').textContent='(空)'; return;} const parts=t.split('.'); if(parts.length<2){ $('jwtClaims').textContent='格式错误'; return;} try{ const json=JSON.parse(atob(parts[1].replace(/-/g,'+').replace(/_/g,'/'))); $('jwtClaims').textContent=JSON.stringify(json,null,2);}catch(e){ $('jwtClaims').textContent='解码失败:'+e; } };
-    $('btnCallUserInfo').onclick = async ()=>{ const at=$('access_token').value.trim(); const ep=$('userinfo_endpoint').value.trim(); if(!at||!ep){ alert('请填写UserInfo端点并获取AccessToken'); return;} try{ const res=await fetch(ep,{ headers:{ Authorization:'Bearer '+at }}); const data=await res.json(); $('userinfoOut').textContent=JSON.stringify(data,null,2);}catch(e){ $('userinfoOut').textContent='调用失败:'+e; } };
-    $('btnRefreshToken').onclick = async ()=>{ const rt=$('refresh_token').value.trim(); if(!rt){ alert('没有刷新令牌'); return;} try{ const tokenEp=load('token_endpoint'); const cid=load('client_id'); const csec=$('client_secret').value.trim(); const basic=csec?{id:cid,secret:csec}:null; const data=await postForm(tokenEp,{ grant_type:'refresh_token', refresh_token:rt, client_id:cid }, basic); $('access_token').value=data.access_token||''; $('id_token').value=data.id_token||''; $('refresh_token').value=data.refresh_token||''; $('token_raw').value=JSON.stringify(data,null,2); setAuthInfo('刷新成功', true);}catch(e){ setAuthInfo('刷新失败:'+e.message,false);} };
-    $('btnParseConf').onclick = ()=>{ const txt=$('conf_json').value.trim(); if(!txt){ alert('请先粘贴 JSON'); return;} try{ const d=JSON.parse(txt); if(d.issuer){ $('issuer').value=d.issuer; persist('issuer', d.issuer);} if(d.authorization_endpoint) $('authorization_endpoint').value=d.authorization_endpoint; if(d.token_endpoint) $('token_endpoint').value=d.token_endpoint; if(d.userinfo_endpoint) $('userinfo_endpoint').value=d.userinfo_endpoint; setAuthInfo('已解析配置并填充端点', true);}catch(e){ setAuthInfo('解析失败:'+e,false);} };
-    $('btnGenConf').onclick = ()=>{ const d={ issuer:$('issuer').value.trim()||undefined, authorization_endpoint:$('authorization_endpoint').value.trim()||undefined, token_endpoint:$('token_endpoint').value.trim()||undefined, userinfo_endpoint:$('userinfo_endpoint').value.trim()||undefined }; $('conf_json').value = JSON.stringify(d,null,2); };
-  </script>
-</body>
+    <script>
+      const $ = (id) => document.getElementById(id);
+      const toB64Url = (buf) =>
+        btoa(String.fromCharCode(...new Uint8Array(buf)))
+          .replace(/\+/g, '-')
+          .replace(/\//g, '_')
+          .replace(/=+$/, '');
+      async function sha256B64Url(str) {
+        const data = new TextEncoder().encode(str);
+        const digest = await crypto.subtle.digest('SHA-256', data);
+        return toB64Url(digest);
+      }
+      function randStr(len = 64) {
+        const chars =
+          'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
+        const arr = new Uint8Array(len);
+        crypto.getRandomValues(arr);
+        return Array.from(arr, (v) => chars[v % chars.length]).join('');
+      }
+      function setAuthInfo(msg, ok = true) {
+        const el = $('authResult');
+        el.textContent = msg;
+        el.className = ok ? 'ok' : 'err';
+      }
+      function qs(name) {
+        const u = new URL(location.href);
+        return u.searchParams.get(name);
+      }
+      function persist(k, v) {
+        sessionStorage.setItem('demo_' + k, v);
+      }
+      function load(k) {
+        return sessionStorage.getItem('demo_' + k) || '';
+      }
+      (function init() {
+        $('redirect_uri').value =
+          window.location.origin + window.location.pathname;
+        const iss = load('issuer');
+        if (iss) $('issuer').value = iss;
+        const cid = load('client_id');
+        if (cid) $('client_id').value = cid;
+        const scp = load('scope');
+        if (scp) $('scope').value = scp;
+      })();
+      $('btnDiscover').onclick = async () => {
+        const iss = $('issuer').value.trim();
+        if (!iss) {
+          alert('请填写 Issuer');
+          return;
+        }
+        try {
+          persist('issuer', iss);
+          const res = await fetch(
+            iss.replace(/\/$/, '') + '/api/.well-known/openid-configuration',
+          );
+          const d = await res.json();
+          $('authorization_endpoint').value = d.authorization_endpoint || '';
+          $('token_endpoint').value = d.token_endpoint || '';
+          $('userinfo_endpoint').value = d.userinfo_endpoint || '';
+          if (d.issuer) {
+            $('issuer').value = d.issuer;
+            persist('issuer', d.issuer);
+          }
+          $('conf_json').value = JSON.stringify(d, null, 2);
+          setAuthInfo('已从发现文档加载端点', true);
+        } catch (e) {
+          setAuthInfo('自动发现失败:' + e, false);
+        }
+      };
+      $('btnGenPkce').onclick = async () => {
+        const v = randStr(64);
+        const c = await sha256B64Url(v);
+        $('code_verifier').value = v;
+        $('code_challenge').value = c;
+        persist('code_verifier', v);
+        persist('code_challenge', c);
+        setAuthInfo('已生成 PKCE 参数', true);
+      };
+      $('btnRandomState').onclick = () => {
+        $('state').value = randStr(16);
+        persist('state', $('state').value);
+      };
+      $('btnRandomNonce').onclick = () => {
+        $('nonce').value = randStr(16);
+        persist('nonce', $('nonce').value);
+      };
+      function buildAuthorizeURLFromFields() {
+        const auth = $('authorization_endpoint').value.trim();
+        const token = $('token_endpoint').value.trim();
+        const cid = $('client_id').value.trim();
+        const red = $('redirect_uri').value.trim();
+        const scp = $('scope').value.trim() || 'openid profile email';
+        const rt = $('response_type').value;
+        const st = $('state').value.trim() || randStr(16);
+        const no = $('nonce').value.trim() || randStr(16);
+        const cc = $('code_challenge').value.trim();
+        const cv = $('code_verifier').value.trim();
+        if (!auth || !cid || !red) {
+          throw new Error('请先完善端点/ClientID/RedirectURI');
+        }
+        if (rt === 'code' && (!cc || !cv)) {
+          throw new Error('请先生成 PKCE');
+        }
+        persist('authorization_endpoint', auth);
+        persist('token_endpoint', token);
+        persist('client_id', cid);
+        persist('redirect_uri', red);
+        persist('scope', scp);
+        persist('state', st);
+        persist('nonce', no);
+        persist('code_verifier', cv);
+        const u = new URL(auth);
+        u.searchParams.set('response_type', rt);
+        u.searchParams.set('client_id', cid);
+        u.searchParams.set('redirect_uri', red);
+        u.searchParams.set('scope', scp);
+        u.searchParams.set('state', st);
+        if (no) u.searchParams.set('nonce', no);
+        if (rt === 'code') {
+          u.searchParams.set('code_challenge', cc);
+          u.searchParams.set('code_challenge_method', 'S256');
+        }
+        return u.toString();
+      }
+      $('btnMakeAuthURL').onclick = () => {
+        try {
+          const url = buildAuthorizeURLFromFields();
+          $('authorize_url').value = url;
+          setAuthInfo('已生成授权链接', true);
+        } catch (e) {
+          setAuthInfo(e.message, false);
+        }
+      };
+      $('btnAuthorize').onclick = () => {
+        try {
+          const url = buildAuthorizeURLFromFields();
+          location.href = url;
+        } catch (e) {
+          setAuthInfo(e.message, false);
+        }
+      };
+      $('btnCopyAuthURL').onclick = async () => {
+        try {
+          await navigator.clipboard.writeText($('authorize_url').value);
+        } catch {}
+      };
+      async function postForm(url, data, basic) {
+        const body = Object.entries(data)
+          .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
+          .join('&');
+        const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
+        if (basic && basic.id && basic.secret) {
+          headers['Authorization'] =
+            'Basic ' + btoa(`${basic.id}:${basic.secret}`);
+        }
+        const res = await fetch(url, { method: 'POST', headers, body });
+        if (!res.ok) {
+          const t = await res.text();
+          throw new Error(`HTTP ${res.status} ${t}`);
+        }
+        return res.json();
+      }
+      async function handleCallback() {
+        const frag =
+          location.hash && location.hash.startsWith('#')
+            ? new URLSearchParams(location.hash.slice(1))
+            : null;
+        const at = frag ? frag.get('access_token') : null;
+        const err = qs('error') || (frag ? frag.get('error') : null);
+        const state = qs('state') || (frag ? frag.get('state') : null);
+        if (err) {
+          setAuthInfo('授权失败:' + err, false);
+          return;
+        }
+        if (at) {
+          $('access_token').value = at || '';
+          $('token_raw').value = JSON.stringify(
+            {
+              access_token: at,
+              token_type: frag.get('token_type'),
+              expires_in: frag.get('expires_in'),
+              scope: frag.get('scope'),
+              state,
+            },
+            null,
+            2,
+          );
+          setAuthInfo('隐式模式已获取 Access Token', true);
+          return;
+        }
+        const code = qs('code');
+        if (!code) {
+          setAuthInfo('等待授权...', true);
+          return;
+        }
+        if (state && load('state') && state !== load('state')) {
+          setAuthInfo('state 不匹配,已拒绝', false);
+          return;
+        }
+        try {
+          const tokenEp = load('token_endpoint');
+          const cid = load('client_id');
+          const csec = $('client_secret').value.trim();
+          const basic = csec ? { id: cid, secret: csec } : null;
+          const data = await postForm(
+            tokenEp,
+            {
+              grant_type: 'authorization_code',
+              code,
+              client_id: cid,
+              redirect_uri: load('redirect_uri'),
+              code_verifier: load('code_verifier'),
+            },
+            basic,
+          );
+          $('access_token').value = data.access_token || '';
+          $('id_token').value = data.id_token || '';
+          $('refresh_token').value = data.refresh_token || '';
+          $('token_raw').value = JSON.stringify(data, null, 2);
+          setAuthInfo('授权成功,已获取令牌', true);
+        } catch (e) {
+          setAuthInfo('交换令牌失败:' + e.message, false);
+        }
+      }
+      handleCallback();
+      $('btnCopyAT').onclick = async () => {
+        try {
+          await navigator.clipboard.writeText($('access_token').value);
+        } catch {}
+      };
+      $('btnDecodeJWT').onclick = () => {
+        const t = $('id_token').value.trim();
+        if (!t) {
+          $('jwtClaims').textContent = '(空)';
+          return;
+        }
+        const parts = t.split('.');
+        if (parts.length < 2) {
+          $('jwtClaims').textContent = '格式错误';
+          return;
+        }
+        try {
+          const json = JSON.parse(
+            atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')),
+          );
+          $('jwtClaims').textContent = JSON.stringify(json, null, 2);
+        } catch (e) {
+          $('jwtClaims').textContent = '解码失败:' + e;
+        }
+      };
+      $('btnCallUserInfo').onclick = async () => {
+        const at = $('access_token').value.trim();
+        const ep = $('userinfo_endpoint').value.trim();
+        if (!at || !ep) {
+          alert('请填写UserInfo端点并获取AccessToken');
+          return;
+        }
+        try {
+          const res = await fetch(ep, {
+            headers: { Authorization: 'Bearer ' + at },
+          });
+          const data = await res.json();
+          $('userinfoOut').textContent = JSON.stringify(data, null, 2);
+        } catch (e) {
+          $('userinfoOut').textContent = '调用失败:' + e;
+        }
+      };
+      $('btnRefreshToken').onclick = async () => {
+        const rt = $('refresh_token').value.trim();
+        if (!rt) {
+          alert('没有刷新令牌');
+          return;
+        }
+        try {
+          const tokenEp = load('token_endpoint');
+          const cid = load('client_id');
+          const csec = $('client_secret').value.trim();
+          const basic = csec ? { id: cid, secret: csec } : null;
+          const data = await postForm(
+            tokenEp,
+            { grant_type: 'refresh_token', refresh_token: rt, client_id: cid },
+            basic,
+          );
+          $('access_token').value = data.access_token || '';
+          $('id_token').value = data.id_token || '';
+          $('refresh_token').value = data.refresh_token || '';
+          $('token_raw').value = JSON.stringify(data, null, 2);
+          setAuthInfo('刷新成功', true);
+        } catch (e) {
+          setAuthInfo('刷新失败:' + e.message, false);
+        }
+      };
+      $('btnParseConf').onclick = () => {
+        const txt = $('conf_json').value.trim();
+        if (!txt) {
+          alert('请先粘贴 JSON');
+          return;
+        }
+        try {
+          const d = JSON.parse(txt);
+          if (d.issuer) {
+            $('issuer').value = d.issuer;
+            persist('issuer', d.issuer);
+          }
+          if (d.authorization_endpoint)
+            $('authorization_endpoint').value = d.authorization_endpoint;
+          if (d.token_endpoint) $('token_endpoint').value = d.token_endpoint;
+          if (d.userinfo_endpoint)
+            $('userinfo_endpoint').value = d.userinfo_endpoint;
+          setAuthInfo('已解析配置并填充端点', true);
+        } catch (e) {
+          setAuthInfo('解析失败:' + e, false);
+        }
+      };
+      $('btnGenConf').onclick = () => {
+        const d = {
+          issuer: $('issuer').value.trim() || undefined,
+          authorization_endpoint:
+            $('authorization_endpoint').value.trim() || undefined,
+          token_endpoint: $('token_endpoint').value.trim() || undefined,
+          userinfo_endpoint: $('userinfo_endpoint').value.trim() || undefined,
+        };
+        $('conf_json').value = JSON.stringify(d, null, 2);
+      };
+    </script>
+  </body>
 </html>

+ 3 - 1
web/src/components/common/modals/TwoFactorAuthModal.jsx

@@ -135,7 +135,9 @@ const TwoFactorAuthModal = ({
             autoFocus
           />
           <Typography.Text type='tertiary' size='small' className='mt-2 block'>
-            {t('支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。')}
+            {t(
+              '支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。',
+            )}
           </Typography.Text>
         </div>
       </div>

+ 1 - 4
web/src/components/settings/OAuth2Setting.jsx

@@ -61,10 +61,7 @@ const OAuth2Setting = () => {
   return (
     <Spin spinning={loading} size='large'>
       {/* 服务器配置 */}
-      <OAuth2ServerSettings 
-        options={options} 
-        refresh={refresh}
-      />
+      <OAuth2ServerSettings options={options} refresh={refresh} />
 
       {/* 客户端管理 */}
       <OAuth2ClientSettings />

+ 200 - 238
web/src/components/settings/oauth2/OAuth2ClientSettings.jsx

@@ -18,39 +18,29 @@ For commercial licensing, please contact [email protected]
 */
 
 import React, { useEffect, useState } from 'react';
-import { 
-  Card, 
-  Table, 
-  Button, 
-  Space, 
-  Tag, 
-  Typography, 
-  Input, 
+import {
+  Card,
+  Table,
+  Button,
+  Space,
+  Tag,
+  Typography,
+  Input,
   Popconfirm,
-  Modal,
-  Banner,
-  Row,
-  Col,
   Empty,
-  Tooltip
+  Tooltip,
 } from '@douyinfe/semi-ui';
-import { 
-  Search, 
-  Plus, 
-  RefreshCw,
-  Edit,
-  Key,
-  Trash2,
-  Eye,
-  User,
-  Grid3X3
-} from 'lucide-react';
+import { IconSearch } from '@douyinfe/semi-icons';
+import { User, Grid3X3 } from 'lucide-react';
 import { API, showError, showSuccess } from '../../../helpers';
 import CreateOAuth2ClientModal from './modals/CreateOAuth2ClientModal';
 import EditOAuth2ClientModal from './modals/EditOAuth2ClientModal';
+import SecretDisplayModal from './modals/SecretDisplayModal';
+import ServerInfoModal from './modals/ServerInfoModal';
+import JWKSInfoModal from './modals/JWKSInfoModal';
 import { useTranslation } from 'react-i18next';
 
-const { Text, Title } = Typography;
+const { Text } = Typography;
 
 export default function OAuth2ClientSettings() {
   const { t } = useTranslation();
@@ -63,6 +53,8 @@ export default function OAuth2ClientSettings() {
   const [editingClient, setEditingClient] = useState(null);
   const [showSecretModal, setShowSecretModal] = useState(false);
   const [currentSecret, setCurrentSecret] = useState('');
+  const [showServerInfoModal, setShowServerInfoModal] = useState(false);
+  const [showJWKSModal, setShowJWKSModal] = useState(false);
 
   // 加载客户端列表
   const loadClients = async () => {
@@ -88,10 +80,11 @@ export default function OAuth2ClientSettings() {
     if (!value) {
       setFilteredClients(clients);
     } else {
-      const filtered = clients.filter(client =>
-        client.name?.toLowerCase().includes(value.toLowerCase()) ||
-        client.id?.toLowerCase().includes(value.toLowerCase()) ||
-        client.description?.toLowerCase().includes(value.toLowerCase())
+      const filtered = clients.filter(
+        (client) =>
+          client.name?.toLowerCase().includes(value.toLowerCase()) ||
+          client.id?.toLowerCase().includes(value.toLowerCase()) ||
+          client.description?.toLowerCase().includes(value.toLowerCase()),
       );
       setFilteredClients(filtered);
     }
@@ -115,7 +108,9 @@ export default function OAuth2ClientSettings() {
   // 重新生成密钥
   const handleRegenerateSecret = async (client) => {
     try {
-      const res = await API.post(`/api/oauth_clients/${client.id}/regenerate_secret`);
+      const res = await API.post(
+        `/api/oauth_clients/${client.id}/regenerate_secret`,
+      );
       if (res.data.success) {
         setCurrentSecret(res.data.client_secret);
         setShowSecretModal(true);
@@ -128,91 +123,54 @@ export default function OAuth2ClientSettings() {
     }
   };
 
-  // 快速查看服务器信息
-  const showServerInfo = async () => {
-    try {
-      const res = await API.get('/api/oauth/server-info');
-      Modal.info({
-        title: t('OAuth2 服务器信息'),
-        content: (
-          <div>
-            <Text>{t('授权服务器配置')}:</Text>
-            <pre style={{ 
-              background: '#f8f9fa', 
-              padding: '12px', 
-              borderRadius: '4px',
-              marginTop: '8px',
-              fontSize: '12px',
-              maxHeight: '300px',
-              overflow: 'auto'
-            }}>
-              {JSON.stringify(res.data, null, 2)}
-            </pre>
-          </div>
-        ),
-        width: 600
-      });
-    } catch (error) {
-      showError(t('获取服务器信息失败'));
-    }
+  // 查看服务器信息
+  const showServerInfo = () => {
+    setShowServerInfoModal(true);
   };
 
   // 查看JWKS
-  const showJWKS = async () => {
-    try {
-      const res = await API.get('/api/oauth/jwks');
-      Modal.info({
-        title: t('JWKS 信息'),
-        content: (
-          <div>
-            <Text>{t('JSON Web Key Set')}:</Text>
-            <pre style={{ 
-              background: '#f8f9fa', 
-              padding: '12px', 
-              borderRadius: '4px',
-              marginTop: '8px',
-              fontSize: '12px',
-              maxHeight: '300px',
-              overflow: 'auto'
-            }}>
-              {JSON.stringify(res.data, null, 2)}
-            </pre>
-          </div>
-        ),
-        width: 600
-      });
-    } catch (error) {
-      showError(t('获取JWKS失败'));
-    }
+  const showJWKS = () => {
+    setShowJWKSModal(true);
   };
 
   // 表格列定义
   const columns = [
     {
-      title: t('客户端信息'),
-      key: 'info',
-      render: (_, record) => (
-        <div>
-          <div style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
-            <User size={16} style={{ marginRight: 6, color: 'var(--semi-color-text-2)' }} />
-            <Text strong>{record.name}</Text>
-          </div>
-          <div style={{ display: 'flex', alignItems: 'center' }}>
-            <Grid3X3 size={16} style={{ marginRight: 6, color: 'var(--semi-color-text-2)' }} />
-            <Text type="tertiary" size="small" code copyable>
-              {record.id}
-            </Text>
-          </div>
+      title: t('客户端名称'),
+      dataIndex: 'name',
+      render: (name) => (
+        <div className='flex items-center'>
+          <User size={16} className='mr-1.5 text-gray-500' />
+          <Text strong>{name}</Text>
         </div>
       ),
+      width: 150,
+    },
+    {
+      title: t('客户端ID'),
+      dataIndex: 'id',
+      render: (id) => (
+        <Text type='tertiary' size='small' code copyable>
+          {id}
+        </Text>
+      ),
       width: 200,
     },
+    {
+      title: t('描述'),
+      dataIndex: 'description',
+      render: (description) => (
+        <Text type='tertiary' size='small'>
+          {description || '-'}
+        </Text>
+      ),
+      width: 150,
+    },
     {
       title: t('类型'),
       dataIndex: 'client_type',
-      key: 'client_type',
       render: (text) => (
-        <Tag 
+        <Tag
           color={text === 'confidential' ? 'blue' : 'green'}
           style={{ borderRadius: '12px' }}
         >
@@ -224,24 +182,31 @@ export default function OAuth2ClientSettings() {
     {
       title: t('授权类型'),
       dataIndex: 'grant_types',
-      key: 'grant_types',
       render: (grantTypes) => {
-        const types = typeof grantTypes === 'string' ? grantTypes.split(',') : (grantTypes || []);
+        const types =
+          typeof grantTypes === 'string'
+            ? grantTypes.split(',')
+            : grantTypes || [];
         const typeMap = {
-          'client_credentials': t('客户端凭证'),
-          'authorization_code': t('授权码'),
-          'refresh_token': t('刷新令牌')
+          client_credentials: t('客户端凭证'),
+          authorization_code: t('授权码'),
+          refresh_token: t('刷新令牌'),
         };
         return (
-          <div>
-            {types.slice(0, 2).map(type => (
-              <Tag key={type} size="small" style={{ margin: '1px', borderRadius: '8px' }}>
+          <div className='flex flex-wrap gap-1'>
+            {types.slice(0, 2).map((type) => (
+              <Tag key={type} size='small' style={{ borderRadius: '8px' }}>
                 {typeMap[type] || type}
               </Tag>
             ))}
             {types.length > 2 && (
-              <Tooltip content={types.slice(2).map(t => typeMap[t] || t).join(', ')}>
-                <Tag size="small" style={{ margin: '1px', borderRadius: '8px' }}>
+              <Tooltip
+                content={types
+                  .slice(2)
+                  .map((t) => typeMap[t] || t)
+                  .join(', ')}
+              >
+                <Tag size='small' style={{ borderRadius: '8px' }}>
                   +{types.length - 2}
                 </Tag>
               </Tooltip>
@@ -254,9 +219,8 @@ export default function OAuth2ClientSettings() {
     {
       title: t('状态'),
       dataIndex: 'status',
-      key: 'status',
       render: (status) => (
-        <Tag 
+        <Tag
           color={status === 1 ? 'green' : 'red'}
           style={{ borderRadius: '12px' }}
         >
@@ -268,78 +232,88 @@ export default function OAuth2ClientSettings() {
     {
       title: t('创建时间'),
       dataIndex: 'created_time',
-      key: 'created_time',
       render: (time) => new Date(time * 1000).toLocaleString(),
       width: 150,
     },
     {
       title: t('操作'),
-      key: 'action',
       render: (_, record) => (
-        <Space size="small">
-          <Tooltip content={t('编辑客户端')}>
-            <Button
-              theme="borderless"
-              type="primary"
-              size="small"
-              icon={<Edit size={14} />}
-              onClick={() => {
-                setEditingClient(record);
-                setShowEditModal(true);
-              }}
-            />
-          </Tooltip>
+        <Space size={4} wrap>
+          <Button
+            theme='borderless'
+            type='primary'
+            size='small'
+            onClick={() => {
+              setEditingClient(record);
+              setShowEditModal(true);
+            }}
+            style={{ padding: '4px 8px' }}
+          >
+            {t('编辑')}
+          </Button>
           {record.client_type === 'confidential' && (
             <Popconfirm
               title={t('确认重新生成客户端密钥?')}
               content={
-                <div>
-                  <div>{t('客户端')}:{record.name}</div>
-                  <div style={{ marginTop: 6, color: 'var(--semi-color-warning)' }}>
-                    ⚠️ {t('操作不可撤销,旧密钥将立即失效。')}
+                <div style={{ maxWidth: 280 }}>
+                  <div className='mb-2'>
+                    <Text strong>{t('客户端')}:</Text>
+                    <Text>{record.name}</Text>
+                  </div>
+                  <div className='p-3 bg-orange-50 border border-orange-200 rounded-md'>
+                    <Text size='small' type='warning'>
+                      ⚠️ {t('操作不可撤销,旧密钥将立即失效。')}
+                    </Text>
                   </div>
                 </div>
               }
               onConfirm={() => handleRegenerateSecret(record)}
               okText={t('确认')}
               cancelText={t('取消')}
+              position='bottomLeft'
             >
-              <Tooltip content={t('重新生成密钥')}>
-                <Button
-                  theme="borderless"
-                  type="secondary"
-                  size="small"
-                  icon={<Key size={14} />}
-                />
-              </Tooltip>
+              <Button
+                theme='borderless'
+                type='secondary'
+                size='small'
+                style={{ padding: '4px 8px' }}
+              >
+                {t('重新生成密钥')}
+              </Button>
             </Popconfirm>
           )}
           <Popconfirm
             title={t('请再次确认删除该客户端')}
             content={
-              <div>
-                <div>{t('客户端')}:{record.name}</div>
-                <div style={{ marginTop: 6, color: 'var(--semi-color-danger)' }}>
-                  🗑️ {t('删除后无法恢复,相关 API 调用将立即失效。')}
+              <div style={{ maxWidth: 280 }}>
+                <div className='mb-2'>
+                  <Text strong>{t('客户端')}:</Text>
+                  <Text>{record.name}</Text>
+                </div>
+                <div className='p-3 bg-red-50 border border-red-200 rounded-md'>
+                  <Text size='small' type='danger'>
+                    🗑️ {t('删除后无法恢复,相关 API 调用将立即失效。')}
+                  </Text>
                 </div>
               </div>
             }
             onConfirm={() => handleDelete(record)}
             okText={t('确定删除')}
             cancelText={t('取消')}
+            position='bottomLeft'
           >
-            <Tooltip content={t('删除客户端')}>
-              <Button
-                theme="borderless"
-                type="danger"
-                size="small"
-                icon={<Trash2 size={14} />}
-              />
-            </Tooltip>
+            <Button
+              theme='borderless'
+              type='danger'
+              size='small'
+              style={{ padding: '4px 8px' }}
+            >
+              {t('删除')}
+            </Button>
           </Popconfirm>
         </Space>
       ),
-      width: 120,
+      width: 140,
       fixed: 'right',
     },
   ];
@@ -349,94 +323,94 @@ export default function OAuth2ClientSettings() {
   }, []);
 
   return (
-    <Card 
+    <Card
       className='!rounded-2xl shadow-sm border-0'
       style={{ marginTop: 10 }}
       title={
-        <div className='flex items-center'>
-          <User size={18} className='mr-2' />
-          <Text strong>{t('OAuth2 客户端管理')}</Text>
-        </div>
-      }
-    >
-      <div style={{ marginBottom: 16 }}>
-        <Text type="tertiary">
-          {t('管理OAuth2客户端应用程序,每个客户端代表一个可以访问API的应用程序。机密客户端用于服务器端应用,公开客户端用于移动应用或单页应用。')}
-        </Text>
-      </div>
-      
-      {/* 工具栏 */}
-      <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
-        <Col xs={24} sm={24} md={10} lg={8}>
-          <Input
-            prefix={<Search size={16} />}
-            placeholder={t('搜索客户端名称、ID或描述')}
-            value={searchKeyword}
-            onChange={handleSearch}
-            showClear
-            style={{ width: '100%' }}
-          />
-        </Col>
-        <Col xs={24} sm={24} md={14} lg={16}>
-          <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, flexWrap: 'wrap' }}>
-            <Button 
-              icon={<RefreshCw size={16} />} 
-              onClick={loadClients}
-              size="default"
-            >
-              <span className="hidden sm:inline">{t('刷新')}</span>
+        <div
+          className='flex flex-col sm:flex-row sm:items-center sm:justify-between w-full gap-3 sm:gap-0'
+          style={{ paddingRight: '8px' }}
+        >
+          <div className='flex items-center'>
+            <User size={18} className='mr-2' />
+            <Text strong>{t('OAuth2 客户端管理')}</Text>
+            <Tag color='white' shape='circle' size='small' className='ml-2'>
+              {filteredClients.length} {t('个客户端')}
+            </Tag>
+          </div>
+          <div className='flex items-center gap-2 sm:flex-shrink-0 flex-wrap'>
+            <Input
+              prefix={<IconSearch />}
+              placeholder={t('搜索客户端名称、ID或描述')}
+              value={searchKeyword}
+              onChange={handleSearch}
+              showClear
+              size='small'
+              style={{ width: 300 }}
+            />
+            <Button onClick={loadClients} size='small'>
+              {t('刷新')}
             </Button>
-            <Button 
-              icon={<Eye size={16} />} 
-              onClick={showServerInfo}
-              size="default"
-            >
-              <span className="hidden sm:inline">{t('服务器信息')}</span>
+            <Button onClick={showServerInfo} size='small'>
+              {t('服务器信息')}
             </Button>
-            <Button 
-              icon={<Key size={16} />} 
-              onClick={showJWKS}
-              size="default"
-            >
-              <span className="hidden md:inline">{t('查看JWKS')}</span>
+            <Button onClick={showJWKS} size='small'>
+              {t('查看JWKS')}
             </Button>
             <Button
-              type="primary"
-              icon={<Plus size={16} />}
+              type='primary'
               onClick={() => setShowCreateModal(true)}
-              size="default"
+              size='small'
             >
               {t('创建客户端')}
             </Button>
           </div>
-        </Col>
-      </Row>
+        </div>
+      }
+    >
+      <div style={{ marginBottom: 16 }}>
+        <Text type='tertiary'>
+          {t(
+            '管理OAuth2客户端应用程序,每个客户端代表一个可以访问API的应用程序。机密客户端用于服务器端应用,公开客户端用于移动应用或单页应用。',
+          )}
+        </Text>
+      </div>
 
       {/* 客户端表格 */}
       <Table
         columns={columns}
         dataSource={filteredClients}
-        rowKey="id"
+        rowKey='id'
         loading={loading}
+        scroll={{ x: 1200 }}
+        style={{ marginTop: 8 }}
         pagination={{
           showSizeChanger: true,
           showQuickJumper: true,
-          showTotal: (total, range) => t('第 {{start}}-{{end}} 条,共 {{total}} 条', { start: range[0], end: range[1], total }),
+          showTotal: (total, range) =>
+            t('第 {{start}}-{{end}} 条,共 {{total}} 条', {
+              start: range[0],
+              end: range[1],
+              total,
+            }),
           pageSize: 10,
-          size: 'small'
+          size: 'small',
+          style: { marginTop: 16 },
         }}
-        scroll={{ x: 800 }}
         empty={
           <Empty
-            image={<User size={48} />}
+            image={<User size={48} className='text-gray-400' />}
             title={t('暂无OAuth2客户端')}
-            description={t('还没有创建任何客户端,点击下方按钮创建第一个客户端')}
+            description={
+              <div className='text-gray-500 mt-2'>
+                {t('还没有创建任何客户端,点击下方按钮创建第一个客户端')}
+              </div>
+            }
           >
             <Button
-              type="primary"
-              icon={<Plus size={16} />}
+              type='primary'
               onClick={() => setShowCreateModal(true)}
-              style={{ marginTop: 16 }}
+              className='mt-4'
             >
               {t('创建第一个客户端')}
             </Button>
@@ -470,35 +444,23 @@ export default function OAuth2ClientSettings() {
       />
 
       {/* 密钥显示模态框 */}
-      <Modal
-        title={t('客户端密钥已重新生成')}
+      <SecretDisplayModal
         visible={showSecretModal}
-        onCancel={() => setShowSecretModal(false)}
-        onOk={() => setShowSecretModal(false)}
-        cancelText=""
-        okText={t('我已复制保存')}
-        width={600}
-      >
-        <div>
-          <Banner
-            type="warning"
-            description={t('新的客户端密钥如下,请立即复制保存。关闭此窗口后将无法再次查看。')}
-            style={{ marginBottom: 16 }}
-          />
-          <div style={{ 
-            background: '#f8f9fa', 
-            padding: '16px', 
-            borderRadius: '6px',
-            fontFamily: 'monospace',
-            wordBreak: 'break-all',
-            border: '1px solid var(--semi-color-border)'
-          }}>
-            <Text code copyable style={{ fontSize: '14px' }}>
-              {currentSecret}
-            </Text>
-          </div>
-        </div>
-      </Modal>
+        onClose={() => setShowSecretModal(false)}
+        secret={currentSecret}
+      />
+
+      {/* 服务器信息模态框 */}
+      <ServerInfoModal
+        visible={showServerInfoModal}
+        onClose={() => setShowServerInfoModal(false)}
+      />
+
+      {/* JWKS信息模态框 */}
+      <JWKSInfoModal
+        visible={showJWKSModal}
+        onClose={() => setShowJWKSModal(false)}
+      />
     </Card>
   );
 }

+ 257 - 306
web/src/components/settings/oauth2/OAuth2ServerSettings.jsx

@@ -26,22 +26,10 @@ import {
   Row,
   Card,
   Typography,
-  Space,
-  Tag
+  Badge,
+  Divider,
 } from '@douyinfe/semi-ui';
-import {
-  Server,
-  Key,
-  Shield,
-  Settings,
-  CheckCircle,
-  AlertTriangle,
-  PlayCircle,
-  Wrench,
-  BookOpen
-} from 'lucide-react';
-import OAuth2ToolsModal from './modals/OAuth2ToolsModal';
-import OAuth2QuickStartModal from './modals/OAuth2QuickStartModal';
+import { Server } from 'lucide-react';
 import JWKSManagerModal from './modals/JWKSManagerModal';
 import {
   compareObjects,
@@ -52,7 +40,7 @@ import {
 } from '../../../helpers';
 import { useTranslation } from 'react-i18next';
 
-const { Title, Text } = Typography;
+const { Text } = Typography;
 
 export default function OAuth2ServerSettings(props) {
   const { t } = useTranslation();
@@ -64,7 +52,11 @@ export default function OAuth2ServerSettings(props) {
     'oauth2.refresh_token_ttl': 720,
     'oauth2.jwt_signing_algorithm': 'RS256',
     'oauth2.jwt_key_id': 'oauth2-key-1',
-    'oauth2.allowed_grant_types': ['client_credentials', 'authorization_code', 'refresh_token'],
+    'oauth2.allowed_grant_types': [
+      'client_credentials',
+      'authorization_code',
+      'refresh_token',
+    ],
     'oauth2.require_pkce': true,
     'oauth2.max_jwks_keys': 3,
   });
@@ -73,11 +65,10 @@ export default function OAuth2ServerSettings(props) {
   const [keysReady, setKeysReady] = useState(true);
   const [keysLoading, setKeysLoading] = useState(false);
   const [serverInfo, setServerInfo] = useState(null);
+  const enabledRef = useRef(inputs['oauth2.enabled']);
 
   // 模态框状态
-  const [qsVisible, setQsVisible] = useState(false);
   const [jwksVisible, setJwksVisible] = useState(false);
-  const [toolsVisible, setToolsVisible] = useState(false);
 
   function handleFieldChange(fieldName) {
     return (value) => {
@@ -124,18 +115,28 @@ export default function OAuth2ServerSettings(props) {
       });
   }
 
-  // 测试OAuth2连接
-  const testOAuth2 = async () => {
+  // 测试OAuth2连接(默认静默,仅用户点击时弹提示)
+  const testOAuth2 = async (silent = true) => {
+    // 未启用时不触发测试,避免 404
+    if (!enabledRef.current) return;
     try {
-      const res = await API.get('/api/oauth/server-info');
-      if (res.status === 200 && (res.data.issuer || res.data.authorization_endpoint)) {
-        showSuccess('OAuth2服务器运行正常');
+      const res = await API.get('/api/oauth/server-info', {
+        skipErrorHandler: true,
+      });
+      if (!enabledRef.current) return;
+      if (
+        res.status === 200 &&
+        (res.data.issuer || res.data.authorization_endpoint)
+      ) {
+        if (!silent) showSuccess('OAuth2服务器运行正常');
         setServerInfo(res.data);
       } else {
-        showError('OAuth2服务器测试失败');
+        if (!enabledRef.current) return;
+        if (!silent) showError('OAuth2服务器测试失败');
       }
     } catch (error) {
-      showError('OAuth2服务器连接测试失败');
+      if (!enabledRef.current) return;
+      if (!silent) showError('OAuth2服务器连接测试失败');
     }
   };
 
@@ -146,9 +147,16 @@ export default function OAuth2ServerSettings(props) {
         if (Object.keys(inputs).includes(key)) {
           if (key === 'oauth2.allowed_grant_types') {
             try {
-              currentInputs[key] = JSON.parse(props.options[key] || '["client_credentials","authorization_code","refresh_token"]');
+              currentInputs[key] = JSON.parse(
+                props.options[key] ||
+                  '["client_credentials","authorization_code","refresh_token"]',
+              );
             } catch {
-              currentInputs[key] = ['client_credentials', 'authorization_code', 'refresh_token'];
+              currentInputs[key] = [
+                'client_credentials',
+                'authorization_code',
+                'refresh_token',
+              ];
             }
           } else if (typeof inputs[key] === 'boolean') {
             currentInputs[key] = props.options[key] === 'true';
@@ -167,11 +175,17 @@ export default function OAuth2ServerSettings(props) {
     }
   }, [props]);
 
+  useEffect(() => {
+    enabledRef.current = inputs['oauth2.enabled'];
+  }, [inputs['oauth2.enabled']]);
+
   useEffect(() => {
     const loadKeys = async () => {
       try {
         setKeysLoading(true);
-        const res = await API.get('/api/oauth/keys', { skipErrorHandler: true });
+        const res = await API.get('/api/oauth/keys', {
+          skipErrorHandler: true,
+        });
         const list = res?.data?.data || [];
         setKeysReady(list.length > 0);
       } catch {
@@ -182,7 +196,12 @@ export default function OAuth2ServerSettings(props) {
     };
     if (inputs['oauth2.enabled']) {
       loadKeys();
-      testOAuth2();
+      testOAuth2(true);
+    } else {
+      // 禁用时清理状态,避免残留状态与不必要的请求
+      setKeysReady(true);
+      setServerInfo(null);
+      setKeysLoading(false);
     }
   }, [inputs['oauth2.enabled']]);
 
@@ -190,73 +209,62 @@ export default function OAuth2ServerSettings(props) {
 
   return (
     <div>
-      {/* OAuth2 & SSO 管理 */}
+      {/* OAuth2 服务端管理 */}
       <Card
         className='!rounded-2xl shadow-sm border-0'
         style={{ marginTop: 10 }}
         title={
-          <div className='flex items-center'>
-            <Server size={18} className='mr-2' />
-            <Text strong>{t('OAuth2 & SSO 管理')}</Text>
+          <div
+            className='flex flex-col sm:flex-row sm:items-center sm:justify-between w-full gap-3 sm:gap-0'
+            style={{ paddingRight: '8px' }}
+          >
+            <div className='flex items-center'>
+              <Server size={18} className='mr-2' />
+              <Text strong>{t('OAuth2 & SSO 管理')}</Text>
+              {isEnabled ? (
+                serverInfo ? (
+                  <Badge
+                    count={t('运行正常')}
+                    type='success'
+                    style={{ marginLeft: 8 }}
+                  />
+                ) : (
+                  <Badge
+                    count={t('配置中')}
+                    type='warning'
+                    style={{ marginLeft: 8 }}
+                  />
+                )
+              ) : (
+                <Badge
+                  count={t('未启用')}
+                  type='tertiary'
+                  style={{ marginLeft: 8 }}
+                />
+              )}
+            </div>
+            <div className='flex items-center gap-2 sm:flex-shrink-0'>
+              <Button
+                type='primary'
+                onClick={onSubmit}
+                loading={loading}
+                size='small'
+              >
+                {t('保存配置')}
+              </Button>
+              {isEnabled && (
+                <Button
+                  type='secondary'
+                  onClick={() => setJwksVisible(true)}
+                  size='small'
+                >
+                  {t('密钥管理')}
+                </Button>
+              )}
+            </div>
           </div>
         }
       >
-        <div style={{ marginBottom: 16 }}>
-          <Text type="tertiary">
-            {t('OAuth2 是一个开放标准的授权框架,允许用户授权第三方应用访问他们的资源,而无需分享他们的凭据。支持标准的 API 认证与授权流程。')}
-          </Text>
-        </div>
-
-        {!isEnabled && (
-          <Banner
-            type="info"
-            icon={<Settings size={16} />}
-            description={t('OAuth2 功能尚未启用,建议使用一键初始化向导完成基础配置。')}
-            style={{ marginBottom: 16 }}
-          />
-        )}
-
-        {/* 快捷操作按钮 */}
-        <Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
-          <Col xs={12} sm={6} md={6} lg={6}>
-            <Button
-              type="primary"
-              icon={<PlayCircle size={16} />}
-              onClick={() => setQsVisible(true)}
-              style={{ width: '100%' }}
-            >
-              <span className="hidden sm:inline">{t('一键初始化')}</span>
-            </Button>
-          </Col>
-          <Col xs={12} sm={6} md={6} lg={6}>
-            <Button
-              icon={<Key size={16} />}
-              onClick={() => setJwksVisible(true)}
-              style={{ width: '100%' }}
-            >
-              <span className="hidden sm:inline">{t('密钥管理')}</span>
-            </Button>
-          </Col>
-          <Col xs={12} sm={6} md={6} lg={6}>
-            <Button
-              icon={<Wrench size={16} />}
-              onClick={() => setToolsVisible(true)}
-              style={{ width: '100%' }}
-            >
-              <span className="hidden sm:inline">{t('调试助手')}</span>
-            </Button>
-          </Col>
-          <Col xs={12} sm={6} md={6} lg={6}>
-            <Button
-              icon={<BookOpen size={16} />}
-              onClick={() => window.open('/oauth-demo.html', '_blank')}
-              style={{ width: '100%' }}
-            >
-              <span className="hidden sm:inline">{t('前端演示')}</span>
-            </Button>
-          </Col>
-        </Row>
-
         <Form
           initValues={inputs}
           getFormApi={(formAPI) => (refForm.current = formAPI)}
@@ -264,17 +272,18 @@ export default function OAuth2ServerSettings(props) {
           {!keysReady && isEnabled && (
             <Banner
               type='warning'
-              icon={<AlertTriangle size={16} />}
               description={
                 <div>
-                  <div>⚠️ 尚未准备签名密钥,建议立即初始化或轮换以发布 JWKS。</div>
+                  <div>
+                    ⚠️ 尚未准备签名密钥,建议立即初始化或轮换以发布 JWKS。
+                  </div>
                   <div>签名密钥用于 JWT 令牌的安全签发。</div>
                 </div>
               }
               actions={
                 <Button
                   size='small'
-                  type="primary"
+                  type='primary'
                   onClick={() => setJwksVisible(true)}
                   loading={keysLoading}
                 >
@@ -289,18 +298,10 @@ export default function OAuth2ServerSettings(props) {
             <Col xs={24} lg={12}>
               <Form.Switch
                 field='oauth2.enabled'
-                label={
-                  <span style={{ display: 'flex', alignItems: 'center' }}>
-                    <Shield size={16} style={{ marginRight: 4 }} />
-                    {t('启用 OAuth2 & SSO')}
-                  </span>
-                }
-                checkedText={t('开')}
-                uncheckedText={t('关')}
+                label={t('启用 OAuth2 & SSO')}
                 value={inputs['oauth2.enabled']}
                 onChange={handleFieldChange('oauth2.enabled')}
-                extraText={t("开启后将允许以 OAuth2/OIDC 标准进行授权与登录")}
-                size="large"
+                extraText={t('开启后将允许以 OAuth2/OIDC 标准进行授权与登录')}
               />
             </Col>
             <Col xs={24} lg={12}>
@@ -310,227 +311,177 @@ export default function OAuth2ServerSettings(props) {
                 placeholder={window.location.origin}
                 value={inputs['oauth2.issuer']}
                 onChange={handleFieldChange('oauth2.issuer')}
-                extraText={t("为空则按请求自动推断(含 X-Forwarded-Proto)")}
+                extraText={t('为空则按请求自动推断(含 X-Forwarded-Proto)')}
               />
             </Col>
           </Row>
 
-          {/* 服务器状态 */}
-          {isEnabled && serverInfo && (
-            <div style={{
-              marginTop: 16,
-              padding: '12px 16px',
-              backgroundColor: 'var(--semi-color-success-light-default)',
-              borderRadius: '8px',
-              border: '1px solid var(--semi-color-success-light-active)'
-            }}>
-              <div style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
-                <CheckCircle size={16} style={{ marginRight: 6, color: 'var(--semi-color-success)' }} />
-                <Text strong style={{ color: 'var(--semi-color-success)' }}>{t('服务器运行正常')}</Text>
-              </div>
-              <Space wrap>
-                <Tag color="green">{t('发行人')}: {serverInfo.issuer}</Tag>
-                {serverInfo.authorization_endpoint && <Tag>{t('授权端点')}: {t('已配置')}</Tag>}
-                {serverInfo.token_endpoint && <Tag>{t('令牌端点')}: {t('已配置')}</Tag>}
-                {serverInfo.jwks_uri && <Tag>JWKS: {t('已配置')}</Tag>}
-              </Space>
-            </div>
-          )}
-
-          <div style={{ marginTop: 16 }}>
-            <Button type="primary" onClick={onSubmit} loading={loading}>
-              {t('保存基础配置')}
-            </Button>
-            {isEnabled && (
-              <Button
-                type="secondary"
-                onClick={testOAuth2}
-                style={{ marginLeft: 8 }}
-              >
-                测试连接
-              </Button>
-            )}
-          </div>
-        </Form>
-      </Card>
-
-      {/* 高级配置 */}
-      {isEnabled && (
-        <>
           {/* 令牌配置 */}
-          <Card
-            className='!rounded-2xl shadow-sm border-0'
-            style={{ marginTop: 10 }}
-            title={
-              <div className='flex items-center'>
-                <Key size={18} className='mr-2' />
-                <Text strong>{t('令牌配置')}</Text>
-              </div>
-            }
-            footer={
-              <Text type='tertiary' size='small'>
-                <div className='space-y-1'>
-                  <div>• {t('OAuth2 服务器提供标准的 API 认证与授权')}</div>
-                  <div>• {t('支持 Client Credentials、Authorization Code + PKCE 等标准流程')}</div>
-                  <div>• {t('配置保存后多数项即时生效;签名密钥轮换与 JWKS 发布为即时操作')}</div>
-                  <div>• {t('生产环境务必启用 HTTPS,并妥善管理 JWT 签名密钥')}</div>
-                </div>
-              </Text>
-            }
-          >
-
-            <Form initValues={inputs}>
-              <Row gutter={[16, 24]}>
-                <Col xs={24} sm={12} lg={8}>
-                  <Form.InputNumber
-                    field='oauth2.access_token_ttl'
-                    label={t('访问令牌有效期')}
-                    suffix={t("分钟")}
-                    min={1}
-                    max={1440}
-                    value={inputs['oauth2.access_token_ttl']}
-                    onChange={handleFieldChange('oauth2.access_token_ttl')}
-                    extraText={t("访问令牌的有效时间,建议较短(10-60分钟)")}
-                    style={{ width: '100%' }}
-                  />
-                </Col>
-                <Col xs={24} sm={12} lg={8}>
-                  <Form.InputNumber
-                    field='oauth2.refresh_token_ttl'
-                    label={t('刷新令牌有效期')}
-                    suffix={t("小时")}
-                    min={1}
-                    max={8760}
-                    value={inputs['oauth2.refresh_token_ttl']}
-                    onChange={handleFieldChange('oauth2.refresh_token_ttl')}
-                    extraText={t("刷新令牌的有效时间,建议较长(12-720小时)")}
-                    style={{ width: '100%' }}
-                  />
-                </Col>
-                <Col xs={24} sm={12} lg={8}>
-                  <Form.InputNumber
-                    field='oauth2.max_jwks_keys'
-                    label={t('JWKS历史保留上限')}
-                    min={1}
-                    max={10}
-                    value={inputs['oauth2.max_jwks_keys']}
-                    onChange={handleFieldChange('oauth2.max_jwks_keys')}
-                    extraText={t("轮换后最多保留的历史签名密钥数量")}
-                    style={{ width: '100%' }}
-                  />
-                </Col>
-              </Row>
+          <Divider margin='24px'>{t('令牌配置')}</Divider>
 
-              <Row gutter={[16, 24]} style={{ marginTop: 16 }}>
-                <Col xs={24} lg={12}>
-                  <Form.Select
-                    field='oauth2.jwt_signing_algorithm'
-                    label={t('JWT签名算法')}
-                    value={inputs['oauth2.jwt_signing_algorithm']}
-                    onChange={handleFieldChange('oauth2.jwt_signing_algorithm')}
-                    extraText={t("JWT令牌的签名算法,推荐使用RS256")}
-                    style={{ width: '100%' }}
-                  >
-                    <Form.Select.Option value="RS256">RS256 (RSA with SHA-256)</Form.Select.Option>
-                    <Form.Select.Option value="HS256">HS256 (HMAC with SHA-256)</Form.Select.Option>
-                  </Form.Select>
-                </Col>
-                <Col xs={24} lg={12}>
-                  <Form.Input
-                    field='oauth2.jwt_key_id'
-                    label={t('JWT密钥ID')}
-                    placeholder="oauth2-key-1"
-                    value={inputs['oauth2.jwt_key_id']}
-                    onChange={handleFieldChange('oauth2.jwt_key_id')}
-                    extraText={t("用于标识JWT签名密钥,支持密钥轮换")}
-                    style={{ width: '100%' }}
-                  />
-                </Col>
-              </Row>
+          <Row gutter={[16, 24]}>
+            <Col xs={24} sm={12} lg={8}>
+              <Form.InputNumber
+                field='oauth2.access_token_ttl'
+                label={t('访问令牌有效期')}
+                suffix={t('分钟')}
+                min={1}
+                max={1440}
+                value={inputs['oauth2.access_token_ttl']}
+                onChange={handleFieldChange('oauth2.access_token_ttl')}
+                extraText={t('访问令牌的有效时间,建议较短(10-60分钟)')}
+                style={{
+                  width: '100%',
+                  opacity: isEnabled ? 1 : 0.5,
+                }}
+                disabled={!isEnabled}
+              />
+            </Col>
+            <Col xs={24} sm={12} lg={8}>
+              <Form.InputNumber
+                field='oauth2.refresh_token_ttl'
+                label={t('刷新令牌有效期')}
+                suffix={t('小时')}
+                min={1}
+                max={8760}
+                value={inputs['oauth2.refresh_token_ttl']}
+                onChange={handleFieldChange('oauth2.refresh_token_ttl')}
+                extraText={t('刷新令牌的有效时间,建议较长(12-720小时)')}
+                style={{
+                  width: '100%',
+                  opacity: isEnabled ? 1 : 0.5,
+                }}
+                disabled={!isEnabled}
+              />
+            </Col>
+            <Col xs={24} sm={12} lg={8}>
+              <Form.InputNumber
+                field='oauth2.max_jwks_keys'
+                label={t('JWKS历史保留上限')}
+                min={1}
+                max={10}
+                value={inputs['oauth2.max_jwks_keys']}
+                onChange={handleFieldChange('oauth2.max_jwks_keys')}
+                extraText={t('轮换后最多保留的历史签名密钥数量')}
+                style={{
+                  width: '100%',
+                  opacity: isEnabled ? 1 : 0.5,
+                }}
+                disabled={!isEnabled}
+              />
+            </Col>
+          </Row>
 
-              <div style={{ marginTop: 16 }}>
-                <Button type="primary" onClick={onSubmit} loading={loading}>
-                  {t('更新令牌配置')}
-                </Button>
-                <Button
-                  type='secondary'
-                  onClick={() => setJwksVisible(true)}
-                  style={{ marginLeft: 8 }}
-                >
-                  密钥管理
-                </Button>
-              </div>
-            </Form>
-          </Card>
+          <Row gutter={[16, 24]} style={{ marginTop: 16 }}>
+            <Col xs={24} lg={12}>
+              <Form.Select
+                field='oauth2.jwt_signing_algorithm'
+                label={t('JWT签名算法')}
+                value={inputs['oauth2.jwt_signing_algorithm']}
+                onChange={handleFieldChange('oauth2.jwt_signing_algorithm')}
+                extraText={t('JWT令牌的签名算法,推荐使用RS256')}
+                style={{
+                  width: '100%',
+                  opacity: isEnabled ? 1 : 0.5,
+                }}
+                disabled={!isEnabled}
+              >
+                <Form.Select.Option value='RS256'>
+                  RS256 (RSA with SHA-256)
+                </Form.Select.Option>
+                <Form.Select.Option value='HS256'>
+                  HS256 (HMAC with SHA-256)
+                </Form.Select.Option>
+              </Form.Select>
+            </Col>
+            <Col xs={24} lg={12}>
+              <Form.Input
+                field='oauth2.jwt_key_id'
+                label={t('JWT密钥ID')}
+                placeholder='oauth2-key-1'
+                value={inputs['oauth2.jwt_key_id']}
+                onChange={handleFieldChange('oauth2.jwt_key_id')}
+                extraText={t('用于标识JWT签名密钥,支持密钥轮换')}
+                style={{
+                  width: '100%',
+                  opacity: isEnabled ? 1 : 0.5,
+                }}
+                disabled={!isEnabled}
+              />
+            </Col>
+          </Row>
 
           {/* 授权配置 */}
-          <Card
-            className='!rounded-2xl shadow-sm border-0'
-            style={{ marginTop: 10 }}
-            title={
-              <div className='flex items-center'>
-                <Settings size={18} className='mr-2' />
-                <Text strong>{t('授权配置')}</Text>
-              </div>
-            }
-          >
+          <Divider margin='24px'>{t('授权配置')}</Divider>
 
-            <Form initValues={inputs}>
-              <Row gutter={[16, 24]}>
-                <Col xs={24} lg={12}>
-                  <Form.Select
-                    field='oauth2.allowed_grant_types'
-                    label={t('允许的授权类型')}
-                    multiple
-                    value={inputs['oauth2.allowed_grant_types']}
-                    onChange={handleFieldChange('oauth2.allowed_grant_types')}
-                    extraText={t("选择允许的OAuth2授权流程")}
-                    style={{ width: '100%' }}
-                  >
-                    <Form.Select.Option value="client_credentials">{t('Client Credentials(客户端凭证)')}</Form.Select.Option>
-                    <Form.Select.Option value="authorization_code">{t('Authorization Code(授权码)')}</Form.Select.Option>
-                    <Form.Select.Option value="refresh_token">{t('Refresh Token(刷新令牌)')}</Form.Select.Option>
-                  </Form.Select>
-                </Col>
-                <Col xs={24} lg={12}>
-                  <Form.Switch
-                    field='oauth2.require_pkce'
-                    label={t('强制PKCE验证')}
-                    checkedText={t('开')}
-                    uncheckedText={t('关')}
-                    value={inputs['oauth2.require_pkce']}
-                    onChange={handleFieldChange('oauth2.require_pkce')}
-                    extraText={t("为授权码流程强制启用PKCE,提高安全性")}
-                    size="large"
-                  />
-                </Col>
-              </Row>
+          <Row gutter={[16, 24]}>
+            <Col xs={24} lg={12}>
+              <Form.Select
+                field='oauth2.allowed_grant_types'
+                label={t('允许的授权类型')}
+                multiple
+                value={inputs['oauth2.allowed_grant_types']}
+                onChange={handleFieldChange('oauth2.allowed_grant_types')}
+                extraText={t('选择允许的OAuth2授权流程')}
+                style={{
+                  width: '100%',
+                  opacity: isEnabled ? 1 : 0.5,
+                }}
+                disabled={!isEnabled}
+              >
+                <Form.Select.Option value='client_credentials'>
+                  {t('Client Credentials(客户端凭证)')}
+                </Form.Select.Option>
+                <Form.Select.Option value='authorization_code'>
+                  {t('Authorization Code(授权码)')}
+                </Form.Select.Option>
+                <Form.Select.Option value='refresh_token'>
+                  {t('Refresh Token(刷新令牌)')}
+                </Form.Select.Option>
+              </Form.Select>
+            </Col>
+            <Col xs={24} lg={12}>
+              <Form.Switch
+                field='oauth2.require_pkce'
+                label={t('强制PKCE验证')}
+                value={inputs['oauth2.require_pkce']}
+                onChange={handleFieldChange('oauth2.require_pkce')}
+                extraText={t('为授权码流程强制启用PKCE,提高安全性')}
+                disabled={!isEnabled}
+              />
+            </Col>
+          </Row>
 
-              <div style={{ marginTop: 16 }}>
-                <Button type="primary" onClick={onSubmit} loading={loading}>
-                  {t('更新授权配置')}
-                </Button>
+          <div style={{ marginTop: 16 }}>
+            <Text type='tertiary' size='small'>
+              <div className='space-y-1'>
+                <div>• {t('OAuth2 服务器提供标准的 API 认证与授权')}</div>
+                <div>
+                  •{' '}
+                  {t(
+                    '支持 Client Credentials、Authorization Code + PKCE 等标准流程',
+                  )}
+                </div>
+                <div>
+                  •{' '}
+                  {t(
+                    '配置保存后多数项即时生效;签名密钥轮换与 JWKS 发布为即时操作',
+                  )}
+                </div>
+                <div>
+                  • {t('生产环境务必启用 HTTPS,并妥善管理 JWT 签名密钥')}
+                </div>
               </div>
-            </Form>
-          </Card>
-
-        </>
-      )}
+            </Text>
+          </div>
+        </Form>
+      </Card>
 
       {/* 模态框 */}
-      <OAuth2QuickStartModal
-        visible={qsVisible}
-        onClose={() => setQsVisible(false)}
-        onDone={() => { props?.refresh && props.refresh(); }}
-      />
       <JWKSManagerModal
         visible={jwksVisible}
         onClose={() => setJwksVisible(false)}
       />
-      <OAuth2ToolsModal
-        visible={toolsVisible}
-        onClose={() => setToolsVisible(false)}
-      />
     </div>
   );
 }

+ 108 - 62
web/src/components/settings/oauth2/modals/CreateOAuth2ClientModal.jsx

@@ -31,7 +31,6 @@ import {
   Row,
   Col,
 } from '@douyinfe/semi-ui';
-import { Plus, Trash2 } from 'lucide-react';
 import { API, showError, showSuccess } from '../../../../helpers';
 import { useTranslation } from 'react-i18next';
 
@@ -119,7 +118,9 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
         // 仅允许本地开发时使用 http
         const host = u.hostname;
         const isLocal =
-          host === 'localhost' || host === '127.0.0.1' || host.endsWith('.local');
+          host === 'localhost' ||
+          host === '127.0.0.1' ||
+          host.endsWith('.local');
         if (!isLocal) return false;
       }
       return true;
@@ -145,10 +146,15 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
       // 校验是否包含不被允许的授权类型
       const invalids = grantTypes.filter((g) => !allowedGrantTypes.includes(g));
       if (invalids.length) {
-        showError(t('不被允许的授权类型: {{types}}', { types: invalids.join(', ') }));
+        showError(
+          t('不被允许的授权类型: {{types}}', { types: invalids.join(', ') }),
+        );
         return;
       }
-      if (clientType === 'public' && grantTypes.includes('client_credentials')) {
+      if (
+        clientType === 'public' &&
+        grantTypes.includes('client_credentials')
+      ) {
         showError(t('公开客户端不允许使用client_credentials授权类型'));
         return;
       }
@@ -173,17 +179,23 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
 
       const res = await API.post('/api/oauth_clients/', payload);
       const { success, message, client_id, client_secret } = res.data;
-      
+
       if (success) {
         showSuccess(t('OAuth2客户端创建成功'));
-        
+
         // 显示客户端信息
         Modal.info({
           title: t('客户端创建成功'),
           content: (
             <div>
               <Paragraph>{t('请妥善保存以下信息:')}</Paragraph>
-              <div style={{ background: '#f8f9fa', padding: '16px', borderRadius: '6px' }}>
+              <div
+                style={{
+                  background: '#f8f9fa',
+                  padding: '16px',
+                  borderRadius: '6px',
+                }}
+              >
                 <div style={{ marginBottom: '12px' }}>
                   <Text strong>{t('客户端ID')}:</Text>
                   <br />
@@ -201,11 +213,10 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
                   </div>
                 )}
               </div>
-              <Paragraph type="warning" style={{ marginTop: '12px' }}>
-                {client_secret 
-                  ? t('客户端密钥仅显示一次,请立即复制保存。') 
-                  : t('公开客户端无需密钥。')
-                }
+              <Paragraph type='warning' style={{ marginTop: '12px' }}>
+                {client_secret
+                  ? t('客户端密钥仅显示一次,请立即复制保存。')
+                  : t('公开客户端无需密钥。')}
               </Paragraph>
             </div>
           ),
@@ -213,7 +224,7 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
           onOk: () => {
             resetForm();
             onSuccess();
-          }
+          },
         });
       } else {
         showError(message);
@@ -280,13 +291,13 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
       okText={t('创建')}
       cancelText={t('取消')}
       confirmLoading={loading}
-      width="90vw"
-      style={{ 
-        top: 20, 
+      width='90vw'
+      style={{
+        top: 20,
         maxWidth: '800px',
         '@media (min-width: 768px)': {
-          width: '600px'
-        }
+          width: '600px',
+        },
       }}
     >
       <Form
@@ -298,13 +309,13 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
           grant_types: grantTypes,
         }}
         onSubmit={handleSubmit}
-        labelPosition="top"
+        labelPosition='top'
       >
         {/* 基本信息 */}
         <Row gutter={[16, 24]}>
           <Col xs={24}>
             <Form.Input
-              field="name"
+              field='name'
               label={t('客户端名称')}
               placeholder={t('输入客户端名称')}
               rules={[{ required: true, message: t('请输入客户端名称') }]}
@@ -313,7 +324,7 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
           </Col>
           <Col xs={24}>
             <Form.TextArea
-              field="description"
+              field='description'
               label={t('描述')}
               placeholder={t('输入客户端描述')}
               rows={3}
@@ -325,31 +336,40 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
         {/* 客户端类型 */}
         <div>
           <Text strong>{t('客户端类型')}</Text>
-          <Paragraph type="tertiary" size="small" style={{ marginTop: 4, marginBottom: 8 }}>
+          <Paragraph
+            type='tertiary'
+            size='small'
+            style={{ marginTop: 4, marginBottom: 8 }}
+          >
             {t('选择适合您应用程序的客户端类型。')}
           </Paragraph>
           <Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
             <Col xs={24} md={12}>
-              <div 
+              <div
                 onClick={() => setClientType('confidential')}
                 style={{
                   padding: '16px',
                   border: `2px solid ${clientType === 'confidential' ? '#3370ff' : '#e4e6e9'}`,
                   borderRadius: '8px',
                   cursor: 'pointer',
-                  background: clientType === 'confidential' ? '#f0f5ff' : '#fff',
+                  background:
+                    clientType === 'confidential' ? '#f0f5ff' : '#fff',
                   transition: 'all 0.2s ease',
-                  minHeight: '80px'
+                  minHeight: '80px',
                 }}
               >
                 <Text strong>{t('机密客户端(Confidential)')}</Text>
-                <Paragraph type="tertiary" size="small" style={{ margin: '4px 0 0 0' }}>
+                <Paragraph
+                  type='tertiary'
+                  size='small'
+                  style={{ margin: '4px 0 0 0' }}
+                >
                   {t('用于服务器端应用,可以安全地存储客户端密钥')}
                 </Paragraph>
               </div>
             </Col>
             <Col xs={24} md={12}>
-              <div 
+              <div
                 onClick={() => setClientType('public')}
                 style={{
                   padding: '16px',
@@ -358,11 +378,15 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
                   cursor: 'pointer',
                   background: clientType === 'public' ? '#f0f5ff' : '#fff',
                   transition: 'all 0.2s ease',
-                  minHeight: '80px'
+                  minHeight: '80px',
                 }}
               >
                 <Text strong>{t('公开客户端(Public)')}</Text>
-                <Paragraph type="tertiary" size="small" style={{ margin: '4px 0 0 0' }}>
+                <Paragraph
+                  type='tertiary'
+                  size='small'
+                  style={{ margin: '4px 0 0 0' }}
+                >
                   {t('用于移动应用或单页应用,无法安全存储密钥')}
                 </Paragraph>
               </div>
@@ -374,7 +398,7 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
           {/* 授权类型 */}
           <Col xs={24} lg={12}>
             <Form.Select
-              field="grant_types"
+              field='grant_types'
               label={t('允许的授权类型')}
               multiple
               value={grantTypes}
@@ -382,13 +406,22 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
               rules={[{ required: true, message: t('请选择至少一种授权类型') }]}
               style={{ width: '100%' }}
             >
-              <Option value="client_credentials" disabled={isGrantTypeDisabled('client_credentials')}>
+              <Option
+                value='client_credentials'
+                disabled={isGrantTypeDisabled('client_credentials')}
+              >
                 {t('Client Credentials(客户端凭证)')}
               </Option>
-              <Option value="authorization_code" disabled={isGrantTypeDisabled('authorization_code')}>
+              <Option
+                value='authorization_code'
+                disabled={isGrantTypeDisabled('authorization_code')}
+              >
                 {t('Authorization Code(授权码)')}
               </Option>
-              <Option value="refresh_token" disabled={isGrantTypeDisabled('refresh_token')}>
+              <Option
+                value='refresh_token'
+                disabled={isGrantTypeDisabled('refresh_token')}
+              >
                 {t('Refresh Token(刷新令牌)')}
               </Option>
             </Form.Select>
@@ -397,75 +430,88 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
           {/* Scope */}
           <Col xs={24} lg={12}>
             <Form.Select
-              field="scopes"
+              field='scopes'
               label={t('允许的权限范围(Scope)')}
               multiple
               rules={[{ required: true, message: t('请选择至少一个权限范围') }]}
               style={{ width: '100%' }}
             >
-              <Option value="openid">openid(OIDC 基础身份)</Option>
-              <Option value="profile">profile(用户名/昵称等)</Option>
-              <Option value="email">email(邮箱信息)</Option>
-              <Option value="api:read">api:read(读取API)</Option>
-              <Option value="api:write">api:write(写入API)</Option>
-              <Option value="admin">admin(管理员权限)</Option>
+              <Option value='openid'>openid(OIDC 基础身份)</Option>
+              <Option value='profile'>profile(用户名/昵称等)</Option>
+              <Option value='email'>email(邮箱信息)</Option>
+              <Option value='api:read'>api:read(读取API)</Option>
+              <Option value='api:write'>api:write(写入API)</Option>
+              <Option value='admin'>admin(管理员权限)</Option>
             </Form.Select>
           </Col>
 
           {/* PKCE设置 */}
           <Col xs={24}>
-            <Form.Switch
-              field="require_pkce"
-              label={t('强制PKCE验证')}
-            />
-            <Paragraph type="tertiary" size="small" style={{ marginTop: 4, marginBottom: 0 }}>
-              {t('PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。')}
+            <Form.Switch field='require_pkce' label={t('强制PKCE验证')} />
+            <Paragraph
+              type='tertiary'
+              size='small'
+              style={{ marginTop: 4, marginBottom: 0 }}
+            >
+              {t(
+                'PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。',
+              )}
             </Paragraph>
           </Col>
         </Row>
 
         {/* 重定向URI */}
-        {(grantTypes.includes('authorization_code') || redirectUris.length > 0) && (
+        {(grantTypes.includes('authorization_code') ||
+          redirectUris.length > 0) && (
           <>
             <Divider>{t('重定向URI配置')}</Divider>
             <div style={{ marginBottom: 16 }}>
               <Text strong>{t('重定向URI')}</Text>
-              <Paragraph type="tertiary" size="small">
-                {t('用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)。')}
+              <Paragraph type='tertiary' size='small'>
+                {t(
+                  '用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)。',
+                )}
               </Paragraph>
-              
+
               <div style={{ width: '100%' }}>
                 {redirectUris.map((uri, index) => (
                   <Row gutter={[8, 8]} key={index} style={{ marginBottom: 8 }}>
                     <Col xs={redirectUris.length > 1 ? 20 : 24}>
                       <Input
-                        placeholder="https://your-app.com/callback"
+                        placeholder='https://your-app.com/callback'
                         value={uri}
                         onChange={(value) => updateRedirectUri(index, value)}
                         style={{ width: '100%' }}
                       />
                     </Col>
                     {redirectUris.length > 1 && (
-                      <Col xs={4} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
+                      <Col
+                        xs={4}
+                        style={{
+                          display: 'flex',
+                          alignItems: 'center',
+                          justifyContent: 'center',
+                        }}
+                      >
                         <Button
-                          theme="borderless"
-                          type="danger"
-                          size="small"
-                          icon={<Trash2 size={14} />}
+                          theme='borderless'
+                          type='danger'
+                          size='small'
                           onClick={() => removeRedirectUri(index)}
                           style={{ width: '100%' }}
-                        />
+                        >
+                          {t('删除')}
+                        </Button>
                       </Col>
                     )}
                   </Row>
                 ))}
               </div>
-              
+
               <Button
-                theme="borderless"
-                type="primary"
-                size="small"
-                icon={<Plus size={14} />}
+                theme='borderless'
+                type='primary'
+                size='small'
                 onClick={addRedirectUri}
                 style={{ marginTop: 8 }}
               >

+ 126 - 90
web/src/components/settings/oauth2/modals/EditOAuth2ClientModal.jsx

@@ -30,13 +30,14 @@ import {
   Divider,
   Button,
 } from '@douyinfe/semi-ui';
-import { Plus, Trash2 } from 'lucide-react';
 import { API, showError, showSuccess } from '../../../../helpers';
+import { useTranslation } from 'react-i18next';
 
 const { Text, Paragraph } = Typography;
 const { Option } = Select;
 
 const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
+  const { t } = useTranslation();
   const [formApi, setFormApi] = useState(null);
   const [loading, setLoading] = useState(false);
   const [redirectUris, setRedirectUris] = useState([]);
@@ -99,9 +100,10 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
       let parsedRedirectUris = [];
       if (client.redirect_uris) {
         try {
-          const parsed = typeof client.redirect_uris === 'string' 
-            ? JSON.parse(client.redirect_uris)
-            : client.redirect_uris;
+          const parsed =
+            typeof client.redirect_uris === 'string'
+              ? JSON.parse(client.redirect_uris)
+              : client.redirect_uris;
           if (Array.isArray(parsed) && parsed.length > 0) {
             parsedRedirectUris = parsed;
           }
@@ -114,12 +116,16 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
       const filteredGrantTypes = (parsedGrantTypes || []).filter((g) =>
         allowedGrantTypes.includes(g),
       );
-      const finalGrantTypes = client.client_type === 'public'
-        ? filteredGrantTypes.filter((g) => g !== 'client_credentials')
-        : filteredGrantTypes;
+      const finalGrantTypes =
+        client.client_type === 'public'
+          ? filteredGrantTypes.filter((g) => g !== 'client_credentials')
+          : filteredGrantTypes;
 
       setGrantTypes(finalGrantTypes);
-      if (finalGrantTypes.includes('authorization_code') && parsedRedirectUris.length === 0) {
+      if (
+        finalGrantTypes.includes('authorization_code') &&
+        parsedRedirectUris.length === 0
+      ) {
         setRedirectUris(['']);
       } else {
         setRedirectUris(parsedRedirectUris);
@@ -153,18 +159,23 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
 
       // 校验授权类型
       if (!grantTypes.length) {
-        showError('请至少选择一种授权类型');
+        showError(t('请至少选择一种授权类型'));
         setLoading(false);
         return;
       }
       const invalids = grantTypes.filter((g) => !allowedGrantTypes.includes(g));
       if (invalids.length) {
-        showError(`不被允许的授权类型: ${invalids.join(', ')}`);
+        showError(
+          t('不被允许的授权类型: {{types}}', { types: invalids.join(', ') }),
+        );
         setLoading(false);
         return;
       }
-      if (client?.client_type === 'public' && grantTypes.includes('client_credentials')) {
-        showError('公开客户端不允许使用client_credentials授权类型');
+      if (
+        client?.client_type === 'public' &&
+        grantTypes.includes('client_credentials')
+      ) {
+        showError(t('公开客户端不允许使用client_credentials授权类型'));
         setLoading(false);
         return;
       }
@@ -177,7 +188,9 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
           if (u.protocol === 'http:') {
             const host = u.hostname;
             const isLocal =
-              host === 'localhost' || host === '127.0.0.1' || host.endsWith('.local');
+              host === 'localhost' ||
+              host === '127.0.0.1' ||
+              host.endsWith('.local');
             if (!isLocal) return false;
           }
           return true;
@@ -187,18 +200,18 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
       };
       if (grantTypes.includes('authorization_code')) {
         if (!validRedirectUris.length) {
-          showError('选择授权码授权类型时,必须填写至少一个重定向URI');
+          showError(t('选择授权码授权类型时,必须填写至少一个重定向URI'));
           setLoading(false);
           return;
         }
         const allValid = validRedirectUris.every(isValidRedirectUri);
         if (!allValid) {
-          showError('重定向URI格式不合法:仅支持https,或本地开发使用http');
+          showError(t('重定向URI格式不合法:仅支持https,或本地开发使用http'));
           setLoading(false);
           return;
         }
       }
-      
+
       const payload = {
         ...values,
         grant_types: grantTypes,
@@ -207,15 +220,15 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
 
       const res = await API.put('/api/oauth_clients/', payload);
       const { success, message } = res.data;
-      
+
       if (success) {
-        showSuccess('OAuth2客户端更新成功');
+        showSuccess(t('OAuth2客户端更新成功'));
         onSuccess();
       } else {
         showError(message);
       }
     } catch (error) {
-      showError('更新OAuth2客户端失败');
+      showError(t('更新OAuth2客户端失败'));
     } finally {
       setLoading(false);
     }
@@ -246,7 +259,10 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
       setRedirectUris(['']);
     }
     // 公开客户端不允许client_credentials
-    if (client?.client_type === 'public' && values.includes('client_credentials')) {
+    if (
+      client?.client_type === 'public' &&
+      values.includes('client_credentials')
+    ) {
       setGrantTypes(values.filter((v) => v !== 'client_credentials'));
     }
   };
@@ -255,12 +271,12 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
 
   return (
     <Modal
-      title={`编辑OAuth2客户端 - ${client.name}`}
+      title={t('编辑OAuth2客户端 - {{name}}', { name: client.name })}
       visible={visible}
       onCancel={onCancel}
       onOk={() => formApi?.submitForm()}
-      okText="保存"
-      cancelText="取消"
+      okText={t('保存')}
+      cancelText={t('取消')}
       confirmLoading={loading}
       width={600}
       style={{ top: 50 }}
@@ -268,143 +284,163 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
       <Form
         getFormApi={(api) => setFormApi(api)}
         onSubmit={handleSubmit}
-        labelPosition="top"
+        labelPosition='top'
       >
         {/* 客户端ID(只读) */}
         <Form.Input
-          field="id"
-          label="客户端ID"
+          field='id'
+          label={t('客户端ID')}
           disabled
           style={{ backgroundColor: '#f8f9fa' }}
         />
 
         {/* 基本信息 */}
         <Form.Input
-          field="name"
-          label="客户端名称"
-          placeholder="输入客户端名称"
-          rules={[{ required: true, message: '请输入客户端名称' }]}
+          field='name'
+          label={t('客户端名称')}
+          placeholder={t('输入客户端名称')}
+          rules={[{ required: true, message: t('请输入客户端名称') }]}
         />
 
         <Form.TextArea
-          field="description"
-          label="描述"
-          placeholder="输入客户端描述"
+          field='description'
+          label={t('描述')}
+          placeholder={t('输入客户端描述')}
           rows={3}
         />
 
         {/* 客户端类型(只读) */}
         <Form.Select
-          field="client_type"
-          label="客户端类型"
+          field='client_type'
+          label={t('客户端类型')}
           disabled
           style={{ backgroundColor: '#f8f9fa' }}
         >
-          <Option value="confidential">机密客户端(Confidential)</Option>
-          <Option value="public">公开客户端(Public)</Option>
+          <Option value='confidential'>
+            {t('机密客户端(Confidential)')}
+          </Option>
+          <Option value='public'>{t('公开客户端(Public)')}</Option>
         </Form.Select>
-        
-        <Paragraph type="tertiary" size="small" style={{ marginTop: -8, marginBottom: 16 }}>
-          客户端类型创建后不可更改。
+
+        <Paragraph
+          type='tertiary'
+          size='small'
+          style={{ marginTop: -8, marginBottom: 16 }}
+        >
+          {t('客户端类型创建后不可更改。')}
         </Paragraph>
 
         {/* 授权类型 */}
         <Form.Select
-          field="grant_types"
-          label="允许的授权类型"
+          field='grant_types'
+          label={t('允许的授权类型')}
           multiple
           value={grantTypes}
           onChange={handleGrantTypesChange}
-          rules={[{ required: true, message: '请选择至少一种授权类型' }]}
+          rules={[{ required: true, message: t('请选择至少一种授权类型') }]}
         >
-          <Option value="client_credentials" disabled={
-            client?.client_type === 'public' || !allowedGrantTypes.includes('client_credentials')
-          }>
-            Client Credentials(客户端凭证)
+          <Option
+            value='client_credentials'
+            disabled={
+              client?.client_type === 'public' ||
+              !allowedGrantTypes.includes('client_credentials')
+            }
+          >
+            {t('Client Credentials(客户端凭证)')}
           </Option>
-          <Option value="authorization_code" disabled={!allowedGrantTypes.includes('authorization_code')}>
-            Authorization Code(授权码)
+          <Option
+            value='authorization_code'
+            disabled={!allowedGrantTypes.includes('authorization_code')}
+          >
+            {t('Authorization Code(授权码)')}
           </Option>
-          <Option value="refresh_token" disabled={!allowedGrantTypes.includes('refresh_token')}>
-            Refresh Token(刷新令牌)
+          <Option
+            value='refresh_token'
+            disabled={!allowedGrantTypes.includes('refresh_token')}
+          >
+            {t('Refresh Token(刷新令牌)')}
           </Option>
         </Form.Select>
 
         {/* Scope */}
         <Form.Select
-          field="scopes"
-          label="允许的权限范围(Scope)"
+          field='scopes'
+          label={t('允许的权限范围(Scope)')}
           multiple
-          rules={[{ required: true, message: '请选择至少一个权限范围' }]}
+          rules={[{ required: true, message: t('请选择至少一个权限范围') }]}
         >
-          <Option value="openid">openid(OIDC 基础身份)</Option>
-          <Option value="profile">profile(用户名/昵称等)</Option>
-          <Option value="email">email(邮箱信息)</Option>
-          <Option value="api:read">api:read(读取API)</Option>
-          <Option value="api:write">api:write(写入API)</Option>
-          <Option value="admin">admin(管理员权限)</Option>
+          <Option value='openid'>openid(OIDC 基础身份)</Option>
+          <Option value='profile'>profile(用户名/昵称等)</Option>
+          <Option value='email'>email(邮箱信息)</Option>
+          <Option value='api:read'>api:read(读取API)</Option>
+          <Option value='api:write'>api:write(写入API)</Option>
+          <Option value='admin'>admin(管理员权限)</Option>
         </Form.Select>
 
         {/* PKCE设置 */}
-        <Form.Switch
-          field="require_pkce"
-          label="强制PKCE验证"
-        />
-        <Paragraph type="tertiary" size="small" style={{ marginTop: -8, marginBottom: 16 }}>
-          PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。
+        <Form.Switch field='require_pkce' label={t('强制PKCE验证')} />
+        <Paragraph
+          type='tertiary'
+          size='small'
+          style={{ marginTop: -8, marginBottom: 16 }}
+        >
+          {t('PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。')}
         </Paragraph>
 
         {/* 状态 */}
         <Form.Select
-          field="status"
-          label="状态"
-          rules={[{ required: true, message: '请选择状态' }]}
+          field='status'
+          label={t('状态')}
+          rules={[{ required: true, message: t('请选择状态') }]}
         >
-          <Option value={1}>启用</Option>
-          <Option value={2}>禁用</Option>
+          <Option value={1}>{t('启用')}</Option>
+          <Option value={2}>{t('禁用')}</Option>
         </Form.Select>
 
         {/* 重定向URI */}
-        {(grantTypes.includes('authorization_code') || redirectUris.length > 0) && (
+        {(grantTypes.includes('authorization_code') ||
+          redirectUris.length > 0) && (
           <>
-            <Divider>重定向URI配置</Divider>
+            <Divider>{t('重定向URI配置')}</Divider>
             <div style={{ marginBottom: 16 }}>
-              <Text strong>重定向URI</Text>
-              <Paragraph type="tertiary" size="small">
-                用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)。
+              <Text strong>{t('重定向URI')}</Text>
+              <Paragraph type='tertiary' size='small'>
+                {t(
+                  '用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)。',
+                )}
               </Paragraph>
-              
-              <Space direction="vertical" style={{ width: '100%' }}>
+
+              <Space direction='vertical' style={{ width: '100%' }}>
                 {redirectUris.map((uri, index) => (
                   <Space key={index} style={{ width: '100%' }}>
                     <Input
-                      placeholder="https://your-app.com/callback"
+                      placeholder='https://your-app.com/callback'
                       value={uri}
                       onChange={(value) => updateRedirectUri(index, value)}
                       style={{ flex: 1 }}
                     />
                     {redirectUris.length > 1 && (
                       <Button
-                        theme="borderless"
-                        type="danger"
-                        size="small"
-                        icon={<Trash2 size={14} />}
+                        theme='borderless'
+                        type='danger'
+                        size='small'
                         onClick={() => removeRedirectUri(index)}
-                      />
+                      >
+                        {t('删除')}
+                      </Button>
                     )}
                   </Space>
                 ))}
               </Space>
-              
+
               <Button
-                theme="borderless"
-                type="primary"
-                size="small"
-                icon={<Plus size={14} />}
+                theme='borderless'
+                type='primary'
+                size='small'
                 onClick={addRedirectUri}
                 style={{ marginTop: 8 }}
               >
-                添加重定向URI
+                {t('添加重定向URI')}
               </Button>
             </div>
           </>

+ 87 - 0
web/src/components/settings/oauth2/modals/JWKSInfoModal.jsx

@@ -0,0 +1,87 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useState, useEffect } from 'react';
+import { Modal, Typography } from '@douyinfe/semi-ui';
+import { API, showError } from '../../../../helpers';
+import { useTranslation } from 'react-i18next';
+
+const { Text } = Typography;
+
+const JWKSInfoModal = ({ visible, onClose }) => {
+  const { t } = useTranslation();
+  const [loading, setLoading] = useState(false);
+  const [jwksInfo, setJwksInfo] = useState(null);
+
+  const loadJWKSInfo = async () => {
+    setLoading(true);
+    try {
+      const res = await API.get('/api/oauth/jwks');
+      setJwksInfo(res.data);
+    } catch (error) {
+      showError(t('获取JWKS失败'));
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    if (visible) {
+      loadJWKSInfo();
+    }
+  }, [visible]);
+
+  return (
+    <Modal
+      title={
+        <div className='flex items-center'>
+          <span>🔐</span>
+          <Text strong className='ml-2'>
+            {t('JWKS 信息')}
+          </Text>
+        </div>
+      }
+      visible={visible}
+      onCancel={onClose}
+      onOk={onClose}
+      cancelText=''
+      okText={t('关闭')}
+      width={650}
+      bodyStyle={{ padding: '20px 24px' }}
+      confirmLoading={loading}
+    >
+      <pre
+        style={{
+          background: 'var(--semi-color-fill-0)',
+          padding: '16px',
+          borderRadius: '8px',
+          fontSize: '12px',
+          maxHeight: '400px',
+          overflow: 'auto',
+          border: '1px solid var(--semi-color-border)',
+          margin: 0,
+        }}
+      >
+        {jwksInfo ? JSON.stringify(jwksInfo, null, 2) : t('加载中...')}
+      </pre>
+    </Modal>
+  );
+};
+
+export default JWKSInfoModal;

+ 214 - 49
web/src/components/settings/oauth2/modals/JWKSManagerModal.jsx

@@ -1,11 +1,43 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
 import React, { useEffect, useState } from 'react';
-import { Modal, Table, Button, Space, Tag, Typography, Popconfirm, Toast, Form, TextArea, Divider, Input } from '@douyinfe/semi-ui';
-import { RefreshCw, Trash2, PlayCircle } from 'lucide-react';
+import {
+  Modal,
+  Table,
+  Button,
+  Space,
+  Tag,
+  Typography,
+  Popconfirm,
+  Toast,
+  Form,
+  TextArea,
+  Divider,
+  Input,
+} from '@douyinfe/semi-ui';
 import { API, showError, showSuccess } from '../../../../helpers';
+import { useTranslation } from 'react-i18next';
 
 const { Text } = Typography;
 
 export default function JWKSManagerModal({ visible, onClose }) {
+  const { t } = useTranslation();
   const [loading, setLoading] = useState(false);
   const [keys, setKeys] = useState([]);
 
@@ -14,37 +46,55 @@ export default function JWKSManagerModal({ visible, onClose }) {
     try {
       const res = await API.get('/api/oauth/keys');
       if (res?.data?.success) setKeys(res.data.data || []);
-      else showError(res?.data?.message || '获取密钥列表失败');
-    } catch { showError('获取密钥列表失败'); } finally { setLoading(false); }
+      else showError(res?.data?.message || t('获取密钥列表失败'));
+    } catch {
+      showError(t('获取密钥列表失败'));
+    } finally {
+      setLoading(false);
+    }
   };
 
   const rotate = async () => {
     setLoading(true);
     try {
       const res = await API.post('/api/oauth/keys/rotate', {});
-      if (res?.data?.success) { showSuccess('签名密钥已轮换:' + res.data.kid); await load(); }
-      else showError(res?.data?.message || '密钥轮换失败');
-    } catch { showError('密钥轮换失败'); } finally { setLoading(false); }
+      if (res?.data?.success) {
+        showSuccess(t('签名密钥已轮换:{{kid}}', { kid: res.data.kid }));
+        await load();
+      } else showError(res?.data?.message || t('密钥轮换失败'));
+    } catch {
+      showError(t('密钥轮换失败'));
+    } finally {
+      setLoading(false);
+    }
   };
 
   const del = async (kid) => {
     setLoading(true);
     try {
       const res = await API.delete(`/api/oauth/keys/${kid}`);
-      if (res?.data?.success) { Toast.success('已删除:' + kid); await load(); }
-      else showError(res?.data?.message || '删除失败');
-    } catch { showError('删除失败'); } finally { setLoading(false); }
+      if (res?.data?.success) {
+        Toast.success(t('已删除:{{kid}}', { kid }));
+        await load();
+      } else showError(res?.data?.message || t('删除失败'));
+    } catch {
+      showError(t('删除失败'));
+    } finally {
+      setLoading(false);
+    }
   };
 
-  useEffect(() => { if (visible) load(); }, [visible]);
+  useEffect(() => {
+    if (visible) load();
+  }, [visible]);
   useEffect(() => {
     if (!visible) return;
-    (async ()=>{
-      try{
+    (async () => {
+      try {
         const res = await API.get('/api/oauth/server-info');
         const p = res?.data?.default_private_key_path;
         if (p) setGenPath(p);
-      }catch{}
+      } catch {}
     })();
   }, [visible]);
 
@@ -53,18 +103,29 @@ export default function JWKSManagerModal({ visible, onClose }) {
   const [pem, setPem] = useState('');
   const [customKid, setCustomKid] = useState('');
   const importPem = async () => {
-    if (!pem.trim()) return Toast.warning('请粘贴 PEM 私钥');
+    if (!pem.trim()) return Toast.warning(t('请粘贴 PEM 私钥'));
     setLoading(true);
     try {
-      const res = await API.post('/api/oauth/keys/import_pem', { pem, kid: customKid.trim() });
+      const res = await API.post('/api/oauth/keys/import_pem', {
+        pem,
+        kid: customKid.trim(),
+      });
       if (res?.data?.success) {
-        Toast.success('已导入私钥并切换到 kid=' + res.data.kid);
-        setPem(''); setCustomKid(''); setShowImport(false);
+        Toast.success(
+          t('已导入私钥并切换到 kid={{kid}}', { kid: res.data.kid }),
+        );
+        setPem('');
+        setCustomKid('');
+        setShowImport(false);
         await load();
       } else {
-        Toast.error(res?.data?.message || '导入失败');
+        Toast.error(res?.data?.message || t('导入失败'));
       }
-    } catch { Toast.error('导入失败'); } finally { setLoading(false); }
+    } catch {
+      Toast.error(t('导入失败'));
+    } finally {
+      setLoading(false);
+    }
   };
 
   // Generate PEM file state
@@ -72,77 +133,181 @@ export default function JWKSManagerModal({ visible, onClose }) {
   const [genPath, setGenPath] = useState('/etc/new-api/oauth2-private.pem');
   const [genKid, setGenKid] = useState('');
   const generatePemFile = async () => {
-    if (!genPath.trim()) return Toast.warning('请填写保存路径');
+    if (!genPath.trim()) return Toast.warning(t('请填写保存路径'));
     setLoading(true);
     try {
-      const res = await API.post('/api/oauth/keys/generate_file', { path: genPath.trim(), kid: genKid.trim() });
+      const res = await API.post('/api/oauth/keys/generate_file', {
+        path: genPath.trim(),
+        kid: genKid.trim(),
+      });
       if (res?.data?.success) {
-        Toast.success('已生成并生效:' + res.data.path);
+        Toast.success(t('已生成并生效:{{path}}', { path: res.data.path }));
         await load();
       } else {
-        Toast.error(res?.data?.message || '生成失败');
+        Toast.error(res?.data?.message || t('生成失败'));
       }
-    } catch { Toast.error('生成失败'); } finally { setLoading(false); }
+    } catch {
+      Toast.error(t('生成失败'));
+    } finally {
+      setLoading(false);
+    }
   };
 
   const columns = [
-    { title: 'KID', dataIndex: 'kid', render: (kid) => <Text code copyable>{kid}</Text> },
-    { title: '创建时间', dataIndex: 'created_at', render: (ts) => (ts ? new Date(ts * 1000).toLocaleString() : '-') },
-    { title: '状态', dataIndex: 'current', render: (cur) => (cur ? <Tag color='green'>当前</Tag> : <Tag>历史</Tag>) },
-    { title: '操作', render: (_, r) => (
+    {
+      title: 'KID',
+      dataIndex: 'kid',
+      render: (kid) => (
+        <Text code copyable>
+          {kid}
+        </Text>
+      ),
+    },
+    {
+      title: t('创建时间'),
+      dataIndex: 'created_at',
+      render: (ts) => (ts ? new Date(ts * 1000).toLocaleString() : '-'),
+    },
+    {
+      title: t('状态'),
+      dataIndex: 'current',
+      render: (cur) =>
+        cur ? <Tag color='green'>{t('当前')}</Tag> : <Tag>{t('历史')}</Tag>,
+    },
+    {
+      title: t('操作'),
+      render: (_, r) => (
         <Space>
           {!r.current && (
-            <Popconfirm title={`确定删除密钥 ${r.kid} ?`} content='删除后使用该 kid 签发的旧令牌仍可被验证(外部 JWKS 缓存可能仍保留)' okText='删除' onConfirm={() => del(r.kid)}>
-              <Button icon={<Trash2 size={14} />} size='small' theme='borderless'>删除</Button>
+            <Popconfirm
+              title={t('确定删除密钥 {{kid}} ?', { kid: r.kid })}
+              content={t(
+                '删除后使用该 kid 签发的旧令牌仍可被验证(外部 JWKS 缓存可能仍保留)',
+              )}
+              okText={t('删除')}
+              onConfirm={() => del(r.kid)}
+            >
+              <Button size='small' theme='borderless'>
+                {t('删除')}
+              </Button>
             </Popconfirm>
           )}
         </Space>
-      ) },
+      ),
+    },
   ];
 
   return (
     <Modal
       visible={visible}
-      title='JWKS 管理'
+      title={t('JWKS 管理')}
       onCancel={onClose}
       footer={null}
       width={820}
       style={{ top: 48 }}
     >
       <Space style={{ marginBottom: 8 }}>
-        <Button icon={<RefreshCw size={16} />} onClick={load} loading={loading}>刷新</Button>
-        <Button icon={<PlayCircle size={16} />} type='primary' onClick={rotate} loading={loading}>轮换密钥</Button>
-        <Button onClick={()=>setShowImport(!showImport)}>导入 PEM 私钥</Button>
-        <Button onClick={()=>setShowGenerate(!showGenerate)}>生成 PEM 文件</Button>
-        <Button onClick={onClose}>关闭</Button>
+        <Button onClick={load} loading={loading}>
+          {t('刷新')}
+        </Button>
+        <Button type='primary' onClick={rotate} loading={loading}>
+          {t('轮换密钥')}
+        </Button>
+        <Button onClick={() => setShowImport(!showImport)}>
+          {t('导入 PEM 私钥')}
+        </Button>
+        <Button onClick={() => setShowGenerate(!showGenerate)}>
+          {t('生成 PEM 文件')}
+        </Button>
+        <Button onClick={onClose}>{t('关闭')}</Button>
       </Space>
       {showGenerate && (
-        <div style={{ border: '1px solid var(--semi-color-border)', borderRadius: 6, padding: 12, marginBottom: 12 }}>
+        <div
+          style={{
+            border: '1px solid var(--semi-color-border)',
+            borderRadius: 6,
+            padding: 12,
+            marginBottom: 12,
+          }}
+        >
           <Form labelPosition='left' labelWidth={120}>
-            <Form.Input field='path' label='保存路径' value={genPath} onChange={setGenPath} placeholder='/secure/path/oauth2-private.pem' />
-            <Form.Input field='genKid' label='自定义 KID' value={genKid} onChange={setGenKid} placeholder='可留空自动生成' />
+            <Form.Input
+              field='path'
+              label={t('保存路径')}
+              value={genPath}
+              onChange={setGenPath}
+              placeholder='/secure/path/oauth2-private.pem'
+            />
+            <Form.Input
+              field='genKid'
+              label={t('自定义 KID')}
+              value={genKid}
+              onChange={setGenKid}
+              placeholder={t('可留空自动生成')}
+            />
           </Form>
           <div style={{ marginTop: 8 }}>
-            <Button type='primary' onClick={generatePemFile} loading={loading}>生成并生效</Button>
+            <Button type='primary' onClick={generatePemFile} loading={loading}>
+              {t('生成并生效')}
+            </Button>
           </div>
           <Divider margin='12px' />
-          <Text type='tertiary'>建议:仅在合规要求下使用文件私钥。请确保目录权限安全(建议 0600),并妥善备份。</Text>
+          <Text type='tertiary'>
+            {t(
+              '建议:仅在合规要求下使用文件私钥。请确保目录权限安全(建议 0600),并妥善备份。',
+            )}
+          </Text>
         </div>
       )}
       {showImport && (
-        <div style={{ border: '1px solid var(--semi-color-border)', borderRadius: 6, padding: 12, marginBottom: 12 }}>
+        <div
+          style={{
+            border: '1px solid var(--semi-color-border)',
+            borderRadius: 6,
+            padding: 12,
+            marginBottom: 12,
+          }}
+        >
           <Form labelPosition='left' labelWidth={120}>
-            <Form.Input field='kid' label='自定义 KID' placeholder='可留空自动生成' value={customKid} onChange={setCustomKid} />
-            <Form.TextArea field='pem' label='PEM 私钥' value={pem} onChange={setPem} rows={6} placeholder={'-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----'} />
+            <Form.Input
+              field='kid'
+              label={t('自定义 KID')}
+              placeholder={t('可留空自动生成')}
+              value={customKid}
+              onChange={setCustomKid}
+            />
+            <Form.TextArea
+              field='pem'
+              label={t('PEM 私钥')}
+              value={pem}
+              onChange={setPem}
+              rows={6}
+              placeholder={
+                '-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----'
+              }
+            />
           </Form>
           <div style={{ marginTop: 8 }}>
-            <Button type='primary' onClick={importPem} loading={loading}>导入并生效</Button>
+            <Button type='primary' onClick={importPem} loading={loading}>
+              {t('导入并生效')}
+            </Button>
           </div>
           <Divider margin='12px' />
-          <Text type='tertiary'>建议:优先使用内存签名密钥与 JWKS 轮换;仅在有合规要求时导入外部私钥。</Text>
+          <Text type='tertiary'>
+            {t(
+              '建议:优先使用内存签名密钥与 JWKS 轮换;仅在有合规要求时导入外部私钥。',
+            )}
+          </Text>
         </div>
       )}
-      <Table dataSource={keys} columns={columns} rowKey='kid' loading={loading} pagination={false} empty={<Text type='tertiary'>暂无密钥</Text>} />
+      <Table
+        dataSource={keys}
+        columns={columns}
+        rowKey='kid'
+        loading={loading}
+        pagination={false}
+        empty={<Text type='tertiary'>{t('暂无密钥')}</Text>}
+      />
     </Modal>
   );
 }

+ 0 - 230
web/src/components/settings/oauth2/modals/OAuth2QuickStartModal.jsx

@@ -1,230 +0,0 @@
-import React, { useEffect, useMemo, useState } from 'react';
-import { Modal, Steps, Form, Input, Select, Switch, Typography, Space, Button, Tag, Toast } from '@douyinfe/semi-ui';
-import { API, showError, showSuccess } from '../../../../helpers';
-
-const { Text } = Typography;
-
-export default function OAuth2QuickStartModal({ visible, onClose, onDone }) {
-  const origin = useMemo(() => window.location.origin, []);
-  const [step, setStep] = useState(0);
-  const [loading, setLoading] = useState(false);
-
-  // Step state
-  const [enableOAuth, setEnableOAuth] = useState(true);
-  const [issuer, setIssuer] = useState(origin);
-
-  const [clientType, setClientType] = useState('public');
-  const [redirect1, setRedirect1] = useState(origin + '/oauth/oidc');
-  const [redirect2, setRedirect2] = useState('');
-  const [scopes, setScopes] = useState(['openid', 'profile', 'email', 'api:read']);
-
-  // Results
-  const [createdClient, setCreatedClient] = useState(null);
-
-  useEffect(() => {
-    if (!visible) {
-      setStep(0);
-      setLoading(false);
-      setEnableOAuth(true);
-      setIssuer(origin);
-      setClientType('public');
-      setRedirect1(origin + '/oauth/oidc');
-      setRedirect2('');
-      setScopes(['openid', 'profile', 'email', 'api:read']);
-      setCreatedClient(null);
-    }
-  }, [visible, origin]);
-
-  // 打开时读取现有配置作为默认值
-  useEffect(() => {
-    if (!visible) return;
-    (async () => {
-      try {
-        const res = await API.get('/api/option/');
-        const { success, data } = res.data || {};
-        if (!success || !Array.isArray(data)) return;
-        const map = Object.fromEntries(data.map(i => [i.key, i.value]));
-        if (typeof map['oauth2.enabled'] !== 'undefined') {
-          setEnableOAuth(String(map['oauth2.enabled']).toLowerCase() === 'true');
-        }
-        if (map['oauth2.issuer']) {
-          setIssuer(map['oauth2.issuer']);
-        }
-      } catch (_) {}
-    })();
-  }, [visible]);
-
-  const applyRecommended = async () => {
-    setLoading(true);
-    try {
-      const ops = [
-        { key: 'oauth2.enabled', value: String(enableOAuth) },
-        { key: 'oauth2.issuer', value: issuer || '' },
-        { key: 'oauth2.allowed_grant_types', value: JSON.stringify(['authorization_code', 'refresh_token', 'client_credentials']) },
-        { key: 'oauth2.require_pkce', value: 'true' },
-        { key: 'oauth2.jwt_signing_algorithm', value: 'RS256' },
-      ];
-      for (const op of ops) {
-        await API.put('/api/option/', op);
-      }
-      showSuccess('已应用推荐配置');
-      setStep(1);
-      onDone && onDone();
-    } catch (e) {
-      showError('应用推荐配置失败');
-    } finally {
-      setLoading(false);
-    }
-  };
-
-  const rotateKey = async () => {
-    setLoading(true);
-    try {
-      const res = await API.post('/api/oauth/keys/rotate', {});
-      if (res?.data?.success) {
-        showSuccess('签名密钥已准备:' + res.data.kid);
-      } else {
-        showError(res?.data?.message || '签名密钥操作失败');
-        return;
-      }
-      setStep(2);
-    } catch (e) {
-      showError('签名密钥操作失败');
-    } finally {
-      setLoading(false);
-    }
-  };
-
-  const createClient = async () => {
-    setLoading(true);
-    try {
-      const grant_types = clientType === 'public' ? ['authorization_code', 'refresh_token'] : ['authorization_code', 'refresh_token', 'client_credentials'];
-      const payload = {
-        name: 'Default OIDC Client',
-        client_type: clientType,
-        grant_types,
-        redirect_uris: [redirect1, redirect2].filter(Boolean),
-        scopes,
-        require_pkce: true,
-      };
-      const res = await API.post('/api/oauth_clients/', payload);
-      if (res?.data?.success) {
-        setCreatedClient({ id: res.data.client_id, secret: res.data.client_secret });
-        showSuccess('客户端已创建');
-        setStep(3);
-      } else {
-        showError(res?.data?.message || '创建失败');
-      }
-      onDone && onDone();
-    } catch (e) {
-      showError('创建失败');
-    } finally {
-      setLoading(false);
-    }
-  };
-
-  const steps = [
-    {
-      title: '应用推荐配置',
-      content: (
-        <div style={{ paddingTop: 8 }}>
-          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
-            <div>
-              <Form labelPosition='left' labelWidth={140}>
-                <Form.Switch field='enable' label='启用 OAuth2 & SSO' checkedText='开' uncheckedText='关' checked={enableOAuth} onChange={setEnableOAuth} extraText='开启后将根据推荐设置完成授权链路' />
-                <Form.Input field='issuer' label='发行人 (Issuer)' placeholder={origin} value={issuer} onChange={setIssuer} extraText='为空则按请求自动推断(含 X-Forwarded-Proto)' />
-              </Form>
-            </div>
-            <div>
-              <Text type='tertiary'>说明</Text>
-              <div style={{ marginTop: 8 }}>
-                <Tag>grant_types: auth_code / refresh_token / client_credentials</Tag>
-                <Tag>PKCE: S256</Tag>
-                <Tag>算法: RS256</Tag>
-              </div>
-            </div>
-          </div>
-          <div style={{ marginTop: 16, paddingBottom: 12 }}>
-            <Button type='primary' onClick={applyRecommended} loading={loading}>一键应用</Button>
-          </div>
-        </div>
-      )
-    },
-    {
-      title: '准备签名密钥',
-      content: (
-        <div style={{ paddingTop: 8 }}>
-          <Text type='tertiary'>若无密钥则初始化;如已存在建议立即轮换以生成新的 kid 并发布到 JWKS。</Text>
-          <div style={{ marginTop: 12 }}>
-            <Button type='primary' onClick={rotateKey} loading={loading}>初始化/轮换密钥</Button>
-          </div>
-        </div>
-      )
-    },
-    {
-      title: '创建默认 OIDC 客户端',
-      content: (
-        <div style={{ paddingTop: 8 }}>
-          <Form labelPosition='left' labelWidth={120}>
-            <Form.Select field='type' label='客户端类型' value={clientType} onChange={setClientType}>
-              <Select.Option value='public'>公开客户端(SPA/移动端)</Select.Option>
-              <Select.Option value='confidential'>机密客户端(服务端)</Select.Option>
-            </Form.Select>
-            <Form.Input field='r1' label='回调 URI 1' value={redirect1} onChange={setRedirect1} />
-            <Form.Input field='r2' label='回调 URI 2' value={redirect2} onChange={setRedirect2} />
-            <Form.Select field='scopes' label='Scopes' multiple value={scopes} onChange={setScopes}>
-              <Select.Option value='openid'>openid</Select.Option>
-              <Select.Option value='profile'>profile</Select.Option>
-              <Select.Option value='email'>email</Select.Option>
-              <Select.Option value='api:read'>api:read</Select.Option>
-              <Select.Option value='api:write'>api:write</Select.Option>
-              <Select.Option value='admin'>admin</Select.Option>
-            </Form.Select>
-          </Form>
-          <div style={{ marginTop: 12 }}>
-            <Button type='primary' onClick={createClient} loading={loading}>创建</Button>
-          </div>
-        </div>
-      )
-    },
-    {
-      title: '完成',
-      content: (
-        <div style={{ paddingTop: 8 }}>
-          {createdClient ? (
-            <div>
-              <Text>客户端已创建:</Text>
-              <div style={{ marginTop: 8 }}>
-                <Text>Client ID:</Text> <Text code copyable>{createdClient.id}</Text>
-              </div>
-              {createdClient.secret && (
-                <div style={{ marginTop: 8 }}>
-                  <Text>Client Secret(仅此一次展示):</Text> <Text code copyable>{createdClient.secret}</Text>
-                </div>
-              )}
-            </div>
-          ) : <Text type='tertiary'>已完成初始化。</Text>}
-        </div>
-      )
-    }
-  ];
-
-  return (
-    <Modal
-      visible={visible}
-      title='OAuth2 一键初始化向导'
-      onCancel={onClose}
-      footer={null}
-      width={720}
-      style={{ top: 48 }}
-      maskClosable={false}
-    >
-      <Steps current={step} style={{ marginBottom: 16 }}>
-        {steps.map((s, idx) => <Steps.Step key={idx} title={s.title} />)}
-      </Steps>
-      <div style={{ paddingLeft: 8, paddingRight: 8 }}>
-        {steps[step].content}
-      </div>
-    </Modal>
-  );
-}

+ 0 - 324
web/src/components/settings/oauth2/modals/OAuth2ToolsModal.jsx

@@ -1,324 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import { Modal, Form, Input, Button, Space, Select, Typography, Divider, Toast, TextArea } from '@douyinfe/semi-ui';
-import { API } from '../../../../helpers';
-
-const { Text } = Typography;
-
-async function sha256Base64Url(input) {
-  const enc = new TextEncoder();
-  const data = enc.encode(input);
-  const hash = await crypto.subtle.digest('SHA-256', data);
-  const bytes = new Uint8Array(hash);
-  let binary = '';
-  for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
-  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
-}
-
-function randomString(len = 43) {
-  const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
-  let res = '';
-  const array = new Uint32Array(len);
-  crypto.getRandomValues(array);
-  for (let i = 0; i < len; i++) res += charset[array[i] % charset.length];
-  return res;
-}
-
-export default function OAuth2ToolsModal({ visible, onClose }) {
-  const [server, setServer] = useState({});
-  const [authURL, setAuthURL] = useState('');
-  const [issuer, setIssuer] = useState('');
-  const [confJSON, setConfJSON] = useState('');
-  const [userinfoEndpoint, setUserinfoEndpoint] = useState('');
-  const [code, setCode] = useState('');
-  const [accessToken, setAccessToken] = useState('');
-  const [idToken, setIdToken] = useState('');
-  const [refreshToken, setRefreshToken] = useState('');
-  const [tokenRaw, setTokenRaw] = useState('');
-  const [jwtClaims, setJwtClaims] = useState('');
-  const [userinfoOut, setUserinfoOut] = useState('');
-  const [values, setValues] = useState({
-    authorization_endpoint: '',
-    token_endpoint: '',
-    client_id: '',
-    client_secret: '',
-    redirect_uri: window.location.origin + '/oauth/oidc',
-    scope: 'openid profile email',
-    response_type: 'code',
-    code_verifier: '',
-    code_challenge: '',
-    code_challenge_method: 'S256',
-    state: '',
-    nonce: '',
-  });
-
-  useEffect(() => {
-    if (!visible) return;
-    (async () => {
-      try {
-        const res = await API.get('/api/oauth/server-info');
-        if (res?.data) {
-          const d = res.data;
-          setServer(d);
-          setValues((v) => ({
-            ...v,
-            authorization_endpoint: d.authorization_endpoint,
-            token_endpoint: d.token_endpoint,
-          }));
-          setIssuer(d.issuer || '');
-          setUserinfoEndpoint(d.userinfo_endpoint || '');
-        }
-      } catch {}
-    })();
-  }, [visible]);
-
-  const buildAuthorizeURL = () => {
-    const u = new URL(values.authorization_endpoint || (server.issuer + '/api/oauth/authorize'));
-    const rt = values.response_type || 'code';
-    u.searchParams.set('response_type', rt);
-    u.searchParams.set('client_id', values.client_id);
-    u.searchParams.set('redirect_uri', values.redirect_uri);
-    u.searchParams.set('scope', values.scope);
-    if (values.state) u.searchParams.set('state', values.state);
-    if (values.nonce) u.searchParams.set('nonce', values.nonce);
-    if (rt === 'code' && values.code_challenge) {
-      u.searchParams.set('code_challenge', values.code_challenge);
-      u.searchParams.set('code_challenge_method', values.code_challenge_method || 'S256');
-    }
-    return u.toString();
-  };
-
-  const copy = async (text, tip = '已复制') => {
-    try { await navigator.clipboard.writeText(text); Toast.success(tip); } catch {}
-  };
-
-  const genVerifier = async () => {
-    const v = randomString(64);
-    const c = await sha256Base64Url(v);
-    setValues((val) => ({ ...val, code_verifier: v, code_challenge: c }));
-  };
-
-  const discover = async () => {
-    const iss = (issuer || '').trim();
-    if (!iss) { Toast.warning('请填写 Issuer'); return; }
-    try {
-      const url = iss.replace(/\/$/, '') + '/api/.well-known/openid-configuration';
-      const res = await fetch(url);
-      const d = await res.json();
-      setValues((v)=>({
-        ...v,
-        authorization_endpoint: d.authorization_endpoint || v.authorization_endpoint,
-        token_endpoint: d.token_endpoint || v.token_endpoint,
-      }));
-      setUserinfoEndpoint(d.userinfo_endpoint || '');
-      setIssuer(d.issuer || iss);
-      setConfJSON(JSON.stringify(d, null, 2));
-      Toast.success('已从发现文档加载端点');
-    } catch (e) {
-      Toast.error('自动发现失败');
-    }
-  };
-
-  const parseConf = () => {
-    try {
-      const d = JSON.parse(confJSON || '{}');
-      if (d.issuer) setIssuer(d.issuer);
-      if (d.authorization_endpoint) setValues((v)=>({...v, authorization_endpoint: d.authorization_endpoint}));
-      if (d.token_endpoint) setValues((v)=>({...v, token_endpoint: d.token_endpoint}));
-      if (d.userinfo_endpoint) setUserinfoEndpoint(d.userinfo_endpoint);
-      Toast.success('已解析配置并填充端点');
-    } catch (e) {
-      Toast.error('解析失败:' + e.message);
-    }
-  };
-
-  const genConf = () => {
-    const d = {
-      issuer: issuer || undefined,
-      authorization_endpoint: values.authorization_endpoint || undefined,
-      token_endpoint: values.token_endpoint || undefined,
-      userinfo_endpoint: userinfoEndpoint || undefined,
-    };
-    setConfJSON(JSON.stringify(d, null, 2));
-  };
-
-  async function postForm(url, data, basicAuth) {
-    const body = Object.entries(data)
-      .filter(([_, v]) => v !== undefined && v !== null)
-      .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
-      .join('&');
-    const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
-    if (basicAuth) headers['Authorization'] = 'Basic ' + btoa(`${basicAuth.id}:${basicAuth.secret}`);
-    const res = await fetch(url, { method: 'POST', headers, body });
-    if (!res.ok) {
-      const t = await res.text();
-      throw new Error(`HTTP ${res.status} ${t}`);
-    }
-    return res.json();
-  }
-
-  const exchangeCode = async () => {
-    try {
-      const basic = values.client_secret ? { id: values.client_id, secret: values.client_secret } : undefined;
-      const data = await postForm(values.token_endpoint, {
-        grant_type: 'authorization_code',
-        code: code.trim(),
-        client_id: values.client_id,
-        redirect_uri: values.redirect_uri,
-        code_verifier: values.code_verifier,
-      }, basic);
-      setAccessToken(data.access_token || '');
-      setIdToken(data.id_token || '');
-      setRefreshToken(data.refresh_token || '');
-      setTokenRaw(JSON.stringify(data, null, 2));
-      Toast.success('已获取令牌');
-    } catch (e) {
-      Toast.error('兑换失败:' + e.message);
-    }
-  };
-
-  const decodeIdToken = () => {
-    const t = (idToken || '').trim();
-    if (!t) { setJwtClaims('(空)'); return; }
-    const parts = t.split('.');
-    if (parts.length < 2) { setJwtClaims('格式错误'); return; }
-    try {
-      const json = JSON.parse(atob(parts[1].replace(/-/g,'+').replace(/_/g,'/')));
-      setJwtClaims(JSON.stringify(json, null, 2));
-    } catch (e) {
-      setJwtClaims('解码失败:' + e);
-    }
-  };
-
-  const callUserInfo = async () => {
-    if (!accessToken || !userinfoEndpoint) { Toast.warning('缺少 AccessToken 或 UserInfo 端点'); return; }
-    try {
-      const res = await fetch(userinfoEndpoint, { headers: { Authorization: 'Bearer ' + accessToken } });
-      const data = await res.json();
-      setUserinfoOut(JSON.stringify(data, null, 2));
-    } catch (e) {
-      setUserinfoOut('调用失败:' + e);
-    }
-  };
-
-  const doRefresh = async () => {
-    if (!refreshToken) { Toast.warning('没有刷新令牌'); return; }
-    try {
-      const basic = values.client_secret ? { id: values.client_id, secret: values.client_secret } : undefined;
-      const data = await postForm(values.token_endpoint, {
-        grant_type: 'refresh_token',
-        refresh_token: refreshToken,
-        client_id: values.client_id,
-      }, basic);
-      setAccessToken(data.access_token || '');
-      setIdToken(data.id_token || '');
-      setRefreshToken(data.refresh_token || '');
-      setTokenRaw(JSON.stringify(data, null, 2));
-      Toast.success('刷新成功');
-    } catch (e) {
-      Toast.error('刷新失败:' + e.message);
-    }
-  };
-
-  return (
-    <Modal
-      visible={visible}
-      title='OAuth2 调试助手'
-      onCancel={onClose}
-      footer={<Button onClick={onClose}>关闭</Button>}
-      width={720}
-      style={{ top: 48 }}
-    >
-      {/* Discovery */}
-      <Typography.Title heading={6}>OIDC 发现</Typography.Title>
-      <Form labelPosition='left' labelWidth={140} style={{ marginBottom: 8 }}>
-        <Form.Input field='issuer' label='Issuer' placeholder='https://your-domain' value={issuer} onChange={setIssuer} />
-      </Form>
-      <Space style={{ marginBottom: 12 }}>
-        <Button onClick={discover}>自动发现端点</Button>
-        <Button onClick={genConf}>生成配置 JSON</Button>
-        <Button onClick={parseConf}>解析配置 JSON</Button>
-      </Space>
-      <TextArea value={confJSON} onChange={setConfJSON} autosize={{ minRows: 3, maxRows: 8 }} placeholder='粘贴 /.well-known/openid-configuration JSON 或点击“生成配置 JSON”' />
-      <Divider />
-
-      {/* Authorization URL & PKCE */}
-      <Typography.Title heading={6}>授权参数</Typography.Title>
-      <Form labelPosition='left' labelWidth={140}>
-        <Form.Select field='response_type' label='Response Type' value={values.response_type} onChange={(v)=>setValues({...values, response_type: v})}>
-          <Select.Option value='code'>code</Select.Option>
-          <Select.Option value='token'>token</Select.Option>
-        </Form.Select>
-        <Form.Input field='authorization_endpoint' label='Authorize URL' value={values.authorization_endpoint} onChange={(v)=>setValues({...values, authorization_endpoint: v})} />
-        <Form.Input field='token_endpoint' label='Token URL' value={values.token_endpoint} onChange={(v)=>setValues({...values, token_endpoint: v})} />
-        <Form.Input field='client_id' label='Client ID' placeholder='输入 client_id' value={values.client_id} onChange={(v)=>setValues({...values, client_id: v})} />
-        <Form.Input field='client_secret' label='Client Secret(可选)' placeholder='留空表示公开客户端' value={values.client_secret} onChange={(v)=>setValues({...values, client_secret: v})} />
-        <Form.Input field='redirect_uri' label='Redirect URI' value={values.redirect_uri} onChange={(v)=>setValues({...values, redirect_uri: v})} />
-        <Form.Input field='scope' label='Scope' value={values.scope} onChange={(v)=>setValues({...values, scope: v})} />
-        <Form.Select field='code_challenge_method' label='PKCE 方法' value={values.code_challenge_method} onChange={(v)=>setValues({...values, code_challenge_method: v})}>
-          <Select.Option value='S256'>S256</Select.Option>
-        </Form.Select>
-        <Form.Input field='code_verifier' label='Code Verifier' value={values.code_verifier} onChange={(v)=>setValues({...values, code_verifier: v})} suffix={<Button size='small' onClick={genVerifier}>生成</Button>} />
-        <Form.Input field='code_challenge' label='Code Challenge' value={values.code_challenge} onChange={(v)=>setValues({...values, code_challenge: v})} />
-        <Form.Input field='state' label='State' value={values.state} onChange={(v)=>setValues({...values, state: v})} suffix={<Button size='small' onClick={()=>setValues({...values, state: randomString(16)})}>随机</Button>} />
-        <Form.Input field='nonce' label='Nonce' value={values.nonce} onChange={(v)=>setValues({...values, nonce: v})} suffix={<Button size='small' onClick={()=>setValues({...values, nonce: randomString(16)})}>随机</Button>} />
-      </Form>
-      <Divider />
-      <Space style={{ marginBottom: 8 }}>
-        <Button onClick={()=>{ const url=buildAuthorizeURL(); setAuthURL(url); }}>生成授权链接</Button>
-        <Button onClick={()=>window.open(buildAuthorizeURL(), '_blank')}>打开授权URL</Button>
-        <Button onClick={()=>copy(buildAuthorizeURL(), '授权URL已复制')}>复制授权URL</Button>
-        <Button onClick={()=>copy(JSON.stringify({
-          authorize_url: values.authorization_endpoint,
-          token_url: values.token_endpoint,
-          client_id: values.client_id,
-          redirect_uri: values.redirect_uri,
-          scope: values.scope,
-          response_type: values.response_type,
-          code_challenge_method: values.code_challenge_method,
-          code_verifier: values.code_verifier,
-          code_challenge: values.code_challenge,
-          state: values.state,
-          nonce: values.nonce,
-        }, null, 2), 'oauthdebugger参数已复制')}>复制 oauthdebugger 参数</Button>
-        <Button onClick={()=>window.open('/oauth-demo.html', '_blank')}>打开前端 Demo</Button>
-      </Space>
-      <Form labelPosition='left' labelWidth={140}>
-        <Form.TextArea field='authorize_url' label='授权链接' value={authURL} onChange={setAuthURL} rows={3} placeholder='(空)' />
-        <div style={{ marginTop: 8 }}>
-          <Button onClick={()=>copy(authURL, '授权URL已复制')}>复制当前授权URL</Button>
-        </div>
-      </Form>
-      <Text type='tertiary' style={{ display: 'block', marginTop: 8 }}>
-        提示:将上述参数粘贴到 oauthdebugger.com,或直接打开授权URL完成授权后回调。
-      </Text>
-
-      <Divider />
-      {/* Token exchange */}
-      <Typography.Title heading={6}>令牌操作</Typography.Title>
-      <Form labelPosition='left' labelWidth={140}>
-        <Form.Input field='code' label='授权码 (code)' value={code} onChange={setCode} placeholder='回调后粘贴 code' />
-        <div style={{ marginBottom: 8 }}>
-          <Space>
-            <Button type='primary' onClick={exchangeCode}>用 code 交换令牌</Button>
-            <Button onClick={doRefresh}>使用 Refresh Token 刷新</Button>
-          </Space>
-        </div>
-        <Form.Input field='access_token' label='Access Token' value={accessToken} onChange={setAccessToken} suffix={<Button size='small' onClick={()=>copy(accessToken,'AccessToken已复制')}>复制</Button>} />
-        <Form.Input field='id_token' label='ID Token' value={idToken} onChange={setIdToken} suffix={<Button size='small' onClick={decodeIdToken}>解码</Button>} />
-        <Form.Input field='refresh_token' label='Refresh Token' value={refreshToken} onChange={setRefreshToken} />
-        <Form.TextArea field='token_raw' label='原始响应' value={tokenRaw} onChange={setTokenRaw} rows={3} placeholder='(空)' />
-        <Form.TextArea field='jwt_claims' label='ID Token Claims' value={jwtClaims} onChange={setJwtClaims} rows={3} placeholder='(点击“解码”显示)'></Form.TextArea>
-      </Form>
-
-      <Divider />
-      <Typography.Title heading={6}>UserInfo</Typography.Title>
-      <Form labelPosition='left' labelWidth={140}>
-        <Form.Input field='userinfo_endpoint' label='UserInfo URL' value={userinfoEndpoint} onChange={setUserinfoEndpoint} />
-        <div style={{ marginTop: 8 }}>
-          <Button onClick={callUserInfo}>调用 UserInfo (Bearer)</Button>
-        </div>
-        <Form.TextArea field='userinfo_out' label='返回' value={userinfoOut} onChange={setUserinfoOut} rows={3} placeholder='(空)'></Form.TextArea>
-      </Form>
-    </Modal>
-  );
-}

+ 75 - 0
web/src/components/settings/oauth2/modals/SecretDisplayModal.jsx

@@ -0,0 +1,75 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React from 'react';
+import { Modal, Banner, Typography } from '@douyinfe/semi-ui';
+import { useTranslation } from 'react-i18next';
+
+const { Text } = Typography;
+
+const SecretDisplayModal = ({ visible, onClose, secret }) => {
+  const { t } = useTranslation();
+
+  return (
+    <Modal
+      title={
+        <div className='flex items-center'>
+          <span>🔑</span>
+          <Text strong className='ml-2'>
+            {t('客户端密钥已重新生成')}
+          </Text>
+        </div>
+      }
+      visible={visible}
+      onCancel={onClose}
+      onOk={onClose}
+      cancelText=''
+      okText={t('我已复制保存')}
+      width={650}
+      bodyStyle={{ padding: '20px 24px' }}
+    >
+      <Banner
+        type='warning'
+        description={t(
+          '新的客户端密钥如下,请立即复制保存。关闭此窗口后将无法再次查看。',
+        )}
+        className='mb-5'
+      />
+      <div className='bg-gray-50 p-4 rounded-lg border font-mono break-all'>
+        <Text
+          code
+          copyable={{
+            content: secret,
+            successTip: t('已复制到剪贴板'),
+          }}
+          style={{ fontSize: '13px', lineHeight: '1.5' }}
+        >
+          {secret}
+        </Text>
+      </div>
+      <div className='mt-3 p-3 bg-blue-50 border border-blue-200 rounded-md'>
+        <Text size='small' type='tertiary'>
+          💡 {t('请妥善保管此密钥,用于应用程序的身份验证')}
+        </Text>
+      </div>
+    </Modal>
+  );
+};
+
+export default SecretDisplayModal;

+ 87 - 0
web/src/components/settings/oauth2/modals/ServerInfoModal.jsx

@@ -0,0 +1,87 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useState, useEffect } from 'react';
+import { Modal, Typography } from '@douyinfe/semi-ui';
+import { API, showError } from '../../../../helpers';
+import { useTranslation } from 'react-i18next';
+
+const { Text } = Typography;
+
+const ServerInfoModal = ({ visible, onClose }) => {
+  const { t } = useTranslation();
+  const [loading, setLoading] = useState(false);
+  const [serverInfo, setServerInfo] = useState(null);
+
+  const loadServerInfo = async () => {
+    setLoading(true);
+    try {
+      const res = await API.get('/api/oauth/server-info');
+      setServerInfo(res.data);
+    } catch (error) {
+      showError(t('获取服务器信息失败'));
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    if (visible) {
+      loadServerInfo();
+    }
+  }, [visible]);
+
+  return (
+    <Modal
+      title={
+        <div className='flex items-center'>
+          <span>🖥️</span>
+          <Text strong className='ml-2'>
+            {t('OAuth2 服务器信息')}
+          </Text>
+        </div>
+      }
+      visible={visible}
+      onCancel={onClose}
+      onOk={onClose}
+      cancelText=''
+      okText={t('关闭')}
+      width={650}
+      bodyStyle={{ padding: '20px 24px' }}
+      confirmLoading={loading}
+    >
+      <pre
+        style={{
+          background: 'var(--semi-color-fill-0)',
+          padding: '16px',
+          borderRadius: '8px',
+          fontSize: '12px',
+          maxHeight: '400px',
+          overflow: 'auto',
+          border: '1px solid var(--semi-color-border)',
+          margin: 0,
+        }}
+      >
+        {serverInfo ? JSON.stringify(serverInfo, null, 2) : t('加载中...')}
+      </pre>
+    </Modal>
+  );
+};
+
+export default ServerInfoModal;

+ 11 - 4
web/src/components/table/channels/modals/EditChannelModal.jsx

@@ -802,7 +802,9 @@ const EditChannelModal = (props) => {
               delete localInputs.key;
             }
           } else {
-            localInputs.key = batch ? JSON.stringify(keys) : JSON.stringify(keys[0]);
+            localInputs.key = batch
+              ? JSON.stringify(keys)
+              : JSON.stringify(keys[0]);
           }
         }
       }
@@ -1198,7 +1200,10 @@ const EditChannelModal = (props) => {
                       value={inputs.vertex_key_type || 'json'}
                       onChange={(value) => {
                         // 更新设置中的 vertex_key_type
-                        handleChannelOtherSettingsChange('vertex_key_type', value);
+                        handleChannelOtherSettingsChange(
+                          'vertex_key_type',
+                          value,
+                        );
                         // 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件
                         if (value === 'api_key') {
                           setBatch(false);
@@ -1218,7 +1223,8 @@ const EditChannelModal = (props) => {
                     />
                   )}
                   {batch ? (
-                    inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
+                    inputs.type === 41 &&
+                    (inputs.vertex_key_type || 'json') === 'json' ? (
                       <Form.Upload
                         field='vertex_files'
                         label={t('密钥文件 (.json)')}
@@ -1282,7 +1288,8 @@ const EditChannelModal = (props) => {
                     )
                   ) : (
                     <>
-                      {inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
+                      {inputs.type === 41 &&
+                      (inputs.vertex_key_type || 'json') === 'json' ? (
                         <>
                           {!batch && (
                             <div className='flex items-center justify-between mb-3'>

+ 57 - 25
web/src/components/topup/RechargeCard.jsx

@@ -30,7 +30,8 @@ import {
   Space,
   Row,
   Col,
-  Spin, Tooltip
+  Spin,
+  Tooltip,
 } from '@douyinfe/semi-ui';
 import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
 import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react';
@@ -266,7 +267,8 @@ const RechargeCard = ({
                         {payMethods && payMethods.length > 0 ? (
                           <Space wrap>
                             {payMethods.map((payMethod) => {
-                              const minTopupVal = Number(payMethod.min_topup) || 0;
+                              const minTopupVal =
+                                Number(payMethod.min_topup) || 0;
                               const isStripe = payMethod.type === 'stripe';
                               const disabled =
                                 (!enableOnlineTopUp && !isStripe) ||
@@ -280,7 +282,9 @@ const RechargeCard = ({
                                   type='tertiary'
                                   onClick={() => preTopUp(payMethod.type)}
                                   disabled={disabled}
-                                  loading={paymentLoading && payWay === payMethod.type}
+                                  loading={
+                                    paymentLoading && payWay === payMethod.type
+                                  }
                                   icon={
                                     payMethod.type === 'alipay' ? (
                                       <SiAlipay size={18} color='#1677FF' />
@@ -291,7 +295,10 @@ const RechargeCard = ({
                                     ) : (
                                       <CreditCard
                                         size={18}
-                                        color={payMethod.color || 'var(--semi-color-text-2)'}
+                                        color={
+                                          payMethod.color ||
+                                          'var(--semi-color-text-2)'
+                                        }
                                       />
                                     )
                                   }
@@ -301,12 +308,22 @@ const RechargeCard = ({
                                 </Button>
                               );
 
-                              return disabled && minTopupVal > Number(topUpCount || 0) ? (
-                                <Tooltip content={t('此支付方式最低充值金额为') + ' ' + minTopupVal} key={payMethod.type}>
+                              return disabled &&
+                                minTopupVal > Number(topUpCount || 0) ? (
+                                <Tooltip
+                                  content={
+                                    t('此支付方式最低充值金额为') +
+                                    ' ' +
+                                    minTopupVal
+                                  }
+                                  key={payMethod.type}
+                                >
                                   {buttonEl}
                                 </Tooltip>
                               ) : (
-                                <React.Fragment key={payMethod.type}>{buttonEl}</React.Fragment>
+                                <React.Fragment key={payMethod.type}>
+                                  {buttonEl}
+                                </React.Fragment>
                               );
                             })}
                           </Space>
@@ -324,23 +341,27 @@ const RechargeCard = ({
                   <Form.Slot label={t('选择充值额度')}>
                     <div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
                       {presetAmounts.map((preset, index) => {
-                        const discount = preset.discount || topupInfo?.discount?.[preset.value] || 1.0;
+                        const discount =
+                          preset.discount ||
+                          topupInfo?.discount?.[preset.value] ||
+                          1.0;
                         const originalPrice = preset.value * priceRatio;
                         const discountedPrice = originalPrice * discount;
                         const hasDiscount = discount < 1.0;
                         const actualPay = discountedPrice;
                         const save = originalPrice - discountedPrice;
-                        
+
                         return (
                           <Card
                             key={index}
                             style={{
                               cursor: 'pointer',
-                              border: selectedPreset === preset.value 
-                                ? '2px solid var(--semi-color-primary)' 
-                                : '1px solid var(--semi-color-border)',
+                              border:
+                                selectedPreset === preset.value
+                                  ? '2px solid var(--semi-color-primary)'
+                                  : '1px solid var(--semi-color-border)',
                               height: '100%',
-                              width: '100%'
+                              width: '100%',
                             }}
                             bodyStyle={{ padding: '12px' }}
                             onClick={() => {
@@ -352,24 +373,35 @@ const RechargeCard = ({
                             }}
                           >
                             <div style={{ textAlign: 'center' }}>
-                              <Typography.Title heading={6} style={{ margin: '0 0 8px 0' }}>
+                              <Typography.Title
+                                heading={6}
+                                style={{ margin: '0 0 8px 0' }}
+                              >
                                 <Coins size={18} />
                                 {formatLargeNumber(preset.value)}
                                 {hasDiscount && (
-                                   <Tag style={{ marginLeft: 4 }} color="green">
-                                   {t('折').includes('off') ?
-                                     ((1 - parseFloat(discount)) * 100).toFixed(1) :
-                                     (discount * 10).toFixed(1)}{t('折')}
-                                 </Tag>
+                                  <Tag style={{ marginLeft: 4 }} color='green'>
+                                    {t('折').includes('off')
+                                      ? (
+                                          (1 - parseFloat(discount)) *
+                                          100
+                                        ).toFixed(1)
+                                      : (discount * 10).toFixed(1)}
+                                    {t('折')}
+                                  </Tag>
                                 )}
                               </Typography.Title>
-                              <div style={{ 
-                                color: 'var(--semi-color-text-2)', 
-                                fontSize: '12px', 
-                                margin: '4px 0' 
-                              }}>
+                              <div
+                                style={{
+                                  color: 'var(--semi-color-text-2)',
+                                  fontSize: '12px',
+                                  margin: '4px 0',
+                                }}
+                              >
                                 {t('实付')} {actualPay.toFixed(2)},
-                                {hasDiscount ? `${t('节省')} ${save.toFixed(2)}` : `${t('节省')} 0.00`}
+                                {hasDiscount
+                                  ? `${t('节省')} ${save.toFixed(2)}`
+                                  : `${t('节省')} 0.00`}
                               </div>
                             </div>
                           </Card>

+ 20 - 11
web/src/components/topup/index.jsx

@@ -80,11 +80,11 @@ const TopUp = () => {
   // 预设充值额度选项
   const [presetAmounts, setPresetAmounts] = useState([]);
   const [selectedPreset, setSelectedPreset] = useState(null);
-  
+
   // 充值配置信息
   const [topupInfo, setTopupInfo] = useState({
     amount_options: [],
-    discount: {}
+    discount: {},
   });
 
   const topUp = async () => {
@@ -262,9 +262,9 @@ const TopUp = () => {
       if (success) {
         setTopupInfo({
           amount_options: data.amount_options || [],
-          discount: data.discount || {}
+          discount: data.discount || {},
         });
-        
+
         // 处理支付方式
         let payMethods = data.pay_methods || [];
         try {
@@ -280,10 +280,15 @@ const TopUp = () => {
             payMethods = payMethods.map((method) => {
               // 规范化最小充值数
               const normalizedMinTopup = Number(method.min_topup);
-              method.min_topup = Number.isFinite(normalizedMinTopup) ? normalizedMinTopup : 0;
+              method.min_topup = Number.isFinite(normalizedMinTopup)
+                ? normalizedMinTopup
+                : 0;
 
               // Stripe 的最小充值从后端字段回填
-              if (method.type === 'stripe' && (!method.min_topup || method.min_topup <= 0)) {
+              if (
+                method.type === 'stripe' &&
+                (!method.min_topup || method.min_topup <= 0)
+              ) {
                 const stripeMin = Number(data.stripe_min_topup);
                 if (Number.isFinite(stripeMin)) {
                   method.min_topup = stripeMin;
@@ -313,7 +318,11 @@ const TopUp = () => {
           setPayMethods(payMethods);
           const enableStripeTopUp = data.enable_stripe_topup || false;
           const enableOnlineTopUp = data.enable_online_topup || false;
-          const minTopUpValue = enableOnlineTopUp? data.min_topup : enableStripeTopUp? data.stripe_min_topup : 1;
+          const minTopUpValue = enableOnlineTopUp
+            ? data.min_topup
+            : enableStripeTopUp
+              ? data.stripe_min_topup
+              : 1;
           setEnableOnlineTopUp(enableOnlineTopUp);
           setEnableStripeTopUp(enableStripeTopUp);
           setMinTopUp(minTopUpValue);
@@ -330,12 +339,12 @@ const TopUp = () => {
           console.log('解析支付方式失败:', e);
           setPayMethods([]);
         }
-        
+
         // 如果有自定义充值数量选项,使用它们替换默认的预设选项
         if (data.amount_options && data.amount_options.length > 0) {
-          const customPresets = data.amount_options.map(amount => ({
+          const customPresets = data.amount_options.map((amount) => ({
             value: amount,
-            discount: data.discount[amount] || 1.0
+            discount: data.discount[amount] || 1.0,
           }));
           setPresetAmounts(customPresets);
         }
@@ -483,7 +492,7 @@ const TopUp = () => {
   const selectPresetAmount = (preset) => {
     setTopUpCount(preset.value);
     setSelectedPreset(preset.value);
-    
+
     // 计算实际支付金额,考虑折扣
     const discount = preset.discount || topupInfo.discount[preset.value] || 1.0;
     const discountedAmount = preset.value * priceRatio * discount;

+ 4 - 3
web/src/components/topup/modals/PaymentConfirmModal.jsx

@@ -40,9 +40,10 @@ const PaymentConfirmModal = ({
   amountNumber,
   discountRate,
 }) => {
-  const hasDiscount = discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0;
-  const originalAmount = hasDiscount ? (amountNumber / discountRate) : 0;
-  const discountAmount = hasDiscount ? (originalAmount - amountNumber) : 0;
+  const hasDiscount =
+    discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0;
+  const originalAmount = hasDiscount ? amountNumber / discountRate : 0;
+  const discountAmount = hasDiscount ? originalAmount - amountNumber : 0;
   return (
     <Modal
       title={

+ 79 - 3
web/src/i18n/locales/en.json

@@ -444,7 +444,7 @@
   "其他设置": "Other Settings",
   "项目仓库地址": "Project Repository Address",
   "可在设置页面设置关于内容,支持 HTML & Markdown": "The About content can be set on the settings page, supporting HTML & Markdown",
-  "由": "developed by",
+  "由": "by",
   "开发,基于": "based on",
   "MIT 协议": "MIT License",
   "充值额度": "Recharge Quota",
@@ -828,7 +828,7 @@
   "删除所选令牌": "Delete selected token",
   "请先选择要删除的令牌!": "Please select the token to be deleted!",
   "已删除 {{count}} 个令牌!": "Deleted {{count}} tokens!",
-  "删除失败": "Delete failed",
+  "删除失败-oauth2clients": "Delete failed",
   "复制令牌": "Copy token",
   "请选择你的复制方式": "Please select your copy method",
   "名称+密钥": "Name + key",
@@ -2098,5 +2098,81 @@
   "原价": "Original price",
   "优惠": "Discount",
   "折": "% off",
-  "节省": "Save"
+  "节省": "Save",
+  "OAuth2 客户端管理": "OAuth2 Clients",
+  "加载OAuth2客户端失败": "Failed to load OAuth2 clients",
+  "删除成功": "Deleted successfully",
+  "删除失败": "Delete failed",
+  "重新生成密钥失败": "Failed to regenerate secret",
+  "OAuth2 服务器信息": "OAuth2 Server Info",
+  "授权服务器配置": "Authorization server configuration",
+  "获取服务器信息失败": "Failed to get server info",
+  "JWKS 信息": "JWKS Info",
+  "JSON Web Key Set": "JSON Web Key Set",
+  "获取JWKS失败": "Failed to get JWKS",
+  "客户端信息": "Client Info",
+  "机密客户端": "Confidential",
+  "公开客户端": "Public",
+  "客户端凭证": "Client Credentials",
+  "刷新令牌": "Refresh Token",
+  "编辑客户端": "Edit Client",
+  "确认重新生成客户端密钥?": "Confirm regenerating client secret?",
+  "客户端": "Client",
+  "操作不可撤销,旧密钥将立即失效。": "This action cannot be undone. The old secret will be invalid immediately.",
+  "确认": "Confirm",
+  "重新生成密钥": "Regenerate Secret",
+  "请再次确认删除该客户端": "Please confirm deleting this client",
+  "删除后无法恢复,相关 API 调用将立即失效。": "This operation cannot be undone. Related API calls will stop working immediately.",
+  "确定删除": "Confirm Delete",
+  "删除客户端": "Delete Client",
+  "管理OAuth2客户端应用程序,每个客户端代表一个可以访问API的应用程序。机密客户端用于服务器端应用,公开客户端用于移动应用或单页应用。": "Manage OAuth2 client applications. Each client represents an application that can access APIs. Confidential clients are for server-side apps; public clients are for mobile apps or SPAs.",
+  "搜索客户端名称、ID或描述": "Search client name, ID or description",
+  "服务器信息": "Server Info",
+  "查看JWKS": "View JWKS",
+  "创建客户端": "Create Client",
+  "第 {{start}}-{{end}} 条,共 {{total}} 条": "Items {{start}}-{{end}} of {{total}}",
+  "暂无OAuth2客户端": "No OAuth2 clients",
+  "还没有创建任何客户端,点击下方按钮创建第一个客户端": "No clients yet. Click the button below to create the first one.",
+  "创建第一个客户端": "Create first client",
+  "客户端密钥已重新生成": "Client secret regenerated",
+  "我已复制保存": "I have copied and saved",
+  "新的客户端密钥如下,请立即复制保存。关闭此窗口后将无法再次查看。": "The new client secret is shown below. Copy and save it now. You will not be able to view it again after closing this window.",
+  "OAuth2 & SSO 管理": "OAuth2 & SSO Management",
+  "运行正常": "Healthy",
+  "配置中": "Configuring",
+  "保存配置": "Save Configuration",
+  "密钥管理": "Key Management",
+  "打开密钥管理": "Open Key Management",
+  "⚠️ 尚未准备签名密钥,建议立即初始化或轮换以发布 JWKS。": "Signing key not prepared. Initialize or rotate to publish JWKS.",
+  "签名密钥用于 JWT 令牌的安全签发。": "Signing keys are used to securely issue JWT tokens.",
+  "启用 OAuth2 & SSO": "Enable OAuth2 & SSO",
+  "开启后将允许以 OAuth2/OIDC 标准进行授权与登录": "Enables OAuth2/OIDC standard based authorization and login",
+  "发行人 (Issuer)": "Issuer",
+  "为空则按请求自动推断(含 X-Forwarded-Proto)": "Leave empty to infer from request (including X-Forwarded-Proto)",
+  "令牌配置": "Token Settings",
+  "访问令牌有效期": "Access token TTL",
+  "访问令牌的有效时间,建议较短(10-60分钟)": "Access token lifetime. Recommended: short (10–60 minutes)",
+  "刷新令牌有效期": "Refresh token TTL",
+  "刷新令牌的有效时间,建议较长(12-720小时)": "Refresh token lifetime. Recommended: long (12–720 hours)",
+  "JWKS历史保留上限": "JWKS history retention limit",
+  "轮换后最多保留的历史签名密钥数量": "Max number of historical signing keys to keep after rotation",
+  "JWT签名算法": "JWT signing algorithm",
+  "JWT令牌的签名算法,推荐使用RS256": "Signing algorithm for JWT tokens. RS256 is recommended",
+  "JWT密钥ID": "JWT key ID",
+  "用于标识JWT签名密钥,支持密钥轮换": "Identifier for JWT signing key; supports key rotation",
+  "授权配置": "Authorization Settings",
+  "允许的授权类型": "Allowed grant types",
+  "选择允许的OAuth2授权流程": "Select allowed OAuth2 grant flows",
+  "Client Credentials(客户端凭证)": "Client Credentials",
+  "Authorization Code(授权码)": "Authorization Code",
+  "Refresh Token(刷新令牌)": "Refresh Token",
+  "强制PKCE验证": "Enforce PKCE",
+  "为授权码流程强制启用PKCE,提高安全性": "Enforce PKCE for authorization code flow to improve security",
+  "OAuth2 服务器提供标准的 API 认证与授权": "OAuth2 server provides standard API authentication and authorization",
+  "支持 Client Credentials、Authorization Code + PKCE 等标准流程": "Supports standard flows such as Client Credentials and Authorization Code + PKCE",
+  "配置保存后多数项即时生效;签名密钥轮换与 JWKS 发布为即时操作": "Most settings take effect immediately after saving; key rotation and JWKS publication are instantaneous",
+  "生产环境务必启用 HTTPS,并妥善管理 JWT 签名密钥": "Enable HTTPS in production and manage JWT signing keys properly",
+  "个客户端": "clients",
+  "请妥善保管此密钥,用于应用程序的身份验证": "Please keep this secret safe, it is used for application authentication",
+  "客户端名称": "Client Name"
 }

+ 72 - 19
web/src/pages/OAuth/Consent.jsx

@@ -18,7 +18,16 @@ For commercial licensing, please contact [email protected]
 */
 
 import React, { useEffect, useMemo, useState } from 'react';
-import { Card, Button, Typography, Spin, Banner, Avatar, Divider, Popover } from '@douyinfe/semi-ui';
+import {
+  Card,
+  Button,
+  Typography,
+  Spin,
+  Banner,
+  Avatar,
+  Divider,
+  Popover,
+} from '@douyinfe/semi-ui';
 import { Link, Dot, Key, User, Mail, Eye, Pencil, Shield } from 'lucide-react';
 import { useLocation } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
@@ -135,13 +144,21 @@ export default function OAuthConsent() {
         {loading ? (
           <Card className='text-center py-8'>
             <Spin size='large' />
-            <Text type='tertiary' className='block mt-4'>{t('加载授权信息中...')}</Text>
+            <Text type='tertiary' className='block mt-4'>
+              {t('加载授权信息中...')}
+            </Text>
           </Card>
         ) : error ? (
           <Card>
             <Banner
               type='warning'
-              description={error === 'login_required' ? t('请先登录后再继续授权。') : t('暂时无法加载授权信息')}
+              closeIcon={null}
+              className='!rounded-lg'
+              description={
+                error === 'login_required'
+                  ? t('请先登录后再继续授权。')
+                  : t('暂时无法加载授权信息')
+              }
             />
           </Card>
         ) : (
@@ -173,7 +190,9 @@ export default function OAuthConsent() {
                         {t('授权后将重定向到')}
                       </Text>
                       <Text type='tertiary' size='small' className='block'>
-                        {info?.redirect_uri?.length > 60 ? info.redirect_uri.slice(0, 60) + '...' : info?.redirect_uri}
+                        {info?.redirect_uri?.length > 60
+                          ? info.redirect_uri.slice(0, 60) + '...'
+                          : info?.redirect_uri}
                       </Text>
                     </div>
                   </div>
@@ -190,12 +209,20 @@ export default function OAuthConsent() {
                             {info?.client?.name || info?.client?.id}
                           </Text>
                           {info?.client?.desc && (
-                            <Text type='tertiary' size='small' className='block'>
+                            <Text
+                              type='tertiary'
+                              size='small'
+                              className='block'
+                            >
                               {info.client.desc}
                             </Text>
                           )}
                           {info?.client?.domain && (
-                            <Text type='tertiary' size='small' className='block mt-1'>
+                            <Text
+                              type='tertiary'
+                              size='small'
+                              className='block mt-1'
+                            >
                               {t('域名')}: {info.client.domain}
                             </Text>
                           )}
@@ -207,11 +234,15 @@ export default function OAuthConsent() {
                       <Avatar
                         size={36}
                         style={{
-                          backgroundColor: stringToColor(info?.client?.name || info?.client?.id || 'A'),
-                          cursor: 'pointer'
+                          backgroundColor: stringToColor(
+                            info?.client?.name || info?.client?.id || 'A',
+                          ),
+                          cursor: 'pointer',
                         }}
                       >
-                        {String(info?.client?.name || info?.client?.id || 'A').slice(0, 1).toUpperCase()}
+                        {String(info?.client?.name || info?.client?.id || 'A')
+                          .slice(0, 1)
+                          .toUpperCase()}
                       </Avatar>
                     </Popover>
                     {/* 链接图标 */}
@@ -232,8 +263,10 @@ export default function OAuthConsent() {
                       <div
                         className='w-full h-full rounded-full flex items-center justify-center'
                         style={{
-                          backgroundColor: stringToColor(window.location.hostname || 'S'),
-                          display: 'none'
+                          backgroundColor: stringToColor(
+                            window.location.hostname || 'S',
+                          ),
+                          display: 'none',
                         }}
                       >
                         <Text className='font-bold text-lg'>
@@ -254,12 +287,17 @@ export default function OAuthConsent() {
                   <div className='flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3'>
                     <div className='flex-1 min-w-0'>
                       <Text className='block text-sm sm:text-base'>
-                        <Text strong>{info?.client?.name || info?.client?.id}</Text>
-                        {' '}{t('由')}{' '}
-                        <Text strong>{info?.client?.domain || t('未知域')}</Text>
+                        <Text strong>
+                          {info?.client?.name || info?.client?.id}
+                        </Text>{' '}
+                        {t('由')}{' '}
+                        <Text strong>
+                          {info?.client?.domain || t('未知域')}
+                        </Text>
                       </Text>
                       <Text type='tertiary' size='small' className='block mt-1'>
-                        {t('想要访问你的')} <Text strong>{info?.user?.name || ''}</Text> {t('账户')}
+                        {t('想要访问你的')}{' '}
+                        <Text strong>{info?.user?.name || ''}</Text> {t('账户')}
                       </Text>
                     </div>
                     <Button
@@ -269,7 +307,10 @@ export default function OAuthConsent() {
                       className='w-full sm:w-auto flex-shrink-0'
                       onClick={() => {
                         const u = new URL(window.location.origin + '/login');
-                        u.searchParams.set('next', '/oauth/consent' + window.location.search);
+                        u.searchParams.set(
+                          'next',
+                          '/oauth/consent' + window.location.search,
+                        );
                         window.location.href = u.toString();
                       }}
                     >
@@ -306,13 +347,25 @@ export default function OAuthConsent() {
               <Card bordered={false}>
                 <div className='text-center'>
                   <div className='flex flex-wrap justify-center gap-x-2 gap-y-1 items-center'>
-                    <Text size='small'>{t('客户端ID')}: {info?.client?.id?.slice(-8) || 'N/A'}</Text>
+                    <Text size='small'>
+                      {t('客户端ID')}: {info?.client?.id?.slice(-8) || 'N/A'}
+                    </Text>
                     <Dot size={16} />
-                    <Text size='small'>{t('类型')}: {info?.client?.type === 'public' ? t('公开应用') : t('机密应用')}</Text>
+                    <Text size='small'>
+                      {t('类型')}:{' '}
+                      {info?.client?.type === 'public'
+                        ? t('公开应用')
+                        : t('机密应用')}
+                    </Text>
                     {info?.response_type && (
                       <>
                         <Dot size={16} />
-                        <Text size='small'>{t('授权类型')}: {info.response_type === 'code' ? t('授权码') : info.response_type}</Text>
+                        <Text size='small'>
+                          {t('授权类型')}:{' '}
+                          {info.response_type === 'code'
+                            ? t('授权码')
+                            : info.response_type}
+                        </Text>
                       </>
                     )}
                     {info?.require_pkce && (

+ 14 - 13
web/src/pages/Setting/Operation/SettingsGeneral.jsx

@@ -130,19 +130,20 @@ export default function GeneralSettings(props) {
                   showClear
                 />
               </Col>
-              {inputs.QuotaPerUnit !== '500000' && inputs.QuotaPerUnit !== 500000 && (
-                <Col xs={24} sm={12} md={8} lg={8} xl={8}>
-                  <Form.Input
-                    field={'QuotaPerUnit'}
-                    label={t('单位美元额度')}
-                    initValue={''}
-                    placeholder={t('一单位货币能兑换的额度')}
-                    onChange={handleFieldChange('QuotaPerUnit')}
-                    showClear
-                    onClick={() => setShowQuotaWarning(true)}
-                  />
-                </Col>
-              )}
+              {inputs.QuotaPerUnit !== '500000' &&
+                inputs.QuotaPerUnit !== 500000 && (
+                  <Col xs={24} sm={12} md={8} lg={8} xl={8}>
+                    <Form.Input
+                      field={'QuotaPerUnit'}
+                      label={t('单位美元额度')}
+                      initValue={''}
+                      placeholder={t('一单位货币能兑换的额度')}
+                      onChange={handleFieldChange('QuotaPerUnit')}
+                      showClear
+                      onClick={() => setShowQuotaWarning(true)}
+                    />
+                  </Col>
+                )}
               <Col xs={24} sm={12} md={8} lg={8} xl={8}>
                 <Form.Input
                   field={'USDExchangeRate'}

+ 2 - 1
web/src/pages/Setting/Operation/SettingsMonitoring.jsx

@@ -128,7 +128,8 @@ export default function SettingsMonitoring(props) {
                   onChange={(value) =>
                     setInputs({
                       ...inputs,
-                      'monitor_setting.auto_test_channel_minutes': parseInt(value),
+                      'monitor_setting.auto_test_channel_minutes':
+                        parseInt(value),
                     })
                   }
                 />

+ 31 - 11
web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx

@@ -118,14 +118,20 @@ export default function SettingsPaymentGateway(props) {
       }
     }
 
-    if (originInputs['AmountOptions'] !== inputs.AmountOptions && inputs.AmountOptions.trim() !== '') {
+    if (
+      originInputs['AmountOptions'] !== inputs.AmountOptions &&
+      inputs.AmountOptions.trim() !== ''
+    ) {
       if (!verifyJSON(inputs.AmountOptions)) {
         showError(t('自定义充值数量选项不是合法的 JSON 数组'));
         return;
       }
     }
 
-    if (originInputs['AmountDiscount'] !== inputs.AmountDiscount && inputs.AmountDiscount.trim() !== '') {
+    if (
+      originInputs['AmountDiscount'] !== inputs.AmountDiscount &&
+      inputs.AmountDiscount.trim() !== ''
+    ) {
       if (!verifyJSON(inputs.AmountDiscount)) {
         showError(t('充值金额折扣配置不是合法的 JSON 对象'));
         return;
@@ -163,10 +169,16 @@ export default function SettingsPaymentGateway(props) {
         options.push({ key: 'PayMethods', value: inputs.PayMethods });
       }
       if (originInputs['AmountOptions'] !== inputs.AmountOptions) {
-        options.push({ key: 'payment_setting.amount_options', value: inputs.AmountOptions });
+        options.push({
+          key: 'payment_setting.amount_options',
+          value: inputs.AmountOptions,
+        });
       }
       if (originInputs['AmountDiscount'] !== inputs.AmountDiscount) {
-        options.push({ key: 'payment_setting.amount_discount', value: inputs.AmountDiscount });
+        options.push({
+          key: 'payment_setting.amount_discount',
+          value: inputs.AmountDiscount,
+        });
       }
 
       // 发送请求
@@ -273,7 +285,7 @@ export default function SettingsPaymentGateway(props) {
             placeholder={t('为一个 JSON 文本')}
             autosize
           />
-          
+
           <Row
             gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
             style={{ marginTop: 16 }}
@@ -282,13 +294,17 @@ export default function SettingsPaymentGateway(props) {
               <Form.TextArea
                 field='AmountOptions'
                 label={t('自定义充值数量选项')}
-                placeholder={t('为一个 JSON 数组,例如:[10, 20, 50, 100, 200, 500]')}
+                placeholder={t(
+                  '为一个 JSON 数组,例如:[10, 20, 50, 100, 200, 500]',
+                )}
                 autosize
-                extraText={t('设置用户可选择的充值数量选项,例如:[10, 20, 50, 100, 200, 500]')}
+                extraText={t(
+                  '设置用户可选择的充值数量选项,例如:[10, 20, 50, 100, 200, 500]',
+                )}
               />
             </Col>
           </Row>
-          
+
           <Row
             gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
             style={{ marginTop: 16 }}
@@ -297,13 +313,17 @@ export default function SettingsPaymentGateway(props) {
               <Form.TextArea
                 field='AmountDiscount'
                 label={t('充值金额折扣配置')}
-                placeholder={t('为一个 JSON 对象,例如:{"100": 0.95, "200": 0.9, "500": 0.85}')}
+                placeholder={t(
+                  '为一个 JSON 对象,例如:{"100": 0.95, "200": 0.9, "500": 0.85}',
+                )}
                 autosize
-                extraText={t('设置不同充值金额对应的折扣,键为充值金额,值为折扣率,例如:{"100": 0.95, "200": 0.9, "500": 0.85}')}
+                extraText={t(
+                  '设置不同充值金额对应的折扣,键为充值金额,值为折扣率,例如:{"100": 0.95, "200": 0.9, "500": 0.85}',
+                )}
               />
             </Col>
           </Row>
-          
+
           <Button onClick={submitPayAddress}>{t('更新支付设置')}</Button>
         </Form.Section>
       </Form>