mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +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:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user