mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
fix(ui): add method-aware kiro auth flow parity
- add Kiro auth-method selector with AWS device-code default - route start requests by method-specific flow and endpoint - support auth_url status polling and delayed URL UX state Refs #552 Refs #233
This commit is contained in:
@@ -18,11 +18,25 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Loader2, ExternalLink, User, Download, Copy, Check } from 'lucide-react';
|
||||
import { useKiroImport } from '@/hooks/use-cliproxy';
|
||||
import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow';
|
||||
import { applyDefaultPreset } from '@/lib/preset-utils';
|
||||
import { isDeviceCodeProvider, isNicknameRequiredProvider } from '@/lib/provider-config';
|
||||
import {
|
||||
DEFAULT_KIRO_AUTH_METHOD,
|
||||
getKiroAuthMethodOption,
|
||||
isDeviceCodeProvider,
|
||||
isNicknameRequiredProvider,
|
||||
KIRO_AUTH_METHOD_OPTIONS,
|
||||
KiroAuthMethod,
|
||||
} from '@/lib/provider-config';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface AddAccountDialogProps {
|
||||
@@ -45,13 +59,16 @@ export function AddAccountDialog({
|
||||
const [callbackUrl, setCallbackUrl] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [kiroAuthMethod, setKiroAuthMethod] = useState<KiroAuthMethod>(DEFAULT_KIRO_AUTH_METHOD);
|
||||
const wasAuthenticatingRef = useRef(false);
|
||||
const authFlow = useCliproxyAuthFlow();
|
||||
const kiroImportMutation = useKiroImport();
|
||||
|
||||
const isKiro = provider === 'kiro';
|
||||
const isDeviceCode = isDeviceCodeProvider(provider);
|
||||
const defaultDeviceCode = isDeviceCodeProvider(provider);
|
||||
const requiresNickname = isNicknameRequiredProvider(provider);
|
||||
const kiroMethodOption = getKiroAuthMethodOption(kiroAuthMethod);
|
||||
const isDeviceCode = isKiro ? kiroMethodOption.flowType === 'device_code' : defaultDeviceCode;
|
||||
const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending;
|
||||
const nicknameTrimmed = nickname.trim();
|
||||
const errorMessage = localError || authFlow.error;
|
||||
@@ -61,6 +78,7 @@ export function AddAccountDialog({
|
||||
setCallbackUrl('');
|
||||
setCopied(false);
|
||||
setLocalError(null);
|
||||
setKiroAuthMethod(DEFAULT_KIRO_AUTH_METHOD);
|
||||
wasAuthenticatingRef.current = false;
|
||||
onClose();
|
||||
};
|
||||
@@ -119,7 +137,12 @@ export function AddAccountDialog({
|
||||
}
|
||||
setLocalError(null);
|
||||
wasAuthenticatingRef.current = true;
|
||||
authFlow.startAuth(provider, { nickname: nicknameTrimmed || undefined });
|
||||
authFlow.startAuth(provider, {
|
||||
nickname: nicknameTrimmed || undefined,
|
||||
kiroMethod: isKiro ? kiroAuthMethod : undefined,
|
||||
flowType: isKiro ? kiroMethodOption.flowType : undefined,
|
||||
startEndpoint: isKiro ? kiroMethodOption.startEndpoint : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleKiroImport = () => {
|
||||
@@ -156,7 +179,7 @@ export function AddAccountDialog({
|
||||
<DialogTitle>Add {displayName} Account</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isKiro
|
||||
? 'Authenticate via browser or import an existing token from Kiro IDE.'
|
||||
? 'Choose a Kiro auth method, then authenticate via browser or import from Kiro IDE.'
|
||||
: isDeviceCode
|
||||
? 'Click Authenticate. A verification code will appear for you to enter on the provider website.'
|
||||
: 'Click Authenticate to get an OAuth URL. Open it in any browser to sign in.'}
|
||||
@@ -164,6 +187,32 @@ export function AddAccountDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Kiro auth method */}
|
||||
{isKiro && !showAuthUI && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kiro-auth-method">Auth Method</Label>
|
||||
<Select
|
||||
value={kiroAuthMethod}
|
||||
onValueChange={(value) => {
|
||||
setKiroAuthMethod(value as KiroAuthMethod);
|
||||
setLocalError(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="kiro-auth-method">
|
||||
<SelectValue placeholder="Select Kiro auth method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{KIRO_AUTH_METHOD_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{kiroMethodOption.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nickname input - only show before auth starts */}
|
||||
{!showAuthUI && (
|
||||
<div className="space-y-2">
|
||||
@@ -282,6 +331,12 @@ export function AddAccountDialog({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Preparing sign-in URL...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ interface AuthFlowState {
|
||||
|
||||
interface StartAuthOptions {
|
||||
nickname?: string;
|
||||
kiroMethod?: string;
|
||||
flowType?: 'authorization_code' | 'device_code';
|
||||
startEndpoint?: 'start' | 'start-url';
|
||||
}
|
||||
|
||||
/** Polling interval for OAuth status check (3 seconds) */
|
||||
@@ -49,6 +52,7 @@ export function useCliproxyAuthFlow() {
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const pollStartRef = useRef<number>(0);
|
||||
const openedAuthUrlRef = useRef(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Clear polling
|
||||
@@ -64,6 +68,7 @@ export function useCliproxyAuthFlow() {
|
||||
return () => {
|
||||
abortControllerRef.current?.abort();
|
||||
stopPolling();
|
||||
openedAuthUrlRef.current = false;
|
||||
};
|
||||
}, [stopPolling]);
|
||||
|
||||
@@ -85,14 +90,46 @@ export function useCliproxyAuthFlow() {
|
||||
const response = await fetch(
|
||||
`/api/cliproxy/auth/${provider}/status?state=${encodeURIComponent(oauthState)}`
|
||||
);
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as {
|
||||
status?: string;
|
||||
error?: string;
|
||||
url?: string;
|
||||
auth_url?: string;
|
||||
verification_url?: string;
|
||||
user_code?: string;
|
||||
};
|
||||
|
||||
if (data.status === 'ok') {
|
||||
stopPolling();
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['account-quota'] });
|
||||
toast.success(`${provider} authentication successful`);
|
||||
openedAuthUrlRef.current = false;
|
||||
setState(INITIAL_STATE);
|
||||
} else if (data.status === 'auth_url') {
|
||||
const authUrl = data.url || data.auth_url;
|
||||
if (authUrl) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
authUrl,
|
||||
}));
|
||||
if (!openedAuthUrlRef.current) {
|
||||
openedAuthUrlRef.current = true;
|
||||
window.open(authUrl, '_blank');
|
||||
}
|
||||
}
|
||||
} else if (data.status === 'device_code') {
|
||||
stopPolling();
|
||||
const details =
|
||||
data.user_code && data.verification_url
|
||||
? `Open ${data.verification_url} and enter code: ${data.user_code}`
|
||||
: 'Switch to Device Code method and try again.';
|
||||
toast.error('Provider returned Device Code flow in callback mode');
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isAuthenticating: false,
|
||||
error: details,
|
||||
}));
|
||||
} else if (data.status === 'error') {
|
||||
stopPolling();
|
||||
const errorMsg = data.error || 'Authentication failed';
|
||||
@@ -103,7 +140,7 @@ export function useCliproxyAuthFlow() {
|
||||
error: errorMsg,
|
||||
}));
|
||||
}
|
||||
// status === 'pending' means continue polling
|
||||
// status === 'wait' (or pending) means continue polling
|
||||
} catch {
|
||||
// Network error - continue polling
|
||||
}
|
||||
@@ -124,12 +161,21 @@ export function useCliproxyAuthFlow() {
|
||||
// Abort any in-progress auth
|
||||
abortControllerRef.current?.abort();
|
||||
stopPolling();
|
||||
openedAuthUrlRef.current = false;
|
||||
|
||||
// Create fresh controller and capture locally to avoid race with cancelAuth
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
const deviceCodeFlow = isDeviceCodeProvider(provider);
|
||||
const flowType =
|
||||
options?.flowType ||
|
||||
(isDeviceCodeProvider(provider) ? 'device_code' : 'authorization_code');
|
||||
const deviceCodeFlow = flowType === 'device_code';
|
||||
const startEndpoint = options?.startEndpoint || (deviceCodeFlow ? 'start' : 'start-url');
|
||||
const payload = {
|
||||
nickname: options?.nickname,
|
||||
kiroMethod: options?.kiroMethod,
|
||||
};
|
||||
|
||||
setState({
|
||||
provider,
|
||||
@@ -142,14 +188,13 @@ export function useCliproxyAuthFlow() {
|
||||
});
|
||||
|
||||
try {
|
||||
if (deviceCodeFlow) {
|
||||
// Device Code Flow: Call /start endpoint which spawns CLIProxyAPI binary.
|
||||
// This emits WebSocket events with userCode that DeviceCodeDialog will display.
|
||||
// The /start endpoint blocks until completion, so we don't await it here.
|
||||
if (startEndpoint === 'start') {
|
||||
// /start spawns CLIProxy binary and blocks until completion.
|
||||
// For Device Code flows, userCode is delivered via WebSocket.
|
||||
fetch(`/api/cliproxy/auth/${provider}/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nickname: options?.nickname }),
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
@@ -159,6 +204,7 @@ export function useCliproxyAuthFlow() {
|
||||
queryClient.invalidateQueries({ queryKey: ['account-quota'] });
|
||||
// Note: No toast here - DeviceCodeDialog's useDeviceCode hook handles success toast
|
||||
// via deviceCodeCompleted WebSocket event to avoid duplicate toasts
|
||||
openedAuthUrlRef.current = false;
|
||||
setState(INITIAL_STATE);
|
||||
} else {
|
||||
const errorMsg = data.error || 'Authentication failed';
|
||||
@@ -183,13 +229,13 @@ export function useCliproxyAuthFlow() {
|
||||
error: message,
|
||||
}));
|
||||
});
|
||||
// Don't await - let the request run in background while DeviceCodeDialog handles UI
|
||||
// Don't await - keeps UI responsive while backend auth is in progress
|
||||
} else {
|
||||
// Authorization Code Flow: Call /start-url to get auth URL immediately (non-blocking)
|
||||
// /start-url uses management API to bootstrap callback/social flows.
|
||||
const response = await fetch(`/api/cliproxy/auth/${provider}/start-url`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nickname: options?.nickname }),
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -202,12 +248,13 @@ export function useCliproxyAuthFlow() {
|
||||
// Update state with auth URL
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
authUrl: data.authUrl,
|
||||
authUrl: data.authUrl || null,
|
||||
oauthState: data.state,
|
||||
}));
|
||||
|
||||
// Auto-open auth URL in new browser tab (fallback URL still shown in dialog)
|
||||
if (data.authUrl) {
|
||||
openedAuthUrlRef.current = true;
|
||||
window.open(data.authUrl, '_blank');
|
||||
}
|
||||
|
||||
@@ -221,6 +268,7 @@ export function useCliproxyAuthFlow() {
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
openedAuthUrlRef.current = false;
|
||||
setState(INITIAL_STATE);
|
||||
return;
|
||||
}
|
||||
@@ -240,6 +288,7 @@ export function useCliproxyAuthFlow() {
|
||||
const currentProvider = state.provider;
|
||||
abortControllerRef.current?.abort();
|
||||
stopPolling();
|
||||
openedAuthUrlRef.current = false;
|
||||
setState(INITIAL_STATE);
|
||||
// Also cancel on backend
|
||||
if (currentProvider) {
|
||||
|
||||
@@ -90,3 +90,67 @@ export const NICKNAME_REQUIRED_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro'];
|
||||
export function isNicknameRequiredProvider(provider: string): boolean {
|
||||
return NICKNAME_REQUIRED_PROVIDERS.includes(provider as CLIProxyProvider);
|
||||
}
|
||||
|
||||
/** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */
|
||||
export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] as const;
|
||||
export type KiroAuthMethod = (typeof KIRO_AUTH_METHODS)[number];
|
||||
|
||||
export type KiroFlowType = 'authorization_code' | 'device_code';
|
||||
export type KiroStartEndpoint = 'start' | 'start-url';
|
||||
|
||||
export interface KiroAuthMethodOption {
|
||||
id: KiroAuthMethod;
|
||||
label: string;
|
||||
description: string;
|
||||
flowType: KiroFlowType;
|
||||
startEndpoint: KiroStartEndpoint;
|
||||
}
|
||||
|
||||
/** UX-first default for issue #233: AWS Builder ID device flow. */
|
||||
export const DEFAULT_KIRO_AUTH_METHOD: KiroAuthMethod = 'aws';
|
||||
|
||||
export const KIRO_AUTH_METHOD_OPTIONS: readonly KiroAuthMethodOption[] = [
|
||||
{
|
||||
id: 'aws',
|
||||
label: 'AWS Builder ID (Recommended)',
|
||||
description: 'Device code flow for AWS organizations and Builder ID accounts.',
|
||||
flowType: 'device_code',
|
||||
startEndpoint: 'start',
|
||||
},
|
||||
{
|
||||
id: 'aws-authcode',
|
||||
label: 'AWS Builder ID (Auth Code)',
|
||||
description: 'Authorization code flow via CLI binary.',
|
||||
flowType: 'authorization_code',
|
||||
startEndpoint: 'start',
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
label: 'Google OAuth',
|
||||
description: 'Social OAuth flow with callback URL support.',
|
||||
flowType: 'authorization_code',
|
||||
startEndpoint: 'start-url',
|
||||
},
|
||||
{
|
||||
id: 'github',
|
||||
label: 'GitHub OAuth',
|
||||
description: 'Social OAuth flow via management API callback.',
|
||||
flowType: 'authorization_code',
|
||||
startEndpoint: 'start-url',
|
||||
},
|
||||
];
|
||||
|
||||
export function isKiroAuthMethod(value: string): value is KiroAuthMethod {
|
||||
return KIRO_AUTH_METHODS.includes(value as KiroAuthMethod);
|
||||
}
|
||||
|
||||
export function normalizeKiroAuthMethod(value?: string): KiroAuthMethod {
|
||||
if (!value) return DEFAULT_KIRO_AUTH_METHOD;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return isKiroAuthMethod(normalized) ? normalized : DEFAULT_KIRO_AUTH_METHOD;
|
||||
}
|
||||
|
||||
export function getKiroAuthMethodOption(method: KiroAuthMethod): KiroAuthMethodOption {
|
||||
const option = KIRO_AUTH_METHOD_OPTIONS.find((candidate) => candidate.id === method);
|
||||
return option || KIRO_AUTH_METHOD_OPTIONS[0];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user