Files
mortgagefi-helper/components/SettingsModal.tsx

322 lines
14 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>("");
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);
}
// 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 and scheduler credentials.</p>
<div className="mt-3 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>
{scheduler === 'schedy' && (
<>
<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>
</>
)}
<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>
{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>
<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={() => onSave(form)}
>
Save
</button>
</div>
{testStatus && (
<div className="mt-2 text-xs text-gray-300">{testStatus}</div>
)}
</div>
</div>
);
}