Files
mortgagefi-helper/components/SettingsModal.tsx
Siavash Sameni 6ae581ab2e feat(ui): Ghibli/Miyazaki reskin + Obsidian docs vault + project audit
UI: warm daylight design system (Tailwind v4 @theme palette, gh-* component
classes, watercolor grain, Zen Maru Gothic + Klee One fonts), animated SSR-safe
GhibliBackground (drifting clouds, meadow hills, soot sprites), and a full reskin
of navbar, connect button, dapp page, loan cards, settings modal, and readme.
Fixes the bg-white-on-dark loan-card inconsistency. Web3/business logic untouched.

Docs: converted docs/ into an Obsidian vault (frontmatter, [[wikilinks]],
callouts, Home MOC, folders Architecture/Operations/Audits) and added a
full-project audit note (Project Audit 2026-06). Redacted a real leaked Schedy
key value from the security audit example (rotate it at Schedy).

Also commits the previously-untracked server layer: app/api (cron + tasks routes)
and lib (redis, ssrf-guard, task-store).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 08:13:53 +04:00

355 lines
14 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { NotificationSettings, NotificationProvider } from "@/types/notifications";
import { scheduleTestNotification, scheduleTestBackupNotification } from "@/utils/scheduler";
export interface SettingsModalProps {
open: boolean;
initial?: NotificationSettings;
onClose: () => void;
onSave: (settings: NotificationSettings) => void;
}
const ENV_NTFY = (process.env.NEXT_PUBLIC_NTFY_URL || '/ntfy').replace(/\/$/, '');
const defaultSettings: NotificationSettings = {
provider: "",
ntfyServer: ENV_NTFY,
ntfyTopic: "",
gotifyServer: "",
gotifyToken: "",
email: "",
backupEmail: "",
backupDelayDays: 1,
daysBefore: 10,
};
export default function SettingsModal({ open, initial, onClose, onSave }: SettingsModalProps) {
const [form, setForm] = useState<NotificationSettings>(initial || defaultSettings);
const [testStatus, setTestStatus] = useState<string>("");
const [rpcBase, setRpcBase] = useState<string>("");
const [rpcArbitrum, setRpcArbitrum] = useState<string>("");
const [rpcMainnet, setRpcMainnet] = useState<string>("");
const ENV_NFTCACHE = (process.env.NEXT_PUBLIC_NFTCACHE_URL || '/nftcache').replace(/\/$/, '');
const [nftcacheEnabled, setNftcacheEnabled] = useState<boolean>(false);
const [nftcacheBaseUrl, setNftcacheBaseUrl] = useState<string>(ENV_NFTCACHE);
const [nftcacheApiKey, setNftcacheApiKey] = useState<string>('');
useEffect(() => {
if (open) {
const merged = { ...defaultSettings, ...(initial || {}) } as NotificationSettings;
if (!merged.ntfyServer) merged.ntfyServer = ENV_NTFY;
setForm(merged);
try {
const ls = typeof window !== 'undefined' ? window.localStorage : undefined;
const b = ls?.getItem('rpc:base') || '';
const a = ls?.getItem('rpc:arbitrum') || '';
const m = ls?.getItem('rpc:mainnet') || '';
setRpcBase(b || 'https://base.llamarpc.com');
setRpcArbitrum(a || '');
setRpcMainnet(m || '');
const nEn = ls?.getItem('nftcache:enabled');
const nUrl = ls?.getItem('nftcache:baseUrl');
const nKey = ls?.getItem('nftcache:apiKey');
setNftcacheEnabled(nEn === '1');
setNftcacheBaseUrl((nUrl && nUrl.trim()) || ENV_NFTCACHE);
setNftcacheApiKey(nKey || '');
} catch {}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, initial]);
const provider: NotificationProvider | '' = form.provider || '';
const saveDisabled = useMemo(() => {
if (!form.email) return true;
const db = form.daysBefore ?? 10;
if (Number(db) < 0) return true;
const hasBackup = Boolean((form.backupEmail || '').trim());
if (hasBackup) {
const bdd = Number(form.backupDelayDays);
if (!Number.isFinite(bdd) || bdd < 1) return true;
}
if (!provider) return true;
if (provider === 'ntfy') return !(form.ntfyServer && form.ntfyTopic);
if (provider === 'gotify') return !(form.gotifyServer && form.gotifyToken);
return false;
}, [form, provider]);
const canTest = useMemo(() => {
if (saveDisabled) return false;
return provider === 'ntfy';
}, [saveDisabled, provider]);
const onTest = async () => {
setTestStatus('Scheduling test…');
try {
const { jobId } = await scheduleTestNotification(form);
setTestStatus(`Test scheduled (job ${jobId}). You should receive a notification shortly.`);
} catch (e: any) {
setTestStatus(`Test failed: ${e?.message || String(e)}`);
}
};
const onTestBackup = async () => {
setTestStatus('Scheduling backup test…');
try {
const { jobId } = await scheduleTestBackupNotification(form);
setTestStatus(`Backup test scheduled (job ${jobId}). You should receive a backup email shortly.`);
} catch (e: any) {
setTestStatus(`Backup test failed: ${e?.message || String(e)}`);
}
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-ink/40 backdrop-blur-sm" onClick={onClose} />
<div className="gh-card relative z-10 w-full max-w-xl p-5 text-ink shadow-float max-h-[90vh] overflow-y-auto">
<div className="font-display text-xl font-semibold text-forest-deep flex items-center gap-2"><span aria-hidden>🔔</span>Notification Settings</div>
<p className="text-sm text-ink-soft">Configure your notification provider, RPC and NFTCache.</p>
<div className="gh-note gh-note-warn mt-2 text-xs">
Settings are stored locally in your browser. Do not use this on shared computers.
</div>
{/* Section: Basics */}
<details className="mt-3" open>
<summary className="cursor-pointer select-none gh-eyebrow text-sm">Basics</summary>
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
<label className="col-span-2">
<span className="text-earth">Provider</span>
<select
className="gh-select appearance-none"
value={provider}
onChange={(e) => setForm((f) => ({ ...f, provider: e.target.value as NotificationProvider }))}
>
<option value="">Select</option>
<option value="ntfy">ntfy.sh</option>
<option value="gotify">Gotify</option>
</select>
</label>
</div>
</details>
{/* Section: Email & Timing */}
<details className="mt-3" open>
<summary className="cursor-pointer select-none gh-eyebrow text-sm">Email & Timing</summary>
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
<label>
<span className="text-earth">Email to notify</span>
<input
className="gh-input"
placeholder="you@example.com"
value={form.email || ''}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
/>
</label>
<label>
<span className="text-earth">Backup Email (optional)</span>
<input
className="gh-input"
placeholder="backup@example.com"
value={form.backupEmail || ''}
onChange={(e) => setForm((f) => ({ ...f, backupEmail: e.target.value }))}
/>
</label>
<label>
<span className="text-earth">Days before liquidation</span>
<input
type="number"
min={0}
max={365}
className="gh-input"
value={form.daysBefore ?? 10}
onChange={(e) => setForm((f) => ({ ...f, daysBefore: Number(e.target.value) }))}
/>
</label>
<label>
<span className="text-earth">Backup delay (days after first email)</span>
<input
type="number"
min={1}
max={90}
className="gh-input"
value={form.backupDelayDays ?? 1}
onChange={(e) => setForm((f) => ({ ...f, backupDelayDays: Number(e.target.value) }))}
/>
</label>
</div>
</details>
{/* Section: Provider-specific */}
<details className="mt-3">
<summary className="cursor-pointer select-none gh-eyebrow text-sm">Provider Settings</summary>
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
{provider === 'ntfy' && (
<>
<label>
<span className="text-earth">ntfy Server</span>
<input
className="gh-input"
placeholder="https://ntfy.sh"
value={form.ntfyServer || ''}
onChange={(e) => setForm((f) => ({ ...f, ntfyServer: e.target.value }))}
/>
</label>
<label>
<span className="text-earth">ntfy Topic</span>
<input
className="gh-input"
placeholder="topic-name"
value={form.ntfyTopic || ''}
onChange={(e) => setForm((f) => ({ ...f, ntfyTopic: e.target.value }))}
/>
</label>
</>
)}
{provider === 'gotify' && (
<>
<label>
<span className="text-earth">Gotify Server</span>
<input
className="gh-input"
placeholder="https://gotify.example.com"
value={form.gotifyServer || ''}
onChange={(e) => setForm((f) => ({ ...f, gotifyServer: e.target.value }))}
/>
</label>
<label>
<span className="text-earth">Gotify App Token</span>
<input
className="gh-input"
placeholder="token"
value={form.gotifyToken || ''}
onChange={(e) => setForm((f) => ({ ...f, gotifyToken: e.target.value }))}
/>
</label>
</>
)}
</div>
</details>
{/* Section: RPC endpoints */}
<details className="mt-3">
<summary className="cursor-pointer select-none gh-eyebrow text-sm">RPC endpoints</summary>
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
<label>
<span className="text-earth">Base RPC (default https://base.llamarpc.com)</span>
<input
className="gh-input"
placeholder="https://base.llamarpc.com"
value={rpcBase}
onChange={(e) => setRpcBase(e.target.value)}
/>
</label>
<label>
<span className="text-earth">Arbitrum RPC (optional)</span>
<input
className="gh-input"
placeholder="https://arb.yourrpc.example"
value={rpcArbitrum}
onChange={(e) => setRpcArbitrum(e.target.value)}
/>
</label>
<label className="col-span-2">
<span className="text-earth">Mainnet RPC (optional)</span>
<input
className="gh-input"
placeholder="https://mainnet.yourrpc.example"
value={rpcMainnet}
onChange={(e) => setRpcMainnet(e.target.value)}
/>
</label>
<div className="col-span-2 text-xs text-danger">
Only use HTTPS RPC endpoints you trust. Malicious RPCs can show fake balances and hide liquidation warnings.
</div>
</div>
</details>
{/* Section: NFTCache */}
<details className="mt-3">
<summary className="cursor-pointer select-none gh-eyebrow text-sm">NFTCache</summary>
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
<label className="flex items-center gap-2">
<input
type="checkbox"
className="h-4 w-4"
checked={nftcacheEnabled}
onChange={(e) => setNftcacheEnabled(e.target.checked)}
/>
<span className="text-earth">Enable NFTCache</span>
</label>
<label className="col-span-2">
<span className="text-earth">NFTCache Base URL</span>
<input
className="gh-input"
placeholder="/nftcache"
value={nftcacheBaseUrl}
onChange={(e) => setNftcacheBaseUrl(e.target.value)}
/>
<div className="text-xs text-earth mt-1">Proxied default via nginx: /nftcache. Endpoint used in-app: {nftcacheBaseUrl || ENV_NFTCACHE}/nfts</div>
</label>
</div>
</details>
<div className="mt-4 flex flex-wrap justify-end gap-2">
<button className="gh-btn gh-btn-ghost" onClick={onClose}>Cancel</button>
<button
className="gh-btn gh-btn-sun"
disabled={!canTest}
onClick={onTest}
title={provider !== 'ntfy' ? 'Test currently supports ntfy provider only' : ''}
>
Send test alert
</button>
<button
className="gh-btn gh-btn-sky"
disabled={!canTest || !(form.backupEmail || '').trim()}
onClick={onTestBackup}
title={!((form.backupEmail || '').trim()) ? 'Set a backup email first' : ''}
>
Send backup test
</button>
<button
className="gh-btn gh-btn-primary"
disabled={saveDisabled}
onClick={() => {
try {
if (typeof window !== 'undefined' && window.localStorage) {
const ls = window.localStorage;
const b = (rpcBase || '').trim();
const a = (rpcArbitrum || '').trim();
const m = (rpcMainnet || '').trim();
if (b) ls.setItem('rpc:base', b); else ls.removeItem('rpc:base');
if (a) ls.setItem('rpc:arbitrum', a); else ls.removeItem('rpc:arbitrum');
if (m) ls.setItem('rpc:mainnet', m); else ls.removeItem('rpc:mainnet');
ls.setItem('nftcache:enabled', nftcacheEnabled ? '1' : '0');
const nurl = (nftcacheBaseUrl || '').trim();
if (nurl) ls.setItem('nftcache:baseUrl', nurl); else ls.removeItem('nftcache:baseUrl');
const nkey = (nftcacheApiKey || '').trim();
if (nkey) ls.setItem('nftcache:apiKey', nkey); else ls.removeItem('nftcache:apiKey');
}
} catch {}
onSave(form);
try {
if (typeof window !== 'undefined') {
setTimeout(() => window.location.reload(), 150);
}
} catch {}
}}
>
Save
</button>
</div>
{testStatus && (
<div className="mt-2 text-xs text-ink-soft">{testStatus}</div>
)}
</div>
</div>
);
}