fix(client+server): OAuth login path now surfaces user identity in desktop TitleBar

Root cause of the recurring "I don't see the logged-in user info in the
desktop client" complaint: the credentials-login path builds an mcpAuth
record with email / displayName / role from the mcp-server /api/auth/login
response and the TitleBar UserPill renders fine. But the OAuth-login path
(the typical browser-redirect flow) only received an sk- API key in the
callback query string — no user fields. So:

  status.user                   == null
  UserPill: if (!user) return null
  → blank space where the user pill should be.

Fix on backend (heicode_oauth.go HeicodeOAuthAuthorize):
- After issuing the sk- token, load the authenticated user from the
  session and embed email / name / role (root|admin|user) / channel_id /
  user_id as query params on the redirect URI.

Fix on client (cc-haha/src/server/api/heicode-auth.ts handleOAuthCallback):
- Read those query params (pickUserFromQuery), build an mcpAuth record
  (buildMcpAuthFromOAuthQuery), and pass it through loginAndActivate the
  same way the credentials path does. The accessToken slot holds the sk-
  key as a placeholder — OAuth flow doesn't deliver a refreshable JWT
  pair, and this mcpAuth exists purely to surface identity on the
  TitleBar.

After this, OAuth-route users see the same gradient-avatar pill with
their email / name / role badge that credentials-route users already see.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 20:44:33 +08:00
co-authored by Claude Opus 4.7
parent b91a68fe2b
commit a2deeb61b0
2 changed files with 99 additions and 2 deletions
+67 -2
View File
@@ -481,9 +481,20 @@ async function handleOAuthCallback(url: URL): Promise<Response> {
oauthSessions.delete(state)
try {
// Pull the user identity fields that the heicode backend's
// /heicode/oauth/authorize redirect now embeds in the query string
// (email, name, role, channel_id, user_id). Without this the TitleBar
// UserPill stays hidden because mcpAuth.email is undefined.
const oauthUser = pickUserFromQuery(url)
const tokenFromQuery = pickTokenFromQuery(url)
if (tokenFromQuery) {
await loginAndActivate(session.providerId, tokenFromQuery)
await loginAndActivate(
session.providerId,
tokenFromQuery,
undefined,
oauthUser ? buildMcpAuthFromOAuthQuery(tokenFromQuery, oauthUser) : undefined,
)
return callbackHtml(true, '登录成功,已回到 HeiCode。你现在可以关闭此页面。')
}
@@ -507,13 +518,67 @@ async function handleOAuthCallback(url: URL): Promise<Response> {
redirectUri,
codeVerifier: session.codeVerifier,
})
await loginAndActivate(session.providerId, exchangedToken)
await loginAndActivate(
session.providerId,
exchangedToken,
undefined,
oauthUser ? buildMcpAuthFromOAuthQuery(exchangedToken, oauthUser) : undefined,
)
return callbackHtml(true, '登录成功,已回到 HeiCode。你现在可以关闭此页面。')
} catch (err) {
return callbackHtml(false, err instanceof Error ? err.message : String(err))
}
}
/** Read user identity fields the heicode backend embeds in the OAuth redirect. */
function pickUserFromQuery(url: URL): {
email?: string
name?: string
role?: string
channelId?: string
userId?: string
} | null {
const email = url.searchParams.get('email')?.trim()
const name = url.searchParams.get('name')?.trim()
const role = url.searchParams.get('role')?.trim()
const channelId = url.searchParams.get('channel_id')?.trim()
const userId = url.searchParams.get('user_id')?.trim()
if (!email && !name && !role && !channelId && !userId) return null
return {
...(email && { email }),
...(name && { name }),
...(role && { role }),
...(channelId && { channelId }),
...(userId && { userId }),
}
}
/**
* Shape a mcpAuth record from the OAuth-redirect query params. The OAuth flow
* does not deliver a refreshable JWT pair (it only gives the sk- API key), so
* we set placeholder access/refresh tokens. They're only meaningful for the
* credentials flow (Step 1 POST /api/auth/login); this record exists purely to
* surface the user identity on the TitleBar UserPill.
*/
function buildMcpAuthFromOAuthQuery(
apiKey: string,
u: { email?: string; name?: string; role?: string; channelId?: string; userId?: string },
) {
const now = Date.now()
return {
accessToken: apiKey,
refreshToken: '',
accessExpiresAt: now + 24 * 3600 * 1000,
refreshExpiresAt: now,
managerLoginUrl: '',
...(u.userId && { userId: u.userId }),
...(u.channelId && { channelId: u.channelId }),
...(u.email && { email: u.email }),
...(u.name && { displayName: u.name }),
...(u.role && { role: u.role }),
}
}
// ─── Helpers ───────────────────────────────────────────────────
async function loginAndActivate(
+32
View File
@@ -85,6 +85,38 @@ func HeicodeOAuthAuthorize(c *gin.Context) {
if providerID != "" {
q.Set("provider_id", providerID)
}
// Include the authenticated user's identity so the desktop client can
// display it in the title bar / settings without making a separate
// /api/user/self call (that endpoint requires a session cookie which the
// desktop client doesn't have). This is the missing piece that left the
// UserPill empty for OAuth-route logins.
var user model.User
if dbErr := model.DB.First(&user, userID).Error; dbErr == nil {
if email := strings.TrimSpace(user.Email); email != "" {
q.Set("email", email)
}
display := strings.TrimSpace(user.DisplayName)
if display == "" {
display = strings.TrimSpace(user.Username)
}
if display != "" {
q.Set("name", display)
}
switch {
case user.Role >= 100:
q.Set("role", "root")
case user.Role >= 10:
q.Set("role", "admin")
default:
q.Set("role", "user")
}
if g := strings.TrimSpace(user.Group); g != "" {
q.Set("channel_id", g)
}
q.Set("user_id", fmt.Sprintf("%d", userID))
}
parsed.RawQuery = q.Encode()
c.Redirect(http.StatusFound, parsed.String())
}