Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions backend/src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { Portfolio } from '../types/index.js'
import { ok, fail } from '../utils/apiResponse.js'
import { getPortfolioExport } from '../services/portfolioExportService.js'
import { assetRegistryService } from '../services/assetRegistryService.js'
import { databaseService } from '../services/databaseService.js'

const router = Router()
const stellarService = new StellarService()
Expand Down Expand Up @@ -54,6 +55,68 @@ router.get('/strategies', (_req: Request, res: Response) => {
return ok(res, { strategies: REBALANCE_STRATEGIES })
})

// ─── Legal consent (GDPR/CCPA) ─────────────────────────────────────────────
/** Get consent status for a user. Required before using the app. */
router.get('/consent/status', (req: Request, res: Response) => {
try {
const userId = (req.query.userId ?? req.query.user_id) as string
if (!userId) return fail(res, 400, 'VALIDATION_ERROR', 'userId is required')
const consent = databaseService.getConsent(userId)
const accepted = databaseService.hasFullConsent(userId)
return ok(res, {
accepted,
termsAcceptedAt: consent?.termsAcceptedAt ?? null,
privacyAcceptedAt: consent?.privacyAcceptedAt ?? null,
cookieAcceptedAt: consent?.cookieAcceptedAt ?? null
})
} catch (error) {
logger.error('[ERROR] Consent status failed', { error: getErrorObject(error) })
return fail(res, 500, 'INTERNAL_ERROR', getErrorMessage(error))
}
})

/** Record user acceptance of ToS, Privacy Policy, Cookie Policy. */
router.post('/consent', writeRateLimiter, (req: Request, res: Response) => {
try {
const { userId, terms, privacy, cookies } = req.body ?? {}
if (!userId || typeof userId !== 'string') return fail(res, 400, 'VALIDATION_ERROR', 'userId is required')
if (typeof terms !== 'boolean' || typeof privacy !== 'boolean' || typeof cookies !== 'boolean') {
return fail(res, 400, 'VALIDATION_ERROR', 'terms, privacy, and cookies must be booleans')
}
if (!terms || !privacy || !cookies) {
return fail(res, 400, 'VALIDATION_ERROR', 'You must accept Terms of Service, Privacy Policy, and Cookie Policy')
}
const ipAddress = req.ip ?? req.socket?.remoteAddress
const userAgent = req.get('user-agent')
databaseService.recordConsent(userId, { terms, privacy, cookies, ipAddress, userAgent })
return ok(res, { message: 'Consent recorded', accepted: true })
} catch (error) {
logger.error('[ERROR] Record consent failed', { error: getErrorObject(error) })
return fail(res, 500, 'INTERNAL_ERROR', getErrorMessage(error))
}
})

/** GDPR: Delete all data for a user (portfolios, history, consent). Requires JWT when enabled. */
router.delete('/user/:address/data', requireJwtWhenEnabled, writeRateLimiter, async (req: Request, res: Response) => {
try {
const address = req.params.address
const userId = req.user?.address ?? address
if (userId !== address) return fail(res, 403, 'FORBIDDEN', 'You can only delete your own data')
if (!address) return fail(res, 400, 'VALIDATION_ERROR', 'address is required')
try {
const { deleteAllRefreshTokensForUser } = await import('../db/refreshTokenDb.js')
if (typeof deleteAllRefreshTokensForUser === 'function') {
await deleteAllRefreshTokensForUser(userId)
}
} catch (_) { /* refresh token DB optional */ }
databaseService.deleteUserData(userId)
return ok(res, { message: 'Your data has been deleted' })
} catch (error) {
logger.error('[ERROR] Delete user data failed', { error: getErrorObject(error) })
return fail(res, 500, 'INTERNAL_ERROR', getErrorMessage(error))
}
})

// ─── Asset registry (configurable assets, no contract redeploy) ─────────────────
/** Public: list enabled assets for portfolio setup and frontend */
router.get('/assets', (_req: Request, res: Response) => {
Expand Down
3 changes: 3 additions & 0 deletions backend/src/db/migrations/008_legal_consent.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Migration: 008_legal_consent (down)
DROP INDEX IF EXISTS idx_legal_consent_updated;
DROP TABLE IF EXISTS legal_consent;
18 changes: 18 additions & 0 deletions backend/src/db/migrations/008_legal_consent.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- Migration: 008_legal_consent (up)
-- Description: Store user consent for Terms of Service, Privacy Policy, and Cookie Policy (GDPR/CCPA).
-- Rollback: See 008_legal_consent.down.sql

CREATE TABLE IF NOT EXISTS legal_consent (
user_id VARCHAR(256) PRIMARY KEY,
terms_accepted_at TIMESTAMPTZ,
privacy_accepted_at TIMESTAMPTZ,
cookie_accepted_at TIMESTAMPTZ,
ip_address VARCHAR(64),
user_agent VARCHAR(512),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_legal_consent_updated ON legal_consent(updated_at);

COMMENT ON TABLE legal_consent IS 'User acceptance of ToS, Privacy Policy, Cookie Policy for GDPR/CCPA compliance';
67 changes: 67 additions & 0 deletions backend/src/services/databaseService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,17 @@ CREATE TABLE IF NOT EXISTS assets (
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_assets_enabled ON assets(enabled) WHERE enabled = 1;

CREATE TABLE IF NOT EXISTS legal_consent (
user_id TEXT PRIMARY KEY,
terms_accepted_at TEXT,
privacy_accepted_at TEXT,
cookie_accepted_at TEXT,
ip_address TEXT,
user_agent TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`

// ─────────────────────────────────────────────
Expand Down Expand Up @@ -585,6 +596,62 @@ export class DatabaseService {
}
}

// ──────────────────────────────────────────
// Legal consent (GDPR/CCPA)
// ──────────────────────────────────────────

recordConsent(
userId: string,
opts: { terms: boolean; privacy: boolean; cookies: boolean; ipAddress?: string; userAgent?: string }
): void {
const now = new Date().toISOString()
this.db.prepare(
`INSERT INTO legal_consent (user_id, terms_accepted_at, privacy_accepted_at, cookie_accepted_at, ip_address, user_agent, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
terms_accepted_at = COALESCE(excluded.terms_accepted_at, terms_accepted_at),
privacy_accepted_at = COALESCE(excluded.privacy_accepted_at, privacy_accepted_at),
cookie_accepted_at = COALESCE(excluded.cookie_accepted_at, cookie_accepted_at),
ip_address = excluded.ip_address,
user_agent = excluded.user_agent,
updated_at = excluded.updated_at`
).run(
userId,
opts.terms ? now : null,
opts.privacy ? now : null,
opts.cookies ? now : null,
opts.ipAddress ?? null,
opts.userAgent ?? null,
now
)
}

getConsent(userId: string): { termsAcceptedAt: string | null; privacyAcceptedAt: string | null; cookieAcceptedAt: string | null } | undefined {
const row = this.db.prepare<[string], { terms_accepted_at: string | null; privacy_accepted_at: string | null; cookie_accepted_at: string | null }>(
'SELECT terms_accepted_at, privacy_accepted_at, cookie_accepted_at FROM legal_consent WHERE user_id = ?'
).get(userId)
if (!row) return undefined
return {
termsAcceptedAt: row.terms_accepted_at,
privacyAcceptedAt: row.privacy_accepted_at,
cookieAcceptedAt: row.cookie_accepted_at
}
}

hasFullConsent(userId: string): boolean {
const c = this.getConsent(userId)
return Boolean(c?.termsAcceptedAt && c?.privacyAcceptedAt && c?.cookieAcceptedAt)
}

deleteUserData(userId: string): void {
this.db.prepare('DELETE FROM legal_consent WHERE user_id = ?').run(userId)
const portfolios = this.db.prepare<[string], { id: string }>('SELECT id FROM portfolios WHERE user_address = ?').all(userId)
for (const p of portfolios) {
this.db.prepare('DELETE FROM rebalance_history WHERE portfolio_id = ?').run(p.id)
}
this.db.prepare('DELETE FROM portfolios WHERE user_address = ?').run(userId)
}

clearAll(): void {
try {
this.db.prepare('DELETE FROM rebalance_history').run()
Expand Down
120 changes: 77 additions & 43 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,79 +2,101 @@ import { useState, useEffect } from 'react'
import Landing from './components/Landing'
import Dashboard from './components/Dashboard'
import PortfolioSetup from './components/PortfolioSetup'
import Legal from './components/Legal'
import ConsentGate from './components/ConsentGate'
import { walletManager } from './utils/walletManager'
import { WalletError } from './utils/walletAdapters'
import { login as authLogin } from './services/authService'
import { api } from './config/api'
import type { LegalDocType } from './components/Legal'

function App() {
const [currentView, setCurrentView] = useState('landing')
const [publicKey, setPublicKey] = useState<string | null>(null)
const [pendingConsentPublicKey, setPendingConsentPublicKey] = useState<string | null>(null)
const [legalDoc, setLegalDoc] = useState<LegalDocType | null>(null)
const [isConnecting, setIsConnecting] = useState(false)
const [error, setError] = useState<string | null>(null)

useEffect(() => {
checkWalletConnection()
}, [])

const checkConsent = async (userId: string): Promise<boolean> => {
try {
const res = await api.get<{ accepted: boolean }>(`/api/consent/status?userId=${encodeURIComponent(userId)}`)
return !!res?.accepted
} catch {
return false
}
}

const checkWalletConnection = async () => {
try {
const publicKey = await walletManager.reconnect()
if (publicKey) {
try {
await authLogin(publicKey)
} catch (_) {
const pk = await walletManager.reconnect()
if (pk) {
const accepted = await checkConsent(pk)
if (accepted) {
try { await authLogin(pk) } catch (_) {}
setPublicKey(pk)
setCurrentView('dashboard')
} else {
setPublicKey(pk)
setPendingConsentPublicKey(pk)
}
setPublicKey(publicKey)
setCurrentView('dashboard')
}
} catch (error) {
console.error('Error checking wallet connection:', error)
} catch (err) {
console.error('Error checking wallet connection:', err)
}
}

const connectWallet = async () => {
setIsConnecting(true)
setError(null)

try {
const publicKey = walletManager.getPublicKey()
if (publicKey) {
try {
await authLogin(publicKey)
} catch (_) {
}
setPublicKey(publicKey)
const pk = walletManager.getPublicKey()
if (pk) {
try { await authLogin(pk) } catch (_) {}
setPublicKey(pk)
setCurrentView('dashboard')
} else {
setError('No wallet connected. Please select a wallet first.')
}
} catch (error: any) {
console.error('Wallet connection error:', error)

if (error instanceof WalletError) {
if (error.code === 'USER_DECLINED') {
setError('Connection was declined. Please approve in your wallet.')
} else if (error.code === 'WALLET_NOT_INSTALLED') {
setError(`${error.walletType || 'Wallet'} is not installed. Please install it and refresh.`)
} else if (error.code === 'NETWORK_MISMATCH') {
setError('Network mismatch. Please check your wallet network settings.')
} else if (error.code === 'TIMEOUT') {
setError('Connection timed out. Please try again.')
} else {
setError(error.message || 'Failed to connect wallet.')
}
} else if (error.message === 'NO_WALLET_FOUND') {
} catch (err: any) {
console.error('Wallet connection error:', err)
if (err instanceof WalletError) {
if (err.code === 'USER_DECLINED') setError('Connection was declined. Please approve in your wallet.')
else if (err.code === 'WALLET_NOT_INSTALLED') setError(`${err.walletType || 'Wallet'} is not installed. Please install it and refresh.`)
else if (err.code === 'NETWORK_MISMATCH') setError('Network mismatch. Please check your wallet network settings.')
else if (err.code === 'TIMEOUT') setError('Connection timed out. Please try again.')
else setError(err.message || 'Failed to connect wallet.')
} else if (err.message === 'NO_WALLET_FOUND') {
setError('No Stellar wallet detected. Please install Freighter, Rabet, or xBull wallet.')
} else {
setError(error.message || 'Failed to connect wallet. Please try again.')
setError(err.message || 'Failed to connect wallet. Please try again.')
}
} finally {
setIsConnecting(false)
}
}

const handleNavigate = (view: string) => {
const handleNeedsConsent = (pk: string) => {
setPendingConsentPublicKey(pk)
}

const handleConsentAccepted = () => {
if (pendingConsentPublicKey) {
setPublicKey(pendingConsentPublicKey)
setPendingConsentPublicKey(null)
setCurrentView('dashboard')
}
}

const handleNavigate = (view: string, legalDocType?: LegalDocType) => {
setError(null)
if (legalDocType) setLegalDoc(legalDocType)
else if (view.startsWith('legal-')) setLegalDoc(view.replace('legal-', '') as LegalDocType)
else setLegalDoc(null)
setCurrentView(view)
}

Expand All @@ -95,28 +117,40 @@ function App() {
</div>
)}

{currentView === 'landing' && (
{pendingConsentPublicKey ? (
legalDoc ? (
<Legal doc={legalDoc} onBack={() => setLegalDoc(null)} />
) : (
<ConsentGate
userId={pendingConsentPublicKey}
onAccept={handleConsentAccepted}
onOpenLegal={(doc) => setLegalDoc(doc)}
/>
)
) : (currentView === 'legal-terms' || currentView === 'legal-privacy' || currentView === 'legal-cookies') && legalDoc ? (
<Legal
doc={legalDoc}
onBack={() => handleNavigate('landing')}
/>
) : currentView === 'landing' ? (
<Landing
onNavigate={handleNavigate}
onConnectWallet={connectWallet}
onNeedsConsent={handleNeedsConsent}
isConnecting={isConnecting}
publicKey={publicKey}
/>
)}

{currentView === 'dashboard' && (
) : currentView === 'dashboard' ? (
<Dashboard
onNavigate={handleNavigate}
publicKey={publicKey}
/>
)}

{currentView === 'setup' && (
) : currentView === 'setup' ? (
<PortfolioSetup
onNavigate={handleNavigate}
publicKey={publicKey}
/>
)}
) : null}
</div>
)
}
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/components/ConsentGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* ConsentGate.tsx — Full-page gate when user must accept ToS/Privacy/Cookies before using the app.
* Renders ConsentModal on a full-page background (reconnect or first-time flow).
*/

import React from 'react'
import ConsentModal from './ConsentModal'
import type { LegalDocType } from './Legal'

interface ConsentGateProps {
userId: string
onAccept: () => void
onOpenLegal: (doc: LegalDocType) => void
}

const ConsentGate: React.FC<ConsentGateProps> = ({ userId, onAccept, onOpenLegal }) => {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 flex items-center justify-center p-4">
<ConsentModal
userId={userId}
onAccept={onAccept}
onOpenLegal={onOpenLegal}
/>
</div>
)
}

export default ConsentGate
Loading
Loading