Remove plaintext-transactions feature flag & E2E references (#116)

## Summary

- Removes the `plaintext-transactions` Pennant feature flag — plaintext
is now the default for all users
- Removes encryption guards and `isPlaintext` conditionals from
transaction create/edit/import flows, keeping only the plaintext code
paths
- Makes `EncryptionKeyButton` conditional — only shown when user has
legacy encrypted accounts or transactions
- Removes encryption onboarding steps (`step-encryption-explained`,
`step-encryption-setup`) and related state/props
- Updates landing page to replace E2E encryption marketing with
privacy-first messaging ("Your Data, Your Rules" section)
- Updates privacy policy to replace E2E encryption claims with accurate
security language (encryption at rest, TLS in transit, no third-party
sharing)
- Cleans up tests to remove feature flag assertions and
`Feature::activate()` calls

**22 files changed, 145 insertions, 730 deletions**

## Test plan

- [x] Create a transaction without encryption key unlocked — should
succeed
- [x] EncryptionKeyButton should not appear in header for users with no
encrypted data
- [x] EncryptionKeyButton should appear for users with legacy encrypted
transactions/accounts
- [x] Landing page has no E2E encryption references, shows new privacy
section
- [x] Onboarding flow has no encryption setup steps
- [x] Privacy policy reflects accurate security language
- [x] Frontend builds successfully (`bun run build`)
- [x] All linting passes (`bun run lint`, `vendor/bin/pint --dirty`)
This commit is contained in:
Víctor Falcón 2026-02-13 11:10:21 +01:00 committed by GitHub
parent eeca437586
commit 6b05de173a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 205 additions and 933 deletions

View File

@ -9,12 +9,14 @@ Whisper Money is a privacy-first personal finance app with end-to-end encryption
## Commands
### Development
```bash
composer run dev # Start full dev environment (PHP server, queue, Vite, logs)
bun run dev # Vite dev server only
```
### Build & Quality
```bash
bun run build # Production build (don't run automatically - ask user)
bun run format # Format code with Prettier
@ -23,13 +25,15 @@ vendor/bin/pint --dirty # PHP code formatting
```
### Testing
```bash
php artisan test # Run all tests
php artisan test --exclue-testsuite=Browser # Run all tests
php artisan test tests/Feature/ExampleTest.php # Run specific file
php artisan test --filter=testName # Filter by test name
php artisan test --filter=testName # Filter by test name
```
### CI Requirements (must pass before finalizing changes)
```bash
bun install --frozen-lockfile
composer install --no-interaction --prefer-dist --optimize-autoloader
@ -43,18 +47,21 @@ bun run lint
## Architecture
### Backend (Laravel 12)
- **Streamlined structure**: No middleware files in `app/Http/Middleware/`, configuration in `bootstrap/app.php`
- **Commands auto-register**: Files in `app/Console/Commands/` are automatically available
- **Form Requests**: Always use for validation instead of inline controller validation
- **Eloquent**: Prefer `Model::query()` over `DB::`, use eager loading to prevent N+1
### Frontend (React 19 + Inertia v2)
- **Pages**: `resources/js/pages/` - Inertia page components
- **Components**: `resources/js/components/` - Reusable UI components
- **Services**: `resources/js/services/` - Sync services with IndexedDB (Dexie) for offline support
- **Wayfinder**: Type-safe routes generated in `resources/js/routes/` and `resources/js/actions/`
### Wayfinder Usage
```typescript
// Import controller methods (tree-shakable)
import { show, store } from '@/actions/App/Http/Controllers/PostController'
@ -67,6 +74,7 @@ show.url(1) // "/posts/1"
```
### Navigation
- Use `router.visit()` from `@inertiajs/react` or `<Link>` component
- Never use traditional `<a>` tags for internal navigation
@ -202,7 +210,7 @@ This project has domain-specific skills available. You MUST activate the relevan
## Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- <code-snippet>public function \_\_construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
## Type Declarations
@ -414,4 +422,4 @@ Fortify is a headless authentication backend that provides authentication routes
- `Features::updateProfileInformation()` to let users update their profile.
- `Features::updatePasswords()` to let users change their passwords.
- `Features::resetPasswords()` for password reset via email.
</laravel-boost-guidelines>
</laravel-boost-guidelines>

View File

@ -49,6 +49,6 @@ trait ResolvesFeatures
private function getStringBasedFeatures(): array
{
return ['plaintext-transactions', 'open-banking', 'account-mapping'];
return ['open-banking', 'account-mapping'];
}
}

View File

@ -27,7 +27,6 @@ class OnboardingController extends Controller
return Inertia::render('onboarding/index', [
'banks' => $banks,
'accounts' => $accounts,
'hasEncryptionSetup' => $user->encryption_salt !== null,
]);
}

View File

@ -73,7 +73,6 @@ class HandleInertiaRequests extends Middleware
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'features' => [
'cashflow' => true,
'plaintext-transactions' => $user ? Feature::for($user)->active('plaintext-transactions') : false,
'open-banking' => $user ? Feature::for($user)->active('open-banking') : false,
'account-mapping' => $user ? Feature::for($user)->active('account-mapping') : false,
],
@ -101,6 +100,7 @@ class HandleInertiaRequests extends Middleware
->orderBy('name')
->get(['id', 'name', 'color']) : [],
'hasEncryptedAccounts' => $user?->accounts()->where('encrypted', true)->exists() ?? false,
'hasEncryptionSetup' => $user?->encryption_salt !== null,
'locale' => app()->getLocale(),
'translations' => $this->getTranslations(),
];

View File

@ -5,7 +5,6 @@ namespace App\Http\Requests;
use App\Enums\TransactionSource;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Laravel\Pennant\Feature;
class StoreTransactionRequest extends FormRequest
{
@ -31,9 +30,7 @@ class StoreTransactionRequest extends FormRequest
}),
],
'description' => ['required', 'string'],
'description_iv' => Feature::for($this->user())->active('plaintext-transactions')
? ['nullable', 'string', 'size:16']
: ['required', 'string', 'size:16'],
'description_iv' => ['nullable', 'string', 'size:16'],
'transaction_date' => ['required', 'date'],
'amount' => ['required', 'integer'],
'currency_code' => ['required', 'string', 'size:3'],
@ -59,7 +56,6 @@ class StoreTransactionRequest extends FormRequest
'category_id.exists' => 'The selected category does not exist or does not belong to you.',
'label_ids.*.exists' => 'One or more selected labels do not exist or do not belong to you.',
'description.required' => 'The description is required.',
'description_iv.required' => 'The description IV is required.',
'description_iv.size' => 'The description IV must be exactly 16 characters.',
'transaction_date.required' => 'The transaction date is required.',
'transaction_date.date' => 'The transaction date must be a valid date.',

View File

@ -4,7 +4,6 @@ namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Laravel\Pennant\Feature;
class UpdateTransactionRequest extends FormRequest
{
@ -23,9 +22,7 @@ class UpdateTransactionRequest extends FormRequest
}),
],
'description' => ['sometimes', 'string'],
'description_iv' => Feature::for($this->user())->active('plaintext-transactions')
? ['nullable', 'string', 'size:16']
: ['sometimes', 'string', 'size:16'],
'description_iv' => ['nullable', 'string', 'size:16'],
'notes' => ['nullable', 'string'],
'notes_iv' => ['nullable', 'string', 'size:16'],
'label_ids' => ['nullable', 'array'],

View File

@ -50,7 +50,6 @@ class AppServiceProvider extends ServiceProvider
return Limit::perSecond(30);
});
Feature::define('plaintext-transactions', fn (User $user) => false);
Feature::define('open-banking', fn (User $user) => false);
Feature::define('account-mapping', fn (User $user) => false);
}

View File

@ -56,6 +56,8 @@ createInertiaApp({
| undefined;
const initialUser = initialPageProps?.auth?.user ?? null;
const initialIsAuthenticated = Boolean(initialUser);
const hasEncryptionSetup =
(initialPageProps?.hasEncryptionSetup as boolean) ?? false;
// Initialize translations from server-rendered page data
setTranslations(
@ -72,7 +74,7 @@ createInertiaApp({
root.render(
<StrictMode>
<EncryptionKeyProvider>
<EncryptionKeyProvider hasEncryptionSetup={hasEncryptionSetup}>
<PrivacyModeProvider>
<SyncProvider
initialIsAuthenticated={initialIsAuthenticated}

View File

@ -110,7 +110,11 @@ export function AccountListCard({
</div>
</div>
<div className="h-[100px] w-full">
<ResponsiveContainer width="100%" height="100%" initialDimension={{ width: 1, height: 1 }}>
<ResponsiveContainer
width="100%"
height="100%"
initialDimension={{ width: 1, height: 1 }}
>
<LineChart data={account.history}>
<Tooltip
content={({ active, payload }) => {

View File

@ -3,7 +3,11 @@ import { EncryptionKeyButton } from '@/components/encryption-key-button';
import { ImportTransactionsButton } from '@/components/transactions/import-transactions-button';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
import {
type BreadcrumbItem as BreadcrumbItemType,
type SharedData,
} from '@/types';
import { usePage } from '@inertiajs/react';
import AppLogo from './app-logo';
import { NavUser } from './nav-user';
@ -12,6 +16,8 @@ export function AppSidebarHeader({
}: {
breadcrumbs?: BreadcrumbItemType[];
}) {
const { hasEncryptionSetup } = usePage<SharedData>().props;
return (
<header className="pt-safe flex min-h-16 shrink-0 items-center justify-between gap-2 border-b border-sidebar-border/50 px-5 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:min-h-12 sm:px-6 md:px-4">
<div className="flex items-center gap-2 sm:hidden">
@ -23,11 +29,15 @@ export function AppSidebarHeader({
</div>
<div className="flex items-center gap-2">
<ImportTransactionsButton />
<Separator
orientation="vertical"
className="data-[orientation=vertical]:h-6"
/>
<EncryptionKeyButton />
{hasEncryptionSetup && (
<>
<Separator
orientation="vertical"
className="data-[orientation=vertical]:h-6"
/>
<EncryptionKeyButton />
</>
)}
<Separator
orientation="vertical"
className="data-[orientation=vertical]:h-6 sm:hidden"

View File

@ -95,7 +95,11 @@ export function AccountBalanceCard({
/>
</div>
<div className="h-[70px] w-full max-w-[250px] flex-1">
<ResponsiveContainer width="100%" height="100%" initialDimension={{ width: 1, height: 1 }}>
<ResponsiveContainer
width="100%"
height="100%"
initialDimension={{ width: 1, height: 1 }}
>
<LineChart data={account.history}>
<Tooltip
content={({ active, payload }) => {

View File

@ -1,106 +0,0 @@
import { StepButton } from '@/components/onboarding/step-button';
import { StepHeader } from '@/components/onboarding/step-header';
import { __ } from '@/utils/i18n';
import { Eye, EyeOff, Lock, Server, Shield, User } from 'lucide-react';
interface StepEncryptionExplainedProps {
onContinue: () => void;
}
export function StepEncryptionExplained({
onContinue,
}: StepEncryptionExplainedProps) {
return (
<div className="flex animate-in flex-col items-center text-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
icon={Shield}
iconContainerClassName="bg-gradient-to-br from-blue-500 to-indigo-600"
title={__('Your Data, Your Privacy')}
description={__(
"Whisper Money uses end-to-end encryption to protect your financial data. Here's how it works:",
)}
/>
<div className="mb-5 grid w-full max-w-xl gap-4 sm:mb-4">
<Item
title={__('Create a Password')}
description={__(
'Only you know this password. It never leaves your device.',
)}
icon={
<User className="size-4 text-emerald-600 dark:text-emerald-400" />
}
/>
<Item
title={__('Data is Encrypted')}
description={__(
'Your data is encrypted before it leaves your browser.',
)}
icon={
<Lock className="size-4 text-blue-600 dark:text-blue-400" />
}
/>
<Item
title={__("We Can't Read It")}
description={__(
"Even we can't access your data. It's truly private.",
)}
icon={
<Server className="size-4 text-violet-600 dark:text-violet-400" />
}
/>
</div>
<div className="mb-8 flex w-full max-w-xl flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-muted-foreground/20 p-4">
<div className="flex items-center gap-2">
<Eye className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">
{__('You see:')}
</span>
<span className="font-mono text-emerald-600 dark:text-emerald-400">
{__('STARBUCKS@TEKKA PLC')}
</span>
</div>
<div className="flex items-center gap-2">
<EyeOff className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">{__('We see:')}</span>
<span className="font-mono text-muted-foreground">
{__('$KO!F6LMHU1W%TAEQFZMD9')}
</span>
</div>
</div>
<StepButton
text={__('I Understand, Continue')}
onClick={onContinue}
data-testid="encryption-continue-button"
/>
</div>
);
}
function Item({
title,
description,
icon,
}: {
title: string;
description: string;
icon: React.ReactNode;
}) {
return (
<div className="flex w-full flex-col items-start gap-2 rounded-xl border bg-card p-3 sm:p-5">
<div className="flex flex-row items-center gap-2">
{icon}
<h3 className="font-semibold">{title}</h3>
</div>
<div className="text-left sm:text-center">
<p className="text-sm text-pretty text-muted-foreground">
{description}
</p>
</div>
</div>
);
}

View File

@ -1,241 +0,0 @@
import { StepHeader } from '@/components/onboarding/step-header';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import {
bufferToBase64,
encrypt,
exportKey,
generateSalt,
getAESKeyFromPBKDF,
getKeyFromPassword,
} from '@/lib/crypto';
import { storeKey } from '@/lib/key-storage';
import { __ } from '@/utils/i18n';
import axios from 'axios';
import { AlertCircle, CheckCircle2, KeyRound } from 'lucide-react';
import { useState } from 'react';
import { StepButton } from './step-button';
interface StepEncryptionSetupProps {
onComplete: () => void;
}
export function StepEncryptionSetup({ onComplete }: StepEncryptionSetupProps) {
const { refreshKeyState } = useEncryptionKey();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [storagePreference, setStoragePreference] = useState<
'session' | 'persistent'
>('session');
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const passwordStrength = getPasswordStrength(password);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
if (password.length < 12) {
setError(__('Password must be at least 12 characters'));
return;
}
if (password !== confirmPassword) {
setError(__('Passwords do not match'));
return;
}
setProcessing(true);
try {
const salt = generateSalt();
const pbkdfKey = await getKeyFromPassword(password);
const aesKey = await getAESKeyFromPBKDF(pbkdfKey, salt);
const { encrypted, iv } = await encrypt('Hello, world', aesKey);
const exportedKey = await exportKey(aesKey);
await axios.post('/api/encryption/setup', {
salt: bufferToBase64(salt),
encrypted_content: encrypted,
iv: iv,
});
storeKey(exportedKey, storagePreference === 'persistent');
refreshKeyState();
onComplete();
} catch (err) {
console.error('Encryption setup error:', err);
setError(__('Failed to setup encryption. Please try again.'));
setProcessing(false);
}
}
return (
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
icon={KeyRound}
iconContainerClassName="bg-gradient-to-br from-amber-400 to-orange-500"
title={__('Create Your Encryption Password')}
description={__(
"This password will encrypt all your financial data. Make it strong and memorable \u2014 we can't recover it for you.",
)}
/>
<form onSubmit={handleSubmit} className="w-full max-w-md space-y-6">
<div className="space-y-2">
<Label htmlFor="password">
{__('Encryption Password')}
</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={__('Enter a strong password')}
disabled={processing}
autoComplete="new-password"
required
minLength={12}
/>
<div className="flex items-center justify-between gap-2">
<div className="flex flex-1 items-center gap-2">
<div className="flex flex-1 gap-1">
{[1, 2, 3, 4].map((level) => (
<div
key={level}
className={`h-1.5 flex-1 rounded-full transition-colors ${
level <= passwordStrength.level
? passwordStrength.color
: 'bg-muted'
}`}
/>
))}
</div>
<span className="text-xs text-muted-foreground">
{__(passwordStrength.label)}
</span>
</div>
<span
className={`text-xs ${password.length >= 12 ? 'text-emerald-600' : 'text-muted-foreground'}`}
>
{password.length}
{__('/12 min')}
</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">
{__('Confirm Password')}
</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={__('Confirm your password')}
disabled={processing}
autoComplete="new-password"
required
/>
{confirmPassword && password === confirmPassword && (
<div className="flex items-center gap-1 text-xs text-emerald-600">
<CheckCircle2 className="h-3 w-3" />
{__('Passwords match')}
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="storagePreference">
{__('Key Storage')}
</Label>
<Select
value={storagePreference}
onValueChange={(value) =>
setStoragePreference(
value as 'session' | 'persistent',
)
}
disabled={processing}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="session">
{__('Session only (more secure)')}
</SelectItem>
<SelectItem value="persistent">
{__('Keep me logged in (convenient)')}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{storagePreference === 'session'
? __(
'Your key will be cleared when you close the browser.',
)
: __('Your key will be stored until you log out.')}
</p>
</div>
{error && (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<StepButton
type="submit"
disabled={processing || password.length < 12}
loading={processing}
loadingText={__('Setting up encryption...')}
text={__('Setup Encryption')}
className="w-full sm:w-full"
/>
</form>
</div>
);
}
function getPasswordStrength(password: string): {
level: number;
label: string;
color: string;
} {
if (!password) {
return { level: 0, label: '', color: 'bg-muted' };
}
let score = 0;
if (password.length >= 6) score++;
if (password.length >= 12) score++;
if (/[A-Z]/.test(password) || /[a-z]/.test(password)) score++;
if (/\d/.test(password)) score++;
if (/[0-9]/.test(password)) score++;
if (score <= 1) {
return { level: 1, label: 'Weak', color: 'bg-red-500' };
}
if (score === 2) {
return { level: 2, label: 'Fair', color: 'bg-orange-500' };
}
if (score === 3) {
return { level: 3, label: 'Good', color: 'bg-yellow-500' };
}
return { level: 4, label: 'Strong', color: 'bg-emerald-500' };
}

View File

@ -1,7 +1,4 @@
import { CategorySelect } from '@/components/transactions/category-select';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { cn } from '@/lib/utils';
import { transactionSyncService } from '@/services/transaction-sync';
import { type Account, type Bank } from '@/types/account';
@ -9,7 +6,6 @@ import { type Category } from '@/types/category';
import { type DecryptedTransaction } from '@/types/transaction';
import { __ } from '@/utils/i18n';
import { useState } from 'react';
import { toast } from 'sonner';
interface CategoryCellProps {
transaction: DecryptedTransaction;
@ -30,40 +26,19 @@ export function CategoryCell({
className,
withoutChevronIcon,
}: CategoryCellProps) {
const { isKeySet } = useEncryptionKey();
const [isUpdating, setIsUpdating] = useState(false);
async function handleCategoryChange(value: string) {
if (!isKeySet) {
toast.error(
'Please unlock your encryption key to update transactions',
);
return;
}
const categoryId = value === 'null' ? null : value;
setIsUpdating(true);
try {
const updateData: {
category_id: string | null;
notes?: string;
notes_iv?: string;
} = {
category_id: categoryId,
};
if (transaction.notes) {
const keyString = getStoredKey();
if (!keyString) {
throw new Error('Encryption key not available');
}
const key = await importKey(keyString);
const encrypted = await encrypt(transaction.notes, key);
updateData.notes = encrypted.encrypted;
updateData.notes_iv = encrypted.iv;
}
await transactionSyncService.update(transaction.id, updateData);
const updatedCategory = categoryId

View File

@ -25,15 +25,13 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { useSyncContext } from '@/contexts/sync-context';
import { useLocale } from '@/hooks/use-locale';
import { decrypt, encrypt, importKey } from '@/lib/crypto';
import { decrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { evaluateRulesForNewTransaction } from '@/lib/rule-engine';
import { appendNoteIfNotPresent } from '@/lib/utils';
import { transactionSyncService } from '@/services/transaction-sync';
import { type SharedData } from '@/types';
import {
filterTransactionalAccounts,
type Account,
@ -45,7 +43,6 @@ import { type Label } from '@/types/label';
import { type DecryptedTransaction } from '@/types/transaction';
import { formatDate } from '@/utils/date';
import { __ } from '@/utils/i18n';
import { usePage } from '@inertiajs/react';
import { getYear, parseISO } from 'date-fns';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
@ -79,10 +76,7 @@ export function EditTransactionDialog({
const STORAGE_KEY_UPDATE_BALANCE =
'whisper_money_update_balance_on_transaction';
const { isKeySet } = useEncryptionKey();
const { sync } = useSyncContext();
const { features } = usePage<SharedData>().props;
const isPlaintext = features['plaintext-transactions'];
const [transactionDate, setTransactionDate] = useState('');
const [description, setDescription] = useState('');
const [amount, setAmount] = useState<number>(0);
@ -336,13 +330,6 @@ export function EditTransactionDialog({
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!isPlaintext && !isKeySet) {
toast.error(
__('Please unlock your encryption key to save transactions'),
);
return;
}
if (mode === 'create') {
if (!description.trim()) {
toast.error(__('Description is required'));
@ -373,11 +360,6 @@ export function EditTransactionDialog({
setIsSubmitting(true);
try {
const trimmedDescription = description.trim();
const keyString = getStoredKey();
if (!isPlaintext && !keyString) {
throw new Error(__('Encryption key not available'));
}
const key = keyString ? await importKey(keyString) : null;
if (mode === 'create') {
const ruleResult = await checkAndApplyAutomationRules();
@ -399,30 +381,10 @@ export function EditTransactionDialog({
finalLabelIds = [...ruleResult.labelIds];
}
let finalDescription: string;
let finalDescriptionIv: string | null;
let encryptedNotes: string | null = null;
let notesIv: string | null = null;
if (isPlaintext) {
finalDescription = trimmedDescription;
finalDescriptionIv = null;
encryptedNotes = finalNotes || null;
notesIv = null;
} else {
const encryptedDescription = await encrypt(
trimmedDescription,
key!,
);
finalDescription = encryptedDescription.encrypted;
finalDescriptionIv = encryptedDescription.iv;
if (finalNotes) {
const encrypted = await encrypt(finalNotes, key!);
encryptedNotes = encrypted.encrypted;
notesIv = encrypted.iv;
}
}
const finalDescription = trimmedDescription;
const finalDescriptionIv = null;
const encryptedNotes = finalNotes || null;
const notesIv = null;
const selectedAccount = accounts.find(
(acc) => acc.id === accountId,
@ -505,14 +467,8 @@ export function EditTransactionDialog({
let encryptedNotes: string | null = null;
let notesIv: string | null = null;
if (isPlaintext) {
encryptedNotes = trimmedNotes || null;
notesIv = null;
} else if (trimmedNotes) {
const encrypted = await encrypt(trimmedNotes, key!);
encryptedNotes = encrypted.encrypted;
notesIv = encrypted.iv;
}
encryptedNotes = trimmedNotes || null;
notesIv = null;
const updateData: {
category_id: string | null;
@ -535,17 +491,8 @@ export function EditTransactionDialog({
transaction.source === 'manually_created' &&
trimmedDescription
) {
if (isPlaintext) {
updateData.description = trimmedDescription;
updateData.description_iv = null;
} else {
const encryptedDescription = await encrypt(
trimmedDescription,
key!,
);
updateData.description = encryptedDescription.encrypted;
updateData.description_iv = encryptedDescription.iv;
}
updateData.description = trimmedDescription;
updateData.description_iv = null;
finalDecryptedDescription = trimmedDescription;
}

View File

@ -8,7 +8,6 @@ import {
DrawerTitle,
} from '@/components/ui/drawer';
import { Progress } from '@/components/ui/progress';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { importKey } from '@/lib/crypto';
import {
autoDetectColumns,
@ -70,9 +69,7 @@ export function ImportTransactionsDrawer({
onOpenChange,
onImportComplete,
}: ImportTransactionsDrawerProps) {
const { isKeySet } = useEncryptionKey();
const { features, locale } = usePage<SharedData>().props;
const isPlaintext = features['plaintext-transactions'];
const { locale } = usePage<SharedData>().props;
const [isImporting, setIsImporting] = useState(false);
const [importProgress, setImportProgress] = useState(0);
const [importTotal, setImportTotal] = useState(0);
@ -314,11 +311,6 @@ export function ImportTransactionsDrawer({
};
const handleConfirmImport = async () => {
if (!isPlaintext && !isKeySet) {
setError('Please unlock your encryption key first');
return;
}
setIsImporting(true);
setError(null);
setImportErrors([]);
@ -351,20 +343,8 @@ export function ImportTransactionsDrawer({
batch.map(async (transaction, batchIndex) => {
const rowNumber = i + batchIndex + 1;
let encrypted: string;
let iv: string | null;
if (isPlaintext) {
encrypted = transaction.description;
iv = null;
} else {
const result =
await transactionSyncService.encryptDescription(
transaction.description,
);
encrypted = result.encrypted;
iv = result.iv;
}
const encrypted: string = transaction.description;
const iv: string | null = null;
let categoryId: string | null = null;
let notes: string | null = null;
@ -391,20 +371,15 @@ export function ImportTransactionsDrawer({
categoryId = ruleMatch.categoryId;
}
if (ruleMatch.note && ruleMatch.noteIv) {
if (isPlaintext) {
const { decrypt } = await import(
'@/lib/crypto'
);
notes = await decrypt(
ruleMatch.note,
key,
ruleMatch.noteIv,
);
notesIv = null;
} else {
notes = ruleMatch.note;
notesIv = ruleMatch.noteIv;
}
const { decrypt } = await import(
'@/lib/crypto'
);
notes = await decrypt(
ruleMatch.note,
key,
ruleMatch.noteIv,
);
notesIv = null;
}
if (
ruleMatch.labelIds &&

View File

@ -1,7 +1,5 @@
import { useLocale } from '@/hooks/use-locale';
import { type SharedData } from '@/types';
import { __ } from '@/utils/i18n';
import { usePage } from '@inertiajs/react';
import {
Cell,
ColumnFiltersState,
@ -56,7 +54,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { TableCell, TableRow } from '@/components/ui/table';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { decrypt, encrypt, importKey } from '@/lib/crypto';
import { decrypt, importKey } from '@/lib/crypto';
import { consoleDebug } from '@/lib/debug';
import { db } from '@/lib/dexie-db';
import { getStoredKey } from '@/lib/key-storage';
@ -249,9 +247,6 @@ export function TransactionList({
}: TransactionListProps) {
const { isKeySet } = useEncryptionKey();
const locale = useLocale();
const { features } = usePage<SharedData>().props;
const isPlaintext = features['plaintext-transactions'];
const labels = initialLabels;
const [transactions, setTransactions] = useState<DecryptedTransaction[]>(
@ -902,17 +897,8 @@ export function TransactionList({
);
if (combinedNote !== transaction.decryptedNotes) {
if (isPlaintext) {
finalNotes = combinedNote;
finalNotesIv = null;
} else {
const encrypted = await encrypt(
combinedNote,
key,
);
finalNotes = encrypted.encrypted;
finalNotesIv = encrypted.iv;
}
finalNotes = combinedNote;
finalNotesIv = null;
consoleDebug('Combined notes with rule note');
} else {
consoleDebug('Rule note already present, skipping');
@ -1068,17 +1054,8 @@ export function TransactionList({
);
if (combinedNote !== transaction.decryptedNotes) {
if (isPlaintext) {
finalNotes = combinedNote;
finalNotesIv = null;
} else {
const encrypted = await encrypt(
combinedNote,
key,
);
finalNotes = encrypted.encrypted;
finalNotesIv = encrypted.iv;
}
finalNotes = combinedNote;
finalNotesIv = null;
consoleDebug('Combined notes with rule note');
} else {
consoleDebug('Rule note already present, skipping');

View File

@ -3,6 +3,7 @@ import axios from 'axios';
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useState,
@ -26,15 +27,27 @@ const EncryptionKeyContext = createContext<
EncryptionKeyContextType | undefined
>(undefined);
export function EncryptionKeyProvider({ children }: { children: ReactNode }) {
const [isKeySet, setIsKeySet] = useState(false);
interface EncryptionKeyProviderProps {
hasEncryptionSetup: boolean;
children: ReactNode;
}
export function EncryptionKeyProvider({
hasEncryptionSetup,
children,
}: EncryptionKeyProviderProps) {
const [isKeySet, setIsKeySet] = useState(!hasEncryptionSetup);
const [encryptedMessageData, setEncryptedMessageData] =
useState<EncryptedMessageData | null>(null);
function refreshKeyState() {
const refreshKeyState = useCallback(() => {
if (!hasEncryptionSetup) {
setIsKeySet(true);
return;
}
const key = getStoredKey();
setIsKeySet(!!key);
}
}, [hasEncryptionSetup]);
async function fetchEncryptedMessage() {
try {
@ -55,13 +68,17 @@ export function EncryptionKeyProvider({ children }: { children: ReactNode }) {
useEffect(() => {
refreshKeyState();
if (!hasEncryptionSetup) {
return;
}
const interval = setInterval(() => {
const key = getStoredKey();
setIsKeySet(!!key);
}, 1000);
return () => clearInterval(interval);
}, []);
}, [hasEncryptionSetup, refreshKeyState]);
return (
<EncryptionKeyContext.Provider

View File

@ -1,10 +1,7 @@
import { getStoredKey } from '@/lib/key-storage';
import { useCallback, useMemo, useState } from 'react';
export type OnboardingStep =
| 'welcome'
| 'encryption-explained'
| 'encryption-setup'
| 'account-types'
| 'create-account'
| 'category-types'
@ -19,8 +16,6 @@ export type OnboardingStep =
// import-transactions and import-balances are sub-steps that don't increment the counter
const PRIMARY_STEPS: OnboardingStep[] = [
'welcome',
'encryption-explained',
'encryption-setup',
'account-types',
'create-account',
'category-types',
@ -32,8 +27,6 @@ const PRIMARY_STEPS: OnboardingStep[] = [
// Steps that are sub-steps (shown under the same progress position as 'create-account')
const SUB_STEPS: OnboardingStep[] = ['import-transactions', 'import-balances'];
const SKIPPABLE_ENCRYPTION_STEPS: OnboardingStep[] = ['encryption-setup'];
export interface OnboardingState {
currentStep: OnboardingStep;
stepIndex: number;
@ -51,31 +44,12 @@ export interface CreatedAccount {
interface UseOnboardingStateOptions {
existingAccountsCount?: number;
hasEncryptionSetup?: boolean;
}
export function useOnboardingState(options: UseOnboardingStateOptions = {}) {
const { existingAccountsCount = 0, hasEncryptionSetup = false } = options;
const { existingAccountsCount = 0 } = options;
// Check both: backend says encryption is set up AND we have the key in browser storage
// We need the key in storage to actually decrypt data
const hasEncryptionKey = useMemo(() => {
// If backend says encryption is not set up, we definitely don't have a key
if (!hasEncryptionSetup) {
return false;
}
// If backend says it's set up, also check if we have the key locally
return getStoredKey() !== null;
}, [hasEncryptionSetup]);
const primarySteps = useMemo(() => {
if (hasEncryptionKey) {
return PRIMARY_STEPS.filter(
(step) => !SKIPPABLE_ENCRYPTION_STEPS.includes(step),
);
}
return PRIMARY_STEPS;
}, [hasEncryptionKey]);
const primarySteps = PRIMARY_STEPS;
// Determine initial step based on existing state
const initialStep = useMemo((): OnboardingStep => {
@ -140,6 +114,5 @@ export function useOnboardingState(options: UseOnboardingStateOptions = {}) {
goNext,
goBack,
addCreatedAccount,
hasEncryptionKey,
};
}

View File

@ -1,14 +1,12 @@
import { decrypt, encrypt, importKey } from '@/lib/crypto';
import { decrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { evaluateRules } from '@/lib/rule-engine';
import { appendNoteIfNotPresent } from '@/lib/utils';
import { transactionSyncService } from '@/services/transaction-sync';
import type { SharedData } from '@/types';
import type { Account, Bank } from '@/types/account';
import type { AutomationRule } from '@/types/automation-rule';
import type { Category } from '@/types/category';
import type { DecryptedTransaction } from '@/types/transaction';
import { usePage } from '@inertiajs/react';
import { useCallback } from 'react';
import { toast } from 'sonner';
@ -22,9 +20,6 @@ interface ReEvaluateAllOptions {
}
export function useReEvaluateAllTransactions() {
const { features } = usePage<SharedData>().props;
const isPlaintext = features['plaintext-transactions'];
const reEvaluateAll = useCallback(
async (
transactions: DecryptedTransaction[],
@ -95,17 +90,8 @@ export function useReEvaluateAllTransactions() {
);
if (combinedNote !== transaction.decryptedNotes) {
if (isPlaintext) {
finalNotes = combinedNote;
finalNotesIv = null;
} else {
const encrypted = await encrypt(
combinedNote,
key,
);
finalNotes = encrypted.encrypted;
finalNotesIv = encrypted.iv;
}
finalNotes = combinedNote;
finalNotesIv = null;
}
}

View File

@ -3,8 +3,6 @@ import { StepCategoryTypes } from '@/components/onboarding/step-category-types';
import { StepComplete } from '@/components/onboarding/step-complete';
import { StepCreateAccount } from '@/components/onboarding/step-create-account';
import { StepCustomizeCategories } from '@/components/onboarding/step-customize-categories';
import { StepEncryptionExplained } from '@/components/onboarding/step-encryption-explained';
import { StepEncryptionSetup } from '@/components/onboarding/step-encryption-setup';
import { StepImportBalances } from '@/components/onboarding/step-import-balances';
import { StepImportTransactions } from '@/components/onboarding/step-import-transactions';
import { StepMoreAccounts } from '@/components/onboarding/step-more-accounts';
@ -40,14 +38,9 @@ interface ExistingAccount {
interface OnboardingProps {
banks: Bank[];
accounts: ExistingAccount[];
hasEncryptionSetup: boolean;
}
export default function Onboarding({
banks,
accounts,
hasEncryptionSetup,
}: OnboardingProps) {
export default function Onboarding({ banks, accounts }: OnboardingProps) {
const { sync } = useSyncContext();
const hasSyncedRef = useRef(false);
@ -70,7 +63,6 @@ export default function Onboarding({
addCreatedAccount,
} = useOnboardingState({
existingAccountsCount: accounts.length,
hasEncryptionSetup,
});
const handleAccountCreated = async (account: CreatedAccount) => {
@ -120,12 +112,6 @@ export default function Onboarding({
case 'welcome':
return <StepWelcome onContinue={goNext} />;
case 'encryption-explained':
return <StepEncryptionExplained onContinue={goNext} />;
case 'encryption-setup':
return <StepEncryptionSetup onComplete={goNext} />;
case 'account-types':
return <StepAccountTypes onContinue={goNext} />;
@ -193,8 +179,6 @@ export default function Onboarding({
const getStepTitle = (step: OnboardingStep): string => {
const titles: Record<OnboardingStep, string> = {
welcome: __('Welcome'),
'encryption-explained': __('End-to-End Encryption'),
'encryption-setup': __('Setup Encryption'),
'account-types': __('Account Types'),
'create-account': __('Create Account'),
'category-types': __('Categories'),

View File

@ -11,7 +11,7 @@ export default function Privacy() {
<meta
name="description"
content={__(
'Privacy policy for Whisper Money. Learn how we collect, use, and protect your personal information with end-to-end encryption.',
'Privacy policy for Whisper Money. Learn how we collect, use, and protect your personal information.',
)}
/>
@ -181,23 +181,31 @@ export default function Privacy() {
</p>
<ul className="mb-4 list-disc pl-6 text-[#706f6c] dark:text-[#A1A09A]">
<li>
<strong>
{__('End-to-End Encryption:')}
</strong>
<strong>{__('Encryption at Rest:')}</strong>
{__(
'Your\n financial data is encrypted on your device\n before being transmitted to our servers,\n ensuring that only you can access your\n information',
' All data is stored on secure servers with encryption at rest, protecting your information from unauthorized access',
)}
</li>
<li>
<strong>{__('Secure Storage:')}</strong>
<strong>
{__('Encryption in Transit:')}
</strong>
{__(
'All data is\n stored on secure servers with encryption at\n rest',
' All communications between your device and our servers are protected using TLS (Transport Layer Security)',
)}
</li>
<li>
<strong>
{__('No Third-Party Data Sharing:')}
</strong>
{__(
' Your financial data is never shared with advertisers, data brokers, or any third party',
)}
</li>
<li>
<strong>{__('Access Controls:')}</strong>
{__(
'Strict\n access controls and authentication\n mechanisms protect against unauthorized\n access',
' Strict access controls and authentication mechanisms protect against unauthorized access',
)}
</li>
<li>
@ -205,7 +213,7 @@ export default function Privacy() {
{__('Regular Security Audits:')}
</strong>
{__(
'We\n regularly review and update our security\n practices',
' We regularly review and update our security practices',
)}
</li>
</ul>

View File

@ -1,6 +1,6 @@
import { useLocale } from '@/hooks/use-locale';
import { __ } from '@/utils/i18n';
import { Head, router, usePage } from '@inertiajs/react';
import { Head, router } from '@inertiajs/react';
import {
Cell,
ColumnFiltersState,
@ -62,14 +62,14 @@ import { TableCell, TableRow } from '@/components/ui/table';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { useSyncContext } from '@/contexts/sync-context';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { decrypt, encrypt, importKey } from '@/lib/crypto';
import { decrypt, importKey } from '@/lib/crypto';
import { consoleDebug } from '@/lib/debug';
import { db } from '@/lib/dexie-db';
import { getStoredKey } from '@/lib/key-storage';
import { evaluateRules } from '@/lib/rule-engine';
import { appendNoteIfNotPresent, cn } from '@/lib/utils';
import { transactionSyncService } from '@/services/transaction-sync';
import { type BreadcrumbItem, type SharedData } from '@/types';
import { type BreadcrumbItem } from '@/types';
import { type Account, type Bank } from '@/types/account';
import { type AutomationRule } from '@/types/automation-rule';
import { type Category } from '@/types/category';
@ -376,9 +376,6 @@ export default function Transactions({
const { isKeySet } = useEncryptionKey();
const { sync } = useSyncContext();
const locale = useLocale();
const { features } = usePage<SharedData>().props;
const isPlaintext = features['plaintext-transactions'];
const [transactions, setTransactions] = useState<DecryptedTransaction[]>(
[],
);
@ -963,17 +960,8 @@ export default function Transactions({
);
if (combinedNote !== transaction.decryptedNotes) {
if (isPlaintext) {
finalNotes = combinedNote;
finalNotesIv = null;
} else {
const encrypted = await encrypt(
combinedNote,
key,
);
finalNotes = encrypted.encrypted;
finalNotesIv = encrypted.iv;
}
finalNotes = combinedNote;
finalNotesIv = null;
consoleDebug('Combined notes with rule note');
} else {
consoleDebug('Rule note already present, skipping');
@ -1146,17 +1134,8 @@ export default function Transactions({
);
if (combinedNote !== transaction.decryptedNotes) {
if (isPlaintext) {
finalNotes = combinedNote;
finalNotesIv = null;
} else {
const encrypted = await encrypt(
combinedNote,
key,
);
finalNotes = encrypted.encrypted;
finalNotesIv = encrypted.iv;
}
finalNotes = combinedNote;
finalNotesIv = null;
consoleDebug('Combined notes with rule note');
} else {
consoleDebug('Rule note already present, skipping');

View File

@ -1,4 +1,3 @@
import EncryptionVideoPlayer from '@/components/landing/encryption-video-player';
import InstallAppButton from '@/components/landing/install-app-button';
import Header from '@/components/partials/header';
import { Button } from '@/components/ui/button';
@ -37,7 +36,7 @@ const LANDING_IMAGES = [
key: 'bank-accounts',
light: '/images/landing/whisper.money_light_3.png',
dark: '/images/landing/whisper.money_dark_3.png',
alt: 'Transactions encrypted',
alt: 'Your transactions at a glance',
className: 'left-[-24%] group-hover:left-[-32%]',
},
{
@ -227,14 +226,14 @@ export default function Welcome({
<meta
name="description"
content={__(
'The most secure personal finance app with end-to-end encryption. Track expenses, create budgets, and manage your money privately.',
'The most secure privacy-first personal finance app. Track expenses, create budgets, and manage your money privately.',
)}
/>
<meta
name="keywords"
content={__(
'finance app, budgeting, expense tracking, end-to-end encryption, secure finance, personal finance, money management, privacy, encrypted finance app',
'finance app, budgeting, expense tracking, secure finance, personal finance, money management, privacy, privacy-first finance app',
)}
/>
@ -251,7 +250,7 @@ export default function Welcome({
<meta
property="og:description"
content={__(
'Your financial data stays private with end-to-end encryption. The most secure way to manage your personal finances.',
'Your financial data stays private. The most secure way to manage your personal finances.',
)}
/>
@ -282,7 +281,7 @@ export default function Welcome({
<meta
name="twitter:description"
content={__(
'Your financial data stays private with end-to-end encryption. The most secure way to manage your personal finances.',
'Your financial data stays private. The most secure way to manage your personal finances.',
)}
/>
@ -302,11 +301,11 @@ export default function Welcome({
'@type': 'WebApplication',
name: 'Whisper Money',
description:
'The most secure personal finance app with end-to-end encryption. Track expenses, create budgets, and manage your money privately.',
'The most secure privacy-first personal finance app. Track expenses, create budgets, and manage your money privately.',
url: appUrl,
applicationCategory: 'FinanceApplication',
featureList: [
'End-to-end encryption',
'Privacy-first design',
'Smart budgeting',
'Expense tracking',
'Visual insights',
@ -367,9 +366,7 @@ export default function Welcome({
</div>
)}
<p className="text-xs text-[#706f6c] dark:text-[#A1A09A]">
{__(
'Your data stays private with end-to-end encryption.',
)}
{__('Your data stays private. Always.')}
</p>
</div>
</div>
@ -422,68 +419,54 @@ export default function Welcome({
<div className="mx-auto flex max-w-7xl flex-col items-center gap-8 sm:gap-12">
<div className="flex flex-col items-center gap-4 text-center">
<h2 className="max-w-[720px] text-3xl leading-tight font-semibold text-balance sm:text-5xl sm:leading-tight">
{__('How End-to-End Encryption Works')}
{__('Your Data, Your Rules')}
</h2>
<p className="text-md max-w-[640px] font-medium text-[#706f6c] sm:text-xl dark:text-[#A1A09A]">
{__(
'Your financial data is encrypted on your\n device before it ever reaches our servers.',
'We built Whisper Money with one principle: your financial data belongs to you.',
)}
</p>
</div>
<div className="flex flex-col gap-8 sm:flex-row">
<div className="flex w-full grow flex-col items-center gap-4 rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] p-6 text-center dark:border-[#3E3E3A] dark:bg-[#161615]">
<EncryptionVideoPlayer
lightSrc="/images/landing_videos/Whisper Money - Light - Encryption.mp4"
darkSrc="/images/landing_videos/Whisper Money - Dark - Encryption.mp4"
className="w-full max-w-4xl"
/>
<h3 className="text-xl font-semibold">
{__('Your Private Key')}
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__(
'A unique encryption key is generated on your device. Only you have access to it—we never see or store it.',
)}
</p>
</div>
<div className="grid grow-0 gap-8">
{[
{
icon: LockIcon,
title: __('Client-Side Encryption'),
description: __(
'Your transactions, accounts, and budgets are encrypted on your device before syncing to the cloud.',
),
},
{
icon: ShieldCheckIcon,
title: __(
'Zero-Knowledge Architecture',
),
description: __(
"We store encrypted data we can't read. Even if our servers were compromised, your data stays secure.",
),
},
].map((item) => (
<div
key={item.title}
className="flex flex-col items-center justify-center gap-4 rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] p-6 text-center dark:border-[#3E3E3A] dark:bg-[#161615]"
>
<div className="flex size-16 items-center justify-center rounded-full bg-emerald-500/10">
<item.icon className="size-8 text-emerald-600 dark:text-emerald-400" />
</div>
<h3 className="text-xl font-semibold">
{item.title}
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
{item.description}
</p>
<div className="grid w-full gap-8 sm:grid-cols-3">
{[
{
icon: EyeOffIcon,
title: __('No Third-Party Sharing'),
description: __(
'Your financial data is never shared with advertisers, data brokers, or any third party. Period.',
),
},
{
icon: ShieldCheckIcon,
title: __('No AI Snooping'),
description: __(
'We never feed your transactions into AI systems. Your spending habits are yours alone.',
),
},
{
icon: LockIcon,
title: __('You Own Your Data'),
description: __(
'Export or delete your data anytime. We store it securely and never use it for anything other than providing you the service.',
),
},
].map((item) => (
<div
key={item.title}
className="flex flex-col items-center justify-center gap-4 rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] p-6 text-center dark:border-[#3E3E3A] dark:bg-[#161615]"
>
<div className="flex size-16 items-center justify-center rounded-full bg-emerald-500/10">
<item.icon className="size-8 text-emerald-600 dark:text-emerald-400" />
</div>
))}
</div>
<h3 className="text-xl font-semibold">
{item.title}
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
{item.description}
</p>
</div>
))}
</div>
</div>
</section>
@ -513,7 +496,7 @@ export default function Welcome({
</div>
<p className="text-[#706f6c] dark:text-[#A1A09A]">
{__(
"AI can't help you with your transactions\n because they're end-to-end encrypted.\n This is intentional\u2014we believe your\n financial data should never be fed into\n AI systems that you don't control.",
"We believe your financial data should never be fed into AI systems that you don't control. Your transactions stay between you and Whisper Money.",
)}
</p>
</div>
@ -561,7 +544,7 @@ export default function Welcome({
</h3>
<p className="text-lg text-[#706f6c] dark:text-[#A1A09A]">
{__(
"Import a year's worth of transactions in\n under 10 seconds. Simply export a CSV or\n XLS file from your bank and drag it into\n Whisper Money. All data is encrypted\n locally before upload.",
"Import a year's worth of transactions in under 10 seconds. Simply export a CSV or XLS file from your bank and drag it into Whisper Money.",
)}
</p>
</div>
@ -576,7 +559,7 @@ export default function Welcome({
<div className="flex items-center gap-3 rounded-lg border border-[#e3e3e0] bg-background p-4 dark:border-[#3E3E3A]">
<CheckIcon className="size-5 shrink-0 text-emerald-500" />
<span className="text-sm font-medium">
{__('Encrypted on your device')}
{__('Secure upload')}
</span>
</div>
<div className="flex items-center gap-3 rounded-lg border border-[#e3e3e0] bg-background p-4 dark:border-[#3E3E3A]">
@ -601,11 +584,11 @@ export default function Welcome({
<div className="flex items-center self-start">
<ShieldCheckIcon className="size-5 stroke-1 text-[#1b1b18] dark:text-[#EDEDEC]" />
</div>
{__('End-to-end encryption')}
{__('Privacy-first')}
</h3>
<div className="flex max-w-[240px] flex-col gap-2 text-sm text-balance text-[#706f6c] dark:text-[#A1A09A]">
{__(
'Your financial data is encrypted on your\n device. Only you can access it.',
'Your financial data stays private. We never share, sell, or use it for anything other than your benefit.',
)}
</div>
</div>
@ -815,7 +798,7 @@ export default function Welcome({
</div>
<p className="sm:text-md mt-4 text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__(
'Finally, a finance app that\n respects my privacy. The\n encryption gives me peace of\n mind.',
"Finally, a finance app that respects my privacy. Knowing my data isn't being shared gives me peace of mind.",
)}
</p>
</div>
@ -849,7 +832,7 @@ export default function Welcome({
</div>
<p className="sm:text-md mt-4 text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__(
'Love that my financial data is\n encrypted. No more worrying\n about data breaches!',
'Love that my financial data stays private. No more worrying about who has access to my spending habits!',
)}
</p>
</div>
@ -868,7 +851,7 @@ export default function Welcome({
</div>
<p className="sm:text-md mt-4 text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__(
'Finally, a finance app that\n respects my privacy. The\n encryption gives me peace of\n mind.',
"Finally, a finance app that respects my privacy. Knowing my data isn't being shared gives me peace of mind.",
)}
</p>
</div>
@ -902,7 +885,7 @@ export default function Welcome({
</div>
<p className="sm:text-md mt-4 text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__(
'Love that my financial data is\n encrypted. No more worrying\n about data breaches!',
'Love that my financial data stays private. No more worrying about who has access to my spending habits!',
)}
</p>
</div>
@ -921,7 +904,7 @@ export default function Welcome({
</div>
<p className="sm:text-md mt-4 text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__(
'Finally, a finance app that\n respects my privacy. The\n encryption gives me peace of\n mind.',
"Finally, a finance app that respects my privacy. Knowing my data isn't being shared gives me peace of mind.",
)}
</p>
</div>
@ -955,7 +938,7 @@ export default function Welcome({
</div>
<p className="sm:text-md mt-4 text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__(
'Love that my financial data is\n encrypted. No more worrying\n about data breaches!',
'Love that my financial data stays private. No more worrying about who has access to my spending habits!',
)}
</p>
</div>
@ -974,7 +957,7 @@ export default function Welcome({
</div>
<p className="sm:text-md mt-4 text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__(
'Finally, a finance app that\n respects my privacy. The\n encryption gives me peace of\n mind.',
"Finally, a finance app that respects my privacy. Knowing my data isn't being shared gives me peace of mind.",
)}
</p>
</div>
@ -1008,7 +991,7 @@ export default function Welcome({
</div>
<p className="sm:text-md mt-4 text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__(
'Love that my financial data is\n encrypted. No more worrying\n about data breaches!',
'Love that my financial data stays private. No more worrying about who has access to my spending habits!',
)}
</p>
</div>

View File

@ -1,4 +1,4 @@
import { encrypt, importKey } from '@/lib/crypto';
import { importKey } from '@/lib/crypto';
import { db } from '@/lib/dexie-db';
import { getStoredKey } from '@/lib/key-storage';
import { TransactionSyncManager } from '@/lib/sync-manager';
@ -274,18 +274,6 @@ class TransactionSyncService {
}
}
async encryptDescription(
description: string,
): Promise<{ encrypted: string; iv: string }> {
const keyString = getStoredKey();
if (!keyString) {
throw new Error('Encryption key not set');
}
const key = await importKey(keyString);
return await encrypt(description, key);
}
async getLastSyncTime(): Promise<string | null> {
return await this.syncManager.getLastSyncTime();
}

View File

@ -25,9 +25,11 @@ createServer((page) =>
| undefined;
const initialUser = initialPageProps?.auth?.user ?? null;
const initialIsAuthenticated = Boolean(initialUser);
const hasEncryptionSetup =
(initialPageProps?.hasEncryptionSetup as boolean) ?? false;
return (
<EncryptionKeyProvider>
<EncryptionKeyProvider hasEncryptionSetup={hasEncryptionSetup}>
<PrivacyModeProvider>
<SyncProvider
initialIsAuthenticated={initialIsAuthenticated}

View File

@ -40,7 +40,6 @@ export interface NavDivider {
export interface Features {
cashflow: boolean;
'plaintext-transactions': boolean;
'open-banking': boolean;
'account-mapping': boolean;
}
@ -62,6 +61,7 @@ export interface SharedData {
sidebarOpen: boolean;
features: Features;
hasEncryptedAccounts: boolean;
hasEncryptionSetup: boolean;
locale: string;
translations: Record<string, string>;
[key: string]: unknown;

View File

@ -71,7 +71,7 @@ it('shows welcome step as first onboarding step', function () {
->assertNoJavascriptErrors();
});
it('navigates from welcome to encryption explanation', function () {
it('navigates from welcome to account types', function () {
$user = User::factory()->create([
'onboarded_at' => null,
]);
@ -84,26 +84,7 @@ it('navigates from welcome to encryption explanation', function () {
->assertSee('Whisper Money')
->click("Let's Get Started")
->wait(1)
->assertSee('Your Data, Your Privacy')
->assertSee('End-to-End Encryption')
->assertNoJavascriptErrors();
});
it('shows encryption setup after encryption explanation', function () {
$user = User::factory()->create([
'onboarded_at' => null,
]);
$this->actingAs($user);
$page = visit('/onboarding');
$page->click("Let's Get Started")
->wait(1)
->assertSee('Your Data, Your Privacy')
->click('@encryption-continue-button')
->wait(1)
->assertSee('Create Your Encryption Password')
->assertSee('Account Types')
->assertNoJavascriptErrors();
});
@ -122,71 +103,6 @@ it('marks user as onboarded when completing onboarding', function () {
expect($user->onboarded_at)->not->toBeNull();
});
// =============================================================================
// Encryption Key Skip Tests
// =============================================================================
it('skips encryption setup step when user has encryption key stored', function () {
$user = User::factory()->create([
'onboarded_at' => null,
'encryption_salt' => 'test-salt',
]);
$this->actingAs($user);
$page = visit('/onboarding');
// Store a mock encryption key in localStorage (using the correct key name)
$page->script("localStorage.setItem('encryption_key', 'mock-encryption-key')");
// Reload the page to pick up the stored key
$page->navigate('/onboarding')
->wait(1)
// Still shows welcome
->assertSee('Welcome to')
->assertSee('Whisper Money')
->click("Let's Get Started")
->wait(1)
// Still shows encryption explanation
->assertSee('Your Data, Your Privacy')
->click('@encryption-continue-button')
->wait(1)
// Should skip encryption-setup and go directly to account types
->assertSee('Account Types')
->assertDontSee('Create Your Encryption Password')
->assertNoJavascriptErrors();
// Cleanup
$page->script("localStorage.removeItem('encryption_key')");
});
it('shows encryption setup step when no encryption key exists', function () {
$user = User::factory()->create([
'onboarded_at' => null,
]);
$this->actingAs($user);
$page = visit('/onboarding');
// Ensure no key exists
$page->script("localStorage.removeItem('encryption_key')");
$page->script("sessionStorage.removeItem('encryption_key')");
$page->navigate('/onboarding')
->wait(1)
->assertSee('Welcome to')
->assertSee('Whisper Money')
->click("Let's Get Started")
->wait(1)
->assertSee('Your Data, Your Privacy')
->click('@encryption-continue-button')
->wait(1)
// Should show encryption setup since no key exists
->assertSee('Create Your Encryption Password')
->assertNoJavascriptErrors();
});
// =============================================================================
// Existing Account Flow Tests
// =============================================================================
@ -209,17 +125,8 @@ it('shows existing accounts instead of create form when accounts exist', functio
$page = visit('/onboarding');
// Store mock encryption key to skip encryption setup step
$page->script("localStorage.setItem('encryption_key', 'mock-encryption-key')");
$page->navigate('/onboarding')
$page->click("Let's Get Started")
->wait(1)
// Navigate through initial steps
->click("Let's Get Started")
->wait(1)
->click('@encryption-continue-button')
->wait(1)
// Should now be at account types (encryption-setup was skipped)
->assertSee('Account Types')
->click('Create Your First Account')
->wait(1)
@ -228,8 +135,6 @@ it('shows existing accounts instead of create form when accounts exist', functio
->assertSee('Test Bank')
->assertSee('Checking')
->assertNoJavascriptErrors();
$page->script("localStorage.removeItem('encryption_key')");
});
it('allows continuing with existing accounts', function () {
@ -250,14 +155,7 @@ it('allows continuing with existing accounts', function () {
$page = visit('/onboarding');
$page->script("localStorage.setItem('encryption_key', 'mock-encryption-key')");
$page->navigate('/onboarding')
->wait(1)
// Navigate through initial steps
->click("Let's Get Started")
->wait(1)
->click('@encryption-continue-button')
$page->click("Let's Get Started")
->wait(1)
->assertSee('Account Types')
->click('Create Your First Account')
@ -270,8 +168,6 @@ it('allows continuing with existing accounts', function () {
// Should go to import transactions (since checking account needs transactions)
->assertSee('Import Your Transactions')
->assertNoJavascriptErrors();
$page->script("localStorage.removeItem('encryption_key')");
});
// =============================================================================
@ -296,14 +192,7 @@ it('shows import transactions step after account creation', function () {
$page = visit('/onboarding');
$page->script("localStorage.setItem('encryption_key', 'mock-encryption-key')");
$page->navigate('/onboarding')
->wait(1)
// Navigate through initial steps
->click("Let's Get Started")
->wait(1)
->click('@encryption-continue-button')
$page->click("Let's Get Started")
->wait(1)
->click('Create Your First Account')
->wait(1)
@ -313,8 +202,6 @@ it('shows import transactions step after account creation', function () {
->assertSee('Import Your Transactions')
->assertSee('Import Transactions')
->assertNoJavascriptErrors();
$page->script("localStorage.removeItem('encryption_key')");
});
it('shows add another account form without first account restriction', function () {
@ -335,26 +222,14 @@ it('shows add another account form without first account restriction', function
$page = visit('/onboarding');
$page->script("localStorage.setItem('encryption_key', 'mock-encryption-key')");
// Navigate to more-accounts step via direct state manipulation
// For this test, we verify that when adding another account, the first account restriction is gone
$page->navigate('/onboarding')
->wait(1)
->click("Let's Get Started")
->wait(1)
->click('@encryption-continue-button')
$page->click("Let's Get Started")
->wait(1)
->click('Create Your First Account')
->wait(1)
// At this point, the "Your Accounts" view shows existing accounts
// The Continue button will proceed to import, but the key point is tested:
// existing accounts are shown correctly
->assertSee('Your Accounts')
->assertSee('Primary Bank')
->assertNoJavascriptErrors();
$page->script("localStorage.removeItem('encryption_key')");
});
// =============================================================================
@ -376,25 +251,13 @@ it('completes onboarding flow through account creation', function () {
$page->assertPathIs('/onboarding')
->assertNoJavascriptErrors();
// Step 2: Welcome
// Step 1: Welcome
$page->assertSee('Welcome to')
->assertSee('Whisper Money')
->click("Let's Get Started")
->wait(1);
// Step 3: Encryption Explanation
$page->assertSee('Your Data, Your Privacy')
->click('@encryption-continue-button')
->wait(1);
// Step 4: Encryption Setup
$page->assertSee('Create Your Encryption Password')
->fill('#password', 'MySecureEncryptionPassword123!')
->fill('#confirmPassword', 'MySecureEncryptionPassword123!')
->click('Setup Encryption')
->wait(3);
// Step 5: Account Types
// Step 2: Account Types
$page->assertSee('Account Types')
->assertSee('Checking')
->assertSee('Savings')
@ -402,7 +265,7 @@ it('completes onboarding flow through account creation', function () {
->click('Create Your First Account')
->wait(1);
// Step 6: Create Account
// Step 3: Create Account
$page->assertSee('Create an Account')
->assertSee('Your first account must be a')
->fill('#display_name', 'My Checking Account')
@ -421,14 +284,10 @@ it('completes onboarding flow through account creation', function () {
->click('Create Account')
->wait(3);
// Step 7: Import Transactions step should appear
// Step 4: Import Transactions step should appear
$page->assertSee('Import Your Transactions')
->assertNoJavascriptErrors();
// Verify user's encryption was set up
$user->refresh();
expect($user->encryption_salt)->not->toBeNull();
// Verify account was created
expect($user->accounts()->count())->toBe(1);
expect($user->accounts()->first()->type->value)->toBe('checking');

View File

@ -3,36 +3,11 @@
use App\Models\Account;
use App\Models\Transaction;
use App\Models\User;
use Laravel\Pennant\Feature;
use function Pest\Laravel\actingAs;
test('plaintext-transactions feature flag defaults to false', function () {
test('creating plaintext transaction succeeds', function () {
$user = User::factory()->onboarded()->create();
expect(Feature::for($user)->active('plaintext-transactions'))->toBeFalse();
});
test('creating transaction without description_iv fails when flag is inactive', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$response = actingAs($user)->postJson(route('transactions.store'), [
'account_id' => $account->id,
'description' => 'Grocery shopping',
'transaction_date' => '2025-11-11',
'amount' => 5000,
'currency_code' => 'USD',
'source' => 'manually_created',
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['description_iv']);
});
test('creating plaintext transaction succeeds when flag is active', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('plaintext-transactions');
$account = Account::factory()->create(['user_id' => $user->id]);
$response = actingAs($user)->postJson(route('transactions.store'), [
@ -52,9 +27,8 @@ test('creating plaintext transaction succeeds when flag is active', function ()
]);
});
test('creating plaintext transaction with notes succeeds when flag is active', function () {
test('creating plaintext transaction with notes succeeds', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('plaintext-transactions');
$account = Account::factory()->create(['user_id' => $user->id]);
$response = actingAs($user)->postJson(route('transactions.store'), [
@ -77,9 +51,8 @@ test('creating plaintext transaction with notes succeeds when flag is active', f
]);
});
test('encrypted transactions still work when flag is active', function () {
test('encrypted transactions still work', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('plaintext-transactions');
$account = Account::factory()->create(['user_id' => $user->id]);
$response = actingAs($user)->postJson(route('transactions.store'), [
@ -104,7 +77,7 @@ test('encrypted and plaintext transactions can coexist', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
// Create an encrypted transaction (before flag was activated)
// Create an encrypted transaction (legacy)
Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
@ -112,9 +85,7 @@ test('encrypted and plaintext transactions can coexist', function () {
'description_iv' => str_repeat('e', 16),
]);
// Activate the flag and create a plaintext transaction
Feature::for($user)->activate('plaintext-transactions');
// Create a plaintext transaction
Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'account_id' => $account->id,
@ -126,9 +97,8 @@ test('encrypted and plaintext transactions can coexist', function () {
expect(Transaction::where('user_id', $user->id)->whereNotNull('description_iv')->count())->toBe(1);
});
test('updating transaction without description_iv works when flag is active', function () {
test('updating transaction without description_iv works', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('plaintext-transactions');
$account = Account::factory()->create(['user_id' => $user->id]);
$transaction = Transaction::factory()->create([
@ -152,26 +122,3 @@ test('updating transaction without description_iv works when flag is active', fu
'notes_iv' => null,
]);
});
test('plaintext-transactions feature flag is shared with frontend', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('plaintext-transactions');
$response = actingAs($user)->get(route('transactions.index'));
$response->assertSuccessful();
$response->assertInertia(fn ($page) => $page
->where('features.plaintext-transactions', true)
);
});
test('plaintext-transactions feature flag defaults to false in frontend', function () {
$user = User::factory()->onboarded()->create();
$response = actingAs($user)->get(route('transactions.index'));
$response->assertSuccessful();
$response->assertInertia(fn ($page) => $page
->where('features.plaintext-transactions', false)
);
});