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:
parent
eeca437586
commit
6b05de173a
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -49,6 +49,6 @@ trait ResolvesFeatures
|
|||
|
||||
private function getStringBasedFeatures(): array
|
||||
{
|
||||
return ['plaintext-transactions', 'open-banking', 'account-mapping'];
|
||||
return ['open-banking', 'account-mapping'];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ class OnboardingController extends Controller
|
|||
return Inertia::render('onboarding/index', [
|
||||
'banks' => $banks,
|
||||
'accounts' => $accounts,
|
||||
'hasEncryptionSetup' => $user->encryption_salt !== null,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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' };
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue