feat(xai): add Grok account management UI with four-locale strings

Add XaiOAuthSection (device-code login, account list, default selection)
backed by the generalized useManagedAuth hook, wire it into the Auth
Center and both Claude provider forms, and expose model fetching for
signed-in accounts.

- Accounts requiring re-auth stay visible in the account selector as
  disabled items with an expired badge instead of silently vanishing.
- Auth status refetches periodically so a revoked refresh token surfaces
  without reloading the panel.
- All strings ship in zh/en/ja/zh-TW; a locale coverage test pins every
  required key in all four locales.
This commit is contained in:
Jason
2026-07-19 00:20:17 +08:00
parent 615c99c62b
commit e9317f476e
16 changed files with 936 additions and 68 deletions
@@ -39,6 +39,7 @@ import { Switch } from "@/components/ui/switch";
import { BasicFormFields } from "./BasicFormFields";
import { CodexOAuthSection } from "./CodexOAuthSection";
import { CopilotAuthSection } from "./CopilotAuthSection";
import { XaiOAuthSection } from "./XaiOAuthSection";
import { ApiKeySection } from "./shared/ApiKeySection";
import { EndpointField } from "./shared/EndpointField";
import { ModelDropdown } from "./shared/ModelDropdown";
@@ -68,6 +69,7 @@ import {
type ClaudeDesktopDefaultRoute,
} from "@/lib/api/providers";
import { resolveManagedAccountId } from "@/lib/authBinding";
import { useCopilotAuth, useCodexOauth, useXaiOauth } from "./hooks";
export type ClaudeDesktopProviderFormValues = ProviderFormData & {
presetId?: string;
@@ -280,6 +282,9 @@ export function ClaudeDesktopProviderForm({
const [selectedCodexAccountId, setSelectedCodexAccountId] = useState<
string | null
>(() => resolveManagedAccountId(initialData?.meta, "codex_oauth"));
const [selectedXaiAccountId, setSelectedXaiAccountId] = useState<
string | null
>(() => resolveManagedAccountId(initialData?.meta, "xai_oauth"));
const [codexFastMode, setCodexFastMode] = useState<boolean>(
() => initialData?.meta?.codexFastMode ?? false,
);
@@ -377,13 +382,24 @@ export function ClaudeDesktopProviderForm({
);
const activeProviderType =
activePreset?.providerType ?? initialData?.meta?.providerType;
const { isAuthenticated: isCopilotAuthenticated, accounts: copilotAccounts } =
useCopilotAuth();
const {
isAuthenticated: isCodexOauthAuthenticated,
accounts: codexOauthAccounts,
} = useCodexOauth();
const {
isAuthenticated: isXaiOauthAuthenticated,
accounts: xaiOauthAccounts,
} = useXaiOauth();
const isOfficial =
initialData?.category === "official" ||
activePreset?.category === "official";
const usesManagedOAuth =
activePreset?.requiresOAuth === true ||
activeProviderType === "github_copilot" ||
activeProviderType === "codex_oauth";
activeProviderType === "codex_oauth" ||
activeProviderType === "xai_oauth";
// API Key 获取/邀请链接(与 Claude Code 表单同款,见 ClaudeFormFields
const apiKeyLinkCategory = activePreset?.category ?? initialData?.category;
@@ -568,7 +584,7 @@ export function ClaudeDesktopProviderForm({
});
return;
}
if (!baseUrl.trim()) {
if (!baseUrl.trim() && !usesManagedOAuth) {
toast.error(
t("providerForm.fetchModelsNeedEndpoint", {
defaultValue: "请先填写接口地址",
@@ -576,6 +592,61 @@ export function ClaudeDesktopProviderForm({
);
return;
}
const selectedAccountIsUsable = (
accountId: string | null,
accounts: Array<{ id: string; requires_reauth: boolean }>,
) =>
accountId === null ||
accounts.some(
(account) => account.id === accountId && !account.requires_reauth,
);
const managedAuthState =
activeProviderType === "github_copilot"
? {
authenticated: isCopilotAuthenticated,
accountId: selectedGitHubAccountId,
accounts: copilotAccounts,
loginMessage: t("copilot.loginRequired", {
defaultValue: "请先登录 GitHub Copilot",
}),
}
: activeProviderType === "codex_oauth"
? {
authenticated: isCodexOauthAuthenticated,
accountId: selectedCodexAccountId,
accounts: codexOauthAccounts,
loginMessage: t("codexOauth.loginRequired", {
defaultValue: "请先登录 ChatGPT 账号",
}),
}
: activeProviderType === "xai_oauth"
? {
authenticated: isXaiOauthAuthenticated,
accountId: selectedXaiAccountId,
accounts: xaiOauthAccounts,
loginMessage: t("xaiOauth.loginRequired", {
defaultValue: "请先登录 xAI 账号",
}),
}
: null;
if (managedAuthState && !managedAuthState.authenticated) {
toast.error(managedAuthState.loginMessage);
return;
}
if (
managedAuthState &&
!selectedAccountIsUsable(
managedAuthState.accountId,
managedAuthState.accounts,
)
) {
toast.error(
t("managedAuth.selectedAccountUnavailable", {
defaultValue: "已绑定账号不存在或需要重新登录,请重新选择账号",
}),
);
return;
}
if (!usesManagedOAuth && !apiKey.trim()) {
toast.error(
t("providerForm.fetchModelsNeedApiKey", {
@@ -594,7 +665,8 @@ export function ClaudeDesktopProviderForm({
}))
.filter((route) => route.route || route.model);
if (mode === "proxy") {
const effectiveMode = activeProviderType === "xai_oauth" ? "proxy" : mode;
if (effectiveMode === "proxy") {
// 固定四档(Sonnet / Opus / Fable / Haiku),route_id 由 UI 生成、恒合法,
// 因此只要求至少填一个实际请求模型;留空档继承第一个已填档(Sonnet 优先),
// 对齐 Claude Code 的兜底,保证落库四档齐全、子 agent 不会找不到模型。
@@ -654,9 +726,11 @@ export function ClaudeDesktopProviderForm({
Record<string, ClaudeDesktopModelRoute>
>((acc, route) => {
acc[route.route] = {
model: mode === "direct" ? route.route : route.model || route.route,
model:
effectiveMode === "direct" ? route.route : route.model || route.route,
labelOverride:
route.labelOverride || (mode === "proxy" ? route.model : undefined),
route.labelOverride ||
(effectiveMode === "proxy" ? route.model : undefined),
supports1m: route.supports1m || undefined,
};
return acc;
@@ -664,8 +738,13 @@ export function ClaudeDesktopProviderForm({
const meta: ProviderMeta = {
...(initialData?.meta ?? {}),
claudeDesktopMode: mode,
apiFormat: mode === "proxy" ? apiFormat : "anthropic",
claudeDesktopMode: effectiveMode,
apiFormat:
activeProviderType === "xai_oauth"
? "openai_responses"
: effectiveMode === "proxy"
? apiFormat
: "anthropic",
};
meta.claudeDesktopModelRoutes = routeMap;
@@ -683,7 +762,13 @@ export function ClaudeDesktopProviderForm({
authProvider: "codex_oauth",
accountId: selectedCodexAccountId ?? undefined,
}
: undefined;
: activeProviderType === "xai_oauth"
? {
source: "managed_account",
authProvider: "xai_oauth",
accountId: selectedXaiAccountId ?? undefined,
}
: undefined;
meta.codexFastMode =
activeProviderType === "codex_oauth" ? codexFastMode : undefined;
@@ -773,13 +858,18 @@ export function ClaudeDesktopProviderForm({
selectedAccountId={selectedGitHubAccountId}
onAccountSelect={setSelectedGitHubAccountId}
/>
) : (
) : activeProviderType === "codex_oauth" ? (
<CodexOAuthSection
selectedAccountId={selectedCodexAccountId}
onAccountSelect={setSelectedCodexAccountId}
fastModeEnabled={codexFastMode}
onFastModeChange={setCodexFastMode}
/>
) : (
<XaiOAuthSection
selectedAccountId={selectedXaiAccountId}
onAccountSelect={setSelectedXaiAccountId}
/>
)}
</div>
) : (
@@ -835,6 +925,7 @@ export function ClaudeDesktopProviderForm({
<Switch
checked={needsModelMapping}
onCheckedChange={handleModelMappingChange}
disabled={activeProviderType === "xai_oauth"}
aria-label={t("claudeDesktop.modelMappingToggle", {
defaultValue: "需要模型映射",
})}
@@ -844,44 +935,49 @@ export function ClaudeDesktopProviderForm({
{needsModelMapping && (
<div className="space-y-4 rounded-lg border border-border-default p-4">
<div className="space-y-2">
<Label>
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
</Label>
<Select
value={apiFormat}
onValueChange={(value) =>
setApiFormat(value as ClaudeApiFormat)
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="anthropic">
{t("providerForm.apiFormatAnthropic", {
defaultValue: "Anthropic Messages (原生)",
})}
</SelectItem>
<SelectItem value="openai_chat">
{t("providerForm.apiFormatOpenAIChat", {
defaultValue: "OpenAI Chat Completions (需开启路由)",
})}
</SelectItem>
<SelectItem value="openai_responses">
{t("providerForm.apiFormatOpenAIResponses", {
defaultValue: "OpenAI Responses API (需开启路由)",
})}
</SelectItem>
<SelectItem value="gemini_native">
{t("providerForm.apiFormatGeminiNative", {
defaultValue:
"Gemini Native generateContent (需开启路由)",
})}
</SelectItem>
</SelectContent>
</Select>
</div>
{activeProviderType !== "xai_oauth" && (
<div className="space-y-2">
<Label>
{t("providerForm.apiFormat", {
defaultValue: "API 格式",
})}
</Label>
<Select
value={apiFormat}
onValueChange={(value) =>
setApiFormat(value as ClaudeApiFormat)
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="anthropic">
{t("providerForm.apiFormatAnthropic", {
defaultValue: "Anthropic Messages (原生)",
})}
</SelectItem>
<SelectItem value="openai_chat">
{t("providerForm.apiFormatOpenAIChat", {
defaultValue:
"OpenAI Chat Completions (需开启路由)",
})}
</SelectItem>
<SelectItem value="openai_responses">
{t("providerForm.apiFormatOpenAIResponses", {
defaultValue: "OpenAI Responses API (需开启路由)",
})}
</SelectItem>
<SelectItem value="gemini_native">
{t("providerForm.apiFormatGeminiNative", {
defaultValue:
"Gemini Native generateContent (需开启路由)",
})}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="space-y-3">
<div className="space-y-1 border-t border-border-default pt-4">
@@ -36,6 +36,7 @@ import EndpointSpeedTest from "./EndpointSpeedTest";
import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared";
import { CopilotAuthSection } from "./CopilotAuthSection";
import { CodexOAuthSection } from "./CodexOAuthSection";
import { XaiOAuthSection } from "./XaiOAuthSection";
import {
copilotGetModels,
copilotGetModelsForAccount,
@@ -43,6 +44,7 @@ import {
import type { CopilotModel } from "@/lib/api/copilot";
import {
fetchCodexOauthModels,
fetchXaiOauthModels,
fetchModelsForConfig,
showFetchModelsError,
type FetchedModel,
@@ -98,6 +100,12 @@ interface ClaudeFormFieldsProps {
codexFastMode?: boolean;
onCodexFastModeChange?: (enabled: boolean) => void;
// xAI OAuth
isXaiOauthPreset?: boolean;
isXaiOauthAuthenticated?: boolean;
selectedXaiAccountId?: string | null;
onXaiAccountSelect?: (accountId: string | null) => void;
// Template Values
templateValueEntries: Array<[string, TemplateValueConfig]>;
templateValues: Record<string, TemplateValueConfig>;
@@ -174,6 +182,10 @@ export function ClaudeFormFields({
onCodexAccountSelect,
codexFastMode,
onCodexFastModeChange,
isXaiOauthPreset,
isXaiOauthAuthenticated,
selectedXaiAccountId,
onXaiAccountSelect,
templateValueEntries,
templateValues,
templatePresetName,
@@ -224,19 +236,23 @@ export function ClaudeFormFields({
defaultOpusModel ||
defaultFableModel ||
subagentModel ||
apiFormat !== "anthropic" ||
(!isXaiOauthPreset && apiFormat !== "anthropic") ||
apiKeyField !== "ANTHROPIC_AUTH_TOKEN" ||
customUserAgent ||
hasRequestOverrides
);
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
const [advancedExpanded, setAdvancedExpanded] = useState(
isXaiOauthPreset ? false : hasAnyAdvancedValue,
);
// 预设填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
useEffect(() => {
if (hasAnyAdvancedValue) {
if (isXaiOauthPreset) {
setAdvancedExpanded(false);
} else if (hasAnyAdvancedValue) {
setAdvancedExpanded(true);
}
}, [hasAnyAdvancedValue]);
}, [hasAnyAdvancedValue, isXaiOauthPreset]);
// Copilot 可用模型列表
const [copilotModels, setCopilotModels] = useState<CopilotModel[]>([]);
@@ -247,6 +263,10 @@ export function ClaudeFormFields({
const [codexOauthModels, setCodexOauthModels] = useState<FetchedModel[]>([]);
const [codexOauthModelsLoading, setCodexOauthModelsLoading] = useState(false);
const codexOauthModelsRequestRef = useRef(0);
const [xaiOauthModels, setXaiOauthModels] = useState<FetchedModel[]>([]);
const [xaiOauthModelsLoading, setXaiOauthModelsLoading] = useState(false);
const xaiOauthModelsRequestRef = useRef(0);
const fallbackUsesOneM = hasClaudeOneMMarker(claudeModel);
// 通用模型获取(非 Copilot 供应商)
@@ -373,6 +393,37 @@ export function ClaudeFormFields({
t,
]);
const handleFetchXaiOauthModels = useCallback(() => {
if (!isXaiOauthAuthenticated) {
toast.error(
t("xaiOauth.loginRequired", {
defaultValue: "请先登录 xAI 账号",
}),
);
return;
}
const requestId = xaiOauthModelsRequestRef.current + 1;
xaiOauthModelsRequestRef.current = requestId;
setXaiOauthModelsLoading(true);
fetchXaiOauthModels(selectedXaiAccountId)
.then((models) => {
if (xaiOauthModelsRequestRef.current !== requestId) return;
setXaiOauthModels(models);
showModelFetchResult(models.length);
})
.catch((err) => {
if (xaiOauthModelsRequestRef.current !== requestId) return;
console.warn("[XaiOAuth] Failed to fetch models:", err);
showFetchModelsError(err, t);
})
.finally(() => {
if (xaiOauthModelsRequestRef.current === requestId) {
setXaiOauthModelsLoading(false);
}
});
}, [isXaiOauthAuthenticated, selectedXaiAccountId, showModelFetchResult, t]);
useEffect(() => {
copilotModelsRequestRef.current += 1;
setCopilotModels([]);
@@ -385,16 +436,26 @@ export function ClaudeFormFields({
setCodexOauthModelsLoading(false);
}, [isCodexOauthPreset, isCodexOauthAuthenticated, selectedCodexAccountId]);
useEffect(() => {
xaiOauthModelsRequestRef.current += 1;
setXaiOauthModels([]);
setXaiOauthModelsLoading(false);
}, [isXaiOauthPreset, isXaiOauthAuthenticated, selectedXaiAccountId]);
const modelFetchLoading = isCopilotPreset
? modelsLoading
: isCodexOauthPreset
? codexOauthModelsLoading
: isFetchingModels;
: isXaiOauthPreset
? xaiOauthModelsLoading
: isFetchingModels;
const handleModelFetchClick = isCopilotPreset
? handleFetchCopilotModels
: isCodexOauthPreset
? handleFetchCodexOauthModels
: handleFetchModels;
: isXaiOauthPreset
? handleFetchXaiOauthModels
: handleFetchModels;
// 模型输入框:支持手动输入 + 下拉选择
const renderModelInput = (
@@ -420,6 +481,19 @@ export function ClaudeFormFields({
);
}
if (isXaiOauthPreset) {
return (
<ModelInputWithFetch
id={id}
value={value}
onChange={updateValue}
placeholder={placeholder}
fetchedModels={xaiOauthModels}
isLoading={xaiOauthModelsLoading}
/>
);
}
if (isCopilotPreset && copilotModels.length > 0) {
// 按 vendor 分组
const grouped: Record<string, CopilotModel[]> = {};
@@ -619,6 +693,13 @@ export function ClaudeFormFields({
/>
)}
{isXaiOauthPreset && (
<XaiOAuthSection
selectedAccountId={selectedXaiAccountId}
onAccountSelect={onXaiAccountSelect}
/>
)}
{/* API Key 输入框(非 OAuth 预设时显示) */}
{shouldShowApiKey && !usesOAuth && (
<ApiKeySection
@@ -693,7 +774,7 @@ export function ClaudeFormFields({
onManageClick={
showEndpointTools ? () => onEndpointModalToggle(true) : undefined
}
showFullUrlToggle={showEndpointTools}
showFullUrlToggle={showEndpointTools && !isXaiOauthPreset}
isFullUrl={isFullUrl}
onFullUrlChange={onFullUrlChange}
/>
@@ -739,7 +820,7 @@ export function ClaudeFormFields({
)}
<CollapsibleContent className="space-y-4 pt-2">
{/* API 格式选择(仅非云服务商显示) */}
{category !== "cloud_provider" && (
{category !== "cloud_provider" && !isXaiOauthPreset && (
<div className="space-y-2">
<FormLabel htmlFor="apiFormat">
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
+102 -8
View File
@@ -107,6 +107,7 @@ import {
useHermesFormState,
useCopilotAuth,
useCodexOauth,
useXaiOauth,
} from "./hooks";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { useSettingsQuery } from "@/lib/query";
@@ -514,10 +515,19 @@ function ProviderFormFull({
);
// Copilot OAuth 认证状态(仅 Claude 应用需要)
const { isAuthenticated: isCopilotAuthenticated } = useCopilotAuth();
const { isAuthenticated: isCopilotAuthenticated, accounts: copilotAccounts } =
useCopilotAuth();
// Codex OAuth 认证状态(ChatGPT Plus/Pro 反代)
const { isAuthenticated: isCodexOauthAuthenticated } = useCodexOauth();
const {
isAuthenticated: isCodexOauthAuthenticated,
accounts: codexOauthAccounts,
} = useCodexOauth();
const {
isAuthenticated: isXaiOauthAuthenticated,
accounts: xaiOauthAccounts,
} = useXaiOauth();
// 选中的 GitHub 账号 ID(多账号支持)
const [selectedGitHubAccountId, setSelectedGitHubAccountId] = useState<
@@ -528,6 +538,9 @@ function ProviderFormFull({
const [selectedCodexAccountId, setSelectedCodexAccountId] = useState<
string | null
>(() => resolveManagedAccountId(initialData?.meta, "codex_oauth"));
const [selectedXaiAccountId, setSelectedXaiAccountId] = useState<
string | null
>(() => resolveManagedAccountId(initialData?.meta, "xai_oauth"));
const [codexFastMode, setCodexFastMode] = useState<boolean>(
() => initialData?.meta?.codexFastMode ?? false,
);
@@ -1146,6 +1159,9 @@ function ProviderFormFull({
const isCodexOauthProvider =
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth";
const isXaiOauthProvider =
templatePreset?.providerType === "xai_oauth" ||
initialData?.meta?.providerType === "xai_oauth";
if (isCopilotProvider && !isCopilotAuthenticated) {
toast.error(
t("copilot.loginRequired", {
@@ -1162,6 +1178,56 @@ function ProviderFormFull({
);
return;
}
if (isXaiOauthProvider && !isXaiOauthAuthenticated) {
toast.error(
t("xaiOauth.loginRequired", {
defaultValue: "请先登录 xAI 账号",
}),
);
return;
}
const selectedAccountIsUsable = (
accountId: string | null,
accounts: Array<{ id: string; requires_reauth: boolean }>,
) =>
accountId === null ||
accounts.some(
(account) => account.id === accountId && !account.requires_reauth,
);
if (
isCopilotProvider &&
!selectedAccountIsUsable(selectedGitHubAccountId, copilotAccounts)
) {
toast.error(
t("managedAuth.selectedAccountUnavailable", {
defaultValue: "已绑定账号不存在,请重新选择账号",
}),
);
return;
}
if (
isCodexOauthProvider &&
!selectedAccountIsUsable(selectedCodexAccountId, codexOauthAccounts)
) {
toast.error(
t("managedAuth.selectedAccountUnavailable", {
defaultValue: "已绑定账号不存在,请重新选择账号",
}),
);
return;
}
if (
isXaiOauthProvider &&
!selectedAccountIsUsable(selectedXaiAccountId, xaiOauthAccounts)
) {
toast.error(
t("managedAuth.selectedAccountNeedsReauth", {
defaultValue: "已绑定 xAI 账号不存在或需要重新登录",
}),
);
return;
}
// OMO Other Fields JSONB 类(格式错了保存下去数据就坏了)
if (
@@ -1198,14 +1264,19 @@ function ProviderFormFull({
// cloud_provider(如 Bedrock)通过模板变量处理认证,跳过通用校验
if (category !== "official" && category !== "cloud_provider") {
if (appId === "claude") {
if (!isCodexOauthProvider && !baseUrl.trim()) {
if (!isCodexOauthProvider && !isXaiOauthProvider && !baseUrl.trim()) {
issues.push(
t("providerForm.endpointRequired", {
defaultValue: "非官方供应商请填写 API 端点",
}),
);
}
if (!isCopilotProvider && !isCodexOauthProvider && !apiKey.trim()) {
if (
!isCopilotProvider &&
!isCodexOauthProvider &&
!isXaiOauthProvider &&
!apiKey.trim()
) {
issues.push(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
@@ -1278,6 +1349,9 @@ function ProviderFormFull({
const isCodexOauthProvider =
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth";
const isXaiOauthProvider =
templatePreset?.providerType === "xai_oauth" ||
initialData?.meta?.providerType === "xai_oauth";
let settingsConfig: string;
@@ -1483,7 +1557,13 @@ function ProviderFormFull({
authProvider: "codex_oauth",
accountId: selectedCodexAccountId ?? undefined,
}
: undefined,
: isXaiOauthProvider
? {
source: "managed_account",
authProvider: "xai_oauth",
accountId: selectedXaiAccountId ?? undefined,
}
: undefined,
// GitHub Copilot 多账号:保存关联的账号 ID
githubAccountId:
isCopilotProvider && selectedGitHubAccountId
@@ -1519,7 +1599,9 @@ function ProviderFormFull({
: undefined,
apiFormat:
appId === "claude" && category !== "official"
? localApiFormat
? isXaiOauthProvider
? "openai_responses"
: localApiFormat
: appId === "codex" && category !== "official"
? localCodexApiFormat
: undefined,
@@ -1552,7 +1634,10 @@ function ProviderFormFull({
? Number(localCodexMaxOutputTokens)
: undefined,
isFullUrl:
supportsFullUrl && category !== "official" && localIsFullUrl
supportsFullUrl &&
category !== "official" &&
!isXaiOauthProvider &&
localIsFullUrl
? true
: undefined,
};
@@ -2109,13 +2194,19 @@ function ProviderFormFull({
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth"
}
isXaiOauthPreset={
templatePreset?.providerType === "xai_oauth" ||
initialData?.meta?.providerType === "xai_oauth"
}
usesOAuth={
templatePreset?.requiresOAuth === true ||
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com") ||
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth"
initialData?.meta?.providerType === "codex_oauth" ||
templatePreset?.providerType === "xai_oauth" ||
initialData?.meta?.providerType === "xai_oauth"
}
isCopilotAuthenticated={isCopilotAuthenticated}
selectedGitHubAccountId={selectedGitHubAccountId}
@@ -2125,6 +2216,9 @@ function ProviderFormFull({
onCodexAccountSelect={setSelectedCodexAccountId}
codexFastMode={codexFastMode}
onCodexFastModeChange={setCodexFastMode}
isXaiOauthAuthenticated={isXaiOauthAuthenticated}
selectedXaiAccountId={selectedXaiAccountId}
onXaiAccountSelect={setSelectedXaiAccountId}
templateValueEntries={templateValueEntries}
templateValues={templateValues}
templatePresetName={templatePreset?.name || ""}
@@ -0,0 +1,326 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
AlertTriangle,
Check,
Copy,
ExternalLink,
Loader2,
LogOut,
Plus,
Sparkles,
User,
X,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { copyText } from "@/lib/clipboard";
import { useXaiOauth } from "./hooks/useXaiOauth";
interface XaiOAuthSectionProps {
className?: string;
selectedAccountId?: string | null;
onAccountSelect?: (accountId: string | null) => void;
}
export const XaiOAuthSection: React.FC<XaiOAuthSectionProps> = ({
className,
selectedAccountId,
onAccountSelect,
}) => {
const { t } = useTranslation();
const [copied, setCopied] = React.useState(false);
const {
accounts,
defaultAccountId,
hasAnyAccount,
isAuthenticated,
pollingState,
deviceCode,
error,
isPolling,
isAddingAccount,
isRemovingAccount,
isSettingDefaultAccount,
addAccount,
removeAccount,
setDefaultAccount,
cancelAuth,
logout,
} = useXaiOauth();
const usableAccounts = accounts.filter((account) => !account.requires_reauth);
const copyUserCode = async () => {
if (!deviceCode?.user_code) return;
await copyText(deviceCode.user_code);
setCopied(true);
setTimeout(() => setCopied(false), 2_000);
};
const remove = (accountId: string, event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
removeAccount(accountId);
if (selectedAccountId === accountId) onAccountSelect?.(null);
};
return (
<div className={`space-y-4 ${className ?? ""}`}>
<div className="flex items-center justify-between">
<Label>{t("xaiOauth.authStatus", "xAI OAuth 认证")}</Label>
<Badge
variant={isAuthenticated ? "default" : "secondary"}
className={
isAuthenticated
? "bg-green-500 hover:bg-green-600"
: hasAnyAccount
? "border-amber-500 text-amber-600"
: ""
}
>
{isAuthenticated
? t("xaiOauth.accountCount", {
count: usableAccounts.length,
defaultValue: `${usableAccounts.length} 个可用账号`,
})
: hasAnyAccount
? t("xaiOauth.reauthRequired", "需要重新登录")
: t("xaiOauth.notAuthenticated", "未认证")}
</Badge>
</div>
{accounts.length > 0 && onAccountSelect && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("xaiOauth.selectAccount", "选择账号")}
</Label>
<Select
value={selectedAccountId || "none"}
onValueChange={(value) =>
onAccountSelect(value === "none" ? null : value)
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"xaiOauth.selectAccountPlaceholder",
"选择 xAI 账号",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("xaiOauth.useDefaultAccount", "使用默认账号")}
</SelectItem>
{accounts.map((account) => (
<SelectItem
key={account.id}
value={account.id}
disabled={account.requires_reauth}
>
<span className="flex items-center gap-2">
{account.requires_reauth ? (
<AlertTriangle className="h-4 w-4 text-amber-500" />
) : (
<User className="h-4 w-4 text-muted-foreground" />
)}
{account.login}
{account.requires_reauth && (
<span className="text-xs text-amber-600">
({t("xaiOauth.expired", "凭据已失效")})
</span>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{hasAnyAccount && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("xaiOauth.accounts", "xAI 账号")}
</Label>
<div className="space-y-1">
{accounts.map((account) => (
<div
key={account.id}
className="flex items-center justify-between rounded-md border bg-muted/30 p-2"
>
<div className="flex min-w-0 items-center gap-2">
{account.requires_reauth ? (
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-500" />
) : (
<User className="h-5 w-5 shrink-0 text-muted-foreground" />
)}
<span className="truncate text-sm font-medium">
{account.login}
</span>
{defaultAccountId === account.id && (
<Badge variant="secondary" className="text-xs">
{t("xaiOauth.defaultAccount", "默认")}
</Badge>
)}
{account.requires_reauth && (
<Badge
variant="outline"
className="border-amber-500 text-xs text-amber-600"
>
{t("xaiOauth.expired", "凭据已失效")}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
{!account.requires_reauth &&
defaultAccountId !== account.id && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
disabled={isSettingDefaultAccount}
onClick={() => setDefaultAccount(account.id)}
>
{t("xaiOauth.setAsDefault", "设为默认")}
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-red-500"
disabled={isRemovingAccount}
onClick={(event) => remove(account.id, event)}
title={t("xaiOauth.removeAccount", "移除账号")}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
{pollingState === "idle" && (
<Button
type="button"
variant="outline"
className="w-full"
disabled={isAddingAccount}
onClick={addAccount}
>
{hasAnyAccount ? (
<Plus className="mr-2 h-4 w-4" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{hasAnyAccount
? t("xaiOauth.addOrReauth", "添加账号或重新登录")
: t("xaiOauth.login", "使用 xAI 登录")}
</Button>
)}
{isPolling && deviceCode && (
<div className="space-y-3 rounded-lg border bg-muted/50 p-4">
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("xaiOauth.waitingForAuth", "等待 xAI 授权中…")}
</div>
<div className="text-center">
<p className="mb-1 text-xs text-muted-foreground">
{t("xaiOauth.enterCode", "若浏览器未自动填入,请输入:")}
</p>
<div className="flex items-center justify-center gap-2">
<code className="rounded border bg-background px-4 py-2 font-mono text-2xl font-bold tracking-wider">
{deviceCode.user_code}
</code>
<Button
type="button"
size="icon"
variant="ghost"
onClick={copyUserCode}
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="text-center">
<a
href={deviceCode.verification_uri}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-blue-500 hover:underline"
>
{deviceCode.verification_uri}
<ExternalLink className="h-3 w-3" />
</a>
</div>
<div className="text-center">
<Button
type="button"
variant="ghost"
size="sm"
onClick={cancelAuth}
>
{t("common.cancel", "取消")}
</Button>
</div>
</div>
)}
{pollingState === "error" && error && (
<div className="space-y-2">
<p className="text-sm text-red-500">{error}</p>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={addAccount}
>
{t("xaiOauth.retry", "重试")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={cancelAuth}
>
{t("common.cancel", "取消")}
</Button>
</div>
</div>
)}
{hasAnyAccount && accounts.length > 1 && (
<Button
type="button"
variant="outline"
className="w-full text-red-500 hover:text-red-600"
onClick={logout}
>
<LogOut className="mr-2 h-4 w-4" />
{t("xaiOauth.logoutAll", "移除所有 xAI 账号")}
</Button>
)}
</div>
);
};
export default XaiOAuthSection;
@@ -20,3 +20,4 @@ export { useOpenclawFormState } from "./useOpenclawFormState";
export { useHermesFormState } from "./useHermesFormState";
export { useCopilotAuth } from "./useCopilotAuth";
export { useCodexOauth } from "./useCodexOauth";
export { useXaiOauth } from "./useXaiOauth";
@@ -35,6 +35,10 @@ export function useManagedAuth(
queryKey,
queryFn: () => authApi.authGetStatus(authProvider),
staleTime: 30000,
// A rejected xAI refresh token is persisted as `requires_reauth` by the
// proxy hot path. Periodically refresh local status so an already-open Auth
// Center stops showing the account as logged in without requiring a reload.
refetchInterval: authProvider === "xai_oauth" ? 15_000 : false,
});
const stopPolling = useCallback(() => {
@@ -0,0 +1,6 @@
import { useManagedAuth } from "./useManagedAuth";
/** xAI OAuth device-code authentication hook. */
export function useXaiOauth() {
return useManagedAuth("xai_oauth");
}