Files
CC-Switch/src/components/providers/forms/CopilotAuthSection.tsx
T
Zhou Mengze de49f6fbbe fix(copilot): 修复 GitHub Copilot 认证和代理问题 (#1854)
* fix(copilot): 修复 GitHub Copilot 400 认证错误

问题:使用 GitHub Copilot provider 时报错 400 bad request

根因:与 copilot-api 项目对比发现多处差异

修复内容:
- 更新版本号 0.26.7 到 0.38.2
- 更新 API 版本 2025-04-01 到 2025-10-01
- 添加缺失的关键 headers
- 修正 openai-intent 值
- 添加动态 API endpoint 支持
- 同步更新 stream_check.rs headers

Closes #1777

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: flush stream after write_all in hyper_client proxy

Add explicit flush() calls after write_all() for TLS stream, plain TCP
stream, and CONNECT tunnel requests to ensure buffered data is sent
immediately, preventing connection hangs in Copilot auth header flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 修复登录时的剪切板在mac与linux端可能没复制验证码

* fix: flush stream after write_all in hyper_client proxy

Add explicit flush() calls after write_all() for TLS stream, plain TCP
stream, and CONNECT tunnel requests to ensure buffered data is sent
immediately, preventing connection hangs in Copilot auth header flow.

* 修复登录时的剪切板在mac与linux端可能没复制验证码

* 1、修复不同类型的个人商业等不同类型的copilot账号问题
2、将验证码复制改为异步操作

* fix: address PR review comments for Copilot auth                                                      │
│                                                                                                                      │
│ - Fix clipboard blocking by using spawn_blocking for arboard ops                                                     │
│ - Implement dynamic endpoint routing for enterprise Copilot users                                                    │
│ - Add api_endpoints cache cleanup in remove_account() and clear_auth()                                               │
│ - Change API endpoint log level from info to debug                                                                   │
│ - Fix clear_auth() to continue cleanup even if file deletion fails                                                   │
│ - Add 9 unit tests for Copilot detection and api_endpoints cachin

* style: fix cargo fmt formatting

* Fix Copilot dynamic endpoint handling

* fix: restore clear_auth() memory-first cleanup order and fix cache leaks

- Restore clear_auth() to clean memory state before deleting the storage
  file. The previous order (file deletion first) caused a regression where
  users could get stuck in a "cannot log out" state if file removal failed.

- Add missing copilot_models.clear() in clear_auth() — this cache was
  cleaned in remove_account() but never in the full clear path.

- Add endpoint_locks cleanup in both remove_account() and clear_auth()
  to prevent minor in-process memory leaks.

- Update test to assert the correct behavior: memory should be cleaned
  even when file deletion fails.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: 周梦泽 <mengze.zhou@dafeng-tech.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-04-04 22:52:23 +08:00

369 lines
11 KiB
TypeScript

import React from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Loader2,
Github,
LogOut,
Copy,
Check,
ExternalLink,
Plus,
X,
User,
} from "lucide-react";
import { useCopilotAuth } from "./hooks/useCopilotAuth";
import { copyText } from "@/lib/clipboard";
import type { GitHubAccount } from "@/lib/api";
interface CopilotAuthSectionProps {
className?: string;
/** 当前选中的 GitHub 账号 ID */
selectedAccountId?: string | null;
/** 账号选择回调 */
onAccountSelect?: (accountId: string | null) => void;
}
/**
* Copilot OAuth 认证区块
*
* 显示 GitHub Copilot 的认证状态,支持多账号管理和选择。
*/
export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
className,
selectedAccountId,
onAccountSelect,
}) => {
const { t } = useTranslation();
const [copied, setCopied] = React.useState(false);
const {
accounts,
defaultAccountId,
migrationError,
hasAnyAccount,
pollingState,
deviceCode,
error,
isPolling,
isAddingAccount,
isRemovingAccount,
isSettingDefaultAccount,
addAccount,
removeAccount,
setDefaultAccount,
cancelAuth,
logout,
} = useCopilotAuth();
// 复制用户码
const copyUserCode = async () => {
if (deviceCode?.user_code) {
await copyText(deviceCode.user_code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
// 处理账号选择
const handleAccountSelect = (value: string) => {
onAccountSelect?.(value === "none" ? null : value);
};
// 处理移除账号
const handleRemoveAccount = (accountId: string, e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
removeAccount(accountId);
// 如果移除的是当前选中的账号,清除选择
if (selectedAccountId === accountId) {
onAccountSelect?.(null);
}
};
// 渲染账号头像
const renderAvatar = (account: GitHubAccount) => {
return <CopilotAccountAvatar account={account} />;
};
return (
<div className={`space-y-4 ${className || ""}`}>
{/* 认证状态标题 */}
<div className="flex items-center justify-between">
<Label>{t("copilot.authStatus", "GitHub Copilot 认证")}</Label>
<Badge
variant={hasAnyAccount ? "default" : "secondary"}
className={hasAnyAccount ? "bg-green-500 hover:bg-green-600" : ""}
>
{hasAnyAccount
? t("copilot.accountCount", {
count: accounts.length,
defaultValue: `${accounts.length} 个账号`,
})
: t("copilot.notAuthenticated", "未认证")}
</Badge>
</div>
{migrationError && (
<p className="text-sm text-amber-600 dark:text-amber-400">
{t("copilot.migrationFailed", {
error: migrationError,
defaultValue: `旧认证数据迁移失败:${migrationError}`,
})}
</p>
)}
{/* 账号选择器(有账号时显示) */}
{hasAnyAccount && onAccountSelect && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("copilot.selectAccount", "选择账号")}
</Label>
<Select
value={selectedAccountId || "none"}
onValueChange={handleAccountSelect}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"copilot.selectAccountPlaceholder",
"选择一个 GitHub 账号",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">
{t("copilot.useDefaultAccount", "使用默认账号")}
</span>
</SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<div className="flex items-center gap-2">
{renderAvatar(account)}
<span>{account.login}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 已登录账号列表 */}
{hasAnyAccount && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("copilot.loggedInAccounts", "已登录账号")}
</Label>
<div className="space-y-1">
{accounts.map((account) => (
<div
key={account.id}
className="flex items-center justify-between p-2 rounded-md border bg-muted/30"
>
<div className="flex items-center gap-2">
{renderAvatar(account)}
<span className="text-sm font-medium">{account.login}</span>
{defaultAccountId === account.id && (
<Badge variant="secondary" className="text-xs">
{t("copilot.defaultAccount", "默认")}
</Badge>
)}
{selectedAccountId === account.id && (
<Badge variant="outline" className="text-xs">
{t("copilot.selected", "已选中")}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
{defaultAccountId !== account.id && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => setDefaultAccount(account.id)}
disabled={isSettingDefaultAccount}
>
{t("copilot.setAsDefault", "设为默认")}
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-red-500"
onClick={(e) => handleRemoveAccount(account.id, e)}
disabled={isRemovingAccount}
title={t("copilot.removeAccount", "移除账号")}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
{/* 未认证状态 - 登录按钮 */}
{!hasAnyAccount && pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
>
<Github className="mr-2 h-4 w-4" />
{t("copilot.loginWithGitHub", "使用 GitHub 登录")}
</Button>
)}
{/* 已有账号 - 添加更多账号按钮 */}
{hasAnyAccount && pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
disabled={isAddingAccount}
>
<Plus className="mr-2 h-4 w-4" />
{t("copilot.addAnotherAccount", "添加其他账号")}
</Button>
)}
{/* 轮询中状态 */}
{isPolling && deviceCode && (
<div className="space-y-3 p-4 rounded-lg border border-border bg-muted/50">
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("copilot.waitingForAuth", "等待授权中...")}
</div>
{/* 用户码 */}
<div className="text-center">
<p className="text-xs text-muted-foreground mb-1">
{t("copilot.enterCode", "在浏览器中输入以下代码:")}
</p>
<div className="flex items-center justify-center gap-2">
<code className="text-2xl font-mono font-bold tracking-wider bg-background px-4 py-2 rounded border">
{deviceCode.user_code}
</code>
<Button
type="button"
size="icon"
variant="ghost"
onClick={copyUserCode}
title={t("copilot.copyCode", "复制代码")}
>
{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"
onClick={addAccount}
variant="outline"
size="sm"
>
{t("copilot.retry", "重试")}
</Button>
<Button
type="button"
onClick={cancelAuth}
variant="ghost"
size="sm"
>
{t("common.cancel", "取消")}
</Button>
</div>
</div>
)}
{/* 注销所有账号按钮 */}
{hasAnyAccount && accounts.length > 1 && (
<Button
type="button"
variant="outline"
onClick={logout}
className="w-full text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950"
>
<LogOut className="mr-2 h-4 w-4" />
{t("copilot.logoutAll", "注销所有账号")}
</Button>
)}
</div>
);
};
const CopilotAccountAvatar: React.FC<{ account: GitHubAccount }> = ({
account,
}) => {
const [failed, setFailed] = React.useState(false);
if (!account.avatar_url || failed) {
return <User className="h-5 w-5 text-muted-foreground" />;
}
return (
<img
src={account.avatar_url}
alt={account.login}
className="h-5 w-5 rounded-full"
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
/>
);
};
export default CopilotAuthSection;