feat(privacy): add privacy/demo mode for personal info blurring

This commit is contained in:
kaitranntt
2025-12-17 02:24:36 -05:00
parent cefb3a59d2
commit 6f3fb54cc3
10 changed files with 188 additions and 29 deletions
+19 -16
View File
@@ -4,6 +4,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { queryClient } from '@/lib/query-client'; import { queryClient } from '@/lib/query-client';
import { ThemeProvider } from '@/components/theme-provider'; import { ThemeProvider } from '@/components/theme-provider';
import { PrivacyProvider } from '@/contexts/privacy-context';
import { Layout } from '@/components/layout'; import { Layout } from '@/components/layout';
// Eager load: HomePage (initial route) // Eager load: HomePage (initial route)
@@ -33,22 +34,24 @@ export default function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme"> <ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
<BrowserRouter> <PrivacyProvider>
<Routes> <BrowserRouter>
<Route element={<Layout />}> <Routes>
<Route path="/" element={<HomePage />} /> <Route element={<Layout />}>
<Route path="/analytics" element={<AnalyticsPage />} /> <Route path="/" element={<HomePage />} />
<Route path="/api" element={<ApiPage />} /> <Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/cliproxy" element={<CliproxyPage />} /> <Route path="/api" element={<ApiPage />} />
<Route path="/cliproxy/control-panel" element={<CliproxyControlPanelPage />} /> <Route path="/cliproxy" element={<CliproxyPage />} />
<Route path="/accounts" element={<AccountsPage />} /> <Route path="/cliproxy/control-panel" element={<CliproxyControlPanelPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/accounts" element={<AccountsPage />} />
<Route path="/health" element={<HealthPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/shared" element={<SharedPage />} /> <Route path="/health" element={<HealthPage />} />
</Route> <Route path="/shared" element={<SharedPage />} />
</Routes> </Route>
<Toaster position="top-right" /> </Routes>
</BrowserRouter> <Toaster position="top-right" />
</BrowserRouter>
</PrivacyProvider>
</ThemeProvider> </ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
); );
+36 -6
View File
@@ -9,6 +9,7 @@ import { cn } from '@/lib/utils';
import { ProviderIcon } from '@/components/provider-icon'; import { ProviderIcon } from '@/components/provider-icon';
import { PROVIDER_COLORS } from '@/lib/provider-config'; import { PROVIDER_COLORS } from '@/lib/provider-config';
import { STATUS_COLORS } from '@/lib/utils'; import { STATUS_COLORS } from '@/lib/utils';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { import {
ChevronRight, ChevronRight,
X, X,
@@ -94,7 +95,13 @@ function formatTimelineTime(date: Date): string {
} }
/** Connection Timeline Component - right sidebar panel */ /** Connection Timeline Component - right sidebar panel */
function ConnectionTimeline({ events }: { events: ConnectionEvent[] }) { function ConnectionTimeline({
events,
privacyMode,
}: {
events: ConnectionEvent[];
privacyMode: boolean;
}) {
if (events.length === 0) { if (events.length === 0) {
return ( return (
<div className="h-full flex items-center justify-center rounded-xl bg-muted/20 dark:bg-zinc-900/40 border border-border/30 dark:border-white/[0.05]"> <div className="h-full flex items-center justify-center rounded-xl bg-muted/20 dark:bg-zinc-900/40 border border-border/30 dark:border-white/[0.05]">
@@ -149,7 +156,12 @@ function ConnectionTimeline({ events }: { events: ConnectionEvent[] }) {
{/* Event content */} {/* Event content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-mono text-foreground truncate"> <span
className={cn(
'text-[10px] font-mono text-foreground truncate',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{cleanEmail(event.accountEmail)} {cleanEmail(event.accountEmail)}
</span> </span>
<span className="text-[9px] text-muted-foreground font-mono flex-shrink-0"> <span className="text-[9px] text-muted-foreground font-mono flex-shrink-0">
@@ -217,6 +229,9 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
const [selectedAccount, setSelectedAccount] = useState<AccountData | null>(null); const [selectedAccount, setSelectedAccount] = useState<AccountData | null>(null);
const [paths, setPaths] = useState<string[]>([]); const [paths, setPaths] = useState<string[]>([]);
// Privacy mode for demo purposes
const { privacyMode } = usePrivacy();
// Drag state for all cards (account IDs + 'provider') // Drag state for all cards (account IDs + 'provider')
const [dragOffsets, setDragOffsets] = useState<Record<string, DragOffset>>({}); const [dragOffsets, setDragOffsets] = useState<Record<string, DragOffset>>({});
const [draggingId, setDraggingId] = useState<string | null>(null); const [draggingId, setDraggingId] = useState<string | null>(null);
@@ -494,7 +509,12 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
{/* Drag handle indicator */} {/* Drag handle indicator */}
<GripVertical className="absolute top-1/2 left-1 -translate-y-1/2 w-3 h-3 text-muted-foreground/40" /> <GripVertical className="absolute top-1/2 left-1 -translate-y-1/2 w-3 h-3 text-muted-foreground/40" />
<div className="flex justify-between items-start mb-1 ml-3"> <div className="flex justify-between items-start mb-1 ml-3">
<span className="text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]"> <span
className={cn(
'text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{cleanEmail(account.email)} {cleanEmail(account.email)}
</span> </span>
<ChevronRight <ChevronRight
@@ -672,7 +692,12 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
isHovered ? 'opacity-100' : 'opacity-0' isHovered ? 'opacity-100' : 'opacity-0'
)} )}
/> />
<span className="text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]"> <span
className={cn(
'text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{cleanEmail(account.email)} {cleanEmail(account.email)}
</span> </span>
</div> </div>
@@ -704,7 +729,7 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
{/* Right Section: Connection Timeline - Fixed compact width */} {/* Right Section: Connection Timeline - Fixed compact width */}
<div className="w-56 flex-shrink-0"> <div className="w-56 flex-shrink-0">
<ConnectionTimeline events={connectionEvents} /> <ConnectionTimeline events={connectionEvents} privacyMode={privacyMode} />
</div> </div>
</div> </div>
@@ -733,7 +758,12 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
className="w-2 h-2 rounded-full" className="w-2 h-2 rounded-full"
style={{ backgroundColor: selectedAccount.color }} style={{ backgroundColor: selectedAccount.color }}
/> />
<span className="text-sm font-semibold text-foreground tracking-tight truncate"> <span
className={cn(
'text-sm font-semibold text-foreground tracking-tight truncate',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{cleanEmail(selectedAccount.email)} {cleanEmail(selectedAccount.email)}
</span> </span>
</div> </div>
+3 -1
View File
@@ -12,6 +12,7 @@ import { getProviderDisplayName, PROVIDER_COLORS } from '@/lib/provider-config';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { ProviderIcon } from '@/components/provider-icon'; import { ProviderIcon } from '@/components/provider-icon';
import { AccountFlowViz } from '@/components/account-flow-viz'; import { AccountFlowViz } from '@/components/account-flow-viz';
import { usePrivacy } from '@/contexts/privacy-context';
import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
import { Activity, CheckCircle2, XCircle, ChevronRight, Radio } from 'lucide-react'; import { Activity, CheckCircle2, XCircle, ChevronRight, Radio } from 'lucide-react';
@@ -114,6 +115,7 @@ function InlineStatsBadge({ success, failure }: { success: number; failure: numb
export function AuthMonitor() { export function AuthMonitor() {
const { data, isLoading, error } = useCliproxyAuth(); const { data, isLoading, error } = useCliproxyAuth();
const { data: statsData, isLoading: statsLoading, dataUpdatedAt } = useCliproxyStats(); const { data: statsData, isLoading: statsLoading, dataUpdatedAt } = useCliproxyStats();
const { privacyMode } = usePrivacy();
const [selectedProvider, setSelectedProvider] = useState<string | null>(null); const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [hoveredProvider, setHoveredProvider] = useState<string | null>(null); const [hoveredProvider, setHoveredProvider] = useState<string | null>(null);
const [timeSinceUpdate, setTimeSinceUpdate] = useState(''); const [timeSinceUpdate, setTimeSinceUpdate] = useState('');
@@ -406,7 +408,7 @@ export function AuthMonitor() {
key={acc.id} key={acc.id}
className="w-2 h-2 rounded-full" className="w-2 h-2 rounded-full"
style={{ backgroundColor: acc.color }} style={{ backgroundColor: acc.color }}
title={cleanEmail(acc.email)} title={privacyMode ? '••••••' : cleanEmail(acc.email)}
/> />
))} ))}
{ps.accounts.length > 5 && ( {ps.accounts.length > 5 && (
+3 -1
View File
@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy'; import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
import { usePrivacy } from '@/contexts/privacy-context';
const providers = ['gemini', 'codex', 'agy', 'qwen', 'iflow'] as const; const providers = ['gemini', 'codex', 'agy', 'qwen', 'iflow'] as const;
@@ -43,6 +44,7 @@ const providerOptions = [
export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
const createMutation = useCreateVariant(); const createMutation = useCreateVariant();
const { data: authData } = useCliproxyAuth(); const { data: authData } = useCliproxyAuth();
const { privacyMode } = usePrivacy();
const { const {
register, register,
@@ -115,7 +117,7 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
<option value="">Use default account</option> <option value="">Use default account</option>
{providerAccounts.map((acc) => ( {providerAccounts.map((acc) => (
<option key={acc.id} value={acc.id}> <option key={acc.id} value={acc.id}>
{acc.email || acc.id} {privacyMode ? '••••••' : acc.email || acc.id}
{acc.isDefault ? ' (default)' : ''} {acc.isDefault ? ' (default)' : ''}
</option> </option>
))} ))}
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge';
import { CheckCircle2, AlertCircle, XCircle, MinusCircle, RefreshCw } from 'lucide-react'; import { CheckCircle2, AlertCircle, XCircle, MinusCircle, RefreshCw } from 'lucide-react';
import { useCliproxyAuth } from '@/hooks/use-cliproxy'; import { useCliproxyAuth } from '@/hooks/use-cliproxy';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
type CredentialStatus = 'ready' | 'warning' | 'error' | 'disabled'; type CredentialStatus = 'ready' | 'warning' | 'error' | 'disabled';
@@ -20,6 +21,7 @@ interface CredentialRowProps {
email?: string; email?: string;
expiresAt?: string; expiresAt?: string;
onRefresh?: () => void; onRefresh?: () => void;
privacyMode?: boolean;
} }
function CredentialRow({ function CredentialRow({
@@ -30,6 +32,7 @@ function CredentialRow({
email, email,
expiresAt, expiresAt,
onRefresh, onRefresh,
privacyMode,
}: CredentialRowProps) { }: CredentialRowProps) {
const statusConfig = { const statusConfig = {
ready: { ready: {
@@ -76,7 +79,9 @@ function CredentialRow({
<Icon className={cn('w-4 h-4', config.color)} /> <Icon className={cn('w-4 h-4', config.color)} />
</div> </div>
<div> <div>
<div className="font-medium text-sm">{email ?? name}</div> <div className={cn('font-medium text-sm', privacyMode && PRIVACY_BLUR_CLASS)}>
{email ?? name}
</div>
<div className="text-xs text-muted-foreground capitalize">{provider}</div> <div className="text-xs text-muted-foreground capitalize">{provider}</div>
</div> </div>
</div> </div>
@@ -124,6 +129,7 @@ function CredentialHealthSkeleton() {
export function CredentialHealthList() { export function CredentialHealthList() {
const { data: authData, isLoading } = useCliproxyAuth(); const { data: authData, isLoading } = useCliproxyAuth();
const { privacyMode } = usePrivacy();
if (isLoading) { if (isLoading) {
return <CredentialHealthSkeleton />; return <CredentialHealthSkeleton />;
@@ -164,7 +170,11 @@ export function CredentialHealthList() {
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{credentials.map((cred, i) => ( {credentials.map((cred, i) => (
<CredentialRow key={`${cred.provider}-${cred.name}-${i}`} {...cred} /> <CredentialRow
key={`${cred.provider}-${cred.name}-${i}`}
{...cred}
privacyMode={privacyMode}
/>
))} ))}
</CardContent> </CardContent>
</Card> </Card>
@@ -57,6 +57,7 @@ import {
useDeletePreset, useDeletePreset,
} from '@/hooks/use-cliproxy'; } from '@/hooks/use-cliproxy';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
// Lazy load CodeEditor // Lazy load CodeEditor
const CodeEditor = lazy(() => const CodeEditor = lazy(() =>
@@ -100,6 +101,7 @@ export function ProviderEditor({
const [conflictDialog, setConflictDialog] = useState(false); const [conflictDialog, setConflictDialog] = useState(false);
const [customPresetOpen, setCustomPresetOpen] = useState(false); const [customPresetOpen, setCustomPresetOpen] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { privacyMode } = usePrivacy();
// Fetch available models from CLIProxy API // Fetch available models from CLIProxy API
const { data: modelsData } = useCliproxyModels(); const { data: modelsData } = useCliproxyModels();
@@ -433,6 +435,7 @@ export function ProviderEditor({
onSetDefault={() => onSetDefault(account.id)} onSetDefault={() => onSetDefault(account.id)}
onRemove={() => onRemoveAccount(account.id)} onRemove={() => onRemoveAccount(account.id)}
isRemoving={isRemovingAccount} isRemoving={isRemovingAccount}
privacyMode={privacyMode}
/> />
))} ))}
</div> </div>
@@ -685,11 +688,13 @@ function AccountItem({
onSetDefault, onSetDefault,
onRemove, onRemove,
isRemoving, isRemoving,
privacyMode,
}: { }: {
account: OAuthAccount; account: OAuthAccount;
onSetDefault: () => void; onSetDefault: () => void;
onRemove: () => void; onRemove: () => void;
isRemoving?: boolean; isRemoving?: boolean;
privacyMode?: boolean;
}) { }) {
return ( return (
<div <div
@@ -709,7 +714,9 @@ function AccountItem({
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-sm">{account.email || account.id}</span> <span className={cn('font-medium text-sm', privacyMode && PRIVACY_BLUR_CLASS)}>
{account.email || account.id}
</span>
{account.isDefault && ( {account.isDefault && (
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5"> <Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
<Star className="w-2.5 h-2.5 fill-current" /> <Star className="w-2.5 h-2.5 fill-current" />
+2
View File
@@ -3,6 +3,7 @@ import { Outlet } from 'react-router-dom';
import { SidebarProvider } from '@/components/ui/sidebar'; import { SidebarProvider } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar'; import { AppSidebar } from '@/components/app-sidebar';
import { ThemeToggle } from '@/components/theme-toggle'; import { ThemeToggle } from '@/components/theme-toggle';
import { PrivacyToggle } from '@/components/privacy-toggle';
import { GitHubLink } from '@/components/github-link'; import { GitHubLink } from '@/components/github-link';
import { DocsLink } from '@/components/docs-link'; import { DocsLink } from '@/components/docs-link';
import { ConnectionIndicator } from '@/components/connection-indicator'; import { ConnectionIndicator } from '@/components/connection-indicator';
@@ -29,6 +30,7 @@ export function Layout() {
<ConnectionIndicator /> <ConnectionIndicator />
<DocsLink /> <DocsLink />
<GitHubLink /> <GitHubLink />
<PrivacyToggle />
<ThemeToggle /> <ThemeToggle />
</div> </div>
</header> </header>
+39
View File
@@ -0,0 +1,39 @@
/**
* Privacy Toggle Button
* Toggles demo mode to blur personal information (emails, account IDs)
*/
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { usePrivacy } from '@/contexts/privacy-context';
import { Eye, EyeOff } from 'lucide-react';
import { cn } from '@/lib/utils';
export function PrivacyToggle() {
const { privacyMode, togglePrivacyMode } = usePrivacy();
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={togglePrivacyMode}
className={cn(
'h-8 w-8 transition-colors',
privacyMode && 'text-amber-600 hover:text-amber-700 dark:text-amber-400'
)}
>
{privacyMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{privacyMode
? 'Privacy mode ON - Click to show data'
: 'Privacy mode OFF - Click to hide data'}
</p>
</TooltipContent>
</Tooltip>
);
}
+12 -2
View File
@@ -39,6 +39,8 @@ import {
import { useCliproxyAuth, useCreateVariant, useStartAuth } from '@/hooks/use-cliproxy'; import { useCliproxyAuth, useCreateVariant, useStartAuth } from '@/hooks/use-cliproxy';
import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
import { MODEL_CATALOGS } from '@/lib/model-catalogs'; import { MODEL_CATALOGS } from '@/lib/model-catalogs';
import { cn } from '@/lib/utils';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
interface QuickSetupWizardProps { interface QuickSetupWizardProps {
open: boolean; open: boolean;
@@ -68,6 +70,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
const { data: authData, refetch } = useCliproxyAuth(); const { data: authData, refetch } = useCliproxyAuth();
const createMutation = useCreateVariant(); const createMutation = useCreateVariant();
const startAuthMutation = useStartAuth(); const startAuthMutation = useStartAuth();
const { privacyMode } = usePrivacy();
// Get auth status for selected provider // Get auth status for selected provider
const providerAuth = authData?.authStatus.find( const providerAuth = authData?.authStatus.find(
@@ -347,7 +350,9 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
<User className="w-4 h-4 text-muted-foreground" /> <User className="w-4 h-4 text-muted-foreground" />
</div> </div>
<div> <div>
<div className="font-medium">{acc.email || acc.id}</div> <div className={cn('font-medium', privacyMode && PRIVACY_BLUR_CLASS)}>
{acc.email || acc.id}
</div>
{acc.isDefault && ( {acc.isDefault && (
<div className="text-xs text-muted-foreground">Default account</div> <div className="text-xs text-muted-foreground">Default account</div>
)} )}
@@ -403,7 +408,12 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
{selectedAccount && ( {selectedAccount && (
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded-md text-sm"> <div className="flex items-center gap-2 p-2 bg-muted/50 rounded-md text-sm">
<User className="w-4 h-4" /> <User className="w-4 h-4" />
<span>Using: {selectedAccount.email || selectedAccount.id}</span> <span>
Using:{' '}
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
{selectedAccount.email || selectedAccount.id}
</span>
</span>
</div> </div>
)} )}
+54
View File
@@ -0,0 +1,54 @@
/**
* Privacy Context - Global demo mode for censoring personal information
* Provides blur effect on emails, account IDs, and other PII
* Persists state to localStorage
*/
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
interface PrivacyContextValue {
/** Whether privacy/demo mode is enabled */
privacyMode: boolean;
/** Toggle privacy mode on/off */
togglePrivacyMode: () => void;
}
const PrivacyContext = createContext<PrivacyContextValue | null>(null);
const STORAGE_KEY = 'ccs-privacy-mode';
export function PrivacyProvider({ children }: { children: ReactNode }) {
const [privacyMode, setPrivacyMode] = useState(() => {
// Initialize from localStorage
if (typeof window !== 'undefined') {
return localStorage.getItem(STORAGE_KEY) === 'true';
}
return false;
});
// Persist to localStorage when changed
useEffect(() => {
localStorage.setItem(STORAGE_KEY, String(privacyMode));
}, [privacyMode]);
const togglePrivacyMode = () => setPrivacyMode((prev) => !prev);
return (
<PrivacyContext.Provider value={{ privacyMode, togglePrivacyMode }}>
{children}
</PrivacyContext.Provider>
);
}
export function usePrivacy() {
const context = useContext(PrivacyContext);
if (!context) {
throw new Error('usePrivacy must be used within a PrivacyProvider');
}
return context;
}
/** CSS class for blurring sensitive content */
export const PRIVACY_BLUR_CLASS =
'blur-[4px] select-none hover:blur-none transition-all duration-200';