Skip to content

Commit fbac9f0

Browse files
lidge-junWibias
andcommitted
feat(gui): warn before high-risk subscription OAuth login (#176)
Adds ToS warning dialogs before OAuth login for providers whose subscription terms prohibit third-party proxy usage. High risk: Anthropic, Google Antigravity. Elevated: GitHub Copilot, Cursor. Warning shown before OAuth flow starts, dismissible with Cancel/Escape, proceed with acknowledgement. Squash-merged from Wibias:feat/oauth-tos-warnings (6bc022c). Co-Authored-By: Wibias <37517432+Wibias@users.noreply.github.com>
1 parent 24d8bbb commit fbac9f0

9 files changed

Lines changed: 347 additions & 6 deletions

File tree

gui/src/components/AddProviderModal.tsx

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
type ProviderPayload,
99
type ProviderPayloadForm,
1010
} from "../provider-payload";
11+
import { oauthTosRisk } from "../oauth-tos-risk";
12+
import OAuthTosWarningModal from "./OAuthTosWarningModal";
1113
import ProviderCatalog from "./provider-catalog/ProviderCatalog";
1214
import type { CatalogPreset } from "./provider-catalog/provider-presets";
1315
import { baseUrlForChoice, matchChoiceId, resolvedBaseUrlForChoice } from "../base-url-choice";
@@ -57,6 +59,7 @@ export default function AddProviderModal({
5759
const [presetsLoading, setPresetsLoading] = useState(true);
5860
const [usageRank, setUsageRank] = useState<Record<string, number>>({});
5961
const [endpointChoice, setEndpointChoice] = useState("custom");
62+
const [oauthTosPending, setOauthTosPending] = useState<string | null>(null);
6063
const aliveRef = useRef(true);
6164
const loadedPresetsRef = useRef(false);
6265
const previousFocusRef = useRef<HTMLElement | null>(null);
@@ -79,10 +82,13 @@ export default function AddProviderModal({
7982
};
8083
}, []);
8184
useEffect(() => {
82-
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
85+
const onKey = (e: KeyboardEvent) => {
86+
// Child ToS warning owns Escape while it is open.
87+
if (e.key === "Escape" && !oauthTosPending) onClose();
88+
};
8389
window.addEventListener("keydown", onKey);
8490
return () => window.removeEventListener("keydown", onKey);
85-
}, [onClose]);
91+
}, [onClose, oauthTosPending]);
8692
useEffect(() => {
8793
fetch(`${apiBase}/api/oauth/providers`).then(r => r.json()).then(d => setOauthSupported(d.providers ?? [])).catch(() => {});
8894
}, [apiBase]);
@@ -239,6 +245,15 @@ export default function AddProviderModal({
239245
}
240246
};
241247

248+
const requestLoginOAuth = (providerId: string) => {
249+
if (oauthBusy) return;
250+
if (oauthTosRisk(providerId)) {
251+
setOauthTosPending(providerId);
252+
return;
253+
}
254+
void loginOAuth(providerId);
255+
};
256+
242257
const submitManualCode = async (providerId: string) => {
243258
const input = manualCode.trim();
244259
if (!input || manualCodeBusy) return;
@@ -276,6 +291,7 @@ export default function AddProviderModal({
276291
const isReservedForward = preset ? isReservedCodexForwardPreset(preset) : false;
277292

278293
return (
294+
<>
279295
<div role="dialog" aria-modal="true" aria-label={t("modal.add")} className="modal-overlay" onClick={onClose}>
280296
<div ref={dialogRef} className="modal-card" onClick={e => e.stopPropagation()}>
281297
<div className="modal-head">
@@ -298,7 +314,7 @@ export default function AddProviderModal({
298314
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
299315
<div className="muted text-control">{preset.note ?? t("modal.oauthDefaultNote")}</div>
300316
{oauthSupported.includes(preset.oauthProvider ?? "") ? (
301-
<button className="btn btn-primary" onClick={() => loginOAuth(preset.oauthProvider!)} disabled={oauthBusy}
317+
<button className="btn btn-primary" onClick={() => requestLoginOAuth(preset.oauthProvider!)} disabled={oauthBusy}
302318
style={{ width: "100%", padding: "12px 16px" }}>
303319
<IconLock />{oauthBusy ? t("modal.waitingBrowser") : t("modal.logInWith", { label: preset.label })}
304320
</button>
@@ -486,6 +502,21 @@ export default function AddProviderModal({
486502
)}
487503
</div>
488504
</div>
505+
{oauthTosPending && (
506+
<OAuthTosWarningModal
507+
key={oauthTosPending}
508+
providerId={oauthTosPending}
509+
providerLabel={preset?.label ?? oauthTosPending}
510+
onCancel={() => setOauthTosPending(null)}
511+
onContinue={() => {
512+
const id = oauthTosPending;
513+
if (!id) return;
514+
setOauthTosPending(null);
515+
void loginOAuth(id);
516+
}}
517+
/>
518+
)}
519+
</>
489520
);
490521
}
491522

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* Modal shown before starting OAuth for providers whose subscription tokens
3+
* are restricted (or risky) when used outside the official client.
4+
*/
5+
import { useEffect, useId, useRef, useState } from "react";
6+
import { useT } from "../i18n";
7+
import { IconAlert } from "../icons";
8+
import {
9+
oauthTosRisk,
10+
oauthTosRiskBodyKey,
11+
oauthTosRiskTitleKey,
12+
} from "../oauth-tos-risk";
13+
14+
export default function OAuthTosWarningModal({
15+
providerId,
16+
providerLabel,
17+
onCancel,
18+
onContinue,
19+
}: {
20+
providerId: string;
21+
providerLabel: string;
22+
onCancel: () => void;
23+
onContinue: () => void;
24+
}) {
25+
const t = useT();
26+
const titleId = useId();
27+
const bodyId = useId();
28+
const dialogRef = useRef<HTMLDivElement>(null);
29+
const previousFocusRef = useRef<HTMLElement | null>(null);
30+
const submittedRef = useRef(false);
31+
const [acknowledged, setAcknowledged] = useState(false);
32+
const [submitted, setSubmitted] = useState(false);
33+
const level = oauthTosRisk(providerId);
34+
35+
// Capture-phase Escape so a parent modal (e.g. Add Provider) does not also close.
36+
useEffect(() => {
37+
const onKey = (e: KeyboardEvent) => {
38+
if (e.key !== "Escape") return;
39+
e.preventDefault();
40+
e.stopImmediatePropagation();
41+
onCancel();
42+
};
43+
window.addEventListener("keydown", onKey, true);
44+
return () => window.removeEventListener("keydown", onKey, true);
45+
}, [onCancel]);
46+
47+
// Focus first control on open; restore on close.
48+
useEffect(() => {
49+
previousFocusRef.current = document.activeElement as HTMLElement | null;
50+
const dialog = dialogRef.current;
51+
if (dialog) {
52+
const focusable = dialog.querySelector<HTMLElement>(
53+
"input:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex='-1'])",
54+
);
55+
focusable?.focus();
56+
}
57+
return () => {
58+
previousFocusRef.current?.focus();
59+
};
60+
}, []);
61+
62+
// Unmarked provider: render nothing (callers must gate with oauthTosRisk).
63+
if (!level) return null;
64+
65+
const handleContinue = () => {
66+
if (!acknowledged || submittedRef.current) return;
67+
submittedRef.current = true;
68+
setSubmitted(true);
69+
onContinue();
70+
};
71+
72+
return (
73+
<div
74+
role="dialog"
75+
aria-modal="true"
76+
aria-labelledby={titleId}
77+
aria-describedby={bodyId}
78+
className="modal-overlay"
79+
onClick={onCancel}
80+
style={{ zIndex: 60 }}
81+
>
82+
<div
83+
ref={dialogRef}
84+
className="modal-card"
85+
onClick={e => e.stopPropagation()}
86+
style={{ maxWidth: 460 }}
87+
>
88+
<h3 id={titleId}>{t(oauthTosRiskTitleKey(level), { provider: providerLabel })}</h3>
89+
<div
90+
id={bodyId}
91+
className="notice-warn"
92+
style={{ marginTop: 12, display: "flex", gap: 8, alignItems: "flex-start" }}
93+
>
94+
<IconAlert width={16} height={16} style={{ flexShrink: 0, marginTop: 2 }} aria-hidden="true" />
95+
<p className="modal-desc" style={{ margin: 0 }}>
96+
{t(oauthTosRiskBodyKey(level), { provider: providerLabel })}
97+
</p>
98+
</div>
99+
<p className="muted text-label" style={{ marginTop: 12 }}>
100+
{t("oauthTos.saferPath")}
101+
</p>
102+
<label className="oauth-tos-ack" style={{ display: "flex", gap: 8, alignItems: "flex-start", marginTop: 14 }}>
103+
<input
104+
type="checkbox"
105+
checked={acknowledged}
106+
onChange={e => setAcknowledged(e.target.checked)}
107+
style={{ marginTop: 3 }}
108+
aria-required="true"
109+
/>
110+
<span className="text-label">{t("oauthTos.acknowledge")}</span>
111+
</label>
112+
<div className="modal-actions">
113+
<button type="button" className="btn btn-ghost" onClick={onCancel}>
114+
{t("common.cancel")}
115+
</button>
116+
<button
117+
type="button"
118+
className="btn btn-primary"
119+
disabled={!acknowledged || submitted}
120+
onClick={handleContinue}
121+
>
122+
{t("oauthTos.continue")}
123+
</button>
124+
</div>
125+
</div>
126+
</div>
127+
);
128+
}

gui/src/i18n/de.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,13 @@ export const de = {
145145
"prov.loginError": "{provider}-Login-Fehler: {error}",
146146
"prov.loginRequestFail": "{provider}-Login-Anfrage fehlgeschlagen",
147147
"prov.loginOk": "Bei {provider} angemeldet. Führe {cmd} aus (oder es gilt live), um seine Modelle aufzulisten.",
148+
"oauthTos.highTitle": "{provider}: Risiko bei Abo-OAuth",
149+
"oauthTos.elevatedTitle": "{provider}: inoffizielle OAuth-Brücke",
150+
"oauthTos.highBody": "OpenCodex ist ein Drittanbieter-Proxy. {provider} beschränkt Abo-OAuth auf offizielle Apps. Die Anmeldung hier kann gegen die Nutzungsbedingungen verstoßen und zu Limits oder Kontosperrung führen. Ein API-Schlüssel ist der sicherere Weg, sofern verfügbar.",
151+
"oauthTos.elevatedBody": "OpenCodex verbindet {provider} über einen inoffiziellen OAuth-Pfad. Anbieter können ungewöhnlichen oder automatisierten Traffic als Missbrauch werten und den Zugang sperren. Bevorzuge den offiziellen Client oder einen API-Schlüssel.",
152+
"oauthTos.saferPath": "Sicherere Optionen: die offizielle App mit deinem Abo nutzen oder in OpenCodex einen API-Schlüssel hinterlegen.",
153+
"oauthTos.acknowledge": "Ich verstehe das Risiko und möchte trotzdem mit OAuth fortfahren.",
154+
"oauthTos.continue": "Mit OAuth fortfahren",
148155
"prov.logoutOk": "Von {provider} abgemeldet.",
149156
"prov.logoutFail": "Abmeldung von {provider} fehlgeschlagen. Der Kontostatus bleibt unverändert.",
150157
"prov.removed": "\"{name}\" entfernt.",

gui/src/i18n/en.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,13 @@ export const en = {
156156
"prov.loginError": "{provider} login error: {error}",
157157
"prov.loginRequestFail": "{provider} login request failed",
158158
"prov.loginOk": "Logged in to {provider}. Run {cmd} (or it applies live) to list its models.",
159+
"oauthTos.highTitle": "{provider}: subscription OAuth risk",
160+
"oauthTos.elevatedTitle": "{provider}: unofficial OAuth bridge",
161+
"oauthTos.highBody": "OpenCodex is a third-party proxy. {provider} restricts subscription OAuth to its official apps. Using that login here can violate their terms and may lead to rate limits or account suspension. An API key is the safer path when available.",
162+
"oauthTos.elevatedBody": "OpenCodex bridges {provider} through an unofficial OAuth path. Providers may treat unusual or automated traffic as abuse and suspend access. Prefer an official client or an API key when you can.",
163+
"oauthTos.saferPath": "Safer options: use the provider’s official app with your subscription, or configure an API key in OpenCodex.",
164+
"oauthTos.acknowledge": "I understand the risk and want to continue with OAuth anyway.",
165+
"oauthTos.continue": "Continue with OAuth",
159166
"prov.logoutOk": "Logged out of {provider}.",
160167
"prov.logoutFail": "Could not log out of {provider}. Your account state is unchanged.",
161168
"prov.removed": "Removed \"{name}\".",

gui/src/i18n/ko.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,13 @@ export const ko: Record<TKey, string> = {
151151
"prov.loginError": "{provider} 로그인 오류: {error}",
152152
"prov.loginRequestFail": "{provider} 로그인 요청 실패",
153153
"prov.loginOk": "{provider} 에 로그인했습니다. 모델을 표시하려면 {cmd} 를 실행하세요(또는 실시간 적용됩니다).",
154+
"oauthTos.highTitle": "{provider}: 구독 OAuth 위험",
155+
"oauthTos.elevatedTitle": "{provider}: 비공식 OAuth 브리지",
156+
"oauthTos.highBody": "OpenCodex는 타사 프록시입니다. {provider}는 구독 OAuth를 공식 앱으로 제한합니다. 여기서 로그인하면 약관 위반으로 제한이나 계정 정지가 발생할 수 있습니다. 가능하면 API 키가 더 안전합니다.",
157+
"oauthTos.elevatedBody": "OpenCodex는 {provider}를 비공식 OAuth 경로로 연결합니다. 비정상/자동화 트래픽은 남용으로 간주되어 접근이 정지될 수 있습니다. 가능하면 공식 클라이언트나 API 키를 사용하세요.",
158+
"oauthTos.saferPath": "더 안전한 방법: 구독은 공식 앱에서 쓰거나 OpenCodex에 API 키를 설정하세요.",
159+
"oauthTos.acknowledge": "위험을 이해했으며 OAuth로 계속 진행합니다.",
160+
"oauthTos.continue": "OAuth로 계속",
154161
"prov.logoutOk": "{provider} 에서 로그아웃했습니다.",
155162
"prov.logoutFail": "{provider}에서 로그아웃하지 못했습니다. 계정 상태는 그대로입니다.",
156163
"prov.removed": "\"{name}\" 을(를) 삭제했습니다.",

gui/src/i18n/zh.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,13 @@ export const zh: Record<TKey, string> = {
151151
"prov.loginError": "{provider} 登录错误:{error}",
152152
"prov.loginRequestFail": "{provider} 登录请求失败",
153153
"prov.loginOk": "已登录到 {provider}。运行 {cmd}(或实时生效)以列出其模型。",
154+
"oauthTos.highTitle": "{provider}:订阅 OAuth 风险",
155+
"oauthTos.elevatedTitle": "{provider}:非官方 OAuth 桥接",
156+
"oauthTos.highBody": "OpenCodex 是第三方代理。{provider} 将订阅 OAuth 限制在官方应用内。在此登录可能违反其条款,并导致限流或账号停用。如有 API 密钥,更安全。",
157+
"oauthTos.elevatedBody": "OpenCodex 通过非官方 OAuth 路径连接 {provider}。异常或自动化流量可能被判定为滥用并暂停访问。请优先使用官方客户端或 API 密钥。",
158+
"oauthTos.saferPath": "更安全的做法:用官方应用使用订阅,或在 OpenCodex 中配置 API 密钥。",
159+
"oauthTos.acknowledge": "我了解风险,仍要继续使用 OAuth。",
160+
"oauthTos.continue": "继续使用 OAuth",
154161
"prov.logoutOk": "已退出 {provider}。",
155162
"prov.logoutFail": "无法退出 {provider}。账户状态保持不变。",
156163
"prov.removed": "已移除 \"{name}\"。",

gui/src/oauth-tos-risk.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* OAuth providers where subscription login into a third-party proxy
3+
* (OpenCodex) carries elevated Terms-of-Service / account-action risk.
4+
*
5+
* High: provider docs/ToS explicitly restrict subscription OAuth to official apps.
6+
* Elevated: reverse-engineered / unofficial bridges; abuse detection may suspend access.
7+
*/
8+
export type OAuthTosRiskLevel = "high" | "elevated";
9+
10+
const HIGH_RISK = new Set(["anthropic", "google-antigravity"]);
11+
const ELEVATED_RISK = new Set(["github-copilot", "cursor"]);
12+
13+
export function oauthTosRisk(providerId: string): OAuthTosRiskLevel | null {
14+
const id = providerId.trim().toLowerCase();
15+
if (HIGH_RISK.has(id)) return "high";
16+
if (ELEVATED_RISK.has(id)) return "elevated";
17+
return null;
18+
}
19+
20+
export function oauthTosRiskTitleKey(level: OAuthTosRiskLevel): "oauthTos.highTitle" | "oauthTos.elevatedTitle" {
21+
switch (level) {
22+
case "high":
23+
return "oauthTos.highTitle";
24+
case "elevated":
25+
return "oauthTos.elevatedTitle";
26+
default: {
27+
const _exhaustive: never = level;
28+
return _exhaustive;
29+
}
30+
}
31+
}
32+
33+
export function oauthTosRiskBodyKey(level: OAuthTosRiskLevel): "oauthTos.highBody" | "oauthTos.elevatedBody" {
34+
switch (level) {
35+
case "high":
36+
return "oauthTos.highBody";
37+
case "elevated":
38+
return "oauthTos.elevatedBody";
39+
default: {
40+
const _exhaustive: never = level;
41+
return _exhaustive;
42+
}
43+
}
44+
}

0 commit comments

Comments
 (0)