feat(privacy): enable privacy mode for all users and extend amount masking (#182)

## 🚪 Why?

### Problem
Privacy mode — which masks financial amounts so users can share their
screen without leaking account balances — was previously gated behind an
admin-only check. Regular users had no way to toggle it from the UI.
Additionally, several places in the app (chart tooltips, budget pages)
were rendering raw amounts via \`formatCurrency\` directly, bypassing
the privacy mode check entirely.

## 🔑 What?

### Changes
- Remove the \`isAdmin()\` guard from the privacy mode toggle in the
user menu, making it available to all users
- Mask digits with \`*\` in \`AmountDisplay\` when privacy mode is
enabled (e.g. \`$1,234.56\` → \`$*,***.**\`)
- Apply the same digit masking to the net worth chart tooltip totals in
\`chart.tsx\` (both single-currency and multi-currency paths)
- Replace raw \`formatCurrency\` calls with \`AmountDisplay\` in
\`budget-list-card.tsx\` (spent, allocated, remaining)
- Add a \`maskIfPrivate\` helper in \`budget-spending-chart.tsx\`
CustomTooltip to mask allocated, spent, last period, and available
amounts when privacy mode is active

##  Verification
<img width="1858" height="1193" alt="image"
src="https://github.com/user-attachments/assets/ec734841-c5cb-44e9-a9f5-ddee7a53a78c"
/>
<img width="1255" height="762" alt="image"
src="https://github.com/user-attachments/assets/b1b93615-41a0-4e8d-8dea-7b27e227181c"
/>
This commit is contained in:
Víctor Falcón 2026-03-02 10:48:45 +00:00 committed by GitHub
parent 186ae57c2d
commit 152b186c10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 53 additions and 63 deletions

View File

@ -1,4 +1,5 @@
import { show } from '@/actions/App/Http/Controllers/BudgetController';
import { AmountDisplay } from '@/components/ui/amount-display';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@ -11,7 +12,6 @@ import {
import { Progress } from '@/components/ui/progress';
import { useLocale } from '@/hooks/use-locale';
import { Budget, getBudgetPeriodTypeLabel } from '@/types/budget';
import { formatCurrency } from '@/utils/currency';
import { formatDate } from '@/utils/date';
import { __ } from '@/utils/i18n';
import { Link } from '@inertiajs/react';
@ -102,17 +102,15 @@ export function BudgetListCard({ budget, currencyCode }: Props) {
{__('Spent')}
</span>
<span className={statusColor}>
{formatCurrency(
stats.totalSpent,
currencyCode,
locale,
)}{' '}
<AmountDisplay
amountInCents={stats.totalSpent}
currencyCode={currencyCode}
/>{' '}
{__('of')}{' '}
{formatCurrency(
stats.totalAllocated,
currencyCode,
locale,
)}
<AmountDisplay
amountInCents={stats.totalAllocated}
currencyCode={currencyCode}
/>
</span>
</div>
<Progress
@ -125,11 +123,10 @@ export function BudgetListCard({ budget, currencyCode }: Props) {
{__('Remaining')}
</span>
<span className={statusColor}>
{formatCurrency(
stats.remaining,
currencyCode,
locale,
)}
<AmountDisplay
amountInCents={stats.remaining}
currencyCode={currencyCode}
/>
</span>
</div>
</div>

View File

@ -10,6 +10,7 @@ import {
ChartTooltip,
type ChartConfig,
} from '@/components/ui/chart';
import { usePrivacyMode } from '@/contexts/privacy-mode-context';
import { useLocale } from '@/hooks/use-locale';
import { BudgetPeriod } from '@/types/budget';
import { formatCurrency } from '@/utils/currency';
@ -53,6 +54,8 @@ function CustomTooltip({
hasPreviousPeriod,
locale,
}: CustomTooltipProps) {
const { isPrivacyModeEnabled } = usePrivacyMode();
if (!active || !payload || !payload.length) {
return null;
}
@ -64,6 +67,11 @@ function CustomTooltip({
const percentage =
allocated > 0 ? Math.round((available / allocated) * 100) : 0;
const maskIfPrivate = (value: number) => {
const formatted = formatCurrency(value, currencyCode, locale);
return isPrivacyModeEnabled ? formatted.replace(/\d/g, '*') : formatted;
};
return (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<p className="mb-2 text-sm font-medium">
@ -77,16 +85,14 @@ function CustomTooltip({
{__('Allocated:')}
</span>
<span className="font-medium">
{formatCurrency(allocated, currencyCode, locale)}
{maskIfPrivate(allocated)}
</span>
</div>
<div className="flex items-center justify-between gap-8">
<span className="text-muted-foreground">
{__('Spent:')}
</span>
<span className="font-medium">
{formatCurrency(spent, currencyCode, locale)}
</span>
<span className="font-medium">{maskIfPrivate(spent)}</span>
</div>
{hasPreviousPeriod && data.prevSpent !== undefined && (
<div className="flex items-center justify-between gap-8">
@ -94,11 +100,7 @@ function CustomTooltip({
{__('Last period:')}
</span>
<span className="font-medium text-muted-foreground">
{formatCurrency(
data.prevSpent,
currencyCode,
locale,
)}
{maskIfPrivate(data.prevSpent)}
</span>
</div>
)}
@ -110,11 +112,7 @@ function CustomTooltip({
{percentage}% /
</span>
<span className="font-semibold">
{formatCurrency(
available,
currencyCode,
locale,
)}
{maskIfPrivate(available)}
</span>
</div>
</div>

View File

@ -1,4 +1,5 @@
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { usePrivacyMode } from '@/contexts/privacy-mode-context';
import { useLocale } from '@/hooks/use-locale';
import { cn } from '@/lib/utils';
import { formatCurrency } from '@/utils/currency';
@ -57,6 +58,7 @@ export function AmountDisplay({
highlightPositive = false,
}: AmountDisplayProps) {
const { isKeySet } = useEncryptionKey();
const { isPrivacyModeEnabled } = usePrivacyMode();
const locale = useLocale();
const [amount, setAmount] = useState<number>(amountInCents / 100);
const isPositive = amountInCents > 0
@ -111,7 +113,7 @@ export function AmountDisplay({
)}
>
<span className='text-xs'>{showSign && amount >= 0 && '+'}</span>
<span>{formatted}</span>
<span>{isPrivacyModeEnabled ? formatted.replace(/\d/g, '*') : formatted}</span>
</span>
);
}

View File

@ -1,6 +1,7 @@
import * as React from 'react';
import * as RechartsPrimitive from 'recharts';
import { usePrivacyMode } from '@/contexts/privacy-mode-context';
import { useLocale } from '@/hooks/use-locale';
import { cn } from '@/lib/utils';
import { formatCurrency } from '@/utils/currency';
@ -164,6 +165,7 @@ const ChartTooltipContent = React.forwardRef<
) => {
const { config } = useChart();
const locale = useLocale();
const { isPrivacyModeEnabled } = usePrivacyMode();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
@ -346,11 +348,9 @@ const ChartTooltipContent = React.forwardRef<
Total {currency}
</span>
<span className="text-foreground font-mono font-medium tabular-nums">
{formatCurrencyWithCode(
total,
currency,
locale,
)}
{isPrivacyModeEnabled
? formatCurrencyWithCode(total, currency, locale).replace(/\d/g, '*')
: formatCurrencyWithCode(total, currency, locale)}
</span>
</div>
))
@ -361,11 +361,9 @@ const ChartTooltipContent = React.forwardRef<
</span>
<span className="text-foreground font-mono font-medium tabular-nums">
{currencyTotals && currencyTotals[0]
? formatCurrencyWithCode(
currencyTotals[0][1],
currencyTotals[0][0],
locale,
)
? isPrivacyModeEnabled
? formatCurrencyWithCode(currencyTotals[0][1], currencyTotals[0][0], locale).replace(/\d/g, '*')
: formatCurrencyWithCode(currencyTotals[0][1], currencyTotals[0][0], locale)
: payload
.reduce(
(

View File

@ -6,7 +6,6 @@ import {
} from '@/components/ui/dropdown-menu';
import { UserInfo } from '@/components/user-info';
import { usePrivacyMode } from '@/contexts/privacy-mode-context';
import { isAdmin } from '@/hooks/use-admin';
import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
import { clearKey } from '@/lib/key-storage';
import { logout } from '@/routes';
@ -46,27 +45,23 @@ export function UserMenuContent({ user }: UserMenuContentProps) {
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{isAdmin() && (
<>
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => {
togglePrivacyMode();
cleanup();
}}
>
{isPrivacyModeEnabled ? (
<EyeOff className="mr-2" />
) : (
<Eye className="mr-2" />
)}
{isPrivacyModeEnabled
? __('Disable privacy mode')
: __('Enable privacy mode')}
</DropdownMenuItem>
</DropdownMenuGroup>
</>
)}
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => {
togglePrivacyMode();
cleanup();
}}
>
{isPrivacyModeEnabled ? (
<EyeOff className="mr-2" />
) : (
<Eye className="mr-2" />
)}
{isPrivacyModeEnabled
? __('Disable privacy mode')
: __('Enable privacy mode')}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link