mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
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:
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user