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
@@ -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>
);
};