diff --git a/README.md b/README.md index 21d6d5a6c..f96b71ac5 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ See [Choosing A CPA Panel](https://seakee.github.io/CPA-Manager-Plus/docs/en/gui ### CPA Gateway Management -- Manage CPA provider configurations, including Gemini, Codex, Claude, Vertex, xAI, and OpenAI-compatible providers. +- Manage CPA provider configurations, including Gemini, Codex, Claude, Vertex, xAI, Devin, and OpenAI-compatible providers. - Maintain auth files, OAuth logins, API keys, model aliases, priorities, plugins, logs, and system settings. - Import official Sub2API OpenAI OAuth exports and split multiple accounts into separate CPA Codex auth files. diff --git a/README_CN.md b/README_CN.md index 10fb6c878..212412658 100644 --- a/README_CN.md +++ b/README_CN.md @@ -75,7 +75,7 @@ CPA / CLIProxyAPI 可以在 `:8317` 直接托管官方 Management Center,也 ### CPA 网关管理 -- 管理 CPA Provider 配置,包括 Gemini、Codex、Claude、Vertex、xAI 和 OpenAI-compatible Provider。 +- 管理 CPA Provider 配置,包括 Gemini、Codex、Claude、Vertex、xAI、Devin 和 OpenAI-compatible Provider。 - 维护认证文件、OAuth 登录、API Key、模型别名、优先级、插件、日志和系统配置。 - 导入官方 Sub2API OpenAI OAuth 导出,并把多账号拆分为独立的 CPA Codex 认证文件。 diff --git a/apps/docs/en/manual/accounts.md b/apps/docs/en/manual/accounts.md index 0777ae2af..4ce0fb018 100644 --- a/apps/docs/en/manual/accounts.md +++ b/apps/docs/en/manual/accounts.md @@ -13,7 +13,7 @@ Open the unified [Accounts Demo](https://seakee.github.io/CPA-Manager-Plus/#/dem ## What To Check First -- **Credential and provider**: confirm whether the account belongs to Codex, Claude, Vertex, Antigravity, Kimi, xAI, or another source. +- **Credential and provider**: confirm whether the account belongs to Codex, Claude, Vertex, Antigravity, Kimi, xAI, Devin, or another source. - **`auth_index`**: the stable account index used to connect usage, quota, inspection, and account actions. - **Enabled state**: automated recovery does not override manually disabled accounts. - **Note, priority, and project ID**: use them to separate account purpose and routing preference. diff --git a/apps/docs/en/manual/oauth.md b/apps/docs/en/manual/oauth.md index ea8bb7873..19f68b929 100644 --- a/apps/docs/en/manual/oauth.md +++ b/apps/docs/en/manual/oauth.md @@ -11,6 +11,7 @@ The page shows login methods based on CPA and plugin capabilities. Common entrie - Antigravity OAuth - Kimi OAuth - xAI OAuth +- Devin OAuth - iFlow OAuth - Plugin-provided OAuth - Vertex credential import diff --git a/apps/docs/en/reference/capability-matrix.md b/apps/docs/en/reference/capability-matrix.md index 3751c3b43..792a94171 100644 --- a/apps/docs/en/reference/capability-matrix.md +++ b/apps/docs/en/reference/capability-matrix.md @@ -48,6 +48,7 @@ Docker and native packages provide the same Full Mode capabilities; only the ins | xAI/Grok OAuth | Provider/Auth File/OAuth | CLI billing, paid OAuth identity fallback, and request-event evidence | Local and server | | xAI API Key | `xai-api-key`, priority, models, and key testing | Request results and provider responses | Provider key test | | Gemini / Vertex / Antigravity / Kimi | Provider, auth file, or OAuth depending on CPA | Provider-specific quota or recent request evidence | Depends on CPA and provider APIs | +| Devin | Provider, Auth File, OAuth, `devin.sensitive-words` | Daily/weekly quota percentages, exact reset, Devin plan metadata | Quota read without model requests | | OpenAI-compatible | Base URL, API key, headers, model mapping, and key testing | Request status, latency, redacted failures, and cost | No assumed common quota API | ## Data And Automation Boundaries diff --git a/apps/docs/manual/accounts.md b/apps/docs/manual/accounts.md index b4fa6af55..0533d44a6 100644 --- a/apps/docs/manual/accounts.md +++ b/apps/docs/manual/accounts.md @@ -13,7 +13,7 @@ description: 在统一 Accounts 工作区管理 CPA 凭证、账号健康、配 ## 先看哪些信息 -- **凭证和 Provider**:确认账号属于 Codex、Claude、Vertex、Antigravity、Kimi、xAI 或其他来源。 +- **凭证和 Provider**:确认账号属于 Codex、Claude、Vertex、Antigravity、Kimi、xAI、Devin 或其他来源。 - **`auth_index`**:账号的稳定索引。用量、配额、巡检和账号处理都依赖它做关联。 - **启用状态**:手动禁用的账号不会被自动恢复流程覆盖。 - **备注、优先级和项目 ID**:用于区分账号用途和路由偏好。 diff --git a/apps/docs/manual/oauth.md b/apps/docs/manual/oauth.md index d699dcaa4..e988bb3e5 100644 --- a/apps/docs/manual/oauth.md +++ b/apps/docs/manual/oauth.md @@ -11,6 +11,7 @@ OAuth 登录页面用于添加或重新授权账号。它解决的是“如何 - Antigravity OAuth - Kimi OAuth - xAI OAuth +- Devin OAuth - iFlow OAuth - 插件提供的 OAuth - Vertex 凭证导入 diff --git a/apps/docs/reference/capability-matrix.md b/apps/docs/reference/capability-matrix.md index 6f16d75a7..1d76bb190 100644 --- a/apps/docs/reference/capability-matrix.md +++ b/apps/docs/reference/capability-matrix.md @@ -48,6 +48,7 @@ Docker 和原生包提供相同的完整模式能力,只是安装方式不同 | xAI/Grok OAuth | Provider/Auth File/OAuth | CLI billing、付费 OAuth identity fallback、请求事件证据 | 本地与服务端 | | xAI API Key | `xai-api-key` 配置、优先级、模型和密钥测试 | 请求结果与 Provider 返回信息 | Provider key test | | Gemini / Vertex / Antigravity / Kimi | Provider、Auth File 或 OAuth(按 CPA 能力) | Provider 特定 quota 或最近请求证据 | 取决于 CPA 与 Provider 接口 | +| Devin | Provider、Auth File、OAuth、`devin.sensitive-words` | 日/周额度百分比、精确 reset、Devin 计划名称与起止时间 | 配额读取,不执行模型请求 | | OpenAI-compatible | Base URL、API Key、Header、模型映射和密钥测试 | 请求状态、延迟、失败摘要和成本 | 不假设存在统一 quota API | ## 数据与自动化边界 diff --git a/apps/web/src/assets/icons/devin-dark.svg b/apps/web/src/assets/icons/devin-dark.svg new file mode 100644 index 000000000..571b08c08 --- /dev/null +++ b/apps/web/src/assets/icons/devin-dark.svg @@ -0,0 +1 @@ +Devin diff --git a/apps/web/src/assets/icons/devin.svg b/apps/web/src/assets/icons/devin.svg new file mode 100644 index 000000000..46c253acd --- /dev/null +++ b/apps/web/src/assets/icons/devin.svg @@ -0,0 +1 @@ +Devin diff --git a/apps/web/src/components/config/VisualConfigEditor.tsx b/apps/web/src/components/config/VisualConfigEditor.tsx index 155fa7c71..230f4208d 100644 --- a/apps/web/src/components/config/VisualConfigEditor.tsx +++ b/apps/web/src/components/config/VisualConfigEditor.tsx @@ -43,6 +43,7 @@ import { PayloadFilterRulesEditor, PayloadRulesEditor, PluginStoreAuthEditor, + StringListEditor, } from './VisualConfigEditorBlocks'; import type { ApiKeyMutation } from './ApiKeysCardEditor'; import styles from './VisualConfigEditor.module.scss'; @@ -954,6 +955,34 @@ export function VisualConfigEditor({ error={redisUsageQueueRetentionError} /> + + + + onChange({ devinSensitiveWords })} + /> + + diff --git a/apps/web/src/components/config/VisualConfigEditorBlocks.tsx b/apps/web/src/components/config/VisualConfigEditorBlocks.tsx index dd444962e..4b8cd9687 100644 --- a/apps/web/src/components/config/VisualConfigEditorBlocks.tsx +++ b/apps/web/src/components/config/VisualConfigEditorBlocks.tsx @@ -167,7 +167,7 @@ function buildProtocolOptions( return options; } -const StringListEditor = memo(function StringListEditor({ +export const StringListEditor = memo(function StringListEditor({ value, disabled, placeholder, diff --git a/apps/web/src/components/quota/index.ts b/apps/web/src/components/quota/index.ts index 83b27fb10..9ef893096 100644 --- a/apps/web/src/components/quota/index.ts +++ b/apps/web/src/components/quota/index.ts @@ -3,6 +3,7 @@ export { CLAUDE_CONFIG, CODEX_CONFIG, CODEX_SUMMARY_CONFIG, + DEVIN_CONFIG, KIMI_CONFIG, XAI_CONFIG, buildObservedCodexQuotaState, diff --git a/apps/web/src/components/quota/quotaConfigs.test.ts b/apps/web/src/components/quota/quotaConfigs.test.ts index c3861ff0a..1f26e703a 100644 --- a/apps/web/src/components/quota/quotaConfigs.test.ts +++ b/apps/web/src/components/quota/quotaConfigs.test.ts @@ -8,6 +8,7 @@ import { buildQuotaFailureState, CLAUDE_CONFIG, CODEX_CONFIG, + DEVIN_CONFIG, getCodexQuotaStoreKey, KIMI_CONFIG, getSortedCodexResetCreditExpiries, @@ -47,7 +48,14 @@ describe('getCodexQuotaStoreKey', () => { provider: 'claude', authIndex: 'auth-1', }; - const configs = [CLAUDE_CONFIG, ANTIGRAVITY_CONFIG, CODEX_CONFIG, KIMI_CONFIG, XAI_CONFIG]; + const configs = [ + CLAUDE_CONFIG, + ANTIGRAVITY_CONFIG, + CODEX_CONFIG, + DEVIN_CONFIG, + KIMI_CONFIG, + XAI_CONFIG, + ]; configs.forEach((config) => { expect(config.getStoreKey?.(file)).toBe('shared.json::auth-1'); @@ -1486,3 +1494,56 @@ describe('Codex plan precedence', () => { ]); }); }); + +describe('DEVIN_CONFIG', () => { + it('correctly builds loading, success, and error states', () => { + const file = { name: 'devin.json', type: 'devin', authIndex: 'd-1' }; + + const loading = DEVIN_CONFIG.buildLoadingState(file); + expect(loading).toMatchObject({ + status: 'loading', + windows: [], + observedAtMs: null, + plan: null, + planStartMs: null, + planEndMs: null, + authFileKey: 'devin.json::d-1', + }); + + const success = DEVIN_CONFIG.buildSuccessState( + { + windows: [ + { id: 'daily', remainingPercent: 50, resetAtMs: 1726000000000, periodHours: 24 }, + { id: 'weekly', remainingPercent: 80, resetAtMs: 1726500000000, periodHours: 168 }, + ], + observedAtMs: 1726000000100, + plan: 'Team', + planStartMs: 1725000000000, + planEndMs: 1727000000000, + }, + file + ); + expect(success).toMatchObject({ + status: 'success', + plan: 'Team', + planStartMs: 1725000000000, + planEndMs: 1727000000000, + windows: [ + { id: 'daily', remainingPercent: 50 }, + { id: 'weekly', remainingPercent: 80 }, + ], + fetchedAtMs: 1726000000100, + authFileKey: 'devin.json::d-1', + }); + + const error = DEVIN_CONFIG.buildErrorState('quota failed', 429, file); + expect(error).toMatchObject({ + status: 'error', + windows: [], + error: 'quota failed', + errorStatus: 429, + authFileKey: 'devin.json::d-1', + }); + }); +}); + diff --git a/apps/web/src/components/quota/quotaConfigs.ts b/apps/web/src/components/quota/quotaConfigs.ts index ab9175c0e..c969de23e 100644 --- a/apps/web/src/components/quota/quotaConfigs.ts +++ b/apps/web/src/components/quota/quotaConfigs.ts @@ -7,6 +7,8 @@ import type { CodexQuotaWindow, CodexRateLimitResetCredit, CredentialScopedQuotaState, + DevinQuotaData, + DevinQuotaState, KimiQuotaState, XaiBillingSummary, XaiQuotaState, @@ -25,6 +27,7 @@ import { fetchClaudeQuota, fetchCodexQuota, fetchCodexQuotaSummary, + fetchDevinQuota, fetchKimiQuota, fetchXaiQuota, filterFreshCodexQuotaWindows, @@ -52,7 +55,7 @@ import { scopeQuotaStateToCredential, } from '@/utils/quota/credentialScope'; -type QuotaType = 'antigravity' | 'claude' | 'codex' | 'kimi' | 'xai'; +type QuotaType = 'antigravity' | 'claude' | 'codex' | 'kimi' | 'xai' | 'devin'; export interface QuotaConfig { type: QuotaType; @@ -771,3 +774,43 @@ export const XAI_CONFIG: QuotaConfig = { }), scopeState: scopeCredentialQuotaState, }; + +export const DEVIN_CONFIG: QuotaConfig = { + type: 'devin', + i18nPrefix: 'devin_quota', + fetchQuota: fetchDevinQuota, + getStoreKey: getQuotaCredentialStoreKey, + buildLoadingState: (file) => ({ + status: 'loading', + windows: [], + observedAtMs: null, + plan: null, + planStartMs: null, + planEndMs: null, + ...buildQuotaCredentialIdentity(file), + }), + buildSuccessState: (data, file) => ({ + status: 'success', + windows: data.windows, + observedAtMs: data.observedAtMs, + plan: data.plan, + planStartMs: data.planStartMs, + planEndMs: data.planEndMs, + ...buildQuotaCredentialIdentity(file), + fetchedAtMs: data.observedAtMs ?? Date.now(), + }), + buildErrorState: (message, status, file) => ({ + status: 'error', + windows: [], + observedAtMs: null, + plan: null, + planStartMs: null, + planEndMs: null, + error: message, + errorStatus: status, + ...buildQuotaCredentialIdentity(file), + failedAtMs: Date.now(), + }), + scopeState: scopeCredentialQuotaState, +}; + diff --git a/apps/web/src/components/quota/quotaRefresh.test.ts b/apps/web/src/components/quota/quotaRefresh.test.ts index f58dc2c28..960fb731e 100644 --- a/apps/web/src/components/quota/quotaRefresh.test.ts +++ b/apps/web/src/components/quota/quotaRefresh.test.ts @@ -1,10 +1,10 @@ import type { TFunction } from 'i18next'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { AuthFileItem } from '@/types'; -import { fetchClaudeQuota, fetchCodexQuota } from '@/utils/quota'; +import type { AuthFileItem, DevinQuotaData } from '@/types'; +import { fetchClaudeQuota, fetchCodexQuota, fetchDevinQuota } from '@/utils/quota'; import { getQuotaCredentialStoreKey } from '@/utils/quota/credentialScope'; import { useQuotaStore } from '@/stores/useQuotaStore'; -import { CLAUDE_CONFIG, CODEX_CONFIG, type QuotaConfig } from './quotaConfigs'; +import { CLAUDE_CONFIG, CODEX_CONFIG, DEVIN_CONFIG, type QuotaConfig } from './quotaConfigs'; import { refreshQuotaWithConfig, type QuotaSetter } from './quotaRefresh'; vi.mock('@/utils/quota', async (importOriginal) => { @@ -13,6 +13,7 @@ vi.mock('@/utils/quota', async (importOriginal) => { ...actual, fetchClaudeQuota: vi.fn(), fetchCodexQuota: vi.fn(), + fetchDevinQuota: vi.fn(), }; }); @@ -93,6 +94,7 @@ describe('refreshQuotaWithConfig', () => { useQuotaStore.getState().clearQuotaCache(); vi.mocked(fetchClaudeQuota).mockReset(); vi.mocked(fetchCodexQuota).mockReset(); + vi.mocked(fetchDevinQuota).mockReset(); }); it('writes the Provider state once to the shared Codex store for all consumers', async () => { @@ -306,4 +308,50 @@ describe('refreshQuotaWithConfig', () => { errorStatus: 429, }); }); + + it('does not commit late Devin quota response into new connection state after connection changes', async () => { + useQuotaStore.getState().activateQuotaCacheScope('connection-a'); + + const devinFile = { + name: 'devin.json', + type: 'devin', + provider: 'devin', + authIndex: 'd-1', + } as AuthFileItem; + + const devinData: DevinQuotaData = { + windows: [ + { id: 'daily', remainingPercent: 90, resetAtMs: 1726000000000, periodHours: 24 }, + { id: 'weekly', remainingPercent: 70, resetAtMs: 1726500000000, periodHours: 168 }, + ], + observedAtMs: 1725900000000, + plan: 'Pro', + planStartMs: null, + planEndMs: null, + }; + + const devinDeferred = deferred(); + vi.mocked(fetchDevinQuota).mockReturnValueOnce(devinDeferred.promise); + + let connectionIsCurrent = true; + const refreshPromise = runRefresh( + DEVIN_CONFIG, + devinFile, + useQuotaStore.getState().setDevinQuota, + undefined, + () => connectionIsCurrent + ); + + // Switch to Connection B before request completes + connectionIsCurrent = false; + useQuotaStore.getState().activateQuotaCacheScope('connection-b'); + + // Devin request from Connection A resolves late + devinDeferred.resolve(devinData); + const result = await refreshPromise; + + expect(result).toBeNull(); + const devinStoreKey = getQuotaCredentialStoreKey(devinFile); + expect(useQuotaStore.getState().devinQuota[devinStoreKey]).toBeUndefined(); + }); }); diff --git a/apps/web/src/features/accounts/AccountsPage.tsx b/apps/web/src/features/accounts/AccountsPage.tsx index b1a233b74..cbb55bffa 100644 --- a/apps/web/src/features/accounts/AccountsPage.tsx +++ b/apps/web/src/features/accounts/AccountsPage.tsx @@ -50,6 +50,7 @@ import { CLAUDE_CONFIG, CODEX_CONFIG, CODEX_SUMMARY_CONFIG, + DEVIN_CONFIG, KIMI_CONFIG, XAI_CONFIG, buildObservedCodexQuotaState, @@ -316,7 +317,13 @@ import { type UsageHeaderSnapshot, type UsageHeaderSnapshotsResponse, } from '@/services/api'; -import type { AuthFileItem, CodexQuotaState, XaiQuotaState } from '@/types'; +import type { + AuthFileItem, + CodexQuotaState, + DevinQuotaData, + DevinQuotaState, + XaiQuotaState, +} from '@/types'; import { fetchCodexResetCredits, type CodexResetCreditsData, @@ -1325,6 +1332,7 @@ export function AccountsPage() { const antigravityQuota = useQuotaStore((state) => state.antigravityQuota); const claudeQuota = useQuotaStore((state) => state.claudeQuota); const codexQuota = useQuotaStore((state) => state.codexQuota); + const devinQuota = useQuotaStore((state) => state.devinQuota); const kimiQuota = useQuotaStore((state) => state.kimiQuota); const xaiQuota = useQuotaStore((state) => state.xaiQuota); const baseQuotaStores = useMemo( @@ -1332,14 +1340,16 @@ export function AccountsPage() { antigravityQuota, claudeQuota, codexQuota, + devinQuota, kimiQuota, xaiQuota, }), - [antigravityQuota, claudeQuota, codexQuota, kimiQuota, xaiQuota] + [antigravityQuota, claudeQuota, codexQuota, devinQuota, kimiQuota, xaiQuota] ); const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota); const setClaudeQuota = useQuotaStore((state) => state.setClaudeQuota); const setCodexQuota = useQuotaStore((state) => state.setCodexQuota); + const setDevinQuota = useQuotaStore((state) => state.setDevinQuota); const setKimiQuota = useQuotaStore((state) => state.setKimiQuota); const setXaiQuota = useQuotaStore((state) => state.setXaiQuota); @@ -3003,6 +3013,9 @@ export function AccountsPage() { case XAI_CONFIG.type: prune(XAI_CONFIG, setXaiQuota); break; + case DEVIN_CONFIG.type: + prune(DEVIN_CONFIG, setDevinQuota); + break; default: break; } @@ -3015,6 +3028,7 @@ export function AccountsPage() { setClaudeQuota, setCredentialEvidenceBoundaries, setCodexQuota, + setDevinQuota, setKimiQuota, setXaiQuota, ] @@ -3873,6 +3887,13 @@ export function AccountsPage() { } break; } + case DEVIN_CONFIG.type: { + const state = getCredentialScopedQuotaState(baseQuotaStores.devinQuota, row.raw); + if (state?.status === 'success' && state.windows.length > 0) { + fetchedAtMs = state.fetchedAtMs ?? state.observedAtMs ?? undefined; + } + break; + } default: return undefined; } @@ -6299,6 +6320,14 @@ export function AccountsPage() { getScopedQuotaState(XAI_CONFIG, baseQuotaStores.xaiQuota, row.raw) ) ); + case DEVIN_CONFIG.type: + return toAccountQuotaRefreshOutcome( + await refreshWithConfig( + DEVIN_CONFIG, + setDevinQuota, + getScopedQuotaState(DEVIN_CONFIG, baseQuotaStores.devinQuota, row.raw) + ) + ); default: return { status: 'error', error: t('common.unknown_error') }; } @@ -6308,6 +6337,7 @@ export function AccountsPage() { setAntigravityQuota, setClaudeQuota, setCodexQuota, + setDevinQuota, setKimiQuota, setXaiQuota, t, @@ -9490,6 +9520,10 @@ export function AccountsPage() { history: accountHistoryByRowKey.get(selectedRow.selectionKey) ?? null, valueRow, codexQuota: selectedCodexQuota, + devinQuota: + selectedRow.provider === DEVIN_CONFIG.type + ? getCredentialScopedQuotaState(devinQuota, selectedRow.raw) + : undefined, xaiQuota: selectedRow.provider === XAI_CONFIG.type ? getCredentialScopedQuotaState(xaiQuota, selectedRow.raw) diff --git a/apps/web/src/features/accounts/components/AccountProviderTabs.test.tsx b/apps/web/src/features/accounts/components/AccountProviderTabs.test.tsx index ad2b50030..e1687929a 100644 --- a/apps/web/src/features/accounts/components/AccountProviderTabs.test.tsx +++ b/apps/web/src/features/accounts/components/AccountProviderTabs.test.tsx @@ -11,6 +11,7 @@ vi.mock('react-i18next', () => ({ 'accounts.provider_filter': 'Platform filter', 'auth_files.filter_claude': 'Claude', 'auth_files.filter_codex': 'Codex', + 'auth_files.filter_devin': 'Devin', 'auth_files.filter_xai': 'xAI', })[key] ?? key, }), @@ -36,7 +37,7 @@ describe('AccountProviderTabs', () => { act(() => { renderer = create( changes.push(provider)} resolvedTheme="light" @@ -45,17 +46,26 @@ describe('AccountProviderTabs', () => { }); const tabs = renderer!.root.findAllByProps({ role: 'tab' }); - expect(tabs.map((tab) => readText(tab.props.children))).toEqual(['All3', 'Codex1', 'xAI2']); + expect(tabs.map((tab) => readText(tab.props.children))).toEqual([ + 'All4', + 'Codex1', + 'Devin1', + 'xAI2', + ]); expect(renderer!.root.findByProps({ role: 'tablist' }).props['aria-label']).toBe( 'Platform filter' ); expect(findTab(renderer!, 'all').props['aria-selected']).toBe(true); + act(() => { + findTab(renderer!, 'devin').props.onClick({ preventDefault: () => {} }); + }); + act(() => { findTab(renderer!, 'xai').props.onClick({ preventDefault: () => {} }); }); - expect(changes).toEqual(['xai']); + expect(changes).toEqual(['devin', 'xai']); }); it('keeps an absent deep-linked platform visible with a zero count', () => { diff --git a/apps/web/src/features/accounts/components/accountDetail/AccountQuotaTab.tsx b/apps/web/src/features/accounts/components/accountDetail/AccountQuotaTab.tsx index 6da26c3da..1f358a1c7 100644 --- a/apps/web/src/features/accounts/components/accountDetail/AccountQuotaTab.tsx +++ b/apps/web/src/features/accounts/components/accountDetail/AccountQuotaTab.tsx @@ -211,6 +211,37 @@ export function AccountQuotaTab({ {windowUsageError ?
{windowUsageError}
: null} + {detailView.identity.provider === 'devin' && detailView.quota.devinPlan ? ( +
+
+
+
+
+

+ {t('devin_quota.plan_label')}: {detailView.quota.devinPlan.plan} +

+ {detailView.quota.devinPlan.planStartMs !== null || + detailView.quota.devinPlan.planEndMs !== null ? ( + + {detailView.quota.devinPlan.planStartMs !== null + ? `${t('devin_quota.plan_start')}: ${formatTime(detailView.quota.devinPlan.planStartMs)}` + : ''} + {detailView.quota.devinPlan.planStartMs !== null && + detailView.quota.devinPlan.planEndMs !== null + ? ' · ' + : ''} + {detailView.quota.devinPlan.planEndMs !== null + ? `${t('devin_quota.plan_end')}: ${formatTime(detailView.quota.devinPlan.planEndMs)}` + : ''} + + ) : null} +
+
+
+
+
+ ) : null} + {standardWindows.length > 0 || allWindows.length === 0 ? (
diff --git a/apps/web/src/features/accounts/model/accountDetailViewModel.test.ts b/apps/web/src/features/accounts/model/accountDetailViewModel.test.ts index ac90dec06..59887cf64 100644 --- a/apps/web/src/features/accounts/model/accountDetailViewModel.test.ts +++ b/apps/web/src/features/accounts/model/accountDetailViewModel.test.ts @@ -2740,4 +2740,54 @@ describe('accountDetailViewModel', () => { targetTab: 'quota', }); }); + + it('attaches Devin plan metadata to quota view model when available', () => { + const row = makeRow({ + provider: 'devin', + raw: { name: 'devin.json', type: 'devin', authIndex: '0' }, + }); + const viewModel = buildAccountDetailViewModel(row, { + devinQuota: { + status: 'success', + windows: [], + observedAtMs: 1726000000000, + plan: 'Team', + planStartMs: 1725000000000, + planEndMs: 1727000000000, + }, + }); + + expect(viewModel.quota.devinPlan).toEqual({ + plan: 'Team', + planStartMs: 1725000000000, + planEndMs: 1727000000000, + }); + }); + + it('preserves Devin plan metadata on transient refresh error when plan exists', () => { + const row = makeRow({ + provider: 'devin', + raw: { name: 'devin.json', type: 'devin', authIndex: '0' }, + }); + const viewModel = buildAccountDetailViewModel(row, { + devinQuota: { + status: 'error', + error: 'temporary failure', + errorStatus: 502, + failedAtMs: 1726000100000, + windows: [], + observedAtMs: 1726000000000, + plan: 'Pro', + planStartMs: 1725000000000, + planEndMs: 1727000000000, + }, + }); + + expect(viewModel.quota.devinPlan).toEqual({ + plan: 'Pro', + planStartMs: 1725000000000, + planEndMs: 1727000000000, + }); + }); }); + diff --git a/apps/web/src/features/accounts/model/accountDetailViewModel.ts b/apps/web/src/features/accounts/model/accountDetailViewModel.ts index 7e4035ce4..a400c4112 100644 --- a/apps/web/src/features/accounts/model/accountDetailViewModel.ts +++ b/apps/web/src/features/accounts/model/accountDetailViewModel.ts @@ -1,5 +1,5 @@ import type { TFunction } from 'i18next'; -import type { CodexQuotaState, QuotaResetAccuracy, XaiQuotaState } from '@/types'; +import type { CodexQuotaState, DevinQuotaState, QuotaResetAccuracy, XaiQuotaState } from '@/types'; import { getSortedCodexResetCreditExpiries } from '@/components/quota/quotaConfigs'; import type { AccountActionCandidate, @@ -323,6 +323,11 @@ export interface AccountDetailViewModel { cooldown: QuotaCooldownInfo | null; resetCreditsAvailableCount: number | null; resetCreditExpiries: AccountDetailResetCreditExpiry[]; + devinPlan?: { + plan: string; + planStartMs: number | null; + planEndMs: number | null; + } | null; }; auth: { fields: AccountDetailField[]; @@ -353,6 +358,7 @@ export interface BuildAccountDetailViewModelOptions { history?: MonitoringAccountHistoryItem | null; valueRow?: UsageValueRow | null; codexQuota?: CodexQuotaState | null; + devinQuota?: DevinQuotaState | null; xaiQuota?: XaiQuotaState | null; diagnosticsSummary?: MonitoringAnalyticsSummary | null; diagnosticsRecentFailure?: MonitoringAnalyticsRecentFailure | null; @@ -1490,6 +1496,14 @@ export const buildAccountDetailViewModel = ( resetCreditExpiries: getSortedCodexResetCreditExpiries( options.codexQuota?.rateLimitResetCredits ).map((item) => ({ id: item.id, expiresAtMs: item.expiresAtMs })), + devinPlan: + row.provider === 'devin' && options.devinQuota?.plan + ? { + plan: options.devinQuota.plan, + planStartMs: options.devinQuota.planStartMs, + planEndMs: options.devinQuota.planEndMs, + } + : null, }, auth: { fields: buildAuthFields(row), diff --git a/apps/web/src/features/accounts/model/accountOperationalScope.test.ts b/apps/web/src/features/accounts/model/accountOperationalScope.test.ts index fa33d49da..1c8e3aa33 100644 --- a/apps/web/src/features/accounts/model/accountOperationalScope.test.ts +++ b/apps/web/src/features/accounts/model/accountOperationalScope.test.ts @@ -8,6 +8,7 @@ const emptyStores = () => ({ claudeQuota: {}, codexQuota: {}, kimiQuota: {}, + devinQuota: {}, xaiQuota: {}, }); diff --git a/apps/web/src/features/accounts/model/accountQuotaDisplayWindows.test.ts b/apps/web/src/features/accounts/model/accountQuotaDisplayWindows.test.ts index a446552cb..ec6e7607c 100644 --- a/apps/web/src/features/accounts/model/accountQuotaDisplayWindows.test.ts +++ b/apps/web/src/features/accounts/model/accountQuotaDisplayWindows.test.ts @@ -26,6 +26,7 @@ const emptyStores = (): AccountQuotaStores => ({ antigravityQuota: {}, claudeQuota: {}, codexQuota: {}, + devinQuota: {}, kimiQuota: {}, xaiQuota: {}, }); @@ -35,6 +36,8 @@ const t = ((key: string, options?: Record) => { 'antigravity_quota.group_gemini_models': 'Gemini models', 'antigravity_quota.daily_limit': 'Daily limit', 'claude_quota.extra_usage_label': 'Extra Usage', + 'devin_quota.daily': 'Daily limit', + 'devin_quota.weekly': 'Weekly limit', 'kimi_quota.reset_hint': `resets in ${options?.hint ?? ''}`, 'kimi_quota.weekly_limit': 'Weekly limit', 'xai_quota.weekly_credits': 'Weekly credits', @@ -54,6 +57,7 @@ const buildRow = (file: AuthFileItem, stores: AccountQuotaStores = emptyStores() stores.antigravityQuota, stores.claudeQuota, stores.codexQuota, + stores.devinQuota, stores.kimiQuota, stores.xaiQuota, ] as Array>; @@ -681,6 +685,130 @@ describe('accountQuotaDisplayWindows', () => { expect(isStandardAccountQuotaListWindow(windows[0])).toBe(true); }); + it('builds Devin daily and weekly display windows with exact reset and clamp percent', () => { + const stores = { + ...emptyStores(), + devinQuota: { + 'devin.json::d-1': { + status: 'success', + authFileKey: 'devin.json::d-1', + authFileName: 'devin.json', + authIndex: 'd-1', + authFileIdentityVerified: true, + windows: [ + { + id: 'daily', + remainingPercent: 0, + resetAtMs: Date.parse('2026-09-15T12:00:00Z'), + periodHours: 24, + }, + { + id: 'weekly', + remainingPercent: 75, + resetAtMs: Date.parse('2026-09-22T12:00:00Z'), + periodHours: 168, + }, + ], + plan: 'Pro', + planStartMs: null, + planEndMs: null, + observedAtMs: Date.parse('2026-09-15T10:00:00Z'), + }, + }, + } satisfies AccountQuotaStores; + const row = buildRow({ name: 'devin.json', type: 'devin', authIndex: 'd-1' }, stores); + + const windows = buildAccountQuotaDisplayWindows(row, { + stores, + translateQuotaWindowLabel, + t, + }); + + expect(windows).toHaveLength(2); + expect(windows[0]).toMatchObject({ + key: 'devin:daily', + label: 'Daily limit', + kind: 'daily', + remainingPercent: 0, + usedPercent: 100, + resetAtMs: Date.parse('2026-09-15T12:00:00Z'), + resetAccuracy: 'exact', + limitWindowSeconds: 24 * 3600, + source: 'devin', + modelScope: { kind: 'all', complete: true }, + }); + expect(getAccountQuotaSemanticGroup(windows[0])).toBe('standard'); + + expect(windows[1]).toMatchObject({ + key: 'devin:weekly', + label: 'Weekly limit', + kind: 'weekly', + remainingPercent: 75, + usedPercent: 25, + resetAtMs: Date.parse('2026-09-22T12:00:00Z'), + resetAccuracy: 'exact', + limitWindowSeconds: 168 * 3600, + source: 'devin', + modelScope: { kind: 'all', complete: true }, + }); + expect(getAccountQuotaSemanticGroup(windows[1])).toBe('standard'); + }); + + it('preserves Devin daily and weekly windows on transient refresh error when previous windows exist', () => { + const stores = { + ...emptyStores(), + devinQuota: { + 'devin.json::d-1': { + status: 'error', + error: 'temporary failure', + errorStatus: 502, + failedAtMs: Date.parse('2026-09-15T10:05:00Z'), + authFileKey: 'devin.json::d-1', + authFileName: 'devin.json', + authIndex: 'd-1', + authFileIdentityVerified: true, + plan: 'Pro', + planStartMs: Date.parse('2026-09-01T00:00:00Z'), + planEndMs: Date.parse('2026-10-01T00:00:00Z'), + windows: [ + { + id: 'daily', + remainingPercent: 50, + resetAtMs: Date.parse('2026-09-15T12:00:00Z'), + periodHours: 24, + }, + { + id: 'weekly', + remainingPercent: 80, + resetAtMs: Date.parse('2026-09-22T12:00:00Z'), + periodHours: 168, + }, + ], + observedAtMs: Date.parse('2026-09-15T10:00:00Z'), + }, + }, + } satisfies AccountQuotaStores; + const row = buildRow({ name: 'devin.json', type: 'devin', authIndex: 'd-1' }, stores); + + const windows = buildAccountQuotaDisplayWindows(row, { + stores, + translateQuotaWindowLabel, + t, + }); + + expect(windows).toHaveLength(2); + expect(windows[0]).toMatchObject({ + key: 'devin:daily', + label: 'Daily limit', + remainingPercent: 50, + }); + expect(windows[1]).toMatchObject({ + key: 'devin:weekly', + label: 'Weekly limit', + remainingPercent: 80, + }); + }); + it('splits xAI billing into monthly and pay-as-you-go windows', () => { const stores = { ...emptyStores(), diff --git a/apps/web/src/features/accounts/model/accountQuotaDisplayWindows.ts b/apps/web/src/features/accounts/model/accountQuotaDisplayWindows.ts index 895c17a3b..6633a11f1 100644 --- a/apps/web/src/features/accounts/model/accountQuotaDisplayWindows.ts +++ b/apps/web/src/features/accounts/model/accountQuotaDisplayWindows.ts @@ -43,6 +43,7 @@ export type AccountQuotaWindowSource = | 'codex' | 'claude' | 'antigravity' + | 'devin' | 'kimi' | 'xai' | 'summary'; @@ -841,5 +842,51 @@ export const buildAccountQuotaDisplayWindows = ( if (windows.length) return windows; } + if (row.provider === 'devin') { + const windows = buildDevinQuotaDisplayWindows(row, options); + if (windows.length) return windows; + } + return buildSummaryQuotaDisplayWindow(row, options); }; + +const buildDevinQuotaDisplayWindows = ( + row: AccountRow, + options: BuildAccountQuotaDisplayWindowsOptions +): AccountQuotaDisplayWindow[] => { + const quota = getCredentialScopedQuotaState(options.stores.devinQuota, row.raw); + if (!quota || !quota.windows?.length) return []; + return quota.windows.map((window) => { + const remainingPercent = + typeof window.remainingPercent === 'number' && Number.isFinite(window.remainingPercent) + ? clampDisplayPercent(window.remainingPercent) + : null; + const usedPercent = + remainingPercent === null ? null : clampDisplayPercent(100 - remainingPercent); + const hasReset = isValidQuotaResetAtMs(window.resetAtMs); + const resetLabel = + hasReset && window.resetAtMs !== null + ? formatQuotaResetTime(window.resetAtMs) + : '-'; + const labelKey = window.id === 'daily' ? 'devin_quota.daily' : 'devin_quota.weekly'; + const label = options.translateQuotaWindowLabel(undefined, labelKey); + + return buildAccountQuotaDisplayWindow({ + key: `devin:${window.id}`, + label, + kind: window.id, + remainingPercent, + usedPercent, + resetLabel, + resetAtMs: window.resetAtMs, + resetAccuracy: hasReset ? 'exact' : 'unknown', + limitWindowSeconds: window.periodHours * 3600, + source: 'devin', + modelScope: { kind: 'all', complete: true }, + windowMode: 'unknown', + observedAtMs: quota.observedAtMs ?? quota.fetchedAtMs ?? null, + nowMs: options.nowMs, + }); + }); +}; + diff --git a/apps/web/src/features/accounts/model/accountQuotaSummary.test.ts b/apps/web/src/features/accounts/model/accountQuotaSummary.test.ts index af44e0a41..db8f21e93 100644 --- a/apps/web/src/features/accounts/model/accountQuotaSummary.test.ts +++ b/apps/web/src/features/accounts/model/accountQuotaSummary.test.ts @@ -13,6 +13,7 @@ const emptyStores = (): AccountQuotaStores => ({ antigravityQuota: {}, claudeQuota: {}, codexQuota: {}, + devinQuota: {}, kimiQuota: {}, xaiQuota: {}, }); @@ -471,6 +472,54 @@ describe('resolveAccountQuota', () => { expect(resolveAccountQuota(file, stores).planType).toBe('Antigravity Future'); }); + + it('resolves Devin quota summary choosing the limiting window and preserving live plan', () => { + const file = { name: 'devin.json', type: 'devin', authIndex: 'd-1' }; + const stores = emptyStores(); + stores.devinQuota['devin.json::d-1'] = { + status: 'success', + authFileKey: 'devin.json::d-1', + authFileName: 'devin.json', + authIndex: 'd-1', + authFileIdentityVerified: true, + windows: [ + { id: 'daily', remainingPercent: 0, resetAtMs: 1726400000000, periodHours: 24 }, + { id: 'weekly', remainingPercent: 80, resetAtMs: 1726900000000, periodHours: 168 }, + ], + plan: 'Pro', + planStartMs: 1726000000000, + planEndMs: 1727000000000, + observedAtMs: 1726000000100, + fetchedAtMs: 1726000000100, + }; + + const exhaustedSummary = resolveAccountQuota(file, stores); + expect(exhaustedSummary.status).toBe('exhausted'); + expect(exhaustedSummary.remainingPercent).toBe(0); + expect(exhaustedSummary.usedPercent).toBe(100); + expect(exhaustedSummary.planType).toBe('Pro'); + expect(exhaustedSummary.resetAccuracy).toBe('exact'); + + // daily = 54, weekly = 77 -> summary = 54 + stores.devinQuota['devin.json::d-1'].windows[0].remainingPercent = 54; + stores.devinQuota['devin.json::d-1'].windows[1].remainingPercent = 77; + + const activeSummary = resolveAccountQuota(file, stores); + expect(activeSummary.status).toBe('ok'); + expect(activeSummary.remainingPercent).toBe(54); + expect(activeSummary.usedPercent).toBe(46); + expect(activeSummary.planType).toBe('Pro'); + + // daily = 80, weekly = 35 -> summary = 35 (limiting window = min(daily, weekly)) + stores.devinQuota['devin.json::d-1'].windows[0].remainingPercent = 80; + stores.devinQuota['devin.json::d-1'].windows[1].remainingPercent = 35; + + const reverseSummary = resolveAccountQuota(file, stores); + expect(reverseSummary.status).toBe('ok'); + expect(reverseSummary.remainingPercent).toBe(35); + expect(reverseSummary.usedPercent).toBe(65); + expect(reverseSummary.planType).toBe('Pro'); + }); }); describe('hasConfirmedXaiBillingEntitlement', () => { diff --git a/apps/web/src/features/accounts/model/accountQuotaSummary.ts b/apps/web/src/features/accounts/model/accountQuotaSummary.ts index 35f16e076..42f4ad3e7 100644 --- a/apps/web/src/features/accounts/model/accountQuotaSummary.ts +++ b/apps/web/src/features/accounts/model/accountQuotaSummary.ts @@ -3,6 +3,7 @@ import type { AuthFileItem, ClaudeQuotaState, CodexQuotaState, + DevinQuotaState, KimiQuotaState, QuotaResetAccuracy, XaiBillingSummary, @@ -74,6 +75,7 @@ export interface AccountQuotaStores { antigravityQuota: Record; claudeQuota: Record; codexQuota: Record; + devinQuota: Record; kimiQuota: Record; xaiQuota: Record; } @@ -1063,6 +1065,24 @@ export const resolveAccountQuota = ( }); } + if (provider === 'devin') { + const quota = getCredentialScopedQuotaState(stores.devinQuota, file); + if (!quota) return emptyQuota(filePlanType); + const planType = quota.plan ?? filePlanType; + if (quota.status === 'loading') return loadingQuota(planType); + if (quota.status === 'error') + return quotaFromError(quota.error, planType, quota.errorStatus, quota.failedAtMs); + return quotaFromRemainingWindows( + quota.windows.map((window) => ({ + remainingPercent: window.remainingPercent, + resetAtMs: window.resetAtMs, + resetAccuracy: 'exact', + })), + planType, + { fetchedAtMs: quota.fetchedAtMs } + ); + } + return emptyQuota(filePlanType); }; diff --git a/apps/web/src/features/accounts/model/accountReauth.test.ts b/apps/web/src/features/accounts/model/accountReauth.test.ts index 0cc033a00..be16acc10 100644 --- a/apps/web/src/features/accounts/model/accountReauth.test.ts +++ b/apps/web/src/features/accounts/model/accountReauth.test.ts @@ -19,6 +19,11 @@ describe('accountReauth', () => { oauthProvider: 'anthropic', path: '/oauth#oauth-provider-anthropic', }); + expect(resolveAccountReauthAction({ name: 'devin.json', type: 'devin' })).toEqual({ + kind: 'navigate', + oauthProvider: 'devin', + path: '/oauth#oauth-provider-devin', + }); }); it('returns an explicit unsupported action for providers without OAuth login', () => { diff --git a/apps/web/src/features/accounts/model/accountReauth.ts b/apps/web/src/features/accounts/model/accountReauth.ts index 5d2a27fd4..55199a745 100644 --- a/apps/web/src/features/accounts/model/accountReauth.ts +++ b/apps/web/src/features/accounts/model/accountReauth.ts @@ -13,6 +13,7 @@ const OAUTH_PROVIDER_BY_ACCOUNT_PROVIDER: Record = { claude: 'anthropic', kimi: 'kimi', xai: 'xai', + devin: 'devin', }; export const resolveAccountReauthAction = (file: AuthFileItem): AccountReauthAction => { diff --git a/apps/web/src/features/accounts/model/accountRows.test.ts b/apps/web/src/features/accounts/model/accountRows.test.ts index 3a68583f8..91f77b22c 100644 --- a/apps/web/src/features/accounts/model/accountRows.test.ts +++ b/apps/web/src/features/accounts/model/accountRows.test.ts @@ -44,6 +44,7 @@ const emptyStores = (): AccountQuotaStores => ({ claudeQuota: {}, codexQuota: {}, kimiQuota: {}, + devinQuota: {}, xaiQuota: {}, }); diff --git a/apps/web/src/features/accounts/model/usageValueRows.test.ts b/apps/web/src/features/accounts/model/usageValueRows.test.ts index d1ce4df81..e440f8e94 100644 --- a/apps/web/src/features/accounts/model/usageValueRows.test.ts +++ b/apps/web/src/features/accounts/model/usageValueRows.test.ts @@ -19,6 +19,7 @@ const emptyStores = (): AccountQuotaStores => ({ claudeQuota: {}, codexQuota: {}, kimiQuota: {}, + devinQuota: {}, xaiQuota: {}, }); diff --git a/apps/web/src/features/authFiles/constants.test.ts b/apps/web/src/features/authFiles/constants.test.ts new file mode 100644 index 000000000..993c3f6d1 --- /dev/null +++ b/apps/web/src/features/authFiles/constants.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import type { AuthFileType } from '@/types'; +import { getAuthFileIcon, getTypeColor } from './constants'; + +describe('authFiles constants - devin', () => { + it('returns valid distinct light and dark icons for devin', () => { + const light = getAuthFileIcon('devin', 'light'); + const dark = getAuthFileIcon('devin', 'dark'); + + expect(light).toBeTruthy(); + expect(dark).toBeTruthy(); + expect(light).not.toBe(dark); + }); + + it('returns correct type colors for devin in light and dark mode', () => { + expect(getTypeColor('devin', 'light')).toEqual({ + bg: '#e8f4ff', + text: '#155e9b', + }); + expect(getTypeColor('devin', 'dark')).toEqual({ + bg: '#123b5d', + text: '#8dc9f5', + }); + }); + + it('supports devin as AuthFileType', () => { + const type: AuthFileType = 'devin'; + expect(type).toBe('devin'); + }); +}); diff --git a/apps/web/src/features/authFiles/constants.ts b/apps/web/src/features/authFiles/constants.ts index 3908bd1c1..134906068 100644 --- a/apps/web/src/features/authFiles/constants.ts +++ b/apps/web/src/features/authFiles/constants.ts @@ -2,6 +2,8 @@ import type { TFunction } from 'i18next'; import iconAntigravity from '@/assets/icons/antigravity.svg'; import iconClaude from '@/assets/icons/claude.svg'; import iconCodex from '@/assets/icons/codex.svg'; +import iconDevin from '@/assets/icons/devin.svg'; +import iconDevinDark from '@/assets/icons/devin-dark.svg'; import iconGemini from '@/assets/icons/gemini.svg'; import iconGrok from '@/assets/icons/grok.svg'; import iconGrokDark from '@/assets/icons/grok-dark.svg'; @@ -24,7 +26,7 @@ export type AuthFileModelItem = { }; export type AuthFileIconAsset = string | { light: string; dark: string }; -export type QuotaProviderType = 'antigravity' | 'claude' | 'codex' | 'kimi' | 'xai'; +export type QuotaProviderType = 'antigravity' | 'claude' | 'codex' | 'kimi' | 'xai' | 'devin'; export type OAuthConfigLoadState = 'loading' | 'ready' | 'unsupported' | 'error'; export const QUOTA_PROVIDER_TYPES = new Set([ @@ -33,6 +35,7 @@ export const QUOTA_PROVIDER_TYPES = new Set([ 'codex', 'kimi', 'xai', + 'devin', ]); export const AUTH_FILE_REFRESH_WARNING_MS = 24 * 60 * 60 * 1000; @@ -107,6 +110,10 @@ export const TYPE_COLORS: Record = { light: { bg: '#e4edfd', text: '#2b5fbc' }, dark: { bg: '#1a3d80', text: '#89b3f7' }, }, + devin: { + light: { bg: '#e8f4ff', text: '#155e9b' }, + dark: { bg: '#123b5d', text: '#8dc9f5' }, + }, empty: { light: { bg: '#f5f5f5', text: '#616161' }, dark: { bg: '#424242', text: '#bdbdbd' }, @@ -122,6 +129,7 @@ export const AUTH_FILE_ICONS: Record = { aistudio: iconGemini, claude: iconClaude, codex: iconCodex, + devin: { light: iconDevin, dark: iconDevinDark }, gemini: iconGemini, xai: { light: iconGrok, dark: iconGrokDark }, iflow: iconIflow, diff --git a/apps/web/src/features/demo/demoFixtures.ts b/apps/web/src/features/demo/demoFixtures.ts index 7d5e5565a..268a7a3d9 100644 --- a/apps/web/src/features/demo/demoFixtures.ts +++ b/apps/web/src/features/demo/demoFixtures.ts @@ -33,6 +33,7 @@ import type { ClaudeQuotaState, CodexQuotaState, CredentialScopedQuotaState, + DevinQuotaState, KimiQuotaState, XaiQuotaState, } from '@/types'; @@ -58,6 +59,7 @@ export type DemoQuotaStoreState = { antigravityQuota: Record; claudeQuota: Record; codexQuota: Record; + devinQuota: Record; kimiQuota: Record; xaiQuota: Record; }; @@ -6444,6 +6446,7 @@ const getDemoQuotaStoreStateByFileName = ( }, }, }, + devinQuota: {}, }); const scopeDemoQuotaRecord = ( @@ -6482,6 +6485,7 @@ export const getDemoQuotaStoreState = ( antigravityQuota: scopeDemoQuotaRecord(raw.antigravityQuota, filesByName), claudeQuota: scopeDemoQuotaRecord(raw.claudeQuota, filesByName), codexQuota: scopeDemoQuotaRecord(raw.codexQuota, filesByName), + devinQuota: scopeDemoQuotaRecord(raw.devinQuota, filesByName), kimiQuota: scopeDemoQuotaRecord(raw.kimiQuota, filesByName), xaiQuota: scopeDemoQuotaRecord(raw.xaiQuota, filesByName), }; diff --git a/apps/web/src/features/monitoring/MonitoringCenterPage.tsx b/apps/web/src/features/monitoring/MonitoringCenterPage.tsx index 0c34c381d..fec446479 100644 --- a/apps/web/src/features/monitoring/MonitoringCenterPage.tsx +++ b/apps/web/src/features/monitoring/MonitoringCenterPage.tsx @@ -80,6 +80,7 @@ import { ANTIGRAVITY_CONFIG, CLAUDE_CONFIG, CODEX_CONFIG, + DEVIN_CONFIG, KIMI_CONFIG, XAI_CONFIG, refreshQuotaWithConfig, @@ -270,6 +271,7 @@ export function MonitoringCenterPage() { const antigravityQuota = useQuotaStore((state) => state.antigravityQuota); const claudeQuota = useQuotaStore((state) => state.claudeQuota); const codexQuota = useQuotaStore((state) => state.codexQuota); + const devinQuota = useQuotaStore((state) => state.devinQuota); const kimiQuota = useQuotaStore((state) => state.kimiQuota); const xaiQuota = useQuotaStore((state) => state.xaiQuota); const sharedQuotaStores = useMemo( @@ -277,14 +279,16 @@ export function MonitoringCenterPage() { antigravityQuota, claudeQuota, codexQuota, + devinQuota, kimiQuota, xaiQuota, }), - [antigravityQuota, claudeQuota, codexQuota, kimiQuota, xaiQuota] + [antigravityQuota, claudeQuota, codexQuota, devinQuota, kimiQuota, xaiQuota] ); const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota); const setClaudeQuota = useQuotaStore((state) => state.setClaudeQuota); const setCodexQuota = useQuotaStore((state) => state.setCodexQuota); + const setDevinQuota = useQuotaStore((state) => state.setDevinQuota); const setKimiQuota = useQuotaStore((state) => state.setKimiQuota); const setXaiQuota = useQuotaStore((state) => state.setXaiQuota); const [selectedAccount, setSelectedAccount] = useState( @@ -1348,6 +1352,12 @@ export function MonitoringCenterPage() { setKimiQuota, getCredentialScopedQuotaState(sharedQuotaStores.kimiQuota, target.file) ); + case 'devin': + return run( + DEVIN_CONFIG, + setDevinQuota, + getCredentialScopedQuotaState(sharedQuotaStores.devinQuota, target.file) + ); case 'xai': return run( XAI_CONFIG, @@ -1362,6 +1372,7 @@ export function MonitoringCenterPage() { setAntigravityQuota, setClaudeQuota, setCodexQuota, + setDevinQuota, setKimiQuota, setXaiQuota, t, diff --git a/apps/web/src/features/monitoring/accountOverviewQuotaTargets.test.ts b/apps/web/src/features/monitoring/accountOverviewQuotaTargets.test.ts index 3fc3e8186..ed86b9877 100644 --- a/apps/web/src/features/monitoring/accountOverviewQuotaTargets.test.ts +++ b/apps/web/src/features/monitoring/accountOverviewQuotaTargets.test.ts @@ -273,4 +273,40 @@ describe('accountOverviewQuotaTargets', () => { ]); expect(result.get('same@example.com')).toBeUndefined(); }); + + it('builds Devin quota targets for Devin credentials', () => { + const authStateByRowId = new Map([ + [ + 'devin@example.com', + createAuthState({ + files: [ + { + name: 'devin.json', + type: 'devin', + authIndex: 'd-1', + label: 'Devin Account', + account: 'devin@example.com', + }, + ], + enabledState: 'enabled', + }), + ], + ]); + + const result = buildMonitoringAccountQuotaTargetsByRowId( + [ + createAccountRow({ + id: 'devin@example.com', + account: 'devin@example.com', + authIndices: ['d-1'], + }), + ], + authStateByRowId + ); + + expect(result.get('devin@example.com')).toMatchObject([ + { provider: 'devin', authIndex: 'd-1', fileName: 'devin.json', authLabel: 'Devin Account' }, + ]); + }); }); + diff --git a/apps/web/src/features/monitoring/accountOverviewQuotaTargets.ts b/apps/web/src/features/monitoring/accountOverviewQuotaTargets.ts index 2ec0cbea2..daa6e8137 100644 --- a/apps/web/src/features/monitoring/accountOverviewQuotaTargets.ts +++ b/apps/web/src/features/monitoring/accountOverviewQuotaTargets.ts @@ -3,6 +3,7 @@ import { isAntigravityFile, isClaudeFile, isCodexFile, + isDevinFile, isDisabledAuthFile, isKimiFile, isXaiFile, @@ -13,7 +14,7 @@ import { import type { MonitoringAccountAuthState } from './accountOverviewState'; import type { MonitoringAccountRow } from './hooks/useMonitoringData'; -export type MonitoringAccountQuotaProvider = 'antigravity' | 'claude' | 'codex' | 'kimi' | 'xai'; +export type MonitoringAccountQuotaProvider = 'antigravity' | 'claude' | 'codex' | 'kimi' | 'xai' | 'devin'; export type MonitoringAccountQuotaTarget = { key: string; @@ -46,6 +47,7 @@ export const resolveMonitoringAccountQuotaProvider = ( if (isCodexFile(file)) return 'codex'; if (isClaudeFile(file)) return 'claude'; if (isAntigravityFile(file)) return 'antigravity'; + if (isDevinFile(file)) return 'devin'; if (isKimiFile(file)) return 'kimi'; if (isXaiFile(file)) return 'xai'; return null; diff --git a/apps/web/src/features/monitoring/model/monitoringCenterPageModel.test.ts b/apps/web/src/features/monitoring/model/monitoringCenterPageModel.test.ts index f12164074..0c02a3be4 100644 --- a/apps/web/src/features/monitoring/model/monitoringCenterPageModel.test.ts +++ b/apps/web/src/features/monitoring/model/monitoringCenterPageModel.test.ts @@ -4,6 +4,7 @@ import { ANTIGRAVITY_CONFIG, CLAUDE_CONFIG, CODEX_CONFIG, + DEVIN_CONFIG, KIMI_CONFIG, XAI_CONFIG, } from '@/components/quota'; @@ -11,12 +12,18 @@ import { fetchAntigravityQuota, fetchClaudeQuota, fetchCodexQuota, + fetchDevinQuota, fetchKimiQuota, fetchXaiQuota, } from '@/utils/quota'; import zhCN from '@/i18n/locales/zh-CN.json'; import zhTW from '@/i18n/locales/zh-TW.json'; -import type { AntigravityQuotaState, ClaudeQuotaState, CodexQuotaState } from '@/types'; +import type { + AntigravityQuotaState, + ClaudeQuotaState, + CodexQuotaState, + DevinQuotaState, +} from '@/types'; import { getQuotaCredentialStoreKey } from '@/utils/quota/credentialScope'; import type { MonitoringAccountQuotaTarget } from '@/features/monitoring/accountOverviewQuotaTargets'; import type { @@ -55,6 +62,7 @@ vi.mock('@/utils/quota', async (importOriginal) => { fetchAntigravityQuota: vi.fn(), fetchClaudeQuota: vi.fn(), fetchCodexQuota: vi.fn(), + fetchDevinQuota: vi.fn(), fetchKimiQuota: vi.fn(), fetchXaiQuota: vi.fn(), }; @@ -74,6 +82,11 @@ const t = ((key: string, options?: Record) => { 'plans.codex.free': 'Free', 'codex_quota.monthly_window': 'Monthly limit', 'codex_quota.window_usage_duration': '{{used}} / {{total}} used', + 'devin_quota.title': 'Devin Quota', + 'devin_quota.daily': 'Daily limit', + 'devin_quota.weekly': 'Weekly limit', + 'devin_quota.plan_label': 'Plan', + 'devin_quota.empty_data': 'No Devin quota data', 'kimi_quota.title': 'Kimi Quota', 'kimi_quota.empty_data': 'No Kimi quota data', 'xai_quota.title': 'xAI Quota', @@ -168,6 +181,15 @@ const buildEntryFromMockedProviderFetch = async ( ); break; } + case 'devin': { + const data = await fetchDevinQuota(target.file, translate); + entry = buildAccountQuotaEntryFromProviderState( + target, + DEVIN_CONFIG.buildSuccessState(data, target.file), + translate + ); + break; + } case 'kimi': { const data = await fetchKimiQuota(target.file, translate); entry = buildAccountQuotaEntryFromProviderState( @@ -195,10 +217,45 @@ const emptyQuotaStores = (): MonitoringQuotaStores => ({ antigravityQuota: {}, claudeQuota: {}, codexQuota: {}, + devinQuota: {}, kimiQuota: {}, xaiQuota: {}, }); +const devinState = ( + file: MonitoringAccountQuotaTarget['file'], + dailyPercent: number, + weeklyPercent: number, + fetchedAtMs = 1_000, + overrides: Partial = {} +): DevinQuotaState => ({ + status: 'success', + authFileKey: getQuotaCredentialStoreKey(file), + authFileName: file.name, + authIndex: String(file.authIndex ?? file['auth_index'] ?? ''), + authFileIdentityVerified: true, + fetchedAtMs, + windows: [ + { + id: 'daily', + remainingPercent: dailyPercent, + resetAtMs: 1_700_000_100 * 1000, + periodHours: 24, + }, + { + id: 'weekly', + remainingPercent: weeklyPercent, + resetAtMs: 1_700_000_200 * 1000, + periodHours: 168, + }, + ], + observedAtMs: fetchedAtMs, + plan: 'Devin Pro', + planStartMs: Date.parse('2026-09-01T00:00:00Z'), + planEndMs: Date.parse('2026-10-01T00:00:00Z'), + ...overrides, +}); + const codexState = ( file: MonitoringAccountQuotaTarget['file'], usedPercent: number, @@ -451,6 +508,7 @@ describe('monitoringCenterPageModel account quota', () => { vi.mocked(fetchAntigravityQuota).mockReset(); vi.mocked(fetchClaudeQuota).mockReset(); vi.mocked(fetchCodexQuota).mockReset(); + vi.mocked(fetchDevinQuota).mockReset(); vi.mocked(fetchKimiQuota).mockReset(); vi.mocked(fetchXaiQuota).mockReset(); }); @@ -2058,4 +2116,127 @@ describe('monitoringCenterPageModel account quota', () => { expect(metaLabels.join(' ')).not.toContain('protocol_changed'); expect(metaLabels.join(' ')).not.toContain('HTTP 200'); }); + + it('maps Devin daily and weekly quota windows with plan into account quota entries', async () => { + vi.mocked(fetchDevinQuota).mockResolvedValue({ + windows: [ + { + id: 'daily', + remainingPercent: 75, + resetAtMs: 1_700_000_100 * 1000, + periodHours: 24, + }, + { + id: 'weekly', + remainingPercent: 40, + resetAtMs: 1_700_000_200 * 1000, + periodHours: 168, + }, + ], + observedAtMs: 1_000, + plan: 'Team', + planStartMs: Date.parse('2026-09-01T00:00:00Z'), + planEndMs: Date.parse('2026-10-01T00:00:00Z'), + }); + + const entry = await buildEntryFromMockedProviderFetch( + createTarget({ + key: 'devin::1::devin.json', + provider: 'devin', + authIndex: '1', + fileName: 'devin.json', + file: { + name: 'devin.json', + type: 'devin', + provider: 'devin', + authIndex: '1', + }, + }), + t + ); + + expect(entry).toMatchObject({ + provider: 'devin', + providerLabel: 'Devin Quota', + metaLabels: ['Devin Quota', 'Plan: Team'], + windows: [ + { + id: 'daily', + label: 'Daily limit', + remainingPercent: 75, + resetAtMs: 1_700_000_100 * 1000, + resetAccuracy: 'exact', + }, + { + id: 'weekly', + label: 'Weekly limit', + remainingPercent: 40, + resetAtMs: 1_700_000_200 * 1000, + resetAccuracy: 'exact', + }, + ], + }); + }); + + it('reads cached Devin quota from stores without fetching', () => { + const file = { + name: 'devin-cached.json', + type: 'devin', + provider: 'devin', + authIndex: '1', + }; + const target = createTarget({ + key: 'devin::1::devin-cached.json', + provider: 'devin', + authIndex: '1', + fileName: file.name, + file, + }); + const stores = emptyQuotaStores(); + stores.devinQuota[getQuotaCredentialStoreKey(file)] = devinState(file, 90, 60, 2_500); + + const entry = buildCachedAccountQuotaEntry(target, stores, t); + + expect(fetchDevinQuota).not.toHaveBeenCalled(); + expect(entry).toMatchObject({ + key: target.key, + provider: 'devin', + fetchedAtMs: 2_500, + windows: [ + { id: 'daily', remainingPercent: 90 }, + { id: 'weekly', remainingPercent: 60 }, + ], + }); + }); + + it('handles Devin empty_data state gracefully in cached account quota entries', () => { + const file = { + name: 'devin-empty.json', + type: 'devin', + provider: 'devin', + authIndex: '1', + }; + const target = createTarget({ + key: 'devin::1::devin-empty.json', + provider: 'devin', + authIndex: '1', + fileName: file.name, + file, + }); + const stores = emptyQuotaStores(); + stores.devinQuota[getQuotaCredentialStoreKey(file)] = devinState(file, 0, 0, 1_000, { + status: 'success', + windows: [], + plan: null, + }); + + const entry = buildCachedAccountQuotaEntry(target, stores, t); + + expect(entry).toMatchObject({ + key: target.key, + provider: 'devin', + windows: [], + emptyMessage: 'No Devin quota data', + }); + }); }); diff --git a/apps/web/src/features/monitoring/model/monitoringCenterPageModel.ts b/apps/web/src/features/monitoring/model/monitoringCenterPageModel.ts index 739349b51..c0c40e595 100644 --- a/apps/web/src/features/monitoring/model/monitoringCenterPageModel.ts +++ b/apps/web/src/features/monitoring/model/monitoringCenterPageModel.ts @@ -7,6 +7,8 @@ import type { ClaudeQuotaWindow, CodexQuotaState, CodexQuotaWindow, + DevinQuotaState, + DevinQuotaWindow, KimiQuotaState, KimiQuotaRow, XaiBillingSummary, @@ -1292,6 +1294,36 @@ const buildXaiAccountQuotaWindows = ( return windows; }; +const buildDevinAccountQuotaWindows = ( + windows: DevinQuotaWindow[] | undefined, + t: TFunction +): AccountQuotaWindow[] => + (windows ?? []).map((window) => { + const remainingPercent = + typeof window.remainingPercent === 'number' && Number.isFinite(window.remainingPercent) + ? Math.max(0, Math.min(100, window.remainingPercent)) + : null; + const hasReset = + typeof window.resetAtMs === 'number' && + Number.isFinite(window.resetAtMs) && + window.resetAtMs > 0; + const resetLabel = + hasReset && window.resetAtMs !== null + ? formatQuotaResetTime(window.resetAtMs) + : '-'; + const label = window.id === 'daily' ? t('devin_quota.daily') : t('devin_quota.weekly'); + + return { + id: window.id, + label, + remainingPercent, + resetLabel, + resetAtMs: window.resetAtMs, + resetAccuracy: hasReset ? 'exact' : 'unknown', + usageLabel: null, + }; + }); + export const getAccountQuotaProviderLabel = ( provider: MonitoringAccountQuotaProvider, t: TFunction @@ -1305,6 +1337,8 @@ export const getAccountQuotaProviderLabel = ( return t('kimi_quota.title'); case 'xai': return t('xai_quota.title'); + case 'devin': + return t('devin_quota.title'); case 'codex': default: return t('codex_quota.title'); @@ -1321,6 +1355,8 @@ const getAccountQuotaEmptyMessage = (provider: MonitoringAccountQuotaProvider, t return t('kimi_quota.empty_data'); case 'xai': return t('xai_quota.empty_data'); + case 'devin': + return t('devin_quota.empty_data'); case 'codex': default: return t('codex_quota.empty_windows'); @@ -1349,6 +1385,7 @@ export type MonitoringQuotaStores = { antigravityQuota: Record; claudeQuota: Record; codexQuota: Record; + devinQuota: Record; kimiQuota: Record; xaiQuota: Record; }; @@ -1357,6 +1394,7 @@ export type MonitoringProviderQuotaState = | AntigravityQuotaState | ClaudeQuotaState | CodexQuotaState + | DevinQuotaState | KimiQuotaState | XaiQuotaState; @@ -1470,6 +1508,26 @@ export const buildAccountQuotaEntryFromProviderState = ( quota ); } + case 'devin': { + const quota = state as DevinQuotaState; + const planType = quota.plan ?? target.planType; + const metaLabels: string[] = []; + if (quota.plan) { + metaLabels.push(`${t('devin_quota.plan_label')}: ${quota.plan}`); + } + return applyProviderQuotaStateMetadata( + { + ...buildBaseAccountQuotaEntry( + { ...target, planType }, + t, + metaLabels + ), + planType, + windows: buildDevinAccountQuotaWindows(quota.windows, t), + }, + quota + ); + } case 'codex': default: { const quota = state as CodexQuotaState; @@ -1516,6 +1574,12 @@ export const buildCachedAccountQuotaEntry = ( getCredentialScopedQuotaState(stores.codexQuota, target.file), t ); + case 'devin': + return buildAccountQuotaEntryFromProviderState( + target, + getCredentialScopedQuotaState(stores.devinQuota, target.file), + t + ); case 'kimi': return buildAccountQuotaEntryFromProviderState( target, diff --git a/apps/web/src/features/monitoring/model/sourceDisplay.test.ts b/apps/web/src/features/monitoring/model/sourceDisplay.test.ts index 2a94893e2..7dbaa63c5 100644 --- a/apps/web/src/features/monitoring/model/sourceDisplay.test.ts +++ b/apps/web/src/features/monitoring/model/sourceDisplay.test.ts @@ -21,6 +21,8 @@ describe('isGenericMonitoringProviderLabel', () => { expect(isGenericMonitoringProviderLabel('x-ai')).toBe(true); expect(isGenericMonitoringProviderLabel('grok')).toBe(true); expect(isGenericMonitoringProviderLabel('antigravity')).toBe(true); + expect(isGenericMonitoringProviderLabel('devin')).toBe(true); + expect(isGenericMonitoringProviderLabel('Devin')).toBe(true); expect(isGenericMonitoringProviderLabel('anyrouter.top #1')).toBe(false); }); }); @@ -112,6 +114,45 @@ describe('buildMonitoringSourceDisplay', () => { expect(display.meta).toBe('codex'); }); + it('keeps generic devin provider labels secondary to the account identity', () => { + const authMetaMap = new Map([ + [ + 'devin-1', + { + authIndex: 'devin-1', + label: 'devin', + account: 'user@example.com', + provider: 'devin', + status: 'active', + disabled: false, + unavailable: false, + runtimeOnly: false, + planType: '-', + updatedAt: '', + }, + ], + ]); + + const display = buildMonitoringSourceDisplay( + { + authIndex: 'devin-1', + accountSnapshot: 'user@example.com', + authLabelSnapshot: 'devin', + authProviderSnapshot: 'devin', + channel: 'devin', + }, + { + authMetaMap, + channelByAuthIndex: new Map(), + } + ); + + expect(display.primary).toBe('use***@example.com'); + expect(display.meta).toBe('devin'); + expect(display.accountMasked).toBe('use***@example.com'); + expect(display.provider).toBe('devin'); + }); + it('still prefers non-generic channel names over the account identity', () => { const display = buildMonitoringSourceDisplay( { diff --git a/apps/web/src/features/monitoring/model/sourceDisplay.ts b/apps/web/src/features/monitoring/model/sourceDisplay.ts index 086190800..b3efbb062 100644 --- a/apps/web/src/features/monitoring/model/sourceDisplay.ts +++ b/apps/web/src/features/monitoring/model/sourceDisplay.ts @@ -15,6 +15,7 @@ const GENERIC_PROVIDER_LABELS = new Set([ 'xai', 'x-ai', 'grok', + 'devin', ]); const hasReadableValue = (value: string | null | undefined) => { diff --git a/apps/web/src/features/oauth/OAuthPage.devin.test.tsx b/apps/web/src/features/oauth/OAuthPage.devin.test.tsx new file mode 100644 index 000000000..c7b515d36 --- /dev/null +++ b/apps/web/src/features/oauth/OAuthPage.devin.test.tsx @@ -0,0 +1,590 @@ +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { OAuthPage } from './OAuthPage'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const { mocks } = vi.hoisted(() => ({ + mocks: { + apiBase: 'http://cpa-a.local:8317', + managementKey: 'key-a', + startAuth: vi.fn(), + getAuthStatus: vi.fn(), + submitCallback: vi.fn(), + cancelSession: vi.fn(), + authFilesList: vi.fn(async () => ({ files: [] })), + pluginList: vi.fn(async () => ({ plugins: [] })), + vertexImport: vi.fn(), + showNotification: vi.fn(), + navigate: vi.fn(), + recordMutationMarker: vi.fn(), + publishMutationRevision: vi.fn(), + intervalCallbacks: [] as Array<() => void | Promise>, + }, +})); + +vi.mock('react-i18next', () => ({ + initReactI18next: { type: '3rdParty', init: () => undefined }, + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('react-router-dom', () => ({ + useLocation: () => ({ hash: '' }), + useNavigate: () => mocks.navigate, +})); + +vi.mock('@/stores', () => { + const readAuthState = () => ({ + apiBase: mocks.apiBase, + managementKey: mocks.managementKey, + connectionStatus: 'connected' as const, + supportsPlugin: false, + }); + return { + publishAccountCredentialMutationRevision: mocks.publishMutationRevision, + useAuthStore: Object.assign( + (selector: (state: Record) => unknown) => selector(readAuthState()), + { getState: readAuthState } + ), + useNotificationStore: ( + selector?: (state: { showNotification: typeof mocks.showNotification }) => unknown + ) => { + const state = { showNotification: mocks.showNotification }; + return selector ? selector(state) : state; + }, + useThemeStore: (selector: (state: { resolvedTheme: 'light' }) => unknown) => + selector({ resolvedTheme: 'light' }), + }; +}); + +vi.mock('@/services/api', () => ({ + oauthApi: { + startAuth: mocks.startAuth, + getAuthStatus: mocks.getAuthStatus, + submitCallback: mocks.submitCallback, + cancelSession: mocks.cancelSession, + }, + authFilesApi: { + list: mocks.authFilesList, + }, + pluginsApi: { + list: mocks.pluginList, + }, +})); + +vi.mock('@/services/api/vertex', () => ({ + vertexApi: { + importCredential: mocks.vertexImport, + }, +})); + +vi.mock('@/features/monitoring/codexInspection', () => ({ + createCodexInspectionConnectionFingerprint: (apiBase: string, managementKey: string) => + apiBase && managementKey ? `${apiBase}:${managementKey}` : null, +})); + +vi.mock('@/features/accounts/model/accountCredentialMutationMarker', () => ({ + createAccountCredentialMutationBaseline: (_files: unknown[], provider: string) => ({ + provider, + credentials: [], + }), + recordAccountCredentialMutationMarker: mocks.recordMutationMarker, +})); + +vi.mock('@/utils/clipboard', () => ({ + copyToClipboard: vi.fn(async () => true), +})); + +vi.mock('@/features/plugins/pluginResources', () => ({ + getPluginTitle: (plugin: { id: string }) => plugin.id, + resolvePluginAssetURL: () => '', +})); + +const textContent = (node: ReactTestInstance): string => + node.children.map((child) => (typeof child === 'string' ? child : textContent(child))).join(''); + +const treeText = (renderer: ReactTestRenderer): string => + renderer.root.children + .map((child) => (typeof child === 'string' ? child : textContent(child))) + .join(''); + +const getDevinCard = (renderer: ReactTestRenderer): ReactTestInstance => { + const card = renderer.root.find((node) => node.props?.id === 'oauth-provider-devin'); + if (!card) throw new Error('Devin provider card not found'); + return card; +}; + +const findDevinButton = (renderer: ReactTestRenderer, text: string): ReactTestInstance => { + const card = getDevinCard(renderer); + const button = card + .findAllByType('button') + .find((candidate) => textContent(candidate) === text); + if (!button) throw new Error(`Button not found in Devin card: ${text}`); + return button; +}; + +const queryDevinButton = (renderer: ReactTestRenderer, text: string): ReactTestInstance | undefined => { + const card = getDevinCard(renderer); + return card + .findAllByType('button') + .find((candidate) => textContent(candidate) === text); +}; + +const mountedRenderers = new Set(); + +const renderOAuthPage = async (): Promise => { + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + await Promise.resolve(); + }); + mountedRenderers.add(renderer); + return renderer; +}; + +const findDevinLoginButton = (renderer: ReactTestRenderer): ReactTestInstance => { + const button = + queryDevinButton(renderer, 'auth_login.devin_oauth_button') || + queryDevinButton(renderer, 'auth_login.login_another_account'); + if (!button) throw new Error('Devin login button not found'); + return button; +}; + +const startDevinAuth = (renderer: ReactTestRenderer): Promise => { + const button = findDevinLoginButton(renderer); + return Promise.resolve(button.props.onClick()); +}; + +const submitDevinCallback = async (renderer: ReactTestRenderer, callbackUrl: string) => { + const card = getDevinCard(renderer); + const callbackInput = card + .findAllByType('input') + .find((input) => input.props.placeholder === 'auth_login.devin_callback_placeholder'); + if (!callbackInput) throw new Error('Devin callback input not found'); + + await act(async () => { + callbackInput.props.onChange({ target: { value: callbackUrl } }); + await Promise.resolve(); + }); + + let callbackPromise!: Promise; + act(() => { + callbackPromise = Promise.resolve( + findDevinButton(renderer, 'auth_login.oauth_callback_button').props.onClick() + ); + }); + await act(async () => { + await Promise.resolve(); + }); + return callbackPromise; +}; + +const cancelDevinAuth = (renderer: ReactTestRenderer): Promise => { + const button = findDevinButton(renderer, 'auth_login.devin_oauth_cancel'); + return Promise.resolve(button.props.onClick()); +}; + +describe('OAuthPage Devin OAuth lifecycle', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.apiBase = 'http://cpa-a.local:8317'; + mocks.managementKey = 'key-a'; + mocks.intervalCallbacks = []; + mocks.authFilesList.mockReset(); + mocks.authFilesList.mockResolvedValue({ files: [] }); + vi.stubGlobal('window', { + requestAnimationFrame: vi.fn(() => 1), + cancelAnimationFrame: vi.fn(), + setInterval: vi.fn((callback: () => void | Promise) => { + mocks.intervalCallbacks.push(callback); + return mocks.intervalCallbacks.length; + }), + clearInterval: vi.fn(), + setTimeout: vi.fn(() => 100), + clearTimeout: vi.fn(), + open: vi.fn(), + }); + }); + + afterEach(async () => { + await act(async () => { + mountedRenderers.forEach((renderer) => renderer.unmount()); + }); + mountedRenderers.clear(); + vi.unstubAllGlobals(); + }); + + it('renders Devin card, starts auth, and begins polling', async () => { + mocks.startAuth.mockResolvedValue({ + url: 'https://auth.example/devin?state=devin-state-1', + state: 'devin-state-1', + }); + mocks.getAuthStatus.mockResolvedValue({ status: 'wait' }); + + const renderer = await renderOAuthPage(); + await act(async () => { + await startDevinAuth(renderer); + }); + + expect(mocks.startAuth).toHaveBeenCalledWith('devin', { + apiBase: 'http://cpa-a.local:8317', + managementKey: 'key-a', + }); + + expect(treeText(renderer)).toContain('https://auth.example/devin?state=devin-state-1'); + expect(treeText(renderer)).toContain('auth_login.devin_oauth_status_waiting'); + expect(queryDevinButton(renderer, 'auth_login.devin_oauth_cancel')).toBeDefined(); + + // Verify polling callback is registered and triggers getAuthStatus + expect(mocks.intervalCallbacks).toHaveLength(1); + await act(async () => { + await mocks.intervalCallbacks[0]?.(); + }); + expect(mocks.getAuthStatus).toHaveBeenCalledWith('devin-state-1', { + apiBase: 'http://cpa-a.local:8317', + managementKey: 'key-a', + }); + }); + + it('prevents duplicate auth start when Devin session is already pending', async () => { + mocks.startAuth.mockResolvedValue({ + url: 'https://auth.example/devin?state=devin-state-1', + state: 'devin-state-1', + }); + + const renderer = await renderOAuthPage(); + await act(async () => { + await startDevinAuth(renderer); + }); + expect(mocks.startAuth).toHaveBeenCalledTimes(1); + + const loginButton = findDevinButton(renderer, 'auth_login.devin_oauth_button'); + expect(loginButton.props.disabled).toBe(true); + + // Clicking again while pending state exists should not trigger another startAuth + await act(async () => { + await loginButton.props.onClick(); + }); + expect(mocks.startAuth).toHaveBeenCalledTimes(1); + }); + + it('submits valid Devin callback URL untouched and records mutation marker on completion', async () => { + mocks.startAuth.mockResolvedValue({ + url: 'https://auth.example/devin?state=devin-state-1', + state: 'devin-state-1', + }); + mocks.submitCallback.mockResolvedValue({ status: 'ok' }); + mocks.getAuthStatus.mockResolvedValue({ status: 'ok' }); + + const renderer = await renderOAuthPage(); + await act(async () => { + await startDevinAuth(renderer); + }); + + const fullCallbackUrl = 'http://127.0.0.1:8317/callback?code=devin_code_xyz&state=devin-state-1'; + const callbackPromise = await submitDevinCallback(renderer, fullCallbackUrl); + await act(async () => { + await callbackPromise; + }); + + // submitCallback must receive the complete URL with host, port, path, and parameters intact + expect(mocks.submitCallback).toHaveBeenCalledWith( + 'devin', + fullCallbackUrl, + { + apiBase: 'http://cpa-a.local:8317', + managementKey: 'key-a', + } + ); + + expect(mocks.recordMutationMarker).toHaveBeenCalledWith({ + connectionFingerprint: 'http://cpa-a.local:8317:key-a', + provider: 'devin', + baseline: { provider: 'devin', credentials: [] }, + requireObservedMutation: true, + }); + expect(mocks.showNotification).toHaveBeenCalledWith( + 'auth_login.oauth_callback_success', + 'success' + ); + }); + + it('blocks submission and shows warning when callback state does not match expected state', async () => { + mocks.startAuth.mockResolvedValue({ + url: 'https://auth.example/devin?state=devin-state-1', + state: 'devin-state-1', + }); + + const renderer = await renderOAuthPage(); + await act(async () => { + await startDevinAuth(renderer); + }); + + const mismatchedUrl = 'http://127.0.0.1:8317/callback?code=devin_code_xyz&state=wrong-state'; + await submitDevinCallback(renderer, mismatchedUrl); + + expect(mocks.submitCallback).not.toHaveBeenCalled(); + expect(mocks.showNotification).toHaveBeenCalledWith( + 'auth_login.devin_callback_state_mismatch', + 'warning' + ); + + // Input retains user value + const input = renderer.root + .findAllByType('input') + .find((candidate) => candidate.props.placeholder === 'auth_login.devin_callback_placeholder'); + expect(input?.props.value).toBe(mismatchedUrl); + }); + + it('blocks submission and shows warning when callback URL is malformed or missing code', async () => { + mocks.startAuth.mockResolvedValue({ + url: 'https://auth.example/devin?state=devin-state-1', + state: 'devin-state-1', + }); + + const renderer = await renderOAuthPage(); + await act(async () => { + await startDevinAuth(renderer); + }); + + await submitDevinCallback(renderer, 'not-a-valid-url'); + + expect(mocks.submitCallback).not.toHaveBeenCalled(); + expect(mocks.showNotification).toHaveBeenCalledWith( + 'auth_login.devin_callback_invalid', + 'warning' + ); + }); + + it('cancels pending Devin auth session successfully when cancelled: true', async () => { + mocks.startAuth.mockResolvedValue({ + url: 'https://auth.example/devin?state=devin-state-1', + state: 'devin-state-1', + }); + mocks.cancelSession.mockResolvedValue({ status: 'ok', cancelled: true }); + + const renderer = await renderOAuthPage(); + await act(async () => { + await startDevinAuth(renderer); + }); + + expect(queryDevinButton(renderer, 'auth_login.devin_oauth_cancel')).toBeDefined(); + + await act(async () => { + await cancelDevinAuth(renderer); + }); + + expect(mocks.cancelSession).toHaveBeenCalledWith('devin-state-1', { + apiBase: 'http://cpa-a.local:8317', + managementKey: 'key-a', + }); + + // Session reset: cancel button disappears, URL box removed, start button re-enabled + expect(queryDevinButton(renderer, 'auth_login.devin_oauth_cancel')).toBeUndefined(); + expect(treeText(renderer)).not.toContain('https://auth.example/devin'); + const startButton = findDevinButton(renderer, 'auth_login.devin_oauth_button'); + expect(startButton.props.disabled).toBeFalsy(); + expect(mocks.showNotification).toHaveBeenCalledWith( + 'auth_login.devin_oauth_cancelled', + 'success' + ); + }); + + it('handles cancel race condition when cancelled: false and session already completed', async () => { + mocks.startAuth.mockResolvedValue({ + url: 'https://auth.example/devin?state=devin-state-1', + state: 'devin-state-1', + }); + mocks.cancelSession.mockResolvedValue({ status: 'ok', cancelled: false }); + mocks.getAuthStatus.mockResolvedValue({ status: 'ok' }); + + const renderer = await renderOAuthPage(); + await act(async () => { + await startDevinAuth(renderer); + }); + + await act(async () => { + await cancelDevinAuth(renderer); + }); + + expect(mocks.cancelSession).toHaveBeenCalledWith('devin-state-1', { + apiBase: 'http://cpa-a.local:8317', + managementKey: 'key-a', + }); + expect(mocks.getAuthStatus).toHaveBeenCalledWith('devin-state-1', { + apiBase: 'http://cpa-a.local:8317', + managementKey: 'key-a', + }); + expect(mocks.recordMutationMarker).toHaveBeenCalledWith({ + connectionFingerprint: 'http://cpa-a.local:8317:key-a', + provider: 'devin', + baseline: { provider: 'devin', credentials: [] }, + requireObservedMutation: true, + }); + expect(mocks.showNotification).toHaveBeenCalledWith( + 'auth_login.devin_oauth_status_success', + 'success' + ); + }); + + it('handles cancel failure: preserves state, shows cancel error, and resumes polling', async () => { + mocks.startAuth.mockResolvedValue({ + url: 'https://auth.example/devin?state=devin-state-1', + state: 'devin-state-1', + }); + mocks.cancelSession.mockRejectedValue(new Error('Network error on cancel')); + + const renderer = await renderOAuthPage(); + await act(async () => { + await startDevinAuth(renderer); + }); + + await act(async () => { + await cancelDevinAuth(renderer); + }); + + expect(mocks.showNotification).toHaveBeenCalledWith( + 'Network error on cancel', + 'error' + ); + expect(treeText(renderer)).toContain('Network error on cancel'); + // State and Cancel button preserved + expect(queryDevinButton(renderer, 'auth_login.devin_oauth_cancel')).toBeDefined(); + expect(treeText(renderer)).toContain('https://auth.example/devin'); + }); + + it('clears cancelError when attempt finishes successfully and does not leak to a new Devin login', async () => { + mocks.startAuth + .mockResolvedValueOnce({ + url: 'https://auth.example/devin?state=devin-state-1', + state: 'devin-state-1', + }) + .mockResolvedValueOnce({ + url: 'https://auth.example/devin?state=devin-state-2', + state: 'devin-state-2', + }); + mocks.cancelSession.mockRejectedValueOnce(new Error('Network error on cancel')); + mocks.getAuthStatus.mockResolvedValueOnce({ status: 'ok' }); + + const renderer = await renderOAuthPage(); + await act(async () => { + await startDevinAuth(renderer); + }); + + // Cancel fails, preserves pending state and shows cancelError + await act(async () => { + await cancelDevinAuth(renderer); + }); + expect(treeText(renderer)).toContain('Network error on cancel'); + expect(queryDevinButton(renderer, 'auth_login.devin_oauth_cancel')).toBeDefined(); + + // Subsequent polling succeeds and finishes the attempt + const latestPollingCallback = mocks.intervalCallbacks[mocks.intervalCallbacks.length - 1]; + await act(async () => { + await latestPollingCallback?.(); + }); + + // Attempt completes successfully: cancelError cleared + expect(treeText(renderer)).not.toContain('Network error on cancel'); + + // Start a new Devin login attempt + await act(async () => { + await startDevinAuth(renderer); + }); + + // Old cancel error must not leak into the new login attempt + expect(treeText(renderer)).not.toContain('Network error on cancel'); + expect(treeText(renderer)).toContain('https://auth.example/devin?state=devin-state-2'); + }); + + it('clears cancelError when CPA returns error status and does not leak to a new Devin login', async () => { + mocks.startAuth + .mockResolvedValueOnce({ + url: 'https://auth.example/devin?state=devin-state-1', + state: 'devin-state-1', + }) + .mockResolvedValueOnce({ + url: 'https://auth.example/devin?state=devin-state-2', + state: 'devin-state-2', + }); + mocks.cancelSession.mockRejectedValueOnce(new Error('Network error on cancel')); + mocks.getAuthStatus.mockResolvedValueOnce({ status: 'error', error: 'session expired' }); + + const renderer = await renderOAuthPage(); + await act(async () => { + await startDevinAuth(renderer); + }); + + // Cancel fails, cancelError is displayed + await act(async () => { + await cancelDevinAuth(renderer); + }); + expect(treeText(renderer)).toContain('Network error on cancel'); + + // Polling receives CPA error status + const latestPollingCallback = mocks.intervalCallbacks[mocks.intervalCallbacks.length - 1]; + await act(async () => { + await latestPollingCallback?.(); + }); + + // Error status clears cancelError and resets session + expect(treeText(renderer)).not.toContain('Network error on cancel'); + expect(treeText(renderer)).toContain('session expired'); + + // Start a new Devin login attempt + await act(async () => { + await startDevinAuth(renderer); + }); + + // Old cancel error must not leak into the new login attempt + expect(treeText(renderer)).not.toContain('Network error on cancel'); + expect(treeText(renderer)).toContain('https://auth.example/devin?state=devin-state-2'); + }); + + it('preserves state and leaves cancel button visible when polling encounters network error', async () => { + mocks.startAuth.mockResolvedValue({ + url: 'https://auth.example/devin?state=devin-state-1', + state: 'devin-state-1', + }); + mocks.getAuthStatus.mockRejectedValue(new Error('Connection reset')); + + const renderer = await renderOAuthPage(); + await act(async () => { + await startDevinAuth(renderer); + }); + + // Fire polling interval + await act(async () => { + await mocks.intervalCallbacks[0]?.(); + }); + + // Error status shown, but state and cancel button remain accessible + expect(treeText(renderer)).toContain('auth_login.devin_oauth_status_error'); + expect(queryDevinButton(renderer, 'auth_login.devin_oauth_cancel')).toBeDefined(); + expect(treeText(renderer)).toContain('https://auth.example/devin'); + }); + + it('resets Devin state back to idle when CPA explicitly returns error status', async () => { + mocks.startAuth.mockResolvedValue({ + url: 'https://auth.example/devin?state=devin-state-1', + state: 'devin-state-1', + }); + mocks.getAuthStatus.mockResolvedValue({ status: 'error', error: 'session expired' }); + + const renderer = await renderOAuthPage(); + await act(async () => { + await startDevinAuth(renderer); + }); + + // Fire polling interval + await act(async () => { + await mocks.intervalCallbacks[0]?.(); + }); + + // Error status handled: state cleared, cancel button gone, start button re-enabled + expect(queryDevinButton(renderer, 'auth_login.devin_oauth_cancel')).toBeUndefined(); + expect(treeText(renderer)).not.toContain('https://auth.example/devin'); + const startButton = findDevinButton(renderer, 'auth_login.devin_oauth_button'); + expect(startButton.props.disabled).toBeFalsy(); + }); +}); diff --git a/apps/web/src/features/oauth/OAuthPage.lifecycle.test.tsx b/apps/web/src/features/oauth/OAuthPage.lifecycle.test.tsx index 0fadfd574..22ed8b97a 100644 --- a/apps/web/src/features/oauth/OAuthPage.lifecycle.test.tsx +++ b/apps/web/src/features/oauth/OAuthPage.lifecycle.test.tsx @@ -11,6 +11,7 @@ const { mocks } = vi.hoisted(() => ({ startAuth: vi.fn(), getAuthStatus: vi.fn(), submitCallback: vi.fn(), + cancelSession: vi.fn(), authFilesList: vi.fn(async () => ({ files: [] })), pluginList: vi.fn(async () => ({ plugins: [] })), vertexImport: vi.fn(), @@ -61,6 +62,7 @@ vi.mock('@/services/api', () => ({ startAuth: mocks.startAuth, getAuthStatus: mocks.getAuthStatus, submitCallback: mocks.submitCallback, + cancelSession: mocks.cancelSession, }, authFilesApi: { list: mocks.authFilesList, @@ -146,6 +148,16 @@ const startCodexAuth = (renderer: ReactTestRenderer): Promise => { return promise; }; +const startDevinAuth = (renderer: ReactTestRenderer): Promise => { + let promise!: Promise; + act(() => { + promise = Promise.resolve( + findButton(renderer, 'auth_login.devin_oauth_button').props.onClick() + ); + }); + return promise; +}; + describe('OAuthPage connection lifecycle', () => { beforeEach(() => { vi.clearAllMocks(); @@ -417,4 +429,35 @@ describe('OAuthPage connection lifecycle', () => { }); expect(mocks.recordMutationMarker).toHaveBeenCalledTimes(1); }); + + it('ignores a late Devin polling response after the CPA connection changes', async () => { + const polling = deferred<{ status: 'ok' }>(); + mocks.startAuth.mockResolvedValue({ url: 'https://auth.example/devin', state: 'state-devin-a' }); + mocks.getAuthStatus.mockReturnValue(polling.promise); + const renderer = await renderOAuthPage(); + const authPromise = startDevinAuth(renderer); + await act(async () => { + await authPromise; + }); + + let pollingPromise!: Promise; + await act(async () => { + pollingPromise = Promise.resolve(mocks.intervalCallbacks[0]?.()); + await Promise.resolve(); + }); + + mocks.apiBase = 'http://cpa-b.local:8317'; + mocks.managementKey = 'key-b'; + await act(async () => { + renderer.update(); + polling.resolve({ status: 'ok' }); + await pollingPromise; + }); + + expect(mocks.recordMutationMarker).not.toHaveBeenCalled(); + expect(mocks.showNotification).not.toHaveBeenCalledWith( + 'auth_login.devin_oauth_status_success', + 'success' + ); + }); }); diff --git a/apps/web/src/features/oauth/OAuthPage.test.tsx b/apps/web/src/features/oauth/OAuthPage.test.tsx index e3830b07c..9bac2b3dd 100644 --- a/apps/web/src/features/oauth/OAuthPage.test.tsx +++ b/apps/web/src/features/oauth/OAuthPage.test.tsx @@ -275,7 +275,7 @@ describe('OAuthPage request lifecycle', () => { }); }); -const builtInProviderIds = new Set(['codex', 'anthropic', 'antigravity', 'kimi', 'xai']); +const builtInProviderIds = new Set(['codex', 'anthropic', 'antigravity', 'kimi', 'xai', 'devin']); describe('plugin OAuth provider helpers', () => { it('uses explicit plugin OAuth provider ids when present', () => { @@ -296,6 +296,16 @@ describe('plugin OAuth provider helpers', () => { builtInProviderIds ) ).toBe(false); + expect( + shouldShowPluginOAuthProvider( + { + id: 'custom-plugin', + oauthProvider: 'devin', + supportsOAuth: true, + }, + builtInProviderIds + ) + ).toBe(false); expect( shouldShowPluginOAuthProvider( { diff --git a/apps/web/src/features/oauth/OAuthPage.tsx b/apps/web/src/features/oauth/OAuthPage.tsx index 9f686866c..0802ec6d6 100644 --- a/apps/web/src/features/oauth/OAuthPage.tsx +++ b/apps/web/src/features/oauth/OAuthPage.tsx @@ -48,6 +48,7 @@ import { type OAuthProviderAttempt, type OAuthPollingScope, } from './oauthProviderHelpers'; +import { validateDevinCallback } from './devinOAuth'; import styles from './OAuthPage.module.scss'; import iconCodex from '@/assets/icons/codex.svg'; import iconClaude from '@/assets/icons/claude.svg'; @@ -57,6 +58,8 @@ import iconKimiDark from '@/assets/icons/kimi-dark.svg'; import iconVertex from '@/assets/icons/vertex.svg'; import iconGrok from '@/assets/icons/grok.svg'; import iconGrokDark from '@/assets/icons/grok-dark.svg'; +import iconDevin from '@/assets/icons/devin.svg'; +import iconDevinDark from '@/assets/icons/devin-dark.svg'; interface ProviderState { url?: string; @@ -64,6 +67,8 @@ interface ProviderState { status?: 'idle' | 'waiting' | 'success' | 'error'; error?: string; polling?: boolean; + cancelling?: boolean; + cancelError?: string; callbackUrl?: string; callbackSubmitting?: boolean; callbackStatus?: 'success' | 'error'; @@ -160,11 +165,24 @@ const BUILT_IN_PROVIDERS: BuiltInProviderDefinition[] = [ urlLabelKey: 'auth_login.xai_oauth_url_label', icon: { light: iconGrok, dark: iconGrokDark }, }, + { + id: 'devin', + titleKey: 'auth_login.devin_oauth_title', + hintKey: 'auth_login.devin_oauth_hint', + urlLabelKey: 'auth_login.devin_oauth_url_label', + icon: { light: iconDevin, dark: iconDevinDark }, + }, ]; const BUILT_IN_PROVIDER_IDS = new Set(BUILT_IN_PROVIDERS.map((provider) => provider.id)); -const CALLBACK_SUPPORTED = new Set(['codex', 'anthropic', 'antigravity', 'xai']); +const CALLBACK_SUPPORTED = new Set([ + 'codex', + 'anthropic', + 'antigravity', + 'xai', + 'devin', +]); const XAI_CALLBACK_URL = 'http://127.0.0.1:56121/callback'; const SUCCESS_RESET_DELAY_MS = 5000; const getProviderI18nPrefix = (provider: BuiltInOAuthProvider) => provider.replace('-', '_'); @@ -242,6 +260,7 @@ const resolveCallbackUrl = ( input: string, state?: string ): string | null => { + if (provider === 'devin') return input.trim(); if (provider !== 'xai') return input.trim(); return buildXaiCallbackUrl(input, state); }; @@ -550,6 +569,12 @@ export function OAuthPage() { status: 'success', error: undefined, polling: false, + ...(provider === 'devin' + ? { + cancelling: false, + cancelError: undefined, + } + : {}), callbackUrl: '', callbackSubmitting: false, callbackStatus: undefined, @@ -580,6 +605,15 @@ export function OAuthPage() { delete callbackAttemptVersions.current[provider]; clearPollingTimer(provider); updateProviderState(provider, { + ...(provider === 'devin' + ? { + url: undefined, + state: undefined, + callbackUrl: '', + cancelling: false, + cancelError: undefined, + } + : {}), status: 'error', error: response.error, polling: false, @@ -638,6 +672,15 @@ export function OAuthPage() { stopAttempt(); return; } + if (provider === 'devin') { + updateProviderState(provider, { + status: 'error', + error: `${getErrorMessage(err) || ''} ${t('auth_login.devin_oauth_retry_hint')}`.trim(), + polling: false, + }); + stopAttempt(); + return; + } finishProviderAttempt(provider, attempt); updateProviderState(provider, { status: 'error', @@ -653,6 +696,9 @@ export function OAuthPage() { }; const startAuth = async (provider: OAuthProvider) => { + if (provider === 'devin' && states[provider]?.state) { + return; + } clearProviderTimers(provider); delete callbackAttemptVersions.current[provider]; delete providerCredentialBaselines.current[provider]; @@ -663,6 +709,8 @@ export function OAuthPage() { status: 'waiting', polling: true, error: undefined, + cancelling: false, + cancelError: undefined, callbackSubmitting: false, callbackStatus: undefined, callbackError: undefined, @@ -718,6 +766,73 @@ export function OAuthPage() { } }; + const cancelAuth = async (provider: OAuthProvider) => { + if (provider !== 'devin') return; + const providerState = states[provider]; + const state = providerState?.state; + if (!state) return; + + const previousVersion = providerAttemptVersions.current[provider]; + const previousBaseline = providerCredentialBaselines.current[provider]; + + clearPollingTimer(provider); + delete callbackAttemptVersions.current[provider]; + + const attempt = beginProviderAttempt(provider); + + if (previousBaseline && previousBaseline.version === previousVersion) { + attempt.credentialBaseline = previousBaseline.baseline; + providerCredentialBaselines.current[provider] = { + version: attempt.version, + baseline: previousBaseline.baseline, + }; + } + + updateProviderState(provider, { + cancelling: true, + cancelError: undefined, + }); + + try { + const res = await oauthApi.cancelSession(state, attempt.requestScope); + if (!isProviderAttemptCurrent(provider, attempt)) return; + + if (res.cancelled) { + resetProviderAttempt(provider); + showNotification(t('auth_login.devin_oauth_cancelled'), 'success'); + return; + } + + updateProviderState(provider, { cancelling: false }); + const statusRes = await oauthApi.getAuthStatus(state, attempt.requestScope); + if (!isProviderAttemptCurrent(provider, attempt)) return; + + const result = handleProviderAuthStatus( + provider, + statusRes, + attempt, + getProviderActionText(provider, 'oauth_status_success') + ); + if (result === 'waiting') { + updateProviderState(provider, { polling: true }); + startPolling(provider, state, attempt); + } + } catch (err: unknown) { + if (!isProviderAttemptCurrent(provider, attempt)) return; + const message = getErrorMessage(err); + const cancelErrorMessage = + message || t('auth_login.devin_oauth_cancel_error'); + updateProviderState(provider, { + cancelling: false, + cancelError: cancelErrorMessage, + status: 'waiting', + polling: true, + }); + showNotification(cancelErrorMessage, 'error'); + startPolling(provider, state, attempt); + } + }; + const copyLink = async (url?: string) => { if (!url) return; const copied = await copyToClipboard(url); @@ -743,6 +858,18 @@ export function OAuthPage() { } const state = providerState?.state; const providerVersion = providerAttemptVersions.current[provider]; + if (provider === 'devin') { + const validation = validateDevinCallback(callbackInput, state); + if (!validation.valid) { + const errorMsg = t(validation.errorKey || 'auth_login.devin_callback_invalid'); + updateProviderState(provider, { + callbackStatus: 'error', + callbackError: errorMsg, + }); + showNotification(errorMsg, 'warning'); + return; + } + } const redirectUrl = resolveCallbackUrl(provider, callbackInput, state); if (!redirectUrl) { showNotification( @@ -963,7 +1090,11 @@ export function OAuthPage() { } extra={ - } @@ -985,23 +1116,47 @@ export function OAuthPage() { > {getProviderActionText(provider.id, 'open_link')} + {provider.id === 'devin' && Boolean(state.state) && ( + + )}
+ {state.cancelError && ( +
+ {state.cancelError} +
+ )} )} {canSubmitCallback && (
updateProviderState(provider.id, { callbackUrl: e.target.value, @@ -1010,9 +1165,11 @@ export function OAuthPage() { }) } placeholder={t( - provider.id === 'xai' - ? 'auth_login.xai_callback_placeholder' - : 'auth_login.oauth_callback_placeholder' + provider.id === 'devin' + ? 'auth_login.devin_callback_placeholder' + : provider.id === 'xai' + ? 'auth_login.xai_callback_placeholder' + : 'auth_login.oauth_callback_placeholder' )} />
@@ -1021,6 +1178,10 @@ export function OAuthPage() { size="sm" onClick={() => submitCallback(provider.id)} loading={state.callbackSubmitting} + disabled={ + provider.id === 'devin' && + (state.cancelling === true || state.status !== 'waiting') + } > {t('auth_login.oauth_callback_button')} diff --git a/apps/web/src/features/oauth/devinOAuth.test.ts b/apps/web/src/features/oauth/devinOAuth.test.ts new file mode 100644 index 000000000..9afa9cac0 --- /dev/null +++ b/apps/web/src/features/oauth/devinOAuth.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; +import { validateDevinCallback } from './devinOAuth'; + +describe('validateDevinCallback', () => { + it('accepts standard loopback /callback with code and matching state', () => { + const result = validateDevinCallback( + 'http://127.0.0.1:8317/callback?code=abc&state=expected', + 'expected' + ); + expect(result).toEqual({ valid: true }); + }); + + it('accepts alternative path /devin/callback with code and matching state', () => { + const result = validateDevinCallback( + 'http://127.0.0.1:8317/devin/callback?code=abc&state=expected', + 'expected' + ); + expect(result).toEqual({ valid: true }); + }); + + it('accepts custom domain or forwarded https URL', () => { + const result = validateDevinCallback( + 'https://proxy.example:9443/custom/callback?code=abc&state=expected', + 'expected' + ); + expect(result).toEqual({ valid: true }); + }); + + it('accepts callback with error or error_description instead of code', () => { + expect( + validateDevinCallback( + 'http://127.0.0.1:8317/callback?error=access_denied&state=expected', + 'expected' + ) + ).toEqual({ valid: true }); + + expect( + validateDevinCallback( + 'http://127.0.0.1:8317/callback?error_description=user_cancelled&state=expected', + 'expected' + ) + ).toEqual({ valid: true }); + }); + + it('rejects invalid or relative URLs', () => { + expect(validateDevinCallback('not-a-url', 'expected')).toEqual({ + valid: false, + errorKey: 'auth_login.devin_callback_invalid', + }); + expect(validateDevinCallback('/callback?code=123&state=expected', 'expected')).toEqual({ + valid: false, + errorKey: 'auth_login.devin_callback_invalid', + }); + expect( + validateDevinCallback('javascript:alert(1)?code=123&state=expected', 'expected') + ).toEqual({ + valid: false, + errorKey: 'auth_login.devin_callback_invalid', + }); + }); + + it('rejects missing or empty state', () => { + expect( + validateDevinCallback('http://127.0.0.1:8317/callback?code=abc', 'expected') + ).toEqual({ + valid: false, + errorKey: 'auth_login.devin_callback_invalid', + }); + expect( + validateDevinCallback('http://127.0.0.1:8317/callback?code=abc&state=', 'expected') + ).toEqual({ + valid: false, + errorKey: 'auth_login.devin_callback_invalid', + }); + }); + + it('rejects duplicate state parameters', () => { + expect( + validateDevinCallback( + 'http://127.0.0.1:8317/callback?code=abc&state=expected&state=another', + 'expected' + ) + ).toEqual({ + valid: false, + errorKey: 'auth_login.devin_callback_invalid', + }); + }); + + it('rejects state mismatch when expected state is provided', () => { + expect( + validateDevinCallback( + 'http://127.0.0.1:8317/callback?code=abc&state=wrong-state', + 'expected' + ) + ).toEqual({ + valid: false, + errorKey: 'auth_login.devin_callback_state_mismatch', + }); + }); + + it('rejects callback when expected state is undefined or empty', () => { + expect( + validateDevinCallback( + 'http://127.0.0.1:8317/callback?code=abc&state=state-a', + undefined + ) + ).toEqual({ + valid: false, + errorKey: 'auth_login.devin_callback_state_mismatch', + }); + expect( + validateDevinCallback( + 'http://127.0.0.1:8317/callback?code=abc&state=state-a', + ' ' + ) + ).toEqual({ + valid: false, + errorKey: 'auth_login.devin_callback_state_mismatch', + }); + }); + + it('rejects callback missing code, error, and error_description', () => { + expect( + validateDevinCallback('http://127.0.0.1:8317/callback?state=expected', 'expected') + ).toEqual({ + valid: false, + errorKey: 'auth_login.devin_callback_invalid', + }); + }); +}); diff --git a/apps/web/src/features/oauth/devinOAuth.ts b/apps/web/src/features/oauth/devinOAuth.ts new file mode 100644 index 000000000..7d6f4bfb9 --- /dev/null +++ b/apps/web/src/features/oauth/devinOAuth.ts @@ -0,0 +1,63 @@ +export interface DevinCallbackValidationResult { + valid: boolean; + errorKey?: string; +} + +/** + * Validates a user-provided Devin callback URL without modifying it. + * + * Rules: + * 1. Must parse with URL() + * 2. Protocol must be http: or https: + * 3. Exactly one non-empty state parameter + * 4. At least one of code, error, or error_description present + * 5. State must match expectedState when provided + * 6. Does NOT restrict hostname, port, or pathname + */ +export function validateDevinCallback( + input: string, + expectedState?: string +): DevinCallbackValidationResult { + const trimmed = input.trim(); + if (!trimmed) { + return { valid: false, errorKey: 'auth_login.devin_callback_invalid' }; + } + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return { valid: false, errorKey: 'auth_login.devin_callback_invalid' }; + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { valid: false, errorKey: 'auth_login.devin_callback_invalid' }; + } + + const allStates = parsed.searchParams.getAll('state'); + if (allStates.length !== 1) { + return { valid: false, errorKey: 'auth_login.devin_callback_invalid' }; + } + + const callbackState = allStates[0].trim(); + if (!callbackState) { + return { valid: false, errorKey: 'auth_login.devin_callback_invalid' }; + } + + const code = parsed.searchParams.get('code')?.trim(); + const error = parsed.searchParams.get('error')?.trim(); + const errorDescription = parsed.searchParams.get('error_description')?.trim(); + + if (!code && !error && !errorDescription) { + return { valid: false, errorKey: 'auth_login.devin_callback_invalid' }; + } + + if (!expectedState?.trim() || callbackState !== expectedState.trim()) { + return { + valid: false, + errorKey: 'auth_login.devin_callback_state_mismatch', + }; + } + + return { valid: true }; +} diff --git a/apps/web/src/features/system/SystemPage.tsx b/apps/web/src/features/system/SystemPage.tsx index 163f64b6e..e7df6672d 100644 --- a/apps/web/src/features/system/SystemPage.tsx +++ b/apps/web/src/features/system/SystemPage.tsx @@ -25,6 +25,8 @@ import { classifyModels } from '@/utils/models'; import { STORAGE_KEY_AUTH, STORAGE_KEY_QUOTA_CACHE } from '@/utils/constants'; import iconGemini from '@/assets/icons/gemini.svg'; import iconClaude from '@/assets/icons/claude.svg'; +import iconDevinLight from '@/assets/icons/devin.svg'; +import iconDevinDark from '@/assets/icons/devin-dark.svg'; import iconOpenaiLight from '@/assets/icons/openai-light.svg'; import iconOpenaiDark from '@/assets/icons/openai-dark.svg'; import iconQwen from '@/assets/icons/qwen.svg'; @@ -40,6 +42,7 @@ import { useManagerUpdates } from './ManagerUpdates'; import styles from './SystemPage.module.scss'; const MODEL_CATEGORY_ICONS: Record = { + devin: { light: iconDevinLight, dark: iconDevinDark }, gpt: { light: iconOpenaiLight, dark: iconOpenaiDark }, claude: iconClaude, gemini: iconGemini, diff --git a/apps/web/src/hooks/useVisualConfig.test.ts b/apps/web/src/hooks/useVisualConfig.test.ts index f55519dc1..2103d1ff2 100644 --- a/apps/web/src/hooks/useVisualConfig.test.ts +++ b/apps/web/src/hooks/useVisualConfig.test.ts @@ -689,4 +689,247 @@ describe('useVisualConfig', () => { harness.unmount(); }); + + describe('devin sensitive words', () => { + it('parses devin.sensitive-words with trimming, filtering empty items, and preserving order', () => { + const harness = mountUseVisualConfig(); + const yaml = [ + 'devin:', + ' sensitive-words:', + ' - " forbidden-token "', + ' - ""', + ' - " "', + ' - "system prompt leak"', + ' - "secret-key"', + '', + ].join('\n'); + + act(() => { + expect(harness.getCurrent().loadVisualValuesFromYaml(yaml).ok).toBe(true); + }); + + expect(harness.getCurrent().visualValues.devinSensitiveWords).toEqual([ + 'forbidden-token', + 'system prompt leak', + 'secret-key', + ]); + + // Verify non-canonical keys are ignored + const nonCanonicalYaml = [ + 'devin:', + ' sensitiveWords:', + ' - "bad1"', + 'devin-sensitive-words:', + ' - "bad2"', + '', + ].join('\n'); + + act(() => { + expect(harness.getCurrent().loadVisualValuesFromYaml(nonCanonicalYaml).ok).toBe(true); + }); + expect(harness.getCurrent().visualValues.devinSensitiveWords).toEqual([]); + + harness.unmount(); + }); + + it('canonically writes devin.sensitive-words into yaml', () => { + const harness = mountUseVisualConfig(); + const initialYaml = ['port: 8080', ''].join('\n'); + + act(() => { + expect(harness.getCurrent().loadVisualValuesFromYaml(initialYaml).ok).toBe(true); + harness.getCurrent().setVisualValues({ + devinSensitiveWords: ['word1', 'word2'], + }); + }); + + const resultYaml = harness.getCurrent().applyVisualChangesToYaml(initialYaml); + const parsed = parseYaml(resultYaml) as Record; + expect(parsed.devin).toEqual({ + 'sensitive-words': ['word1', 'word2'], + }); + + harness.unmount(); + }); + + it('canonically writes devin.sensitive-words trimming items and dropping empty strings', () => { + const harness = mountUseVisualConfig(); + const initialYaml = ['port: 8080', ''].join('\n'); + + act(() => { + expect(harness.getCurrent().loadVisualValuesFromYaml(initialYaml).ok).toBe(true); + harness.getCurrent().setVisualValues({ + devinSensitiveWords: [' API ', '', 'Claude Code'], + }); + }); + + const resultYaml = harness.getCurrent().applyVisualChangesToYaml(initialYaml); + const parsed = parseYaml(resultYaml) as Record; + expect(parsed.devin).toEqual({ + 'sensitive-words': ['API', 'Claude Code'], + }); + + harness.unmount(); + }); + + it('removes the devin map completely when clearing sensitive words and no other fields exist', () => { + const harness = mountUseVisualConfig(); + const yaml = [ + 'devin:', + ' sensitive-words:', + ' - secret', + 'port: 8080', + '', + ].join('\n'); + + act(() => { + expect(harness.getCurrent().loadVisualValuesFromYaml(yaml).ok).toBe(true); + harness.getCurrent().setVisualValues({ + devinSensitiveWords: [], + }); + }); + + const resultYaml = harness.getCurrent().applyVisualChangesToYaml(yaml); + const parsed = parseYaml(resultYaml) as Record; + expect(parsed.devin).toBeUndefined(); + expect(parsed.port).toBe(8080); + + harness.unmount(); + }); + + it('preserves unknown future sibling properties under devin when editing sensitive words', () => { + const harness = mountUseVisualConfig(); + const yaml = [ + 'devin:', + ' sensitive-words:', + ' - old-secret', + ' future-option: true', + ' nested-config:', + ' feature-flag: enabled', + 'port: 8080', + '', + ].join('\n'); + + act(() => { + expect(harness.getCurrent().loadVisualValuesFromYaml(yaml).ok).toBe(true); + harness.getCurrent().setVisualValues({ + devinSensitiveWords: ['new-secret'], + }); + }); + + const resultYaml = harness.getCurrent().applyVisualChangesToYaml(yaml); + const parsed = parseYaml(resultYaml) as Record; + expect(parsed.devin).toEqual({ + 'sensitive-words': ['new-secret'], + 'future-option': true, + 'nested-config': { + 'feature-flag': 'enabled', + }, + }); + + harness.unmount(); + }); + + it('preserves future sibling properties when clearing devin.sensitive-words', () => { + const harness = mountUseVisualConfig(); + const yaml = [ + 'devin:', + ' sensitive-words:', + ' - secret', + ' future-option: "keep-me"', + 'port: 8080', + '', + ].join('\n'); + + act(() => { + expect(harness.getCurrent().loadVisualValuesFromYaml(yaml).ok).toBe(true); + harness.getCurrent().setVisualValues({ + devinSensitiveWords: [], + }); + }); + + const resultYaml = harness.getCurrent().applyVisualChangesToYaml(yaml); + const parsed = parseYaml(resultYaml) as Record; + expect(parsed.devin).toEqual({ + 'future-option': 'keep-me', + }); + expect(parsed.port).toBe(8080); + + harness.unmount(); + }); + + it('does not touch or modify the devin subtree on unrelated visual edits', () => { + const harness = mountUseVisualConfig(); + const yaml = [ + '# Custom devin comment', + 'devin:', + ' sensitive-words:', + ' - do-not-touch', + ' custom-flag: 123', + 'port: 8080', + '', + ].join('\n'); + + act(() => { + expect(harness.getCurrent().loadVisualValuesFromYaml(yaml).ok).toBe(true); + harness.getCurrent().setVisualValues({ + port: '9090', + }); + }); + + const resultYaml = harness.getCurrent().applyVisualChangesToYaml(yaml); + expect(resultYaml).toContain('# Custom devin comment'); + expect(resultYaml).toContain('custom-flag: 123'); + const parsed = parseYaml(resultYaml) as Record; + expect(parsed.port).toBe(9090); + expect(parsed.devin).toEqual({ + 'sensitive-words': ['do-not-touch'], + 'custom-flag': 123, + }); + + harness.unmount(); + }); + + it('tracks the dirty lifecycle accurately for devinSensitiveWords', () => { + const harness = mountUseVisualConfig(); + const yaml = [ + 'devin:', + ' sensitive-words:', + ' - foo', + ' - bar', + '', + ].join('\n'); + + act(() => { + expect(harness.getCurrent().loadVisualValuesFromYaml(yaml).ok).toBe(true); + }); + expect(harness.getCurrent().visualDirty).toBe(false); + + // Setting to identical values does not mark dirty + act(() => { + harness.getCurrent().setVisualValues({ + devinSensitiveWords: ['foo', 'bar'], + }); + }); + expect(harness.getCurrent().visualDirty).toBe(false); + + // Editing marks dirty + act(() => { + harness.getCurrent().setVisualValues({ + devinSensitiveWords: ['foo', 'bar', 'baz'], + }); + }); + expect(harness.getCurrent().visualDirty).toBe(true); + + // Reverting clears dirty + act(() => { + harness.getCurrent().setVisualValues({ + devinSensitiveWords: ['foo', 'bar'], + }); + }); + expect(harness.getCurrent().visualDirty).toBe(false); + + harness.unmount(); + }); + }); }); diff --git a/apps/web/src/hooks/useVisualConfig.ts b/apps/web/src/hooks/useVisualConfig.ts index 3d9eadbb0..27fc2b1a0 100644 --- a/apps/web/src/hooks/useVisualConfig.ts +++ b/apps/web/src/hooks/useVisualConfig.ts @@ -644,6 +644,12 @@ function getNextDirtyFields( arePayloadFilterRulesEqual(nextValues.payloadFilterRules, baselineValues.payloadFilterRules) ); } + if (Object.prototype.hasOwnProperty.call(patch, 'devinSensitiveWords')) { + updateDirty( + 'devinSensitiveWords', + areStringArraysEqual(nextValues.devinSensitiveWords, baselineValues.devinSensitiveWords) + ); + } if (patch.streaming) { const streamingPatch = patch.streaming; if (Object.prototype.hasOwnProperty.call(streamingPatch, 'keepaliveSeconds')) { @@ -770,6 +776,7 @@ export function useVisualConfig() { const claudeHeaderDefaults = asRecord(parsed['claude-header-defaults']); const codexHeaderDefaults = asRecord(parsed['codex-header-defaults']); const codex = asRecord(parsed.codex); + const devin = asRecord(parsed.devin); const newValues: VisualConfigValues = { host: typeof parsed.host === 'string' ? parsed.host : '', @@ -874,6 +881,7 @@ export function useVisualConfig() { ? codexHeaderDefaults['beta-features'] : '', codexIdentityConfuse: Boolean(codex?.['identity-confuse'] ?? codex?.identityConfuse), + devinSensitiveWords: parseStringList(devin?.['sensitive-words']), quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? false), quotaSwitchPreviewModel: Boolean(quotaExceeded?.['switch-preview-model'] ?? false), @@ -1225,6 +1233,17 @@ export function useVisualConfig() { deleteIfMapEmpty(doc, ['codex']); } + if (isDirty('devinSensitiveWords')) { + const devinSensitiveWords = serializeStringListForYaml(values.devinSensitiveWords); + if (devinSensitiveWords.length > 0) { + ensureMapInDoc(doc, ['devin']); + doc.setIn(['devin', 'sensitive-words'], devinSensitiveWords); + } else if (docHas(doc, ['devin', 'sensitive-words'])) { + doc.deleteIn(['devin', 'sensitive-words']); + } + deleteIfMapEmpty(doc, ['devin']); + } + const writeQuotaSwitchProject = isDirty('quotaSwitchProject'); const writeQuotaSwitchPreviewModel = isDirty('quotaSwitchPreviewModel'); const writeQuotaAntigravityCredits = isDirty('quotaAntigravityCredits'); diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index be6e43978..efa2d1217 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -1638,6 +1638,7 @@ "filter_codex": "Codex", "filter_antigravity": "Antigravity", "filter_xai": "xAI", + "filter_devin": "Devin", "filter_iflow": "iFlow", "filter_vertex": "Vertex", "filter_empty": "Empty", @@ -1933,6 +1934,26 @@ "usage_amount": "{{remaining}} / {{limit}} remaining", "reset_unknown": "reset unknown" }, + "devin_quota": { + "title": "Devin Quota", + "empty_title": "No Devin Auth Files", + "empty_desc": "Upload or log in with Devin to view remaining quota.", + "idle": "Click here to refresh quota", + "loading": "Loading quota...", + "load_failed": "Failed to load quota: {{message}}", + "missing_auth_index": "Auth file missing auth_index", + "missing_identity": "Devin credential is missing its file name or auth_index", + "empty_data": "No quota data available", + "unavailable": "Quota unavailable", + "reset_unknown": "Reset time unknown", + "refresh_button": "Refresh Quota", + "fetch_all": "Fetch All", + "daily": "Daily limit", + "weekly": "Weekly limit", + "plan_label": "Plan", + "plan_start": "Plan start", + "plan_end": "Plan end" + }, "vertex_import": { "title": "Vertex JSON Login", "description": "Upload a Google service account JSON to store it as auth-dir/vertex-.json using the same rules as the CLI vertex-import helper.", @@ -2161,6 +2182,25 @@ "xai_callback_hint": "Grok may only show a code on the page. Paste that code directly and the app will submit it as http://127.0.0.1:56121/callback?... automatically.", "xai_callback_required": "Please paste the code shown by Grok or the full callback URL first.", "xai_callback_state_missing": "Missing the state for this xAI login. Start xAI login again, then submit the code.", + "devin_oauth_title": "Devin", + "devin_oauth_button": "Start Devin Login", + "devin_oauth_hint": "Sign in to Devin / Cognition through browser OAuth to save an authentication file. Requires CLI Proxy API v7.3.1 or later; complete authorization within five minutes.", + "devin_oauth_url_label": "Authorization URL:", + "devin_copy_link": "Copy Link", + "devin_open_link": "Open Link", + "devin_oauth_status_waiting": "Waiting for Devin authentication...", + "devin_oauth_status_success": "Devin authentication successful!", + "devin_oauth_status_error": "Devin authentication failed:", + "devin_oauth_start_error": "Failed to start Devin OAuth:", + "devin_oauth_cancel": "Cancel Devin Login", + "devin_oauth_cancelled": "Devin login cancelled.", + "devin_oauth_cancel_error": "Failed to cancel Devin login:", + "devin_oauth_retry_hint": "The server may still be waiting for authorization. Cancel this login before starting again.", + "devin_callback_label": "Callback URL", + "devin_callback_hint": "Remote server: copy the complete final callback URL (e.g. /callback?...) from the browser address bar, even if the loopback page cannot be reached. Keep its port, http/https, code and state unchanged. Submit the URL here, then wait for authentication to finish.", + "devin_callback_placeholder": "http://127.0.0.1:/callback?code=...&state=...", + "devin_callback_invalid": "Paste the complete http/https callback URL containing state and an authorization code or error.", + "devin_callback_state_mismatch": "This callback does not belong to the current Devin login. Use the final URL from this attempt; do not reuse an old callback.", "qwen_oauth_title": "Qwen OAuth", "qwen_oauth_button": "Start Qwen Login", "qwen_oauth_hint": "Log in to Qwen through device authorization, then automatically obtain and save the login file.", @@ -3546,7 +3586,13 @@ "antigravity_signature_cache": "Enable Antigravity Signature Cache", "antigravity_signature_cache_desc": "Prefer and validate cached thinking-block signatures", "antigravity_signature_strict": "Strict Bypass Signature Validation", - "antigravity_signature_strict_desc": "Only applies when the signature cache is disabled; validates the full Claude protobuf tree" + "antigravity_signature_strict_desc": "Only applies when the signature cache is disabled; validates the full Claude protobuf tree", + "devin_title": "Devin", + "devin_sensitive_words": "Sensitive Words", + "devin_sensitive_words_desc": "Configure sensitive words for Devin system prompts. Matching words will be obfuscated using zero-width characters before being sent to Devin.", + "devin_sensitive_words_label": "Sensitive Words", + "devin_sensitive_words_placeholder": "Enter a sensitive word or phrase", + "devin_sensitive_words_hint": "One word or phrase per item. Matches in Devin system prompts are obfuscated with zero-width characters. Empty entries are removed when saving." }, "headers": { "title": "Header Defaults", diff --git a/apps/web/src/i18n/locales/ru.json b/apps/web/src/i18n/locales/ru.json index 1e4b6565b..12e22c4e2 100644 --- a/apps/web/src/i18n/locales/ru.json +++ b/apps/web/src/i18n/locales/ru.json @@ -1642,6 +1642,7 @@ "filter_codex": "Codex", "filter_antigravity": "Antigravity", "filter_xai": "xAI", + "filter_devin": "Devin", "filter_iflow": "iFlow", "filter_vertex": "Vertex", "filter_empty": "Пусто", @@ -1935,6 +1936,26 @@ "usage_amount": "Осталось {{remaining}} / {{limit}}", "reset_unknown": "сброс неизвестен" }, + "devin_quota": { + "title": "Квота Devin", + "empty_title": "Файлы авторизации Devin отсутствуют", + "empty_desc": "Войдите или загрузите учётные данные Devin, чтобы просмотреть квоту.", + "idle": "Не загружено. Нажмите \"Обновить квоту\".", + "loading": "Загрузка квоты...", + "load_failed": "Не удалось загрузить квоту: {{message}}", + "missing_auth_index": "В файле авторизации отсутствует auth_index", + "missing_identity": "В учётных данных Devin отсутствует имя файла или auth_index", + "empty_data": "Данные по квоте отсутствуют", + "unavailable": "Квота недоступна", + "reset_unknown": "Время сброса неизвестно", + "refresh_button": "Обновить квоту", + "fetch_all": "Получить все", + "daily": "Дневной лимит", + "weekly": "Недельный лимит", + "plan_label": "Тариф", + "plan_start": "Начало тарифа", + "plan_end": "Окончание тарифа" + }, "vertex_import": { "title": "Вход с Vertex JSON", "description": "Загрузите JSON ключа сервисного аккаунта Google, чтобы сохранить его как auth-dir/vertex-.json по тем же правилам, что и помощник CLI vertex-import.", @@ -2163,6 +2184,25 @@ "xai_callback_hint": "Grok иногда показывает только код на странице. Вставьте этот код напрямую, приложение автоматически отправит его как http://127.0.0.1:56121/callback?...", "xai_callback_required": "Сначала вставьте код со страницы Grok или полный callback URL.", "xai_callback_state_missing": "Отсутствует state для этого входа xAI. Запустите вход xAI заново и затем отправьте код.", + "devin_oauth_title": "Devin", + "devin_oauth_button": "Войти в Devin", + "devin_oauth_hint": "Войдите в Devin / Cognition через OAuth в браузере для сохранения файла аутентификации. Требуется CLI Proxy API v7.3.1 или новее; завершите авторизацию в течение пяти минут.", + "devin_oauth_url_label": "URL авторизации:", + "devin_copy_link": "Скопировать ссылку", + "devin_open_link": "Открыть ссылку", + "devin_oauth_status_waiting": "Ожидание аутентификации Devin...", + "devin_oauth_status_success": "Аутентификация Devin успешна!", + "devin_oauth_status_error": "Ошибка аутентификации Devin:", + "devin_oauth_start_error": "Не удалось запустить Devin OAuth:", + "devin_oauth_cancel": "Отменить вход в Devin", + "devin_oauth_cancelled": "Вход в Devin отменён.", + "devin_oauth_cancel_error": "Не удалось отменить вход в Devin:", + "devin_oauth_retry_hint": "Сервер может всё ещё ожидать авторизацию. Отмените эту попытку входа перед новой.", + "devin_callback_label": "URL обратного вызова", + "devin_callback_hint": "Удалённый сервер: скопируйте полный конечный URL обратного вызова (например /callback?...) из адресной строки, даже если локальный адрес недоступен. Сохраните порт, http/https, code и state без изменений. Отправьте URL здесь и дождитесь завершения аутентификации.", + "devin_callback_placeholder": "http://127.0.0.1:<порт-сервера>/callback?code=...&state=...", + "devin_callback_invalid": "Вставьте полный URL обратного вызова http/https со state и кодом авторизации или ошибкой.", + "devin_callback_state_mismatch": "Этот обратный вызов не относится к текущему входу в Devin. Используйте конечный URL этой попытки, а не старый URL.", "qwen_oauth_title": "Qwen OAuth", "qwen_oauth_button": "Начать вход Qwen", "qwen_oauth_hint": "Войдите в Qwen через авторизацию устройства, затем файл входа будет получен и сохранён автоматически.", @@ -3548,7 +3588,13 @@ "antigravity_signature_cache": "Включить кэш подписей Antigravity", "antigravity_signature_cache_desc": "Предпочитать и проверять кэшированные подписи thinking-блоков", "antigravity_signature_strict": "Строгая проверка bypass-подписи", - "antigravity_signature_strict_desc": "Применяется только при отключённом кэше подписей; проверяет полное дерево Claude protobuf" + "antigravity_signature_strict_desc": "Применяется только при отключённом кэше подписей; проверяет полное дерево Claude protobuf", + "devin_title": "Devin", + "devin_sensitive_words": "Список чувствительных слов", + "devin_sensitive_words_desc": "Настройка чувствительных слов для системных промптов Devin. Совпадающие слова обфусцируются с помощью символов нулевой ширины перед отправкой в Devin.", + "devin_sensitive_words_label": "Чувствительные слова", + "devin_sensitive_words_placeholder": "Введите чувствительное слово или фразу", + "devin_sensitive_words_hint": "По одному слову или фразе на строку. Совпадения в системных промптах Devin обфусцируются символами нулевой ширины. Пустые записи удаляются при сохранении." }, "headers": { "title": "Заголовки по умолчанию", diff --git a/apps/web/src/i18n/locales/zh-CN.json b/apps/web/src/i18n/locales/zh-CN.json index 3630c56b9..71f28fce3 100644 --- a/apps/web/src/i18n/locales/zh-CN.json +++ b/apps/web/src/i18n/locales/zh-CN.json @@ -1636,6 +1636,7 @@ "filter_codex": "Codex", "filter_antigravity": "Antigravity", "filter_xai": "xAI", + "filter_devin": "Devin", "filter_iflow": "iFlow", "filter_vertex": "Vertex", "filter_empty": "空文件", @@ -1931,6 +1932,26 @@ "usage_amount": "剩余 {{remaining}} / {{limit}}", "reset_unknown": "重置时间未知" }, + "devin_quota": { + "title": "Devin 额度", + "empty_title": "暂无 Devin 凭证", + "empty_desc": "登录或导入 Devin 凭证后即可查看额度。", + "idle": "点击此处刷新额度", + "loading": "正在加载额度...", + "load_failed": "额度获取失败:{{message}}", + "missing_auth_index": "认证文件缺少 auth_index", + "missing_identity": "Devin 凭证缺少文件名或 auth_index", + "empty_data": "暂无额度数据", + "unavailable": "额度不可用", + "reset_unknown": "重置时间未知", + "refresh_button": "刷新额度", + "fetch_all": "获取全部", + "daily": "每日限额", + "weekly": "每周限额", + "plan_label": "套餐", + "plan_start": "套餐生效时间", + "plan_end": "套餐到期时间" + }, "vertex_import": { "title": "Vertex JSON 登录", "description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-.json。", @@ -2159,6 +2180,25 @@ "xai_callback_hint": "Grok 有时只在页面显示 code。可直接粘贴 code,系统会自动拼接为 http://127.0.0.1:56121/callback?... 后提交。", "xai_callback_required": "请先粘贴 Grok 页面显示的 code 或完整回调 URL。", "xai_callback_state_missing": "缺少本次 xAI 登录的 state,请重新开始 xAI 登录后再提交 code。", + "devin_oauth_title": "Devin", + "devin_oauth_button": "开始 Devin 登录", + "devin_oauth_hint": "通过浏览器 OAuth 登录 Devin / Cognition,自动保存认证文件。需要 CLI Proxy API v7.3.1 或更高版本,请在五分钟内完成授权。", + "devin_oauth_url_label": "授权链接:", + "devin_copy_link": "复制链接", + "devin_open_link": "打开链接", + "devin_oauth_status_waiting": "等待 Devin 认证中...", + "devin_oauth_status_success": "Devin 认证成功!", + "devin_oauth_status_error": "Devin 认证失败:", + "devin_oauth_start_error": "启动 Devin OAuth 失败:", + "devin_oauth_cancel": "取消 Devin 登录", + "devin_oauth_cancelled": "已取消 Devin 登录。", + "devin_oauth_cancel_error": "取消 Devin 登录失败:", + "devin_oauth_retry_hint": "服务器可能仍在等待授权。请先取消本次登录,再重新开始。", + "devin_callback_label": "回调 URL", + "devin_callback_hint": "远程服务器:即使浏览器无法打开回环地址,也请从地址栏复制最终的完整回调 URL(如 /callback?...),保留原有端口、http/https、code 和 state。提交后请继续等待认证完成。", + "devin_callback_placeholder": "http://127.0.0.1:<服务器端口>/callback?code=...&state=...", + "devin_callback_invalid": "请粘贴完整的 http/https 回调 URL,包含 state 和授权码或错误信息。", + "devin_callback_state_mismatch": "此回调不属于当前 Devin 登录。请使用本次授权的最终 URL,不要复用旧回调。", "qwen_oauth_title": "Qwen OAuth", "qwen_oauth_button": "开始 Qwen 登录", "qwen_oauth_hint": "通过设备授权流程登录 Qwen 服务,自动获取并保存认证文件。", @@ -3544,7 +3584,13 @@ "antigravity_signature_cache": "启用 Antigravity 签名缓存", "antigravity_signature_cache_desc": "优先使用并校验缓存的 thinking block 签名", "antigravity_signature_strict": "严格校验旁路签名", - "antigravity_signature_strict_desc": "仅在关闭签名缓存时生效,按完整 Claude protobuf 树校验" + "antigravity_signature_strict_desc": "仅在关闭签名缓存时生效,按完整 Claude protobuf 树校验", + "devin_title": "Devin", + "devin_sensitive_words": "敏感词列表", + "devin_sensitive_words_desc": "配置 Devin 专用的系统提示词敏感词列表。命中的敏感词会在发送至 Devin 时通过零宽字符混淆。", + "devin_sensitive_words_label": "敏感词", + "devin_sensitive_words_placeholder": "输入敏感词或短语", + "devin_sensitive_words_hint": "每项一个词或短语。命中 Devin 系统提示词内容时将通过零宽字符混淆,保存时自动移除空白项。" }, "headers": { "title": "Header 默认值", diff --git a/apps/web/src/i18n/locales/zh-TW.json b/apps/web/src/i18n/locales/zh-TW.json index b1dc0bb10..db043eedc 100644 --- a/apps/web/src/i18n/locales/zh-TW.json +++ b/apps/web/src/i18n/locales/zh-TW.json @@ -1636,6 +1636,7 @@ "filter_codex": "Codex", "filter_antigravity": "Antigravity", "filter_xai": "xAI", + "filter_devin": "Devin", "filter_iflow": "iFlow", "filter_vertex": "Vertex", "filter_empty": "空檔案", @@ -1931,6 +1932,26 @@ "usage_amount": "剩餘 {{remaining}} / {{limit}}", "reset_unknown": "重置時間未知" }, + "devin_quota": { + "title": "Devin 配額", + "empty_title": "暫無 Devin 憑證", + "empty_desc": "登入或匯入 Devin 憑證後即可查看配額。", + "idle": "點擊此處重新整理配額", + "loading": "正在載入配額...", + "load_failed": "配額取得失敗:{{message}}", + "missing_auth_index": "驗證檔案缺少 auth_index", + "missing_identity": "Devin 憑證缺少檔案名稱或 auth_index", + "empty_data": "暫無配額資料", + "unavailable": "配額不可用", + "reset_unknown": "重置時間未知", + "refresh_button": "重新整理配額", + "fetch_all": "取得全部", + "daily": "每日限額", + "weekly": "每週限額", + "plan_label": "方案", + "plan_start": "方案生效時間", + "plan_end": "方案到期時間" + }, "vertex_import": { "title": "Vertex JSON 登入", "description": "上傳 Google 服務帳號 JSON,使用 CLI vertex-import 同步規則寫入 auth-dir/vertex-.json。", @@ -2159,6 +2180,25 @@ "xai_callback_hint": "Grok 有時只在頁面顯示 code。可直接貼上 code,系統會自動拼接為 http://127.0.0.1:56121/callback?... 後提交。", "xai_callback_required": "請先貼上 Grok 頁面顯示的 code 或完整回調 URL。", "xai_callback_state_missing": "缺少本次 xAI 登入的 state,請重新開始 xAI 登入後再提交 code。", + "devin_oauth_title": "Devin", + "devin_oauth_button": "開始 Devin 登入", + "devin_oauth_hint": "透過瀏覽器 OAuth 登入 Devin / Cognition,自動儲存認證檔案。需要 CLI Proxy API v7.3.1 或更新版本,請在五分鐘內完成授權。", + "devin_oauth_url_label": "授權連結:", + "devin_copy_link": "複製連結", + "devin_open_link": "開啟連結", + "devin_oauth_status_waiting": "等待 Devin 認證中...", + "devin_oauth_status_success": "Devin 認證成功!", + "devin_oauth_status_error": "Devin 認證失敗:", + "devin_oauth_start_error": "啟動 Devin OAuth 失敗:", + "devin_oauth_cancel": "取消 Devin 登入", + "devin_oauth_cancelled": "已取消 Devin 登入。", + "devin_oauth_cancel_error": "取消 Devin 登入失敗:", + "devin_oauth_retry_hint": "伺服器可能仍在等待授權。請先取消本次登入,再重新開始。", + "devin_callback_label": "回調 URL", + "devin_callback_hint": "遠端伺服器:即使瀏覽器無法開啟迴路位址,也請從網址列複製最終的完整回呼 URL(如 /callback?...),保留原有連接埠、http/https、code 和 state。提交後請繼續等待認證完成。", + "devin_callback_placeholder": "http://127.0.0.1:<伺服器連接埠>/callback?code=...&state=...", + "devin_callback_invalid": "請貼上完整的 http/https 回呼 URL,包含 state 和授權碼或錯誤資訊。", + "devin_callback_state_mismatch": "此回呼不屬於目前的 Devin 登入。請使用本次授權的最終 URL,不要重複使用舊回呼。", "qwen_oauth_title": "Qwen OAuth", "qwen_oauth_button": "開始 Qwen 登入", "qwen_oauth_hint": "透過裝置授權流程登入 Qwen 服務,自動取得並儲存驗證檔案。", @@ -3544,7 +3584,13 @@ "antigravity_signature_cache": "啟用 Antigravity 簽名快取", "antigravity_signature_cache_desc": "優先使用並校驗快取的 thinking block 簽名", "antigravity_signature_strict": "嚴格校驗旁路簽名", - "antigravity_signature_strict_desc": "僅在關閉簽名快取時生效,按完整 Claude protobuf 樹校驗" + "antigravity_signature_strict_desc": "僅在關閉簽名快取時生效,按完整 Claude protobuf 樹校驗", + "devin_title": "Devin", + "devin_sensitive_words": "敏感詞列表", + "devin_sensitive_words_desc": "設定 Devin 專用的系統提示詞敏感詞列表。命中的敏感詞會在發送至 Devin 時透過零寬字元混淆。", + "devin_sensitive_words_label": "敏感詞", + "devin_sensitive_words_placeholder": "輸入敏感詞或短語", + "devin_sensitive_words_hint": "每項一個詞或短語。命中 Devin 系統提示詞內容時將透過零寬字元混淆,儲存時自動移除空白項。" }, "headers": { "title": "Header 預設值", diff --git a/apps/web/src/services/api/oauth.test.ts b/apps/web/src/services/api/oauth.test.ts index 19e0f1d0d..a77ecf8a9 100644 --- a/apps/web/src/services/api/oauth.test.ts +++ b/apps/web/src/services/api/oauth.test.ts @@ -4,6 +4,7 @@ const { mocks } = vi.hoisted(() => ({ mocks: { get: vi.fn(), post: vi.fn(), + delete: vi.fn(), }, })); @@ -11,6 +12,7 @@ vi.mock('./client', () => ({ apiClient: { get: mocks.get, post: mocks.post, + delete: mocks.delete, }, createScopedApiRequestConfig: (scope: { apiBase: string; managementKey: string }) => ({ baseURL: `${scope.apiBase.replace(/\/+$/, '')}/v0/management`, @@ -24,6 +26,7 @@ import { oauthApi } from './oauth'; beforeEach(() => { mocks.get.mockReset(); mocks.post.mockReset(); + mocks.delete.mockReset(); }); describe('oauthApi', () => { @@ -83,4 +86,35 @@ describe('oauthApi', () => { scopedConfig ); }); + + it('starts Devin OAuth with is_webui flag', async () => { + mocks.get.mockResolvedValue({ url: 'https://auth.example/devin', state: 'state-devin-1' }); + + await oauthApi.startAuth('devin'); + + expect(mocks.get).toHaveBeenCalledWith('/devin-auth-url', { + params: { is_webui: true }, + }); + }); + + it('cancels an active OAuth session using DELETE /oauth-session with captured scope', async () => { + const requestScope = { + apiBase: 'http://cpa.example:8317', + managementKey: 'cpa-key-1', + }; + const scopedConfig = { + baseURL: 'http://cpa.example:8317/v0/management', + headers: { Authorization: 'Bearer cpa-key-1' }, + cpampScopedRequest: true, + }; + mocks.delete.mockResolvedValue({ status: 'ok', cancelled: true }); + + const result = await oauthApi.cancelSession('state-devin-1', requestScope); + + expect(mocks.delete).toHaveBeenCalledWith('/oauth-session', { + ...scopedConfig, + params: { state: 'state-devin-1' }, + }); + expect(result).toEqual({ status: 'ok', cancelled: true }); + }); }); diff --git a/apps/web/src/services/api/oauth.ts b/apps/web/src/services/api/oauth.ts index 620dfc33c..341aa4585 100644 --- a/apps/web/src/services/api/oauth.ts +++ b/apps/web/src/services/api/oauth.ts @@ -4,7 +4,13 @@ import { apiClient, createScopedApiRequestConfig, type ApiClientRequestScope } from './client'; -export type BuiltInOAuthProvider = 'codex' | 'anthropic' | 'antigravity' | 'kimi' | 'xai'; +export type BuiltInOAuthProvider = + | 'codex' + | 'anthropic' + | 'antigravity' + | 'kimi' + | 'xai' + | 'devin'; export type OAuthProvider = BuiltInOAuthProvider | (string & {}); export interface OAuthStartResponse { @@ -16,7 +22,12 @@ export interface OAuthCallbackResponse { status: 'ok'; } -const WEBUI_SUPPORTED: string[] = ['codex', 'anthropic', 'antigravity', 'xai']; +export interface OAuthCancelResponse { + status: 'ok'; + cancelled: boolean; +} + +const WEBUI_SUPPORTED: string[] = ['codex', 'anthropic', 'antigravity', 'xai', 'devin']; export const oauthApi = { startAuth: (provider: OAuthProvider, requestScope?: ApiClientRequestScope) => { @@ -50,4 +61,10 @@ export const oauthApi = { requestScope ? createScopedApiRequestConfig(requestScope) : undefined ); }, + + cancelSession: (state: string, requestScope?: ApiClientRequestScope) => + apiClient.delete('/oauth-session', { + ...(requestScope ? createScopedApiRequestConfig(requestScope) : {}), + params: { state }, + }), }; diff --git a/apps/web/src/stores/useQuotaStore.test.ts b/apps/web/src/stores/useQuotaStore.test.ts index 2d9f5f007..0e511f92e 100644 --- a/apps/web/src/stores/useQuotaStore.test.ts +++ b/apps/web/src/stores/useQuotaStore.test.ts @@ -3,6 +3,7 @@ import type { AntigravityQuotaState, ClaudeQuotaState, CodexQuotaState, + DevinQuotaState, KimiQuotaState, XaiQuotaState, } from '@/types'; @@ -38,6 +39,7 @@ const readPersistedQuotaState = async () => { antigravityQuota?: Record; claudeQuota?: Record; codexQuota?: Record; + devinQuota?: Record; kimiQuota?: Record; xaiQuota?: Record; }; @@ -177,6 +179,38 @@ describe('useQuotaStore persistence', () => { }, xaiLoading: { status: 'loading', billing: null }, }); + useQuotaStore.getState().setDevinQuota({ + devinSuccess: { + status: 'success', + windows: [], + observedAtMs: null, + plan: null, + planStartMs: null, + planEndMs: null, + authFileKey: 'devinSuccess', + authFileIdentityVerified: true, + }, + devinError: { + status: 'error', + windows: [], + observedAtMs: null, + plan: null, + planStartMs: null, + planEndMs: null, + error: 'devin failed', + errorStatus: 500, + authFileKey: 'devinError', + authFileIdentityVerified: true, + }, + devinLoading: { + status: 'loading', + windows: [], + observedAtMs: null, + plan: null, + planStartMs: null, + planEndMs: null, + }, + }); const persisted = await readPersistedQuotaState(); @@ -187,6 +221,7 @@ describe('useQuotaStore persistence', () => { ]); expect(Object.keys(persisted.kimiQuota ?? {})).toEqual(['kimiSuccess', 'kimiError']); expect(Object.keys(persisted.xaiQuota ?? {})).toEqual(['xaiSuccess', 'xaiError']); + expect(Object.keys(persisted.devinQuota ?? {})).toEqual(['devinSuccess', 'devinError']); }); it('drops legacy and unverified quota cache entries while canonicalizing verified keys', async () => { @@ -233,6 +268,30 @@ describe('useQuotaStore persistence', () => { authFileIdentityVerified: true, }, }); + useQuotaStore.getState().setDevinQuota({ + devinSuccess: { + status: 'success', + windows: [], + observedAtMs: 1_000, + plan: 'Team', + planStartMs: null, + planEndMs: null, + authFileKey: 'devinSuccess', + authFileIdentityVerified: true, + }, + devinError: { + status: 'error', + windows: [], + observedAtMs: null, + plan: null, + planStartMs: null, + planEndMs: null, + error: 'devin failed', + errorStatus: 502, + authFileKey: 'devinError', + authFileIdentityVerified: true, + }, + }); vi.resetModules(); const { useQuotaStore: hydratedQuotaStore } = await import('./useQuotaStore'); @@ -244,6 +303,15 @@ describe('useQuotaStore persistence', () => { expect(hydratedQuotaStore.getState().claudeQuota.claudeSuccess).toMatchObject({ status: 'success', }); + expect(hydratedQuotaStore.getState().devinQuota.devinSuccess).toMatchObject({ + status: 'success', + plan: 'Team', + }); + expect(hydratedQuotaStore.getState().devinQuota.devinError).toMatchObject({ + status: 'error', + error: 'devin failed', + errorStatus: 502, + }); }); it('clears quota state and persisted quota cache together', async () => { @@ -266,6 +334,7 @@ describe('useQuotaStore persistence', () => { antigravityQuota: {}, claudeQuota: {}, codexQuota: {}, + devinQuota: {}, kimiQuota: {}, xaiQuota: {}, }); diff --git a/apps/web/src/stores/useQuotaStore.ts b/apps/web/src/stores/useQuotaStore.ts index 20567842a..929a6e3b6 100644 --- a/apps/web/src/stores/useQuotaStore.ts +++ b/apps/web/src/stores/useQuotaStore.ts @@ -9,6 +9,7 @@ import type { ClaudeQuotaState, CodexQuotaState, CredentialScopedQuotaState, + DevinQuotaState, KimiQuotaState, XaiQuotaState, } from '@/types'; @@ -23,11 +24,13 @@ interface QuotaStoreState { antigravityQuota: Record; claudeQuota: Record; codexQuota: Record; + devinQuota: Record; kimiQuota: Record; xaiQuota: Record; setAntigravityQuota: (updater: QuotaUpdater>) => void; setClaudeQuota: (updater: QuotaUpdater>) => void; setCodexQuota: (updater: QuotaUpdater>) => void; + setDevinQuota: (updater: QuotaUpdater>) => void; setKimiQuota: (updater: QuotaUpdater>) => void; setXaiQuota: (updater: QuotaUpdater>) => void; activateQuotaCacheScope: (scope: string) => void; @@ -45,6 +48,7 @@ const emptyQuotaState = { antigravityQuota: {}, claudeQuota: {}, codexQuota: {}, + devinQuota: {}, kimiQuota: {}, xaiQuota: {}, }; @@ -115,6 +119,10 @@ export const useQuotaStore = create()( set((state) => ({ codexQuota: resolveUpdater(updater, state.codexQuota), })), + setDevinQuota: (updater) => + set((state) => ({ + devinQuota: resolveUpdater(updater, state.devinQuota), + })), setKimiQuota: (updater) => set((state) => ({ kimiQuota: resolveUpdater(updater, state.kimiQuota), @@ -153,6 +161,7 @@ export const useQuotaStore = create()( antigravityQuota: filterPersistableQuotaStates(state.antigravityQuota), claudeQuota: filterPersistableQuotaStates(state.claudeQuota), codexQuota: filterPersistableCodexQuota(state.codexQuota), + devinQuota: filterPersistableQuotaStates(state.devinQuota), kimiQuota: filterPersistableQuotaStates(state.kimiQuota), xaiQuota: filterPersistableQuotaStates(state.xaiQuota), }), @@ -164,6 +173,7 @@ export const useQuotaStore = create()( antigravityQuota: filterPersistableQuotaStates(persisted?.antigravityQuota), claudeQuota: filterPersistableQuotaStates(persisted?.claudeQuota), codexQuota: filterPersistableCodexQuota(persisted?.codexQuota), + devinQuota: filterPersistableQuotaStates(persisted?.devinQuota), kimiQuota: filterPersistableQuotaStates(persisted?.kimiQuota), xaiQuota: filterPersistableQuotaStates(persisted?.xaiQuota), }; diff --git a/apps/web/src/types/authFile.ts b/apps/web/src/types/authFile.ts index e85ef05b4..3be76b640 100644 --- a/apps/web/src/types/authFile.ts +++ b/apps/web/src/types/authFile.ts @@ -16,6 +16,7 @@ export type AuthFileType = | 'xai' | 'iflow' | 'vertex' + | 'devin' | 'empty' | 'unknown'; diff --git a/apps/web/src/types/quota.ts b/apps/web/src/types/quota.ts index 76ba5a77e..f7df2ca35 100644 --- a/apps/web/src/types/quota.ts +++ b/apps/web/src/types/quota.ts @@ -614,3 +614,28 @@ export interface XaiQuotaState extends CredentialScopedQuotaState { error?: string; errorStatus?: number; } + +export interface DevinQuotaWindow { + id: 'daily' | 'weekly'; + label?: string; + remainingPercent: number | null; + resetAtMs: number | null; + periodHours: number; +} + +export interface DevinQuotaData { + windows: DevinQuotaWindow[]; + observedAtMs: number | null; + plan: string | null; + planStartMs: number | null; + planEndMs: number | null; +} + +export interface DevinQuotaState + extends CredentialScopedQuotaState, + DevinQuotaData { + status: 'idle' | 'loading' | 'success' | 'error'; + error?: string; + errorStatus?: number; +} + diff --git a/apps/web/src/types/visualConfig.ts b/apps/web/src/types/visualConfig.ts index 5c0082552..8d99cf3fe 100644 --- a/apps/web/src/types/visualConfig.ts +++ b/apps/web/src/types/visualConfig.ts @@ -150,6 +150,7 @@ export type VisualConfigValues = { codexHeaderUserAgent: string; codexHeaderBetaFeatures: string; codexIdentityConfuse: boolean; + devinSensitiveWords: string[]; payloadDefaultRules: PayloadRule[]; payloadDefaultRawRules: PayloadRule[]; payloadOverrideRules: PayloadRule[]; @@ -225,6 +226,7 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = { codexHeaderUserAgent: '', codexHeaderBetaFeatures: '', codexIdentityConfuse: false, + devinSensitiveWords: [], payloadDefaultRules: [], payloadDefaultRawRules: [], payloadOverrideRules: [], diff --git a/apps/web/src/utils/models.test.ts b/apps/web/src/utils/models.test.ts new file mode 100644 index 000000000..23bcd51dc --- /dev/null +++ b/apps/web/src/utils/models.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { classifyModels } from './models'; + +describe('classifyModels', () => { + it('classifies devin/* models into Devin group without altering model names', () => { + const input = [ + { name: 'devin/gpt-5' }, + { name: 'devin/claude-sonnet' }, + { name: 'devin/gemini-3-8-flash' }, + { name: 'devin/grok-4-6' }, + { name: 'devin/deepseek-v4-1-flash' }, + + { name: 'gpt-5' }, + { name: 'claude-sonnet' }, + { name: 'gemini-3-8-flash' }, + { name: 'grok-4-6' }, + { name: 'deepseek-v4-1-flash' }, + ]; + + const groups = classifyModels(input); + + const devinGroup = groups.find((g) => g.id === 'devin'); + expect(devinGroup).toBeDefined(); + expect(devinGroup?.items.map((m) => m.name)).toEqual([ + 'devin/gpt-5', + 'devin/claude-sonnet', + 'devin/gemini-3-8-flash', + 'devin/grok-4-6', + 'devin/deepseek-v4-1-flash', + ]); + + const gptGroup = groups.find((g) => g.id === 'gpt'); + expect(gptGroup).toBeDefined(); + expect(gptGroup?.items.map((m) => m.name)).toEqual(['gpt-5']); + + const claudeGroup = groups.find((g) => g.id === 'claude'); + expect(claudeGroup).toBeDefined(); + expect(claudeGroup?.items.map((m) => m.name)).toEqual(['claude-sonnet']); + + const geminiGroup = groups.find((g) => g.id === 'gemini'); + expect(geminiGroup).toBeDefined(); + expect(geminiGroup?.items.map((m) => m.name)).toEqual(['gemini-3-8-flash']); + + const grokGroup = groups.find((g) => g.id === 'grok'); + expect(grokGroup).toBeDefined(); + expect(grokGroup?.items.map((m) => m.name)).toEqual(['grok-4-6']); + + const deepseekGroup = groups.find((g) => g.id === 'deepseek'); + expect(deepseekGroup).toBeDefined(); + expect(deepseekGroup?.items.map((m) => m.name)).toEqual(['deepseek-v4-1-flash']); + + devinGroup?.items.forEach((item) => { + expect(item.name.startsWith('devin/')).toBe(true); + }); + }); + + it('keeps devin namespace check strictly on name and does not classify by alias alone', () => { + const input = [ + { name: 'custom-model', alias: 'devin/something' }, + ]; + const groups = classifyModels(input); + const devinGroup = groups.find((g) => g.id === 'devin'); + expect(devinGroup).toBeUndefined(); + }); +}); diff --git a/apps/web/src/utils/models.ts b/apps/web/src/utils/models.ts index 5049fa0e6..855101e16 100644 --- a/apps/web/src/utils/models.ts +++ b/apps/web/src/utils/models.ts @@ -10,6 +10,7 @@ export interface ModelInfo { } const MODEL_CATEGORIES = [ + { id: 'devin', label: 'Devin', patterns: [] }, { id: 'gpt', label: 'GPT', patterns: [/gpt/i, /\bo\d\b/i, /\bo\d+\.?/i, /\bchatgpt/i] }, { id: 'claude', label: 'Claude', patterns: [/claude/i] }, { id: 'gemini', label: 'Gemini', patterns: [/gemini/i, /\bgai\b/i] }, @@ -103,7 +104,9 @@ export function classifyModels(models: ModelInfo[] = [], { otherLabel = 'Other' const name = (model?.name || '').toString(); const alias = (model?.alias || '').toString(); const haystack = `${name} ${alias}`.toLowerCase(); - const matchedId = matchCategory(haystack); + const matchedId = /^devin\//i.test(name) + ? 'devin' + : matchCategory(haystack); const target = matchedId ? groups.find((group) => group.id === matchedId) : null; if (target) { diff --git a/apps/web/src/utils/quota/constants.ts b/apps/web/src/utils/quota/constants.ts index b91272d84..080b8e1bc 100644 --- a/apps/web/src/utils/quota/constants.ts +++ b/apps/web/src/utils/quota/constants.ts @@ -38,6 +38,10 @@ export const TYPE_COLORS: Record = { light: { bg: '#f3f4f6', text: '#111827', border: '1px solid #d1d5db' }, dark: { bg: '#111827', text: '#f9fafb', border: '1px solid #374151' }, }, + devin: { + light: { bg: '#e8f4ff', text: '#155e9b' }, + dark: { bg: '#123b5d', text: '#8dc9f5' }, + }, iflow: { light: { bg: '#f5e3fc', text: '#9025c8' }, dark: { bg: '#521490', text: '#d49cf5' }, @@ -218,3 +222,13 @@ export const XAI_REQUEST_HEADERS = { accept: '*/*', 'user-agent': XAI_GROK_USER_AGENT, }; + +// Devin API configuration +export const DEVIN_GET_USER_STATUS_URL = + 'https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus'; + +export const DEVIN_REQUEST_HEADERS = { + 'Content-Type': 'application/json', + 'Connect-Protocol-Version': '1', +}; + diff --git a/apps/web/src/utils/quota/credentialScope.test.ts b/apps/web/src/utils/quota/credentialScope.test.ts index df8073afe..5d19b5a4a 100644 --- a/apps/web/src/utils/quota/credentialScope.test.ts +++ b/apps/web/src/utils/quota/credentialScope.test.ts @@ -25,6 +25,36 @@ describe('quota credential scope', () => { expect(buildQuotaCredentialIdentity(first).authFileIdentityVerified).toBe(true); }); + it('identifies Devin credentials with stable credential store keys', () => { + const devinFirst = { + name: 'devin-account.json', + provider: 'devin', + authIndex: 'auth-0', + }; + const devinSecond = { + name: 'devin-account.json', + provider: 'devin', + authIndex: 'auth-1', + }; + const devinSame = { + name: 'devin-account.json', + provider: 'devin', + authIndex: 'auth-0', + }; + + expect(getQuotaCredentialStoreKey(devinFirst)).toBe('devin-account.json::auth-0'); + expect(getQuotaCredentialStoreKey(devinSame)).toBe(getQuotaCredentialStoreKey(devinFirst)); + expect(getQuotaCredentialStoreKey(devinSecond)).not.toBe( + getQuotaCredentialStoreKey(devinFirst) + ); + expect(getQuotaCredentialStoreKey(devinSecond)).toBe('devin-account.json::auth-1'); + + const identity = buildQuotaCredentialIdentity(devinFirst); + expect(identity.authFileIdentityVerified).toBe(true); + expect(identity.authIndex).toBe('auth-0'); + expect(identity.authFileName).toBe('devin-account.json'); + }); + it('marks filename-only identities as unverified', () => { expect( buildQuotaCredentialIdentity({ name: 'legacy.json', provider: 'kimi' }) diff --git a/apps/web/src/utils/quota/devinQuota.test.ts b/apps/web/src/utils/quota/devinQuota.test.ts new file mode 100644 index 000000000..fab4c5a2b --- /dev/null +++ b/apps/web/src/utils/quota/devinQuota.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeIsoTimestampMs, + normalizePlanName, + normalizeQuotaPercent, + normalizeUnixSecondsToMs, + parseDevinQuotaPayload, +} from './devinQuota'; + +describe('devinQuota normalizers', () => { + it('normalizes quota percent correctly', () => { + expect(normalizeQuotaPercent(54)).toBe(54); + expect(normalizeQuotaPercent('54')).toBe(54); + expect(normalizeQuotaPercent(0)).toBe(0); + expect(normalizeQuotaPercent('0')).toBe(0); + expect(normalizeQuotaPercent(100)).toBe(100); + expect(normalizeQuotaPercent('100')).toBe(100); + expect(normalizeQuotaPercent(0.5)).toBe(0.5); + expect(normalizeQuotaPercent('0.5')).toBe(0.5); + + // Invalid percent inputs return null + expect(normalizeQuotaPercent('54%')).toBeNull(); + expect(normalizeQuotaPercent(-1)).toBeNull(); + expect(normalizeQuotaPercent('-1')).toBeNull(); + expect(normalizeQuotaPercent(101)).toBeNull(); + expect(normalizeQuotaPercent('101')).toBeNull(); + expect(normalizeQuotaPercent(NaN)).toBeNull(); + expect(normalizeQuotaPercent(Infinity)).toBeNull(); + expect(normalizeQuotaPercent(-Infinity)).toBeNull(); + expect(normalizeQuotaPercent(true)).toBeNull(); + expect(normalizeQuotaPercent(false)).toBeNull(); + expect(normalizeQuotaPercent({})).toBeNull(); + expect(normalizeQuotaPercent([])).toBeNull(); + expect(normalizeQuotaPercent(null)).toBeNull(); + expect(normalizeQuotaPercent(undefined)).toBeNull(); + expect(normalizeQuotaPercent('')).toBeNull(); + expect(normalizeQuotaPercent(' ')).toBeNull(); + }); + + it('normalizes unix seconds to epoch ms', () => { + expect(normalizeUnixSecondsToMs(1726000000)).toBe(1726000000000); + expect(normalizeUnixSecondsToMs('1726000000')).toBe(1726000000000); + expect(normalizeUnixSecondsToMs(0)).toBeNull(); + expect(normalizeUnixSecondsToMs(-1)).toBeNull(); + expect(normalizeUnixSecondsToMs(1.5)).toBeNull(); + expect(normalizeUnixSecondsToMs('1.5')).toBeNull(); + expect(normalizeUnixSecondsToMs(Infinity)).toBeNull(); + expect(normalizeUnixSecondsToMs(NaN)).toBeNull(); + expect(normalizeUnixSecondsToMs(1e20)).toBeNull(); + expect(normalizeUnixSecondsToMs('abc')).toBeNull(); + expect(normalizeUnixSecondsToMs('')).toBeNull(); + expect(normalizeUnixSecondsToMs(null)).toBeNull(); + expect(normalizeUnixSecondsToMs(undefined)).toBeNull(); + }); + + it('normalizes ISO timestamp to ms and rejects Go/protobuf zero-time', () => { + expect(normalizeIsoTimestampMs('2026-09-15T10:00:00Z')).toBe( + Date.parse('2026-09-15T10:00:00Z') + ); + expect(normalizeIsoTimestampMs('0001-01-01T00:00:00Z')).toBeNull(); + expect(normalizeIsoTimestampMs('invalid-date')).toBeNull(); + expect(normalizeIsoTimestampMs('')).toBeNull(); + expect(normalizeIsoTimestampMs(null)).toBeNull(); + expect(normalizeIsoTimestampMs(undefined)).toBeNull(); + }); + + it('normalizes plan name preserving case and trimming', () => { + expect(normalizePlanName(' Pro ')).toBe('Pro'); + expect(normalizePlanName('Team')).toBe('Team'); + expect(normalizePlanName('pro')).toBe('pro'); + expect(normalizePlanName('')).toBeNull(); + expect(normalizePlanName(' ')).toBeNull(); + expect(normalizePlanName(null)).toBeNull(); + }); +}); + +describe('parseDevinQuotaPayload', () => { + it('parses valid Connect JSON response into daily and weekly windows', () => { + const payload = { + userStatus: { + planStatus: { + planInfo: { planName: ' Devin Pro ' }, + planStart: '2026-09-01T00:00:00Z', + planEnd: '2026-10-01T00:00:00Z', + dailyQuotaRemainingPercent: '54', + weeklyQuotaRemainingPercent: 80, + dailyQuotaResetAtUnix: 1726400000, + weeklyQuotaResetAtUnix: '1726900000', + }, + // Sensitive fields that must NOT enter quota data + email: 'user@example.com', + userName: 'devin-user', + userId: 'u_12345', + teamId: 't_67890', + orgId: 'org_abc', + orgName: 'Acme Corp', + sessionToken: 'secret-session-token', + signals: { abuse: false }, + metadata: { apiKey: 'sk-devin-secret' }, + }, + }; + + const parsed = parseDevinQuotaPayload(payload, { observedAtMs: 123456789 }); + expect(parsed).not.toBeNull(); + expect(parsed?.observedAtMs).toBe(123456789); + expect(parsed?.plan).toBe('Devin Pro'); + expect(parsed?.planStartMs).toBe(Date.parse('2026-09-01T00:00:00Z')); + expect(parsed?.planEndMs).toBe(Date.parse('2026-10-01T00:00:00Z')); + + expect(parsed?.windows).toEqual([ + { + id: 'daily', + remainingPercent: 54, + resetAtMs: 1726400000000, + periodHours: 24, + }, + { + id: 'weekly', + remainingPercent: 80, + resetAtMs: 1726900000000, + periodHours: 168, + }, + ]); + + // Verify sensitive data leak prevention + const rawResult = parsed as unknown as Record; + expect(rawResult['email']).toBeUndefined(); + expect(rawResult['userName']).toBeUndefined(); + expect(rawResult['userId']).toBeUndefined(); + expect(rawResult['teamId']).toBeUndefined(); + expect(rawResult['orgId']).toBeUndefined(); + expect(rawResult['orgName']).toBeUndefined(); + expect(rawResult['sessionToken']).toBeUndefined(); + expect(rawResult['signals']).toBeUndefined(); + expect(rawResult['metadata']).toBeUndefined(); + }); + + it('correctly handles 0 remaining percent as genuine quota exhaustion', () => { + const payload = { + userStatus: { + planStatus: { + dailyQuotaRemainingPercent: 0, + weeklyQuotaRemainingPercent: '0', + dailyQuotaResetAtUnix: 1726400000, + weeklyQuotaResetAtUnix: 1726900000, + }, + }, + }; + + const parsed = parseDevinQuotaPayload(payload); + expect(parsed?.windows[0].remainingPercent).toBe(0); + expect(parsed?.windows[1].remainingPercent).toBe(0); + }); + + it('parses JSON string payload', () => { + const jsonStr = JSON.stringify({ + userStatus: { + planStatus: { + dailyQuotaRemainingPercent: 100, + dailyQuotaResetAtUnix: 1726400000, + }, + }, + }); + + const parsed = parseDevinQuotaPayload(jsonStr); + expect(parsed).not.toBeNull(); + expect(parsed?.windows[0].remainingPercent).toBe(100); + expect(parsed?.windows[1].remainingPercent).toBeNull(); + }); + + it('returns null (empty_data) when plan-only or no quota observations exist', () => { + // Only plan name, no remaining percent or reset unix + const planOnly = { + userStatus: { + planStatus: { + planInfo: { planName: 'Pro' }, + planStart: '2026-09-01T00:00:00Z', + planEnd: '2026-10-01T00:00:00Z', + }, + }, + }; + expect(parseDevinQuotaPayload(planOnly)).toBeNull(); + + // Invalid values across all quota fields + const invalidValues = { + userStatus: { + planStatus: { + dailyQuotaRemainingPercent: 'invalid%', + weeklyQuotaRemainingPercent: -5, + dailyQuotaResetAtUnix: 'bad', + weeklyQuotaResetAtUnix: 0, + }, + }, + }; + expect(parseDevinQuotaPayload(invalidValues)).toBeNull(); + + // Malformed JSON string + expect(parseDevinQuotaPayload('not a json')).toBeNull(); + + // Empty object or non-object + expect(parseDevinQuotaPayload({})).toBeNull(); + expect(parseDevinQuotaPayload(null)).toBeNull(); + expect(parseDevinQuotaPayload(undefined)).toBeNull(); + }); + + it('rejects payloads where only invalid reset timestamp exists without valid observation', () => { + const invalidResetOnly = { + userStatus: { + planStatus: { + dailyQuotaResetAtUnix: 1.5, + weeklyQuotaResetAtUnix: 'bad', + }, + }, + }; + expect(parseDevinQuotaPayload(invalidResetOnly)).toBeNull(); + }); +}); diff --git a/apps/web/src/utils/quota/devinQuota.ts b/apps/web/src/utils/quota/devinQuota.ts new file mode 100644 index 000000000..ac1aaa4ae --- /dev/null +++ b/apps/web/src/utils/quota/devinQuota.ts @@ -0,0 +1,140 @@ +/** + * Pure parsers and normalizers for Devin Quota responses. + */ + +import type { DevinQuotaData, DevinQuotaWindow } from '@/types'; + +export const normalizeQuotaPercent = (value: unknown): number | null => { + if (value === null || value === undefined) return null; + if (typeof value === 'boolean') return null; + + let num: number; + if (typeof value === 'number') { + num = value; + } else if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return null; + if (!/^-?\d+(\.\d+)?$/.test(trimmed)) return null; + num = Number(trimmed); + } else { + return null; + } + + if (!Number.isFinite(num)) return null; + if (num < 0 || num > 100) return null; + return num; +}; + +export const normalizeUnixSecondsToMs = (value: unknown): number | null => { + if (typeof value !== 'number' && typeof value !== 'string') return null; + if (typeof value === 'string' && !/^\d+$/.test(value)) return null; + + const seconds = Number(value); + const ms = seconds * 1000; + + if ( + Number.isSafeInteger(seconds) && + seconds > 0 && + Number.isFinite(new Date(ms).getTime()) + ) { + return ms; + } + return null; +}; + +export const normalizeIsoTimestampMs = (value: unknown): number | null => { + if (typeof value !== 'string') return null; + if (!/^\d{4}-\d{2}-\d{2}T/.test(value)) return null; + + const ms = Date.parse(value); + return Number.isFinite(ms) && ms > 0 ? ms : null; +}; + +export const normalizePlanName = (value: unknown): string | null => { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +export interface ParseDevinQuotaOptions { + observedAtMs?: number; +} + +export const parseDevinQuotaPayload = ( + rawPayload: unknown, + options: ParseDevinQuotaOptions = {} +): DevinQuotaData | null => { + let body = rawPayload; + if (typeof body === 'string') { + try { + body = JSON.parse(body); + } catch { + return null; + } + } + + if (!body || typeof body !== 'object') { + return null; + } + + const root = body as Record; + const userStatus = + root.userStatus && typeof root.userStatus === 'object' + ? (root.userStatus as Record) + : null; + const planStatus = + userStatus?.planStatus && typeof userStatus.planStatus === 'object' + ? (userStatus.planStatus as Record) + : null; + + if (!planStatus) { + return null; + } + + const planInfo = + planStatus.planInfo && typeof planStatus.planInfo === 'object' + ? (planStatus.planInfo as Record) + : null; + + const plan = normalizePlanName(planInfo?.planName); + const planStartMs = normalizeIsoTimestampMs(planStatus.planStart); + const planEndMs = normalizeIsoTimestampMs(planStatus.planEnd); + + const dailyRemaining = normalizeQuotaPercent(planStatus.dailyQuotaRemainingPercent); + const weeklyRemaining = normalizeQuotaPercent(planStatus.weeklyQuotaRemainingPercent); + + const dailyReset = normalizeUnixSecondsToMs(planStatus.dailyQuotaResetAtUnix); + const weeklyReset = normalizeUnixSecondsToMs(planStatus.weeklyQuotaResetAtUnix); + + const hasObservation = + dailyRemaining !== null || + dailyReset !== null || + weeklyRemaining !== null || + weeklyReset !== null; + + if (!hasObservation) { + return null; + } + + const dailyWindow: DevinQuotaWindow = { + id: 'daily', + remainingPercent: dailyRemaining, + resetAtMs: dailyReset, + periodHours: 24, + }; + + const weeklyWindow: DevinQuotaWindow = { + id: 'weekly', + remainingPercent: weeklyRemaining, + resetAtMs: weeklyReset, + periodHours: 168, + }; + + return { + windows: [dailyWindow, weeklyWindow], + observedAtMs: options.observedAtMs ?? Date.now(), + plan, + planStartMs, + planEndMs, + }; +}; diff --git a/apps/web/src/utils/quota/index.ts b/apps/web/src/utils/quota/index.ts index fa573ed74..f8603b5ef 100644 --- a/apps/web/src/utils/quota/index.ts +++ b/apps/web/src/utils/quota/index.ts @@ -12,3 +12,5 @@ export * from './codexQuota'; export * from './codexRequestHeaders'; export * from './resetCredits'; export * from './providerRequests'; +export * from './devinQuota'; + diff --git a/apps/web/src/utils/quota/providerRequests.test.ts b/apps/web/src/utils/quota/providerRequests.test.ts index db48f1393..35ddfd1a2 100644 --- a/apps/web/src/utils/quota/providerRequests.test.ts +++ b/apps/web/src/utils/quota/providerRequests.test.ts @@ -51,6 +51,7 @@ import { fetchCodexQuota, fetchCodexQuotaSummary, fetchCodexResetCredits, + fetchDevinQuota, fetchKimiQuota, mergeXaiBillingSummaries, probeXaiBilling, @@ -3831,3 +3832,114 @@ describe('CODEX_REQUEST_HEADERS', () => { ); }); }); + +describe('fetchDevinQuota', () => { + it('sends POST request to GetUserStatus with exact contract and no Authorization headers', async () => { + mocks.request.mockResolvedValueOnce({ + statusCode: 200, + hasStatusCode: true, + header: {}, + bodyText: '', + body: { + userStatus: { + planStatus: { + planInfo: { planName: 'Pro' }, + dailyQuotaRemainingPercent: 54, + dailyQuotaResetAtUnix: 1726400000, + }, + }, + }, + }); + + const file = { name: 'devin.json', type: 'devin', authIndex: 'devin-001' }; + const requestScope = { apiBase: 'https://cpa.example.com', managementKey: 'test-key' }; + const result = await fetchDevinQuota(file, t, requestScope); + + expect(mocks.request).toHaveBeenCalledTimes(1); + const [payload, config] = mocks.request.mock.calls[0]; + + expect(payload).toEqual({ + authIndex: 'devin-001', + method: 'POST', + url: 'https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus', + header: { + 'Content-Type': 'application/json', + 'Connect-Protocol-Version': '1', + }, + data: JSON.stringify({ + metadata: { + ideName: 'chisel', + ideVersion: '3000.10.21', + apiKey: '$TOKEN$', + locale: 'en', + os: 'darwin', + extensionVersion: '3000.10.21', + clientName: 'chisel', + }, + }), + }); + + // Ensure NO Authorization or X-Api-Key headers exist on the proxied upstream request + expect(payload.header['Authorization']).toBeUndefined(); + expect(payload.header['X-Api-Key']).toBeUndefined(); + + // Verify requestScope is captured in axios config for CPA management call + expect(config?.baseURL).toBe('https://cpa.example.com/v0/management'); + expect(config?.headers?.['Authorization']).toBe('Bearer test-key'); + expect(config?.cpampScopedRequest).toBe(true); + + expect(result.plan).toBe('Pro'); + expect(result.windows[0].remainingPercent).toBe(54); + expect(result.windows[0].resetAtMs).toBe(1726400000000); + }); + + it('rejects without network request when identity is missing', async () => { + // Missing authIndex + await expect( + fetchDevinQuota({ name: 'devin.json', type: 'devin' }, t) + ).rejects.toThrow('devin_quota.missing_identity'); + + // Missing fileName + await expect( + fetchDevinQuota({ name: '', type: 'devin', authIndex: 'devin-1' }, t) + ).rejects.toThrow('devin_quota.missing_identity'); + + expect(mocks.request).not.toHaveBeenCalled(); + }); + + it('preserves error status codes like 429 and 401', async () => { + mocks.request.mockResolvedValueOnce({ + statusCode: 429, + hasStatusCode: true, + header: {}, + bodyText: 'Rate limit exceeded', + }); + + const promise = fetchDevinQuota( + { name: 'devin.json', type: 'devin', authIndex: 'devin-1' }, + t + ); + await expect(promise).rejects.toMatchObject({ status: 429 }); + }); + + it('throws empty_data on 200 response without quota observations', async () => { + mocks.request.mockResolvedValueOnce({ + statusCode: 200, + hasStatusCode: true, + header: {}, + bodyText: '', + body: { + userStatus: { + planStatus: { + planInfo: { planName: 'Pro' }, + }, + }, + }, + }); + + await expect( + fetchDevinQuota({ name: 'devin.json', type: 'devin', authIndex: 'devin-1' }, t) + ).rejects.toThrow('devin_quota.empty_data'); + }); +}); + diff --git a/apps/web/src/utils/quota/providerRequests.ts b/apps/web/src/utils/quota/providerRequests.ts index bd4517e75..e7b64794d 100644 --- a/apps/web/src/utils/quota/providerRequests.ts +++ b/apps/web/src/utils/quota/providerRequests.ts @@ -12,6 +12,7 @@ import type { CodexRateLimitResetCredit, CodexQuotaWindow, CodexUsagePayload, + DevinQuotaData, KimiQuotaRow, KimiUsagePayload, XaiBillingConfig, @@ -42,6 +43,8 @@ import { CLAUDE_USAGE_WINDOW_KEYS, CODEX_RATE_LIMIT_RESET_CREDITS_URL, CODEX_USAGE_URL, + DEVIN_GET_USER_STATUS_URL, + DEVIN_REQUEST_HEADERS, KIMI_REQUEST_HEADERS, KIMI_USAGE_URL, XAI_BILLING_MONTHLY_URL, @@ -55,6 +58,7 @@ import { XAI_OFFICIAL_API_ME_URL, XAI_REQUEST_HEADERS, } from './constants'; +import { parseDevinQuotaPayload } from './devinQuota'; import { buildAntigravityQuotaGroups, buildKimiQuotaRows } from './builders'; import { createStatusError, @@ -2046,3 +2050,53 @@ export const fetchXaiQuota = async ( }), }; }); + +export const fetchDevinQuota = async ( + file: AuthFileItem, + t: TFunction, + requestScope?: ApiClientRequestScope +): Promise => { + const fileName = typeof file?.name === 'string' ? file.name.trim() : ''; + const rawAuthIndex = file['auth_index'] ?? file.authIndex; + const authIndex = normalizeAuthIndex(rawAuthIndex); + + if (!fileName || !authIndex) { + throw new Error(t('devin_quota.missing_identity')); + } + + const result = await apiCallApi.request( + { + authIndex, + method: 'POST', + url: DEVIN_GET_USER_STATUS_URL, + header: { ...DEVIN_REQUEST_HEADERS }, + data: JSON.stringify({ + metadata: { + ideName: 'chisel', + ideVersion: '3000.10.21', + apiKey: '$TOKEN$', + locale: 'en', + os: 'darwin', + extensionVersion: '3000.10.21', + clientName: 'chisel', + }, + }), + }, + requestScope ? createScopedApiRequestConfig(requestScope) : undefined + ); + + if (result.statusCode < 200 || result.statusCode >= 300) { + throw createStatusError(getApiCallErrorMessage(result), result.statusCode); + } + + const quotaData = parseDevinQuotaPayload(result.body ?? result.bodyText, { + observedAtMs: Date.now(), + }); + + if (!quotaData) { + throw new Error(t('devin_quota.empty_data')); + } + + return quotaData; +}; + diff --git a/apps/web/src/utils/quota/validators.ts b/apps/web/src/utils/quota/validators.ts index 953170282..9f87a3d7c 100644 --- a/apps/web/src/utils/quota/validators.ts +++ b/apps/web/src/utils/quota/validators.ts @@ -44,6 +44,10 @@ export function isXaiFile(file: AuthFileItem): boolean { return resolveAuthProvider(file) === 'xai'; } +export function isDevinFile(file: AuthFileItem): boolean { + return resolveAuthProvider(file) === 'devin'; +} + export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean { const raw = file['runtime_only'] ?? file.runtimeOnly; if (typeof raw === 'boolean') return raw;