Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import NotFound from "./pages/NotFound";
import { Landing } from "./pages/Landing";
import ProtectedRoute from "./components/auth/ProtectedRoute";
import Account from "./pages/Account";
import { Security } from "./pages/Security";

const queryClient = new QueryClient({
defaultOptions: {
Expand Down Expand Up @@ -91,6 +92,14 @@ const App = () => (
</ProtectedRoute>
}
/>
<Route
path="security"
element={
<ProtectedRoute>
<Security />
</ProtectedRoute>
}
/>
</Route>
<Route path="/signin" element={<SignIn />} />
<Route path="/register" element={<Register />} />
Expand Down
31 changes: 31 additions & 0 deletions app/src/api/security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { api } from './client';

export interface LoginEvent {
id: number;
ip_address: string | null;
user_agent: string | null;
success: boolean;
anomaly_score: number;
anomaly_reasons: string | null;
created_at: string;
}

export interface LoginStats {
total_logins: number;
unique_ips: number;
unique_devices: number;
suspicious_count: number;
last_anomaly: string | null;
}

export async function getLoginHistory(limit = 50): Promise<LoginEvent[]> {
return api<LoginEvent[]>(`/security/login-history?limit=${limit}`);
}

export async function getAnomalies(limit = 50): Promise<LoginEvent[]> {
return api<LoginEvent[]>(`/security/anomalies?limit=${limit}`);
}

export async function getLoginStats(): Promise<LoginStats> {
return api<LoginStats>('/security/login-stats');
}
1 change: 1 addition & 0 deletions app/src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const navigation = [
{ name: 'Reminders', href: '/reminders' },
{ name: 'Expenses', href: '/expenses' },
{ name: 'Analytics', href: '/analytics' },
{ name: 'Security', href: '/security' },
];

export function Navbar() {
Expand Down
214 changes: 214 additions & 0 deletions app/src/pages/Security.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { useEffect, useState } from 'react';
import {
Shield,
AlertTriangle,
CheckCircle,
Monitor,
Globe,
Clock,
RefreshCw,
} from 'lucide-react';
import { getLoginHistory, getLoginStats, LoginEvent, LoginStats } from '@/api/security';
import { useToast } from '@/components/ui/use-toast';

function riskLabel(score: number): { label: string; color: string } {
if (score === 0) return { label: 'Safe', color: 'text-green-600' };
if (score < 0.4) return { label: 'Low', color: 'text-yellow-500' };
if (score < 0.7) return { label: 'Medium', color: 'text-orange-500' };
return { label: 'High', color: 'text-red-600' };
}

function formatDate(iso: string | null): string {
if (!iso) return '—';
return new Date(iso).toLocaleString();
}

export function Security() {
const [stats, setStats] = useState<LoginStats | null>(null);
const [history, setHistory] = useState<LoginEvent[]>([]);
const [loading, setLoading] = useState(true);
const { toast } = useToast();

const load = async () => {
setLoading(true);
try {
const [s, h] = await Promise.all([getLoginStats(), getLoginHistory(20)]);
setStats(s);
setHistory(h);
} catch (err: unknown) {
toast({
variant: 'destructive',
title: 'Failed to load security data',
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setLoading(false);
}
};

useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

if (loading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="w-6 h-6 animate-spin text-primary" />
<span className="ml-3 text-muted-foreground">Loading security data…</span>
</div>
);
}

return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Shield className="w-7 h-7 text-primary" />
<div>
<h1 className="text-2xl font-bold text-foreground">Security Center</h1>
<p className="text-sm text-muted-foreground">
Monitor login activity and suspicious behaviour
</p>
</div>
</div>
<button
onClick={load}
className="flex items-center gap-2 text-sm text-primary hover:text-primary-hover transition-colors"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
</div>

{/* Stats cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
icon={<CheckCircle className="w-5 h-5 text-green-500" />}
label="Total Logins"
value={stats.total_logins}
/>
<StatCard
icon={<Globe className="w-5 h-5 text-blue-500" />}
label="Unique IPs"
value={stats.unique_ips}
/>
<StatCard
icon={<Monitor className="w-5 h-5 text-purple-500" />}
label="Unique Devices"
value={stats.unique_devices}
/>
<StatCard
icon={<AlertTriangle className="w-5 h-5 text-orange-500" />}
label="Suspicious Events"
value={stats.suspicious_count}
highlight={stats.suspicious_count > 0}
/>
</div>
)}

{stats?.last_anomaly && (
<div className="flex items-center gap-2 rounded-lg border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-800">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
<span>
Last suspicious login detected at{' '}
<strong>{formatDate(stats.last_anomaly)}</strong>. Review your login
history below.
</span>
</div>
)}

{/* Login history */}
<div>
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
<Clock className="w-5 h-5 text-muted-foreground" />
Recent Login Activity
</h2>
{history.length === 0 ? (
<p className="text-muted-foreground text-sm">No login events recorded yet.</p>
) : (
<div className="rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-muted-foreground">
<tr>
<th className="px-4 py-3 text-left font-medium">Date &amp; Time</th>
<th className="px-4 py-3 text-left font-medium">IP Address</th>
<th className="px-4 py-3 text-left font-medium">Status</th>
<th className="px-4 py-3 text-left font-medium">Risk</th>
<th className="px-4 py-3 text-left font-medium">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{history.map((evt) => {
const { label, color } = riskLabel(evt.anomaly_score);
return (
<tr
key={evt.id}
className={
evt.anomaly_score > 0
? 'bg-orange-50/40 hover:bg-orange-50/60'
: 'hover:bg-muted/30'
}
>
<td className="px-4 py-3 text-foreground">
{formatDate(evt.created_at)}
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
{evt.ip_address || '—'}
</td>
<td className="px-4 py-3">
{evt.success ? (
<span className="inline-flex items-center gap-1 text-green-700">
<CheckCircle className="w-3.5 h-3.5" />
Success
</span>
) : (
<span className="inline-flex items-center gap-1 text-red-600">
<AlertTriangle className="w-3.5 h-3.5" />
Failed
</span>
)}
</td>
<td className={`px-4 py-3 font-semibold ${color}`}>{label}</td>
<td className="px-4 py-3 text-muted-foreground text-xs max-w-xs truncate">
{evt.anomaly_reasons || '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

function StatCard({
icon,
label,
value,
highlight = false,
}: {
icon: React.ReactNode;
label: string;
value: number;
highlight?: boolean;
}) {
return (
<div
className={`rounded-xl border p-4 space-y-2 ${
highlight ? 'border-orange-300 bg-orange-50' : 'border-border bg-card'
}`}
>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{icon}
<span>{label}</span>
</div>
<div className="text-2xl font-bold text-foreground">{value}</div>
</div>
);
}
23 changes: 21 additions & 2 deletions packages/backend/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def create_app(settings: Settings | None = None) -> Flask:
)

# Logging
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
log_level = (os.environ.get("LOG_LEVEL") or "INFO").upper()
configure_logging(log_level)
logger = logging.getLogger("finmind")
logger.info("Starting FinMind backend with log level %s", log_level)
Expand Down Expand Up @@ -110,10 +110,29 @@ def _ensure_schema_compatibility(app: Flask) -> None:
NOT NULL DEFAULT 'INR'
"""
)
# Create login_events table if it doesn't exist (for existing deployments)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS login_events (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ip_address VARCHAR(45),
user_agent VARCHAR(512),
success BOOLEAN NOT NULL DEFAULT TRUE,
anomaly_score NUMERIC(4, 2) NOT NULL DEFAULT 0.0,
anomaly_reasons VARCHAR(512),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_login_events_user_id "
"ON login_events(user_id)"
)
conn.commit()
except Exception:
app.logger.exception(
"Schema compatibility patch failed for users.preferred_currency"
"Schema compatibility patch failed"
)
conn.rollback()
finally:
Expand Down
17 changes: 17 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,20 @@ CREATE TABLE IF NOT EXISTS audit_logs (
action VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Login anomaly detection
CREATE TABLE IF NOT EXISTS login_events (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ip_address VARCHAR(45),
user_agent VARCHAR(512),
success BOOLEAN NOT NULL DEFAULT TRUE,
anomaly_score NUMERIC(4, 2) NOT NULL DEFAULT 0.0,
anomaly_reasons VARCHAR(512),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_login_events_user_id ON login_events(user_id);
CREATE INDEX IF NOT EXISTS idx_login_events_created_at ON login_events(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_login_events_anomaly ON login_events(user_id, anomaly_score)
WHERE anomaly_score > 0;
16 changes: 16 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,19 @@ class AuditLog(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
action = db.Column(db.String(100), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class LoginEvent(db.Model):
"""Records every login attempt with anomaly-detection metadata."""

__tablename__ = "login_events"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
ip_address = db.Column(db.String(45), nullable=True) # supports IPv6
user_agent = db.Column(db.String(512), nullable=True)
success = db.Column(db.Boolean, nullable=False, default=True)
anomaly_score = db.Column(db.Numeric(4, 2), nullable=False, default=0.0)
anomaly_reasons = db.Column(db.String(512), nullable=True)
created_at = db.Column(
db.DateTime(timezone=True), default=datetime.utcnow, nullable=False
)
2 changes: 2 additions & 0 deletions packages/backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .categories import bp as categories_bp
from .docs import bp as docs_bp
from .dashboard import bp as dashboard_bp
from .security import bp as security_bp


def register_routes(app: Flask):
Expand All @@ -18,3 +19,4 @@ def register_routes(app: Flask):
app.register_blueprint(categories_bp, url_prefix="/categories")
app.register_blueprint(docs_bp, url_prefix="/docs")
app.register_blueprint(dashboard_bp, url_prefix="/dashboard")
app.register_blueprint(security_bp, url_prefix="/security")
Loading