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");
}
@@ -4,6 +4,8 @@ import { Badge } from "@/components/ui/badge";
import { CodexIcon } from "@/components/BrandIcons";
import { CopilotAuthSection } from "@/components/providers/forms/CopilotAuthSection";
import { CodexOAuthSection } from "@/components/providers/forms/CodexOAuthSection";
import { XaiOAuthSection } from "@/components/providers/forms/XaiOAuthSection";
import { ProviderIcon } from "@/components/ProviderIcon";
export function AuthCenterPanel() {
const { t } = useTranslation();
@@ -69,6 +71,24 @@ export function AuthCenterPanel() {
<CodexOAuthSection />
</section>
<section className="rounded-xl border border-border/60 bg-card/60 p-6">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-muted">
<ProviderIcon icon="xai" name="xAI" size={20} />
</div>
<div>
<h4 className="font-medium">xAI (Grok OAuth)</h4>
<p className="text-sm text-muted-foreground">
{t("settings.authCenter.xaiOauthDescription", {
defaultValue: "管理 xAI / Grok 账号",
})}
</p>
</div>
</div>
<XaiOAuthSection />
</section>
</div>
);
}
+28 -1
View File
@@ -326,7 +326,8 @@
"description": "Use your other subscriptions in Claude Code — please be mindful of compliance risks.",
"beta": "Beta",
"copilotDescription": "Manage GitHub Copilot accounts",
"codexOauthDescription": "Manage ChatGPT accounts"
"codexOauthDescription": "Manage ChatGPT accounts",
"xaiOauthDescription": "Manage xAI / Grok accounts"
},
"advanced": {
"configDir": {
@@ -1014,6 +1015,7 @@
"customApiKeyHint": "💡 Custom configuration requires manually filling all necessary fields",
"omoHint": "💡 OMO config manages Agent model assignments and supports both oh-my-openagent.jsonc and oh-my-opencode.jsonc",
"officialHint": "💡 Official provider uses browser login, no API Key needed",
"providerKeyStatusLoading": "Provider identifier status is still loading. Please try again shortly.",
"getApiKey": "Get API Key",
"partnerPromotion": {
"sudocode": "With one SudoCode key, Claude Code and Claude Desktop use Claude Opus 4.8, while Codex uses GPT-5.6. Sign up, join QQ group 726213516, and contact the group owner to claim CNY ¥10 in trial credit.",
@@ -1222,6 +1224,31 @@
"fastMode": "FAST mode",
"fastModeDescription": "Send service_tier=\"priority\" for lower latency. Off by default — enabling it consumes your ChatGPT quota at a higher rate."
},
"xaiOauth": {
"authStatus": "xAI OAuth authentication",
"accountCount": "Usable accounts: {{count}}",
"reauthRequired": "Sign-in required",
"notAuthenticated": "Not authenticated",
"selectAccount": "Select account",
"selectAccountPlaceholder": "Select an xAI account",
"useDefaultAccount": "Use default account",
"accounts": "xAI accounts",
"defaultAccount": "Default",
"expired": "Credentials expired",
"setAsDefault": "Set as default",
"removeAccount": "Remove account",
"addOrReauth": "Add account or sign in again",
"login": "Sign in with xAI",
"waitingForAuth": "Waiting for xAI authorization…",
"enterCode": "If the browser did not fill it in automatically, enter:",
"retry": "Retry",
"logoutAll": "Remove all xAI accounts",
"loginRequired": "Sign in to an xAI account first"
},
"managedAuth": {
"selectedAccountNeedsReauth": "The linked account is missing or needs you to sign in again",
"selectedAccountUnavailable": "The linked account is missing or needs you to sign in again. Select another account."
},
"endpointTest": {
"title": "API Endpoint Management",
"endpoints": "endpoints",
+28 -1
View File
@@ -326,7 +326,8 @@
"description": "Claude Code で他のサブスクリプションをご利用いただけます。コンプライアンスリスクにご注意ください。",
"beta": "Beta",
"copilotDescription": "GitHub Copilot アカウントを管理します",
"codexOauthDescription": "ChatGPT アカウントを管理します"
"codexOauthDescription": "ChatGPT アカウントを管理します",
"xaiOauthDescription": "xAI / Grok アカウントを管理します"
},
"advanced": {
"configDir": {
@@ -1014,6 +1015,7 @@
"customApiKeyHint": "💡 カスタム設定では必要な項目をすべて手動で入力してください",
"omoHint": "💡 OMO 設定は Agent のモデル割り当てを管理し、oh-my-openagent.jsonc / oh-my-opencode.jsonc の両方に対応します",
"officialHint": "💡 公式プロバイダーはブラウザログインで、API Key は不要です",
"providerKeyStatusLoading": "プロバイダー識別子の状態を読み込んでいます。しばらくしてからもう一度お試しください",
"getApiKey": "API Key を取得",
"partnerPromotion": {
"sudocode": "SudoCode の1つのキーで、Claude Code と Claude Desktop では Claude Opus 4.8、Codex では GPT-5.6 を利用できます。登録後 QQ グループ 726213516 に参加し、管理者へ連絡すると CNY ¥10 のトライアルクレジットを受け取れます。",
@@ -1222,6 +1224,31 @@
"fastMode": "FAST モード",
"fastModeDescription": "低遅延のため service_tier=\"priority\" を送信します。既定ではオフ——オンにすると ChatGPT のクォータがより速く消費されます。"
},
"xaiOauth": {
"authStatus": "xAI OAuth 認証",
"accountCount": "利用可能なアカウント: {{count}} 件",
"reauthRequired": "再ログインが必要です",
"notAuthenticated": "未認証",
"selectAccount": "アカウントを選択",
"selectAccountPlaceholder": "xAI アカウントを選択",
"useDefaultAccount": "デフォルトアカウントを使用",
"accounts": "xAI アカウント",
"defaultAccount": "デフォルト",
"expired": "認証情報が失効しました",
"setAsDefault": "デフォルトに設定",
"removeAccount": "アカウントを削除",
"addOrReauth": "アカウントを追加または再ログイン",
"login": "xAI でログイン",
"waitingForAuth": "xAI の認証を待っています…",
"enterCode": "ブラウザに自動入力されていない場合は、次を入力してください:",
"retry": "再試行",
"logoutAll": "すべての xAI アカウントを削除",
"loginRequired": "先に xAI アカウントへログインしてください"
},
"managedAuth": {
"selectedAccountNeedsReauth": "リンクされたアカウントが存在しないか、再ログインが必要です",
"selectedAccountUnavailable": "リンクされたアカウントが存在しないか、再ログインが必要です。別のアカウントを選択してください"
},
"endpointTest": {
"title": "API エンドポイント管理",
"endpoints": "エンドポイント",
+28 -1
View File
@@ -326,7 +326,8 @@
"description": "在 Claude Code 中使用您的其他訂閱,請注意合規風險。",
"beta": "Beta",
"copilotDescription": "管理 GitHub Copilot 帳號",
"codexOauthDescription": "管理 ChatGPT 帳號"
"codexOauthDescription": "管理 ChatGPT 帳號",
"xaiOauthDescription": "管理 xAI / Grok 帳號"
},
"advanced": {
"configDir": {
@@ -985,6 +986,7 @@
"customApiKeyHint": "自訂設定需手動填寫所有必要欄位",
"omoHint": "OMO 設定管理 Agent 模型指派,相容 oh-my-openagent.jsonc / oh-my-opencode.jsonc",
"officialHint": "官方供應商使用瀏覽器登入,無需設定 API Key",
"providerKeyStatusLoading": "正在載入供應商識別碼狀態,請稍後再試",
"getApiKey": "取得 API Key",
"partnerPromotion": {
"sudocode": "SudoCode 讓 Claude Code 與 Claude Desktop 使用 Claude Opus 4.8Codex 使用 GPT-5.6,一個 Key 統一管理。CC Switch 使用者註冊並加入 QQ 群 726213516,聯絡群主領取人民幣 ¥10 試用額度。",
@@ -1194,6 +1196,31 @@
"fastMode": "FAST 模式",
"fastModeDescription": "發送 service_tier=\"priority\" 換取更低延遲。預設關閉——開啟後會以更高速率消耗 ChatGPT 配額。"
},
"xaiOauth": {
"authStatus": "xAI OAuth 驗證",
"accountCount": "{{count}} 個可用帳號",
"reauthRequired": "需要重新登入",
"notAuthenticated": "尚未驗證",
"selectAccount": "選擇帳號",
"selectAccountPlaceholder": "選擇 xAI 帳號",
"useDefaultAccount": "使用預設帳號",
"accounts": "xAI 帳號",
"defaultAccount": "預設",
"expired": "憑證已失效",
"setAsDefault": "設為預設",
"removeAccount": "移除帳號",
"addOrReauth": "新增帳號或重新登入",
"login": "使用 xAI 登入",
"waitingForAuth": "正在等待 xAI 授權…",
"enterCode": "若瀏覽器未自動填入,請輸入:",
"retry": "重試",
"logoutAll": "移除所有 xAI 帳號",
"loginRequired": "請先登入 xAI 帳號"
},
"managedAuth": {
"selectedAccountNeedsReauth": "已綁定的帳號不存在或需要重新登入",
"selectedAccountUnavailable": "已綁定的帳號不存在或需要重新登入,請重新選擇帳號"
},
"endpointTest": {
"title": "請求位址管理",
"endpoints": "個端點",
+28 -1
View File
@@ -326,7 +326,8 @@
"description": "在 Claude Code 中使用您的其他订阅,请注意合规风险。",
"beta": "Beta",
"copilotDescription": "管理 GitHub Copilot 账号",
"codexOauthDescription": "管理 ChatGPT 账号"
"codexOauthDescription": "管理 ChatGPT 账号",
"xaiOauthDescription": "管理 xAI / Grok 账号"
},
"advanced": {
"configDir": {
@@ -1014,6 +1015,7 @@
"customApiKeyHint": "💡 自定义配置需手动填写所有必要字段",
"omoHint": "💡 OMO 配置管理 Agent 模型分配,兼容 oh-my-openagent.jsonc / oh-my-opencode.jsonc",
"officialHint": "💡 官方供应商使用浏览器登录,无需配置 API Key",
"providerKeyStatusLoading": "正在加载供应商标识状态,请稍后再试",
"getApiKey": "获取 API Key",
"partnerPromotion": {
"sudocode": "SudoCode 让 Claude Code 与 Claude Desktop 接入 Claude Opus 4.8Codex 接入 GPT-5.6,一个 Key 统一使用。CC Switch 用户注册并加入 QQ 群 726213516,联系群主领取 ¥10 试用额度。",
@@ -1222,6 +1224,31 @@
"fastMode": "FAST 模式",
"fastModeDescription": "发送 service_tier=\"priority\" 换取更低延迟。默认关闭——开启后会按更高速率消耗 ChatGPT 配额。"
},
"xaiOauth": {
"authStatus": "xAI OAuth 认证",
"accountCount": "{{count}} 个可用账号",
"reauthRequired": "需要重新登录",
"notAuthenticated": "未认证",
"selectAccount": "选择账号",
"selectAccountPlaceholder": "选择 xAI 账号",
"useDefaultAccount": "使用默认账号",
"accounts": "xAI 账号",
"defaultAccount": "默认",
"expired": "凭据已失效",
"setAsDefault": "设为默认",
"removeAccount": "移除账号",
"addOrReauth": "添加账号或重新登录",
"login": "使用 xAI 登录",
"waitingForAuth": "等待 xAI 授权中…",
"enterCode": "若浏览器未自动填入,请输入:",
"retry": "重试",
"logoutAll": "移除所有 xAI 账号",
"loginRequired": "请先登录 xAI 账号"
},
"managedAuth": {
"selectedAccountNeedsReauth": "已绑定账号不存在或需要重新登录",
"selectedAccountUnavailable": "已绑定账号不存在或需要重新登录,请重新选择账号"
},
"endpointTest": {
"title": "请求地址管理",
"endpoints": "个端点",
+5 -1
View File
@@ -1,6 +1,9 @@
import { invoke } from "@tauri-apps/api/core";
export type ManagedAuthProvider = "github_copilot" | "codex_oauth";
export type ManagedAuthProvider =
| "github_copilot"
| "codex_oauth"
| "xai_oauth";
export interface ManagedAuthAccount {
id: string;
@@ -10,6 +13,7 @@ export interface ManagedAuthAccount {
authenticated_at: number;
is_default: boolean;
github_domain: string;
requires_reauth: boolean;
}
export interface ManagedAuthStatus {
+9
View File
@@ -42,6 +42,15 @@ export async function fetchCodexOauthModels(
});
}
/** 获取当前 xAI OAuth 账号可访问的模型列表。 */
export async function fetchXaiOauthModels(
accountId?: string | null,
): Promise<FetchedModel[]> {
return invoke("get_xai_oauth_models", {
accountId: accountId || null,
});
}
/**
* toast
*/
+63
View File
@@ -0,0 +1,63 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { XaiOAuthSection } from "@/components/providers/forms/XaiOAuthSection";
const mockUseXaiOauth = vi.hoisted(() => vi.fn());
vi.mock("@/components/providers/forms/hooks/useXaiOauth", () => ({
useXaiOauth: mockUseXaiOauth,
}));
describe("XaiOAuthSection", () => {
beforeEach(() => {
mockUseXaiOauth.mockReturnValue({
accounts: [
{
id: "expired-account",
login: "expired@example.com",
avatar_url: null,
authenticated_at: 1,
github_domain: "x.ai",
requires_reauth: true,
},
{
id: "usable-account",
login: "usable@example.com",
avatar_url: null,
authenticated_at: 2,
github_domain: "x.ai",
requires_reauth: false,
},
],
defaultAccountId: "usable-account",
hasAnyAccount: true,
isAuthenticated: true,
pollingState: "idle",
deviceCode: null,
error: null,
isPolling: false,
isAddingAccount: false,
isRemovingAccount: false,
isSettingDefaultAccount: false,
addAccount: vi.fn(),
removeAccount: vi.fn(),
setDefaultAccount: vi.fn(),
cancelAuth: vi.fn(),
logout: vi.fn(),
});
});
it("keeps a selected account visible when it requires reauthentication", () => {
render(
<XaiOAuthSection
selectedAccountId="expired-account"
onAccountSelect={vi.fn()}
/>,
);
expect(screen.getByRole("combobox")).toHaveTextContent(
"expired@example.com",
);
expect(screen.getByRole("combobox")).toHaveTextContent("凭据已失效");
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import en from "@/i18n/locales/en.json";
import ja from "@/i18n/locales/ja.json";
import zhTW from "@/i18n/locales/zh-TW.json";
import zh from "@/i18n/locales/zh.json";
const requiredKeys = [
"xaiOauth.authStatus",
"xaiOauth.accountCount",
"xaiOauth.reauthRequired",
"xaiOauth.notAuthenticated",
"xaiOauth.selectAccount",
"xaiOauth.selectAccountPlaceholder",
"xaiOauth.useDefaultAccount",
"xaiOauth.accounts",
"xaiOauth.defaultAccount",
"xaiOauth.expired",
"xaiOauth.setAsDefault",
"xaiOauth.removeAccount",
"xaiOauth.addOrReauth",
"xaiOauth.login",
"xaiOauth.waitingForAuth",
"xaiOauth.enterCode",
"xaiOauth.retry",
"xaiOauth.logoutAll",
"xaiOauth.loginRequired",
"managedAuth.selectedAccountNeedsReauth",
"managedAuth.selectedAccountUnavailable",
"providerForm.providerKeyStatusLoading",
"settings.authCenter.xaiOauthDescription",
] as const;
type TranslationTree = Record<string, unknown>;
function readTranslation(tree: TranslationTree, path: string): unknown {
return path.split(".").reduce<unknown>((value, segment) => {
if (typeof value !== "object" || value === null) return undefined;
return (value as TranslationTree)[segment];
}, tree);
}
describe("xAI OAuth locale coverage", () => {
it.each([
["zh", zh],
["zh-TW", zhTW],
["en", en],
["ja", ja],
])("defines every required key in %s", (_locale, translations) => {
const missing = requiredKeys.filter((key) => {
const value = readTranslation(translations, key);
return typeof value !== "string" || value.trim().length === 0;
});
expect(missing).toEqual([]);
});
});