mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-23 12:23:35 +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.
|
* 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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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' : ''}`} />
|
||||||
|
|||||||
Reference in New Issue
Block a user