Files
lead-scraper/app/settings/page.tsx
Timo Uttenweiler facf8c9f69 Initial commit: LeadFlow lead generation platform
Full-stack Next.js 16 app with three scraping pipelines:
- AirScale CSV → Anymailfinder Bulk Decision Maker search
- LinkedIn Sales Navigator → Vayne → Anymailfinder email enrichment
- Apify Google SERP → domain extraction → Anymailfinder bulk enrichment

Includes Docker multi-stage build + docker-compose for Coolify deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 11:21:11 +01:00

245 lines
8.7 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { CheckCircle2, XCircle, Loader2, ExternalLink, Eye, EyeOff } from "lucide-react";
interface Credential {
service: string;
label: string;
placeholder: string;
link: string;
linkLabel: string;
isTextarea?: boolean;
instructions?: string;
}
const CREDENTIALS: Credential[] = [
{
service: "anymailfinder",
label: "Anymailfinder API Key",
placeholder: "amf_...",
link: "https://anymailfinder.com/account",
linkLabel: "Get API key",
},
{
service: "apify",
label: "Apify API Token",
placeholder: "apify_api_...",
link: "https://console.apify.com/account/integrations",
linkLabel: "Get API token",
},
{
service: "vayne",
label: "Vayne API Token",
placeholder: "Bearer token from vayne.io dashboard",
link: "https://www.vayne.io",
linkLabel: "Vayne Dashboard",
isTextarea: true,
instructions: "Generate your API token in the API Settings section of your Vayne Dashboard at vayne.io. Use Bearer authentication — Vayne manages the LinkedIn session on their end.",
},
];
interface CredentialState {
value: string;
saved: boolean;
testing: boolean;
testResult: "ok" | "fail" | null;
show: boolean;
}
export default function SettingsPage() {
const [creds, setCreds] = useState<Record<string, CredentialState>>(() =>
Object.fromEntries(
CREDENTIALS.map(c => [c.service, { value: "", saved: false, testing: false, testResult: null, show: false }])
)
);
const [saving, setSaving] = useState(false);
// Load existing (masked) status
useEffect(() => {
fetch("/api/credentials")
.then(r => r.json())
.then((data: Record<string, boolean>) => {
setCreds(prev => {
const next = { ...prev };
for (const [k, v] of Object.entries(data)) {
if (next[k]) {
next[k] = { ...next[k], saved: v };
}
}
return next;
});
})
.catch(() => {});
}, []);
const update = (service: string, field: keyof CredentialState, val: unknown) => {
setCreds(prev => ({ ...prev, [service]: { ...prev[service], [field]: val } }));
};
const saveAll = async () => {
setSaving(true);
try {
const body: Record<string, string> = {};
for (const [service, state] of Object.entries(creds)) {
if (state.value) body[service] = state.value;
}
const res = await fetch("/api/credentials", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error("Save failed");
toast.success("Credentials saved successfully");
setCreds(prev => {
const next = { ...prev };
for (const service of Object.keys(body)) {
next[service] = { ...next[service], saved: true };
}
return next;
});
} catch {
toast.error("Failed to save credentials");
} finally {
setSaving(false);
}
};
const testConnection = async (service: string) => {
update(service, "testing", true);
update(service, "testResult", null);
try {
const res = await fetch(`/api/credentials/test?service=${service}`);
const data = await res.json() as { ok: boolean };
update(service, "testResult", data.ok ? "ok" : "fail");
if (data.ok) toast.success(`${service} connection verified`);
else toast.error(`${service} connection failed — check your key`);
} catch {
update(service, "testResult", "fail");
toast.error("Connection test failed");
} finally {
update(service, "testing", false);
}
};
return (
<div className="max-w-2xl space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-white">Settings</h1>
<p className="text-gray-400 mt-1 text-sm">
API credentials are encrypted with AES-256 and stored locally in SQLite.
</p>
</div>
{/* Credential cards */}
{CREDENTIALS.map(cred => {
const state = creds[cred.service];
return (
<Card key={cred.service} className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div>
<Label className="text-white font-semibold text-base">{cred.label}</Label>
<div className="flex items-center gap-2 mt-1">
{state.saved ? (
<span className="flex items-center gap-1 text-xs text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" /> Configured
</span>
) : (
<span className="flex items-center gap-1 text-xs text-red-400">
<XCircle className="w-3.5 h-3.5" /> Not configured
</span>
)}
</div>
</div>
</div>
<a
href={cred.link}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300 transition-colors"
>
{cred.linkLabel} <ExternalLink className="w-3 h-3" />
</a>
</div>
{cred.instructions && (
<p className="text-xs text-gray-500 bg-[#0d0d18] rounded-lg p-3 border border-[#1e1e2e]">
{cred.instructions}
</p>
)}
<div className="relative">
{cred.isTextarea ? (
<Textarea
placeholder={cred.placeholder}
value={state.value}
onChange={e => update(cred.service, "value", e.target.value)}
className="bg-[#0d0d18] border-[#2e2e3e] text-white placeholder:text-gray-600 focus:border-blue-500 resize-none h-20"
/>
) : (
<div className="relative">
<Input
type={state.show ? "text" : "password"}
placeholder={state.saved && !state.value ? "••••••••••••••••" : cred.placeholder}
value={state.value}
onChange={e => update(cred.service, "value", e.target.value)}
className="bg-[#0d0d18] border-[#2e2e3e] text-white placeholder:text-gray-600 focus:border-blue-500 pr-10"
/>
<button
type="button"
onClick={() => update(cred.service, "show", !state.show)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
{state.show ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
disabled={state.testing || (!state.saved && !state.value)}
onClick={() => testConnection(cred.service)}
className="border-[#2e2e3e] hover:border-blue-500/50 text-gray-300"
>
{state.testing ? (
<><Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" /> Testing...</>
) : "Test Connection"}
</Button>
{state.testResult === "ok" && (
<span className="flex items-center gap-1 text-xs text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" /> Connected
</span>
)}
{state.testResult === "fail" && (
<span className="flex items-center gap-1 text-xs text-red-400">
<XCircle className="w-3.5 h-3.5" /> Failed
</span>
)}
</div>
</Card>
);
})}
{/* Save button */}
<Button
disabled={saving}
onClick={saveAll}
className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium px-8 shadow-lg hover:shadow-blue-500/25 transition-all"
>
{saving ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Saving...</> : "Save All Credentials"}
</Button>
</div>
);
}