first commit
This commit is contained in:
18
frontend/index.html
Normal file
18
frontend/index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Private OSINT Automation Platform" />
|
||||
<meta name="theme-color" content="#020617" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
<title>OSINT Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2848
frontend/package-lock.json
generated
Normal file
2848
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend/package.json
Normal file
31
frontend/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "osint-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"lucide-react": "^0.359.0",
|
||||
"zustand": "^4.5.2",
|
||||
"date-fns": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.64",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.4.2",
|
||||
"vite": "^5.1.6"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
6
frontend/public/vite.svg
Normal file
6
frontend/public/vite.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#020617"/>
|
||||
<path d="M16 6L7 11v10l9 5 9-5V11l-9-5z" stroke="#22d3ee" stroke-width="1.5" fill="none"/>
|
||||
<circle cx="16" cy="16" r="4" fill="#22d3ee" opacity="0.3"/>
|
||||
<path d="M16 12v8M12 16h8" stroke="#22d3ee" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 387 B |
36
frontend/src/App.tsx
Normal file
36
frontend/src/App.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<div className="min-h-screen bg-slate-950">
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
161
frontend/src/components/AddProfileModal.tsx
Normal file
161
frontend/src/components/AddProfileModal.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useState } from 'react';
|
||||
import { useTargetStore } from '../stores/targetStore';
|
||||
import { X, Globe, Twitter, Instagram, Linkedin, Facebook } from 'lucide-react';
|
||||
|
||||
interface AddProfileModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PLATFORMS = [
|
||||
{ id: 'x', name: 'X (Twitter)', icon: Twitter },
|
||||
{ id: 'instagram', name: 'Instagram', icon: Instagram },
|
||||
{ id: 'linkedin', name: 'LinkedIn', icon: Linkedin },
|
||||
{ id: 'facebook', name: 'Facebook', icon: Facebook },
|
||||
{ id: 'other', name: 'Other', icon: Globe },
|
||||
];
|
||||
|
||||
export function AddProfileModal({ onClose }: AddProfileModalProps) {
|
||||
const { selectedTarget, addProfile } = useTargetStore();
|
||||
const [platform, setPlatform] = useState('x');
|
||||
const [username, setUsername] = useState('');
|
||||
const [profileUrl, setProfileUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
if (!selectedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!platform) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await addProfile(
|
||||
selectedTarget.id,
|
||||
platform,
|
||||
username.trim() || undefined,
|
||||
profileUrl.trim() || undefined
|
||||
);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to add profile');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-md mx-4 bg-slate-900 border border-slate-800 rounded-xl shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-accent-cyan" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-100">Add Profile</h2>
|
||||
<p className="text-xs text-slate-500">for {selectedTarget.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-accent-rose/10 border border-accent-rose/20 text-accent-rose text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-400 mb-2">
|
||||
Platform *
|
||||
</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{PLATFORMS.map((p) => {
|
||||
const Icon = p.icon;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setPlatform(p.id)}
|
||||
className={`flex flex-col items-center gap-1 p-3 rounded-lg border transition-colors ${
|
||||
platform === p.id
|
||||
? 'border-accent-cyan bg-accent-cyan/10 text-accent-cyan'
|
||||
: 'border-slate-700 text-slate-400 hover:border-slate-600 hover:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="text-[10px]">{p.id === 'x' ? 'X' : p.id.slice(0, 2).toUpperCase()}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-slate-400 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="input-field w-full font-mono"
|
||||
placeholder="@username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="profileUrl" className="block text-sm font-medium text-slate-400 mb-2">
|
||||
Profile URL
|
||||
</label>
|
||||
<input
|
||||
id="profileUrl"
|
||||
type="url"
|
||||
value={profileUrl}
|
||||
onChange={(e) => setProfileUrl(e.target.value)}
|
||||
className="input-field w-full font-mono text-sm"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-secondary flex-1"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary flex-1"
|
||||
disabled={!platform || loading}
|
||||
>
|
||||
{loading ? 'Adding...' : 'Add Profile'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
frontend/src/components/AddSessionModal.tsx
Normal file
190
frontend/src/components/AddSessionModal.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState } from 'react';
|
||||
import { useScraperStore } from '../stores/scraperStore';
|
||||
import { X, Key, AlertCircle, HelpCircle } from 'lucide-react';
|
||||
|
||||
interface AddSessionModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PLATFORMS = [
|
||||
{ id: 'x', name: 'X (Twitter)' },
|
||||
{ id: 'instagram', name: 'Instagram' },
|
||||
{ id: 'linkedin', name: 'LinkedIn' },
|
||||
{ id: 'facebook', name: 'Facebook' },
|
||||
];
|
||||
|
||||
export function AddSessionModal({ onClose }: AddSessionModalProps) {
|
||||
const { addSession } = useScraperStore();
|
||||
const [platform, setPlatform] = useState('x');
|
||||
const [sessionName, setSessionName] = useState('');
|
||||
const [cookiesJson, setCookiesJson] = useState('');
|
||||
const [userAgent, setUserAgent] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!platform || !sessionName.trim() || !cookiesJson.trim()) {
|
||||
setError('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse cookies JSON
|
||||
let cookies: any[];
|
||||
try {
|
||||
cookies = JSON.parse(cookiesJson);
|
||||
if (!Array.isArray(cookies)) {
|
||||
throw new Error('Cookies must be an array');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Invalid cookies JSON. Please provide a valid array of cookie objects.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await addSession({
|
||||
platform,
|
||||
session_name: sessionName.trim(),
|
||||
cookies,
|
||||
user_agent: userAgent.trim() || undefined,
|
||||
});
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to add session');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-lg mx-4 bg-slate-900 border border-slate-800 rounded-xl shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5 text-accent-cyan" />
|
||||
<h2 className="text-lg font-semibold text-slate-100">Add Session</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-accent-rose/10 border border-accent-rose/20 text-accent-rose text-sm">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info box */}
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-accent-cyan/10 border border-accent-cyan/20 text-accent-cyan text-xs">
|
||||
<HelpCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium mb-1">How to get cookies:</p>
|
||||
<ol className="list-decimal list-inside text-accent-cyan/80 space-y-0.5">
|
||||
<li>Log into the platform in your browser</li>
|
||||
<li>Open DevTools (F12) → Application → Cookies</li>
|
||||
<li>Export cookies as JSON array</li>
|
||||
<li>Or use a browser extension like "Cookie Editor"</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="platform" className="block text-sm font-medium text-slate-400 mb-2">
|
||||
Platform *
|
||||
</label>
|
||||
<select
|
||||
id="platform"
|
||||
value={platform}
|
||||
onChange={(e) => setPlatform(e.target.value)}
|
||||
className="input-field w-full"
|
||||
>
|
||||
{PLATFORMS.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="sessionName" className="block text-sm font-medium text-slate-400 mb-2">
|
||||
Session Name *
|
||||
</label>
|
||||
<input
|
||||
id="sessionName"
|
||||
type="text"
|
||||
value={sessionName}
|
||||
onChange={(e) => setSessionName(e.target.value)}
|
||||
className="input-field w-full"
|
||||
placeholder="My Account"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="cookies" className="block text-sm font-medium text-slate-400 mb-2">
|
||||
Cookies JSON *
|
||||
</label>
|
||||
<textarea
|
||||
id="cookies"
|
||||
value={cookiesJson}
|
||||
onChange={(e) => setCookiesJson(e.target.value)}
|
||||
className="input-field w-full h-32 resize-none font-mono text-xs"
|
||||
placeholder='[{"name": "auth_token", "value": "...", "domain": ".twitter.com"}]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="userAgent" className="block text-sm font-medium text-slate-400 mb-2">
|
||||
User Agent <span className="text-slate-600">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="userAgent"
|
||||
type="text"
|
||||
value={userAgent}
|
||||
onChange={(e) => setUserAgent(e.target.value)}
|
||||
className="input-field w-full font-mono text-xs"
|
||||
placeholder="Mozilla/5.0 ..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-secondary flex-1"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary flex-1"
|
||||
disabled={!platform || !sessionName.trim() || !cookiesJson.trim() || loading}
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save Session'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
frontend/src/components/AddTargetModal.tsx
Normal file
114
frontend/src/components/AddTargetModal.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react';
|
||||
import { useTargetStore } from '../stores/targetStore';
|
||||
import { X, UserPlus } from 'lucide-react';
|
||||
|
||||
interface AddTargetModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AddTargetModal({ onClose }: AddTargetModalProps) {
|
||||
const { createTarget } = useTargetStore();
|
||||
const [name, setName] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await createTarget(name.trim(), notes.trim() || undefined);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create target');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-md mx-4 bg-slate-900 border border-slate-800 rounded-xl shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserPlus className="w-5 h-5 text-accent-cyan" />
|
||||
<h2 className="text-lg font-semibold text-slate-100">New Target</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-accent-rose/10 border border-accent-rose/20 text-accent-rose text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-slate-400 mb-2">
|
||||
Target Name *
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="input-field w-full"
|
||||
placeholder="e.g., John Doe"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="notes" className="block text-sm font-medium text-slate-400 mb-2">
|
||||
Notes
|
||||
</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="input-field w-full h-24 resize-none"
|
||||
placeholder="Additional information about this target..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-secondary flex-1"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary flex-1"
|
||||
disabled={!name.trim() || loading}
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create Target'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
frontend/src/components/Header.tsx
Normal file
86
frontend/src/components/Header.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Shield, LogOut, Plus, Key, Activity } from 'lucide-react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useScraperStore } from '../stores/scraperStore';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface HeaderProps {
|
||||
onAddTarget: () => void;
|
||||
onAddSession: () => void;
|
||||
}
|
||||
|
||||
export function Header({ onAddTarget, onAddSession }: HeaderProps) {
|
||||
const { logout } = useAuthStore();
|
||||
const { activeJobs, sessions } = useScraperStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const runningJobs = activeJobs.filter(j => j.status === 'running').length;
|
||||
const activeSessions = sessions.filter(s => s.status === 'active').length;
|
||||
|
||||
return (
|
||||
<header className="h-14 flex-shrink-0 bg-slate-900 border-b border-slate-800 px-4 flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-accent-cyan/20 to-accent-violet/20 border border-accent-cyan/30">
|
||||
<Shield className="w-4 h-4 text-accent-cyan" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold text-slate-100">OSINT Platform</h1>
|
||||
<p className="text-[10px] text-slate-500 font-mono uppercase tracking-wider">Intelligence Automation</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status indicators */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Running jobs indicator */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-800/50 border border-slate-700/50">
|
||||
<Activity className={`w-3.5 h-3.5 ${runningJobs > 0 ? 'text-accent-emerald animate-pulse' : 'text-slate-500'}`} />
|
||||
<span className="text-xs font-medium text-slate-400">
|
||||
{runningJobs > 0 ? `${runningJobs} Active` : 'Idle'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sessions indicator */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-800/50 border border-slate-700/50">
|
||||
<Key className={`w-3.5 h-3.5 ${activeSessions > 0 ? 'text-accent-cyan' : 'text-slate-500'}`} />
|
||||
<span className="text-xs font-medium text-slate-400">
|
||||
{activeSessions} Sessions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onAddSession}
|
||||
className="btn-secondary flex items-center gap-1.5 text-xs py-1.5"
|
||||
>
|
||||
<Key className="w-3.5 h-3.5" />
|
||||
<span>Add Session</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onAddTarget}
|
||||
className="btn-primary flex items-center gap-1.5 text-xs py-1.5"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>New Target</span>
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-slate-700 mx-2" />
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-800 transition-colors"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
238
frontend/src/components/MainDataFeed.tsx
Normal file
238
frontend/src/components/MainDataFeed.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { useState } from 'react';
|
||||
import { useTargetStore } from '../stores/targetStore';
|
||||
import { useScraperStore } from '../stores/scraperStore';
|
||||
import {
|
||||
Globe, Twitter, Instagram, Linkedin, Facebook,
|
||||
ExternalLink, Play, Trash2, ChevronDown, ChevronUp,
|
||||
Database, FileJson, Copy, CheckCircle, AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
const PLATFORM_ICONS: Record<string, any> = {
|
||||
twitter: Twitter,
|
||||
x: Twitter,
|
||||
instagram: Instagram,
|
||||
linkedin: Linkedin,
|
||||
facebook: Facebook,
|
||||
default: Globe,
|
||||
};
|
||||
|
||||
const PLATFORM_COLORS: Record<string, string> = {
|
||||
twitter: 'text-sky-400',
|
||||
x: 'text-slate-300',
|
||||
instagram: 'text-pink-400',
|
||||
linkedin: 'text-blue-400',
|
||||
facebook: 'text-blue-500',
|
||||
default: 'text-slate-400',
|
||||
};
|
||||
|
||||
export function MainDataFeed() {
|
||||
const { selectedTarget, deleteProfile } = useTargetStore();
|
||||
const { startJob } = useScraperStore();
|
||||
const [expandedProfile, setExpandedProfile] = useState<string | null>(null);
|
||||
const [scraping, setScraping] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
if (!selectedTarget) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-slate-950">
|
||||
<div className="text-center">
|
||||
<Database className="w-16 h-16 text-slate-800 mx-auto mb-4" />
|
||||
<h2 className="text-lg font-medium text-slate-400 mb-2">No Target Selected</h2>
|
||||
<p className="text-sm text-slate-600 max-w-xs">
|
||||
Select a target from the left panel to view their social media profiles and scraped data.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const profiles = selectedTarget.profiles || [];
|
||||
|
||||
const handleScrape = async (profileId: string, platform: string, profileUrl?: string) => {
|
||||
setScraping(profileId);
|
||||
try {
|
||||
await startJob({
|
||||
platform,
|
||||
profileUrl,
|
||||
targetId: selectedTarget.id,
|
||||
profileId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to start scrape:', err);
|
||||
} finally {
|
||||
setScraping(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (profileId: string) => {
|
||||
if (confirm('Delete this profile?')) {
|
||||
await deleteProfile(selectedTarget.id, profileId);
|
||||
}
|
||||
};
|
||||
|
||||
const copyData = (data: any, id: string) => {
|
||||
navigator.clipboard.writeText(JSON.stringify(data, null, 2));
|
||||
setCopied(id);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-slate-950">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-slate-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-100">{selectedTarget.name}</h2>
|
||||
<p className="text-xs text-slate-500 font-mono mt-0.5">
|
||||
ID: {selectedTarget.id.slice(0, 8)}...
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">
|
||||
{profiles.length} profile{profiles.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTarget.notes && (
|
||||
<p className="text-sm text-slate-400 mt-3 p-3 bg-slate-900 rounded border border-slate-800">
|
||||
{selectedTarget.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Profiles */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{profiles.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Globe className="w-12 h-12 text-slate-700 mx-auto mb-3" />
|
||||
<p className="text-sm text-slate-500">No profiles linked</p>
|
||||
<p className="text-xs text-slate-600 mt-1">Add social media profiles to start collecting data</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{profiles.map(profile => {
|
||||
const Icon = PLATFORM_ICONS[profile.platform.toLowerCase()] || PLATFORM_ICONS.default;
|
||||
const colorClass = PLATFORM_COLORS[profile.platform.toLowerCase()] || PLATFORM_COLORS.default;
|
||||
const isExpanded = expandedProfile === profile.id;
|
||||
const profileData = profile.profile_data ? JSON.parse(profile.profile_data) : null;
|
||||
|
||||
return (
|
||||
<div key={profile.id} className="card border-slate-800">
|
||||
{/* Profile header */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg bg-slate-800 ${colorClass}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-200">
|
||||
{profile.username || profile.platform}
|
||||
</span>
|
||||
<span className="text-xs text-slate-600 font-mono uppercase">
|
||||
{profile.platform}
|
||||
</span>
|
||||
</div>
|
||||
{profile.profile_url && (
|
||||
<a
|
||||
href={profile.profile_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-slate-500 hover:text-accent-cyan flex items-center gap-1 mt-0.5"
|
||||
>
|
||||
<span className="truncate max-w-[200px]">{profile.profile_url}</span>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleScrape(profile.id, profile.platform, profile.profile_url || undefined)}
|
||||
disabled={scraping === profile.id}
|
||||
className="p-2 rounded hover:bg-slate-800 text-accent-cyan transition-colors disabled:opacity-50"
|
||||
title="Start scrape"
|
||||
>
|
||||
{scraping === profile.id ? (
|
||||
<div className="w-4 h-4 border-2 border-accent-cyan border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Play className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(profile.id)}
|
||||
className="p-2 rounded hover:bg-slate-800 text-slate-500 hover:text-accent-rose transition-colors"
|
||||
title="Delete profile"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scraped status */}
|
||||
<div className="flex items-center gap-4 mt-3 pt-3 border-t border-slate-800">
|
||||
{profile.last_scraped ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-accent-emerald">
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
<span>Last scraped {formatDistanceToNow(new Date(profile.last_scraped), { addSuffix: true })}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
<span>Never scraped</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profileData && (
|
||||
<button
|
||||
onClick={() => setExpandedProfile(isExpanded ? null : profile.id)}
|
||||
className="flex items-center gap-1 text-xs text-slate-400 hover:text-slate-200"
|
||||
>
|
||||
<FileJson className="w-3.5 h-3.5" />
|
||||
<span>View Data</span>
|
||||
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded data view */}
|
||||
{isExpanded && profileData && (
|
||||
<div className="border-t border-slate-800 p-4 bg-slate-900/50">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-medium text-slate-400">Scraped Data</span>
|
||||
<button
|
||||
onClick={() => copyData(profileData, profile.id)}
|
||||
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-300"
|
||||
>
|
||||
{copied === profile.id ? (
|
||||
<>
|
||||
<CheckCircle className="w-3.5 h-3.5 text-accent-emerald" />
|
||||
<span className="text-accent-emerald">Copied!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
<span>Copy JSON</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-xs text-slate-300 font-mono bg-slate-950 p-3 rounded overflow-x-auto max-h-64 overflow-y-auto">
|
||||
{JSON.stringify(profileData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
288
frontend/src/components/ScraperPanel.tsx
Normal file
288
frontend/src/components/ScraperPanel.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { useState } from 'react';
|
||||
import { useScraperStore, ScraperJob, Session } from '../stores/scraperStore';
|
||||
import {
|
||||
Activity, Key, Clock, CheckCircle, XCircle,
|
||||
StopCircle, ChevronDown, Terminal, RefreshCw
|
||||
} from 'lucide-react';
|
||||
import { formatDistanceToNow, format } from 'date-fns';
|
||||
|
||||
type Tab = 'jobs' | 'sessions';
|
||||
|
||||
export function ScraperPanel() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('jobs');
|
||||
const { jobs, sessions, activeJobs, fetchJobs, fetchSessions } = useScraperStore();
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-slate-900/50">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-slate-800">
|
||||
<button
|
||||
onClick={() => setActiveTab('jobs')}
|
||||
className={`flex-1 px-4 py-3 text-xs font-medium flex items-center justify-center gap-2 transition-colors ${
|
||||
activeTab === 'jobs'
|
||||
? 'text-accent-cyan border-b-2 border-accent-cyan bg-slate-800/30'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Activity className="w-3.5 h-3.5" />
|
||||
Jobs
|
||||
{activeJobs.length > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-accent-cyan/20 text-accent-cyan text-[10px]">
|
||||
{activeJobs.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('sessions')}
|
||||
className={`flex-1 px-4 py-3 text-xs font-medium flex items-center justify-center gap-2 transition-colors ${
|
||||
activeTab === 'sessions'
|
||||
? 'text-accent-cyan border-b-2 border-accent-cyan bg-slate-800/30'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Key className="w-3.5 h-3.5" />
|
||||
Sessions
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-slate-700 text-slate-300 text-[10px]">
|
||||
{sessions.length}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{activeTab === 'jobs' ? (
|
||||
<JobsList jobs={jobs} onRefresh={fetchJobs} />
|
||||
) : (
|
||||
<SessionsList sessions={sessions} onRefresh={fetchSessions} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JobsList({ jobs, onRefresh }: { jobs: ScraperJob[]; onRefresh: () => void }) {
|
||||
const { cancelJob, logs } = useScraperStore();
|
||||
const [expandedJob, setExpandedJob] = useState<string | null>(null);
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return <div className="w-3 h-3 border-2 border-accent-cyan border-t-transparent rounded-full animate-spin" />;
|
||||
case 'completed':
|
||||
return <CheckCircle className="w-4 h-4 text-accent-emerald" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-4 h-4 text-accent-rose" />;
|
||||
case 'cancelled':
|
||||
return <StopCircle className="w-4 h-4 text-slate-500" />;
|
||||
default:
|
||||
return <Clock className="w-4 h-4 text-accent-amber" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running': return 'border-accent-cyan/30 bg-accent-cyan/5';
|
||||
case 'completed': return 'border-accent-emerald/30 bg-accent-emerald/5';
|
||||
case 'failed': return 'border-accent-rose/30 bg-accent-rose/5';
|
||||
default: return 'border-slate-700 bg-slate-800/50';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-slate-500">{jobs.length} total jobs</span>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-800"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{jobs.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Activity className="w-10 h-10 text-slate-700 mx-auto mb-2" />
|
||||
<p className="text-xs text-slate-500">No scraper jobs yet</p>
|
||||
</div>
|
||||
) : (
|
||||
jobs.slice(0, 20).map(job => {
|
||||
const isExpanded = expandedJob === job.id;
|
||||
const jobLogs = logs.get(job.id) || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={job.id}
|
||||
className={`rounded-lg border ${getStatusColor(job.status)} transition-colors`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpandedJob(isExpanded ? null : job.id)}
|
||||
className="w-full p-3 text-left"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(job.status)}
|
||||
<span className="text-sm font-medium text-slate-200 capitalize">
|
||||
{job.platform}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-slate-500 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
{job.status === 'running' && (
|
||||
<div className="flex-1">
|
||||
<div className="h-1 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-cyan transition-all"
|
||||
style={{ width: `${job.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{formatDistanceToNow(new Date(job.created_at), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 border-t border-slate-700/50 mt-2 pt-2">
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Job ID:</span>
|
||||
<span className="text-slate-300 font-mono">{job.id.slice(0, 8)}...</span>
|
||||
</div>
|
||||
{job.target_name && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Target:</span>
|
||||
<span className="text-slate-300">{job.target_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{job.error && (
|
||||
<div className="mt-2 p-2 bg-accent-rose/10 rounded text-accent-rose text-xs">
|
||||
{job.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.status === 'running' && (
|
||||
<button
|
||||
onClick={() => cancelJob(job.id)}
|
||||
className="w-full mt-2 py-1.5 rounded border border-accent-rose/30 text-accent-rose text-xs hover:bg-accent-rose/10"
|
||||
>
|
||||
Cancel Job
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Logs */}
|
||||
{jobLogs.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-1 text-slate-500 mb-2">
|
||||
<Terminal className="w-3 h-3" />
|
||||
<span>Logs</span>
|
||||
</div>
|
||||
<div className="bg-slate-950 rounded p-2 max-h-32 overflow-y-auto font-mono text-[10px] space-y-0.5">
|
||||
{jobLogs.slice(-10).map((log, i) => (
|
||||
<div key={i} className={`${log.level === 'error' ? 'text-accent-rose' : 'text-slate-400'}`}>
|
||||
<span className="text-slate-600">[{format(new Date(log.timestamp), 'HH:mm:ss')}]</span>{' '}
|
||||
{log.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsList({ sessions, onRefresh }: { sessions: Session[]; onRefresh: () => void }) {
|
||||
const { deleteSession } = useScraperStore();
|
||||
|
||||
const getStatusIndicator = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return <div className="status-indicator status-active" />;
|
||||
case 'expired':
|
||||
return <div className="status-indicator status-warning" />;
|
||||
default:
|
||||
return <div className="status-indicator status-error" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-slate-500">Session Vault</span>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-800"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Key className="w-10 h-10 text-slate-700 mx-auto mb-2" />
|
||||
<p className="text-xs text-slate-500">No sessions stored</p>
|
||||
<p className="text-[10px] text-slate-600 mt-1">Add session cookies to enable authenticated scraping</p>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map(session => (
|
||||
<div key={session.id} className="p-3 rounded-lg border border-slate-800 bg-slate-800/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIndicator(session.status)}
|
||||
<span className="text-sm font-medium text-slate-200 capitalize">
|
||||
{session.platform}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full ${
|
||||
session.status === 'active'
|
||||
? 'bg-accent-emerald/20 text-accent-emerald'
|
||||
: session.status === 'expired'
|
||||
? 'bg-accent-amber/20 text-accent-amber'
|
||||
: 'bg-accent-rose/20 text-accent-rose'
|
||||
}`}>
|
||||
{session.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Name:</span>
|
||||
<span className="text-slate-300">{session.session_name}</span>
|
||||
</div>
|
||||
{session.last_validated && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Validated:</span>
|
||||
<span className="text-slate-400 font-mono text-[10px]">
|
||||
{formatDistanceToNow(new Date(session.last_validated), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete ${session.platform} session?`)) {
|
||||
deleteSession(session.id);
|
||||
}
|
||||
}}
|
||||
className="w-full mt-3 py-1.5 rounded border border-slate-700 text-slate-400 text-xs hover:border-accent-rose/30 hover:text-accent-rose hover:bg-accent-rose/5 transition-colors"
|
||||
>
|
||||
Remove Session
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
frontend/src/components/TargetList.tsx
Normal file
199
frontend/src/components/TargetList.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useState } from 'react';
|
||||
import { useTargetStore, Target } from '../stores/targetStore';
|
||||
import {
|
||||
Users, Search, ChevronRight, Plus, MoreHorizontal,
|
||||
Trash2, Edit2, UserCircle2
|
||||
} from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface TargetListProps {
|
||||
onAddProfile: () => void;
|
||||
}
|
||||
|
||||
export function TargetList({ onAddProfile }: TargetListProps) {
|
||||
const { targets, selectedTarget, selectTarget, fetchTarget, loading } = useTargetStore();
|
||||
const [search, setSearch] = useState('');
|
||||
const [contextMenu, setContextMenu] = useState<string | null>(null);
|
||||
|
||||
const filteredTargets = targets.filter(t =>
|
||||
t.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const handleSelectTarget = async (target: Target) => {
|
||||
selectTarget(target);
|
||||
await fetchTarget(target.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-slate-900/50">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b border-slate-800">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-sm font-medium text-slate-300">Targets</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 font-mono">{targets.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search targets..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="input-field w-full pl-8 py-1.5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && targets.length === 0 ? (
|
||||
<div className="p-4 space-y-2">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-16 shimmer rounded" />
|
||||
))}
|
||||
</div>
|
||||
) : filteredTargets.length === 0 ? (
|
||||
<div className="p-4 text-center">
|
||||
<UserCircle2 className="w-10 h-10 text-slate-700 mx-auto mb-2" />
|
||||
<p className="text-sm text-slate-500">
|
||||
{search ? 'No targets found' : 'No targets yet'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600 mt-1">
|
||||
Add a target to get started
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 space-y-1">
|
||||
{filteredTargets.map(target => (
|
||||
<TargetItem
|
||||
key={target.id}
|
||||
target={target}
|
||||
isSelected={selectedTarget?.id === target.id}
|
||||
onSelect={() => handleSelectTarget(target)}
|
||||
showContextMenu={contextMenu === target.id}
|
||||
onToggleContext={() => setContextMenu(contextMenu === target.id ? null : target.id)}
|
||||
onAddProfile={onAddProfile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TargetItemProps {
|
||||
target: Target;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
showContextMenu: boolean;
|
||||
onToggleContext: () => void;
|
||||
onAddProfile: () => void;
|
||||
}
|
||||
|
||||
function TargetItem({
|
||||
target,
|
||||
isSelected,
|
||||
onSelect,
|
||||
showContextMenu,
|
||||
onToggleContext,
|
||||
onAddProfile
|
||||
}: TargetItemProps) {
|
||||
const { deleteTarget } = useTargetStore();
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (confirm(`Delete target "${target.name}"?`)) {
|
||||
await deleteTarget(target.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className={`w-full text-left p-3 rounded-lg transition-all ${
|
||||
isSelected
|
||||
? 'bg-slate-800 border border-accent-cyan/30'
|
||||
: 'hover:bg-slate-800/50 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-200 truncate">
|
||||
{target.name}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-accent-cyan flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-slate-500">
|
||||
{target.profile_count || 0} profiles
|
||||
</span>
|
||||
<span className="text-xs text-slate-600">•</span>
|
||||
<span className="text-[10px] text-slate-600 font-mono">
|
||||
{target.updated_at && !isNaN(new Date(target.updated_at).getTime())
|
||||
? formatDistanceToNow(new Date(target.updated_at), { addSuffix: true })
|
||||
: 'just now'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleContext();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Context menu */}
|
||||
{showContextMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={onToggleContext}
|
||||
/>
|
||||
<div className="absolute right-2 top-10 z-20 w-40 py-1 bg-slate-800 border border-slate-700 rounded-lg shadow-xl">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect();
|
||||
onAddProfile();
|
||||
onToggleContext();
|
||||
}}
|
||||
className="w-full px-3 py-2 text-left text-xs text-slate-300 hover:bg-slate-700 flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add Profile
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-3 py-2 text-left text-xs text-slate-300 hover:bg-slate-700 flex items-center gap-2"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
Edit Target
|
||||
</button>
|
||||
<div className="my-1 border-t border-slate-700" />
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="w-full px-3 py-2 text-left text-xs text-accent-rose hover:bg-accent-rose/10 flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Delete Target
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
frontend/src/index.css
Normal file
125
frontend/src/index.css
Normal file
@@ -0,0 +1,125 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Base styles */
|
||||
:root {
|
||||
--color-bg-primary: #020617;
|
||||
--color-bg-secondary: #0f172a;
|
||||
--color-bg-tertiary: #1e293b;
|
||||
--color-text-primary: #f1f5f9;
|
||||
--color-text-secondary: #94a3b8;
|
||||
--color-accent: #22d3ee;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #334155;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
/* Custom utilities */
|
||||
@layer utilities {
|
||||
.text-gradient {
|
||||
@apply bg-gradient-to-r from-accent-cyan to-accent-emerald bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
.border-glow {
|
||||
box-shadow: 0 0 10px rgba(34, 211, 238, 0.2);
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-slate-900 border border-slate-800 rounded-lg;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply hover:border-slate-700 hover:bg-slate-800/50 transition-all duration-200;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply bg-slate-900 border border-slate-700 rounded px-3 py-2 text-sm text-slate-100
|
||||
placeholder:text-slate-500 focus:outline-none focus:border-accent-cyan focus:ring-1 focus:ring-accent-cyan/30;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-accent-cyan text-slate-950 px-4 py-2 rounded font-medium text-sm
|
||||
hover:bg-accent-cyan/90 active:bg-accent-cyan/80 transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-slate-800 text-slate-200 px-4 py-2 rounded font-medium text-sm border border-slate-700
|
||||
hover:bg-slate-700 hover:border-slate-600 active:bg-slate-600 transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-accent-rose/10 text-accent-rose px-4 py-2 rounded font-medium text-sm border border-accent-rose/30
|
||||
hover:bg-accent-rose/20 active:bg-accent-rose/30 transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
@apply w-2 h-2 rounded-full;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
@apply bg-accent-emerald animate-pulse;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
@apply bg-accent-amber;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
@apply bg-accent-rose;
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
@apply bg-slate-600;
|
||||
}
|
||||
}
|
||||
|
||||
/* Monospace text */
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
/* Animation for loading states */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
background: linear-gradient(90deg, #1e293b 25%, #334155 50%, #1e293b 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
81
frontend/src/pages/Dashboard.tsx
Normal file
81
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useTargetStore } from '../stores/targetStore';
|
||||
import { useScraperStore } from '../stores/scraperStore';
|
||||
import { TargetList } from '../components/TargetList';
|
||||
import { MainDataFeed } from '../components/MainDataFeed';
|
||||
import { ScraperPanel } from '../components/ScraperPanel';
|
||||
import { Header } from '../components/Header';
|
||||
import { AddTargetModal } from '../components/AddTargetModal';
|
||||
import { AddProfileModal } from '../components/AddProfileModal';
|
||||
import { AddSessionModal } from '../components/AddSessionModal';
|
||||
|
||||
export function Dashboard() {
|
||||
const { checkAuth } = useAuthStore();
|
||||
const { fetchTargets } = useTargetStore();
|
||||
const { fetchJobs, fetchSessions, initSocket, disconnectSocket } = useScraperStore();
|
||||
|
||||
const [showAddTarget, setShowAddTarget] = useState(false);
|
||||
const [showAddProfile, setShowAddProfile] = useState(false);
|
||||
const [showAddSession, setShowAddSession] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize
|
||||
checkAuth();
|
||||
fetchTargets();
|
||||
fetchJobs();
|
||||
fetchSessions();
|
||||
initSocket();
|
||||
|
||||
// Polling for updates
|
||||
const interval = setInterval(() => {
|
||||
fetchJobs();
|
||||
fetchSessions();
|
||||
}, 10000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
disconnectSocket();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden bg-slate-950">
|
||||
<Header
|
||||
onAddTarget={() => setShowAddTarget(true)}
|
||||
onAddSession={() => setShowAddSession(true)}
|
||||
/>
|
||||
|
||||
{/* Main 3-column layout */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left column - Target List */}
|
||||
<aside className="w-72 flex-shrink-0 border-r border-slate-800 overflow-hidden">
|
||||
<TargetList onAddProfile={() => setShowAddProfile(true)} />
|
||||
</aside>
|
||||
|
||||
{/* Center column - Main Data Feed */}
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<MainDataFeed />
|
||||
</main>
|
||||
|
||||
{/* Right column - Scraper Status & Logs */}
|
||||
<aside className="w-80 flex-shrink-0 border-l border-slate-800 overflow-hidden">
|
||||
<ScraperPanel />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showAddTarget && (
|
||||
<AddTargetModal onClose={() => setShowAddTarget(false)} />
|
||||
)}
|
||||
|
||||
{showAddProfile && (
|
||||
<AddProfileModal onClose={() => setShowAddProfile(false)} />
|
||||
)}
|
||||
|
||||
{showAddSession && (
|
||||
<AddSessionModal onClose={() => setShowAddSession(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
frontend/src/pages/LoginPage.tsx
Normal file
148
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { Shield, AlertCircle, Lock, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
export function LoginPage() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { login, checkAuth, isAuthenticated } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
// Check if already authenticated
|
||||
checkAuth().then((authenticated) => {
|
||||
if (authenticated) {
|
||||
navigate('/');
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/');
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(password);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Authentication failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-950 px-4">
|
||||
{/* Background pattern */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950" />
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-accent-cyan/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-accent-violet/5 rounded-full blur-3xl" />
|
||||
</div>
|
||||
{/* Grid pattern */}
|
||||
<svg className="absolute inset-0 w-full h-full opacity-[0.03]">
|
||||
<defs>
|
||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="currentColor" strokeWidth="1" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full max-w-md">
|
||||
{/* Logo/Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-accent-cyan/20 to-accent-violet/20 border border-accent-cyan/30 mb-4">
|
||||
<Shield className="w-8 h-8 text-accent-cyan" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-100 mb-1">OSINT Platform</h1>
|
||||
<p className="text-sm text-slate-500">Private Intelligence Automation</p>
|
||||
</div>
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="card p-6 border-slate-800 shadow-xl shadow-black/20">
|
||||
<div className="flex items-center gap-2 mb-6 pb-4 border-b border-slate-800">
|
||||
<Lock className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-sm font-medium text-slate-400">Master Authentication</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded bg-accent-rose/10 border border-accent-rose/20 text-accent-rose text-sm">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-slate-400 mb-2">
|
||||
Master Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input-field w-full pr-10 font-mono"
|
||||
placeholder="Enter your master password"
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!password || loading}
|
||||
className="btn-primary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-slate-950 border-t-transparent rounded-full animate-spin" />
|
||||
<span>Authenticating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>Authenticate</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-slate-800">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-600">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-accent-emerald animate-pulse" />
|
||||
<span>Encrypted connection • Rate limited</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-slate-600 mt-6">
|
||||
All access attempts are logged and monitored.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
frontend/src/stores/authStore.ts
Normal file
86
frontend/src/stores/authStore.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
token: string | null;
|
||||
login: (password: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
checkAuth: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
isAuthenticated: false,
|
||||
token: null,
|
||||
|
||||
login: async (password: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Login failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
set({ isAuthenticated: true, token: data.token });
|
||||
return true;
|
||||
} catch (error) {
|
||||
set({ isAuthenticated: false, token: null });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
try {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
set({ isAuthenticated: false, token: null });
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
const { token } = get();
|
||||
|
||||
if (!token) {
|
||||
set({ isAuthenticated: false });
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/verify', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
set({ isAuthenticated: true });
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Token invalid
|
||||
}
|
||||
|
||||
set({ isAuthenticated: false, token: null });
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'osint-auth',
|
||||
partialize: (state) => ({ token: state.token }),
|
||||
}
|
||||
)
|
||||
);
|
||||
206
frontend/src/stores/scraperStore.ts
Normal file
206
frontend/src/stores/scraperStore.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { create } from 'zustand';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { useAuthStore } from './authStore';
|
||||
|
||||
export interface ScraperJob {
|
||||
id: string;
|
||||
target_id?: string;
|
||||
profile_id?: string;
|
||||
platform: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
progress: number;
|
||||
result?: string;
|
||||
error?: string;
|
||||
target_name?: string;
|
||||
profile_username?: string;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
created_at: string;
|
||||
logs?: ScraperLog[];
|
||||
}
|
||||
|
||||
export interface ScraperLog {
|
||||
id: number;
|
||||
job_id: string;
|
||||
level: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
platform: string;
|
||||
session_name: string;
|
||||
user_agent?: string;
|
||||
proxy?: string;
|
||||
status: 'active' | 'expired' | 'invalid';
|
||||
last_validated?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ScraperState {
|
||||
jobs: ScraperJob[];
|
||||
activeJobs: ScraperJob[];
|
||||
sessions: Session[];
|
||||
logs: Map<string, ScraperLog[]>;
|
||||
socket: Socket | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
initSocket: () => void;
|
||||
disconnectSocket: () => void;
|
||||
fetchJobs: () => Promise<void>;
|
||||
fetchSessions: () => Promise<void>;
|
||||
startJob: (config: { platform: string; profileUrl?: string; targetId?: string; profileId?: string }) => Promise<ScraperJob>;
|
||||
cancelJob: (jobId: string) => Promise<void>;
|
||||
subscribeToJob: (jobId: string) => void;
|
||||
addSession: (data: { platform: string; session_name: string; cookies: any[]; user_agent?: string }) => Promise<void>;
|
||||
deleteSession: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useScraperStore = create<ScraperState>((set, get) => ({
|
||||
jobs: [],
|
||||
activeJobs: [],
|
||||
sessions: [],
|
||||
logs: new Map(),
|
||||
socket: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
initSocket: () => {
|
||||
const token = useAuthStore.getState().token;
|
||||
if (!token) return;
|
||||
|
||||
const socket = io({
|
||||
auth: { token },
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Socket connected');
|
||||
});
|
||||
|
||||
socket.on('scraper:log', (log: ScraperLog & { jobId: string }) => {
|
||||
const logs = new Map(get().logs);
|
||||
const jobLogs = logs.get(log.jobId) || [];
|
||||
logs.set(log.jobId, [...jobLogs, log]);
|
||||
set({ logs });
|
||||
});
|
||||
|
||||
socket.on('scraper:status', (update: { jobId: string; status: string; progress?: number }) => {
|
||||
const jobs = get().jobs.map(job =>
|
||||
job.id === update.jobId
|
||||
? { ...job, status: update.status as any, progress: update.progress ?? job.progress }
|
||||
: job
|
||||
);
|
||||
set({ jobs });
|
||||
});
|
||||
|
||||
socket.on('scraper:jobUpdate', () => {
|
||||
get().fetchJobs();
|
||||
});
|
||||
|
||||
set({ socket });
|
||||
},
|
||||
|
||||
disconnectSocket: () => {
|
||||
const { socket } = get();
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
set({ socket: null });
|
||||
}
|
||||
},
|
||||
|
||||
fetchJobs: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const response = await fetch('/api/scraper/jobs', { credentials: 'include' });
|
||||
if (!response.ok) throw new Error('Failed to fetch jobs');
|
||||
const jobs = await response.json();
|
||||
|
||||
const statusResponse = await fetch('/api/scraper/status', { credentials: 'include' });
|
||||
const statusData = await statusResponse.json();
|
||||
|
||||
set({
|
||||
jobs,
|
||||
activeJobs: statusData.activeJobs || [],
|
||||
loading: false
|
||||
});
|
||||
} catch (error: any) {
|
||||
set({ error: error.message, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchSessions: async () => {
|
||||
try {
|
||||
const response = await fetch('/api/sessions', { credentials: 'include' });
|
||||
if (!response.ok) throw new Error('Failed to fetch sessions');
|
||||
const sessions = await response.json();
|
||||
set({ sessions });
|
||||
} catch (error: any) {
|
||||
set({ error: error.message });
|
||||
}
|
||||
},
|
||||
|
||||
startJob: async (config) => {
|
||||
const response = await fetch('/api/scraper/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
platform: config.platform,
|
||||
profile_url: config.profileUrl,
|
||||
target_id: config.targetId,
|
||||
profile_id: config.profileId,
|
||||
}),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to start job');
|
||||
const job = await response.json();
|
||||
|
||||
// Subscribe to job updates
|
||||
get().subscribeToJob(job.id);
|
||||
await get().fetchJobs();
|
||||
|
||||
return job;
|
||||
},
|
||||
|
||||
cancelJob: async (jobId: string) => {
|
||||
const response = await fetch(`/api/scraper/jobs/${jobId}/cancel`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to cancel job');
|
||||
await get().fetchJobs();
|
||||
},
|
||||
|
||||
subscribeToJob: (jobId: string) => {
|
||||
const { socket } = get();
|
||||
if (socket) {
|
||||
socket.emit('subscribe:scraper', jobId);
|
||||
}
|
||||
},
|
||||
|
||||
addSession: async (data) => {
|
||||
const response = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to add session');
|
||||
await get().fetchSessions();
|
||||
},
|
||||
|
||||
deleteSession: async (id: string) => {
|
||||
const response = await fetch(`/api/sessions/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete session');
|
||||
await get().fetchSessions();
|
||||
},
|
||||
}));
|
||||
143
frontend/src/stores/targetStore.ts
Normal file
143
frontend/src/stores/targetStore.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface Target {
|
||||
id: string;
|
||||
name: string;
|
||||
notes?: string;
|
||||
profile_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
profiles?: SocialProfile[];
|
||||
}
|
||||
|
||||
export interface SocialProfile {
|
||||
id: string;
|
||||
target_id: string;
|
||||
platform: string;
|
||||
username?: string;
|
||||
profile_url?: string;
|
||||
profile_data?: string;
|
||||
last_scraped?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface TargetState {
|
||||
targets: Target[];
|
||||
selectedTarget: Target | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
fetchTargets: () => Promise<void>;
|
||||
fetchTarget: (id: string) => Promise<void>;
|
||||
selectTarget: (target: Target | null) => void;
|
||||
createTarget: (name: string, notes?: string) => Promise<Target>;
|
||||
updateTarget: (id: string, name: string, notes?: string) => Promise<void>;
|
||||
deleteTarget: (id: string) => Promise<void>;
|
||||
addProfile: (targetId: string, platform: string, username?: string, profileUrl?: string) => Promise<void>;
|
||||
deleteProfile: (targetId: string, profileId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useTargetStore = create<TargetState>((set, get) => ({
|
||||
targets: [],
|
||||
selectedTarget: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchTargets: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await fetch('/api/targets', { credentials: 'include' });
|
||||
if (!response.ok) throw new Error('Failed to fetch targets');
|
||||
const data = await response.json();
|
||||
const targets = data.map((t: any) => ({
|
||||
...t,
|
||||
updated_at: t.updatedAt || t.updated_at,
|
||||
created_at: t.createdAt || t.created_at
|
||||
}));
|
||||
set({ targets, loading: false });
|
||||
} catch (error: any) {
|
||||
set({ error: error.message, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchTarget: async (id: string) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await fetch(`/api/targets/${id}`, { credentials: 'include' });
|
||||
if (!response.ok) throw new Error('Failed to fetch target');
|
||||
const t = await response.json();
|
||||
const target = {
|
||||
...t,
|
||||
updated_at: t.updatedAt || t.updated_at,
|
||||
created_at: t.createdAt || t.created_at
|
||||
};
|
||||
set({ selectedTarget: target, loading: false });
|
||||
} catch (error: any) {
|
||||
set({ error: error.message, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
selectTarget: (target) => {
|
||||
set({ selectedTarget: target });
|
||||
},
|
||||
|
||||
createTarget: async (name: string, notes?: string) => {
|
||||
const response = await fetch('/api/targets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, notes }),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to create target');
|
||||
const target = await response.json();
|
||||
await get().fetchTargets();
|
||||
return target;
|
||||
},
|
||||
|
||||
updateTarget: async (id: string, name: string, notes?: string) => {
|
||||
const response = await fetch(`/api/targets/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, notes }),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update target');
|
||||
await get().fetchTargets();
|
||||
if (get().selectedTarget?.id === id) {
|
||||
await get().fetchTarget(id);
|
||||
}
|
||||
},
|
||||
|
||||
deleteTarget: async (id: string) => {
|
||||
const response = await fetch(`/api/targets/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete target');
|
||||
if (get().selectedTarget?.id === id) {
|
||||
set({ selectedTarget: null });
|
||||
}
|
||||
await get().fetchTargets();
|
||||
},
|
||||
|
||||
addProfile: async (targetId: string, platform: string, username?: string, profileUrl?: string) => {
|
||||
const response = await fetch(`/api/targets/${targetId}/profiles`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ platform, username, profile_url: profileUrl }),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to add profile');
|
||||
await get().fetchTarget(targetId);
|
||||
await get().fetchTargets();
|
||||
},
|
||||
|
||||
deleteProfile: async (targetId: string, profileId: string) => {
|
||||
const response = await fetch(`/api/targets/${targetId}/profiles/${profileId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete profile');
|
||||
await get().fetchTarget(targetId);
|
||||
await get().fetchTargets();
|
||||
},
|
||||
}));
|
||||
48
frontend/tailwind.config.js
Normal file
48
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Midnight Utility Theme
|
||||
slate: {
|
||||
950: '#020617',
|
||||
900: '#0f172a',
|
||||
800: '#1e293b',
|
||||
700: '#334155',
|
||||
600: '#475569',
|
||||
500: '#64748b',
|
||||
400: '#94a3b8',
|
||||
300: '#cbd5e1',
|
||||
200: '#e2e8f0',
|
||||
100: '#f1f5f9',
|
||||
},
|
||||
accent: {
|
||||
cyan: '#22d3ee',
|
||||
emerald: '#10b981',
|
||||
amber: '#f59e0b',
|
||||
rose: '#f43f5e',
|
||||
violet: '#8b5cf6',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||
},
|
||||
animation: {
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'glow': 'glow 2s ease-in-out infinite alternate',
|
||||
},
|
||||
keyframes: {
|
||||
glow: {
|
||||
'0%': { boxShadow: '0 0 5px rgba(34, 211, 238, 0.3)' },
|
||||
'100%': { boxShadow: '0 0 20px rgba(34, 211, 238, 0.6)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
25
frontend/vite.config.ts
Normal file
25
frontend/vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user