mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-17 11:45:05 +02:00
Fix username display issues (#6471)
# Description of Changes Main fixes: - Fix the display of the username in the bottom left - Now displays as "User" when not logged in on self-hosted (desktop) and "Guest" on SaaS when logged in anonymously - Now updates properly when the user logs in/out in SaaS, desktop and self-hosted - Fix incremental build issues in the desktop app that have been here since the start (I hope at least - I think the issue is that the JLink is built read-only and then on subsequent builds you get OS errors when trying to override the JLink with the new version. There's no real need for it to be read-only that I know of, so we might as well just make it R/W and ship like that)
This commit is contained in:
@@ -0,0 +1,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