Skip to content

Commit 5b302e5

Browse files
committed
feat(officeace): 登录入口加「换账号」;未开通报错点名账号
华为云会话按浏览器走:浏览器里还留着上一个账号时,云端会直接放行, 「新增凭证」变成「同一个账号再登一遍」,用户永远拿不到另一条凭证。 - `/api/auth/upstream/start` 透传 `force_login`(仅传给支持该参数的渠道, 其余渠道仍单参调用) - officeAce 登录行加「换账号」按钮,走 `/authui/logout?service=` 先登出 - 未开通账号的报错带上用户名/账号 ID,并提示用「换账号」换到已开通的号
1 parent 857733e commit 5b302e5

9 files changed

Lines changed: 124 additions & 28 deletions

File tree

‎README.en.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ Mixing them yields `APIG.0303 app not found`. The failure is confusing either wa
219219

220220
**One account, one user**: the official plan page states the personal edition is "limited to a single user per account", so this channel is not shared between people — each user adds their own credential.
221221

222-
**Switching accounts**: the login flow supports `force_login`, which first hits `/authui/logout?service=…` to clear the Huawei Cloud browser session. The *Log in to officeAce* button does not pass it — use a private window or another browser for a second account, or sign out of Huawei Cloud first. The server-side capability is in place; wiring it into the UI is follow-up work.
222+
**Switching accounts**: the Huawei Cloud session is **per browser**. If a previous account's session is still alive, the cloud silently re-approves it and "add a credential" becomes "log the same account in again". The login row therefore has a second **Switch account** action (`force_login=true`) that hits `/authui/logout?service=…` first. An account that has not opened officeAce is reported by name instead of a generic login failure.
223223

224224
### CodeBuddy international site (`codebuddy_intl`, real account)
225225

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ Playground 请求表单可选填 `temperature` / `max_tokens` / `reasoning_effor
238238

239239
**单账号单用户**:官方《套餐管理》明确「个人版面向个人用户,限制单账号单用户使用」,故本渠道不做多人共用,每个使用者各自添加自己的凭证。
240240

241-
**换账号**:登录流程支持 `force_login`(先走 `/authui/logout?service=…` 清掉华为云浏览器会话再授权)。前端「登录 officeAce」按钮不传该参数——多账号场景请用浏览器的隐私窗口/另一浏览器登录,或先手动退出华为云。服务端能力已就绪,接线属后续工作。
241+
**换账号**:华为云会话按**浏览器**走——浏览器里还留着上一个账号时,云端会直接放行、把「新增账号」变成「同一个账号再登一遍」。故登录按钮旁额外给一个**「换账号」**(`force_login=true`),它先走 `/authui/logout?service=…` 清掉会话再授权。未开通的账号会明确报出是哪个号没开通,而不是笼统的「登录失败」。
242242

243243
### CodeBuddy 国际版(`codebuddy_intl`,真实账号)
244244

‎src/api/admin_auth.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from __future__ import annotations
88

9+
import inspect
910
import time
1011

1112
from fastapi import APIRouter, Depends, Request
@@ -155,7 +156,14 @@ async def upstream_auth_start(request: Request, payload: dict,
155156
require_admin(principal)
156157
provider_id = str(payload.get("provider") or "")
157158
if provider_id in services.upstream_auth:
158-
session = await services.upstream_auth[provider_id].start(principal.username)
159+
oauth = services.upstream_auth[provider_id]
160+
# `force_login`(换账号)只有支持它的渠道认:浏览器里还留着上一个
161+
# 账号的上游会话时,不先登出就会被云端直接放行成「同一个账号再登
162+
# 一遍」,用户点了「新增账号」却始终拿不到另一条凭证。
163+
session = (await oauth.start(principal.username,
164+
force_login=bool(payload.get("force_login")))
165+
if "force_login" in inspect.signature(oauth.start).parameters
166+
else await oauth.start(principal.username))
159167
else:
160168
provider = services.registry.get(provider_id)
161169
builder = getattr(provider, "start_auth", None)

‎src/provider/officeace/oauth.py‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -355,26 +355,30 @@ async def _finish(self, code: str,
355355
credential.client_id = self.config.client_id
356356
credential.dpop_private_jwk = dict(session.dpop_private_jwk)
357357
info = await self.fetch_model_info(credential)
358+
# 身份字段先落:未开通时要靠它告诉用户「是哪个账号没开通」——浏览器里
359+
# 留着另一个华为云账号时,用户看到「没开通」根本不知道说的是哪个号。
360+
credential.account_id = info["account_id"] or credential.account_id
361+
credential.principal_id = info["principal_id"] or credential.principal_id
362+
credential.user_name = info["user_name"] or credential.user_name
358363
if not info["app_key"] or not info["app_secret"]:
359364
# 未开通的账号:云端 200 但 `subscription: null`(实测三账号全如此)。
360365
# 这不是登录失败,而是「还没兑换邀请码」——必须给可行动的指引,
361-
# 否则用户会以为是自己登录姿势不对。
366+
# 否则用户会以为是自己登录姿势不对。带上账号名,否则用户不知道
367+
# 刚才授权的是浏览器里的哪个(很可能不是自己想用的那个)账号。
368+
who = info["user_name"] or info["account_id"]
362369
raise UpstreamProtocolViolation(
363370
"officeAce account is not provisioned",
364-
public=("这个账号还没开通 officeAce 服务。请先在 OfficeAce "
365-
"客户端里登录并兑换邀请码完成开通,再回来重新登录。"))
371+
public=(f"华为云账号 {who} 还没开通 officeAce 服务。" if who
372+
else "这个账号还没开通 officeAce 服务。")
373+
+ ("请先在 OfficeAce 客户端里登录并兑换邀请码完成开通,"
374+
"或点「换账号」用已开通的账号重新登录。"))
366375
if not info["base_url"]:
367376
raise UpstreamProtocolViolation(
368377
"model gateway address missing",
369378
public="云端没有返回模型网关地址,请稍后重试")
370379
credential.model_app_key = info["app_key"]
371380
credential.model_app_secret = info["app_secret"]
372381
credential.model_base_url = info["base_url"]
373-
credential.account_id = info["account_id"] or credential.account_id
374-
credential.principal_id = info["principal_id"] or credential.principal_id
375-
# 用户名只出现在 `principal_urn` 里(接口无独立字段):补上后昵称才不是
376-
# 一串 32 位账号 ID,用户才认得出「哪条凭证是自己的」。
377-
credential.user_name = info["user_name"] or credential.user_name
378382
# 昵称只用于展示:优先上游给的、其次用户名、最后账号 ID(都没有就留空,
379383
# 前端按凭证 ID 兜底)。用 `or` 链而非 if:三者都缺是合法情况,
380384
# STS 响应本身并不带 nickname 字段。

‎tests/api/test_admin_credentials.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,36 @@ def vanishing(_cid):
118118
app.state.credentials.credential_data = original # type: ignore[method-assign]
119119
assert response.status_code == 400
120120

121+
def test_upstream_auth_start_forwards_force_login_when_supported(admin_client):
122+
"""`force_login` 只传给支持的渠道,其余渠道仍按单参调用。"""
123+
from src.provider.base import AuthFlow, AuthSession
124+
125+
app, client = admin_client
126+
seen: dict[str, object] = {}
127+
oauth = app.state.upstream_auth["codebuddy"]
128+
129+
async def with_flag(username: str, *, force_login: bool = False) -> AuthSession:
130+
seen["flagged"] = (username, force_login)
131+
return AuthSession(flow=AuthFlow.POLL, state="s1", auth_url="https://a")
132+
133+
async def plain(username: str) -> AuthSession:
134+
seen["plain"] = username
135+
return AuthSession(flow=AuthFlow.POLL, state="s2", auth_url="https://a")
136+
137+
original = oauth.start
138+
try:
139+
oauth.start = with_flag # type: ignore[method-assign]
140+
client.post("/api/auth/upstream/start",
141+
json={"provider": "codebuddy", "force_login": True})
142+
assert seen["flagged"] == ("root", True)
143+
oauth.start = plain # type: ignore[method-assign]
144+
client.post("/api/auth/upstream/start",
145+
json={"provider": "codebuddy", "force_login": True})
146+
assert seen["plain"] == "root"
147+
finally:
148+
oauth.start = original # type: ignore[method-assign]
149+
150+
121151
def test_upstream_auth_cancel_endpoint(admin_client):
122152
app, client = admin_client
123153
oauth = app.state.upstream_auth["codebuddy"]

‎tests/provider/officeace/test_oauth.py‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,16 +353,33 @@ async def test_poll_credentials_use_client_id_from_config():
353353

354354

355355
async def test_poll_unprovisioned_account_gives_actionable_message():
356-
"""未开通(`subscription: null`)不是登录失败,必须给可行动的指引。"""
356+
"""未开通(`subscription: null`)不是登录失败,必须给可行动的指引。
357+
358+
还要点名是**哪个**账号没开通:浏览器里留着别的华为云会话时,用户根本
359+
不知道刚才授权的是哪个号。
360+
"""
357361
oauth, state = await _started(_flow(permission=httpx.Response(
358-
200, json={"account_id": "a", "subscription": None})))
362+
200, json={"account_id": "a", "principal_urn": "iam::a:user:eve",
363+
"subscription": None})))
359364
with pytest.raises(UpstreamProtocolViolation) as caught:
360365
await oauth.poll(state, "alice")
361366
assert "还没开通 officeAce 服务" in caught.value.public_message
367+
assert "eve" in caught.value.public_message
362368
# 失败后 state 未消费:用户可以补齐开通再重试
363369
assert len(oauth.store) == 1
364370

365371

372+
async def test_poll_unprovisioned_without_identity_uses_generic_wording():
373+
"""上游连身份都不给时不能拼出「华为云账号 还没开通」这种残句。"""
374+
oauth, state = await _started(_flow(permission=httpx.Response(
375+
200, json={"subscription": None})))
376+
with pytest.raises(UpstreamProtocolViolation) as caught:
377+
await oauth.poll(state, "alice")
378+
message = caught.value.public_message
379+
assert message.startswith("这个账号还没开通 officeAce 服务")
380+
assert "华为云账号" not in message
381+
382+
366383
async def test_poll_missing_gateway_raises():
367384
oauth, state = await _started(_flow(permission=_permission_response(
368385
subscription={"model_info": {"model_auth_info": {

‎web/src/api/client.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,13 @@ export const api = {
182182
request<ActivityReportResult>(`/api/credentials/${id}/activity`, { method: "POST" }),
183183

184184
// ------------------------------------------------------------ 渠道登录
185-
upstreamStart: (provider: string) =>
186-
request<UpstreamAuthStart>("/api/auth/upstream/start", { method: "POST", ...json({ provider }) }),
185+
/** 发起渠道登录。`forceLogin`:先登出上游,用于新增另一个账号(避免云端
186+
* 直接复用浏览器里现有的会话,把「新增」变成「同一个账号再登一遍」)。 */
187+
upstreamStart: (provider: string, forceLogin = false) =>
188+
request<UpstreamAuthStart>("/api/auth/upstream/start", {
189+
method: "POST",
190+
...json({ provider, force_login: forceLogin }),
191+
}),
187192
upstreamPoll: (provider: string, state: string) =>
188193
request<UpstreamAuthPoll>("/api/auth/upstream/poll", {
189194
method: "POST",

‎web/src/components/AddCredentialDialog.tsx‎

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -109,15 +109,15 @@ export function AddCredentialDialog({
109109
setLoginProviders((previous) => previous.filter((item) => item !== provider));
110110
};
111111

112-
const startLogin = async (provider: Provider) => {
112+
const startLogin = async (provider: Provider, forceLogin = false) => {
113113
onError("");
114114
onNotice("");
115115
// 先同步开一个占位窗口:window.open 若在 await 之后才调用,
116116
// 会脱离用户手势上下文而被浏览器弹窗拦截。
117117
const popup = window.open("", "_blank");
118118
if (popup) popup.opener = null; // 切断对管理台 window 的引用
119119
try {
120-
const started = await api.upstreamStart(provider);
120+
const started = await api.upstreamStart(provider, forceLogin);
121121
loginStatesRef.current[provider] = started.state;
122122
if (!started.auth_url) {
123123
popup?.close(); // 上游没给授权地址:关掉空白占位窗
@@ -320,24 +320,39 @@ export function AddCredentialDialog({
320320
</Button>
321321
</span>
322322
) : (
323-
<Button
324-
key={item}
325-
size="sm"
326-
variant="primary"
327-
data-testid={`start-login-${item}`}
328-
disabled={busy}
329-
onClick={() => void startLogin(item)}
330-
>
331-
登录 {PROVIDER_LABEL[item]}
332-
</Button>
323+
<span key={item} className="inline-flex items-center gap-1">
324+
<Button
325+
size="sm"
326+
variant="primary"
327+
data-testid={`start-login-${item}`}
328+
disabled={busy}
329+
onClick={() => void startLogin(item)}
330+
>
331+
登录 {PROVIDER_LABEL[item]}
332+
</Button>
333+
{item === "officeace" && (
334+
// 华为云会话按浏览器走:不先登出,云端会直接放行现有账号,
335+
// 用户点「新增」却始终只有同一条凭证。
336+
<Button
337+
size="sm"
338+
variant="ghost"
339+
data-testid="switch-login-officeace"
340+
disabled={busy}
341+
onClick={() => void startLogin(item, true)}
342+
>
343+
换账号
344+
</Button>
345+
)}
346+
</span>
333347
);
334348
})}
335349
</div>
336350
<p className="text-xs text-muted-foreground">
337351
CodeBuddy 走设备码轮询(自动检测完成);TRAE 走浏览器回调
338352
(授权后由 <code>/authorize</code> 直接落库,本对话框轮询凭证列表,
339353
期间勿关闭对话框);Qoder 走设备码登录;CodeArts 走 OAuth2 授权码登录
340-
(授权后把地址栏打不开的 127.0.0.1 回调链接粘回本对话框)。
354+
(授权后把地址栏打不开的 127.0.0.1 回调链接粘回本对话框);
355+
officeAce 由云端承接回调(本对话框轮询,多账号请用「换账号」)。
341356
</p>
342357
{pasteProvider && (
343358
<div className="space-y-2" data-testid="paste-callback">

‎web/src/components/__tests__/AddCredentialDialog.test.tsx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,23 @@ describe("AddCredentialDialog", () => {
111111
vi.useRealTimers();
112112
});
113113

114+
it("officeAce「换账号」带着 force_login 发起登录", async () => {
115+
const bodies: unknown[] = [];
116+
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
117+
const url = String(input);
118+
if (url.includes("/upstream/start")) {
119+
bodies.push(JSON.parse(String(init?.body ?? "")));
120+
return jsonResponse({ flow: "poll", state: "s", auth_url: "https://a", interval: 5 });
121+
}
122+
return jsonResponse({ credentials: [], viewer: "root", is_admin: true });
123+
}));
124+
vi.stubGlobal("open", () => ({ closed: false, close: vi.fn(), opener: {}, location: { href: "" } }));
125+
renderDialog();
126+
await userEvent.click(screen.getByTestId("switch-login-officeace"));
127+
128+
expect(bodies).toEqual([{ provider: "officeace", force_login: true }]);
129+
});
130+
114131
it("拒绝非 http(s) 授权地址,不对弹窗赋值", async () => {
115132
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
116133
const url = String(input);

0 commit comments

Comments
 (0)