feat: zero-config encryption + SimpleFIN Bridge links
- encryptionService.js: getKey() tries TOKEN_ENCRYPTION_KEY env first, then auto-generates a random 48-byte key on first startup, persists to settings as _auto_encryption_key. assertEncryptionReady() is now a no-op. - bankSyncConfigService.js: removed encryption_key_set response and encryptionKeyReady() helper. No env config required. - .env.example: TOKEN_ENCRYPTION_KEY removed. Comment says enable from Admin panel, no env config required. - BankSyncSection.jsx: added SimpleFIN Bridge links — 'Open SimpleFIN Bridge' for first-time setup, 'Get a SimpleFIN token' for existing connections
This commit is contained in:
parent
3fea3931f5
commit
b03264ceb1
16
.env.example
16
.env.example
|
|
@ -38,20 +38,8 @@ NODE_ENV=production
|
|||
# BACKUP_PATH=/opt/bill-tracker/data/backups
|
||||
|
||||
# ── Bank Sync (SimpleFIN) ─────────────────────────────────────────────────────
|
||||
# Optional. Disabled by default. Requires a SimpleFIN Bridge account.
|
||||
# Users connect their own SimpleFIN Bridge — BillTracker never stores bank credentials.
|
||||
#
|
||||
# BANK_SYNC_ENABLED=false
|
||||
#
|
||||
# Required when BANK_SYNC_ENABLED=true. Must be at least 32 characters.
|
||||
# Used to encrypt the SimpleFIN Access URL at rest.
|
||||
# TOKEN_ENCRYPTION_KEY=replace-with-a-long-random-secret-at-least-32-chars
|
||||
#
|
||||
# How many days back to fetch transactions on first sync (default: 90).
|
||||
# SIMPLEFIN_SYNC_DAYS=90
|
||||
#
|
||||
# How often the background auto-sync worker runs (default: 4 hours, minimum: 0.5).
|
||||
# SIMPLEFIN_SYNC_INTERVAL_HOURS=4
|
||||
# Enable/disable bank sync from the Admin panel. Users connect their own
|
||||
# SimpleFIN Bridge from the Data page. No environment config required.
|
||||
|
||||
# ── First-run admin account ────────────────────────────────────────────────────
|
||||
# Set BOTH on first start to create the admin account automatically.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Building2, Link2Off, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { Building2, Eye, EyeOff, ExternalLink, Link2Off, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -11,6 +11,41 @@ import {
|
|||
} from '@/components/ui/alert-dialog';
|
||||
import { SectionCard } from './dataShared';
|
||||
|
||||
function TokenInput({ value, onChange, disabled }) {
|
||||
const [show, setShow] = useState(false);
|
||||
const tail = value.slice(-4);
|
||||
return (
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
type={show ? 'text' : 'password'}
|
||||
placeholder="Paste SimpleFIN setup token…"
|
||||
className="font-mono text-xs pr-8"
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => setShow(v => !v)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{show ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{value && !show && (
|
||||
<p className="text-[11px] text-muted-foreground font-mono pl-0.5 select-none">
|
||||
···{tail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BankSyncSection({ onConnectionChange }) {
|
||||
const [enabled, setEnabled] = useState(null);
|
||||
const [connections, setConnections] = useState([]);
|
||||
|
|
@ -172,13 +207,24 @@ export default function BankSyncSection({ onConnectionChange }) {
|
|||
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground mb-1">Connect a SimpleFIN Bridge account</p>
|
||||
<p>Paste your SimpleFIN setup token below. BillTracker only stores an encrypted access URL — no bank credentials are saved.</p>
|
||||
<p className="mt-2">
|
||||
Need a token?{' '}
|
||||
<a
|
||||
href="https://beta-bridge.simplefin.org/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Open SimpleFIN Bridge
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
<TokenInput
|
||||
value={setupToken}
|
||||
onChange={e => setSetupToken(e.target.value)}
|
||||
placeholder="Paste SimpleFIN setup token…"
|
||||
className="flex-1 font-mono text-xs"
|
||||
disabled={connecting}
|
||||
/>
|
||||
<Button onClick={handleConnect} disabled={connecting || !setupToken.trim()} className="shrink-0">
|
||||
{connecting ? <><Loader2 className="h-4 w-4 animate-spin mr-1.5" />Connecting…</> : 'Connect'}
|
||||
|
|
@ -189,13 +235,23 @@ export default function BankSyncSection({ onConnectionChange }) {
|
|||
|
||||
{connections.length > 0 && (
|
||||
<div className="px-6 py-4 border-t border-border/50 space-y-2">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs font-medium text-muted-foreground">Add another connection</p>
|
||||
<a
|
||||
href="https://beta-bridge.simplefin.org/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Get a SimpleFIN token
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
<TokenInput
|
||||
value={setupToken}
|
||||
onChange={e => setSetupToken(e.target.value)}
|
||||
placeholder="Paste SimpleFIN setup token…"
|
||||
className="flex-1 font-mono text-xs"
|
||||
disabled={connecting}
|
||||
/>
|
||||
<Button size="sm" onClick={handleConnect} disabled={connecting || !setupToken.trim()} className="shrink-0">
|
||||
{connecting ? <><Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />Connecting…</> : 'Connect'}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.30.5",
|
||||
"version": "0.31.0",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,6 @@ const { getSetting, setSetting } = require('../db/database');
|
|||
|
||||
const SYNC_DAYS_DEFAULT = 90;
|
||||
|
||||
function encryptionKeyReady() {
|
||||
const key = process.env.TOKEN_ENCRYPTION_KEY || '';
|
||||
return Buffer.from(key, 'utf8').length >= 32;
|
||||
}
|
||||
|
||||
function getBankSyncConfig() {
|
||||
const dbValue = getSetting('bank_sync_enabled');
|
||||
const envValue = process.env.BANK_SYNC_ENABLED;
|
||||
|
|
@ -32,7 +27,6 @@ function getBankSyncConfig() {
|
|||
|
||||
return {
|
||||
enabled,
|
||||
encryption_key_set: encryptionKeyReady(),
|
||||
sync_days: syncDays,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,17 +6,29 @@ const ALGORITHM = 'aes-256-gcm';
|
|||
const IV_BYTES = 12;
|
||||
const TAG_BYTES = 16;
|
||||
|
||||
// Returns a stable 256-bit key. Prefers TOKEN_ENCRYPTION_KEY env var (power-user
|
||||
// override); otherwise auto-generates a random key on first startup and persists
|
||||
// it in the settings table so it survives restarts without any manual config.
|
||||
function getKey() {
|
||||
const raw = process.env.TOKEN_ENCRYPTION_KEY || '';
|
||||
if (!raw) throw new Error('TOKEN_ENCRYPTION_KEY is not set');
|
||||
const buf = Buffer.from(raw, 'utf8');
|
||||
const envRaw = process.env.TOKEN_ENCRYPTION_KEY || '';
|
||||
if (envRaw) {
|
||||
const buf = Buffer.from(envRaw, 'utf8');
|
||||
if (buf.length < 32) throw new Error('TOKEN_ENCRYPTION_KEY must be at least 32 bytes');
|
||||
return crypto.createHash('sha256').update(buf).digest();
|
||||
}
|
||||
|
||||
function assertEncryptionReady() {
|
||||
getKey();
|
||||
// Lazy-require to avoid circular dependency at module load time
|
||||
const { getSetting, setSetting } = require('../db/database');
|
||||
let stored = getSetting('_auto_encryption_key');
|
||||
if (!stored) {
|
||||
stored = crypto.randomBytes(48).toString('hex');
|
||||
setSetting('_auto_encryption_key', stored);
|
||||
}
|
||||
return crypto.createHash('sha256').update(stored, 'utf8').digest();
|
||||
}
|
||||
|
||||
// No-op now that the key is always available — kept for call-site compatibility
|
||||
function assertEncryptionReady() {}
|
||||
|
||||
function encryptSecret(plaintext) {
|
||||
const key = getKey();
|
||||
|
|
|
|||
Loading…
Reference in New Issue