mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(llm): OAuth device authorization grant for ChatGPT remote deploy
This commit is contained in:
parent
eb64556636
commit
be2235a493
@ -6,6 +6,9 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.llm.oauth.OpenAIDeviceCodeService;
|
||||
import vip.mate.llm.oauth.OpenAIDeviceCodeService.DeviceCodePollResult;
|
||||
import vip.mate.llm.oauth.OpenAIDeviceCodeService.DeviceCodeStartResult;
|
||||
import vip.mate.llm.oauth.OpenAIOAuthService;
|
||||
import vip.mate.llm.oauth.OpenAIOAuthService.OAuthAuthorizeResult;
|
||||
import vip.mate.llm.oauth.OpenAIOAuthService.OAuthStatusResult;
|
||||
@ -17,6 +20,7 @@ import vip.mate.llm.oauth.OpenAIOAuthService.OAuthStatusResult;
|
||||
public class OAuthController {
|
||||
|
||||
private final OpenAIOAuthService oauthService;
|
||||
private final OpenAIDeviceCodeService deviceCodeService;
|
||||
|
||||
@Operation(summary = "获取 OAuth 授权 URL(自动选 LOCAL / MANUAL_PASTE 模式)")
|
||||
@GetMapping("/authorize")
|
||||
@ -46,6 +50,28 @@ public class OAuthController {
|
||||
/** Request body for {@link #callbackPaste(PasteRequest)}. */
|
||||
public record PasteRequest(String callbackUrl) {}
|
||||
|
||||
@Operation(summary = "Device flow: start — request user_code")
|
||||
@PostMapping("/device/start")
|
||||
public R<DeviceCodeStartResult> deviceStart() {
|
||||
return R.ok(deviceCodeService.start());
|
||||
}
|
||||
|
||||
@Operation(summary = "Device flow: poll for completion")
|
||||
@PostMapping("/device/poll")
|
||||
public R<DeviceCodePollResult> devicePoll(@RequestBody DeviceRequest request) {
|
||||
return R.ok(deviceCodeService.poll(request.deviceAuthId()));
|
||||
}
|
||||
|
||||
@Operation(summary = "Device flow: cancel a pending session")
|
||||
@PostMapping("/device/cancel")
|
||||
public R<Void> deviceCancel(@RequestBody DeviceRequest request) {
|
||||
deviceCodeService.cancel(request.deviceAuthId());
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/** Request body for {@link #devicePoll} / {@link #deviceCancel}. */
|
||||
public record DeviceRequest(String deviceAuthId) {}
|
||||
|
||||
@Operation(summary = "手动刷新 Token")
|
||||
@PostMapping("/refresh")
|
||||
public R<Void> refresh() {
|
||||
|
||||
@ -0,0 +1,286 @@
|
||||
package vip.mate.llm.oauth;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
import vip.mate.exception.MateClawException;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* OAuth 2.0 Device Authorization Grant (RFC 8628) for the OpenAI Codex CLI client_id.
|
||||
*
|
||||
* <p>Used when MateClaw runs on a remote host and the browser cannot reach
|
||||
* {@code localhost:1455}. Authorization happens entirely on
|
||||
* {@code auth.openai.com}; we just poll a server-side endpoint until OpenAI hands
|
||||
* us an authorization code, then delegate the token exchange to
|
||||
* {@link OpenAIOAuthService#exchangeTokenWithVerifier}.
|
||||
*
|
||||
* <p>Flow:
|
||||
* <ol>
|
||||
* <li>{@link #start()} POSTs {@code client_id} to {@code /api/accounts/deviceauth/usercode}
|
||||
* and returns {@code user_code} + verification URL for the user to open in any
|
||||
* browser.</li>
|
||||
* <li>The frontend polls {@link #poll(String)} every {@code interval} seconds.
|
||||
* Each call POSTs {@code device_auth_id + user_code} to
|
||||
* {@code /api/accounts/deviceauth/token}; OpenAI returns
|
||||
* {@code authorization_pending} until the user authorizes, then returns
|
||||
* {@code authorization_code + code_verifier}.</li>
|
||||
* <li>On COMPLETED we exchange the code for tokens at {@code /oauth/token} using
|
||||
* the device redirect URI {@code https://auth.openai.com/deviceauth/callback}
|
||||
* and persist via the shared save path.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Sessions are kept in-memory; multi-instance deployments need sticky sessions
|
||||
* until a Redis-backed store is introduced.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OpenAIDeviceCodeService {
|
||||
|
||||
static final String CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
static final String DEVICE_USERCODE_URL =
|
||||
"https://auth.openai.com/api/accounts/deviceauth/usercode";
|
||||
static final String DEVICE_TOKEN_URL =
|
||||
"https://auth.openai.com/api/accounts/deviceauth/token";
|
||||
static final String DEVICE_REDIRECT_URI =
|
||||
"https://auth.openai.com/deviceauth/callback";
|
||||
static final String DEFAULT_VERIFICATION_URL =
|
||||
"https://auth.openai.com/codex/device";
|
||||
static final String DEFAULT_USER_AGENT = "codex_cli_rs/0.7.0";
|
||||
|
||||
private final OpenAIOAuthService oauthService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private RestClient restClient = RestClient.create();
|
||||
|
||||
@Value("${mateclaw.oauth.openai.device.poll-min-interval-ms:3000}")
|
||||
private long pollMinIntervalMs;
|
||||
|
||||
@Value("${mateclaw.oauth.openai.device.session-ttl-seconds:900}")
|
||||
private long defaultSessionTtlSeconds;
|
||||
|
||||
@Value("${mateclaw.oauth.openai.device.user-agent:" + DEFAULT_USER_AGENT + "}")
|
||||
private String userAgent;
|
||||
|
||||
private final ConcurrentHashMap<String, DeviceCodeSession> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
/** Test seam — replace the RestClient (e.g. with a WireMock-pointed instance). */
|
||||
void setRestClient(RestClient restClient) {
|
||||
this.restClient = restClient;
|
||||
}
|
||||
|
||||
/** Step 1: request a user_code and device_auth_id. */
|
||||
public DeviceCodeStartResult start() {
|
||||
String body = "client_id=" + enc(CLIENT_ID);
|
||||
JsonNode resp;
|
||||
try {
|
||||
String raw = restClient.post()
|
||||
.uri(DEVICE_USERCODE_URL)
|
||||
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||
.header(HttpHeaders.USER_AGENT, userAgent)
|
||||
.body(body)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
resp = objectMapper.readTree(raw);
|
||||
} catch (Exception e) {
|
||||
log.error("Device code start failed: {}", e.getMessage());
|
||||
throw new MateClawException("err.llm.device_code_start_failed",
|
||||
"Device code 申请失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
String deviceAuthId = resp.path("device_auth_id").asText(null);
|
||||
String userCode = resp.path("user_code").asText(null);
|
||||
int interval = resp.path("interval").asInt(5);
|
||||
int expiresIn = resp.path("expires_in").asInt((int) defaultSessionTtlSeconds);
|
||||
String verificationUrl = resp.path("verification_uri").asText(DEFAULT_VERIFICATION_URL);
|
||||
String verificationUrlComplete = resp.path("verification_uri_complete").asText(null);
|
||||
|
||||
if (deviceAuthId == null || userCode == null) {
|
||||
throw new MateClawException("err.llm.device_code_start_failed",
|
||||
"Device code 响应缺少必要字段");
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
sessions.put(deviceAuthId, new DeviceCodeSession(
|
||||
deviceAuthId, userCode,
|
||||
now + expiresIn * 1000L,
|
||||
now));
|
||||
|
||||
log.info("Device code session started: deviceAuthId prefix={}, expires_in={}s",
|
||||
deviceAuthId.substring(0, Math.min(8, deviceAuthId.length())), expiresIn);
|
||||
|
||||
return new DeviceCodeStartResult(deviceAuthId, userCode, verificationUrl,
|
||||
verificationUrlComplete, interval, expiresIn);
|
||||
}
|
||||
|
||||
/** Step 2: poll OpenAI for completion. Returns PENDING / COMPLETED / EXPIRED. */
|
||||
public DeviceCodePollResult poll(String deviceAuthId) {
|
||||
if (deviceAuthId == null || deviceAuthId.isBlank()) {
|
||||
return DeviceCodePollResult.expired();
|
||||
}
|
||||
DeviceCodeSession session = sessions.get(deviceAuthId);
|
||||
if (session == null) {
|
||||
return DeviceCodePollResult.expired();
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (now > session.expiresAt()) {
|
||||
sessions.remove(deviceAuthId);
|
||||
return DeviceCodePollResult.expired();
|
||||
}
|
||||
|
||||
// Rate-limit: refuse to hammer OpenAI faster than the configured floor.
|
||||
if (now - session.lastPollAt() < pollMinIntervalMs) {
|
||||
return DeviceCodePollResult.pending();
|
||||
}
|
||||
sessions.put(deviceAuthId, session.withLastPollAt(now));
|
||||
|
||||
String body = "device_auth_id=" + enc(deviceAuthId)
|
||||
+ "&user_code=" + enc(session.userCode());
|
||||
|
||||
String responseBody;
|
||||
try {
|
||||
responseBody = restClient.post()
|
||||
.uri(DEVICE_TOKEN_URL)
|
||||
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||
.header(HttpHeaders.USER_AGENT, userAgent)
|
||||
.body(body)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
} catch (RestClientResponseException e) {
|
||||
return classifyError(deviceAuthId, e.getStatusCode().value(), e.getResponseBodyAsString());
|
||||
} catch (Exception e) {
|
||||
log.warn("Device code poll transport failure (will retry): {}", e.getMessage());
|
||||
return DeviceCodePollResult.pending();
|
||||
}
|
||||
|
||||
JsonNode resp;
|
||||
try {
|
||||
resp = objectMapper.readTree(responseBody);
|
||||
} catch (Exception e) {
|
||||
log.warn("Device code poll: malformed JSON, treating as pending");
|
||||
return DeviceCodePollResult.pending();
|
||||
}
|
||||
|
||||
String authorizationCode = resp.path("authorization_code").asText(null);
|
||||
String codeVerifier = resp.path("code_verifier").asText(null);
|
||||
if (authorizationCode == null) {
|
||||
// Some responses use 200 with an inline {error: authorization_pending}.
|
||||
String error = resp.path("error").asText(null);
|
||||
if ("expired_token".equals(error) || "access_denied".equals(error)) {
|
||||
sessions.remove(deviceAuthId);
|
||||
return DeviceCodePollResult.expired();
|
||||
}
|
||||
return DeviceCodePollResult.pending();
|
||||
}
|
||||
if (codeVerifier == null) {
|
||||
log.warn("Device code response missing code_verifier; cannot exchange token");
|
||||
sessions.remove(deviceAuthId);
|
||||
return DeviceCodePollResult.expired();
|
||||
}
|
||||
|
||||
try {
|
||||
oauthService.exchangeTokenWithVerifier(authorizationCode, codeVerifier, DEVICE_REDIRECT_URI);
|
||||
sessions.remove(deviceAuthId);
|
||||
log.info("Device code session completed: deviceAuthId prefix={}",
|
||||
deviceAuthId.substring(0, Math.min(8, deviceAuthId.length())));
|
||||
return DeviceCodePollResult.completed();
|
||||
} catch (Exception e) {
|
||||
sessions.remove(deviceAuthId);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller-driven cancellation: drop the session so we stop polling. */
|
||||
public void cancel(String deviceAuthId) {
|
||||
if (deviceAuthId != null) {
|
||||
sessions.remove(deviceAuthId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sweep expired sessions every 5 minutes. */
|
||||
@Scheduled(fixedDelay = 300_000L)
|
||||
void cleanupExpiredSessions() {
|
||||
long now = System.currentTimeMillis();
|
||||
sessions.entrySet().removeIf(entry -> now > entry.getValue().expiresAt());
|
||||
}
|
||||
|
||||
private DeviceCodePollResult classifyError(String deviceAuthId, int status, String body) {
|
||||
String error = extractErrorCode(body);
|
||||
if ("authorization_pending".equals(error) || "slow_down".equals(error)) {
|
||||
return DeviceCodePollResult.pending();
|
||||
}
|
||||
if ("expired_token".equals(error) || "access_denied".equals(error)) {
|
||||
sessions.remove(deviceAuthId);
|
||||
return DeviceCodePollResult.expired();
|
||||
}
|
||||
log.warn("Device code poll unexpected error: status={}, error={}, body={}",
|
||||
status, error, body);
|
||||
// Conservative: keep the session alive so the user can retry; expiry will catch it.
|
||||
return DeviceCodePollResult.pending();
|
||||
}
|
||||
|
||||
private String extractErrorCode(String body) {
|
||||
if (body == null || body.isBlank()) return null;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(body);
|
||||
String error = node.path("error").asText(null);
|
||||
if (error != null) return error;
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
// Fallback for non-JSON bodies — match well-known error tokens defensively.
|
||||
if (body.contains("authorization_pending")) return "authorization_pending";
|
||||
if (body.contains("slow_down")) return "slow_down";
|
||||
if (body.contains("expired_token")) return "expired_token";
|
||||
if (body.contains("access_denied")) return "access_denied";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String enc(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
int activeSessionCount() {
|
||||
return sessions.size();
|
||||
}
|
||||
|
||||
private record DeviceCodeSession(
|
||||
String deviceAuthId,
|
||||
String userCode,
|
||||
long expiresAt,
|
||||
long lastPollAt) {
|
||||
|
||||
DeviceCodeSession withLastPollAt(long now) {
|
||||
return new DeviceCodeSession(deviceAuthId, userCode, expiresAt, now);
|
||||
}
|
||||
}
|
||||
|
||||
public record DeviceCodeStartResult(
|
||||
String deviceAuthId,
|
||||
String userCode,
|
||||
String verificationUrl,
|
||||
String verificationUrlComplete,
|
||||
int intervalSeconds,
|
||||
int expiresInSeconds) {
|
||||
}
|
||||
|
||||
public record DeviceCodePollResult(Status status) {
|
||||
public enum Status { PENDING, COMPLETED, EXPIRED }
|
||||
|
||||
public static DeviceCodePollResult pending() { return new DeviceCodePollResult(Status.PENDING); }
|
||||
public static DeviceCodePollResult completed() { return new DeviceCodePollResult(Status.COMPLETED); }
|
||||
public static DeviceCodePollResult expired() { return new DeviceCodePollResult(Status.EXPIRED); }
|
||||
}
|
||||
}
|
||||
@ -28,26 +28,36 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* OpenAI OAuth 服务 — 基于 PKCE 的 OAuth 2.0 流程。
|
||||
* <p>
|
||||
* <strong>双模式</strong>(issue: server 部署回调失败 — Linux 部署后浏览器无法连
|
||||
* localhost:1455 因为它不是 server 的 localhost):
|
||||
* OpenAI OAuth service — supports three flow modes for the same Codex CLI client_id.
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>LOCAL</b>(桌面 / 本机部署):在 localhost:1455 启动临时 HTTP 服务器,浏览器
|
||||
* 自动 callback。OpenAI 的 Codex CLI client_id 注册的 redirect_uri 就是这个,
|
||||
* 所以无法换成公网 URL。</li>
|
||||
* <li><b>MANUAL_PASTE</b>(远程 server 部署):不启动 localhost server。浏览器登录后
|
||||
* 会跳到 localhost:1455/auth/callback 但因为没人监听会报 ERR_CONNECTION_REFUSED;
|
||||
* 此时 URL 栏里已经有 ?code=...&state=... 参数。让用户复制整个 URL 粘贴回 MateClaw
|
||||
* UI,后端通过 {@link #completeFromPastedUrl} 解析 code 完成 token 交换。</li>
|
||||
* <li><b>LOCAL</b>: Authorization Code + PKCE with a temporary HTTP server bound on
|
||||
* 127.0.0.1:1455 to receive the callback. Used when MateClaw is reached via
|
||||
* localhost — the browser can hit our loopback callback. The Codex CLI client_id
|
||||
* only accepts {@code http://localhost:1455/auth/callback} as redirect_uri, so
|
||||
* this path is impossible for remote deployments.</li>
|
||||
* <li><b>DEVICE_CODE</b>: OAuth 2.0 Device Authorization Grant (RFC 8628) — the
|
||||
* authorization happens entirely inside {@code auth.openai.com}; no callback
|
||||
* server is needed. This is the default for remote deployments. Token exchange
|
||||
* still goes through {@link #exchangeTokenWithVerifier} so downstream
|
||||
* persistence and refresh logic stay identical to the PKCE path. Driven by
|
||||
* {@code OpenAIDeviceCodeService}.</li>
|
||||
* <li><b>MANUAL_PASTE</b>: graceful fallback when LOCAL bind fails or DEVICE_CODE
|
||||
* endpoints are unavailable. The user copies the (unreachable) callback URL
|
||||
* from the browser address bar and pastes it back; we parse {@code code} and
|
||||
* {@code state} from the query string and complete the exchange.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>模式选择:
|
||||
* <p>Mode selection:
|
||||
* <ol>
|
||||
* <li>config {@code mateclaw.oauth.openai.deployment-mode = local | server | auto}(默认 auto)</li>
|
||||
* <li>auto 模式按 Host header 判定(localhost / 127.0.0.1 / ::1 → LOCAL,其它 → SERVER)</li>
|
||||
* <li>LOCAL 模式 bind 失败时自动降级到 MANUAL_PASTE</li>
|
||||
* <li>Config override {@code mateclaw.oauth.openai.deployment-mode}: one of
|
||||
* {@code local} / {@code device_code} / {@code manual_paste} / {@code auto}
|
||||
* (default). Alias: {@code server} maps to {@code device_code} for
|
||||
* compatibility with older configs that pre-date device code support.</li>
|
||||
* <li>{@code auto} dispatches by Host header: localhost / 127.0.0.1 / ::1 → LOCAL,
|
||||
* any other host → DEVICE_CODE.</li>
|
||||
* <li>LOCAL bind failure degrades to MANUAL_PASTE so the user always has a path
|
||||
* forward.</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Slf4j
|
||||
@ -75,12 +85,14 @@ public class OpenAIOAuthService {
|
||||
private volatile HttpServer activeCallbackServer;
|
||||
|
||||
/**
|
||||
* OAuth flow 模式 — 决定是自动 callback 还是用户手动粘贴 URL 完成。
|
||||
* OAuth flow mode — selects how the authorization code reaches the backend.
|
||||
*/
|
||||
public enum OAuthFlowMode {
|
||||
/** 启动 localhost:1455 临时 server,浏览器自动 callback */
|
||||
/** Authorization Code + PKCE with a temporary localhost:1455 callback server. */
|
||||
LOCAL,
|
||||
/** 不启动 server;用户手动复制 callback URL 回粘到 UI */
|
||||
/** Device Authorization Grant (RFC 8628) — no callback server, polling-based. */
|
||||
DEVICE_CODE,
|
||||
/** User pastes the callback URL back into the UI. Last-resort fallback. */
|
||||
MANUAL_PASTE
|
||||
}
|
||||
|
||||
@ -92,19 +104,22 @@ public class OpenAIOAuthService {
|
||||
* @param requestHost 来自 controller 的 Host header(可空 → 默认 LOCAL 行为)
|
||||
*/
|
||||
public OAuthAuthorizeResult buildAuthorizeUrl(String requestHost) {
|
||||
OAuthFlowMode mode = resolveFlowMode(requestHost);
|
||||
|
||||
// DEVICE_CODE flow does not produce an authorize URL or PKCE state here — the
|
||||
// frontend sees mode=DEVICE_CODE and calls OpenAIDeviceCodeService directly.
|
||||
if (mode == OAuthFlowMode.DEVICE_CODE) {
|
||||
return new OAuthAuthorizeResult("", "", mode);
|
||||
}
|
||||
|
||||
String codeVerifier = generateCodeVerifier();
|
||||
String codeChallenge = generateCodeChallenge(codeVerifier);
|
||||
String state = generateState();
|
||||
|
||||
pendingStates.put(state, codeVerifier);
|
||||
|
||||
// 决定 flow mode + 尝试启动 localhost server
|
||||
OAuthFlowMode mode = resolveFlowMode(requestHost);
|
||||
boolean serverStarted = false;
|
||||
if (mode == OAuthFlowMode.LOCAL) {
|
||||
serverStarted = startCallbackServer(state);
|
||||
boolean serverStarted = startCallbackServer(state);
|
||||
if (!serverStarted) {
|
||||
// bind 失败 → 优雅降级到 MANUAL_PASTE 而非整体失败
|
||||
log.warn("Callback server bind failed on port {} — degrading to MANUAL_PASTE flow",
|
||||
CALLBACK_PORT);
|
||||
mode = OAuthFlowMode.MANUAL_PASTE;
|
||||
@ -139,26 +154,27 @@ public class OpenAIOAuthService {
|
||||
* MateClaw on the same machine they'll do the OAuth login on — LOCAL works.
|
||||
* Any other host (a domain, a public IP, a private LAN IP) means the user's
|
||||
* browser cannot resolve {@code localhost:1455} to MateClaw's server, so we
|
||||
* must use MANUAL_PASTE.
|
||||
* use DEVICE_CODE (browser-agnostic, no callback server needed).
|
||||
*
|
||||
* <p>Config override values: {@code local} / {@code device_code} /
|
||||
* {@code manual_paste} / {@code auto}. {@code server} is kept as an alias for
|
||||
* {@code device_code} so older configs do not break.
|
||||
*/
|
||||
private OAuthFlowMode resolveFlowMode(String requestHost) {
|
||||
// Explicit config override
|
||||
OAuthFlowMode resolveFlowMode(String requestHost) {
|
||||
String configMode = System.getProperty("mateclaw.oauth.openai.deployment-mode",
|
||||
System.getenv("MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE"));
|
||||
if (configMode != null) {
|
||||
String norm = configMode.trim().toLowerCase();
|
||||
if ("local".equals(norm)) return OAuthFlowMode.LOCAL;
|
||||
if ("server".equals(norm) || "manual_paste".equals(norm)) return OAuthFlowMode.MANUAL_PASTE;
|
||||
if ("device_code".equals(norm) || "server".equals(norm)) return OAuthFlowMode.DEVICE_CODE;
|
||||
if ("manual_paste".equals(norm)) return OAuthFlowMode.MANUAL_PASTE;
|
||||
// "auto" / unknown → fall through to heuristic
|
||||
}
|
||||
|
||||
// Heuristic from Host header
|
||||
if (requestHost == null || requestHost.isBlank()) {
|
||||
// No host info available — assume LOCAL (matches legacy behaviour)
|
||||
return OAuthFlowMode.LOCAL;
|
||||
}
|
||||
String host = requestHost.toLowerCase();
|
||||
// Strip port if present
|
||||
int colon = host.lastIndexOf(':');
|
||||
if (colon > 0 && host.charAt(0) != '[') { // not IPv6
|
||||
host = host.substring(0, colon);
|
||||
@ -169,7 +185,7 @@ public class OpenAIOAuthService {
|
||||
|| "[::1]".equals(host)) {
|
||||
return OAuthFlowMode.LOCAL;
|
||||
}
|
||||
return OAuthFlowMode.MANUAL_PASTE;
|
||||
return OAuthFlowMode.DEVICE_CODE;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -321,22 +337,37 @@ public class OpenAIOAuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 authorization code 换取 token(内部调用,由回调服务器触发)
|
||||
* Exchange an authorization code (from either PKCE callback or device flow) for
|
||||
* tokens at {@code /oauth/token}. Shared by both flows so persistence and JWT
|
||||
* parsing stay in one place.
|
||||
*
|
||||
* @param code authorization code
|
||||
* @param codeVerifier PKCE verifier — for LOCAL/MANUAL_PASTE this is the value
|
||||
* stashed in {@link #pendingStates} during authorize-URL
|
||||
* generation; for DEVICE_CODE this comes back as part of
|
||||
* the device-auth poll response
|
||||
* @param redirectUri redirect_uri presented during authorize — must match the
|
||||
* value the original authorize call used. {@link #REDIRECT_URI}
|
||||
* for PKCE; {@code https://auth.openai.com/deviceauth/callback}
|
||||
* for device flow.
|
||||
*/
|
||||
void exchangeTokenWithVerifier(String code, String codeVerifier, String redirectUri) {
|
||||
String body = "grant_type=authorization_code"
|
||||
+ "&client_id=" + enc(CLIENT_ID)
|
||||
+ "&code=" + enc(code)
|
||||
+ "&code_verifier=" + enc(codeVerifier)
|
||||
+ "&redirect_uri=" + enc(redirectUri);
|
||||
JsonNode tokenResponse = postTokenRequest(body);
|
||||
saveTokens(tokenResponse);
|
||||
}
|
||||
|
||||
/** PKCE callback path — looks up the verifier by state and delegates. */
|
||||
private void exchangeToken(String code, String state) {
|
||||
String codeVerifier = pendingStates.remove(state);
|
||||
if (codeVerifier == null) {
|
||||
throw new MateClawException("err.llm.oauth_state_invalid", "无效的 OAuth state,可能已过期或重复使用");
|
||||
}
|
||||
|
||||
String body = "grant_type=authorization_code"
|
||||
+ "&client_id=" + enc(CLIENT_ID)
|
||||
+ "&code=" + enc(code)
|
||||
+ "&code_verifier=" + enc(codeVerifier)
|
||||
+ "&redirect_uri=" + enc(REDIRECT_URI);
|
||||
|
||||
JsonNode tokenResponse = postTokenRequest(body);
|
||||
saveTokens(tokenResponse);
|
||||
exchangeTokenWithVerifier(code, codeVerifier, REDIRECT_URI);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -230,6 +230,7 @@ err.llm.oauth_exchange_failed=OAuth token \u4ea4\u6362\u5931\u8d25
|
||||
err.llm.oauth_no_token=OAuth \u54cd\u5e94\u4e2d\u7f3a\u5c11 access_token
|
||||
err.llm.chatgpt_not_configured=ChatGPT provider \u672a\u914d\u7f6e
|
||||
err.llm.pkce_failed=PKCE \u751f\u6210\u5931\u8d25
|
||||
err.llm.device_code_start_failed=Device code \u7533\u8bf7\u5931\u8d25
|
||||
err.llm.chatgpt_stream_failed=ChatGPT \u6d41\u5f0f\u8c03\u7528\u5931\u8d25
|
||||
err.llm.chatgpt_error=ChatGPT \u8fd4\u56de\u9519\u8bef
|
||||
err.llm.chatgpt_account_missing=chatgpt-account-id \u7f3a\u5931
|
||||
|
||||
@ -242,6 +242,7 @@ err.llm.oauth_exchange_failed=OAuth token exchange failed
|
||||
err.llm.oauth_no_token=access_token missing in OAuth response
|
||||
err.llm.chatgpt_not_configured=ChatGPT provider not configured, check database initialization
|
||||
err.llm.pkce_failed=PKCE code_challenge generation failed
|
||||
err.llm.device_code_start_failed=Device code request failed
|
||||
err.llm.chatgpt_stream_failed=ChatGPT streaming call failed
|
||||
err.llm.chatgpt_error=ChatGPT returned an error
|
||||
err.llm.chatgpt_account_missing=chatgpt-account-id missing, disconnect and re-login via OAuth
|
||||
|
||||
@ -472,6 +472,15 @@ export const oauthApi = {
|
||||
status: () => http.get('/oauth/openai/status'),
|
||||
refresh: () => http.post('/oauth/openai/refresh'),
|
||||
revoke: () => http.delete('/oauth/openai/revoke'),
|
||||
callbackPaste: (callbackUrl: string) =>
|
||||
http.post('/oauth/openai/callback-paste', { callbackUrl }),
|
||||
// Device Authorization Grant — used when MateClaw runs on a remote host so the
|
||||
// browser cannot reach localhost:1455 for the PKCE callback.
|
||||
deviceStart: () => http.post('/oauth/openai/device/start'),
|
||||
devicePoll: (deviceAuthId: string) =>
|
||||
http.post('/oauth/openai/device/poll', { deviceAuthId }),
|
||||
deviceCancel: (deviceAuthId: string) =>
|
||||
http.post('/oauth/openai/device/cancel', { deviceAuthId }),
|
||||
}
|
||||
|
||||
// RFC-062: Claude Code OAuth piggybacks on the user's local Claude Code
|
||||
|
||||
@ -599,6 +599,16 @@ export default {
|
||||
oauthHint: 'Sign in with your OpenAI account to use ChatGPT Plus/Pro member quota (not API credits).',
|
||||
oauthLoginSuccess: 'OpenAI OAuth login successful',
|
||||
oauthRevokeSuccess: 'OpenAI OAuth disconnected',
|
||||
// Device Authorization Grant (used for remote deployments where the browser
|
||||
// cannot reach localhost:1455 for the PKCE callback).
|
||||
oauthDeviceTitle: 'Sign in to ChatGPT (Device Code)',
|
||||
oauthDeviceStep1: '1. Open this link in any browser or on your phone:',
|
||||
oauthDeviceStep2: '2. Enter this verification code:',
|
||||
oauthDeviceStep3: 'This page will connect automatically once you finish ({seconds}s remaining)',
|
||||
oauthDeviceExpired: 'The device code expired. Please try again.',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
copyFailed: 'Clipboard refused — please select and copy manually',
|
||||
// RFC-062: Claude Code OAuth (subscription piggyback)
|
||||
claudeCodeOauthDetect: 'Detect Claude Code Login',
|
||||
claudeCodeOauthHint: 'Reuses your local Claude Code Pro/Max subscription. Sign in via the Claude Code app first, then click "Detect" to pick up the credentials.',
|
||||
|
||||
@ -485,6 +485,16 @@ export default {
|
||||
oauthHint: '通过 OAuth 登录 OpenAI 账号,使用 ChatGPT Plus/Pro 会员额度(非 API 额度)。',
|
||||
oauthLoginSuccess: 'OpenAI OAuth 登录成功',
|
||||
oauthRevokeSuccess: '已断开 OpenAI OAuth 连接',
|
||||
// Device Authorization Grant (used for remote deployments where the browser
|
||||
// cannot reach localhost:1455 for the PKCE callback).
|
||||
oauthDeviceTitle: '使用设备授权登录 ChatGPT',
|
||||
oauthDeviceStep1: '1. 在任意浏览器或手机打开下面的链接:',
|
||||
oauthDeviceStep2: '2. 输入下方验证码完成授权:',
|
||||
oauthDeviceStep3: '完成后此页面将自动连接({seconds} 秒内有效)',
|
||||
oauthDeviceExpired: '设备授权码已过期,请重试',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
copyFailed: '浏览器拒绝复制,请手动选取',
|
||||
// RFC-062:Claude Code OAuth(订阅复用)
|
||||
claudeCodeOauthDetect: '检测 Claude Code 登录态',
|
||||
claudeCodeOauthHint: '复用本地 Claude Code Pro/Max 订阅。请先在 Claude Code 客户端中登录,再点击"检测"读取凭据。',
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { type Ref } from 'vue'
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { claudeCodeOAuthApi, oauthApi } from '@/api'
|
||||
@ -15,6 +15,14 @@ interface ListDeps {
|
||||
providers: Ref<ProviderInfo[]>
|
||||
}
|
||||
|
||||
export interface DeviceCodeDialogState {
|
||||
visible: boolean
|
||||
userCode: string
|
||||
verificationUrl: string
|
||||
verificationUrlComplete: string | null
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-074 PR-1: OAuth flows. Two distinct shapes:
|
||||
* - openai-chatgpt: pop a real authorize window, poll status, refresh on success.
|
||||
@ -24,6 +32,17 @@ interface ListDeps {
|
||||
export function useProviderOAuth(deps: FormDeps & ListDeps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
const deviceCodeDialog = ref<DeviceCodeDialogState>({
|
||||
visible: false,
|
||||
userCode: '',
|
||||
verificationUrl: '',
|
||||
verificationUrlComplete: null,
|
||||
expiresAt: 0,
|
||||
})
|
||||
|
||||
let devicePollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let activeDeviceAuthId: string | null = null
|
||||
|
||||
/** After a load that may have changed OAuth state, keep the editing modal in sync. */
|
||||
async function reloadProvidersAndSync() {
|
||||
await deps.loadProviders()
|
||||
@ -33,6 +52,80 @@ export function useProviderOAuth(deps: FormDeps & ListDeps) {
|
||||
}
|
||||
}
|
||||
|
||||
function stopDevicePolling() {
|
||||
if (devicePollTimer != null) {
|
||||
clearTimeout(devicePollTimer)
|
||||
devicePollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function closeDeviceCodeDialog() {
|
||||
deviceCodeDialog.value.visible = false
|
||||
stopDevicePolling()
|
||||
if (activeDeviceAuthId) {
|
||||
const id = activeDeviceAuthId
|
||||
activeDeviceAuthId = null
|
||||
oauthApi.deviceCancel(id).catch(() => { /* best-effort */ })
|
||||
}
|
||||
}
|
||||
|
||||
async function runDeviceCodeFlow() {
|
||||
let start: any
|
||||
try {
|
||||
start = await oauthApi.deviceStart()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.msg || 'Device code request failed')
|
||||
return
|
||||
}
|
||||
const data = start.data
|
||||
if (!data?.deviceAuthId || !data?.userCode) {
|
||||
ElMessage.error('Device code response was incomplete')
|
||||
return
|
||||
}
|
||||
|
||||
activeDeviceAuthId = data.deviceAuthId
|
||||
deviceCodeDialog.value = {
|
||||
visible: true,
|
||||
userCode: data.userCode,
|
||||
verificationUrl: data.verificationUrl,
|
||||
verificationUrlComplete: data.verificationUrlComplete ?? null,
|
||||
expiresAt: Date.now() + (data.expiresInSeconds ?? 600) * 1000,
|
||||
}
|
||||
|
||||
const intervalMs = Math.max((data.intervalSeconds ?? 5) * 1000, 3000)
|
||||
|
||||
const tick = async () => {
|
||||
if (!activeDeviceAuthId || !deviceCodeDialog.value.visible) {
|
||||
stopDevicePolling()
|
||||
return
|
||||
}
|
||||
if (Date.now() > deviceCodeDialog.value.expiresAt) {
|
||||
ElMessage.warning(t('settings.model.oauthDeviceExpired'))
|
||||
closeDeviceCodeDialog()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res: any = await oauthApi.devicePoll(activeDeviceAuthId)
|
||||
const status = res.data?.status
|
||||
if (status === 'COMPLETED') {
|
||||
activeDeviceAuthId = null
|
||||
stopDevicePolling()
|
||||
deviceCodeDialog.value.visible = false
|
||||
ElMessage.success(t('settings.model.oauthLoginSuccess'))
|
||||
await reloadProvidersAndSync()
|
||||
return
|
||||
}
|
||||
if (status === 'EXPIRED') {
|
||||
ElMessage.warning(t('settings.model.oauthDeviceExpired'))
|
||||
closeDeviceCodeDialog()
|
||||
return
|
||||
}
|
||||
} catch { /* transient — keep polling */ }
|
||||
devicePollTimer = setTimeout(tick, intervalMs)
|
||||
}
|
||||
devicePollTimer = setTimeout(tick, intervalMs)
|
||||
}
|
||||
|
||||
async function handleOAuthLogin(providerId?: string) {
|
||||
if (providerId === 'anthropic-claude-code') {
|
||||
try {
|
||||
@ -50,7 +143,16 @@ export function useProviderOAuth(deps: FormDeps & ListDeps) {
|
||||
}
|
||||
try {
|
||||
const res: any = await oauthApi.authorize()
|
||||
const { authorizeUrl } = res.data
|
||||
const { authorizeUrl, mode } = res.data || {}
|
||||
|
||||
if (mode === 'DEVICE_CODE') {
|
||||
await runDeviceCodeFlow()
|
||||
return
|
||||
}
|
||||
|
||||
// LOCAL / MANUAL_PASTE both produce an authorize URL the user opens; success
|
||||
// is detected by polling /status (LOCAL completes via the loopback server,
|
||||
// MANUAL_PASTE relies on the user pasting the URL back via callbackPaste).
|
||||
const authWindow = window.open(authorizeUrl, '_blank', 'width=600,height=700')
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
@ -89,5 +191,7 @@ export function useProviderOAuth(deps: FormDeps & ListDeps) {
|
||||
return {
|
||||
handleOAuthLogin,
|
||||
handleOAuthRevoke,
|
||||
deviceCodeDialog,
|
||||
closeDeviceCodeDialog,
|
||||
}
|
||||
}
|
||||
|
||||
@ -172,6 +172,15 @@
|
||||
:enable-provider="enableProvider"
|
||||
@close="closeDrawer"
|
||||
/>
|
||||
|
||||
<DeviceCodeDialog
|
||||
:visible="deviceCodeDialog.visible"
|
||||
:user-code="deviceCodeDialog.userCode"
|
||||
:verification-url="deviceCodeDialog.verificationUrl"
|
||||
:verification-url-complete="deviceCodeDialog.verificationUrlComplete"
|
||||
:expires-at="deviceCodeDialog.expiresAt"
|
||||
@close="closeDeviceCodeDialog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -192,6 +201,7 @@ const ProviderConfigModal = defineAsyncComponent(() => import('./modals/Provider
|
||||
const ManageModelsModal = defineAsyncComponent(() => import('./modals/ManageModelsModal.vue'))
|
||||
// RFC-074 PR-2: drawer for browsing the catalog and opting into hidden built-ins.
|
||||
const AddProviderDrawer = defineAsyncComponent(() => import('./AddProviderDrawer.vue'))
|
||||
const DeviceCodeDialog = defineAsyncComponent(() => import('./modals/DeviceCodeDialog.vue'))
|
||||
|
||||
const { t } = useI18n()
|
||||
const savedTip = ref('')
|
||||
@ -251,6 +261,8 @@ const {
|
||||
onIconError,
|
||||
handleOAuthLogin,
|
||||
handleOAuthRevoke,
|
||||
deviceCodeDialog,
|
||||
closeDeviceCodeDialog,
|
||||
// RFC-074 PR-2 — enablement / drawer
|
||||
catalog,
|
||||
drawerOpen,
|
||||
|
||||
@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div v-if="visible" class="modal-overlay" @click.self="$emit('close')">
|
||||
<div class="modal device-modal">
|
||||
<div class="modal-header">
|
||||
<h2>{{ t('settings.model.oauthDeviceTitle') }}</h2>
|
||||
<button class="modal-close" @click="$emit('close')">×</button>
|
||||
</div>
|
||||
<div class="modal-body device-body">
|
||||
<p class="device-step">{{ t('settings.model.oauthDeviceStep1') }}</p>
|
||||
<a
|
||||
class="device-link"
|
||||
:href="verificationUrlComplete || verificationUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ verificationUrl }}
|
||||
</a>
|
||||
|
||||
<p class="device-step">{{ t('settings.model.oauthDeviceStep2') }}</p>
|
||||
<div class="device-code-row">
|
||||
<code class="device-code">{{ userCode }}</code>
|
||||
<button class="btn-copy" type="button" @click="copyCode">
|
||||
{{ copied ? t('settings.model.copied') : t('settings.model.copy') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="device-step device-countdown">
|
||||
{{ t('settings.model.oauthDeviceStep3', { seconds: remainingSeconds }) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="$emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
userCode: string
|
||||
verificationUrl: string
|
||||
verificationUrlComplete: string | null
|
||||
/** Epoch ms when the device authorization expires. */
|
||||
expiresAt: number
|
||||
}>()
|
||||
|
||||
defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const copied = ref(false)
|
||||
const now = ref(Date.now())
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const remainingSeconds = computed(() => {
|
||||
const left = Math.max(0, Math.floor((props.expiresAt - now.value) / 1000))
|
||||
return left
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(v) => {
|
||||
if (v) {
|
||||
now.value = Date.now()
|
||||
timer = setInterval(() => { now.value = Date.now() }, 1000)
|
||||
} else if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
async function copyCode() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.userCode)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch {
|
||||
ElMessage.warning(t('settings.model.copyFailed'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(124, 63, 30, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
z-index: 50;
|
||||
}
|
||||
.modal {
|
||||
background: var(--mc-bg-elevated);
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.modal-header,
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid var(--mc-border-light);
|
||||
}
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
.modal-footer {
|
||||
border-top: 1px solid var(--mc-border-light);
|
||||
border-bottom: none;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 22px;
|
||||
color: var(--mc-text-secondary);
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.device-body {
|
||||
padding: 24px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.device-step {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--mc-text-secondary);
|
||||
}
|
||||
.device-link {
|
||||
display: inline-block;
|
||||
font-family: var(--mc-mono, monospace);
|
||||
font-size: 14px;
|
||||
color: var(--mc-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
.device-code-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.device-code {
|
||||
font-family: var(--mc-mono, ui-monospace, SFMono-Regular, monospace);
|
||||
font-size: 28px;
|
||||
letter-spacing: 6px;
|
||||
font-weight: 700;
|
||||
padding: 14px 20px;
|
||||
background: var(--mc-bg-sunken);
|
||||
border-radius: 10px;
|
||||
color: var(--mc-text-primary);
|
||||
user-select: all;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.btn-copy {
|
||||
border: 1px solid var(--mc-border);
|
||||
background: var(--mc-bg-elevated);
|
||||
color: var(--mc-text-primary);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-copy:hover {
|
||||
background: var(--mc-bg-sunken);
|
||||
}
|
||||
.device-countdown {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
.btn-secondary {
|
||||
border: 1px solid var(--mc-border);
|
||||
background: var(--mc-bg-elevated);
|
||||
color: var(--mc-text-primary);
|
||||
border-radius: 10px;
|
||||
padding: 9px 14px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: var(--mc-bg-sunken);
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user