mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-03 19:12:04 +08:00
feat: add Codex OAuth (ChatGPT Plus/Pro) reverse proxy support
Adds a new managed OAuth provider that lets Claude Code route requests through a user's ChatGPT Plus/Pro subscription via the chatgpt.com backend-api/codex endpoint. - CodexOAuthManager: OpenAI Device Code flow with multi-account support, JWT-based account identification, and automatic access_token refresh. - Reuses the generic managed-auth command surface (auth_start_login, auth_poll_for_account, etc.) via provider dispatch in commands/auth.rs. - ClaudeAdapter detects codex_oauth providers, forces the base URL to the ChatGPT backend, pins api_format to openai_responses, and emits Authorization + originator headers; the forwarder injects the dynamic access_token and ChatGPT-Account-Id per request. - transform_responses gains an is_codex_oauth path that aligns the body with OpenAI's codex-rs ResponsesApiRequest contract: sets store:false, appends reasoning.encrypted_content to include, strips max_output_tokens / temperature / top_p, injects default instructions/tools/parallel_tool_calls, and forces stream:true. Covered by 9 new unit tests plus regression guards for the non-Codex path. - Stream check reuses the same transform flag so detection matches the production request shape. - Frontend adds CodexOAuthSection + useCodexOauth hook, integrates it into ClaudeFormFields / ProviderForm / AuthCenterPanel, ships a new "Codex (ChatGPT Plus/Pro)" preset, and adds zh/en/ja i18n strings.
This commit is contained in:
@@ -28,6 +28,7 @@ import { ChevronDown, ChevronRight, Download, Loader2 } from "lucide-react";
|
||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||
import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared";
|
||||
import { CopilotAuthSection } from "./CopilotAuthSection";
|
||||
import { CodexOAuthSection } from "./CodexOAuthSection";
|
||||
import {
|
||||
copilotGetModels,
|
||||
copilotGetModelsForAccount,
|
||||
@@ -70,6 +71,12 @@ interface ClaudeFormFieldsProps {
|
||||
/** GitHub 账号选择回调(多账号支持) */
|
||||
onGitHubAccountSelect?: (accountId: string | null) => void;
|
||||
|
||||
// Codex OAuth (ChatGPT Plus/Pro)
|
||||
isCodexOauthPreset?: boolean;
|
||||
isCodexOauthAuthenticated?: boolean;
|
||||
selectedCodexAccountId?: string | null;
|
||||
onCodexAccountSelect?: (accountId: string | null) => void;
|
||||
|
||||
// Template Values
|
||||
templateValueEntries: Array<[string, TemplateValueConfig]>;
|
||||
templateValues: Record<string, TemplateValueConfig>;
|
||||
@@ -134,6 +141,9 @@ export function ClaudeFormFields({
|
||||
isCopilotAuthenticated,
|
||||
selectedGitHubAccountId,
|
||||
onGitHubAccountSelect,
|
||||
isCodexOauthPreset,
|
||||
selectedCodexAccountId,
|
||||
onCodexAccountSelect,
|
||||
templateValueEntries,
|
||||
templateValues,
|
||||
templatePresetName,
|
||||
@@ -357,6 +367,14 @@ export function ClaudeFormFields({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Codex OAuth 认证 (ChatGPT Plus/Pro) */}
|
||||
{isCodexOauthPreset && (
|
||||
<CodexOAuthSection
|
||||
selectedAccountId={selectedCodexAccountId}
|
||||
onAccountSelect={onCodexAccountSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* API Key 输入框(非 OAuth 预设时显示) */}
|
||||
{shouldShowApiKey && !usesOAuth && (
|
||||
<ApiKeySection
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
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,
|
||||
LogOut,
|
||||
Copy,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Plus,
|
||||
X,
|
||||
Sparkles,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useCodexOauth } from "./hooks/useCodexOauth";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
|
||||
interface CodexOAuthSectionProps {
|
||||
className?: string;
|
||||
/** 当前选中的 ChatGPT 账号 ID */
|
||||
selectedAccountId?: string | null;
|
||||
/** 账号选择回调 */
|
||||
onAccountSelect?: (accountId: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex OAuth 认证区块
|
||||
*
|
||||
* 通过 OpenAI Device Code 流程登录 ChatGPT Plus/Pro 账号,
|
||||
* 用于将 Claude Code 请求反代到 Codex 后端 API。
|
||||
*/
|
||||
export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||
className,
|
||||
selectedAccountId,
|
||||
onAccountSelect,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const {
|
||||
accounts,
|
||||
defaultAccountId,
|
||||
hasAnyAccount,
|
||||
pollingState,
|
||||
deviceCode,
|
||||
error,
|
||||
isPolling,
|
||||
isAddingAccount,
|
||||
isRemovingAccount,
|
||||
isSettingDefaultAccount,
|
||||
addAccount,
|
||||
removeAccount,
|
||||
setDefaultAccount,
|
||||
cancelAuth,
|
||||
logout,
|
||||
} = useCodexOauth();
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className || ""}`}>
|
||||
{/* 认证状态标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t("codexOauth.authStatus", "ChatGPT Plus/Pro 认证")}</Label>
|
||||
<Badge
|
||||
variant={hasAnyAccount ? "default" : "secondary"}
|
||||
className={hasAnyAccount ? "bg-green-500 hover:bg-green-600" : ""}
|
||||
>
|
||||
{hasAnyAccount
|
||||
? t("codexOauth.accountCount", {
|
||||
count: accounts.length,
|
||||
defaultValue: `${accounts.length} 个账号`,
|
||||
})
|
||||
: t("codexOauth.notAuthenticated", "未认证")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 账号选择器 */}
|
||||
{hasAnyAccount && onAccountSelect && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("codexOauth.selectAccount", "选择账号")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedAccountId || "none"}
|
||||
onValueChange={handleAccountSelect}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"codexOauth.selectAccountPlaceholder",
|
||||
"选择一个 ChatGPT 账号",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
<span className="text-muted-foreground">
|
||||
{t("codexOauth.useDefaultAccount", "使用默认账号")}
|
||||
</span>
|
||||
</SelectItem>
|
||||
{accounts.map((account) => (
|
||||
<SelectItem key={account.id} value={account.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{account.login}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已登录账号列表 */}
|
||||
{hasAnyAccount && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("codexOauth.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">
|
||||
<User className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{account.login}</span>
|
||||
{defaultAccountId === account.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("codexOauth.defaultAccount", "默认")}
|
||||
</Badge>
|
||||
)}
|
||||
{selectedAccountId === account.id && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("codexOauth.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("codexOauth.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("codexOauth.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"
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
{t("codexOauth.loginWithChatGPT", "使用 ChatGPT 登录")}
|
||||
</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("codexOauth.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("codexOauth.waitingForAuth", "等待授权中...")}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
{t("codexOauth.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("codexOauth.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("codexOauth.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("codexOauth.logoutAll", "注销所有账号")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodexOAuthSection;
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
useOmoDraftState,
|
||||
useOpenclawFormState,
|
||||
useCopilotAuth,
|
||||
useCodexOauth,
|
||||
} from "./hooks";
|
||||
import {
|
||||
CLAUDE_DEFAULT_CONFIG,
|
||||
@@ -346,11 +347,19 @@ export function ProviderForm({
|
||||
// Copilot OAuth 认证状态(仅 Claude 应用需要)
|
||||
const { isAuthenticated: isCopilotAuthenticated } = useCopilotAuth();
|
||||
|
||||
// Codex OAuth 认证状态(ChatGPT Plus/Pro 反代)
|
||||
const { isAuthenticated: isCodexOauthAuthenticated } = useCodexOauth();
|
||||
|
||||
// 选中的 GitHub 账号 ID(多账号支持)
|
||||
const [selectedGitHubAccountId, setSelectedGitHubAccountId] = useState<
|
||||
string | null
|
||||
>(() => resolveManagedAccountId(initialData?.meta, "github_copilot"));
|
||||
|
||||
// 选中的 ChatGPT 账号 ID(Codex OAuth 多账号支持)
|
||||
const [selectedCodexAccountId, setSelectedCodexAccountId] = useState<
|
||||
string | null
|
||||
>(() => resolveManagedAccountId(initialData?.meta, "codex_oauth"));
|
||||
|
||||
const {
|
||||
codexAuth,
|
||||
codexConfig,
|
||||
@@ -782,6 +791,9 @@ export function ProviderForm({
|
||||
templatePreset?.providerType === "github_copilot" ||
|
||||
initialData?.meta?.providerType === "github_copilot" ||
|
||||
baseUrl.includes("githubcopilot.com");
|
||||
const isCodexOauthProvider =
|
||||
templatePreset?.providerType === "codex_oauth" ||
|
||||
initialData?.meta?.providerType === "codex_oauth";
|
||||
// GitHub Copilot 必须先登录才能添加
|
||||
if (isCopilotProvider && !isCopilotAuthenticated) {
|
||||
toast.error(
|
||||
@@ -791,10 +803,19 @@ export function ProviderForm({
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Codex OAuth 必须先登录才能添加
|
||||
if (isCodexOauthProvider && !isCodexOauthAuthenticated) {
|
||||
toast.error(
|
||||
t("codexOauth.loginRequired", {
|
||||
defaultValue: "请先登录 ChatGPT 账号",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (category !== "official" && category !== "cloud_provider") {
|
||||
if (appId === "claude") {
|
||||
if (!baseUrl.trim()) {
|
||||
if (!isCodexOauthProvider && !baseUrl.trim()) {
|
||||
toast.error(
|
||||
t("providerForm.endpointRequired", {
|
||||
defaultValue: "非官方供应商请填写 API 端点",
|
||||
@@ -802,7 +823,7 @@ export function ProviderForm({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isCopilotProvider && !apiKey.trim()) {
|
||||
if (!isCopilotProvider && !isCodexOauthProvider && !apiKey.trim()) {
|
||||
toast.error(
|
||||
t("providerForm.apiKeyRequired", {
|
||||
defaultValue: "非官方供应商请填写 API Key",
|
||||
@@ -1015,7 +1036,7 @@ export function ProviderForm({
|
||||
? useGeminiCommonConfigFlag
|
||||
: undefined,
|
||||
endpointAutoSelect,
|
||||
// 保存 providerType(用于识别 Copilot 等特殊供应商)
|
||||
// 保存 providerType(用于识别 Copilot / Codex OAuth 等特殊供应商)
|
||||
providerType,
|
||||
authBinding: isCopilotProvider
|
||||
? {
|
||||
@@ -1023,7 +1044,13 @@ export function ProviderForm({
|
||||
authProvider: "github_copilot",
|
||||
accountId: selectedGitHubAccountId ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
: isCodexOauthProvider
|
||||
? {
|
||||
source: "managed_account",
|
||||
authProvider: "codex_oauth",
|
||||
accountId: selectedCodexAccountId ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
// GitHub Copilot 多账号:保存关联的账号 ID
|
||||
githubAccountId:
|
||||
isCopilotProvider && selectedGitHubAccountId
|
||||
@@ -1493,15 +1520,24 @@ export function ProviderForm({
|
||||
initialData?.meta?.providerType === "github_copilot" ||
|
||||
baseUrl.includes("githubcopilot.com")
|
||||
}
|
||||
isCodexOauthPreset={
|
||||
templatePreset?.providerType === "codex_oauth" ||
|
||||
initialData?.meta?.providerType === "codex_oauth"
|
||||
}
|
||||
usesOAuth={
|
||||
templatePreset?.requiresOAuth === true ||
|
||||
templatePreset?.providerType === "github_copilot" ||
|
||||
initialData?.meta?.providerType === "github_copilot" ||
|
||||
baseUrl.includes("githubcopilot.com")
|
||||
baseUrl.includes("githubcopilot.com") ||
|
||||
templatePreset?.providerType === "codex_oauth" ||
|
||||
initialData?.meta?.providerType === "codex_oauth"
|
||||
}
|
||||
isCopilotAuthenticated={isCopilotAuthenticated}
|
||||
selectedGitHubAccountId={selectedGitHubAccountId}
|
||||
onGitHubAccountSelect={setSelectedGitHubAccountId}
|
||||
isCodexOauthAuthenticated={isCodexOauthAuthenticated}
|
||||
selectedCodexAccountId={selectedCodexAccountId}
|
||||
onCodexAccountSelect={setSelectedCodexAccountId}
|
||||
templateValueEntries={templateValueEntries}
|
||||
templateValues={templateValues}
|
||||
templatePresetName={templatePreset?.name || ""}
|
||||
|
||||
@@ -18,3 +18,4 @@ export { useOpencodeFormState } from "./useOpencodeFormState";
|
||||
export { useOmoDraftState } from "./useOmoDraftState";
|
||||
export { useOpenclawFormState } from "./useOpenclawFormState";
|
||||
export { useCopilotAuth } from "./useCopilotAuth";
|
||||
export { useCodexOauth } from "./useCodexOauth";
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useManagedAuth } from "./useManagedAuth";
|
||||
|
||||
/**
|
||||
* Codex OAuth (ChatGPT Plus/Pro) 认证 hook
|
||||
*
|
||||
* 复用通用 useManagedAuth,仅指定 provider 为 "codex_oauth"
|
||||
*/
|
||||
export function useCodexOauth() {
|
||||
return useManagedAuth("codex_oauth");
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Github, ShieldCheck } from "lucide-react";
|
||||
import { Github, ShieldCheck, Sparkles } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CopilotAuthSection } from "@/components/providers/forms/CopilotAuthSection";
|
||||
import { CodexOAuthSection } from "@/components/providers/forms/CodexOAuthSection";
|
||||
|
||||
export function AuthCenterPanel() {
|
||||
const { t } = useTranslation();
|
||||
@@ -50,6 +51,25 @@ export function AuthCenterPanel() {
|
||||
|
||||
<CopilotAuthSection />
|
||||
</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">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium">ChatGPT (Codex OAuth)</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.authCenter.codexOauthDescription", {
|
||||
defaultValue:
|
||||
"管理 ChatGPT Plus/Pro 账号,用于将 Claude Code 请求反代到 Codex 后端。仅限个人开发使用。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CodexOAuthSection />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user