Skip to content
This repository was archived by the owner on Jun 19, 2026. It is now read-only.
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,15 @@ See `backend/app/db/schema.sql`. Key tables:
- `user:{id}:categories` — 24h TTL
- `user:{id}:upcoming_bills` — 15 min TTL
- `insights:{id}` — 24h TTL (invalidate on new expense/bill)
- `auth:failed_login:{email}` — 15 min TTL (brute force rate limiting)
- Invalidation
- On expense/bill create/update/delete -> delete affected monthly_summary, upcoming_bills, insights
- Rate limiting (optional): `rl:{userId}:{endpoint}:{minute}` with short TTL

## API Endpoints
OpenAPI: `backend/app/openapi.yaml`
- Auth: `/auth/register`, `/auth/login`, `/auth/refresh`
- Security Alerts: `GET /auth/alerts`, `PATCH /auth/alerts/{id}/read`
- Expenses: CRUD `/expenses`
- Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay`
- Reminders: CRUD `/reminders`, trigger `/reminders/run`
Expand Down
16 changes: 16 additions & 0 deletions app/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,19 @@ export async function updateMe(payload: {
}): Promise<MeResponse> {
return api<MeResponse>('/auth/me', { method: 'PATCH', body: payload });
}

export type SecurityAlert = {
id: number;
alert_type: string;
description: string;
is_read: boolean;
created_at: string;
};

export async function getAlerts(): Promise<{ alerts: SecurityAlert[] }> {
return api<{ alerts: SecurityAlert[] }>('/auth/alerts');
}

export async function markAlertRead(alertId: number): Promise<{ message: string }> {
return api<{ message: string }>(`/auth/alerts/${alertId}/read`, { method: 'PATCH' });
}
52 changes: 51 additions & 1 deletion app/src/pages/Account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useToast } from '@/hooks/use-toast';
import { me, updateMe } from '@/api/auth';
import { me, updateMe, getAlerts, markAlertRead, SecurityAlert } from '@/api/auth';
import { setCurrency } from '@/lib/auth';

const SUPPORTED_CURRENCIES = [
Expand All @@ -23,6 +23,7 @@ export default function Account() {
const [currency, setCurrencyState] = useState('INR');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [alerts, setAlerts] = useState<SecurityAlert[]>([]);

useEffect(() => {
const load = async () => {
Expand All @@ -31,6 +32,12 @@ export default function Account() {
const data = await me();
setEmail(data.email);
setCurrencyState(data.preferred_currency || 'INR');
try {
const alertsData = await getAlerts();
setAlerts(alertsData.alerts);
} catch (e) {
console.error("Failed to load alerts", e);
}
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : 'Failed to load account';
Expand Down Expand Up @@ -60,6 +67,15 @@ export default function Account() {
}
};

const handleMarkRead = async (id: number) => {
try {
await markAlertRead(id);
setAlerts(alerts.map((a) => (a.id === id ? { ...a, is_read: true } : a)));
} catch {
toast({ title: 'Failed to mark alert as read', variant: 'destructive' });
}
};

return (
<div className="page-wrap space-y-6">
<div className="page-header">
Expand Down Expand Up @@ -108,6 +124,40 @@ export default function Account() {
</>
)}
</div>

{alerts.length > 0 && (
<div className="card space-y-4 fade-in-up" style={{ animationDelay: '100ms' }}>
<h2 className="text-xl font-semibold text-destructive">Security Alerts</h2>
<div className="space-y-3">
{alerts.map((alert) => (
<div
key={alert.id}
className={`p-4 rounded-md border ${alert.is_read
? 'bg-muted/30 border-muted'
: 'bg-destructive/10 border-destructive/20'
}`}
>
<div className="flex justify-between items-start">
<div>
<h3 className="font-medium capitalize">
{alert.alert_type.replace(/_/g, ' ').toLowerCase()}
</h3>
<p className="text-sm mt-1 opacity-80">{alert.description}</p>
<p className="text-xs mt-2 opacity-60">
{new Date(alert.created_at).toLocaleString()}
</p>
</div>
{!alert.is_read && (
<Button variant="outline" size="sm" onClick={() => handleMarkRead(alert.id)}>
Mark Read
</Button>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
2 changes: 2 additions & 0 deletions packages/backend/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ def create_app(settings: Settings | None = None) -> Flask:
TWILIO_AUTH_TOKEN=cfg.twilio_auth_token,
TWILIO_WHATSAPP_FROM=cfg.twilio_whatsapp_from,
EMAIL_FROM=cfg.email_from,
RESEND_API_KEY=cfg.resend_api_key,
ADMIN_EMAIL=cfg.admin_email,
)

# Logging
Expand Down
3 changes: 3 additions & 0 deletions packages/backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ class Settings(BaseSettings):
email_from: str | None = None
smtp_url: str | None = None # e.g. smtp+ssl://user:pass@mail:465

resend_api_key: str | None = None
admin_email: str | None = None

# pydantic-settings v2 configuration
model_config = SettingsConfigDict(
env_file=".env",
Expand Down
19 changes: 19 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,22 @@ CREATE TABLE IF NOT EXISTS audit_logs (
action VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS login_history (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
ip_address VARCHAR(45) NOT NULL,
user_agent VARCHAR(500),
status VARCHAR(20) NOT NULL,
attempted_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_login_history_user ON login_history(user_id, attempted_at DESC);

CREATE TABLE IF NOT EXISTS security_alerts (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
alert_type VARCHAR(50) NOT NULL,
description VARCHAR(500) NOT NULL,
is_read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
20 changes: 20 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,23 @@ 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 LoginHistory(db.Model):
__tablename__ = "login_history"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
ip_address = db.Column(db.String(45), nullable=False)
user_agent = db.Column(db.String(500), nullable=True)
status = db.Column(db.String(20), nullable=False)
attempted_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class SecurityAlert(db.Model):
__tablename__ = "security_alerts"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
alert_type = db.Column(db.String(50), nullable=False)
description = db.Column(db.String(500), nullable=False)
is_read = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
56 changes: 56 additions & 0 deletions packages/backend/app/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,54 @@ paths:
schema: { $ref: '#/components/schemas/Error' }
example: { error: "Missing Authorization Header" }

/auth/alerts:
get:
summary: List recent security alerts
tags: [Auth]
security: [{ bearerAuth: [] }]
responses:
'200':
description: List of alerts
content:
application/json:
schema:
type: object
properties:
alerts:
type: array
items:
$ref: '#/components/schemas/SecurityAlert'
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }

/auth/alerts/{alertId}/read:
patch:
summary: Mark a security alert as read
tags: [Auth]
security: [{ bearerAuth: [] }]
parameters:
- in: path
name: alertId
required: true
schema: { type: integer }
responses:
'200':
description: Marked as read
content:
application/json:
schema:
type: object
properties:
message: { type: string }
example: { message: "marked as read" }
'401':
description: Unauthorized
'404':
description: Alert not found

/categories:
get:
summary: List categories
Expand Down Expand Up @@ -498,6 +546,14 @@ components:
properties:
access_token: { type: string }
refresh_token: { type: string }
SecurityAlert:
type: object
properties:
id: { type: integer }
alert_type: { type: string }
description: { type: string }
is_read: { type: boolean }
created_at: { type: string, format: date-time }
Category:
type: object
properties:
Expand Down
Loading