fix(ui): improve agy auth UX and flow resilience

- re-sync AGY bypass state when backend requires risk acknowledgement

- harden auth flow response parsing for non-JSON error payloads

- prevent save collisions in Auth settings and add switch accessibility labels

- disable risky concurrent actions and add guarded refresh behavior
This commit is contained in:
Tam Nhu Tran
2026-02-24 18:09:56 +07:00
parent b602ab99ab
commit 6e20cdcdff
3 changed files with 135 additions and 40 deletions
@@ -7,7 +7,7 @@
* For Kiro: Also shows "Import from IDE" option. * For Kiro: Also shows "Import from IDE" option.
*/ */
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -88,6 +88,15 @@ export function AddAccountDialog({
const nicknameTrimmed = nickname.trim(); const nicknameTrimmed = nickname.trim();
const errorMessage = localError || authFlow.error; const errorMessage = localError || authFlow.error;
const fetchAgyBypassState = useCallback(async (): Promise<boolean> => {
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 = () => { const resetAndClose = () => {
setNickname(''); setNickname('');
setCallbackUrl(''); setCallbackUrl('');
@@ -122,13 +131,9 @@ export function AddAccountDialog({
const loadAgyBypassState = async () => { const loadAgyBypassState = async () => {
try { try {
setAgyAckBypassLoading(true); setAgyAckBypassLoading(true);
const response = await fetch('/api/settings/auth/antigravity-risk'); const enabled = await fetchAgyBypassState();
if (!response.ok) {
throw new Error('Failed to load Antigravity power user setting');
}
const data = (await response.json()) as { antigravityAckBypass?: boolean };
if (!cancelled) { if (!cancelled) {
setAgyAckBypassEnabled(data.antigravityAckBypass === true); setAgyAckBypassEnabled(enabled);
} }
} catch { } catch {
if (!cancelled) { if (!cancelled) {
@@ -146,7 +151,48 @@ export function AddAccountDialog({
return () => { return () => {
cancelled = true; 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 // When authFlow completes successfully (polling detected success), apply preset and close
useEffect(() => { useEffect(() => {
@@ -226,7 +272,7 @@ export function AddAccountDialog({
riskAcknowledgement: requiresAgyResponsibilityFlow riskAcknowledgement: requiresAgyResponsibilityFlow
? { ? {
version: ANTIGRAVITY_ACK_VERSION, version: ANTIGRAVITY_ACK_VERSION,
reviewedIssue622: agyRiskChecklist.reviewedIssue622, reviewedIssue509: agyRiskChecklist.reviewedIssue509,
understandsBanRisk: agyRiskChecklist.understandsBanRisk, understandsBanRisk: agyRiskChecklist.understandsBanRisk,
acceptsFullResponsibility: agyRiskChecklist.acceptsFullResponsibility, acceptsFullResponsibility: agyRiskChecklist.acceptsFullResponsibility,
typedPhrase: agyRiskChecklist.typedPhrase, typedPhrase: agyRiskChecklist.typedPhrase,
+39 -16
View File
@@ -30,7 +30,7 @@ interface StartAuthOptions {
startEndpoint?: 'start' | 'start-url'; startEndpoint?: 'start' | 'start-url';
riskAcknowledgement?: { riskAcknowledgement?: {
version: string; version: string;
reviewedIssue622: boolean; reviewedIssue509: boolean;
understandsBanRisk: boolean; understandsBanRisk: boolean;
acceptsFullResponsibility: boolean; acceptsFullResponsibility: boolean;
typedPhrase: string; typedPhrase: string;
@@ -42,6 +42,19 @@ const POLL_INTERVAL = 3000;
/** Maximum polling duration (5 minutes) */ /** Maximum polling duration (5 minutes) */
const MAX_POLL_DURATION = 5 * 60 * 1000; const MAX_POLL_DURATION = 5 * 60 * 1000;
async function parseResponseBody(response: Response): Promise<Record<string, unknown>> {
const text = await response.text();
if (!text) return {};
try {
return JSON.parse(text) as Record<string, unknown>;
} 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 */ /** Initial state for auth flow - extracted for DRY */
const INITIAL_STATE: AuthFlowState = { const INITIAL_STATE: AuthFlowState = {
provider: null, provider: null,
@@ -206,8 +219,9 @@ export function useCliproxyAuthFlow() {
signal: controller.signal, signal: controller.signal,
}) })
.then(async (response) => { .then(async (response) => {
const data = await response.json(); const data = await parseResponseBody(response);
if (response.ok && data.success) { const success = data.success === true;
if (response.ok && success) {
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
queryClient.invalidateQueries({ queryKey: ['account-quota'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] });
// Note: No toast here - DeviceCodeDialog's useDeviceCode hook handles success toast // Note: No toast here - DeviceCodeDialog's useDeviceCode hook handles success toast
@@ -215,7 +229,8 @@ export function useCliproxyAuthFlow() {
openedAuthUrlRef.current = false; openedAuthUrlRef.current = false;
setState(INITIAL_STATE); setState(INITIAL_STATE);
} else { } else {
const errorMsg = data.error || 'Authentication failed'; const errorMsg =
typeof data.error === 'string' ? data.error : 'Authentication failed';
toast.error(errorMsg); toast.error(errorMsg);
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
@@ -247,30 +262,35 @@ export function useCliproxyAuthFlow() {
signal: controller.signal, signal: controller.signal,
}); });
const data = await response.json(); const data = await parseResponseBody(response);
const success = data.success === true;
if (!response.ok || !data.success) { if (!response.ok || !success) {
throw new Error(data.error || 'Failed to start OAuth'); 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 // Update state with auth URL
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
authUrl: data.authUrl || null, authUrl,
oauthState: data.state, oauthState,
})); }));
// Auto-open auth URL in new browser tab (fallback URL still shown in dialog) // Auto-open auth URL in new browser tab (fallback URL still shown in dialog)
if (data.authUrl) { if (authUrl) {
openedAuthUrlRef.current = true; openedAuthUrlRef.current = true;
window.open(data.authUrl, '_blank'); window.open(authUrl, '_blank');
} }
// Start polling for completion // Start polling for completion
if (data.state) { if (oauthState) {
pollStartRef.current = Date.now(); pollStartRef.current = Date.now();
pollIntervalRef.current = setInterval(() => { pollIntervalRef.current = setInterval(() => {
pollStatus(provider, data.state); pollStatus(provider, oauthState);
}, POLL_INTERVAL); }, POLL_INTERVAL);
} }
} }
@@ -319,16 +339,19 @@ export function useCliproxyAuthFlow() {
body: JSON.stringify({ redirectUrl }), 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(); stopPolling();
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
queryClient.invalidateQueries({ queryKey: ['account-quota'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] });
toast.success(`${state.provider} authentication successful`); toast.success(`${state.provider} authentication successful`);
setState(INITIAL_STATE); setState(INITIAL_STATE);
} else { } 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) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to submit callback'; const message = error instanceof Error ? error.message : 'Failed to submit callback';
+41 -15
View File
@@ -3,7 +3,7 @@
* Settings section for CLIProxy auth tokens (API key and management secret) * 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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
@@ -56,6 +56,7 @@ export default function AuthSection() {
const [agyAckBypass, setAgyAckBypass] = useState(false); const [agyAckBypass, setAgyAckBypass] = useState(false);
const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(true); const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(true);
const [agyAckBypassSaving, setAgyAckBypassSaving] = useState(false); const [agyAckBypassSaving, setAgyAckBypassSaving] = useState(false);
const agyAckBypassSavingRef = useRef(false);
// Fetch tokens // Fetch tokens
const fetchTokens = useCallback(async () => { const fetchTokens = useCallback(async () => {
@@ -117,6 +118,8 @@ export default function AuthSection() {
// Save all changes // Save all changes
const saveChanges = async () => { const saveChanges = async () => {
if (agyAckBypassSaving) return;
const hasApiKeyChange = editedApiKey !== null && editedApiKey !== tokens?.apiKey.value; const hasApiKeyChange = editedApiKey !== null && editedApiKey !== tokens?.apiKey.value;
const hasSecretChange = const hasSecretChange =
editedSecret !== null && editedSecret !== tokens?.managementSecret.value; editedSecret !== null && editedSecret !== tokens?.managementSecret.value;
@@ -156,6 +159,8 @@ export default function AuthSection() {
// Regenerate management secret // Regenerate management secret
const regenerateSecret = async () => { const regenerateSecret = async () => {
if (agyAckBypassSaving) return;
try { try {
setSaving(true); setSaving(true);
setError(null); setError(null);
@@ -180,6 +185,8 @@ export default function AuthSection() {
// Reset to defaults // Reset to defaults
const resetToDefaults = async () => { const resetToDefaults = async () => {
if (agyAckBypassSaving) return;
try { try {
setSaving(true); setSaving(true);
setError(null); setError(null);
@@ -220,14 +227,17 @@ export default function AuthSection() {
}; };
const saveAgyAckBypass = async (nextValue: boolean) => { const saveAgyAckBypass = async (nextValue: boolean) => {
if (agyAckBypassSavingRef.current || agyAckBypassSaving || saving) return;
if (nextValue) { if (nextValue) {
const confirmed = window.confirm( 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; if (!confirmed) return;
} }
try { try {
agyAckBypassSavingRef.current = true;
setAgyAckBypassSaving(true); setAgyAckBypassSaving(true);
setError(null); setError(null);
@@ -250,10 +260,18 @@ export default function AuthSection() {
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error'); setError(err instanceof Error ? err.message : 'Unknown error');
} finally { } finally {
agyAckBypassSavingRef.current = false;
setAgyAckBypassSaving(false); setAgyAckBypassSaving(false);
} }
}; };
const refreshAll = async () => {
if (loading || saving || agyAckBypassSaving) return;
setError(null);
setSuccess(null);
await Promise.all([fetchTokens(), fetchAgyAckBypass(), fetchRawConfig()]);
};
if (loading || !tokens) { if (loading || !tokens) {
return ( return (
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
@@ -326,7 +344,7 @@ export default function AuthSection() {
value={displayApiKey} value={displayApiKey}
onChange={(e) => setEditedApiKey(e.target.value)} onChange={(e) => setEditedApiKey(e.target.value)}
placeholder="API key" placeholder="API key"
disabled={saving} disabled={saving || agyAckBypassSaving}
className="pr-20 font-mono text-sm" className="pr-20 font-mono text-sm"
/> />
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1"> <div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
@@ -377,7 +395,7 @@ export default function AuthSection() {
value={displaySecret} value={displaySecret}
onChange={(e) => setEditedSecret(e.target.value)} onChange={(e) => setEditedSecret(e.target.value)}
placeholder="Management secret" placeholder="Management secret"
disabled={saving} disabled={saving || agyAckBypassSaving}
className="pr-20 font-mono text-sm" className="pr-20 font-mono text-sm"
/> />
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1"> <div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
@@ -408,7 +426,7 @@ export default function AuthSection() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={regenerateSecret} onClick={regenerateSecret}
disabled={saving} disabled={saving || agyAckBypassSaving}
title="Generate new secure secret" title="Generate new secure secret"
> >
<Sparkles className="w-4 h-4" /> <Sparkles className="w-4 h-4" />
@@ -429,15 +447,23 @@ export default function AuthSection() {
</p> </p>
</div> </div>
<Switch <Switch
aria-labelledby="agy-power-user-mode-label"
aria-describedby="agy-power-user-mode-description"
checked={agyAckBypass} checked={agyAckBypass}
disabled={agyAckBypassLoading || agyAckBypassSaving || saving} disabled={agyAckBypassLoading || agyAckBypassSaving || saving}
onCheckedChange={saveAgyAckBypass} onCheckedChange={saveAgyAckBypass}
/> />
</div> </div>
<p className="text-xs text-amber-800/90 dark:text-amber-200/90"> <p
Use only if you fully understand the OAuth suspension/ban risk pattern (#622). CCS id="agy-power-user-mode-description"
className="text-xs text-amber-800/90 dark:text-amber-200/90"
>
Use only if you fully understand the OAuth suspension/ban risk pattern (#509). CCS
cannot assume responsibility for account loss. cannot assume responsibility for account loss.
</p> </p>
<span id="agy-power-user-mode-label" className="sr-only">
Toggle AGY power user mode
</span>
</div> </div>
<div className="pt-4 border-t"> <div className="pt-4 border-t">
@@ -445,7 +471,11 @@ export default function AuthSection() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={resetToDefaults} onClick={resetToDefaults}
disabled={saving || (!tokens.apiKey.isCustom && !tokens.managementSecret.isCustom)} disabled={
saving ||
agyAckBypassSaving ||
(!tokens.apiKey.isCustom && !tokens.managementSecret.isCustom)
}
className="gap-2" className="gap-2"
> >
<RotateCcw className="w-4 h-4" /> <RotateCcw className="w-4 h-4" />
@@ -463,12 +493,8 @@ export default function AuthSection() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={refreshAll}
fetchTokens(); disabled={loading || saving || agyAckBypassSaving}
fetchAgyAckBypass();
fetchRawConfig();
}}
disabled={loading || saving}
className="flex-1" className="flex-1"
> >
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
@@ -478,7 +504,7 @@ export default function AuthSection() {
variant="default" variant="default"
size="sm" size="sm"
onClick={saveChanges} onClick={saveChanges}
disabled={!hasChanges || saving} disabled={!hasChanges || saving || agyAckBypassSaving}
className="flex-1" className="flex-1"
> >
<Save className={`w-4 h-4 mr-2 ${saving ? 'animate-pulse' : ''}`} /> <Save className={`w-4 h-4 mr-2 ${saving ? 'animate-pulse' : ''}`} />