From 4d5eeb103f4b051502c2318d34f0739f94cec1cf Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 29 May 2026 15:35:47 +0100 Subject: [PATCH] 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) --- .taskfiles/desktop.yml | 23 +- .../public/locales/en-GB/translation.toml | 4 + frontend/editor/src/core/auth/UseSession.tsx | 14 ++ .../components/shared/AppConfigModalLazy.tsx | 4 +- .../core/components/shared/FileSidebar.tsx | 31 ++- .../src/core/components/shared/InfoBanner.tsx | 35 ++- .../desktop/components/ConnectionSettings.tsx | 19 +- .../extensions/platformSessionBridge.ts | 107 +++++++++- .../desktop/services/connectionModeService.ts | 34 +++ .../src/proprietary/auth/UseSession.test.ts | 66 ++++++ .../src/proprietary/auth/UseSession.tsx | 44 +++- .../src/proprietary/auth/springAuthClient.ts | 40 +++- .../extensions/platformSessionBridge.ts | 18 ++ .../src/proprietary/routes/Login.test.tsx | 3 + .../editor/src/saas/auth/UseSession.test.ts | 118 +++++++++++ frontend/editor/src/saas/auth/UseSession.tsx | 40 +++- .../src/saas/components/shared/InfoBanner.tsx | 199 ------------------ 17 files changed, 561 insertions(+), 238 deletions(-) create mode 100644 frontend/editor/src/proprietary/auth/UseSession.test.ts create mode 100644 frontend/editor/src/saas/auth/UseSession.test.ts delete mode 100644 frontend/editor/src/saas/components/shared/InfoBanner.tsx diff --git a/.taskfiles/desktop.yml b/.taskfiles/desktop.yml index ebf7f5400..ac4ccbe80 100644 --- a/.taskfiles/desktop.yml +++ b/.taskfiles/desktop.yml @@ -133,8 +133,29 @@ tasks: --no-header-files --no-man-pages --output runtime/jre + # jlink emits its files mode 444 (read-only). Tauri's build-script + # resource copier preserves source permissions when staging + # `runtime/jre/**/*` into `target//runtime/jre/...`, so the + # staged copies are read-only too. On any subsequent incremental + # build the copier tries to overwrite them and fails with a bare + # `Permission denied (os error 13)` (Rust's io::Error Display drops + # the path, so the failure is opaque). Make the source writable here + # so the staged destinations are writable and can be overwritten. + # + # Trade-off: this task runs for both `task desktop:dev` and + # `task desktop:build`, so production bundles also ship mode-644 + # JRE files instead of 444. Functionally harmless on POSIX (the + # `other` bit is `r--` either way, and on macOS code signing is the + # real integrity check) and on Windows the DOS read-only attribute + # isn't load-bearing for the bundled JDK. If we ever need strict + # 444 in production, split the chmod into a dev-only step and have + # `desktop:build` run `jlink:clean` first to force a fresh build. + - cmd: chmod -R u+w runtime/jre + platforms: [linux, darwin] + - cmd: powershell -NoProfile -Command "Get-ChildItem -Recurse runtime/jre | ForEach-Object { $_.IsReadOnly = $false }" + platforms: [windows] status: - - test -d editor/src-tauri/runtime/jre + - test -f runtime/jre/release jlink:clean: desc: "Remove JLink runtime and bundled JARs" diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 1a79197c5..a8e2e1ac4 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -1764,6 +1764,10 @@ insufficientPermissions = "You do not have permission to perform this action." pleaseLoginAgain = "Please login again." sessionExpired = "Session Expired" +[auth.displayName] +guest = "Guest" +user = "User" + [auto-rename] description = "Automatically finds the title from your PDF content and uses it as the filename." header = "Auto Rename PDF" diff --git a/frontend/editor/src/core/auth/UseSession.tsx b/frontend/editor/src/core/auth/UseSession.tsx index 01d9b94d1..8f9a44f52 100644 --- a/frontend/editor/src/core/auth/UseSession.tsx +++ b/frontend/editor/src/core/auth/UseSession.tsx @@ -1,6 +1,19 @@ export interface AuthContextType { session: null; user: { id?: string; email?: string; [key: string]: unknown } | null; + /** + * Human-readable name to show in the UI for the current session. + * - A real identity (username/email/full_name) when the user is signed in. + * - A layer-specific placeholder (e.g. "Guest" in SaaS, "User" in + * proprietary) for anonymous sessions. + * - null only when there is no user object at all (signed-out, or core + * OSS with no auth context) - consumers can fall back to whatever + * makes sense in their build. + * + * Each layer derives this from its own native user shape - consumers + * should treat the resulting string as opaque display text. + */ + displayName: string | null; loading: boolean; error: Error | null; signOut: () => Promise; @@ -15,6 +28,7 @@ export function useAuth(): AuthContextType { return { session: null, user: null, + displayName: null, loading: false, error: null, signOut: async () => {}, diff --git a/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx b/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx index b3bc63249..d242db9a6 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx @@ -22,11 +22,9 @@ export default function AppConfigModalLazy({ if (opened) setShouldMount(true); }, [opened]); - if (!shouldMount) return null; - return ( - + {shouldMount && } ); } diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 6fd3ea9a6..4354f54a2 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -18,6 +18,7 @@ import { } from "@app/contexts/NavigationContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { useFileHandler } from "@app/hooks/useFileHandler"; +import { useAuth } from "@app/auth/UseSession"; import { useIndexedDB, useIndexedDBRevision, @@ -107,19 +108,39 @@ const FileSidebar = forwardRef( const { activeFileId, setActiveFileId } = useViewer(); const { addFiles } = useFileHandler(); const indexedDB = useIndexedDB(); - const [displayName, setDisplayName] = useState("Guest"); + + // Each auth layer derives its own displayName from its native user shape. + // Fall back to the proprietary REST endpoint only when the auth + // context yields nothing - then to "User" as a generic last resort. + const { displayName: authDisplayName } = useAuth(); + const [accountUsername, setAccountUsername] = useState(null); + const displayName = + authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User"); useEffect(() => { - if (!config?.enableLogin) return; + if (!config?.enableLogin) { + setAccountUsername(null); + return; + } + if (authDisplayName) { + // The auth context has a name; don't bother hitting the REST + // endpoint, but clear any stale cached value from a prior call. + setAccountUsername(null); + return; + } accountService .getAccountData() .then((data) => { - if (data?.username) setDisplayName(data.username); + // Always reflect the latest result - including clearing it on + // sign-out, when the endpoint returns no username (or 401s into + // the catch branch below). Without this, signing out would leave + // the old username on screen. + setAccountUsername(data?.username ?? null); }) .catch(() => { - /* not logged in or security disabled */ + setAccountUsername(null); }); - }, [config?.enableLogin]); + }, [config?.enableLogin, authDisplayName]); // Leaf files = user-visible files (excludes intermediate tool outputs) const [allFileStubs, setAllFileStubs] = useState([]); diff --git a/frontend/editor/src/core/components/shared/InfoBanner.tsx b/frontend/editor/src/core/components/shared/InfoBanner.tsx index 50a1cba22..11ecdcc0d 100644 --- a/frontend/editor/src/core/components/shared/InfoBanner.tsx +++ b/frontend/editor/src/core/components/shared/InfoBanner.tsx @@ -31,7 +31,11 @@ const toneStyles: Record< }; interface InfoBannerProps { - icon: string; + /** + * Either a LocalIcon name (string) for the standard sized icon slot, or a + * pre-rendered ReactNode (e.g. a logo image) which is dropped in as-is. + */ + icon?: string | ReactNode; title?: ReactNode; message: ReactNode; buttonText?: string; @@ -48,6 +52,8 @@ interface InfoBannerProps { iconColor?: string; buttonColor?: string; buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle"; + /** Override the button label colour (for dark/custom theme variants). */ + buttonTextColor?: string; minHeight?: number | string; closeIconColor?: string; compact?: boolean; @@ -74,6 +80,7 @@ export const InfoBanner: React.FC = ({ iconColor, buttonColor, buttonVariant = "light", + buttonTextColor, minHeight = 56, closeIconColor, compact = false, @@ -120,12 +127,21 @@ export const InfoBanner: React.FC = ({ wrap="nowrap" style={{ flex: 1, minWidth: 0 }} > - + {icon != null && + (typeof icon === "string" ? ( + + ) : ( +
+ {icon} +
+ ))} {title && ( = ({ height={compact ? "0.75rem" : "0.9rem"} /> } + styles={ + buttonTextColor + ? { label: { color: buttonTextColor } } + : undefined + } > {buttonText} diff --git a/frontend/editor/src/desktop/components/ConnectionSettings.tsx b/frontend/editor/src/desktop/components/ConnectionSettings.tsx index 726004477..aec2d2bad 100644 --- a/frontend/editor/src/desktop/components/ConnectionSettings.tsx +++ b/frontend/editor/src/desktop/components/ConnectionSettings.tsx @@ -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(null); const [userInfo, setUserInfo] = useState(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(); diff --git a/frontend/editor/src/desktop/extensions/platformSessionBridge.ts b/frontend/editor/src/desktop/extensions/platformSessionBridge.ts index 8d89d5829..a01f0eeb4 100644 --- a/frontend/editor/src/desktop/extensions/platformSessionBridge.ts +++ b/frontend/editor/src/desktop/extensions/platformSessionBridge.ts @@ -15,21 +15,110 @@ export async function isDesktopSaaSAuthMode(): Promise { } } -export async function getPlatformSessionUser(): Promise { +/** + * 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 { 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 { + // 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 { try { const mode = await connectionModeService.getCurrentMode(); diff --git a/frontend/editor/src/desktop/services/connectionModeService.ts b/frontend/editor/src/desktop/services/connectionModeService.ts index 67c14f924..9d440e04e 100644 --- a/frontend/editor/src/desktop/services/connectionModeService.ts +++ b/frontend/editor/src/desktop/services/connectionModeService.ts @@ -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"); } diff --git a/frontend/editor/src/proprietary/auth/UseSession.test.ts b/frontend/editor/src/proprietary/auth/UseSession.test.ts new file mode 100644 index 000000000..86285dd6a --- /dev/null +++ b/frontend/editor/src/proprietary/auth/UseSession.test.ts @@ -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 { + return { + id: "user-1", + email: "alice@example.com", + 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: "bob@example.com" }), + t, + ), + ).toBe("bob@example.com"); + }); + + 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"); + }); +}); diff --git a/frontend/editor/src/proprietary/auth/UseSession.tsx b/frontend/editor/src/proprietary/auth/UseSession.tsx index 0162be3ff..efc846787 100644 --- a/frontend/editor/src/proprietary/auth/UseSession.tsx +++ b/frontend/editor/src/proprietary/auth/UseSession.tsx @@ -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; refreshSession: () => Promise; } +/** + * 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({ 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, diff --git a/frontend/editor/src/proprietary/auth/springAuthClient.ts b/frontend/editor/src/proprietary/auth/springAuthClient.ts index 3cfb21380..91cef2e5f 100644 --- a/frontend/editor/src/proprietary/auth/springAuthClient.ts +++ b/frontend/editor/src/proprietary/auth/springAuthClient.ts @@ -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") }, }; diff --git a/frontend/editor/src/proprietary/extensions/platformSessionBridge.ts b/frontend/editor/src/proprietary/extensions/platformSessionBridge.ts index cba00d886..f97120f41 100644 --- a/frontend/editor/src/proprietary/extensions/platformSessionBridge.ts +++ b/frontend/editor/src/proprietary/extensions/platformSessionBridge.ts @@ -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 { return false; } +/** + * Whether the currently-authoritative backend exposes `/api/v1/auth/logout` + * and should be hit during sign-out. + */ +export async function shouldCallBackendLogout(): Promise { + return true; +} + /** * Proprietary/web default: no platform user store. */ diff --git a/frontend/editor/src/proprietary/routes/Login.test.tsx b/frontend/editor/src/proprietary/routes/Login.test.tsx index f9202f706..0a4b79324 100644 --- a/frontend/editor/src/proprietary/routes/Login.test.tsx +++ b/frontend/editor/src/proprietary/routes/Login.test.tsx @@ -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(), diff --git a/frontend/editor/src/saas/auth/UseSession.test.ts b/frontend/editor/src/saas/auth/UseSession.test.ts new file mode 100644 index 000000000..d5f812494 --- /dev/null +++ b/frontend/editor/src/saas/auth/UseSession.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "vitest"; +import type { TFunction } from "i18next"; +import { deriveDisplayName, type User } from "@app/auth/UseSession"; + +// Stub t() that returns the fallback string passed to it. +const t: TFunction = ((_key: string, fallback?: string) => + fallback ?? "") as TFunction; + +// Minimal Supabase-shaped User. The real type has many more fields but +// none of them matter for displayName derivation. +function makeUser(overrides: Partial = {}): User { + return { + id: "00000000-0000-0000-0000-000000000001", + aud: "authenticated", + email: "alice@example.com", + app_metadata: {}, + user_metadata: {}, + created_at: "2026-01-01T00:00:00Z", + ...overrides, + } as User; +} + +describe("saas deriveDisplayName", () => { + it("returns null when there is no user object", () => { + expect(deriveDisplayName(null, t)).toBeNull(); + expect(deriveDisplayName(undefined, t)).toBeNull(); + }); + + it("prefers the bridged username over metadata and email", () => { + expect( + deriveDisplayName( + makeUser({ + username: "alice", + user_metadata: { full_name: "Alice Wonderland" }, + email: "alice@example.com", + }), + t, + ), + ).toBe("alice"); + }); + + it("falls back to user_metadata.full_name when username is missing", () => { + expect( + deriveDisplayName( + makeUser({ + username: undefined, + user_metadata: { full_name: "Alice Wonderland" }, + }), + t, + ), + ).toBe("Alice Wonderland"); + }); + + it("falls back to user_metadata.name when full_name is missing", () => { + expect( + deriveDisplayName( + makeUser({ + username: undefined, + user_metadata: { name: "Alice" }, + }), + t, + ), + ).toBe("Alice"); + }); + + it("falls back to email when no name fields are populated", () => { + expect( + deriveDisplayName( + makeUser({ + username: undefined, + user_metadata: {}, + email: "alice@example.com", + }), + t, + ), + ).toBe("alice@example.com"); + }); + + it("returns null when nothing identifies the user", () => { + expect( + deriveDisplayName( + makeUser({ + username: undefined, + user_metadata: {}, + email: undefined, + }), + t, + ), + ).toBeNull(); + }); + + it("returns the localised 'Guest' placeholder for anonymous users", () => { + expect( + deriveDisplayName( + makeUser({ + is_anonymous: true, + email: "anon@local", + user_metadata: { full_name: "Whatever" }, + }), + t, + ), + ).toBe("Guest"); + }); + + it("treats anonymous flag as authoritative - populated identity fields are ignored", () => { + // Anonymous Supabase sessions can carry a synthetic email; the UI + // should still see the placeholder, not the synthetic address. + expect( + deriveDisplayName( + makeUser({ + is_anonymous: true, + username: "anon-uuid", + }), + t, + ), + ).toBe("Guest"); + }); +}); diff --git a/frontend/editor/src/saas/auth/UseSession.tsx b/frontend/editor/src/saas/auth/UseSession.tsx index 744cb43fb..8f46a1833 100644 --- a/frontend/editor/src/saas/auth/UseSession.tsx +++ b/frontend/editor/src/saas/auth/UseSession.tsx @@ -6,6 +6,8 @@ import { ReactNode, useCallback, } from "react"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { supabase } from "@app/auth/supabase"; import type { Session, @@ -31,6 +33,29 @@ import { // Extend Supabase User to include optional username for compatibility export type User = SupabaseUser & { username?: string }; +/** + * Derive a display name from the Supabase user. Prefers the OAuth-provided + * full_name / name, then the email. Anonymous users get the localised + * "Guest" placeholder (SaaS's chosen label for guest 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.guest", "Guest"); + const metadata = user.user_metadata as + | { full_name?: string; name?: string } + | undefined; + return ( + user.username || metadata?.full_name || metadata?.name || user.email || null + ); +} + export interface TrialStatus { isTrialing: boolean; trialEnd: string; @@ -43,6 +68,15 @@ export interface TrialStatus { interface AuthContextType { session: Session | null; user: User | null; + /** + * Human-readable name to show in the UI for the current session. + * - A real identity (full_name / name / email) when the user is signed in. + * - The localised "Guest" placeholder for anonymous (Supabase + * `is_anonymous`) sessions - SaaS'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; creditBalance: number | null; @@ -66,6 +100,7 @@ interface AuthContextType { const AuthContext = createContext({ session: null, user: null, + displayName: null, loading: true, error: null, creditBalance: null, @@ -660,9 +695,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, creditBalance, diff --git a/frontend/editor/src/saas/components/shared/InfoBanner.tsx b/frontend/editor/src/saas/components/shared/InfoBanner.tsx deleted file mode 100644 index 2f0091999..000000000 --- a/frontend/editor/src/saas/components/shared/InfoBanner.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import React, { ReactNode } from "react"; -import { Paper, Group, Text, Button, ActionIcon, Stack } from "@mantine/core"; -import LocalIcon from "@app/components/shared/LocalIcon"; - -type InfoBannerTone = "info" | "warning"; - -const toneStyles: Record< - InfoBannerTone, - { - background: string; - border: string; - text: string; - icon: string; - buttonColor: string; - } -> = { - info: { - background: "var(--mantine-color-blue-0)", - border: "var(--mantine-color-blue-2)", - text: "var(--mantine-color-blue-9)", - icon: "var(--mantine-color-blue-6)", - buttonColor: "blue", - }, - warning: { - background: "var(--mantine-color-orange-0)", - border: "var(--mantine-color-orange-3)", - text: "var(--mantine-color-orange-9)", - icon: "var(--mantine-color-orange-7)", - buttonColor: "orange", - }, -}; - -interface InfoBannerProps { - icon?: string | ReactNode; // SaaS supports ReactNode (e.g., logo images) - title?: ReactNode; - message: ReactNode; - buttonText?: string; - buttonIcon?: string; - onButtonClick?: () => void; - onDismiss?: () => void; - dismissible?: boolean; - loading?: boolean; - show?: boolean; - tone?: InfoBannerTone; - background?: string; - borderColor?: string; - textColor?: string; - iconColor?: string; - buttonColor?: string; - buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle"; - buttonTextColor?: string; // SaaS-specific for dark theme buttons - minHeight?: number | string; - closeIconColor?: string; // SaaS-specific for dark theme - compact?: boolean; -} - -/** - * SaaS-specific info banner with enhanced theming support - * Supports ReactNode icons (e.g., logo images) and custom button text colors - */ -export const InfoBanner: React.FC = ({ - icon, - title, - message, - buttonText, - buttonIcon = "check-circle-rounded", - onButtonClick, - onDismiss, - dismissible = true, - loading = false, - show = true, - tone = "info", - background, - borderColor, - textColor, - iconColor, - buttonColor, - buttonVariant = "light", - buttonTextColor, - minHeight = 56, - closeIconColor, - compact = false, -}) => { - if (!show) { - return null; - } - - const toneStyle = toneStyles[tone] ?? toneStyles.info; - const handleDismiss = () => { - onDismiss?.(); - }; - - return ( - - - - {icon && - (typeof icon === "string" ? ( - - ) : ( -
- {icon} -
- ))} - - {title && ( - - {title} - - )} - - {message} - - -
- - {buttonText && onButtonClick && ( - - )} - {dismissible && ( - - - - )} - -
-
- ); -};