mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +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-header-files
|
||||||
--no-man-pages
|
--no-man-pages
|
||||||
--output runtime/jre
|
--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:
|
status:
|
||||||
- test -d editor/src-tauri/runtime/jre
|
- test -f runtime/jre/release
|
||||||
|
|
||||||
jlink:clean:
|
jlink:clean:
|
||||||
desc: "Remove JLink runtime and bundled JARs"
|
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."
|
pleaseLoginAgain = "Please login again."
|
||||||
sessionExpired = "Session Expired"
|
sessionExpired = "Session Expired"
|
||||||
|
|
||||||
|
[auth.displayName]
|
||||||
|
guest = "Guest"
|
||||||
|
user = "User"
|
||||||
|
|
||||||
[auto-rename]
|
[auto-rename]
|
||||||
description = "Automatically finds the title from your PDF content and uses it as the filename."
|
description = "Automatically finds the title from your PDF content and uses it as the filename."
|
||||||
header = "Auto Rename PDF"
|
header = "Auto Rename PDF"
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
export interface AuthContextType {
|
export interface AuthContextType {
|
||||||
session: null;
|
session: null;
|
||||||
user: { id?: string; email?: string; [key: string]: unknown } | 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;
|
loading: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
@@ -15,6 +28,7 @@ export function useAuth(): AuthContextType {
|
|||||||
return {
|
return {
|
||||||
session: null,
|
session: null,
|
||||||
user: null,
|
user: null,
|
||||||
|
displayName: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
signOut: async () => {},
|
signOut: async () => {},
|
||||||
|
|||||||
@@ -22,11 +22,9 @@ export default function AppConfigModalLazy({
|
|||||||
if (opened) setShouldMount(true);
|
if (opened) setShouldMount(true);
|
||||||
}, [opened]);
|
}, [opened]);
|
||||||
|
|
||||||
if (!shouldMount) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<AppConfigModal opened={opened} onClose={onClose} />
|
{shouldMount && <AppConfigModal opened={opened} onClose={onClose} />}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "@app/contexts/NavigationContext";
|
} from "@app/contexts/NavigationContext";
|
||||||
import { useViewer } from "@app/contexts/ViewerContext";
|
import { useViewer } from "@app/contexts/ViewerContext";
|
||||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||||
|
import { useAuth } from "@app/auth/UseSession";
|
||||||
import {
|
import {
|
||||||
useIndexedDB,
|
useIndexedDB,
|
||||||
useIndexedDBRevision,
|
useIndexedDBRevision,
|
||||||
@@ -107,19 +108,39 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
|||||||
const { activeFileId, setActiveFileId } = useViewer();
|
const { activeFileId, setActiveFileId } = useViewer();
|
||||||
const { addFiles } = useFileHandler();
|
const { addFiles } = useFileHandler();
|
||||||
const indexedDB = useIndexedDB();
|
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(() => {
|
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
|
accountService
|
||||||
.getAccountData()
|
.getAccountData()
|
||||||
.then((data) => {
|
.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(() => {
|
.catch(() => {
|
||||||
/* not logged in or security disabled */
|
setAccountUsername(null);
|
||||||
});
|
});
|
||||||
}, [config?.enableLogin]);
|
}, [config?.enableLogin, authDisplayName]);
|
||||||
|
|
||||||
// Leaf files = user-visible files (excludes intermediate tool outputs)
|
// Leaf files = user-visible files (excludes intermediate tool outputs)
|
||||||
const [allFileStubs, setAllFileStubs] = useState<StirlingFileStub[]>([]);
|
const [allFileStubs, setAllFileStubs] = useState<StirlingFileStub[]>([]);
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ const toneStyles: Record<
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface InfoBannerProps {
|
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;
|
title?: ReactNode;
|
||||||
message: ReactNode;
|
message: ReactNode;
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
@@ -48,6 +52,8 @@ interface InfoBannerProps {
|
|||||||
iconColor?: string;
|
iconColor?: string;
|
||||||
buttonColor?: string;
|
buttonColor?: string;
|
||||||
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
|
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
|
||||||
|
/** Override the button label colour (for dark/custom theme variants). */
|
||||||
|
buttonTextColor?: string;
|
||||||
minHeight?: number | string;
|
minHeight?: number | string;
|
||||||
closeIconColor?: string;
|
closeIconColor?: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
@@ -74,6 +80,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
|||||||
iconColor,
|
iconColor,
|
||||||
buttonColor,
|
buttonColor,
|
||||||
buttonVariant = "light",
|
buttonVariant = "light",
|
||||||
|
buttonTextColor,
|
||||||
minHeight = 56,
|
minHeight = 56,
|
||||||
closeIconColor,
|
closeIconColor,
|
||||||
compact = false,
|
compact = false,
|
||||||
@@ -120,12 +127,21 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
|||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
style={{ flex: 1, minWidth: 0 }}
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
>
|
>
|
||||||
|
{icon != null &&
|
||||||
|
(typeof icon === "string" ? (
|
||||||
<LocalIcon
|
<LocalIcon
|
||||||
icon={icon}
|
icon={icon}
|
||||||
width={iconSize}
|
width={iconSize}
|
||||||
height={iconSize}
|
height={iconSize}
|
||||||
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
|
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 }}>
|
<Stack gap={compact ? 1 : 2} style={{ flex: 1, minWidth: 0 }}>
|
||||||
{title && (
|
{title && (
|
||||||
<Text
|
<Text
|
||||||
@@ -161,6 +177,11 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
|||||||
height={compact ? "0.75rem" : "0.9rem"}
|
height={compact ? "0.75rem" : "0.9rem"}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
styles={
|
||||||
|
buttonTextColor
|
||||||
|
? { label: { color: buttonTextColor } }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{buttonText}
|
{buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import {
|
|||||||
} from "@app/services/connectionModeService";
|
} from "@app/services/connectionModeService";
|
||||||
import { authService, UserInfo } from "@app/services/authService";
|
import { authService, UserInfo } from "@app/services/authService";
|
||||||
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
|
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
|
||||||
|
import { useAuth } from "@app/auth/UseSession";
|
||||||
|
|
||||||
export const ConnectionSettings: React.FC = () => {
|
export const ConnectionSettings: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { signOut } = useAuth();
|
||||||
const [config, setConfig] = useState<ConnectionConfig | null>(null);
|
const [config, setConfig] = useState<ConnectionConfig | null>(null);
|
||||||
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
|
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -43,7 +45,22 @@ export const ConnectionSettings: React.FC = () => {
|
|||||||
if (config?.mode === "selfhosted" && config?.server_config?.url) {
|
if (config?.mode === "selfhosted" && config?.server_config?.url) {
|
||||||
localStorage.setItem("server_url", 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();
|
await authService.logout();
|
||||||
|
}
|
||||||
// Always switch to local after logout so the app remains usable
|
// Always switch to local after logout so the app remains usable
|
||||||
await connectionModeService.switchToLocal();
|
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 {
|
try {
|
||||||
const userInfo = await authService.getUserInfo();
|
const mode = await connectionModeService.getCurrentMode();
|
||||||
if (!userInfo) {
|
return mode !== "saas";
|
||||||
return null;
|
} 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 {
|
} catch {
|
||||||
return null;
|
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> {
|
export async function refreshPlatformSession(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const mode = await connectionModeService.getCurrentMode();
|
const mode = await connectionModeService.getCurrentMode();
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export class ConnectionModeService {
|
|||||||
|
|
||||||
console.log("Switching to SaaS mode");
|
console.log("Switching to SaaS mode");
|
||||||
|
|
||||||
|
const previousMode = this.currentConfig?.mode ?? null;
|
||||||
const serverConfig: ServerConfig = { url: saasServerUrl };
|
const serverConfig: ServerConfig = { url: saasServerUrl };
|
||||||
|
|
||||||
await invoke("set_connection_mode", {
|
await invoke("set_connection_mode", {
|
||||||
@@ -166,6 +167,26 @@ export class ConnectionModeService {
|
|||||||
|
|
||||||
this.notifyListeners();
|
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");
|
console.log("Switched to SaaS mode successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +229,8 @@ export class ConnectionModeService {
|
|||||||
|
|
||||||
console.log("Switching to self-hosted mode:", serverConfig);
|
console.log("Switching to self-hosted mode:", serverConfig);
|
||||||
|
|
||||||
|
const previousMode = this.currentConfig?.mode ?? null;
|
||||||
|
|
||||||
await invoke("set_connection_mode", {
|
await invoke("set_connection_mode", {
|
||||||
mode: "selfhosted",
|
mode: "selfhosted",
|
||||||
serverConfig,
|
serverConfig,
|
||||||
@@ -231,6 +254,17 @@ export class ConnectionModeService {
|
|||||||
|
|
||||||
this.notifyListeners();
|
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");
|
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,
|
ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
import { springAuth } from "@app/auth/springAuthClient";
|
import { springAuth } from "@app/auth/springAuthClient";
|
||||||
import { clearPlatformAuthOnLoginInit } from "@app/extensions/authSessionCleanup";
|
import { clearPlatformAuthOnLoginInit } from "@app/extensions/authSessionCleanup";
|
||||||
import type {
|
import type {
|
||||||
@@ -22,15 +24,42 @@ import type {
|
|||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
user: User | 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;
|
loading: boolean;
|
||||||
error: AuthError | null;
|
error: AuthError | null;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
refreshSession: () => 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>({
|
const AuthContext = createContext<AuthContextType>({
|
||||||
session: null,
|
session: null,
|
||||||
user: null,
|
user: null,
|
||||||
|
displayName: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
signOut: async () => {},
|
signOut: async () => {},
|
||||||
@@ -100,15 +129,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const { error } = await springAuth.signOut();
|
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) {
|
if (error) {
|
||||||
console.error("[Auth] Sign out error:", error);
|
console.error("[Auth] Sign out error:", error);
|
||||||
setError(error);
|
setError(error);
|
||||||
} else {
|
} else {
|
||||||
console.debug("[Auth] Signed out successfully");
|
console.debug("[Auth] Signed out successfully");
|
||||||
setSession(null);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[Auth] Unexpected error during sign out:", err);
|
console.error("[Auth] Unexpected error during sign out:", err);
|
||||||
|
setSession(null);
|
||||||
setError(err as AuthError);
|
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 = {
|
const value: AuthContextType = {
|
||||||
session,
|
session,
|
||||||
user: session?.user ?? null,
|
user,
|
||||||
|
displayName: deriveDisplayName(user, t),
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
signOut,
|
signOut,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
isDesktopSaaSAuthMode,
|
isDesktopSaaSAuthMode,
|
||||||
refreshPlatformSession,
|
refreshPlatformSession,
|
||||||
savePlatformToken,
|
savePlatformToken,
|
||||||
|
shouldCallBackendLogout,
|
||||||
} from "@app/extensions/platformSessionBridge";
|
} from "@app/extensions/platformSessionBridge";
|
||||||
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
|
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
|
||||||
|
|
||||||
@@ -352,9 +353,13 @@ class SpringAuthClient {
|
|||||||
platformUser?.email ||
|
platformUser?.email ||
|
||||||
platformUser?.username ||
|
platformUser?.username ||
|
||||||
"desktop-saas-user",
|
"desktop-saas-user",
|
||||||
email: platformUser?.email || "",
|
email: platformUser?.email ?? "",
|
||||||
username: platformUser?.username || platformUser?.email || "User",
|
// 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",
|
role: "USER",
|
||||||
|
is_anonymous: platformUser?.is_anonymous,
|
||||||
},
|
},
|
||||||
access_token: token,
|
access_token: token,
|
||||||
expires_in: tokenExpiry.expiresIn,
|
expires_in: tokenExpiry.expiresIn,
|
||||||
@@ -575,6 +580,14 @@ class SpringAuthClient {
|
|||||||
"1",
|
"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, {
|
const response = await apiClient.post("/api/v1/auth/logout", null, {
|
||||||
headers: {
|
headers: {
|
||||||
"X-XSRF-TOKEN": this.getCsrfToken() || "",
|
"X-XSRF-TOKEN": this.getCsrfToken() || "",
|
||||||
@@ -585,6 +598,7 @@ class SpringAuthClient {
|
|||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
// console.debug('[SpringAuth] signOut: Success');
|
// console.debug('[SpringAuth] signOut: Success');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up local storage
|
// Clean up local storage
|
||||||
localStorage.removeItem("stirling_jwt");
|
localStorage.removeItem("stirling_jwt");
|
||||||
@@ -641,6 +655,12 @@ class SpringAuthClient {
|
|||||||
cleanupError,
|
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 {
|
return {
|
||||||
error: { message: getErrorMessage(error, "Logout failed") },
|
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 {
|
export interface PlatformSessionUser {
|
||||||
username: string;
|
username: string;
|
||||||
email?: 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;
|
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.
|
* Proprietary/web default: no platform user store.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ describe("Login", () => {
|
|||||||
vi.mocked(useAuth).mockReturnValue({
|
vi.mocked(useAuth).mockReturnValue({
|
||||||
session: null,
|
session: null,
|
||||||
user: null,
|
user: null,
|
||||||
|
displayName: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
signOut: vi.fn(),
|
signOut: vi.fn(),
|
||||||
@@ -155,6 +156,7 @@ describe("Login", () => {
|
|||||||
vi.mocked(useAuth).mockReturnValue({
|
vi.mocked(useAuth).mockReturnValue({
|
||||||
session: mockSession,
|
session: mockSession,
|
||||||
user: mockSession.user,
|
user: mockSession.user,
|
||||||
|
displayName: mockSession.user.username,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
signOut: vi.fn(),
|
signOut: vi.fn(),
|
||||||
@@ -178,6 +180,7 @@ describe("Login", () => {
|
|||||||
vi.mocked(useAuth).mockReturnValue({
|
vi.mocked(useAuth).mockReturnValue({
|
||||||
session: null,
|
session: null,
|
||||||
user: null,
|
user: null,
|
||||||
|
displayName: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
signOut: vi.fn(),
|
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,
|
ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
import { supabase } from "@app/auth/supabase";
|
import { supabase } from "@app/auth/supabase";
|
||||||
import type {
|
import type {
|
||||||
Session,
|
Session,
|
||||||
@@ -31,6 +33,29 @@ import {
|
|||||||
// Extend Supabase User to include optional username for compatibility
|
// Extend Supabase User to include optional username for compatibility
|
||||||
export type User = SupabaseUser & { username?: string };
|
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 {
|
export interface TrialStatus {
|
||||||
isTrialing: boolean;
|
isTrialing: boolean;
|
||||||
trialEnd: string;
|
trialEnd: string;
|
||||||
@@ -43,6 +68,15 @@ export interface TrialStatus {
|
|||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
user: User | 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;
|
loading: boolean;
|
||||||
error: AuthError | null;
|
error: AuthError | null;
|
||||||
creditBalance: number | null;
|
creditBalance: number | null;
|
||||||
@@ -66,6 +100,7 @@ interface AuthContextType {
|
|||||||
const AuthContext = createContext<AuthContextType>({
|
const AuthContext = createContext<AuthContextType>({
|
||||||
session: null,
|
session: null,
|
||||||
user: null,
|
user: null,
|
||||||
|
displayName: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
creditBalance: null,
|
creditBalance: null,
|
||||||
@@ -660,9 +695,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const user = session?.user ?? null;
|
||||||
const value: AuthContextType = {
|
const value: AuthContextType = {
|
||||||
session,
|
session,
|
||||||
user: session?.user ?? null,
|
user,
|
||||||
|
displayName: deriveDisplayName(user, t),
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
creditBalance,
|
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