mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +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:
+22
-1
@@ -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/<profile>/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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<void>;
|
||||
@@ -15,6 +28,7 @@ export function useAuth(): AuthContextType {
|
||||
return {
|
||||
session: null,
|
||||
user: null,
|
||||
displayName: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
signOut: async () => {},
|
||||
|
||||
@@ -22,11 +22,9 @@ export default function AppConfigModalLazy({
|
||||
if (opened) setShouldMount(true);
|
||||
}, [opened]);
|
||||
|
||||
if (!shouldMount) return null;
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<AppConfigModal opened={opened} onClose={onClose} />
|
||||
{shouldMount && <AppConfigModal opened={opened} onClose={onClose} />}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement, FileSidebarProps>(
|
||||
const { activeFileId, setActiveFileId } = useViewer();
|
||||
const { addFiles } = useFileHandler();
|
||||
const indexedDB = useIndexedDB();
|
||||
const [displayName, setDisplayName] = useState<string>("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<string | null>(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<StirlingFileStub[]>([]);
|
||||
|
||||
@@ -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<InfoBannerProps> = ({
|
||||
iconColor,
|
||||
buttonColor,
|
||||
buttonVariant = "light",
|
||||
buttonTextColor,
|
||||
minHeight = 56,
|
||||
closeIconColor,
|
||||
compact = false,
|
||||
@@ -120,12 +127,21 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
wrap="nowrap"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{icon != null &&
|
||||
(typeof icon === "string" ? (
|
||||
<LocalIcon
|
||||
icon={icon}
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{ flexShrink: 0, display: "flex", alignItems: "center" }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
))}
|
||||
<Stack gap={compact ? 1 : 2} style={{ flex: 1, minWidth: 0 }}>
|
||||
{title && (
|
||||
<Text
|
||||
@@ -161,6 +177,11 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
height={compact ? "0.75rem" : "0.9rem"}
|
||||
/>
|
||||
}
|
||||
styles={
|
||||
buttonTextColor
|
||||
? { label: { color: buttonTextColor } }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
return {
|
||||
username: userInfo.username,
|
||||
email: userInfo.email,
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +580,14 @@ class SpringAuthClient {
|
||||
"1",
|
||||
);
|
||||
}
|
||||
|
||||
// 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() || "",
|
||||
@@ -585,6 +598,7 @@ class SpringAuthClient {
|
||||
if (response.status === 200) {
|
||||
// console.debug('[SpringAuth] signOut: Success');
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up local storage
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
@@ -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(),
|
||||
|
||||
@@ -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> = {}): User {
|
||||
return {
|
||||
id: "00000000-0000-0000-0000-000000000001",
|
||||
aud: "authenticated",
|
||||
email: "[email protected]",
|
||||
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: "[email protected]",
|
||||
}),
|
||||
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: "[email protected]",
|
||||
}),
|
||||
t,
|
||||
),
|
||||
).toBe("[email protected]");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<AuthContextType>({
|
||||
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,
|
||||
|
||||
@@ -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<InfoBannerProps> = ({
|
||||
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 (
|
||||
<Paper
|
||||
p={compact ? "xs" : "sm"}
|
||||
radius={0}
|
||||
style={{
|
||||
background: background ?? toneStyle.background,
|
||||
borderBottom: `1px solid ${borderColor ?? toneStyle.border}`,
|
||||
minHeight,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
gap="sm"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
justify="space-between"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Group
|
||||
gap="sm"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{icon &&
|
||||
(typeof icon === "string" ? (
|
||||
<LocalIcon
|
||||
icon={icon}
|
||||
width="1.2rem"
|
||||
height="1.2rem"
|
||||
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{ flexShrink: 0, display: "flex", alignItems: "center" }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
))}
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
{title && (
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
style={{ color: textColor ?? toneStyle.text }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
fw={title ? 400 : 500}
|
||||
size="sm"
|
||||
style={{ color: textColor ?? toneStyle.text }}
|
||||
lineClamp={2}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
{buttonText && onButtonClick && (
|
||||
<Button
|
||||
variant={buttonVariant}
|
||||
color={buttonColor ?? toneStyle.buttonColor}
|
||||
size="xs"
|
||||
onClick={onButtonClick}
|
||||
loading={loading}
|
||||
leftSection={
|
||||
<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />
|
||||
}
|
||||
styles={
|
||||
buttonTextColor
|
||||
? {
|
||||
label: {
|
||||
color: buttonTextColor,
|
||||
},
|
||||
}
|
||||
: buttonVariant !== "white" && buttonVariant !== "filled"
|
||||
? {
|
||||
label: {
|
||||
color: textColor ?? toneStyle.text,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
{dismissible && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={closeIconColor ? undefined : "gray"}
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss"
|
||||
style={closeIconColor ? { color: closeIconColor } : undefined}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user