diff --git a/changelog/unreleased/donations-page.md b/changelog/unreleased/donations-page.md new file mode 100644 index 00000000..dca37e1f --- /dev/null +++ b/changelog/unreleased/donations-page.md @@ -0,0 +1,17 @@ +### English + +- Add a public `/donations` page where anyone can contribute a WorkBuddy, Qoder, Trae, Devin, or Command Code account and receive New API credit for it. Contributions use the provider's own browser authorization, so a contributor never hands over a raw credential. +- **Web authorization**: `POST /api/donations` creates the account, opens the provider login, and returns the URL to visit. `GET /api/donations/sessions/{id}` polls it and issues the reward once the login completes; `DELETE` abandons the round. The reward is never credited before the account is authorized. +- The contributed account is created disabled and is enabled only after authorization succeeds, so an unfinished round cannot carry traffic. +- If the credit call fails after a successful authorization, the account stays in the pool and the response reports `credited:false` with `credit_error`, so an operator can credit it without asking for a re-login. +- Providers without a browser login keep the pasted-credential path at `POST /api/donations/credential`. +- Configure the donation site under System settings with `donation_base_url` and `donation_token`. The token is stored as a secret and is never returned by the settings API. + +### 中文 + +- 新增公开的 `/donations` 贡献页面:任何人都可以贡献 WorkBuddy、Qoder、Trae、Devin 或 Command Code 账号并获得 New API 额度。贡献走各平台自己的网页授权,贡献者不需要交出原始凭据。 +- **网页授权**:`POST /api/donations` 创建账号并返回需要打开的登录地址;`GET /api/donations/sessions/{id}` 轮询进度,登录完成后发放额度;`DELETE` 放弃本次贡献。账号未授权前不会发放额度。 +- 贡献的账号先以禁用状态创建,授权成功后才启用,因此未完成的流程不会承载流量。 +- 若授权成功但发放额度失败,账号保留在账号池中,响应返回 `credited:false` 与 `credit_error`,管理员无需让用户重新登录即可补发。 +- 不支持网页授权的平台保留 `POST /api/donations/credential` 的粘贴凭据方式。 +- 在「系统设置」里用 `donation_base_url` 和 `donation_token` 配置贡献站点。令牌以密钥形式保存,系统设置接口不会返回它。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 174456ab..b8acbde7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import { LoginPage } from '@/pages/LoginPage' import { SystemPage } from '@/pages/SystemPage' import { LogsPage } from '@/pages/LogsPage' import { KeysPage } from '@/pages/KeysPage' +import { DonationsPage } from '@/pages/DonationsPage' export default function App() { return ( @@ -23,6 +24,9 @@ export default function App() { } /> + {/* Donations are public: a contributor has no console key, and + the reward is credited to their own New API user id. */} + } /> }> }> } /> diff --git a/frontend/src/api/donations.ts b/frontend/src/api/donations.ts new file mode 100644 index 00000000..839726a4 --- /dev/null +++ b/frontend/src/api/donations.ts @@ -0,0 +1,108 @@ +import { api } from './client' + +// A contributable credential format advertised by the backend. The page renders +// these instead of hardcoding which providers accept donations, so adding a +// provider on the Go side is enough. +export type DonationFormat = { + format: string + provider: string + label: string + region: string + // "json" for a credential object, "qoder_native" for the base64 auth blob + // plus machine id pair the Qoder CLI writes to its own home. + credential_kind: string + // web_auth is true when the account can be authorized through the provider's + // own browser login, which is the preferred flow. + web_auth: boolean + description: string +} + +export type DonationInfo = { + object: string + default_usd: number + quota_per_usd: number + formats: DonationFormat[] +} + +// DonationSession is one in-flight web authorization. The reward is credited +// only once status becomes "credited". +export type DonationSession = { + session_id: string + account_id: string + provider: string + region: string + name: string + format: string + newapi_user_id: number + credit_usd: number + auth_url: string + status: 'pending' | 'credited' | 'failed' + message?: string + credited: boolean + credited_quota?: number + credit_error?: string +} + +export type DonationStart = { + format: string + name?: string + region?: string + newapi_user_id: number + credit_usd?: number +} + +export type DonationResult = { + account_id: string + account_name: string + provider: string + region: string + status: string + credited_usd: number + credited_quota: number + credited: boolean + // Set when the account was accepted but the credit call failed. The + // contribution still counts; an operator credits it manually. + credit_error?: string +} + +export type DonationSubmit = { + format: string + name?: string + region?: string + newapi_user_id: number + credit_usd?: number + credential?: unknown + user_blob?: string + machine_id?: string +} + +// /api/donations is intentionally public, so these calls never send a console +// key and must not be wrapped in RequireAuth. +export function fetchDonationInfo() { + return api('/api/donations') +} + +// startDonation begins a web-authorization round and returns the URL to open. +export function startDonation(input: DonationStart) { + return api('/api/donations', { + method: 'POST', + body: JSON.stringify(input), + }) +} + +export function pollDonationSession(sessionId: string) { + return api(`/api/donations/sessions/${encodeURIComponent(sessionId)}`) +} + +export function cancelDonationSession(sessionId: string) { + return api(`/api/donations/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' }) +} + +// submitDonation is the legacy pasted-credential path, kept for providers that +// do not expose a browser login. +export function submitDonation(input: DonationSubmit) { + return api('/api/donations/credential', { + method: 'POST', + body: JSON.stringify(input), + }) +} diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index b19b9d3e..0bdaf4c8 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -656,6 +656,43 @@ export const messages: Record = { consoleKeyRotateNow: 'Rotate now', consoleKeySecretTitle: 'New console key', consoleKeySecretHint: 'This browser session is already updated. Copy the key for any other clients that used the old console secret.', + 'donations.title': 'Contribute an account', + 'donations.noLogin': 'No sign-in required', + 'donations.subtitle': 'Contribute a WorkBuddy, Qoder, or Trae account and receive New API credit on the site. The account joins the shared pool once it is accepted.', + 'donations.rewardTitle': 'Reward', + 'donations.rewardBody': 'Each accepted account earns {usd} USD, credited as {quota} New API quota units.', + 'donations.fieldType': 'Account type', + 'donations.fieldUserID': 'New API user ID', + 'donations.fieldUserIDHint': 'The numeric user ID on the New API site, not the username. It is shown in the site URL or personal settings.', + 'donations.fieldName': 'Account label', + 'donations.fieldNameHint': 'Optional. A name for this account in the console.', + 'donations.fieldCredential': 'Credential', + 'donations.fieldCredentialHint': 'Paste the credential JSON exported from this console, or the provider login bundle.', + 'donations.fieldUserBlob': 'Auth blob', + 'donations.fieldUserBlobHint': 'The base64 contents of the Qoder CLI auth file.', + 'donations.fieldMachineID': 'Machine ID', + 'donations.submit': 'Contribute', + 'donations.submitting': 'Contributing...', + 'donations.authorize': 'Authorize in browser', + 'donations.starting': 'Starting authorization...', + 'donations.waitingAuth': 'Waiting for you to finish signing in...', + 'donations.waitingHint': 'A browser tab was opened for the provider sign-in. This page finishes by itself once you authorize; do not close it.', + 'donations.reopenAuth': 'Reopen the authorization page', + 'donations.cancel': 'Cancel', + 'donations.authTimeout': 'Authorization timed out. Start again.', + 'donations.success': 'Thank you. The account was accepted and {usd} USD was credited.', + 'donations.credited': 'Contribution accepted', + 'donations.failed': 'Contribution failed', + 'donations.creditFailed': 'The account was accepted but the credit did not go through: {error}. Contact an operator to have it credited.', + 'donations.acceptedNoCredit': 'Account accepted, credit pending', + 'donations.infoFailed': 'Could not load contribution options', + 'donations.userIDInvalid': 'Enter a valid numeric New API user ID.', + 'donations.formatRequired': 'Choose an account type.', + 'donations.credentialRequired': 'Paste a credential.', + 'donations.credentialInvalid': 'The credential is not valid JSON.', + 'donations.qoderFieldsRequired': 'Both the auth blob and the machine ID are required.', + 'donations.notesTitle': 'Before you contribute', + 'donations.notesBody': 'A contributed account is added to the shared pool and used for other members requests. Only contribute accounts you are willing to share. Invalid or unusable credentials are rejected before any credit is issued.', }, zh: { brandSub: '登录态网关', @@ -1310,6 +1347,43 @@ export const messages: Record = { consoleKeyRotateNow: '立即轮换', consoleKeySecretTitle: '新的控制台密钥', consoleKeySecretHint: '当前浏览器会话已更新。如果还有客户端在用旧的控制台密钥,请把新值复制过去。', + 'donations.title': '贡献账号', + 'donations.noLogin': '无需登录', + 'donations.subtitle': '贡献一个 WorkBuddy、Qoder 或 Trae 账号,即可获得站点的 New API 额度。账号通过校验后会计入共享账号池。', + 'donations.rewardTitle': '贡献奖励', + 'donations.rewardBody': '每个通过的账号奖励 {usd} 美元,折算为 {quota} New API 额度单位。', + 'donations.fieldType': '账号类型', + 'donations.fieldUserID': 'New API 用户 ID', + 'donations.fieldUserIDHint': '站点上的数字用户 ID,不是用户名。在站点地址或个人设置里可以看到。', + 'donations.fieldName': '账号备注', + 'donations.fieldNameHint': '可选。这个账号在控制台里显示的名称。', + 'donations.fieldCredential': '凭据', + 'donations.fieldCredentialHint': '粘贴从本控制台导出的凭据 JSON,或对应平台的登录信息。', + 'donations.fieldUserBlob': '认证数据', + 'donations.fieldUserBlobHint': 'Qoder CLI 认证文件里的 base64 内容。', + 'donations.fieldMachineID': '机器码', + 'donations.submit': '提交贡献', + 'donations.submitting': '提交中…', + 'donations.authorize': '在浏览器中授权', + 'donations.starting': '正在发起授权…', + 'donations.waitingAuth': '等待你在浏览器里完成登录…', + 'donations.waitingHint': '已打开授权页面。完成授权后本页会自动结束,请不要关闭。', + 'donations.reopenAuth': '重新打开授权页面', + 'donations.cancel': '取消', + 'donations.authTimeout': '授权超时,请重新发起。', + 'donations.success': '感谢贡献。账号已通过校验,已发放 {usd} 美元额度。', + 'donations.credited': '贡献成功', + 'donations.failed': '贡献失败', + 'donations.creditFailed': '账号已通过校验,但额度发放未成功:{error}。请联系管理员手动发放。', + 'donations.acceptedNoCredit': '账号已收录,额度待发放', + 'donations.infoFailed': '无法加载可贡献的类型', + 'donations.userIDInvalid': '请填写有效的 New API 数字用户 ID。', + 'donations.formatRequired': '请选择账号类型。', + 'donations.credentialRequired': '请填写凭据。', + 'donations.credentialInvalid': '凭据不是合法的 JSON。', + 'donations.qoderFieldsRequired': '认证数据和机器码都需要填写。', + 'donations.notesTitle': '贡献前请确认', + 'donations.notesBody': '贡献的账号会加入共享账号池,用于其他成员的请求。请只贡献你愿意共享的账号。无法使用的凭据会在发放额度之前被拒绝。', }, } diff --git a/frontend/src/pages/DonationsPage.tsx b/frontend/src/pages/DonationsPage.tsx new file mode 100644 index 00000000..8c8f6e3f --- /dev/null +++ b/frontend/src/pages/DonationsPage.tsx @@ -0,0 +1,397 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Button, Card, Chip, Input, Label, ListBox, Select, TextArea } from '@heroui/react' +import { CheckCircle, HandHeart, Warning } from '@phosphor-icons/react' +import { useI18n } from '@/hooks/useI18n' +import { + cancelDonationSession, + fetchDonationInfo, + pollDonationSession, + startDonation, + submitDonation, + type DonationFormat, + type DonationInfo, + type DonationSession, +} from '@/api/donations' +import { FormRow } from '@/components/ui/FormRow' +import { PageAlert } from '@/components/ui/PageAlert' +import { ProviderMark } from '@/components/ProviderMark' +import { accountProviderLabel } from '@/lib/provider' + +type Phase = 'idle' | 'starting' | 'waiting' | 'done' | 'error' + +// The Qoder CLI stores its login as a base64 auth blob plus a machine id rather +// than a JSON credential, so that format gets its own pair of fields. +const QODER_NATIVE = 'qoder_native' +const POLL_INTERVAL = 2500 +const POLL_ATTEMPTS = 120 // ~5 minutes, matching the pending-session TTL + +function parseJSONCredential(raw: string): { value?: unknown; error?: string } { + const text = raw.trim() + if (!text) return { error: 'empty' } + try { + return { value: JSON.parse(text) } + } catch { + return { error: 'invalid' } + } +} + +export function DonationsPage() { + const { t } = useI18n() + const [info, setInfo] = useState(null) + const [infoError, setInfoError] = useState('') + const [formatID, setFormatID] = useState('') + const [userID, setUserID] = useState('') + const [accountName, setAccountName] = useState('') + const [phase, setPhase] = useState('idle') + const [message, setMessage] = useState('') + const [session, setSession] = useState(null) + const [authUrl, setAuthUrl] = useState('') + // Fallback credential fields, for formats without a browser login. + const [credential, setCredential] = useState('') + const [userBlob, setUserBlob] = useState('') + const [machineID, setMachineID] = useState('') + const timer = useRef(null) + const cancelled = useRef(false) + + useEffect(() => { + let active = true + fetchDonationInfo() + .then((data) => { + if (!active) return + setInfo(data) + const first = data.formats?.[0] + if (first) setFormatID(first.format) + }) + .catch((err: unknown) => { + if (active) setInfoError(err instanceof Error ? err.message : String(err)) + }) + return () => { + active = false + } + }, []) + + // Stop polling when the page goes away so a detached round is not driven + // after the contributor leaves. + useEffect(() => { + return () => { + cancelled.current = true + if (timer.current) window.clearTimeout(timer.current) + } + }, []) + + const selected: DonationFormat | undefined = useMemo( + () => info?.formats?.find((format) => format.format === formatID), + [info, formatID], + ) + const webAuth = Boolean(selected?.web_auth) + const rewardUSD = info?.default_usd ?? 1 + const rewardQuota = info?.quota_per_usd ?? 0 + + function numericUserID(): number | null { + const value = Number.parseInt(userID.trim(), 10) + if (!Number.isFinite(value) || value <= 0) return null + return value + } + + // poll drives one authorization round to a terminal state, settling the + // reward on the server exactly once. + async function poll(id: string, attempt = 0): Promise { + if (cancelled.current) return + if (attempt >= POLL_ATTEMPTS) { + setPhase('error') + setMessage(t('donations.authTimeout')) + return + } + try { + const current = await pollDonationSession(id) + if (cancelled.current) return + setSession(current) + if (current.credited) { + setPhase('done') + setMessage(t('donations.success', { usd: current.credit_usd, quota: (current.credited_quota || 0).toLocaleString() })) + return + } + if (current.status === 'failed') { + setPhase('error') + setMessage(current.credit_error ? t('donations.creditFailed', { error: current.credit_error }) : current.message || t('donations.failed')) + return + } + setMessage(current.message || t('donations.waitingAuth')) + timer.current = window.setTimeout(() => void poll(id, attempt + 1), POLL_INTERVAL) + } catch (err: unknown) { + if (cancelled.current) return + setPhase('error') + setMessage(err instanceof Error ? err.message : String(err)) + } + } + + async function onAuthorize() { + const id = numericUserID() + if (id === null) { + setPhase('error') + setMessage(t('donations.userIDInvalid')) + return + } + if (!selected) { + setPhase('error') + setMessage(t('donations.formatRequired')) + return + } + setPhase('starting') + setMessage(t('donations.starting')) + setSession(null) + setAuthUrl('') + try { + const started = await startDonation({ + format: selected.format, + name: accountName.trim() || undefined, + region: selected.region || undefined, + newapi_user_id: id, + credit_usd: rewardUSD, + }) + setSession(started) + setAuthUrl(started.auth_url) + if (started.auth_url) window.open(started.auth_url, '_blank', 'noopener,noreferrer') + setPhase('waiting') + setMessage(t('donations.waitingAuth')) + cancelled.current = false + void poll(started.session_id) + } catch (err: unknown) { + setPhase('error') + setMessage(err instanceof Error ? err.message : String(err)) + } + } + + async function onCancel() { + if (!session) return + cancelled.current = true + if (timer.current) window.clearTimeout(timer.current) + try { + await cancelDonationSession(session.session_id) + } catch { + // Cancelling is best effort; a swept session is equivalent. + } + setPhase('idle') + setMessage('') + setSession(null) + setAuthUrl('') + } + + // The pasted-credential path stays available for providers without a login. + async function onSubmitCredential() { + const id = numericUserID() + if (id === null) { + setPhase('error') + setMessage(t('donations.userIDInvalid')) + return + } + if (!selected) { + setPhase('error') + setMessage(t('donations.formatRequired')) + return + } + const body: Record = { + format: selected.format, + name: accountName.trim() || undefined, + region: selected.region || undefined, + newapi_user_id: id, + credit_usd: rewardUSD, + } + if (selected.credential_kind === QODER_NATIVE) { + if (!userBlob.trim() || !machineID.trim()) { + setPhase('error') + setMessage(t('donations.qoderFieldsRequired')) + return + } + body.user_blob = userBlob.trim() + body.machine_id = machineID.trim() + } else { + const parsed = parseJSONCredential(credential) + if (parsed.value === undefined) { + setPhase('error') + setMessage(parsed.error === 'empty' ? t('donations.credentialRequired') : t('donations.credentialInvalid')) + return + } + body.credential = parsed.value + } + setPhase('starting') + setMessage(t('donations.submitting')) + try { + const result = await submitDonation(body as never) + if (result.credited) { + setPhase('done') + setMessage(t('donations.success', { usd: result.credited_usd, quota: result.credited_quota.toLocaleString() })) + } else { + setPhase('error') + setMessage(t('donations.creditFailed', { error: result.credit_error || '' })) + } + setCredential('') + setUserBlob('') + setMachineID('') + } catch (err: unknown) { + setPhase('error') + setMessage(err instanceof Error ? err.message : String(err)) + } + } + + const busy = phase === 'starting' || phase === 'waiting' + + return ( +
+
+
+ +

{t('donations.title')}

+ {t('donations.noLogin')} +
+

{t('donations.subtitle')}

+
+ + +
+
+

{t('donations.rewardTitle')}

+

+ {t('donations.rewardBody', { usd: rewardUSD, quota: rewardQuota.toLocaleString() })} +

+
+ + + + + + + setUserID(event.target.value)} + /> + + + + setAccountName(event.target.value)} + /> + + + {webAuth ? ( +
+ + {phase === 'waiting' ? ( + + ) : null} + {authUrl ? ( + + {t('donations.reopenAuth')} + + ) : null} +
+ ) : ( + <> + {selected?.credential_kind === QODER_NATIVE ? ( + <> + +