Consolidate the editor + processor sidebar footers into one component (#7539)

## What

Both sidebars ended in a different bottom section. The editor showed an
account row (avatar, name, settings); the processor showed a "Link
Stirling account" CTA plus a `Settings` nav item and no identity at all.
They are now **one shared `<NavFooter>`** rendering the same rows in
both apps, in this order:

1. the link-account CTA (self-hosted, when unlinked)
2. free credits remaining
3. **Open \<the other app\>**
4. the account row — avatar, name, settings

It's a **single surface** with hairline dividers between rows, not
stacked cards. Rows are assembled as a list, so a row this build doesn't
show (no wallet, no processor access, nothing to link) takes its divider
with it rather than leaving a stray line.

This also fixes the profile-picture/initials desync between the sidebar
and the account settings page.

## Screenshots

Captured with the stubbed Playwright harness at 1600x900, scoped to the
sidebar and auto-cropped to the region that actually changed. Base is
`origin/main`; every state is driven by dummy backend stubs so all the
nav-bar permutations are covered.

<img width="2104" height="3044" alt="montage_cloud-dark"
src="https://github.com/user-attachments/assets/667c9a71-3ab7-4582-9259-08cda521e238"
/>
<img width="2104" height="3044" alt="montage_cloud-light"
src="https://github.com/user-attachments/assets/126bc994-efa3-436b-bf50-31d76f574eaa"
/>
<img width="2104" height="1492" alt="montage_editor-dark"
src="https://github.com/user-attachments/assets/c725726d-ea90-4fa9-bf0a-6014f3729869"
/>
<img width="2104" height="1490" alt="montage_editor-light"
src="https://github.com/user-attachments/assets/d0d34e0a-ac60-4f8c-af15-afb66fbd679e"
/>
<img width="2104" height="1066" alt="montage_processor-dark"
src="https://github.com/user-attachments/assets/51ccfe63-c327-41f4-ab91-74555b6f7148"
/>
<img width="2104" height="1068" alt="montage_processor-light"
src="https://github.com/user-attachments/assets/c90d7aba-90fa-4998-ac8f-26ce666dde71"
/>


The free-credits meter is a cloud-build surface, so the self-hosted
capture can't reach it. Those states come from the new Storybook stories
with dummy wallet data (`Shared/NavFooter`), which is also where the
credit tone bands and the collapsed rail are easiest to review.

## How it's wired

`NavFooter` is purely presentational. Each app resolves its own data
through three `@app/*` seams, so core carries no build-specific gating
and any box whose data is absent is dropped rather than rendered empty.

| Seam | core | cloud / proprietary / saas |
|---|---|---|
| `useFreeCreditsSummary` | `null` — self-hosted editor installs aren't
metered | cloud reads `freeRemaining` / `freeAllowance` off the same
`useWallet()` the Plan page's free meter uses, so the sidebar and Plan
can't disagree |
| `useOtherAppSwitch` | `null` — core ships no processor | gated on
`portalAccess` (`/api/v1/auth/me` in SaaS, the Spring session flag
self-hosted) |
| Link-account CTA | n/a | unchanged conditions — passed in as
`accountExtras`, still only when `linkState === "unlinked"`, still a
no-op in SaaS |

- The processor reads the meter through its own
`@portal/hooks/useFreeCreditsSummary` rather than the editor's `@app`
one. Self-hosted resolves `@app/*` as proprietary → core, where the
cloud wallet hook isn't in the cascade, and the implementation can't
live in `proprietary/` because core/desktop builds ship no portal and
must never resolve `@portal`. Keeping it in `portal/` gets the figure to
the linked self-hosted processor without weakening that rule; it reads
the same `GET /api/v1/payg/wallet` the Usage page's trial meter already
renders, gated on link state and behind the portal's query cache.
`portal-saas/` just re-exports the cloud hook, so both footers share one
fetch.

The processor-access gate previously lived in two near-identical
`AppSwitcher` copies. It moves into `useOtherAppSwitch`, `AppSwitcher`
now reads it too, and the duplicate
`saas/components/shared/AppSwitcher.tsx` is deleted — the logo switcher
and the footer row can no longer disagree about access.

## Profile picture sync

One `useAccountIdentity` hook now backs the editor footer, the processor
footer and the account settings page. Previously settings derived its
initial from `email[0]` while the sidebar used `displayName[0]`, and the
two drew different blue discs. Alongside that, the shared `Avatar`:

- falls back to initials when a picture URL fails to load, instead of
leaving an empty disc
- renders one letter for single-word names (`admin` → "A", not "AD")
- gains an `xl` size so the settings hero disc is the same component

## Notes

- Labelled **"Free credits"** rather than "free monthly credits":
`freeAllowance` is documented as a one-time lifetime grant, not a
monthly reset, so "monthly" would misdescribe the data. Happy to change
if the backend semantics differ from the type comments.

## Testing

- `task frontend:check` and `task frontend:typecheck:all` pass (all 9
build variants).
- 9 new `Shared/NavFooter` stories pass the Chromium + axe story scan;
`frontend:storybook:a11y:changed` reports no regressions.
- Stubbed E2E suite passes, including the `config-button` tour/settings
specs that target the account row. Two failures (`console-clean ›
landing`, `viewer-text-selection › Ctrl+C`) also fail on `origin/main`
locally — they need a backend on :8080 and clipboard permissions.
This commit is contained in:
EthanHealy01
2026-08-18 14:00:43 +00:00
committed by GitHub
parent cf49742d97
commit 913601ff03
42 changed files with 1804 additions and 353 deletions
@@ -5057,6 +5057,14 @@ title = "Upload from Mobile"
tags = "Multi Tool,Multi operation,UI,click drag,front end,client side,interactive,intractable,move,delete,migrate,divide"
title = "PDF Multi Tool"
[navFooter]
openEditor = "Open PDF Editor"
openProcessor = "Open PDF Processor"
[navFooter.credits]
count = "{{remaining}} of {{total}}"
label = "Free credits"
[oauth.error]
message = "Authentication was not successful. You can close this window and try again."
title = "Authentication Failed"
@@ -5621,8 +5629,8 @@ freeBody = "View, edit, merge, split, sign, watermark, compress, convert and man
freeTitle = "Unlimited PDF editing"
[payg.free.hero]
barAria = "Free PDFs used"
capSuffix = "/ {{limit}} free PDFs"
barAria = "Free PDFs remaining"
capSuffix = "of {{limit}} free PDFs left"
metaCategories = "Automation · AI · API requests"
[payg.free.member]
@@ -6685,12 +6693,12 @@ reachedTitle = "Monthly spend limit reached"
title = "Couldn't open Stripe portal"
[portal.billing.walletMeter]
barAria = "Free PDFs used"
capSuffix_one = "of {{allowance}} free PDFs used"
capSuffix_other = "of {{allowance}} free PDFs used"
barAria = "Free PDFs remaining"
capSuffix_one = "of {{allowance}} free PDF left"
capSuffix_other = "of {{allowance}} free PDFs left"
eyebrow = "Processor trial"
statusLabel_one = "{{remaining}} left"
statusLabel_other = "{{remaining}} left"
statusLabel_one = "{{used}} used"
statusLabel_other = "{{used}} used"
sub = "Use the PDF Editor for free. Pay to process PDFs automatically."
title_one = "Process {{allowance}} PDFs free"
title_other = "Process {{allowance}} PDFs free"
@@ -7665,7 +7673,6 @@ integrations = "Integrations"
pipelines = "Pipelines"
policies = "Policies"
procurement = "Procurement"
settings = "Settings"
sources = "Sources"
usage = "Usage & Billing"
users = "Users"
@@ -12,6 +12,7 @@ import {
formatPeriodDate,
MeterBar,
meterState,
remainingMeter,
} from "@app/billing";
import "@app/components/shared/config/configSections/Payg.css";
import "@app/components/shared/config/configSections/PaygFree.css";
@@ -48,7 +49,8 @@ export function useFreeSnapshot(): FreeSnapshot {
export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
const { t } = useTranslation();
const { state, pct } = meterState(snap.billableUsed, snap.billableLimit);
const remaining = Math.max(0, snap.billableLimit - snap.billableUsed);
const { state, pct } = remainingMeter(remaining, snap.billableLimit);
const stateLabel =
state === "DEGRADED"
? t("payg.free.state.limitReached", "Limit reached")
@@ -60,9 +62,9 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
<MeterBar
state={state}
pct={pct}
barLabel={t("payg.free.hero.barAria", "Free PDFs used")}
figure={snap.billableUsed.toLocaleString()}
capSuffix={t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
barLabel={t("payg.free.hero.barAria", "Free PDFs remaining")}
figure={remaining.toLocaleString()}
capSuffix={t("payg.free.hero.capSuffix", "of {{limit}} free PDFs left", {
limit: snap.billableLimit.toLocaleString(),
})}
statusLabel={stateLabel}
@@ -175,15 +177,15 @@ export function prepaidSnapshotFromWallet(
}
/**
* Prepaid capacity meter. The bar fills as the pool is drawn down ({@code used =
* total remaining}), so it WARNs when the pool is running low and DEGRADEs once
* exhausted — same bands as the free/cap meters. Prepaid is consumed ahead of the
* meter and outside the spend cap, so it reads as its own dimension.
* Prepaid capacity meter. The bar shows what is left and drains towards empty,
* while the bands still key on what is gone ({@code used = total remaining}), so
* it WARNs when the pool is running low and DEGRADEs once exhausted — same bands
* as the free/cap meters. Prepaid is consumed ahead of the meter and outside the
* spend cap, so it reads as its own dimension.
*/
export function PrepaidCapacityMeterPanel({ snap }: { snap: PrepaidSnapshot }) {
const { t } = useTranslation();
const used = Math.max(0, snap.total - snap.remaining);
const { state, pct } = meterState(used, snap.total);
const { state, pct } = remainingMeter(snap.remaining, snap.total);
const stateLabel =
state === "DEGRADED"
? t("payg.prepaid.state.exhausted", "Used up")
@@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import { useWallet } from "@app/hooks/useWallet";
import {
readCachedCredits,
writeCachedCredits,
type CachedCredits,
} from "@app/services/navFooterCache";
import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow";
/** The wallet reduced to what the footer shows: figures, or null for a payer. */
function toCredits(
status: string,
freeRemaining: number,
freeAllowance: number,
): CachedCredits {
// Free teams only. The grant is a lifetime pool that survives subscribing, so
// a paying team would otherwise sit on a permanent "0 of 500" in red while
// nothing is wrong. Plan draws the same line — subscribed teams get the
// spend-vs-cap meter there, and admins get usage in the processor.
if (status === "subscribed") return null;
return { remaining: freeRemaining, total: freeAllowance };
}
/**
* Cloud builds read the free grant off the live wallet — the same snapshot the
* Plan page's free meter renders, so the sidebar and Plan can't disagree.
*
* Seeded from the last figures this browser saw, so the row is right at first
* paint and stays put while the wallet refetches underneath. Only a browser
* that has never loaded a wallet has nothing to show, and that one time the row
* animates in. The seed is read once, at first render: later reads would fight
* the live value, and the whole point is that the row stops moving.
*/
export function useFreeCreditsSummary(): NavFooterCredits | null {
const { wallet } = useWallet();
const [seed] = useState(readCachedCredits);
const live = wallet
? toCredits(wallet.status, wallet.freeRemaining, wallet.freeAllowance)
: undefined;
// useWallet reuses the snapshot reference when nothing changed, so keying on
// it writes only on a real change, not on every render.
useEffect(() => {
if (live !== undefined) writeCachedCredits(live);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wallet]);
return (live !== undefined ? live : seed) ?? null;
}
@@ -0,0 +1,13 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
/**
* Cloud editor builds open the settings modal on its Plan section, which is
* where the free grant is explained and the Processor plan is switched on.
* Routed rather than called directly because the modal is URL-driven here
* (`/settings/*`), the same path the admin tour uses to open it.
*/
export function useOpenPlan(): (() => void) | null {
const navigate = useNavigate();
return useCallback(() => navigate("/settings/plan"), [navigate]);
}
+86 -4
View File
@@ -32,6 +32,14 @@
* promise see the UI flip exactly once the new state is visible — no
* intermediate flash of the old value.
*
* <h2>Freshness</h2>
*
* The figures drain as metered work runs, so a mounted consumer re-reads the
* wallet every {@link WALLET_POLL_MS} and again whenever the tab regains
* visibility. Those refreshes are silent — they leave {@code loading} and
* {@code error} alone and only commit fresher data — so consumers that gate on
* those flags don't flicker on a background tick.
*
* <h2>Dev preview fallback</h2>
*
* When the hook is rendered outside the saas app (e.g. on {@code
@@ -178,6 +186,13 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet {
return prev;
}
/**
* How often a mounted consumer re-reads the wallet. Matches the app query
* client's staleTime, so the sidebar meter and anything cached elsewhere age
* out on the same clock.
*/
const WALLET_POLL_MS = 30_000;
export function useWallet(): UseWalletResult {
// Resolved once: the dev-preview side-channel when rendered outside the real
// app (saas /dev/payg-preview route), else null (every real build + desktop).
@@ -201,13 +216,29 @@ export function useWallet(): UseWalletResult {
// "the request fired." Cleared when no load is pending.
const inFlight = useRef<Promise<void> | null>(null);
// Set for refreshes the user didn't ask for (the poll below). Silence governs
// whether a load may RAISE `loading` / `error`, never whether it may clear
// them: consumers gate on both — the limit modals do
// `if (loading || !wallet) return null`, and Plan swaps in an error alert —
// so a background tick must not blink an open modal out or replace a working
// page over a transient failure. Clearing is always the latest request's job,
// silent or not; a silent load that skipped the clear would strand `loading`
// true after superseding a visible one, which suppresses those modals for the
// rest of the session.
const silentRefresh = useRef(false);
useEffect(() => {
const reqId = ++latestReqId.current;
let cancelled = false;
const silent = silentRefresh.current;
silentRefresh.current = false;
const promise = (async () => {
setLoading(true);
setError(null);
if (!silent) {
setLoading(true);
setError(null);
}
if (devPreview) {
const synth = devPreview.buildWallet(devPreview.role());
@@ -221,11 +252,22 @@ export function useWallet(): UseWalletResult {
const res = await apiClient.get<Wallet>("/api/v1/payg/wallet");
if (cancelled || reqId !== latestReqId.current) return;
setWallet((prev) => reuseIfEqual(prev, res.data));
// Fresh data retires any earlier failure, including one a silent poll
// is recovering from — otherwise Plan keeps its alert over good data.
setError(null);
} catch (e: unknown) {
if (cancelled || reqId !== latestReqId.current) return;
console.warn("[useWallet] fetch failed", e);
setError(e instanceof Error ? e.message : "Failed to load wallet");
if (!silent) {
console.warn("[useWallet] fetch failed", e);
setError(e instanceof Error ? e.message : "Failed to load wallet");
}
// A failed background refresh is a non-event: the last good snapshot
// stands and the next tick self-heals, so it neither surfaces nor
// logs — otherwise an offline tab warns every WALLET_POLL_MS.
} finally {
// Deliberately not gated on `silent`: whichever load is latest owns
// settling the flag, or a silent refresh that supersedes a visible one
// leaves it stuck true.
if (!cancelled && reqId === latestReqId.current) {
setLoading(false);
}
@@ -242,6 +284,46 @@ export function useWallet(): UseWalletResult {
};
}, [devPreview, refetchTick]);
// The wallet drains as automation, AI and API work runs, so a figure fetched
// on mount goes stale while the user watches it. Refresh on a timer, and
// immediately on returning to the tab — coming back to a stale number is the
// case people actually notice. Hidden tabs don't poll, and the dev-preview
// wallet is synthesised locally so there is nothing to re-read.
useEffect(() => {
if (devPreview) return;
let timer: ReturnType<typeof setInterval> | undefined;
const refresh = () => {
silentRefresh.current = true;
setRefetchTick((t) => t + 1);
};
const stop = () => {
if (timer !== undefined) {
clearInterval(timer);
timer = undefined;
}
};
const start = () => {
stop();
timer = setInterval(refresh, WALLET_POLL_MS);
};
const onVisibilityChange = () => {
if (document.visibilityState === "visible") {
refresh();
start();
} else {
stop();
}
};
if (document.visibilityState === "visible") start();
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [devPreview]);
const refetch = useCallback(async () => {
setRefetchTick((t) => t + 1);
// Snapshot the next-tick promise so the caller awaits this refetch
@@ -48,9 +48,55 @@
transform: matrix(0.483871, -0.017568, 0, 0.338028, 23.887097, 26.886428);
}
/* One-shot "thinking" drift — the two parallelograms swap past each other and
settle back. Same motion the chat FAB loops while the agent works, but this
pair starts and ends at rest (translate 0, full opacity) so a single
iteration can end without snapping. Callers apply it for one beat; see
NavFooter.css for the hover use. */
@keyframes sui-brandmark-drift-a {
0%,
100% {
transform: translate(0, 0);
opacity: 1;
}
25% {
transform: translate(-1px, -5px);
opacity: 0.55;
}
50% {
transform: translate(-6px, 0);
opacity: 0.9;
}
75% {
transform: translate(-1px, 5px);
opacity: 0.6;
}
}
@keyframes sui-brandmark-drift-b {
0%,
100% {
transform: translate(0, 0);
opacity: 1;
}
25% {
transform: translate(1px, 5px);
opacity: 0.85;
}
50% {
transform: translate(6px, 0);
opacity: 0.5;
}
75% {
transform: translate(1px, -5px);
opacity: 0.85;
}
}
@media (prefers-reduced-motion: reduce) {
.sui-brandmark__a,
.sui-brandmark__b {
transition: none;
animation: none;
}
}
@@ -75,16 +75,13 @@
padding: 0.25rem 0;
overflow: hidden;
}
.file-sidebar-footer-box {
padding: 0.25rem 0;
flex-shrink: 0;
}
/* The footer is the shared <NavFooter>: it brings its own boxes and padding,
so this class only positions it in the column. */
/* Collapsed rail: the file tree isn't rendered, so hide its (empty) box and
let the boxes stack at the top — controls, then the settings footer right
after — instead of the files box stretching to fill. */
.file-sidebar[data-collapsed="true"] .file-sidebar-controls,
.file-sidebar[data-collapsed="true"] .file-sidebar-footer-box {
.file-sidebar[data-collapsed="true"] .file-sidebar-controls {
padding: 0.25rem;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-files-box {
@@ -538,86 +535,3 @@
pointer-events: none;
animation: none;
}
/* ---- Bottom bar (user + settings) ---- */
.file-sidebar-bottom-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
flex-shrink: 0;
min-height: 40px;
}
/* Bottom bar settings icon tracks the right edge during collapse animation */
.file-sidebar-bottom-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background-color: var(--c-accent-text);
color: var(--c-text-on-primary);
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
user-select: none;
overflow: hidden;
}
/* No colored disc behind an actual photo; keep it for the initials fallback. */
.file-sidebar-bottom-avatar--picture {
background-color: transparent;
}
.file-sidebar-bottom-avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.file-sidebar-bottom-name {
flex: 1;
font-size: 13px;
font-weight: 500;
color: var(--c-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.file-sidebar-bottom-bar[role="button"]:hover {
background-color: var(--c-hover);
}
.file-sidebar-bottom-bar[role="button"]:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: -2px;
}
.file-sidebar-bottom-settings {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--c-text-subtle);
padding: 0;
flex-shrink: 0;
margin-left: auto;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-settings {
width: 32px;
height: 32px;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-bar {
justify-content: center;
padding: 8px 0;
}
@@ -22,13 +22,15 @@ import {
} from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useAuth } from "@app/auth/UseSession";
import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl";
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
import { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary";
import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch";
import { useOpenPlan } from "@app/hooks/useOpenPlan";
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
import {
useIndexedDB,
useIndexedDBRevision,
} from "@app/contexts/IndexedDBContext";
import { accountService } from "@app/services/accountService";
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
@@ -37,8 +39,7 @@ import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import AddIcon from "@mui/icons-material/Add";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import SettingsIcon from "@mui/icons-material/Settings";
import OpenInFullIcon from "@mui/icons-material/OpenInFull";
import type { FileId } from "@app/types/file";
import { FileItem } from "@app/components/shared/FileSidebarFileItem";
import { useLabelName } from "@app/data/labelDisplay";
@@ -241,43 +242,11 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
const { addFiles } = useFileHandler();
const indexedDB = useIndexedDB();
// 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, isAnonymous } = useAuth();
const [accountUsername, setAccountUsername] = useState<string | null>(null);
const displayName =
authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User");
const profilePictureUrl = useProfilePictureUrl();
const [pictureFailed, setPictureFailed] = useState(false);
useEffect(() => setPictureFailed(false), [profilePictureUrl]);
const showProfilePicture = !!profilePictureUrl && !pictureFailed;
useEffect(() => {
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) => {
// 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(() => {
setAccountUsername(null);
});
}, [config?.enableLogin, authDisplayName]);
const { displayName, profilePictureUrl, isAnonymous } =
useAccountIdentity();
const credits = useFreeCreditsSummary();
const otherApp = useOtherAppSwitch();
const openPlan = useOpenPlan();
// Leaf files = user-visible files (excludes intermediate tool outputs)
const [allFileStubs, setAllFileStubs] = useState<StirlingFileStub[]>([]);
@@ -1115,7 +1084,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
)}
data-testid="open-files-page"
>
<OpenInNewIcon sx={{ fontSize: "1rem" }} />
<OpenInFullIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
<ActionIcon
variant="quiet"
@@ -1264,70 +1233,17 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
{/* Getting-started checklist, floating above the footer (SaaS only). */}
<SidebarChecklistSlot collapsed={collapsed} />
{/* Box 3 — account footer (avatar + name + settings). */}
<NavSurface className="file-sidebar-footer-box">
{/* Bottom bar: user name + settings */}
<Tooltip
label={
onOpenSettings
? `${displayName} - ${t("fileSidebar.openSettings", "Open settings")}`
: displayName
}
position="right"
withinPortal
disabled={!collapsed}
>
<div
className="file-sidebar-bottom-bar"
onClick={onOpenSettings}
role={onOpenSettings ? "button" : undefined}
tabIndex={onOpenSettings ? 0 : undefined}
onKeyDown={
onOpenSettings
? (e) => e.key === "Enter" && onOpenSettings()
: undefined
}
data-testid={onOpenSettings ? "config-button" : undefined}
data-tour={onOpenSettings ? "config-button" : undefined}
aria-label={
onOpenSettings
? t("fileSidebar.openSettings", "Open settings")
: displayName
}
style={onOpenSettings ? { cursor: "pointer" } : undefined}
>
<div
className={`file-sidebar-bottom-avatar${
showProfilePicture
? " file-sidebar-bottom-avatar--picture"
: ""
}`}
aria-label={displayName}
>
{showProfilePicture ? (
<img
src={profilePictureUrl}
alt=""
className="file-sidebar-bottom-avatar-img"
onError={() => setPictureFailed(true)}
/>
) : (
displayName.charAt(0).toUpperCase()
)}
</div>
{!collapsed && (
<span className="file-sidebar-bottom-name sidebar-content-fade">
{displayName}
</span>
)}
{onOpenSettings && !collapsed && (
<div className="file-sidebar-bottom-settings">
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
</div>
)}
</div>
</Tooltip>
</NavSurface>
{/* Box 3 — the shared footer: credits, app switch, account row. */}
<NavFooter
className="file-sidebar-footer-box"
displayName={displayName}
profilePictureUrl={profilePictureUrl}
onOpenSettings={onOpenSettings}
credits={credits}
onOpenPlan={openPlan ?? undefined}
otherApp={otherApp}
collapsed={collapsed}
/>
</div>
);
},
@@ -0,0 +1,156 @@
/* Shared sidebar footer: one surface holding the link-account CTA, the credits
meter, the other-app switch and the account row, hairline-separated.
Structural only — every colour comes from a --c-* semantic token. */
.nav-footer {
display: flex;
flex-direction: column;
flex-shrink: 0;
/* Vertical only: the slots carry the horizontal padding so their separator
runs the full width of the surface. */
padding: 0.25rem 0;
overflow: hidden;
}
.nav-footer__slot {
padding-inline: 0.375rem;
}
/* Separators are drawn by the slots themselves, never as their own elements.
A slot whose contents render nothing (the link-account CTA returns null once
the org is linked, and an element is truthy even when it renders null) is
:empty, so it is skipped by both rules below — it can't leave a line behind,
and it can't push one to the top or bottom of the surface. A rule that only
ever matches a slot PRECEDED by another visible slot cannot draw a leading
separator, whatever the caller passes in. */
.nav-footer__slot:empty {
display: none;
}
.nav-footer__slot:not(:empty) ~ .nav-footer__slot:not(:empty) {
border-top: 1px solid var(--c-border-subtle);
margin-top: 0.25rem;
padding-top: 0.25rem;
}
/* Fades the rows up on the first footer mount of a page session only. They are
seeded from cache, so they're already present at first paint; replaying this
on every later mount (switching apps, remounting a view) would animate
content that never changed and read as a twitch. */
@keyframes nav-footer-row-in {
from {
opacity: 0;
transform: translateY(0.25rem);
}
to {
opacity: 1;
transform: none;
}
}
.nav-footer[data-animate] .nav-footer__slot:not(:empty) {
animation: nav-footer-row-in var(--motion-enter) both;
}
@media (prefers-reduced-motion: reduce) {
.nav-footer[data-animate] .nav-footer__slot:not(:empty) {
animation: none;
}
}
/* ---- Rows (link-account, credits, switch, account) ---- */
.nav-footer__row {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
min-height: 2.25rem;
padding: 0.25rem 0.375rem;
border: 0;
border-radius: 0.5rem;
background: none;
color: var(--c-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.nav-footer__row:disabled {
cursor: default;
}
.nav-footer__row:not(:disabled):hover {
background-color: var(--c-hover);
}
.nav-footer__row:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: -2px;
}
.nav-footer__row-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.625rem;
}
/* Hovering the switch row plays the mark's "thinking" drift once — the same
motion the chat FAB loops, for a single beat, as a hint that the row hands
off to the other app. One iteration only: it starts and ends at rest, so
nothing snaps when it finishes, and re-entering the row replays it. */
.nav-footer__row:hover .sui-brandmark__a {
animation: sui-brandmark-drift-a 1.1s ease-in-out 1;
}
.nav-footer__row:hover .sui-brandmark__b {
animation: sui-brandmark-drift-b 1.1s ease-in-out 1;
}
.nav-footer__row-label {
flex: 1;
min-width: 0;
font-size: 0.8125rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Trailing affordance on a row: the account row's gear, the switch row's
leaving-this-app arrow. */
.nav-footer__trailing {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: auto;
color: var(--c-text-subtle);
}
/* Rows contributed by a caller (the link-account NavItem) sit in the same
surface, so match this footer's row metrics rather than the nav rail's. */
.nav-footer .sui-navitem {
min-height: 2.25rem;
padding: 0.25rem 0.375rem;
margin: 0;
border-radius: 0.5rem;
font-size: 0.8125rem;
}
/* ---- Collapsed icon rail ---- */
.nav-footer[data-collapsed] .nav-footer__slot {
padding-inline: 0.25rem;
}
.nav-footer[data-collapsed] .nav-footer__row {
justify-content: center;
padding-inline: 0;
}
.nav-footer[data-collapsed] .sui-navitem {
justify-content: center;
padding-inline: 0;
}
@@ -0,0 +1,119 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import LinkIcon from "@mui/icons-material/Link";
import { NavItem } from "@app/ui/NavItem";
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
/** Stands in for a CTA that has decided it has nothing to show. */
function RendersNothing() {
return null;
}
const meta: Meta<typeof NavFooter> = {
title: "Shared/NavFooter",
component: NavFooter,
parameters: { layout: "padded" },
args: {
displayName: "admin",
onOpenSettings: () => {},
credits: { remaining: 247, total: 500 },
onOpenPlan: () => {},
otherApp: { app: "processor", onOpen: () => {} },
},
decorators: [
(S) => (
<div
style={{
width: "16.25rem",
background: "var(--c-bg)",
padding: "0.5rem",
}}
>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof NavFooter>;
/** The editor's footer: credits, "Open PDF Processor", the account row. */
export const InEditor: Story = {};
/** The processor's footer. Same three boxes, opposite switch target. */
export const InProcessor: Story = {
args: { otherApp: { app: "editor", onOpen: () => {} } },
};
/** Self-hosted processor: no wallet, so no meter, and the link-account CTA
* rides along in the account box. */
export const WithLinkAccountCta: Story = {
args: {
credits: null,
otherApp: { app: "editor", onOpen: () => {} },
accountExtras: (
<NavItem
id="account-link"
label="Link Stirling account"
icon={<LinkIcon sx={{ fontSize: "1.1rem" }} />}
/>
),
},
};
/** Regression guard: the processor always passes its link-account CTA, but that
* component renders null once the org is linked. An element is truthy even
* when it renders nothing, so this must not leave a separator above the first
* visible row. */
export const ExtrasThatRenderNothing: Story = {
args: { accountExtras: <RendersNothing /> },
};
/** A real profile picture replaces the initials disc. */
export const WithProfilePicture: Story = {
args: {
displayName: "Ada Lovelace",
profilePictureUrl:
"data:image/svg+xml;utf8," +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="black"/><circle cx="32" cy="24" r="12" fill="white"/><ellipse cx="32" cy="56" rx="20" ry="16" fill="white"/></svg>',
),
},
};
/** Credits running low — the dot and bar shift to the warning tone at 20% left. */
export const CreditsLow: Story = {
args: { credits: { remaining: 42, total: 500 } },
};
/** Allowance exhausted. */
export const CreditsExhausted: Story = {
args: { credits: { remaining: 0, total: 500 } },
};
/** Core OSS: no wallet, no second app, settings only. */
export const MinimalBuild: Story = {
args: { credits: null, otherApp: null },
};
/** No settings handler — the account row is inert identity, not a button. */
export const NoSettings: Story = {
args: { onOpenSettings: undefined },
};
/** Collapsed icon rail: labels become tooltips. */
export const Collapsed: Story = {
args: { collapsed: true },
decorators: [
(S) => (
<div
style={{
width: "3.5rem",
background: "var(--c-bg)",
padding: "0.5rem",
}}
>
<S />
</div>
),
],
};
@@ -0,0 +1,58 @@
import { describe, it, expect } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
/** The footer's tooltips need Mantine's theme context. */
function withProviders(ui: React.ReactNode) {
return <MantineProvider>{ui}</MantineProvider>;
}
function renderFooter() {
const { container } = render(
withProviders(
<NavFooter
displayName="admin"
onOpenSettings={() => {}}
credits={{ remaining: 247, total: 500 }}
otherApp={{ app: "processor", onOpen: () => {} }}
/>,
),
);
return container.querySelector(".nav-footer") as HTMLElement;
}
describe("NavFooter — enter animation", () => {
it("plays once per page session, not on every remount", () => {
// The rows are seeded from cache, so they're present at first paint. Every
// later mount — switching apps, remounting a view — would otherwise replay
// the fade on content that never changed, which reads as a twitch.
expect(renderFooter().dataset.animate).toBe("true");
cleanup();
expect(renderFooter().dataset.animate).toBeUndefined();
cleanup();
expect(renderFooter().dataset.animate).toBeUndefined();
});
});
describe("NavFooter — separators", () => {
it("never renders a divider beside a row that renders nothing", () => {
// Dividers are CSS between adjacent non-empty slots, so an extras element
// that returns null (the linked org's link-account CTA) can't leave a line.
const { container } = render(
withProviders(
<NavFooter
displayName="admin"
onOpenSettings={() => {}}
credits={null}
otherApp={null}
accountExtras={<>{null}</>}
/>,
),
);
const slots = container.querySelectorAll(".nav-footer__slot");
const filled = [...slots].filter((s) => s.childElementCount > 0);
expect(filled).toHaveLength(1);
expect(container.querySelectorAll(".nav-footer__divider")).toHaveLength(0);
});
});
@@ -0,0 +1,213 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "@mantine/core";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import SettingsIcon from "@mui/icons-material/Settings";
import { Avatar, NavSurface } from "@app/ui";
import { BrandMark } from "@app/components/shared/BrandMark";
import { type AppSwitchTarget } from "@app/components/shared/AppSwitch";
import {
NavFooterCreditsRow,
type NavFooterCredits,
} from "@app/components/shared/navFooter/NavFooterCreditsRow";
import "@app/components/shared/navFooter/NavFooter.css";
export interface NavFooterAppLink {
/** The app this footer is NOT in — the one the row opens. */
app: AppSwitchTarget;
onOpen: () => void;
}
export interface NavFooterProps {
/** Name shown next to the avatar, and the source of its initials fallback. */
displayName: string;
/** Profile picture; initials are drawn when absent or the URL fails to load. */
profilePictureUrl?: string | null;
/** Omit to render the account row as static text (no settings affordance). */
onOpenSettings?: () => void;
/** Null/undefined hides the meter — builds with no wallet never show it. */
credits?: NavFooterCredits | null;
/** Opens the plan surface from the credits row; omit to leave it inert. */
onOpenPlan?: () => void;
/** Null/undefined hides the switch row — e.g. no access to the other app. */
otherApp?: NavFooterAppLink | null;
/** Extra rows above the account row (the self-hosted link-account CTA). */
accountExtras?: ReactNode;
/** Icon-rail state: labels collapse to tooltips. */
collapsed?: boolean;
className?: string;
}
/**
* Whether the enter animation has already played this page session. The rows
* are seeded from cache now, so they're present from first paint and every
* later mount — switching apps, remounting a view — would otherwise replay the
* animation on content that never changed, which reads as the UI twitching.
*/
let hasPlayedEnter = false;
/**
* The bottom section every sidebar ends with, shared by the editor and the
* processor so both present the same rows. ONE surface, hairline-separated, in
* this order:
*
* 1. caller-contributed rows (the self-hosted link-account CTA)
* 2. free credits remaining
* 3. "Open <the other app>"
* 4. the account row — avatar, name, settings
*
* Purely presentational: each app resolves its own identity, wallet and
* app-switch access and passes them in, so this file carries no build-specific
* gating. A row whose data is absent is dropped, and so is the separator that
* would have sat beside it.
*/
export function NavFooter({
displayName,
profilePictureUrl,
onOpenSettings,
credits,
onOpenPlan,
otherApp,
accountExtras,
collapsed = false,
className,
}: NavFooterProps) {
const { t } = useTranslation();
const [animate] = useState(() => {
if (hasPlayedEnter) return false;
hasPlayedEnter = true;
return true;
});
const settingsLabel = t("fileSidebar.openSettings", "Open settings");
const accountLabel = onOpenSettings
? `${displayName} - ${settingsLabel}`
: displayName;
// One surface, hairline-separated rows. Each row gets a slot; the separators
// are drawn by CSS between adjacent NON-EMPTY slots (see NavFooter.css), so a
// row that renders nothing — the link-account CTA returns null once the org is
// linked, and an element is truthy even then — can't leave a line behind.
const rows: Array<{ key: string; node: ReactNode }> = [];
if (accountExtras) rows.push({ key: "extras", node: accountExtras });
if (credits) {
rows.push({
key: "credits",
node: (
<NavFooterCreditsRow
credits={credits}
collapsed={collapsed}
label={t("navFooter.credits.label", "Free credits")}
onOpen={onOpenPlan}
/>
),
});
}
if (otherApp) {
rows.push({
key: "switch",
node: (
<Tooltip
label={openAppLabel(otherApp.app, t)}
position="right"
withinPortal
disabled={!collapsed}
>
<button
type="button"
className="nav-footer__row"
onClick={() => otherApp.onOpen()}
// Collapsed drops the visible label, so name the button here too.
aria-label={openAppLabel(otherApp.app, t)}
>
<span className="nav-footer__row-icon" aria-hidden>
<BrandMark height="1.125rem" />
</span>
{!collapsed && (
<>
<span className="nav-footer__row-label">
{openAppLabel(otherApp.app, t)}
</span>
{/* "Takes you there", not "opens a new tab" — both apps are
one SPA, so this navigates in place. */}
<span className="nav-footer__trailing" aria-hidden>
<ArrowForwardIcon sx={{ fontSize: "1rem" }} />
</span>
</>
)}
</button>
</Tooltip>
),
});
}
rows.push({
key: "account",
node: (
<Tooltip
label={accountLabel}
position="right"
withinPortal
disabled={!collapsed}
>
<button
type="button"
className="nav-footer__row nav-footer__account"
// Called with no args: handlers that take optional params (the
// processor's openSettings(section?)) must not receive the event.
onClick={onOpenSettings ? () => onOpenSettings() : undefined}
disabled={!onOpenSettings}
data-testid={onOpenSettings ? "config-button" : undefined}
data-tour={onOpenSettings ? "config-button" : undefined}
aria-label={accountLabel}
>
{/* Decorative: the button's own label already names the account, so
an alt/label here would just repeat it to a screen reader. */}
<span aria-hidden>
<Avatar
size="sm"
name={displayName}
src={profilePictureUrl ?? undefined}
/>
</span>
{!collapsed && (
<span className="nav-footer__row-label sidebar-content-fade">
{displayName}
</span>
)}
{onOpenSettings && !collapsed && (
<span className="nav-footer__trailing" aria-hidden>
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
</span>
)}
</button>
</Tooltip>
),
});
return (
<NavSurface
className={["nav-footer", className ?? ""].filter(Boolean).join(" ")}
data-collapsed={collapsed || undefined}
data-animate={animate || undefined}
>
{rows.map((row) => (
<div key={row.key} className="nav-footer__slot">
{row.node}
</div>
))}
</NavSurface>
);
}
function openAppLabel(
app: AppSwitchTarget,
t: (key: string, fallback: string) => string,
): string {
return app === "editor"
? t("navFooter.openEditor", "Open PDF Editor")
: t("navFooter.openProcessor", "Open PDF Processor");
}
@@ -0,0 +1,83 @@
/* Free-credits meter inside the sidebar footer. The row base (padding, hover,
focus) comes from NavFooter.css; these rules are the meter itself. */
.nav-footer__credits {
flex-direction: column;
align-items: stretch;
gap: 0.375rem;
cursor: default;
}
/* Inert by default, so it must not read as hoverable; the actionable variant
opts back into the shared row hover. */
.nav-footer__credits:hover {
background: none;
}
.nav-footer__credits--actionable {
cursor: pointer;
}
.nav-footer__credits--actionable:hover {
background-color: var(--c-hover);
}
.nav-footer__credits-head {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.nav-footer__dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
flex-shrink: 0;
background-color: var(--c-success);
}
.nav-footer__dot[data-tone="warning"] {
background-color: var(--c-warning);
}
.nav-footer__dot[data-tone="danger"] {
background-color: var(--c-danger);
}
.nav-footer__credits-label {
flex: 1;
min-width: 0;
font-weight: 500;
color: var(--c-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nav-footer__credits-count {
flex-shrink: 0;
color: var(--c-text-muted);
font-variant-numeric: tabular-nums;
}
/* ---- Collapsed rail ---- */
/* Rotated so the fill starts at 12 o'clock and runs clockwise. */
.nav-footer__credits-ring {
width: 1.25rem;
height: 1.25rem;
margin-inline: auto;
transform: rotate(-90deg);
}
.nav-footer__credits-ring-track,
.nav-footer__credits-ring-fill {
fill: none;
stroke-width: 3;
}
.nav-footer__credits-ring-track {
stroke: var(--c-surface-sunken);
}
.nav-footer__credits-ring-fill {
stroke-linecap: round;
}
@@ -0,0 +1,158 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "@mantine/core";
import { ProgressBar } from "@app/ui";
import "@app/components/shared/navFooter/NavFooterCreditsRow.css";
export interface NavFooterCredits {
/** Free credits still available to spend. */
remaining: number;
/** Size of the free allowance — the "of N" denominator. */
total: number;
}
/** Remaining-credit bands, mirroring the usage meters' 80% / 100% thresholds. */
function creditsTone(remaining: number, total: number): string {
if (remaining <= 0) return "danger";
return total > 0 && remaining / total <= 0.2 ? "warning" : "success";
}
interface NavFooterCreditsRowProps {
credits: NavFooterCredits;
/** Icon rail: the figures drop and the bar alone carries the state. */
collapsed: boolean;
/** Row label, passed in so the meter owns no copy of its own. */
label: string;
/** Opens the plan surface. Omit to render the meter as inert text. */
onOpen?: () => void;
}
/**
* The free-credits meter as it appears in the sidebar footer: a state dot, the
* label, "X of Y" remaining, and a fill bar underneath. Figures are clamped
* here so a wallet that reports more remaining than the allowance (or negative)
* can't overflow the bar.
*
* Rendered as a {@code nav-footer__row}, so it inherits that row's metrics
* from NavFooter.css and only brings its own meter styling.
*/
export function NavFooterCreditsRow({
credits,
collapsed,
label,
onOpen,
}: NavFooterCreditsRowProps) {
const { t } = useTranslation();
const total = Math.max(0, credits.total);
const remaining = Math.min(Math.max(0, credits.remaining), total);
const tone = creditsTone(remaining, total);
const count = t("navFooter.credits.count", "{{remaining}} of {{total}}", {
remaining: remaining.toLocaleString(),
total: total.toLocaleString(),
});
return (
<Tooltip
label={`${label}: ${count}`}
position="right"
withinPortal
disabled={!collapsed}
>
<Row onOpen={onOpen} label={`${label}: ${count}`}>
{collapsed ? (
// The rail is one icon wide, so a full-width bar would read as a
// stray line; a ring carries the same fraction at icon size.
<CreditsRing
fraction={total > 0 ? remaining / total : 0}
tone={tone}
label={`${label}: ${count}`}
/>
) : (
<>
<div className="nav-footer__credits-head">
<span className="nav-footer__dot" data-tone={tone} aria-hidden />
<span className="nav-footer__credits-label">{label}</span>
<span className="nav-footer__credits-count">{count}</span>
</div>
<ProgressBar
value={total > 0 ? remaining / total : 0}
height={6}
color={`var(--c-${tone})`}
label={`${label}: ${count}`}
/>
</>
)}
</Row>
</Tooltip>
);
}
/** Icon-sized donut carrying the same remaining fraction as the expanded bar. */
function CreditsRing({
fraction,
tone,
label,
}: {
fraction: number;
tone: string;
label: string;
}) {
const RADIUS = 8;
const circumference = 2 * Math.PI * RADIUS;
const filled = Math.min(1, Math.max(0, fraction)) * circumference;
return (
<svg
className="nav-footer__credits-ring"
viewBox="0 0 20 20"
role="img"
aria-label={label}
>
<circle
className="nav-footer__credits-ring-track"
cx="10"
cy="10"
r={RADIUS}
/>
<circle
className="nav-footer__credits-ring-fill"
cx="10"
cy="10"
r={RADIUS}
stroke={`var(--c-${tone})`}
strokeDasharray={`${filled} ${circumference - filled}`}
/>
</svg>
);
}
/**
* The meter is a button only where there is a plan surface to open — otherwise
* it stays a plain div, so a build with nowhere to go doesn't advertise a
* click that does nothing.
*/
function Row({
onOpen,
label,
children,
}: {
onOpen?: () => void;
label: string;
children: ReactNode;
}) {
const className = `nav-footer__row nav-footer__credits${
onOpen ? " nav-footer__credits--actionable" : ""
}`;
if (!onOpen) return <div className={className}>{children}</div>;
return (
<button
type="button"
className={className}
onClick={() => onOpen()}
aria-label={label}
>
{children}
</button>
);
}
@@ -0,0 +1,64 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "@app/auth/UseSession";
import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { accountService } from "@app/services/accountService";
export interface AccountIdentity {
/** Never empty — falls back to a generic "User" so a row is never blank. */
displayName: string;
profilePictureUrl: string | null;
isAnonymous: boolean;
}
/**
* The signed-in identity as the UI should draw it: one name and one picture,
* resolved the same way everywhere. Every surface that shows "who am I" (the
* editor and processor sidebar footers, the account settings page) reads this,
* so a user can't see one initial in the sidebar and a different one in
* settings.
*
* Resolution order for the name: the auth layer's own displayName (each layer
* derives it from its native user shape), then the proprietary REST endpoint,
* then a generic last resort.
*/
export function useAccountIdentity(): AccountIdentity {
const { t } = useTranslation();
const { config } = useAppConfig();
const { displayName: authDisplayName, isAnonymous } = useAuth();
const profilePictureUrl = useProfilePictureUrl();
const [accountUsername, setAccountUsername] = useState<string | null>(null);
useEffect(() => {
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) => {
// 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(() => {
setAccountUsername(null);
});
}, [config?.enableLogin, authDisplayName]);
return {
displayName:
authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User"),
profilePictureUrl,
isAnonymous,
};
}
@@ -0,0 +1,12 @@
import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow";
/**
* Free credits left on this team's allowance, for the sidebar footer meter.
* Null hides the meter entirely.
*
* Core has no wallet — self-hosted installs aren't metered — so there is
* nothing to show. Cloud builds override this with the live wallet figure.
*/
export function useFreeCreditsSummary(): NavFooterCredits | null {
return null;
}
@@ -0,0 +1,10 @@
/**
* Opens the plan surface behind the sidebar footer's free-credits row, or null
* when this build has none (the row is then inert text rather than a button).
*
* Core ships no wallet and no plan section, so there is nothing to open. Builds
* that meter usage override this with their own surface.
*/
export function useOpenPlan(): (() => void) | null {
return null;
}
@@ -0,0 +1,12 @@
import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter";
/**
* The sibling app this build can switch to (editor ⇄ processor), or null when
* there is none. The single gate behind both the brand switcher and the
* sidebar footer's "Open ..." row, so the two can never disagree about access.
*
* Core ships no processor, so there is nothing to switch to.
*/
export function useOtherAppSwitch(): NavFooterAppLink | null {
return null;
}
+3
View File
@@ -6,5 +6,8 @@ export const qk = {
["editor", "endpointEnabled", endpoint] as const,
footerInfo: () => ["editor", "footerInfo"] as const,
groupEnabled: (group: string) => ["editor", "groupEnabled", group] as const,
/** Keyed on the asking identity: two users must never share one answer. */
portalAccess: (userId: string | null) =>
["editor", "portalAccess", userId] as const,
users: () => ["editor", "users"] as const,
} as const;
@@ -0,0 +1,73 @@
/**
* Last-known sidebar-footer state, so the rows are correct at first paint
* instead of arriving a request later.
*
* The footer is mounted by both apps, and the editor and processor are separate
* React trees with separate query caches — so without this, every navigation
* between them (and every remount inside them) re-ran the fetches and the rows
* visibly popped in and shoved each other around. Persisting to storage rather
* than to an in-memory cache is what makes it survive that boundary, and a
* reload.
*
* Deliberately stale-then-revalidate: what's stored is only ever what the
* backend last said, every reader refetches immediately and overwrites, and
* nothing is gated on it — the processor enforces its own access server-side,
* and a stale credit figure is replaced within a second of the wallet landing.
*/
const CREDITS_KEY = "stirling.navFooter.credits";
const OTHER_APP_KEY = "stirling.navFooter.otherApp";
/** Figures, or null for a team that sees no meter at all (a paying one). */
export type CachedCredits = { remaining: number; total: number } | null;
function read(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
// Private mode / storage disabled — behave as a first-ever load.
return null;
}
}
function write(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
// Nothing to do: the cache is an optimisation, never a correctness input.
}
}
/** `undefined` when this browser has never seen an answer. */
export function readCachedCredits(): CachedCredits | undefined {
const raw = read(CREDITS_KEY);
if (raw === null) return undefined;
if (raw === "none") return null;
try {
const parsed: unknown = JSON.parse(raw);
if (
typeof parsed === "object" &&
parsed !== null &&
typeof (parsed as CachedCredits & object).remaining === "number" &&
typeof (parsed as CachedCredits & object).total === "number"
) {
return parsed as CachedCredits;
}
} catch {
// Corrupt entry — fall through and treat it as never-seen.
}
return undefined;
}
export function writeCachedCredits(credits: CachedCredits): void {
write(CREDITS_KEY, credits === null ? "none" : JSON.stringify(credits));
}
/** `undefined` when this browser has never seen an answer. */
export function readCachedOtherApp(): boolean | undefined {
const raw = read(OTHER_APP_KEY);
return raw === null ? undefined : raw === "true";
}
export function writeCachedOtherApp(canOpen: boolean): void {
write(OTHER_APP_KEY, String(canOpen));
}
+6
View File
@@ -46,6 +46,12 @@
height: 2.5rem;
font-size: 1rem;
}
/* Account-settings hero disc. */
.sui-avatar--xl {
width: 4.5rem;
height: 4.5rem;
font-size: 1.75rem;
}
.sui-avatar__img {
width: 100%;
+21 -6
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import "@app/ui/Avatar.css";
export type AvatarSize = "xs" | "sm" | "md" | "lg";
export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl";
export type AvatarTone =
| "blue"
| "purple"
@@ -23,10 +24,12 @@ export interface AvatarProps {
className?: string;
}
function initialsOf(name: string): string {
function avatarInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
// Single word (a username or an email) reads as one letter — two letters of
// "admin" ("AD") looks like a different person's initials, not a truncation.
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
@@ -43,6 +46,13 @@ export function Avatar({
ariaLabel,
className,
}: AvatarProps) {
// A picture URL that 404s (expired signed URL, deleted upload) must not leave
// an empty disc — fall back to the same initials the no-picture case shows, so
// every surface rendering this identity agrees on what it draws.
const [srcFailed, setSrcFailed] = useState(false);
useEffect(() => setSrcFailed(false), [src]);
const showImage = Boolean(src) && !srcFailed;
const classes = [
"sui-avatar",
`sui-avatar--${size}`,
@@ -53,11 +63,16 @@ export function Avatar({
.filter(Boolean)
.join(" ");
const content = src ? (
<img src={src} alt={ariaLabel ?? name} className="sui-avatar__img" />
const content = showImage ? (
<img
src={src}
alt={ariaLabel ?? name}
className="sui-avatar__img"
onError={() => setSrcFailed(true)}
/>
) : (
<span className="sui-avatar__initials" aria-hidden>
{initialsOf(name)}
{avatarInitials(name)}
</span>
);
@@ -0,0 +1,7 @@
/**
* SaaS has no link concept — the signed-in account IS the SaaS account, and the
* editor's cloud wallet hook is already in this build's {@code @app/*} cascade.
* Delegating to it means the processor footer and the editor footer share one
* wallet fetch and can't disagree, so there is nothing portal-specific to do.
*/
export { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary";
@@ -0,0 +1,11 @@
import { useCallback } from "react";
import { useUI } from "@portal/contexts/UIContext";
/**
* SaaS processor: the settings modal it hosts carries the same Plan section the
* editor opens, so the footer's credits row lands both apps in one place.
*/
export function useOpenPlan(): (() => void) | null {
const { openSettings } = useUI();
return useCallback(() => openSettings("plan"), [openSettings]);
}
@@ -123,8 +123,6 @@
}
.portal-sidebar[data-collapsed] .portal-sidebar__footer {
margin-inline: 0.375rem;
padding-inline: 0;
align-items: center;
}
.portal-sidebar__logo {
@@ -179,10 +177,8 @@
gap: 0.125rem;
}
/* The shared <NavFooter> brings its own boxes, padding and gap; the sidebar
only positions it. */
.portal-sidebar__footer {
margin: 0 0.625rem 0.75rem;
padding: 0.5rem 0.375rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
@@ -2,6 +2,10 @@ import { useMediaQuery } from "@mantine/hooks";
import { Tooltip } from "@mantine/core";
import { ActionIcon, NavItem, NavSurface } from "@app/ui";
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
import { useFreeCreditsSummary } from "@portal/hooks/useFreeCreditsSummary";
import { useOpenPlan } from "@portal/hooks/useOpenPlan";
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@@ -10,7 +14,7 @@ import { useUI } from "@portal/contexts/UIContext";
import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem";
import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl";
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
import { CloseIcon, SettingsIcon } from "@portal/components/icons";
import { CloseIcon } from "@portal/components/icons";
import {
GROUP_PROCESSOR,
GROUP_PLATFORM,
@@ -41,6 +45,9 @@ export function Sidebar() {
const isMobile = useMediaQuery(MOBILE_QUERY, false, {
getInitialValueInEffect: false,
});
const { displayName, profilePictureUrl } = useAccountIdentity();
const credits = useFreeCreditsSummary();
const openPlan = useOpenPlan();
// Collapse is a desktop-only affordance: on mobile the sidebar is an
// off-canvas drawer, so the icon-rail state never applies there.
@@ -146,15 +153,17 @@ export function Sidebar() {
))}
</nav>
<NavSurface className="portal-sidebar__footer">
<LinkAccountFooterItem />
<NavItem
id="settings"
label={t("portal.nav.settings")}
icon={<SettingsIcon />}
onClick={() => openSettings()}
/>
</NavSurface>
<NavFooter
className="portal-sidebar__footer"
displayName={displayName}
profilePictureUrl={profilePictureUrl}
onOpenSettings={openSettings}
credits={credits}
onOpenPlan={openPlan ?? undefined}
otherApp={{ app: "editor", onOpen: goToEditor }}
accountExtras={<LinkAccountFooterItem />}
collapsed={collapsed}
/>
</aside>
);
}
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { Button, Card } from "@app/ui";
import { formatPeriodDate, MeterBar, meterState } from "@app/billing";
import { formatPeriodDate, MeterBar, remainingMeter } from "@app/billing";
import type { Wallet } from "@portal/api/billing";
/**
@@ -10,8 +10,8 @@ import type { Wallet } from "@portal/api/billing";
* - No bundle → a slim "Get 12 months for the price of 10" offer nudge with a
* "Review offer" CTA (the demo's commit-nudge card), shown only when a buyer
* ({@code onBuy}, leader) is present.
* - Bundle held → the capacity meter (fills as the pool is drawn down, so it
* warns as capacity runs low) plus a "Top up" action for the leader.
* - Bundle held → the capacity meter (drains towards empty as the pool is drawn
* down, so it warns as capacity runs low) plus a "Top up" action for the leader.
*
* Prepaid is consumed before metered billing and sits outside the spend limit, so
* it reads as its own dimension. Buying/topping up opens {@code BundleCheckoutModal}
@@ -55,8 +55,7 @@ export function PrepaidCapacityCard({
const remaining = wallet.prepaidUnitsRemaining;
const total = wallet.prepaidUnitsTotal;
const used = Math.max(0, total - remaining);
const { state, pct } = meterState(used, total);
const { state, pct } = remainingMeter(remaining, total);
const stateLabel =
state === "DEGRADED"
? t("portal.billing.prepaid.state.exhausted", "Used up")
@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Card } from "@app/ui";
import { formatMinor, MeterBar, meterState } from "@app/billing";
import { formatMinor, MeterBar, remainingMeter } from "@app/billing";
import type { Wallet } from "@portal/api/billing";
import type { LocalUsage } from "@portal/api/link";
@@ -15,8 +15,10 @@ interface Props {
}
/**
* The free Processor-trial meter — "X / N free PDFs used" against the one-time
* grant. Uses the shared {@link MeterBar} (same `paygf-meter` structure as the
* The free Processor-trial meter — "X of N free PDFs left" against the one-time
* grant, with what has been used alongside as the status badge. The bar shows what
* is left, so it drains towards empty as the grant is spent.
* Uses the shared {@link MeterBar} (same `paygf-meter` structure as the
* cloud plan page). The subscribed spend-vs-cap meter is a separate surface
* ({@code SpendLimitCard}); this card is only the free face.
*
@@ -30,7 +32,7 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
const pending = unsynced?.totalUnsyncedUnits ?? 0;
const used = wallet.billableUsed + pending;
const remaining = Math.max(0, wallet.freeRemaining - pending);
const { state, pct } = meterState(used, wallet.freeAllowance);
const { state, pct } = remainingMeter(remaining, wallet.freeAllowance);
const rate =
wallet.pricePerDocMinor != null && wallet.pricePerDocMinor > 0
? wallet.pricePerDocMinor
@@ -76,11 +78,14 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
<MeterBar
state={state}
pct={pct}
barLabel={t("portal.billing.walletMeter.barAria", "Free PDFs used")}
figure={used.toLocaleString()}
barLabel={t(
"portal.billing.walletMeter.barAria",
"Free PDFs remaining",
)}
figure={remaining.toLocaleString()}
capSuffix={t(
"portal.billing.walletMeter.capSuffix",
"of {{allowance}} free PDFs used",
"of {{allowance}} free PDFs left",
{
count: wallet.freeAllowance,
allowance: wallet.freeAllowance.toLocaleString(),
@@ -88,11 +93,8 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
)}
statusLabel={t(
"portal.billing.walletMeter.statusLabel",
"{{remaining}} left",
{
count: remaining,
remaining: remaining.toLocaleString(),
},
"{{used}} used",
{ count: used, used: used.toLocaleString() },
)}
/>
</div>
@@ -0,0 +1,110 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { cleanup, render, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
import { useFreeCreditsSummary } from "@portal/hooks/useFreeCreditsSummary";
const fetchWallet = vi.fn();
vi.mock("@portal/api/billing", () => ({
fetchWallet: () => fetchWallet(),
}));
function Probe() {
const credits = useFreeCreditsSummary();
return (
<span data-testid="credits">
{credits ? `${credits.remaining}/${credits.total}` : "none"}
</span>
);
}
function renderFor(initialState: LinkState) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<LinkProvider initialState={initialState}>
<Probe />
</LinkProvider>
</QueryClientProvider>,
).getByTestId("credits");
}
describe("useFreeCreditsSummary (self-hosted) — wallet behind the link gate", () => {
beforeEach(() => {
// The figures persist across mounts now, so isolate the suite from itself.
localStorage.clear();
fetchWallet.mockReset();
fetchWallet.mockResolvedValue({
status: "free",
freeRemaining: 247,
freeAllowance: 500,
});
});
it("unlinked reads no wallet at all", async () => {
const el = renderFor("unlinked");
await waitFor(() => expect(el.textContent).toBe("none"));
expect(fetchWallet).not.toHaveBeenCalled();
});
it("linked surfaces the free grant", async () => {
const el = renderFor("linked-free");
await waitFor(() => expect(el.textContent).toBe("247/500"));
});
it("hides the meter once the team subscribes", async () => {
// The grant is a lifetime pool that survives subscribing, so a paying team
// would otherwise sit on a spent meter forever.
fetchWallet.mockResolvedValue({
status: "subscribed",
freeRemaining: 0,
freeAllowance: 500,
});
const el = renderFor("linked-subscribed");
// The row holds its space while the wallet loads, then drops once the
// answer says this team is paying.
await waitFor(() => expect(el.textContent).toBe("none"));
});
it("hides the meter when the wallet read fails", async () => {
fetchWallet.mockRejectedValue(new Error("saas unreachable"));
const el = renderFor("linked-subscribed");
await waitFor(() => expect(el.textContent).toBe("none"));
});
it("ignores cached figures once the instance is unlinked", async () => {
// The cache survives an unlink and nothing rewrites it afterwards, so the
// linkage gate has to cover the seed too, not just the fetch.
const linked = renderFor("linked-free");
await waitFor(() => expect(linked.textContent).toBe("247/500"));
cleanup();
fetchWallet.mockClear();
const unlinked = renderFor("unlinked");
expect(unlinked.textContent).toBe("none");
expect(fetchWallet).not.toHaveBeenCalled();
});
it("shows the last known figures while the wallet reloads", async () => {
// What stops the row popping in — and resizing the footer — every time the
// processor mounts.
const el = renderFor("linked-free");
await waitFor(() => expect(el.textContent).toBe("247/500"));
cleanup();
let release: (v: unknown) => void = () => {};
fetchWallet.mockReturnValue(
new Promise((resolve) => {
release = resolve;
}),
);
const second = renderFor("linked-free");
// Seeded before the refetch lands...
expect(second.textContent).toBe("247/500");
release({ status: "free", freeRemaining: 12, freeAllowance: 500 });
// ...then updated in place, without the row ever being absent.
await waitFor(() => expect(second.textContent).toBe("12/500"));
});
});
@@ -0,0 +1,65 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useLink } from "@portal/contexts/LinkContext";
import { fetchWallet } from "@portal/api/billing";
import { qk } from "@portal/queries/keys";
import {
readCachedCredits,
writeCachedCredits,
type CachedCredits,
} from "@app/services/navFooterCache";
import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow";
/**
* Free credits left on this team's allowance, for the processor's sidebar
* footer meter. Null hides the meter.
*
* This is the portal's own seam rather than the editor's {@code
* @app/hooks/useFreeCreditsSummary}, because self-hosted resolves {@code @app/*}
* as proprietary → core: the cloud wallet hook isn't in that cascade, and the
* implementation can't move down into proprietary either, since core/desktop
* builds ship no portal and must never resolve {@code @portal}. Keeping it here
* means only builds that actually have a processor pull in the wallet read.
*
* Self-hosted reads the same {@code GET /api/v1/payg/wallet} the Usage page's
* trial meter renders — {@code apiClient.saas} with the admin's Supabase JWT,
* since the wallet lives in the cloud even when the instance doesn't. Gated on
* linkage: an unlinked instance has no wallet to read.
*
* Free teams only, matching the editor and the Plan page. The grant is a
* lifetime pool that survives subscribing, so a paying team would otherwise sit
* on a permanent "0 of 500" in red; their usage lives on Usage & Billing.
*/
export function useFreeCreditsSummary(): NavFooterCredits | null {
const { isLinked } = useLink();
// Shared query key, so the footer rides the same cached snapshot as any other
// wallet reader rather than adding a fetch per mount.
const { data: wallet } = useQuery({
queryKey: qk.wallet(isLinked),
queryFn: fetchWallet,
enabled: isLinked,
});
// Shared with the editor's seam, so crossing between the two apps shows the
// figures the other one last saw rather than re-fetching into an empty row.
const [seed] = useState(readCachedCredits);
const live: CachedCredits | undefined = !wallet
? undefined
: wallet.status === "subscribed"
? null
: { remaining: wallet.freeRemaining, total: wallet.freeAllowance };
useEffect(() => {
// Only once linked: an unlinked instance never asks, so it has no answer of
// its own and must not overwrite what the editor recorded.
if (isLinked && live !== undefined) writeCachedCredits(live);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wallet, isLinked]);
// Linkage gates the seed as well as the fetch. The cache outlives an unlink
// — nothing refetches or rewrites it once the instance stops asking — so
// without this an unlinked instance would keep showing the figures from when
// it was linked, indefinitely.
if (!isLinked) return null;
return (live !== undefined ? live : seed) ?? null;
}
@@ -0,0 +1,13 @@
import { useCallback } from "react";
import { useView } from "@portal/contexts/ViewContext";
/**
* Self-hosted processor: settings carries no Plan section (it is a cloud
* surface, and this build's registry has none), so the footer's credits row
* opens the portal's own Usage & Billing view instead — the same figures, on
* the surface this flavor actually owns.
*/
export function useOpenPlan(): (() => void) | null {
const { setActiveView } = useView();
return useCallback(() => setActiveView("usage"), [setActiveView]);
}
@@ -20,6 +20,8 @@ export const qk = {
// Keyed on linkage: an unlinked account has no deal to read, so linking must not
// serve the unlinked (null) snapshot back from cache.
procurement: (linked: boolean) => ["portal", "procurement", linked] as const,
// Same reasoning: an unlinked instance has no wallet in the cloud.
wallet: (linked: boolean) => ["portal", "wallet", linked] as const,
// Tier-dependent
documents: (tier: Tier) => ["portal", "documents", tier] as const,
@@ -293,6 +293,29 @@ export function computeBundleQuote(
export type MeterState = "FULL" | "WARNED" | "DEGRADED";
/**
* Meter for a balance that is spent DOWN — a free grant, a prepaid pool. The
* bar shows what is LEFT, so full reads as "plenty" and empty as "none", which
* is how the sidebar footer's credits row reads and the only direction that
* matches a figure quoting the remainder.
*
* The state bands still key on consumption, so the tone is unchanged: amber
* once 80% is gone, red once it's exhausted. Meters for money SPENT against a
* cap keep using {@link meterState} directly — there a full bar correctly means
* "at your ceiling".
*/
export function remainingMeter(
remaining: number,
total: number,
): { state: MeterState; pct: number } {
const { state } = meterState(Math.max(0, total - remaining), total);
const pct =
total > 0
? Math.min(100, Math.max(0, (Math.max(0, remaining) / total) * 100))
: 0;
return { state, pct };
}
/** Warn (≥80%) / degrade (≥100%) band for a usage meter; mirrors the BE thresholds. */
export function meterState(
used: number,
@@ -14,6 +14,7 @@ export {
docCapForMoney,
formatPeriodDate,
meterState,
remainingMeter,
PREPAID_MONTHS_GRANTED,
PREPAID_MONTHS_PAID,
PDFS_PER_USER_MONTH,
@@ -1,15 +1,21 @@
import { useNavigate } from "react-router-dom";
import { useAuth } from "@app/auth/context";
import { Logo } from "@app/ui/Logo";
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher";
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch";
/**
* Sidebar brand header for builds that ship the processor. When this user can
* open it, the Stirling logo doubles as the editor⇄processor switcher: the mark
* morphs into a chevron and opens the switch menu (the same BrandSwitcher the
* processor sidebar uses). Users without access get a plain logo.
*
* The access gate lives in {@link useOtherAppSwitch} so this header and the
* sidebar footer's "Open PDF Processor" row are driven by one answer.
*/
export function AppSwitcher({ collapsed }: AppSwitcherProps) {
const { portalAccess } = useAuth();
const navigate = useNavigate();
const otherApp = useOtherAppSwitch();
if (!portalAccess) {
if (!otherApp) {
return (
<Logo
variant={collapsed ? "iconOnly" : "iconAndText"}
@@ -22,7 +28,7 @@ export function AppSwitcher({ collapsed }: AppSwitcherProps) {
return (
<BrandSwitcher
current="editor"
onSwitch={() => navigate(PORTAL_BASENAME)}
onSwitch={otherApp.onOpen}
collapsed={collapsed}
/>
);
@@ -0,0 +1,15 @@
import { useNavigate } from "react-router-dom";
import { useAuth } from "@app/auth/context";
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter";
/**
* Self-hosted: the Spring session carries `portalAccess`, so the switch to the
* processor is offered exactly when that flag is set.
*/
export function useOtherAppSwitch(): NavFooterAppLink | null {
const { portalAccess } = useAuth();
const navigate = useNavigate();
if (!portalAccess) return null;
return { app: "processor", onOpen: () => navigate(PORTAL_BASENAME) };
}
@@ -1,41 +0,0 @@
import { useNavigate } from "react-router-dom";
import { Logo } from "@app/ui/Logo";
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher";
import { usePortalAccess } from "@app/hooks/usePortalAccess";
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
/**
* SaaS sidebar brand header. When the backend says this user can open the
* processor (`/api/v1/auth/me` → `portalAccess` — the exact signal the
* processor's own gate uses), the Stirling logo doubles as the
* editor⇄processor switcher: the mark morphs into a chevron and opens the
* switch menu (same BrandSwitcher the processor sidebar uses). Users without
* access get a plain logo.
*
* Deliberately NOT gated on the editor's Supabase auth context: that context
* never fetches /me, so it can't know about portal access (and its session
* state doesn't always mirror the backend login that actually grants it).
*/
export function AppSwitcher({ collapsed }: AppSwitcherProps) {
const portalAccess = usePortalAccess();
const navigate = useNavigate();
if (!portalAccess) {
return (
<Logo
variant={collapsed ? "iconOnly" : "iconAndText"}
iconHeight="1.6rem"
textHeight="1.3rem"
/>
);
}
return (
<BrandSwitcher
current="editor"
onSwitch={() => navigate(PORTAL_BASENAME)}
collapsed={collapsed}
/>
);
}
@@ -1,7 +1,6 @@
import React, { useState } from "react";
import {
Alert,
Avatar,
Divider,
Group,
Image,
@@ -11,10 +10,12 @@ import {
TextInput,
Modal,
} from "@mantine/core";
import { Avatar } from "@app/ui/Avatar";
import { Button as DSButton } from "@app/ui/Button";
import { FilePicker } from "@app/ui/FilePicker";
import { useTranslation } from "react-i18next";
import { useAuth } from "@app/auth/UseSession";
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
import {
isUserAnonymous,
linkEmailIdentity,
@@ -46,6 +47,8 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
refreshProfilePicture,
refreshProfilePictureMetadata,
} = useAuth();
// Same name + initials the sidebar footer draws, so the two discs agree.
const { displayName } = useAccountIdentity();
const PROFILE_BUCKET = "profile-pictures";
@@ -67,7 +70,6 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
const provider = profilePictureMetadata?.provider;
const profilePath = user ? `${user.id}/avatar` : null;
const profileInitial = user?.email?.trim()?.charAt(0)?.toUpperCase() || "U";
const handleProfileUpload = async (file: File | null) => {
if (!file || !user || !profilePath) {
@@ -410,12 +412,9 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
<Group align="center" gap="md">
<Avatar
src={profilePictureUrl || undefined}
radius="xl"
size={72}
color="blue"
>
{profileInitial}
</Avatar>
name={displayName}
size="xl"
/>
<div
style={{
display: "flex",
@@ -450,12 +449,9 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
<Group align="center" gap="md">
<Avatar
src={profilePictureUrl || undefined}
radius="xl"
size={72}
color="blue"
>
{profileInitial}
</Avatar>
name={displayName}
size="xl"
/>
<div
style={{
display: "flex",
@@ -0,0 +1,16 @@
import { useNavigate } from "react-router-dom";
import { usePortalAccess } from "@app/hooks/usePortalAccess";
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter";
/**
* SaaS: the editor's Supabase context never fetches /me, so processor access
* comes from the backend via {@link usePortalAccess} — the same signal the
* processor's own gate uses.
*/
export function useOtherAppSwitch(): NavFooterAppLink | null {
const portalAccess = usePortalAccess();
const navigate = useNavigate();
if (!portalAccess) return null;
return { app: "processor", onOpen: () => navigate(PORTAL_BASENAME) };
}
@@ -1,5 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { renderHook as baseRenderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
const get = vi.fn();
let currentUserId: string | null = null;
@@ -20,10 +22,28 @@ function meReturning(portalAccess: boolean) {
return { data: { user: { portalAccess } } };
}
// A fresh client per render, so one test's cached answer can't satisfy the
// next — each case exercises a cold cache unless it deliberately shares one.
let client: QueryClient;
function renderHook<T>(cb: () => T) {
return baseRenderHook(cb, {
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
),
});
}
describe("usePortalAccess", () => {
beforeEach(() => {
// The hook now remembers the last answer across mounts, so without this a
// prior test's result seeds the next one.
localStorage.clear();
get.mockReset();
currentUserId = null;
client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
});
});
it("reports the backend's answer for the signed-in user", async () => {
@@ -82,12 +102,31 @@ describe("usePortalAccess", () => {
expect(first.result.current).toBe(false);
first.unmount();
// The failure isn't sticky.
// The failure isn't sticky — a cold cache asks again.
client.clear();
get.mockResolvedValue(meReturning(true));
const second = renderHook(() => usePortalAccess());
await waitFor(() => expect(second.result.current).toBe(true));
});
it("shows the last known answer at first paint, then revalidates", async () => {
// What stops the switcher and the footer's "Open ..." row popping in a
// request late on every mount.
currentUserId = "admin-1";
get.mockResolvedValue(meReturning(true));
const first = renderHook(() => usePortalAccess());
await waitFor(() => expect(first.result.current).toBe(true));
first.unmount();
client.clear();
get.mockResolvedValue(meReturning(false));
const second = renderHook(() => usePortalAccess());
// Seeded from the remembered answer before the request lands...
expect(second.result.current).toBe(true);
// ...and corrected once the backend disagrees.
await waitFor(() => expect(second.result.current).toBe(false));
});
it("ignores a response that lands after unmount", async () => {
currentUserId = "admin-1";
let resolveMe: (v: unknown) => void = () => {};
@@ -1,52 +1,64 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import apiClient from "@app/services/apiClient";
import { useAuth } from "@app/auth/UseSession";
import {
readCachedOtherApp,
writeCachedOtherApp,
} from "@app/services/navFooterCache";
import { qk } from "@app/query/keys";
async function fetchPortalAccess(): Promise<boolean> {
const res = await apiClient.get<{ user?: { portalAccess?: boolean } }>(
"/api/v1/auth/me",
);
return res.data.user?.portalAccess === true;
}
/**
* Whether the current user can open the processor (admin portal), straight
* from the backend (`/api/v1/auth/me` → `portalAccess`) — the same signal the
* processor's own SaasPortalGate uses. Components that must mirror processor
* access (e.g. the sidebar's editor⇄processor switcher) ask here.
* access (the sidebar's editor⇄processor switcher and its footer row) ask here.
*
* The editor's Supabase auth context can't *answer* this — it never fetches
* /me — so it is used only to identify who is asking. Keying the effect on
* that identity is what keeps the answer per-user: the SPA can swap users
* /me — so it is used only to identify who is asking. That identity is the
* cache key, which is what keeps the answer per-user: the SPA can swap users
* without a reload (Supabase fires SIGNED_OUT/SIGNED_IN in place; only the
* settings Logout button hard-navigates), so any answer held beyond the
* current identity would leak to whoever signs in next.
* settings Logout button hard-navigates), and a keyed cache addresses each
* identity separately rather than holding one answer that would have to be
* invalidated on the swap — the bug class this hook once had.
*
* Deliberately unmemoised beyond the mount: the one consumer (the sidebar
* switcher) mounts once, so a cross-mount cache would only add user-scoped
* state that has to be invalidated on identity change — the bug class this
* hook already had once. Guests skip the request entirely.
* Cached through the app query client, so leaving the editor for the processor
* and coming back resolves from cache: the switcher is there on first paint
* instead of appearing a request later. Guests skip the request entirely.
*/
export function usePortalAccess(): boolean {
const { user } = useAuth();
const userId = user?.id ?? null;
const [access, setAccess] = useState(false);
// The query cache is per-tree and per-load, so it can't help a cold start or
// the hop into the processor, which mounts its own client. Seed from the last
// answer this browser saw so the switcher and the footer's "Open ..." row are
// there at first paint. Marked ancient so it still revalidates immediately.
const [seed] = useState(readCachedOtherApp);
const { data, isSuccess } = useQuery({
queryKey: qk.portalAccess(userId),
queryFn: fetchPortalAccess,
// Signed out: nothing to ask, and any previous answer is void.
enabled: userId !== null,
// Backend unreachable or guest (401) means no access now; a later refetch
// asks again rather than trusting the failure.
retry: false,
initialData: seed,
initialDataUpdatedAt: 0,
});
useEffect(() => {
// Signed out: nothing to ask, and any previous answer is void.
if (userId === null) {
setAccess(false);
return;
}
// Only a real answer is recorded — a failed probe is not one, so the next
// mount trusts the last backend response rather than a network blip.
if (isSuccess && data !== undefined) writeCachedOtherApp(data);
}, [isSuccess, data]);
let cancelled = false;
apiClient
.get<{ user?: { portalAccess?: boolean } }>("/api/v1/auth/me")
.then((res) => {
if (!cancelled) setAccess(res.data.user?.portalAccess === true);
})
.catch(() => {
// Backend unreachable or guest (401): no access now; a remount or
// identity change asks again rather than trusting a failure.
if (!cancelled) setAccess(false);
});
return () => {
cancelled = true;
};
}, [userId]);
return access;
return data === true;
}
@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { expectConsole } from "@app/tests/failOnConsole";
const get = vi.fn();
vi.mock("@app/services/apiClient", () => ({
default: { get: (...args: unknown[]) => get(...args) },
}));
vi.mock("@app/hooks/walletDevPreview", () => ({
getWalletDevPreview: () => null,
}));
vi.mock("@app/services/billing", () => ({ createPortalSession: vi.fn() }));
vi.mock("@app/platform/openExternal", () => ({ openExternal: vi.fn() }));
const { useWallet } = await import("@app/hooks/useWallet");
/** Full enough for the hook's deep-compare, which reads every field. */
function walletWith(freeRemaining: number) {
return {
data: {
teamId: 1,
status: "free",
role: "leader",
billingPeriodStart: "2026-08-01",
billingPeriodEnd: "2026-08-31",
billableUsed: 500 - freeRemaining,
billableLimit: 500,
freeAllowance: 500,
freeRemaining,
pricePerDocMinor: 2,
bundleRatePerCreditMinor: null,
currency: "usd",
estimatedBillMinor: 0,
capUsd: null,
noCap: false,
stripeSubscriptionId: null,
spendUnitsThisPeriod: 0,
docsProcessedThisPeriod: 0,
uniquePdfsThisPeriod: 0,
sizeMultiplierPdfsThisPeriod: 0,
billingMode: "metered",
prepaidUnitsRemaining: 0,
prepaidUnitsTotal: 0,
prepaidExpiresAt: null,
recent: [],
members: [],
categoryBreakdown: { api: 0, ai: 0, automation: 0 },
categoryDocs: { api: 0, ai: 0, automation: 0 },
},
};
}
describe("useWallet — keeping the figures fresh", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
get.mockReset();
get.mockResolvedValue(walletWith(500));
});
afterEach(() => vi.useRealTimers());
it("re-reads the wallet on the poll interval", async () => {
const { result } = renderHook(() => useWallet());
await waitFor(() => expect(result.current.wallet).not.toBeNull());
expect(get).toHaveBeenCalledTimes(1);
get.mockResolvedValue(walletWith(480));
await act(async () => {
vi.advanceTimersByTime(30_000);
});
await waitFor(() => expect(result.current.wallet?.freeRemaining).toBe(480));
});
it("polls silently, so consumers gating on loading/error don't flicker", async () => {
const { result } = renderHook(() => useWallet());
await waitFor(() => expect(result.current.wallet).not.toBeNull());
// A poll that fails must leave the last good snapshot, and must not raise
// `error` — Plan swaps a working page for an alert on that.
get.mockRejectedValue(new Error("network blip"));
await act(async () => {
vi.advanceTimersByTime(30_000);
});
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
expect(result.current.wallet?.freeRemaining).toBe(500);
});
it("settles loading when a silent poll supersedes an in-flight visible load", async () => {
// The mount load raises `loading`; a poll firing before it lands cancels it.
// If clearing the flag were the silent load's to skip, both would decline
// and `loading` would stay true forever — which permanently suppresses the
// limit modals, since they do `if (loading || !wallet) return null`.
const visibility = vi.spyOn(document, "visibilityState", "get");
visibility.mockReturnValue("visible");
let landMount: (v: unknown) => void = () => {};
get.mockReturnValueOnce(
new Promise((resolve) => {
landMount = resolve;
}),
);
const { result } = renderHook(() => useWallet());
expect(result.current.loading).toBe(true);
get.mockResolvedValue(walletWith(470));
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
await act(async () => {
landMount(walletWith(500));
});
await waitFor(() => expect(result.current.wallet?.freeRemaining).toBe(470));
expect(result.current.loading).toBe(false);
visibility.mockRestore();
});
it("clears a stale error once a silent poll succeeds", async () => {
// The visible mount load failing is meant to be logged; only the silent
// retries stay quiet.
expectConsole.warn(/\[useWallet\] fetch failed/);
get.mockRejectedValueOnce(new Error("network blip"));
const { result } = renderHook(() => useWallet());
await waitFor(() => expect(result.current.error).not.toBeNull());
get.mockResolvedValue(walletWith(500));
await act(async () => {
vi.advanceTimersByTime(30_000);
});
await waitFor(() => expect(result.current.error).toBeNull());
expect(result.current.wallet?.freeRemaining).toBe(500);
});
it("stops polling while the tab is hidden and re-reads on return", async () => {
const visibility = vi.spyOn(document, "visibilityState", "get");
visibility.mockReturnValue("visible");
const { result } = renderHook(() => useWallet());
await waitFor(() => expect(result.current.wallet).not.toBeNull());
const afterMount = get.mock.calls.length;
visibility.mockReturnValue("hidden");
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
vi.advanceTimersByTime(120_000);
});
expect(get).toHaveBeenCalledTimes(afterMount);
visibility.mockReturnValue("visible");
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
await waitFor(() => expect(get.mock.calls.length).toBe(afterMount + 1));
visibility.mockRestore();
});
});