Fix username display issues (#6471)

# Description of Changes
Main fixes:
- Fix the display of the username in the bottom left
- Now displays as "User" when not logged in on self-hosted (desktop) and
"Guest" on SaaS when logged in anonymously
- Now updates properly when the user logs in/out in SaaS, desktop and
self-hosted
- Fix incremental build issues in the desktop app that have been here
since the start (I hope at least - I think the issue is that the JLink
is built read-only and then on subsequent builds you get OS errors when
trying to override the JLink with the new version. There's no real need
for it to be read-only that I know of, so we might as well just make it
R/W and ship like that)
This commit is contained in:
James Brunton
2026-05-29 14:35:47 +00:00
committed by GitHub
parent 83ea07ed6a
commit 4d5eeb103f
17 changed files with 561 additions and 238 deletions
+22 -1
View File
@@ -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");
});
});
+39 -1
View File
@@ -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>
);
};