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:
@@ -481,9 +481,20 @@ async function handleOAuthCallback(url: URL): Promise<Response> {
|
|||||||
oauthSessions.delete(state)
|
oauthSessions.delete(state)
|
||||||
|
|
||||||
try {
|
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)
|
const tokenFromQuery = pickTokenFromQuery(url)
|
||||||
if (tokenFromQuery) {
|
if (tokenFromQuery) {
|
||||||
await loginAndActivate(session.providerId, tokenFromQuery)
|
await loginAndActivate(
|
||||||
|
session.providerId,
|
||||||
|
tokenFromQuery,
|
||||||
|
undefined,
|
||||||
|
oauthUser ? buildMcpAuthFromOAuthQuery(tokenFromQuery, oauthUser) : undefined,
|
||||||
|
)
|
||||||
return callbackHtml(true, '登录成功,已回到 HeiCode。你现在可以关闭此页面。')
|
return callbackHtml(true, '登录成功,已回到 HeiCode。你现在可以关闭此页面。')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,13 +518,67 @@ async function handleOAuthCallback(url: URL): Promise<Response> {
|
|||||||
redirectUri,
|
redirectUri,
|
||||||
codeVerifier: session.codeVerifier,
|
codeVerifier: session.codeVerifier,
|
||||||
})
|
})
|
||||||
await loginAndActivate(session.providerId, exchangedToken)
|
await loginAndActivate(
|
||||||
|
session.providerId,
|
||||||
|
exchangedToken,
|
||||||
|
undefined,
|
||||||
|
oauthUser ? buildMcpAuthFromOAuthQuery(exchangedToken, oauthUser) : undefined,
|
||||||
|
)
|
||||||
return callbackHtml(true, '登录成功,已回到 HeiCode。你现在可以关闭此页面。')
|
return callbackHtml(true, '登录成功,已回到 HeiCode。你现在可以关闭此页面。')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return callbackHtml(false, err instanceof Error ? err.message : String(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 ───────────────────────────────────────────────────
|
// ─── Helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
async function loginAndActivate(
|
async function loginAndActivate(
|
||||||
|
|||||||
@@ -85,6 +85,38 @@ func HeicodeOAuthAuthorize(c *gin.Context) {
|
|||||||
if providerID != "" {
|
if providerID != "" {
|
||||||
q.Set("provider_id", 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()
|
parsed.RawQuery = q.Encode()
|
||||||
c.Redirect(http.StatusFound, parsed.String())
|
c.Redirect(http.StatusFound, parsed.String())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user