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:
parent
186ae57c2d
commit
152b186c10
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue