refactor(quota): secure managed provider credentials (#2160)
- add shared owner-only credential storage for OpenCode Go, Ollama Cloud, and Cursor - validate credentials before atomic writes using 0700 directories and 0600 files - replace provider-specific credential routes with an allowlisted lifecycle API - stop automatically reading Ollama's legacy cookie file - stop reading or modifying Cursor's database during regular quota requests - add explicit one-time Cursor credential import without mutating Cursor storage - persist refreshed Cursor credentials only in OpenChamber-managed storage - add Ollama Cloud and Cursor credential controls to provider settings - preserve OpenCode Go tracking through the shared credential flow - add VS Code credential management and Cursor quota parity - reject authentication redirects, enforce request timeouts, and fail on unparseable usage pages - mask stored secrets in API responses and extend quota security coverage - update quota provider documentation
This commit is contained in:
committed by
GitHub
parent
3b92d97795
commit
b09614fd68
@@ -1,110 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
interface CredentialStatus {
|
||||
configured: boolean;
|
||||
workspaceId?: string;
|
||||
authCookieMasked?: string;
|
||||
}
|
||||
|
||||
export const OpenCodeGoCredentials: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [status, setStatus] = React.useState<CredentialStatus | null>(null);
|
||||
const [workspaceId, setWorkspaceId] = React.useState('');
|
||||
const [authCookie, setAuthCookie] = React.useState('');
|
||||
const [busy, setBusy] = React.useState<'save' | 'validate' | 'delete' | null>(null);
|
||||
const workspaceInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const cookieInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadStatus = async () => {
|
||||
try {
|
||||
const response = await runtimeFetch('/api/quota/credentials/opencode-go');
|
||||
if (!response.ok) throw new Error('load failed');
|
||||
const next = await response.json() as CredentialStatus;
|
||||
setStatus(next);
|
||||
setWorkspaceId(next.workspaceId ?? '');
|
||||
} catch {
|
||||
setStatus({ configured: false });
|
||||
}
|
||||
};
|
||||
void loadStatus();
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
const submittedWorkspaceId = workspaceInputRef.current?.value.trim() ?? workspaceId.trim();
|
||||
const submittedAuthCookie = cookieInputRef.current?.value.trim() ?? authCookie.trim();
|
||||
setBusy('save');
|
||||
try {
|
||||
const response = await runtimeFetch('/api/quota/credentials/opencode-go', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId: submittedWorkspaceId, authCookie: submittedAuthCookie }),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(payload?.error);
|
||||
setStatus(payload);
|
||||
setAuthCookie('');
|
||||
toast.success(t('settings.providers.page.openCodeGo.saved'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
toast.error(message || t('settings.providers.page.openCodeGo.saveFailed'));
|
||||
} finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const validate = async () => {
|
||||
setBusy('validate');
|
||||
try {
|
||||
const response = await runtimeFetch('/api/quota/credentials/opencode-go/validate', { method: 'POST' });
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(payload?.error || t('settings.providers.page.openCodeGo.invalid'));
|
||||
toast.success(t('settings.providers.page.openCodeGo.valid'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
toast.error(message || t('settings.providers.page.openCodeGo.invalid'));
|
||||
} finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
setBusy('delete');
|
||||
try {
|
||||
const response = await runtimeFetch('/api/quota/credentials/opencode-go', { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('delete failed');
|
||||
setStatus({ configured: false });
|
||||
setWorkspaceId('');
|
||||
setAuthCookie('');
|
||||
toast.success(t('settings.providers.page.openCodeGo.deleted'));
|
||||
} catch {
|
||||
toast.error(t('settings.providers.page.openCodeGo.deleteFailed'));
|
||||
} finally { setBusy(null); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-settings-item="providers.opencode-go-credentials" className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.openCodeGo.title')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.openCodeGo.description')}</p>
|
||||
</div>
|
||||
<section className="space-y-3 px-2 pb-2 pt-0">
|
||||
<label className="block typography-ui-label text-foreground">
|
||||
{t('settings.providers.page.openCodeGo.workspaceId')}
|
||||
<Input ref={workspaceInputRef} className="mt-1 h-7 font-mono text-xs" value={workspaceId} onChange={(event) => setWorkspaceId(event.target.value)} placeholder="wrk_..." />
|
||||
</label>
|
||||
<label className="block typography-ui-label text-foreground">
|
||||
{t('settings.providers.page.openCodeGo.authCookie')}
|
||||
<Input ref={cookieInputRef} className="mt-1 h-7 font-mono text-xs" type="password" autoComplete="off" value={authCookie} onChange={(event) => setAuthCookie(event.target.value)} placeholder={status?.authCookieMasked ?? 'auth=...'} />
|
||||
</label>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.openCodeGo.help')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="xs" onClick={save} disabled={Boolean(busy)}>{status?.configured ? t('settings.providers.page.openCodeGo.replace') : t('settings.providers.page.openCodeGo.save')}</Button>
|
||||
{status?.configured && <Button variant="outline" size="xs" onClick={validate} disabled={Boolean(busy)}>{t('settings.providers.page.openCodeGo.validate')}</Button>}
|
||||
{status?.configured && <Button variant="destructive" size="xs" onClick={remove} disabled={Boolean(busy)}>{t('settings.providers.page.openCodeGo.delete')}</Button>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -24,7 +24,7 @@ import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { shouldLoadAvailableProviders } from './providerAvailability';
|
||||
import { OpenCodeGoCredentials } from './OpenCodeGoCredentials';
|
||||
import { QuotaCredentials } from './QuotaCredentials';
|
||||
|
||||
const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), {
|
||||
notation: 'compact',
|
||||
@@ -947,7 +947,9 @@ export const ProvidersPage: React.FC = () => {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{(selectedProvider.id === 'opencode' || selectedProvider.id === 'opencode-go') && <OpenCodeGoCredentials />}
|
||||
{(selectedProvider.id === 'opencode' || selectedProvider.id === 'opencode-go') && <QuotaCredentials providerId="opencode-go" providerName="OpenCode Go" />}
|
||||
{(selectedProvider.id === 'ollama' || selectedProvider.id === 'ollama-cloud') && <QuotaCredentials providerId="ollama-cloud" providerName="Ollama Cloud" />}
|
||||
{selectedProvider.id === 'cursor' && <QuotaCredentials providerId="cursor" providerName="Cursor" />}
|
||||
|
||||
{/* Connection Details */}
|
||||
<div data-settings-item="providers.connection-details" className="mb-8">
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
type ProviderId = 'opencode-go' | 'ollama-cloud' | 'cursor';
|
||||
type Status = { configured: boolean; workspaceId?: string; secretMasked?: string };
|
||||
|
||||
export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName: string }> = ({ providerId, providerName }) => {
|
||||
const { t } = useI18n();
|
||||
const [status, setStatus] = React.useState<Status | null>(null);
|
||||
const [values, setValues] = React.useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const route = `/api/quota/credentials/${providerId}`;
|
||||
React.useEffect(() => { void runtimeFetch(route).then(async (response) => {
|
||||
if (!response.ok) throw new Error();
|
||||
const next = await response.json() as Status;
|
||||
setStatus(next); setValues(next.workspaceId ? { workspaceId: next.workspaceId } : {});
|
||||
}).catch(() => setStatus({ configured: false })); }, [route]);
|
||||
const request = async (path: string, method: string, body?: object) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await runtimeFetch(path, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined });
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(payload?.error);
|
||||
if (payload?.configured !== undefined) setStatus(payload);
|
||||
setValues((current) => current.workspaceId ? { workspaceId: current.workspaceId } : {} as Record<string, string>);
|
||||
toast.success(t('settings.providers.page.openCodeGo.saved'));
|
||||
} catch (error) { toast.error(error instanceof Error && error.message ? error.message : t('settings.providers.page.openCodeGo.saveFailed')); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const field = (name: string, label: string, placeholder: string) => <label className="block typography-ui-label text-foreground">{label}<Input className="mt-1 h-7 font-mono text-xs" type={name === 'workspaceId' ? 'text' : 'password'} autoComplete="off" value={values[name] ?? ''} onChange={(event) => setValues((current) => ({ ...current, [name]: event.target.value }))} placeholder={status?.secretMasked ?? placeholder} /></label>;
|
||||
return <div data-settings-item={`providers.${providerId}-credentials`} className="mb-8">
|
||||
<div className="mb-1 px-1"><h3 className="typography-ui-header font-medium text-foreground">{providerName}</h3></div>
|
||||
<section className="space-y-3 px-2 pb-2 pt-0">
|
||||
{providerId === 'opencode-go' && field('workspaceId', t('settings.providers.page.openCodeGo.workspaceId'), 'wrk_...')}
|
||||
{providerId === 'opencode-go' && field('authCookie', t('settings.providers.page.openCodeGo.authCookie'), 'auth=...')}
|
||||
{providerId === 'ollama-cloud' && field('cookie', t('settings.providers.page.openCodeGo.authCookie'), 'session=...')}
|
||||
{providerId === 'cursor' && field('accessToken', t('settings.providers.page.auth.apiKeyLabel'), t('settings.providers.page.auth.apiKeyPlaceholder'))}
|
||||
{providerId === 'cursor' && field('refreshToken', t('settings.providers.page.auth.apiKeyLabel'), t('settings.providers.page.auth.apiKeyPlaceholder'))}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="xs" disabled={busy} onClick={() => request(route, 'PUT', values)}>{status?.configured ? t('settings.providers.page.openCodeGo.replace') : t('settings.providers.page.openCodeGo.save')}</Button>
|
||||
{status?.configured && <Button variant="outline" size="xs" disabled={busy} onClick={() => request(`${route}/validate`, 'POST')}>{t('settings.providers.page.openCodeGo.validate')}</Button>}
|
||||
{providerId === 'cursor' && <Button variant="outline" size="xs" disabled={busy} onClick={() => request(`${route}/import`, 'POST')}>{t('settings.providers.page.actions.connect')}</Button>}
|
||||
{status?.configured && <Button variant="destructive" size="xs" disabled={busy} onClick={() => request(route, 'DELETE')}>{t('settings.providers.page.openCodeGo.delete')}</Button>}
|
||||
</div>
|
||||
</section>
|
||||
</div>;
|
||||
};
|
||||
Reference in New Issue
Block a user