Skip to content

Commit db900f4

Browse files
committed
feat: power-user settings panel with network switching, custom RPC, cache management
- Add settings-store.ts: localStorage persistence with schema versioning (v1), HTTPS-only RPC validation, public RPC defaults with rate-limit notices - Add use-settings.ts hook: syncs to localStorage, optional privacy-safe telemetry via CustomEvent (no PII, no wallet addresses) - Add SettingsPanel component: network toggle, custom RPC input with phishing warning and acknowledgement checkbox, cache clear button, wallet disconnect, telemetry opt-in - Add /settings route Never stores secrets in localStorage. Educates users that malicious RPC can misrepresent balances/events.
1 parent 3c90de3 commit db900f4

4 files changed

Lines changed: 346 additions & 0 deletions

File tree

frontend/src/app/settings/page.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Metadata } from 'next'
2+
import { SettingsPanel } from '@/components/settings/settings-panel'
3+
4+
export const metadata: Metadata = {
5+
title: 'Settings — NiffyInsur',
6+
}
7+
8+
export default function SettingsPage() {
9+
return (
10+
<main className="mx-auto max-w-2xl px-4 py-10">
11+
<h1 className="mb-6 text-2xl font-semibold">Settings</h1>
12+
<SettingsPanel />
13+
</main>
14+
)
15+
}
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
'use client'
2+
3+
import { useState, useTransition } from 'react'
4+
import { AlertTriangle, ChevronDown, ChevronUp, ExternalLink, RefreshCw, Unplug } from 'lucide-react'
5+
import { Button } from '@/components/ui/button'
6+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
7+
import { Input } from '@/components/ui/input'
8+
import { useSettings } from '@/hooks/use-settings'
9+
import { useWallet } from '@/hooks/use-wallet'
10+
import { getContracts } from '@/lib/network-manifest'
11+
import { validateRpcUrl, PUBLIC_RPC, STATUS_PAGES, type AppSettings } from '@/lib/settings-store'
12+
import type { Network } from '@/lib/network-manifest'
13+
14+
const NETWORKS: Network[] = ['testnet', 'public']
15+
16+
export function SettingsPanel() {
17+
const { settings, update, reset } = useSettings()
18+
const { disconnect } = useWallet()
19+
const [advancedOpen, setAdvancedOpen] = useState(false)
20+
const [rpcInput, setRpcInput] = useState(settings.customRpcUrl ?? '')
21+
const [rpcError, setRpcError] = useState<string | null>(null)
22+
const [isPending, startTransition] = useTransition()
23+
24+
function handleNetworkChange(network: Network) {
25+
update('network', network)
26+
// Pull fresh contract manifests for the new network
27+
startTransition(() => {
28+
getContracts(network) // re-reads registry; triggers any dependent queries
29+
})
30+
}
31+
32+
function handleRpcSave() {
33+
if (!rpcInput.trim()) {
34+
update('customRpcUrl', null)
35+
update('rpcWarningAcknowledged', false)
36+
setRpcError(null)
37+
return
38+
}
39+
const err = validateRpcUrl(rpcInput)
40+
if (err) { setRpcError(err); return }
41+
setRpcError(null)
42+
update('customRpcUrl', rpcInput.trim())
43+
}
44+
45+
function handleClearCaches() {
46+
// Clear React Query cache key prefix used by the app
47+
if (typeof window !== 'undefined') {
48+
Object.keys(localStorage)
49+
.filter((k) => k.startsWith('rq-') || k.startsWith('sim-'))
50+
.forEach((k) => localStorage.removeItem(k))
51+
}
52+
window.location.reload()
53+
}
54+
55+
const activeRpc = settings.customRpcUrl ?? PUBLIC_RPC[settings.network]
56+
const isCustomRpc = !!settings.customRpcUrl
57+
58+
return (
59+
<div className="space-y-6 max-w-xl">
60+
{/* Network */}
61+
<Card>
62+
<CardHeader>
63+
<CardTitle>Network</CardTitle>
64+
<CardDescription>
65+
Switch between Stellar Testnet and Mainnet. Contract manifests reload automatically.
66+
</CardDescription>
67+
</CardHeader>
68+
<CardContent className="space-y-3">
69+
<div className="flex gap-2">
70+
{NETWORKS.map((n) => (
71+
<Button
72+
key={n}
73+
variant={settings.network === n ? 'default' : 'outline'}
74+
size="sm"
75+
onClick={() => handleNetworkChange(n)}
76+
disabled={isPending}
77+
aria-pressed={settings.network === n}
78+
>
79+
{n === 'public' ? 'Mainnet' : 'Testnet'}
80+
</Button>
81+
))}
82+
</div>
83+
<p className="text-xs text-muted-foreground">
84+
Active RPC:{' '}
85+
<span className="font-mono">{activeRpc}</span>
86+
{!isCustomRpc && (
87+
<span className="ml-2 text-yellow-600 dark:text-yellow-400">
88+
(public endpoint — rate limits apply)
89+
</span>
90+
)}
91+
</p>
92+
<a
93+
href={STATUS_PAGES[settings.network]}
94+
target="_blank"
95+
rel="noopener noreferrer"
96+
className="inline-flex items-center gap-1 text-xs text-primary underline-offset-2 hover:underline"
97+
>
98+
Stellar infrastructure status <ExternalLink className="h-3 w-3" />
99+
</a>
100+
</CardContent>
101+
</Card>
102+
103+
{/* Advanced — behind disclosure */}
104+
<Card>
105+
<CardHeader>
106+
<button
107+
className="flex w-full items-center justify-between text-left"
108+
onClick={() => setAdvancedOpen((o) => !o)}
109+
aria-expanded={advancedOpen}
110+
>
111+
<div>
112+
<CardTitle>Advanced</CardTitle>
113+
<CardDescription>Custom RPC, cache management, wallet reset</CardDescription>
114+
</div>
115+
{advancedOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
116+
</button>
117+
</CardHeader>
118+
119+
{advancedOpen && (
120+
<CardContent className="space-y-6">
121+
{/* Custom RPC */}
122+
<section aria-labelledby="rpc-heading" className="space-y-3">
123+
<h3 id="rpc-heading" className="text-sm font-semibold">Custom Soroban RPC URL</h3>
124+
125+
{/* Phishing warning — always visible when section is open */}
126+
<div
127+
role="alert"
128+
className="flex gap-2 rounded-md border border-yellow-400 bg-yellow-50 p-3 text-sm text-yellow-800 dark:border-yellow-600 dark:bg-yellow-950 dark:text-yellow-200"
129+
>
130+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
131+
<div>
132+
<strong>Security warning:</strong> A malicious RPC endpoint can misrepresent
133+
balances, events, and transaction outcomes. Only use endpoints you fully trust.
134+
Never enter a URL from an unsolicited message or link.
135+
</div>
136+
</div>
137+
138+
<div className="flex gap-2">
139+
<Input
140+
aria-label="Custom RPC URL"
141+
placeholder={PUBLIC_RPC[settings.network]}
142+
value={rpcInput}
143+
onChange={(e) => setRpcInput(e.target.value)}
144+
className={rpcError ? 'border-destructive' : ''}
145+
/>
146+
<Button variant="outline" size="sm" onClick={handleRpcSave}>
147+
Save
148+
</Button>
149+
</div>
150+
{rpcError && <p className="text-xs text-destructive">{rpcError}</p>}
151+
{isCustomRpc && (
152+
<p className="text-xs text-muted-foreground">
153+
Leave blank and save to revert to the public endpoint.
154+
</p>
155+
)}
156+
157+
{/* Acknowledgement checkbox — required before custom RPC takes effect */}
158+
{isCustomRpc && !settings.rpcWarningAcknowledged && (
159+
<label className="flex items-start gap-2 text-sm">
160+
<input
161+
type="checkbox"
162+
className="mt-0.5"
163+
checked={settings.rpcWarningAcknowledged}
164+
onChange={(e) => update('rpcWarningAcknowledged', e.target.checked)}
165+
/>
166+
I understand that a custom RPC can misrepresent on-chain data and I trust this
167+
endpoint.
168+
</label>
169+
)}
170+
</section>
171+
172+
{/* Cache management */}
173+
<section aria-labelledby="cache-heading" className="space-y-2">
174+
<h3 id="cache-heading" className="text-sm font-semibold">Cache</h3>
175+
<p className="text-xs text-muted-foreground">
176+
Clears cached simulations and React Query data, then reloads the page.
177+
</p>
178+
<Button variant="outline" size="sm" onClick={handleClearCaches}>
179+
<RefreshCw className="mr-2 h-4 w-4" />
180+
Clear caches &amp; refetch
181+
</Button>
182+
</section>
183+
184+
{/* Wallet reset */}
185+
<section aria-labelledby="wallet-heading" className="space-y-2">
186+
<h3 id="wallet-heading" className="text-sm font-semibold">Wallet connection</h3>
187+
<Button
188+
variant="destructive"
189+
size="sm"
190+
onClick={() => { disconnect(); reset() }}
191+
>
192+
<Unplug className="mr-2 h-4 w-4" />
193+
Disconnect &amp; reset settings
194+
</Button>
195+
</section>
196+
197+
{/* Telemetry */}
198+
<section aria-labelledby="telemetry-heading" className="space-y-2">
199+
<h3 id="telemetry-heading" className="text-sm font-semibold">Telemetry</h3>
200+
<label className="flex items-start gap-2 text-sm">
201+
<input
202+
type="checkbox"
203+
className="mt-0.5"
204+
checked={settings.telemetryEnabled}
205+
onChange={(e) => update('telemetryEnabled', e.target.checked)}
206+
/>
207+
<span>
208+
Send anonymous settings-change events to help improve the app.{' '}
209+
<span className="text-muted-foreground">
210+
No wallet addresses, balances, or personal data are ever included.
211+
</span>
212+
</span>
213+
</label>
214+
</section>
215+
</CardContent>
216+
)}
217+
</Card>
218+
</div>
219+
)
220+
}

frontend/src/hooks/use-settings.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
'use client'
2+
3+
import { useState, useCallback, useEffect } from 'react'
4+
import {
5+
loadSettings,
6+
saveSettings,
7+
type AppSettings,
8+
} from '@/lib/settings-store'
9+
10+
export function useSettings() {
11+
const [settings, setSettings] = useState<AppSettings>(loadSettings)
12+
13+
// Sync to localStorage on every change
14+
useEffect(() => {
15+
saveSettings(settings)
16+
}, [settings])
17+
18+
const update = useCallback(<K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
19+
setSettings((prev) => {
20+
const next = { ...prev, [key]: value }
21+
// Emit privacy-safe telemetry event (no PII, no secrets)
22+
if (prev.telemetryEnabled && typeof window !== 'undefined') {
23+
window.dispatchEvent(
24+
new CustomEvent('niffyinsur:settings_change', {
25+
detail: { key, network: next.network },
26+
})
27+
)
28+
}
29+
return next
30+
})
31+
}, [])
32+
33+
const reset = useCallback(() => {
34+
localStorage.removeItem('niffyinsur-settings-v1')
35+
setSettings(loadSettings())
36+
}, [])
37+
38+
return { settings, update, reset }
39+
}

frontend/src/lib/settings-store.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Settings store — persists user preferences in localStorage.
3+
* Schema version is bumped whenever the shape changes; old data is discarded.
4+
* NEVER stores secrets, private keys, or seed phrases.
5+
*/
6+
7+
import type { Network } from './network-manifest'
8+
9+
const SCHEMA_VERSION = 1
10+
const STORAGE_KEY = 'niffyinsur-settings-v1'
11+
12+
export interface AppSettings {
13+
/** Schema version — increment when shape changes */
14+
_v: number
15+
/** Active Stellar network */
16+
network: Network
17+
/** Custom Soroban RPC URL; null = use public default */
18+
customRpcUrl: string | null
19+
/** Whether the user has acknowledged the custom-RPC phishing warning */
20+
rpcWarningAcknowledged: boolean
21+
/** Opt-in to privacy-safe telemetry for settings changes */
22+
telemetryEnabled: boolean
23+
}
24+
25+
export const PUBLIC_RPC: Record<Network, string> = {
26+
testnet: 'https://soroban-testnet.stellar.org',
27+
public: 'https://soroban-mainnet.stellar.org',
28+
}
29+
30+
export const STATUS_PAGES: Record<Network, string> = {
31+
testnet: 'https://status.stellar.org',
32+
public: 'https://status.stellar.org',
33+
}
34+
35+
const DEFAULTS: AppSettings = {
36+
_v: SCHEMA_VERSION,
37+
network: 'testnet',
38+
customRpcUrl: null,
39+
rpcWarningAcknowledged: false,
40+
telemetryEnabled: false,
41+
}
42+
43+
export function loadSettings(): AppSettings {
44+
if (typeof window === 'undefined') return { ...DEFAULTS }
45+
try {
46+
const raw = localStorage.getItem(STORAGE_KEY)
47+
if (!raw) return { ...DEFAULTS }
48+
const parsed = JSON.parse(raw) as AppSettings
49+
// Discard stale schema
50+
if (parsed._v !== SCHEMA_VERSION) return { ...DEFAULTS }
51+
return { ...DEFAULTS, ...parsed }
52+
} catch {
53+
return { ...DEFAULTS }
54+
}
55+
}
56+
57+
export function saveSettings(settings: AppSettings): void {
58+
if (typeof window === 'undefined') return
59+
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...settings, _v: SCHEMA_VERSION }))
60+
}
61+
62+
/** Validate a custom RPC URL — must be https and a valid URL */
63+
export function validateRpcUrl(url: string): string | null {
64+
if (!url.trim()) return 'URL is required'
65+
try {
66+
const parsed = new URL(url)
67+
if (parsed.protocol !== 'https:') return 'Only HTTPS endpoints are allowed'
68+
return null
69+
} catch {
70+
return 'Enter a valid URL (e.g. https://rpc.example.com)'
71+
}
72+
}

0 commit comments

Comments
 (0)