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.
*/
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<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 = () => {
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,
+39 -16
View File
@@ -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<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 */
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';
+41 -15
View File
@@ -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 (
<div className="flex-1 flex items-center justify-center">
@@ -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"
/>
<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}
onChange={(e) => setEditedSecret(e.target.value)}
placeholder="Management secret"
disabled={saving}
disabled={saving || agyAckBypassSaving}
className="pr-20 font-mono text-sm"
/>
<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"
size="sm"
onClick={regenerateSecret}
disabled={saving}
disabled={saving || agyAckBypassSaving}
title="Generate new secure secret"
>
<Sparkles className="w-4 h-4" />
@@ -429,15 +447,23 @@ export default function AuthSection() {
</p>
</div>
<Switch
aria-labelledby="agy-power-user-mode-label"
aria-describedby="agy-power-user-mode-description"
checked={agyAckBypass}
disabled={agyAckBypassLoading || agyAckBypassSaving || saving}
onCheckedChange={saveAgyAckBypass}
/>
</div>
<p className="text-xs text-amber-800/90 dark:text-amber-200/90">
Use only if you fully understand the OAuth suspension/ban risk pattern (#622). CCS
<p
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.
</p>
<span id="agy-power-user-mode-label" className="sr-only">
Toggle AGY power user mode
</span>
</div>
<div className="pt-4 border-t">
@@ -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"
>
<RotateCcw className="w-4 h-4" />
@@ -463,12 +493,8 @@ export default function AuthSection() {
<Button
variant="outline"
size="sm"
onClick={() => {
fetchTokens();
fetchAgyAckBypass();
fetchRawConfig();
}}
disabled={loading || saving}
onClick={refreshAll}
disabled={loading || saving || agyAckBypassSaving}
className="flex-1"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
@@ -478,7 +504,7 @@ export default function AuthSection() {
variant="default"
size="sm"
onClick={saveChanges}
disabled={!hasChanges || saving}
disabled={!hasChanges || saving || agyAckBypassSaving}
className="flex-1"
>
<Save className={`w-4 h-4 mr-2 ${saving ? 'animate-pulse' : ''}`} />