467 lines
21 KiB
TypeScript
467 lines
21 KiB
TypeScript
"use client";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { NotificationSettings, NotificationProvider, SchedulerProvider } 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 || process.env.NTFY_URL || 'https://ntfy.sh').replace(/\/$/, '');
|
|
const ENV_SCHEDY = (process.env.NEXT_PUBLIC_SCHEDY_URL || process.env.SCHEDY_URL || 'http://localhost:8080').replace(/\/$/, '');
|
|
const ENV_SCHEDY_API_KEY = (process.env.NEXT_PUBLIC_SCHEDY_API_KEY || process.env.SCHEDY_API_KEY || '').trim();
|
|
|
|
const defaultSettings: NotificationSettings = {
|
|
provider: "",
|
|
scheduler: 'schedy',
|
|
ntfyServer: ENV_NTFY,
|
|
ntfyTopic: "",
|
|
gotifyServer: "",
|
|
gotifyToken: "",
|
|
snsRegion: "",
|
|
snsTopicArn: "",
|
|
snsAccessKeyId: "",
|
|
snsSecretAccessKey: "",
|
|
schedyBaseUrl: ENV_SCHEDY,
|
|
schedyApiKey: ENV_SCHEDY_API_KEY,
|
|
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>("");
|
|
// RPC runtime overrides (stored in localStorage)
|
|
const [rpcBase, setRpcBase] = useState<string>("");
|
|
const [rpcArbitrum, setRpcArbitrum] = useState<string>("");
|
|
const [rpcMainnet, setRpcMainnet] = useState<string>("");
|
|
// NFTCache settings (stored in localStorage)
|
|
const ENV_NFTCACHE = (process.env.NEXT_PUBLIC_NFTCACHE_URL || process.env.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) {
|
|
// Merge defaults to ensure newly added fields (e.g., backupDelayDays) are populated
|
|
const merged = { ...defaultSettings, ...(initial || {}) } as NotificationSettings;
|
|
// Re-apply env defaults if user hasn't set values
|
|
if (!merged.ntfyServer) merged.ntfyServer = ENV_NTFY;
|
|
if (!merged.schedyBaseUrl) merged.schedyBaseUrl = ENV_SCHEDY;
|
|
setForm(merged);
|
|
// Load RPC overrides from localStorage
|
|
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 || '');
|
|
// Load NFTCache settings
|
|
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 || (process.env.NEXT_PUBLIC_NFTCACHE_API_KEY || process.env.NFTCACHE_API_KEY || ''));
|
|
} catch {}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open, initial]);
|
|
|
|
const provider: NotificationProvider | '' = form.provider || '';
|
|
const scheduler: SchedulerProvider | '' = form.scheduler || '';
|
|
|
|
const saveDisabled = useMemo(() => {
|
|
if (!form.email) return true;
|
|
const db = form.daysBefore ?? 10;
|
|
if (Number(db) < 0) return true;
|
|
// if backupEmail provided, enforce min backupDelayDays >= 1 (treat empty/NaN as invalid)
|
|
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 (!scheduler) return true;
|
|
if (provider === 'ntfy') return !(form.ntfyServer && form.ntfyTopic);
|
|
if (provider === 'gotify') return !(form.gotifyServer && form.gotifyToken);
|
|
if (provider === 'sns') return !(form.snsRegion && form.snsTopicArn && form.snsAccessKeyId && form.snsSecretAccessKey);
|
|
if (scheduler === 'schedy') return !(form.schedyBaseUrl && form.schedyApiKey);
|
|
return true;
|
|
}, [form, provider, scheduler]);
|
|
|
|
const canTest = useMemo(() => {
|
|
// Basic same checks as save + provider must be ntfy
|
|
if (saveDisabled) return false;
|
|
return provider === 'ntfy' && scheduler === 'schedy';
|
|
}, [saveDisabled, provider, scheduler]);
|
|
|
|
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">
|
|
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
|
<div className="relative z-10 w-full max-w-xl rounded-lg border border-gray-700 bg-gray-900 p-4 text-gray-100 shadow-lg">
|
|
<div className="text-lg font-semibold">Notification Settings</div>
|
|
<p className="text-sm text-gray-300">Configure your notification provider, scheduler, RPC and NFTCache.</p>
|
|
|
|
{/* Section: Basics */}
|
|
<details className="mt-3" open>
|
|
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">Basics</summary>
|
|
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
|
|
<label className="col-span-2">
|
|
<span className="text-gray-300">Provider</span>
|
|
<select
|
|
className="mt-1 w-full rounded-none border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100 appearance-none shadow-none focus:outline-none focus:ring-0"
|
|
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>
|
|
<option value="sns">Amazon SNS</option>
|
|
</select>
|
|
</label>
|
|
|
|
<label className="col-span-2">
|
|
<span className="text-gray-300">Scheduler</span>
|
|
<select
|
|
className="mt-1 w-full rounded-none border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100 appearance-none shadow-none focus:outline-none focus:ring-0"
|
|
value={scheduler}
|
|
onChange={(e) => setForm((f) => ({ ...f, scheduler: e.target.value as SchedulerProvider }))}
|
|
>
|
|
<option value="schedy">Schedy</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</details>
|
|
|
|
{/* Section: Email & Timing */}
|
|
<details className="mt-3" open>
|
|
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">Email & Timing</summary>
|
|
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
|
|
<label>
|
|
<span className="text-gray-300">Email to notify</span>
|
|
<input
|
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
|
placeholder="you@example.com"
|
|
value={form.email || ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
|
/>
|
|
</label>
|
|
|
|
<label>
|
|
<span className="text-gray-300">Backup Email (optional)</span>
|
|
<input
|
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
|
placeholder="backup@example.com"
|
|
value={form.backupEmail || ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, backupEmail: e.target.value }))}
|
|
/>
|
|
</label>
|
|
|
|
<label>
|
|
<span className="text-gray-300">Days before liquidation</span>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
|
value={form.daysBefore ?? 10}
|
|
onChange={(e) => setForm((f) => ({ ...f, daysBefore: Number(e.target.value) }))}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span className="text-gray-300">Backup delay (days after first email)</span>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
|
value={form.backupDelayDays ?? 1}
|
|
onChange={(e) => setForm((f) => ({ ...f, backupDelayDays: Number(e.target.value) }))}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</details>
|
|
|
|
{/* Section: Scheduler (Schedy) */}
|
|
<details className="mt-3">
|
|
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">Scheduler</summary>
|
|
{scheduler === 'schedy' && (
|
|
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
|
|
<label>
|
|
<span className="text-gray-300">Schedy Base URL</span>
|
|
<input
|
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
|
placeholder="http://localhost:8080"
|
|
value={form.schedyBaseUrl || ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, schedyBaseUrl: e.target.value }))}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span className="text-gray-300">Schedy API Key</span>
|
|
<input
|
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
|
placeholder="your-secret"
|
|
value={form.schedyApiKey || ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, schedyApiKey: e.target.value }))}
|
|
/>
|
|
</label>
|
|
</div>
|
|
)}
|
|
</details>
|
|
|
|
{/* Section: Provider-specific */}
|
|
<details className="mt-3">
|
|
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">Provider Settings</summary>
|
|
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
|
|
{provider === 'ntfy' && (
|
|
<>
|
|
<label>
|
|
<span className="text-gray-300">ntfy Server</span>
|
|
<input
|
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
|
placeholder="https://ntfy.sh"
|
|
value={form.ntfyServer || ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, ntfyServer: e.target.value }))}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span className="text-gray-300">ntfy Topic</span>
|
|
<input
|
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
|
placeholder="topic-name"
|
|
value={form.ntfyTopic || ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, ntfyTopic: e.target.value }))}
|
|
/>
|
|
</label>
|
|
</>
|
|
)}
|
|
|
|
{provider === 'gotify' && (
|
|
<>
|
|
<label>
|
|
<span className="text-gray-300">Gotify Server</span>
|
|
<input
|
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
|
placeholder="https://gotify.example.com"
|
|
value={form.gotifyServer || ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, gotifyServer: e.target.value }))}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span className="text-gray-300">Gotify App Token</span>
|
|
<input
|
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
|
placeholder="token"
|
|
value={form.gotifyToken || ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, gotifyToken: e.target.value }))}
|
|
/>
|
|
</label>
|
|
</>
|
|
)}
|
|
|
|
{provider === 'sns' && (
|
|
<>
|
|
<label>
|
|
<span className="text-gray-300">AWS Region</span>
|
|
<input
|
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
|
placeholder="us-east-1"
|
|
value={form.snsRegion || ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, snsRegion: e.target.value }))}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span className="text-gray-300">SNS Topic ARN</span>
|
|
<input
|
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
|
placeholder="arn:aws:sns:..."
|
|
value={form.snsTopicArn || ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, snsTopicArn: e.target.value }))}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span className="text-gray-300">AWS Access Key ID</span>
|
|
<input
|
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
|
placeholder="AKIA..."
|
|
value={form.snsAccessKeyId || ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, snsAccessKeyId: e.target.value }))}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span className="text-gray-300">AWS Secret Access Key</span>
|
|
<input
|
|
type="password"
|
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
|
placeholder="••••••••"
|
|
value={form.snsSecretAccessKey || ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, snsSecretAccessKey: e.target.value }))}
|
|
/>
|
|
</label>
|
|
<div className="col-span-2 text-xs text-amber-300">
|
|
Storing AWS credentials in the browser is insecure. Prefer a server-side relay.
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</details>
|
|
|
|
{/* Section: RPC endpoints */}
|
|
<details className="mt-3">
|
|
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">RPC endpoints</summary>
|
|
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
|
|
<label>
|
|
<span className="text-gray-300">Base RPC (default https://base.llamarpc.com)</span>
|
|
<input
|
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
|
placeholder="https://base.llamarpc.com"
|
|
value={rpcBase}
|
|
onChange={(e) => setRpcBase(e.target.value)}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span className="text-gray-300">Arbitrum RPC (optional)</span>
|
|
<input
|
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
|
placeholder="https://arb.yourrpc.example"
|
|
value={rpcArbitrum}
|
|
onChange={(e) => setRpcArbitrum(e.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="col-span-2">
|
|
<span className="text-gray-300">Mainnet RPC (optional)</span>
|
|
<input
|
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
|
placeholder="https://mainnet.yourrpc.example"
|
|
value={rpcMainnet}
|
|
onChange={(e) => setRpcMainnet(e.target.value)}
|
|
/>
|
|
</label>
|
|
<div className="col-span-2 text-xs text-gray-400">Changes apply immediately after Save without rebuild.</div>
|
|
</div>
|
|
</details>
|
|
|
|
{/* Section: NFTCache */}
|
|
<details className="mt-3">
|
|
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">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-gray-300">Enable NFTCache</span>
|
|
</label>
|
|
<label className="col-span-2">
|
|
<span className="text-gray-300">NFTCache Base URL</span>
|
|
<input
|
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
|
placeholder="/nftcache"
|
|
value={nftcacheBaseUrl}
|
|
onChange={(e) => setNftcacheBaseUrl(e.target.value)}
|
|
/>
|
|
<div className="text-xs text-gray-400 mt-1">Proxied default via nginx: /nftcache. Endpoint used in-app: {nftcacheBaseUrl || ENV_NFTCACHE}/nfts</div>
|
|
</label>
|
|
<label className="col-span-2">
|
|
<span className="text-gray-300">NFTCache API Key (X-API-Key)</span>
|
|
<input
|
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
|
placeholder="optional, can also be set via NEXT_PUBLIC_NFTCACHE_API_KEY"
|
|
value={nftcacheApiKey}
|
|
onChange={(e) => setNftcacheApiKey(e.target.value)}
|
|
/>
|
|
<div className="text-xs text-gray-400 mt-1">If empty, the app will try NEXT_PUBLIC_NFTCACHE_API_KEY from the environment.</div>
|
|
</label>
|
|
</div>
|
|
</details>
|
|
|
|
<div className="mt-4 flex justify-end gap-2">
|
|
<button className="rounded bg-gray-700 px-3 py-1.5" onClick={onClose}>Cancel</button>
|
|
<button
|
|
className="rounded bg-emerald-600 px-3 py-1.5 disabled:opacity-50"
|
|
disabled={!canTest}
|
|
onClick={onTest}
|
|
title={provider !== 'ntfy' ? 'Test currently supports ntfy provider only' : ''}
|
|
>
|
|
Send test alert
|
|
</button>
|
|
<button
|
|
className="rounded bg-teal-600 px-3 py-1.5 disabled:opacity-50"
|
|
disabled={!canTest || !(form.backupEmail || '').trim()}
|
|
onClick={onTestBackup}
|
|
title={!((form.backupEmail || '').trim()) ? 'Set a backup email first' : ''}
|
|
>
|
|
Send backup test
|
|
</button>
|
|
<button
|
|
className="rounded bg-indigo-600 px-3 py-1.5 disabled:opacity-50"
|
|
disabled={saveDisabled}
|
|
onClick={() => {
|
|
// Persist RPC overrides
|
|
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');
|
|
// Persist NFTCache settings
|
|
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') {
|
|
// Ensure new RPCs are applied by reloading the app
|
|
setTimeout(() => window.location.reload(), 150);
|
|
}
|
|
} catch {}
|
|
}}
|
|
>
|
|
Save
|
|
</button>
|
|
</div>
|
|
{testStatus && (
|
|
<div className="mt-2 text-xs text-gray-300">{testStatus}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|