diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index e426c0df..6dd6dba7 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -7,7 +7,7 @@ * For Kiro: Also shows "Import from IDE" option. */ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { Dialog, DialogContent, @@ -88,6 +88,15 @@ export function AddAccountDialog({ const nicknameTrimmed = nickname.trim(); const errorMessage = localError || authFlow.error; + const fetchAgyBypassState = useCallback(async (): Promise => { + const response = await fetch('/api/settings/auth/antigravity-risk'); + if (!response.ok) { + throw new Error('Failed to load Antigravity power user setting'); + } + const data = (await response.json()) as { antigravityAckBypass?: boolean }; + return data.antigravityAckBypass === true; + }, []); + const resetAndClose = () => { setNickname(''); setCallbackUrl(''); @@ -122,13 +131,9 @@ export function AddAccountDialog({ const loadAgyBypassState = async () => { try { setAgyAckBypassLoading(true); - const response = await fetch('/api/settings/auth/antigravity-risk'); - if (!response.ok) { - throw new Error('Failed to load Antigravity power user setting'); - } - const data = (await response.json()) as { antigravityAckBypass?: boolean }; + const enabled = await fetchAgyBypassState(); if (!cancelled) { - setAgyAckBypassEnabled(data.antigravityAckBypass === true); + setAgyAckBypassEnabled(enabled); } } catch { if (!cancelled) { @@ -146,7 +151,48 @@ export function AddAccountDialog({ return () => { cancelled = true; }; - }, [open, provider]); + }, [fetchAgyBypassState, open, provider]); + + useEffect(() => { + if (!open || provider !== 'agy' || !authFlow.error || !agyAckBypassEnabled) { + return; + } + + const normalizedError = authFlow.error.toLowerCase(); + const ackRequired = + normalizedError.includes('agy_risk_ack_required') || + normalizedError.includes('responsibility acknowledgement') || + normalizedError.includes('responsibility checklist'); + if (!ackRequired) return; + + let cancelled = false; + + const syncBypassState = async () => { + try { + setAgyAckBypassLoading(true); + const enabled = await fetchAgyBypassState(); + if (cancelled) return; + setAgyAckBypassEnabled(enabled); + if (!enabled) { + setLocalError('Power user mode is off. Complete the AGY checklist and retry.'); + } + } catch { + if (cancelled) return; + setAgyAckBypassEnabled(false); + setLocalError('Power user mode is off. Complete the AGY checklist and retry.'); + } finally { + if (!cancelled) { + setAgyAckBypassLoading(false); + } + } + }; + + void syncBypassState(); + + return () => { + cancelled = true; + }; + }, [agyAckBypassEnabled, authFlow.error, fetchAgyBypassState, open, provider]); // When authFlow completes successfully (polling detected success), apply preset and close useEffect(() => { @@ -226,7 +272,7 @@ export function AddAccountDialog({ riskAcknowledgement: requiresAgyResponsibilityFlow ? { version: ANTIGRAVITY_ACK_VERSION, - reviewedIssue622: agyRiskChecklist.reviewedIssue622, + reviewedIssue509: agyRiskChecklist.reviewedIssue509, understandsBanRisk: agyRiskChecklist.understandsBanRisk, acceptsFullResponsibility: agyRiskChecklist.acceptsFullResponsibility, typedPhrase: agyRiskChecklist.typedPhrase, diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index 4f8bb8f9..c31c42e8 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -30,7 +30,7 @@ interface StartAuthOptions { startEndpoint?: 'start' | 'start-url'; riskAcknowledgement?: { version: string; - reviewedIssue622: boolean; + reviewedIssue509: boolean; understandsBanRisk: boolean; acceptsFullResponsibility: boolean; typedPhrase: string; @@ -42,6 +42,19 @@ const POLL_INTERVAL = 3000; /** Maximum polling duration (5 minutes) */ const MAX_POLL_DURATION = 5 * 60 * 1000; +async function parseResponseBody(response: Response): Promise> { + const text = await response.text(); + if (!text) return {}; + + try { + return JSON.parse(text) as Record; + } catch { + const fallbackError = + response.status >= 400 ? `Request failed with status ${response.status}` : undefined; + return fallbackError ? { error: fallbackError } : {}; + } +} + /** Initial state for auth flow - extracted for DRY */ const INITIAL_STATE: AuthFlowState = { provider: null, @@ -206,8 +219,9 @@ export function useCliproxyAuthFlow() { signal: controller.signal, }) .then(async (response) => { - const data = await response.json(); - if (response.ok && data.success) { + const data = await parseResponseBody(response); + const success = data.success === true; + if (response.ok && success) { queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); // Note: No toast here - DeviceCodeDialog's useDeviceCode hook handles success toast @@ -215,7 +229,8 @@ export function useCliproxyAuthFlow() { openedAuthUrlRef.current = false; setState(INITIAL_STATE); } else { - const errorMsg = data.error || 'Authentication failed'; + const errorMsg = + typeof data.error === 'string' ? data.error : 'Authentication failed'; toast.error(errorMsg); setState((prev) => ({ ...prev, @@ -247,30 +262,35 @@ export function useCliproxyAuthFlow() { signal: controller.signal, }); - const data = await response.json(); + const data = await parseResponseBody(response); + const success = data.success === true; - if (!response.ok || !data.success) { - throw new Error(data.error || 'Failed to start OAuth'); + if (!response.ok || !success) { + const errorMsg = typeof data.error === 'string' ? data.error : 'Failed to start OAuth'; + throw new Error(errorMsg); } + const authUrl = typeof data.authUrl === 'string' ? data.authUrl : null; + const oauthState = typeof data.state === 'string' ? data.state : null; + // Update state with auth URL setState((prev) => ({ ...prev, - authUrl: data.authUrl || null, - oauthState: data.state, + authUrl, + oauthState, })); // Auto-open auth URL in new browser tab (fallback URL still shown in dialog) - if (data.authUrl) { + if (authUrl) { openedAuthUrlRef.current = true; - window.open(data.authUrl, '_blank'); + window.open(authUrl, '_blank'); } // Start polling for completion - if (data.state) { + if (oauthState) { pollStartRef.current = Date.now(); pollIntervalRef.current = setInterval(() => { - pollStatus(provider, data.state); + pollStatus(provider, oauthState); }, POLL_INTERVAL); } } @@ -319,16 +339,19 @@ export function useCliproxyAuthFlow() { body: JSON.stringify({ redirectUrl }), }); - const data = await response.json(); + const data = await parseResponseBody(response); + const success = data.success === true; - if (response.ok && data.success) { + if (response.ok && success) { stopPolling(); queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); toast.success(`${state.provider} authentication successful`); setState(INITIAL_STATE); } else { - throw new Error(data.error || 'Callback submission failed'); + const errorMsg = + typeof data.error === 'string' ? data.error : 'Callback submission failed'; + throw new Error(errorMsg); } } catch (error) { const message = error instanceof Error ? error.message : 'Failed to submit callback'; diff --git a/ui/src/pages/settings/sections/auth-section.tsx b/ui/src/pages/settings/sections/auth-section.tsx index 6061629d..fec3c31b 100644 --- a/ui/src/pages/settings/sections/auth-section.tsx +++ b/ui/src/pages/settings/sections/auth-section.tsx @@ -3,7 +3,7 @@ * Settings section for CLIProxy auth tokens (API key and management secret) */ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Alert, AlertDescription } from '@/components/ui/alert'; @@ -56,6 +56,7 @@ export default function AuthSection() { const [agyAckBypass, setAgyAckBypass] = useState(false); const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(true); const [agyAckBypassSaving, setAgyAckBypassSaving] = useState(false); + const agyAckBypassSavingRef = useRef(false); // Fetch tokens const fetchTokens = useCallback(async () => { @@ -117,6 +118,8 @@ export default function AuthSection() { // Save all changes const saveChanges = async () => { + if (agyAckBypassSaving) return; + const hasApiKeyChange = editedApiKey !== null && editedApiKey !== tokens?.apiKey.value; const hasSecretChange = editedSecret !== null && editedSecret !== tokens?.managementSecret.value; @@ -156,6 +159,8 @@ export default function AuthSection() { // Regenerate management secret const regenerateSecret = async () => { + if (agyAckBypassSaving) return; + try { setSaving(true); setError(null); @@ -180,6 +185,8 @@ export default function AuthSection() { // Reset to defaults const resetToDefaults = async () => { + if (agyAckBypassSaving) return; + try { setSaving(true); setError(null); @@ -220,14 +227,17 @@ export default function AuthSection() { }; const saveAgyAckBypass = async (nextValue: boolean) => { + if (agyAckBypassSavingRef.current || agyAckBypassSaving || saving) return; + if (nextValue) { const confirmed = window.confirm( - 'Enable Antigravity power user mode?\n\nThis disables AGY responsibility checklist prompts in CLI and dashboard. You accept full responsibility for OAuth/account risk.' + 'Enable AGY power user mode?\n\nThis skips AGY safety prompts. You accept the OAuth risk.' ); if (!confirmed) return; } try { + agyAckBypassSavingRef.current = true; setAgyAckBypassSaving(true); setError(null); @@ -250,10 +260,18 @@ export default function AuthSection() { } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); } finally { + agyAckBypassSavingRef.current = false; setAgyAckBypassSaving(false); } }; + const refreshAll = async () => { + if (loading || saving || agyAckBypassSaving) return; + setError(null); + setSuccess(null); + await Promise.all([fetchTokens(), fetchAgyAckBypass(), fetchRawConfig()]); + }; + if (loading || !tokens) { return (
@@ -326,7 +344,7 @@ export default function AuthSection() { value={displayApiKey} onChange={(e) => setEditedApiKey(e.target.value)} placeholder="API key" - disabled={saving} + disabled={saving || agyAckBypassSaving} className="pr-20 font-mono text-sm" />
@@ -377,7 +395,7 @@ export default function AuthSection() { value={displaySecret} onChange={(e) => setEditedSecret(e.target.value)} placeholder="Management secret" - disabled={saving} + disabled={saving || agyAckBypassSaving} className="pr-20 font-mono text-sm" />
@@ -408,7 +426,7 @@ export default function AuthSection() { variant="outline" size="sm" onClick={regenerateSecret} - disabled={saving} + disabled={saving || agyAckBypassSaving} title="Generate new secure secret" > @@ -429,15 +447,23 @@ export default function AuthSection() {

-

- Use only if you fully understand the OAuth suspension/ban risk pattern (#622). CCS +

+ Use only if you fully understand the OAuth suspension/ban risk pattern (#509). CCS cannot assume responsibility for account loss.

+ + Toggle AGY power user mode +
@@ -445,7 +471,11 @@ export default function AuthSection() { variant="outline" size="sm" onClick={resetToDefaults} - disabled={saving || (!tokens.apiKey.isCustom && !tokens.managementSecret.isCustom)} + disabled={ + saving || + agyAckBypassSaving || + (!tokens.apiKey.isCustom && !tokens.managementSecret.isCustom) + } className="gap-2" > @@ -463,12 +493,8 @@ export default function AuthSection() {