Fix username display issues (#6471)

# Description of Changes
Main fixes:
- Fix the display of the username in the bottom left
- Now displays as "User" when not logged in on self-hosted (desktop) and
"Guest" on SaaS when logged in anonymously
- Now updates properly when the user logs in/out in SaaS, desktop and
self-hosted
- Fix incremental build issues in the desktop app that have been here
since the start (I hope at least - I think the issue is that the JLink
is built read-only and then on subsequent builds you get OS errors when
trying to override the JLink with the new version. There's no real need
for it to be read-only that I know of, so we might as well just make it
R/W and ship like that)
This commit is contained in:
James Brunton
2026-05-29 14:35:47 +00:00
committed by GitHub
parent 83ea07ed6a
commit 4d5eeb103f
17 changed files with 561 additions and 238 deletions
@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import type { TFunction } from "i18next";
import type { User } from "@app/auth/springAuthClient";
import { deriveDisplayName } from "@app/auth/UseSession";
// Stub t() that returns the fallback string. The real i18next instance
// looks up "auth.displayName.user" -> "User" but we don't need that
// machinery here.
const t: TFunction = ((_key: string, fallback?: string) =>
fallback ?? "") as TFunction;
function makeUser(overrides: Partial<User> = {}): User {
return {
id: "user-1",
email: "[email protected]",
username: "alice",
role: "USER",
...overrides,
};
}
describe("proprietary deriveDisplayName", () => {
it("returns null when there is no user object", () => {
expect(deriveDisplayName(null, t)).toBeNull();
expect(deriveDisplayName(undefined, t)).toBeNull();
});
it("returns the username when present", () => {
expect(deriveDisplayName(makeUser({ username: "alice" }), t)).toBe("alice");
});
it("falls back to email when username is empty", () => {
expect(
deriveDisplayName(
makeUser({ username: "", email: "[email protected]" }),
t,
),
).toBe("[email protected]");
});
it("returns null when both username and email are empty", () => {
expect(
deriveDisplayName(makeUser({ username: "", email: "" }), t),
).toBeNull();
});
it("returns the localised 'User' placeholder for anonymous users", () => {
expect(
deriveDisplayName(
makeUser({ is_anonymous: true, username: "anon-uuid" }),
t,
),
).toBe("User");
});
it("treats anonymous flag as authoritative - even a populated username is overridden", () => {
// The Spring backend may assign a generated username to anonymous users;
// we still want the localised placeholder shown to the UI.
expect(
deriveDisplayName(
makeUser({ is_anonymous: true, username: "anonymous-12345" }),
t,
),
).toBe("User");
});
});
@@ -6,6 +6,8 @@ import {
ReactNode,
useCallback,
} from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { springAuth } from "@app/auth/springAuthClient";
import { clearPlatformAuthOnLoginInit } from "@app/extensions/authSessionCleanup";
import type {
@@ -22,15 +24,42 @@ import type {
interface AuthContextType {
session: Session | null;
user: User | null;
/**
* Human-readable name to show in the UI for the current session.
* - A real identity (username/email) when the user is signed in.
* - The localised "User" placeholder for anonymous sessions
* (proprietary's chosen label - see deriveDisplayName).
* - null only when there is no user object at all (signed-out), so
* consumers can fall back to whatever makes sense.
*/
displayName: string | null;
loading: boolean;
error: AuthError | null;
signOut: () => Promise<void>;
refreshSession: () => Promise<void>;
}
/**
* Derive a display name from the Spring user. Anonymous users get the
* localised "User" placeholder (proprietary's chosen label for unsigned-in
* sessions); returns null only when there is no user object at all so
* consumers can pick their own fallback.
*
* Exported for unit testing.
*/
export function deriveDisplayName(
user: User | null | undefined,
t: TFunction,
): string | null {
if (!user) return null;
if (user.is_anonymous) return t("auth.displayName.user", "User");
return user.username || user.email || null;
}
const AuthContext = createContext<AuthContextType>({
session: null,
user: null,
displayName: null,
loading: true,
error: null,
signOut: async () => {},
@@ -100,15 +129,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const { error } = await springAuth.signOut();
// Always clear the in-memory session: springAuth.signOut() removes the
// local token and platform user_info even when the backend POST fails,
// so the user is effectively signed out either way. Leaving session
// populated on error would mean the UI keeps the old user's badge until
// a manual reload (the SIGNED_OUT notifyListeners call also covers this
// path now, but clearing here is defence in depth).
setSession(null);
if (error) {
console.error("[Auth] Sign out error:", error);
setError(error);
} else {
console.debug("[Auth] Signed out successfully");
setSession(null);
}
} catch (err) {
console.error("[Auth] Unexpected error during sign out:", err);
setSession(null);
setError(err as AuthError);
}
}, []);
@@ -248,9 +285,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
}, []);
const { t } = useTranslation();
const user = session?.user ?? null;
const value: AuthContextType = {
session,
user: session?.user ?? null,
user,
displayName: deriveDisplayName(user, t),
loading,
error,
signOut,
@@ -18,6 +18,7 @@ import {
isDesktopSaaSAuthMode,
refreshPlatformSession,
savePlatformToken,
shouldCallBackendLogout,
} from "@app/extensions/platformSessionBridge";
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
@@ -352,9 +353,13 @@ class SpringAuthClient {
platformUser?.email ||
platformUser?.username ||
"desktop-saas-user",
email: platformUser?.email || "",
username: platformUser?.username || platformUser?.email || "User",
email: platformUser?.email ?? "",
// Username may be empty when the platform layer can't identify
// the user - downstream displayName derivation handles that
// case and falls back to a generic placeholder.
username: platformUser?.username ?? "",
role: "USER",
is_anonymous: platformUser?.is_anonymous,
},
access_token: token,
expires_in: tokenExpiry.expiresIn,
@@ -575,15 +580,24 @@ class SpringAuthClient {
"1",
);
}
const response = await apiClient.post("/api/v1/auth/logout", null, {
headers: {
"X-XSRF-TOKEN": this.getCsrfToken() || "",
},
withCredentials: true,
});
if (response.status === 200) {
// console.debug('[SpringAuth] signOut: Success');
// Only call the backend logout endpoint when the platform tells us
// the current backend implements it. In desktop SaaS mode the
// apiClient points at the SaaS gateway, which doesn't expose
// `/api/v1/auth/logout` (Supabase manages session lifecycle); POSTing
// there returns 500 and pollutes error toasts even though the local
// cleanup below succeeds.
if (await shouldCallBackendLogout()) {
const response = await apiClient.post("/api/v1/auth/logout", null, {
headers: {
"X-XSRF-TOKEN": this.getCsrfToken() || "",
},
withCredentials: true,
});
if (response.status === 200) {
// console.debug('[SpringAuth] signOut: Success');
}
}
// Clean up local storage
@@ -641,6 +655,12 @@ class SpringAuthClient {
cleanupError,
);
}
// The user is logged out *locally* even if the backend call failed
// (token + platform user_info are gone). The previous version skipped
// this notification on error - the AuthProvider then never cleared
// its session state, leaving the UI claiming the user was still signed
// in until a full reload.
this.notifyListeners("SIGNED_OUT", null);
return {
error: { message: getErrorMessage(error, "Logout failed") },
};
@@ -1,6 +1,16 @@
/**
* Resolved identity for the current session, as understood by the platform
* layer (desktop) that owns the underlying token format. The proprietary
* auth client treats these fields as opaque - it does NOT inspect the JWT
* directly. Each platform decides how to populate this from whatever
* token/user storage it owns (e.g. desktop reads the Tauri user_info store
* plus the Supabase JWT claims; web has no platform layer).
*/
export interface PlatformSessionUser {
username: string;
email?: string;
/** True for anonymous/guest sessions (e.g. Supabase anonymous sign-in). */
is_anonymous?: boolean;
}
/**
@@ -10,6 +20,14 @@ export async function isDesktopSaaSAuthMode(): Promise<boolean> {
return false;
}
/**
* Whether the currently-authoritative backend exposes `/api/v1/auth/logout`
* and should be hit during sign-out.
*/
export async function shouldCallBackendLogout(): Promise<boolean> {
return true;
}
/**
* Proprietary/web default: no platform user store.
*/
@@ -109,6 +109,7 @@ describe("Login", () => {
vi.mocked(useAuth).mockReturnValue({
session: null,
user: null,
displayName: null,
loading: false,
error: null,
signOut: vi.fn(),
@@ -155,6 +156,7 @@ describe("Login", () => {
vi.mocked(useAuth).mockReturnValue({
session: mockSession,
user: mockSession.user,
displayName: mockSession.user.username,
loading: false,
error: null,
signOut: vi.fn(),
@@ -178,6 +180,7 @@ describe("Login", () => {
vi.mocked(useAuth).mockReturnValue({
session: null,
user: null,
displayName: null,
loading: true,
error: null,
signOut: vi.fn(),