mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 16:16:52 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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' : ''}`} />
|
||||
|
||||
Reference in New Issue
Block a user