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:
1 parent
e4d0059efa
commit
969e8527b3
5 files changed
+215
-33
No files matched your search
@@ -9,6 +9,7 @@
|
||||
"core:window:allow-set-theme",
|
||||
"core:window:allow-set-background-color",
|
||||
"core:webview:allow-set-webview-background-color",
|
||||
"dialog:allow-open"
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-message"
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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 }));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { confirm } from '@tauri-apps/plugin-dialog';
|
||||
import { clearMocks, mockIPC } from '@tauri-apps/api/mocks';
|
||||
import { resetCodexQuotaWithConfirmation } from '../src/services/quotaActions';
|
||||
import { getQuotaCacheSnapshot, pruneQuotaCache, updateQuotaCache } from '../src/services/quotaCache';
|
||||
import { quotaKey, type QuotaState } from '../src/services/quotaService';
|
||||
|
||||
const file = { name: 'confirm-test.json', provider: 'codex', auth_index: 'confirm-test' };
|
||||
const key = quotaKey(file);
|
||||
const previous: QuotaState = {
|
||||
status: 'success', rows: [{ label: '5h', remainingPercent: 0 }], resetCredits: 2,
|
||||
};
|
||||
const ask = () => confirm('Consume one Codex reset credit?', { title: 'Reset quota', kind: 'warning' });
|
||||
let originalWindow: PropertyDescriptor | undefined;
|
||||
let originalCache: ReturnType<typeof getQuotaCacheSnapshot>;
|
||||
let answer: () => Promise<unknown>;
|
||||
let dialogCalls: unknown[];
|
||||
let upstreamCalls: { url: string; method: string }[];
|
||||
let consumeError: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { value: {}, writable: true, configurable: true });
|
||||
originalCache = getQuotaCacheSnapshot();
|
||||
updateQuotaCache({ [key]: previous });
|
||||
answer = async () => 'Cancel';
|
||||
dialogCalls = [];
|
||||
upstreamCalls = [];
|
||||
consumeError = false;
|
||||
mockIPC((command, payload) => {
|
||||
if (command === 'plugin:dialog|message') {
|
||||
dialogCalls.push(payload);
|
||||
return answer();
|
||||
}
|
||||
if (command === 'management_request') {
|
||||
const request = (payload as { request: { path: string; body: { url: string; method: string } } }).request;
|
||||
expect(request.path).toBe('/api-call');
|
||||
upstreamCalls.push(request.body);
|
||||
if (request.body.url.endsWith('/consume') && consumeError) return { status_code: 409, body: 'reset denied' };
|
||||
return {
|
||||
status_code: 200,
|
||||
body: request.body.url.endsWith('/usage')
|
||||
? { rate_limit: { primary_window: { used_percent: 0 } } }
|
||||
: { available_count: 1 },
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearMocks();
|
||||
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
updateQuotaCache(originalCache);
|
||||
});
|
||||
|
||||
describe('native quota reset confirmation', () => {
|
||||
it('uses the native dialog IPC and has its required Tauri capability', async () => {
|
||||
const capabilities = await Bun.file(new URL('../src-tauri/capabilities/default.json', import.meta.url)).json();
|
||||
expect(capabilities.permissions).toContain('dialog:allow-message');
|
||||
await resetCodexQuotaWithConfirmation(file, ask);
|
||||
expect(dialogCalls).toEqual([{
|
||||
message: 'Consume one Codex reset credit?', title: 'Reset quota', kind: 'warning', buttons: 'OkCancel',
|
||||
}]);
|
||||
expect(upstreamCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('waits for explicit confirmation before sending any request', async () => {
|
||||
let resolve!: (value: string) => void;
|
||||
answer = () => new Promise((done) => { resolve = done; });
|
||||
const resetting = resetCodexQuotaWithConfirmation(file, ask);
|
||||
expect(getQuotaCacheSnapshot()[key].status).toBe('loading');
|
||||
expect(upstreamCalls).toHaveLength(0);
|
||||
resolve('Ok');
|
||||
await resetting;
|
||||
expect(upstreamCalls.filter((request) => request.url.endsWith('/consume'))).toHaveLength(1);
|
||||
expect(upstreamCalls[0].method).toBe('POST');
|
||||
expect(getQuotaCacheSnapshot()[key]).toMatchObject({ status: 'success', resetCredits: 1 });
|
||||
});
|
||||
|
||||
it.each(['Cancel', null, undefined, 'unexpected'])('does not consume a credit when dismissed with %s', async (result) => {
|
||||
answer = async () => result;
|
||||
await resetCodexQuotaWithConfirmation(file, ask);
|
||||
expect(upstreamCalls).toHaveLength(0);
|
||||
expect(getQuotaCacheSnapshot()[key]).toBe(previous);
|
||||
});
|
||||
|
||||
it('restores the previous quota when opening the dialog fails, and allows retry', async () => {
|
||||
answer = async () => { throw new Error('dialog permission denied'); };
|
||||
await expect(resetCodexQuotaWithConfirmation(file, ask)).rejects.toThrow('dialog permission denied');
|
||||
expect(getQuotaCacheSnapshot()[key]).toBe(previous);
|
||||
expect(upstreamCalls).toHaveLength(0);
|
||||
answer = async () => 'Ok';
|
||||
await resetCodexQuotaWithConfirmation(file, ask);
|
||||
expect(upstreamCalls.filter((request) => request.url.endsWith('/consume'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not open another dialog or consume twice on repeated clicks', async () => {
|
||||
let resolve!: (value: string) => void;
|
||||
answer = () => new Promise((done) => { resolve = done; });
|
||||
const first = resetCodexQuotaWithConfirmation(file, ask);
|
||||
await resetCodexQuotaWithConfirmation(file, ask);
|
||||
expect(dialogCalls).toHaveLength(1);
|
||||
expect(upstreamCalls).toHaveLength(0);
|
||||
resolve('Ok');
|
||||
await first;
|
||||
expect(upstreamCalls.filter((request) => request.url.endsWith('/consume'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ignores confirmation if the credential was removed while the dialog was open', async () => {
|
||||
let resolve!: (value: string) => void;
|
||||
answer = () => new Promise((done) => { resolve = done; });
|
||||
const resetting = resetCodexQuotaWithConfirmation(file, ask);
|
||||
pruneQuotaCache(new Set());
|
||||
resolve('Ok');
|
||||
await resetting;
|
||||
expect(upstreamCalls).toHaveLength(0);
|
||||
expect(getQuotaCacheSnapshot()[key]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not overwrite newer quota state after a pending dialog is cancelled', async () => {
|
||||
let resolve!: (value: string) => void;
|
||||
answer = () => new Promise((done) => { resolve = done; });
|
||||
const resetting = resetCodexQuotaWithConfirmation(file, ask);
|
||||
const newer: QuotaState = { status: 'success', rows: [], resetCredits: 3 };
|
||||
updateQuotaCache({ [key]: newer });
|
||||
resolve('Cancel');
|
||||
await resetting;
|
||||
expect(getQuotaCacheSnapshot()[key]).toBe(newer);
|
||||
expect(upstreamCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('reports reset failures and releases the pending state', async () => {
|
||||
answer = async () => 'Ok';
|
||||
consumeError = true;
|
||||
await expect(resetCodexQuotaWithConfirmation(file, ask)).rejects.toThrow('reset denied');
|
||||
expect(getQuotaCacheSnapshot()[key]).toMatchObject({ status: 'error', error: 'reset denied' });
|
||||
});
|
||||
|
||||
it('does not reintroduce browser confirmation in quota actions', async () => {
|
||||
const page = await Bun.file(new URL('../src/pages/QuotaPage.tsx', import.meta.url)).text();
|
||||
expect(page).not.toContain('window.confirm');
|
||||
expect(page).toContain('await resetCodexQuotaWithConfirmation(file, () => confirm(');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user