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
@@ -7,9 +7,11 @@ import {
} from "@app/services/connectionModeService";
import { authService, UserInfo } from "@app/services/authService";
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
import { useAuth } from "@app/auth/UseSession";
export const ConnectionSettings: React.FC = () => {
const { t } = useTranslation();
const { signOut } = useAuth();
const [config, setConfig] = useState<ConnectionConfig | null>(null);
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [loading, setLoading] = useState(false);
@@ -43,7 +45,22 @@ export const ConnectionSettings: React.FC = () => {
if (config?.mode === "selfhosted" && config?.server_config?.url) {
localStorage.setItem("server_url", config.server_config.url);
}
await authService.logout();
// Use the proprietary signOut (which also fans out the SIGNED_OUT event
// to the AuthProvider so the React tree sees the unauthenticated state)
// and treat authService.logout() as a fallback if it errors. The previous
// implementation only called authService.logout() directly, which cleared
// the Tauri-stored token+user_info but left the proprietary AuthProvider's
// session state stale - so the FileSidebar badge kept showing the prior
// user's name until the next session check happened to fire.
try {
await signOut();
} catch (signOutError) {
console.warn(
"[ConnectionSettings] signOut() failed, falling back to authService.logout()",
signOutError,
);
await authService.logout();
}
// Always switch to local after logout so the app remains usable
await connectionModeService.switchToLocal();
@@ -15,21 +15,110 @@ export async function isDesktopSaaSAuthMode(): Promise<boolean> {
}
}
export async function getPlatformSessionUser(): Promise<PlatformSessionUser | null> {
/**
* In 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 floods the error toasts even though local cleanup
* succeeds. Self-hosted mode IS a Spring backend so the endpoint exists.
*/
export async function shouldCallBackendLogout(): Promise<boolean> {
try {
const userInfo = await authService.getUserInfo();
if (!userInfo) {
return null;
}
return {
username: userInfo.username,
email: userInfo.email,
};
const mode = await connectionModeService.getCurrentMode();
return mode !== "saas";
} catch {
// If we can't read the mode, err on the side of trying the POST -
// a 500 is noisy but the catch branch still completes the local
// sign-out, so we'd rather attempt the backend call than skip it
// for a deployment that actually does have the endpoint.
return true;
}
}
/**
* Supabase JWT payload claims we care about. Desktop knows it issues
* Supabase-shaped tokens, so it can read them with proper types here -
* proprietary's auth client never needs to learn about user_metadata.
*/
interface SupabaseJwtClaims {
email?: string;
user_metadata?: {
full_name?: string;
name?: string;
};
is_anonymous?: boolean;
}
/**
* Decode the payload section of a JWT for display purposes only.
*
* SECURITY: this does NOT verify the signature. The returned claims are
* untrusted - never use them for authorisation decisions. The Supabase
* server validates the signature on every API call; this decoder exists
* solely to render the user's name/email in the UI before that
* server-validated state lands.
*/
function decodeSupabaseJwt(token: string): SupabaseJwtClaims | null {
const parts = token.split(".");
if (parts.length < 2) return null;
try {
const base64 = parts[1]
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(Math.ceil(parts[1].length / 4) * 4, "=");
return JSON.parse(atob(base64)) as SupabaseJwtClaims;
} catch {
return null;
}
}
export async function getPlatformSessionUser(): Promise<PlatformSessionUser | null> {
// Preferred source: the Tauri-cached user_info written at login time.
let cachedUser: { username: string; email: string | undefined } | null = null;
try {
const userInfo = await authService.getUserInfo();
if (userInfo) {
cachedUser = {
username: userInfo.username,
email: userInfo.email,
};
}
} catch {
/* fall through to JWT decode */
}
// Fallback: decode the JWT itself. The cache can lag (the
// jwt-available event fires before save_user_info in OAuth login) or be
// missing entirely (older tokens minted before user_info caching was
// wired up). The token always carries enough to identify the account.
let jwtClaims: SupabaseJwtClaims | null = null;
const token =
typeof window !== "undefined"
? window.localStorage.getItem("stirling_jwt")
: null;
if (token) {
jwtClaims = decodeSupabaseJwt(token);
}
if (!cachedUser && !jwtClaims) {
return null;
}
const email = cachedUser?.email || jwtClaims?.email;
const metadata = jwtClaims?.user_metadata;
const username =
cachedUser?.username ||
metadata?.full_name ||
metadata?.name ||
email ||
"";
return {
username,
email,
is_anonymous: jwtClaims?.is_anonymous === true,
};
}
export async function refreshPlatformSession(): Promise<boolean> {
try {
const mode = await connectionModeService.getCurrentMode();
@@ -145,6 +145,7 @@ export class ConnectionModeService {
console.log("Switching to SaaS mode");
const previousMode = this.currentConfig?.mode ?? null;
const serverConfig: ServerConfig = { url: saasServerUrl };
await invoke("set_connection_mode", {
@@ -166,6 +167,26 @@ export class ConnectionModeService {
this.notifyListeners();
// Re-dispatch `jwt-available` so the proprietary AuthProvider re-runs
// getSession() now that the mode is "saas". During OAuth login,
// authService.saveTokenEverywhere fires the initial `jwt-available`
// BEFORE switchToSaaS runs - at that point getSession sees mode="local"
// and takes the standard Spring path, which fails for Supabase JWTs.
// Without re-firing here the AuthProvider's session state stays null
// until a manual reload.
//
// Only fire when the mode actually changed AND there's a token to
// validate - otherwise a stay-in-mode call (e.g. updating the SaaS
// server URL while already signed in) would cause every `jwt-available`
// consumer to refetch unnecessarily.
if (
previousMode !== "saas" &&
typeof window !== "undefined" &&
localStorage.getItem("stirling_jwt")
) {
window.dispatchEvent(new CustomEvent("jwt-available"));
}
console.log("Switched to SaaS mode successfully");
}
@@ -208,6 +229,8 @@ export class ConnectionModeService {
console.log("Switching to self-hosted mode:", serverConfig);
const previousMode = this.currentConfig?.mode ?? null;
await invoke("set_connection_mode", {
mode: "selfhosted",
serverConfig,
@@ -231,6 +254,17 @@ export class ConnectionModeService {
this.notifyListeners();
// See the comment in switchToSaaS: re-fire `jwt-available` so the
// AuthProvider re-validates its session in the new mode. The same race
// applies to the self-hosted OAuth flow. Only on an actual mode change.
if (
previousMode !== "selfhosted" &&
typeof window !== "undefined" &&
localStorage.getItem("stirling_jwt")
) {
window.dispatchEvent(new CustomEvent("jwt-available"));
}
console.log("Switched to self-hosted mode successfully");
}