fix(quota): use native Tauri dialogs for reset confirmation

- Replace browser confirmations and grant dialog permissions
 - Await explicit consent before consuming reset credits
 - Prevent duplicate resets and restore state on cancellation
 - Add native confirmation regression tests
This commit is contained in:
lzt404 committed 2026-09-06 19:02:49 +08:00
1 parent e4d0059efa
commit 969e8527b3
5 files changed
+215 -33

No files matched your search

+4 -1
View File
@@ -1,4 +1,5 @@
import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { confirm } from '@tauri-apps/plugin-dialog';
import {
Check,
Copy,
@@ -216,7 +217,9 @@ export function AuthFileManagementPage() {
if (getQuotaCacheSnapshot()[key]?.status === 'loading') return;
const cacheGeneration = captureQuotaCacheGeneration();
updateQuotaCache((current) => ({ ...current, [key]: { status: 'loading', rows: [] } }));
const result = await loadQuota(file, { confirmXaiPaidProbe: () => window.confirm(t('quota.xaiProbeConfirm')) });
const result = await loadQuota(file, {
confirmXaiPaidProbe: () => confirm(t('quota.xaiProbeConfirm'), { title: t('quota.title'), kind: 'warning' }),
});
commitQuotaCacheIfCurrent(cacheGeneration, () => {
updateQuotaCache((current) => ({ ...current, [key]: result }));
});
+19 -31
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertCircle, LoaderCircle, RefreshCw } from 'lucide-react';
import { confirm } from '@tauri-apps/plugin-dialog';
import { resetCodexQuotaWithConfirmation } from '../services/quotaActions';
import antigravityIcon from '../assets/icons/antigravity.svg';
import claudeIcon from '../assets/icons/claude.svg';
import codexIcon from '../assets/icons/codex.svg';
@@ -8,7 +10,6 @@ import kimiIcon from '../assets/icons/kimi-light.svg';
import { managementApi, readBoolean, responseList } from '../services/managementApi';
import { formatQuotaReset, useQuotaClock } from '../services/quotaTime';
import {
consumeCodexResetCredit,
fileName,
formatQuotaTimestamp,
idleQuota,
@@ -84,43 +85,28 @@ export function QuotaPage() {
if (getQuotaCacheSnapshot()[key]?.status === 'loading') return;
const cacheGeneration = captureQuotaCacheGeneration();
updateQuotaCache((current) => ({ ...current, [key]: { status: 'loading', rows: [] } }));
const result = await loadQuota(file, { confirmXaiPaidProbe: () => window.confirm(t('quota.xaiProbeConfirm')) });
const result = await loadQuota(file, {
confirmXaiPaidProbe: () => confirm(t('quota.xaiProbeConfirm'), { title: t('quota.title'), kind: 'warning' }),
});
commitQuotaCacheIfCurrent(cacheGeneration, () => {
updateQuotaCache((current) => ({ ...current, [key]: result }));
});
}, [t]);
const resetCodexQuota = useCallback(async (file: AuthFile, quota: QuotaState) => {
if (getQuotaCacheSnapshot()[quotaKey(file)]?.status === 'loading') return;
const confirmed = window.confirm([
t('quota.confirm.title', { name: fileName(file) }),
'',
t('quota.confirm.cost'),
t('quota.confirm.available', { count: quota.resetCredits ?? '—' }),
t('quota.confirm.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) }),
'',
t('quota.confirm.warning'),
].join('\n'));
if (!confirmed) return;
const key = quotaKey(file);
const cacheGeneration = captureQuotaCacheGeneration();
updateQuotaCache((current) => ({ ...current, [key]: { ...current[key], status: 'loading', rows: [] } }));
setError('');
try {
const result = await consumeCodexResetCredit(file);
commitQuotaCacheIfCurrent(cacheGeneration, () => {
updateQuotaCache((current) => ({ ...current, [key]: result }));
});
await resetCodexQuotaWithConfirmation(file, () => confirm([
t('quota.confirm.title', { name: fileName(file) }),
'',
t('quota.confirm.cost'),
t('quota.confirm.available', { count: quota.resetCredits ?? '—' }),
t('quota.confirm.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) }),
'',
t('quota.confirm.warning'),
].join('\n'), { title: t('quota.reset'), kind: 'warning' }));
} catch (requestError) {
commitQuotaCacheIfCurrent(cacheGeneration, () => {
updateQuotaCache((current) => ({
...current,
[key]: {
status: 'error',
rows: [],
error: requestError instanceof Error ? requestError.message : String(requestError),
},
}));
});
setError(requestError instanceof Error ? requestError.message : String(requestError));
}
}, [locale, t]);
@@ -140,7 +126,9 @@ export function QuotaPage() {
const batch = files.slice(index, index + REFRESH_CONCURRENCY);
await Promise.all(batch.map(async (file) => {
const result = await loadQuota(file, {
confirmXaiPaidProbe: () => window.confirm(`${fileName(file)}\n\n${t('quota.xaiProbeConfirm')}`),
confirmXaiPaidProbe: () => confirm(`${fileName(file)}\n\n${t('quota.xaiProbeConfirm')}`, {
title: t('quota.title'), kind: 'warning',
}),
});
commitQuotaCacheIfCurrent(cacheGeneration, () => {
updateQuotaCache((current) => ({ ...current, [quotaKey(file)]: result }));
+44
View File
@@ -0,0 +1,44 @@
import {
captureQuotaCacheGeneration,
commitQuotaCacheIfCurrent,
getQuotaCacheSnapshot,
updateQuotaCache,
} from './quotaCache';
import { consumeCodexResetCredit, idleQuota, quotaKey, type AuthFile, type QuotaState } from './quotaService';
/** Reserve the credential before opening the asynchronous native confirmation dialog. */
export async function resetCodexQuotaWithConfirmation(
file: AuthFile,
confirmReset: () => Promise<boolean>,
): Promise<void> {
const key = quotaKey(file);
const previous = getQuotaCacheSnapshot()[key] ?? idleQuota();
if (previous.status === 'loading') return;
const generation = captureQuotaCacheGeneration();
const pending: QuotaState = { ...previous, status: 'loading' };
updateQuotaCache((current) => ({ ...current, [key]: pending }));
const isCurrent = () => captureQuotaCacheGeneration() === generation
&& getQuotaCacheSnapshot()[key] === pending;
const commit = (quota: QuotaState) => {
commitQuotaCacheIfCurrent(generation, () => {
updateQuotaCache((current) => current[key] === pending ? { ...current, [key]: quota } : current);
});
};
let started = false;
try {
// Never treat a pending promise, dismissal, or unexpected result as consent.
if (await confirmReset() !== true || !isCurrent()) return;
started = true;
commit(await consumeCodexResetCredit(file));
} catch (error) {
if (started) commit({
status: 'error', rows: [], error: error instanceof Error ? error.message : String(error),
});
throw error;
} finally {
// Cancellation and dialog failures preserve the displayed quota and release the lock.
if (!started) commit(previous);
}
}