Files
CC-Switch/src/components/providers/forms/hooks/useManagedAuth.ts
T
hotelbe 87635e7fc6 feat(copilot): add GitHub Enterprise Server support (#2175)
* feat(copilot): add GitHub Enterprise Server support

* fix(copilot): address GHES PR review findings (P1 + 2×P2)

- P1: Use composite account ID (domain:user_id) for GHES to prevent
  cross-instance ID collisions; github.com keeps plain numeric ID for
  backward compatibilit
- P2-a: Use get_api_endpoint() for model list URL with automatic
  fallback to static URL when dynamic endpoint resolution fails
- P2-b: Add normalize_github_domain() as backend SSOT for domain
  normalization (lowercase, strip protocol/path/query, reject userinfo)

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 20:29:46 +08:00

239 lines
6.9 KiB
TypeScript

import { useState, useCallback, useRef, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { authApi, settingsApi } from "@/lib/api";
import { copyText } from "@/lib/clipboard";
import type {
ManagedAuthProvider,
ManagedAuthStatus,
ManagedAuthDeviceCodeResponse,
} from "@/lib/api";
type PollingState = "idle" | "polling" | "success" | "error";
export function useManagedAuth(
authProvider: ManagedAuthProvider,
githubDomain?: string,
) {
const queryClient = useQueryClient();
const queryKey = ["managed-auth-status", authProvider];
const [pollingState, setPollingState] = useState<PollingState>("idle");
const [deviceCode, setDeviceCode] =
useState<ManagedAuthDeviceCodeResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
null,
);
const pollingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const {
data: authStatus,
isLoading: isLoadingStatus,
refetch: refetchStatus,
} = useQuery<ManagedAuthStatus>({
queryKey,
queryFn: () => authApi.authGetStatus(authProvider),
staleTime: 30000,
});
const stopPolling = useCallback(() => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
if (pollingTimeoutRef.current) {
clearTimeout(pollingTimeoutRef.current);
pollingTimeoutRef.current = null;
}
}, []);
useEffect(() => {
return () => {
stopPolling();
};
}, [stopPolling]);
const startLoginMutation = useMutation({
mutationFn: () => authApi.authStartLogin(authProvider, githubDomain),
onSuccess: async (response) => {
setDeviceCode(response);
setPollingState("polling");
setError(null);
try {
await copyText(response.user_code);
} catch (e) {
console.debug("[ManagedAuth] Failed to copy user code:", e);
}
try {
await settingsApi.openExternal(response.verification_uri);
} catch (e) {
console.debug("[ManagedAuth] Failed to open browser:", e);
}
// Add a small buffer on top of GitHub's suggested interval to avoid
// hitting slow_down responses too aggressively during device polling.
const interval = Math.max((response.interval || 5) + 3, 8) * 1000;
const expiresAt = Date.now() + response.expires_in * 1000;
const pollOnce = async () => {
if (Date.now() > expiresAt) {
stopPolling();
setPollingState("error");
setError("Device code expired. Please try again.");
return;
}
try {
const newAccount = await authApi.authPollForAccount(
authProvider,
response.device_code,
githubDomain,
);
if (newAccount) {
stopPolling();
setPollingState("success");
await refetchStatus();
await queryClient.invalidateQueries({ queryKey });
setPollingState("idle");
setDeviceCode(null);
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
if (
!errorMessage.includes("pending") &&
!errorMessage.includes("slow_down")
) {
stopPolling();
setPollingState("error");
setError(errorMessage);
}
}
};
void pollOnce();
pollingIntervalRef.current = setInterval(pollOnce, interval);
pollingTimeoutRef.current = setTimeout(() => {
stopPolling();
setPollingState("error");
setError("Device code expired. Please try again.");
}, response.expires_in * 1000);
},
onError: (e) => {
setPollingState("error");
setError(e instanceof Error ? e.message : String(e));
},
});
const logoutMutation = useMutation({
mutationFn: () => authApi.authLogout(authProvider),
onSuccess: async () => {
setPollingState("idle");
setDeviceCode(null);
setError(null);
queryClient.setQueryData(queryKey, {
provider: authProvider,
authenticated: false,
default_account_id: null,
accounts: [],
});
await queryClient.invalidateQueries({ queryKey });
},
onError: async (e) => {
console.error("[ManagedAuth] Failed to logout:", e);
setError(e instanceof Error ? e.message : String(e));
await refetchStatus();
},
});
const removeAccountMutation = useMutation({
mutationFn: (accountId: string) =>
authApi.authRemoveAccount(authProvider, accountId),
onSuccess: async () => {
setPollingState("idle");
setDeviceCode(null);
setError(null);
await refetchStatus();
await queryClient.invalidateQueries({ queryKey });
},
onError: (e) => {
console.error("[ManagedAuth] Failed to remove account:", e);
setError(e instanceof Error ? e.message : String(e));
},
});
const setDefaultAccountMutation = useMutation({
mutationFn: (accountId: string) =>
authApi.authSetDefaultAccount(authProvider, accountId),
onSuccess: async () => {
await refetchStatus();
await queryClient.invalidateQueries({ queryKey });
},
onError: (e) => {
console.error("[ManagedAuth] Failed to set default account:", e);
setError(e instanceof Error ? e.message : String(e));
},
});
const startAuth = useCallback(() => {
setPollingState("idle");
setDeviceCode(null);
setError(null);
stopPolling();
startLoginMutation.mutate();
}, [startLoginMutation, stopPolling]);
const cancelAuth = useCallback(() => {
stopPolling();
setPollingState("idle");
setDeviceCode(null);
setError(null);
}, [stopPolling]);
const logout = useCallback(() => {
logoutMutation.mutate();
}, [logoutMutation]);
const removeAccount = useCallback(
(accountId: string) => {
removeAccountMutation.mutate(accountId);
},
[removeAccountMutation],
);
const setDefaultAccount = useCallback(
(accountId: string) => {
setDefaultAccountMutation.mutate(accountId);
},
[setDefaultAccountMutation],
);
const accounts = authStatus?.accounts ?? [];
return {
authStatus,
isLoadingStatus,
accounts,
hasAnyAccount: accounts.length > 0,
isAuthenticated: authStatus?.authenticated ?? false,
defaultAccountId: authStatus?.default_account_id ?? null,
migrationError: authStatus?.migration_error ?? null,
pollingState,
deviceCode,
error,
isPolling: pollingState === "polling",
isAddingAccount: startLoginMutation.isPending || pollingState === "polling",
isRemovingAccount: removeAccountMutation.isPending,
isSettingDefaultAccount: setDefaultAccountMutation.isPending,
startAuth,
addAccount: startAuth,
cancelAuth,
logout,
removeAccount,
setDefaultAccount,
refetchStatus,
};
}