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
84 changes: 84 additions & 0 deletions app/src/components/household/HouseholdManager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React, { useEffect, useState } from 'react';
import { getMembers, addMember, removeMember } from '../../utils/household';

type HouseholdMember = {
email: string;
name?: string;
role?: string;
joinedAt: string;
};

const HouseholdManager: React.FC = () => {
const [members, setMembers] = useState<HouseholdMember[]>([]);
const [email, setEmail] = useState<string>('');
const [name, setName] = useState<string>('');
const [role, setRole] = useState<string>('');

useEffect(() => {
setMembers(getMembers() as HouseholdMember[]);
}, []);

const handleAdd = () => {
const trimmed = email.trim();
if (!trimmed) return;
addMember(trimmed, name.trim() || undefined, role.trim() || undefined);
setEmail('');
setName('');
setRole('');
setMembers(getMembers() as HouseholdMember[]);
};

const handleRemove = (e: string) => {
removeMember(e);
setMembers(getMembers() as HouseholdMember[]);
};

return (
<section aria-label="Household Collaboration" style={{ border: '1px solid #e5e7eb', padding: 16, borderRadius: 8, margin: 16 }}>
<h3>Household Collaboration</h3>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<input
placeholder="Email of member"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{ padding: '8px 10px', borderRadius: 4, border: '1px solid #d1d5db' }}
/>
<input
placeholder="Name (optional)"
value={name}
onChange={(e) => setName(e.target.value)}
style={{ padding: '8px 10px', borderRadius: 4, border: '1px solid #d1d5db' }}
/>
<input
placeholder="Role (optional)"
value={role}
onChange={(e) => setRole(e.target.value)}
style={{ padding: '8px 10px', borderRadius: 4, border: '1px solid #d1d5db' }}
/>
<button onClick={handleAdd} style={{ padding: '8px 12px', borderRadius: 4, border: 'none', background: '#4f46e5', color: '#fff' }}>
Add Member
</button>
</div>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{members.map((m) => (
<li key={m.email} style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderBottom: '1px solid #f0f0f0' }}>
<span>
{m.email} {m.name ? `(${m.name})` : ''} {m.role ? `- ${m.role}` : ''}
<span style={{ marginLeft: 8, color: '#6b7280', fontSize: 12 }}>
joined {new Date(m.joinedAt).toLocaleDateString()}
</span>
</span>
<button onClick={() => handleRemove(m.email)} style={{ padding: '4px 8px', borderRadius: 4, border: 'none', background: '#f87171', color: '#fff' }}>
Remove
</button>
</li>
))}
{members.length === 0 && (
<li style={{ color: '#6b7280' }}>No members yet. Add someone to collaborate on this FinMind household.</li>
)}
</ul>
</section>
);
};

export default HouseholdManager;
47 changes: 47 additions & 0 deletions app/src/utils/household.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export interface HouseholdMember {
email: string;
name?: string;
role?: string;
joinedAt: string; // ISO timestamp
}

const STORAGE_KEY = 'finmind_household_members';

export function getMembers(): HouseholdMember[] {
try {
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(STORAGE_KEY) : null;
if (!raw) return [];
const parsed = JSON.parse(raw) as HouseholdMember[];
if (Array.isArray(parsed)) return parsed;
return [];
} catch {
return [];
}
}

function saveMembers(members: HouseholdMember[]) {
try {
if (typeof window === 'undefined') return;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(members));
} catch {
// ignore storage errors
}
}

export function addMember(email: string, name?: string, role?: string): void {
const existing = getMembers();
const member: HouseholdMember = {
email,
name,
role,
joinedAt: new Date().toISOString(),
};
existing.push(member);
saveMembers(existing);
}

export function removeMember(email: string): void {
const existing = getMembers();
const next = existing.filter(m => m.email !== email);
saveMembers(next);
}
102 changes: 102 additions & 0 deletions app/src/utils/loginDetector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
export interface LoginEvent {
userId?: string;
ip?: string;
location?: string;
device?: string;
timestamp: string; // ISO string
}

export interface Anomaly {
userId?: string;
loginEvent: LoginEvent;
reason: string;
score: number;
}

// Detect unusual login behavior for a sequence of login events per user.
// Simple heuristic:
// - An event with a location different from previous locations within a short window (default 24h) is an anomaly.
// - An event with a device different from the most common device in the recent history is an anomaly.
export function detectUnusualLogin(events: LoginEvent[]): Anomaly[] {
if (!Array.isArray(events)) return [];

// Group by userId when available; if no userId, skip those events.
const byUser = new Map<string, LoginEvent[]>();

for (const e of events) {
const user = e.userId;
if (!user) continue; // require userId to attribute anomaly
if (!byUser.has(user)) byUser.set(user, []);
byUser.get(user)!.push(e);
}

const anomalies: Anomaly[] = [];

// Process each user separately
for (const [user, list] of byUser.entries()) {
// sort by timestamp ascending
const sorted = list.slice().sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());

// Track history of devices and locations for simple heuristics
const deviceHistory: string[] = [];

for (let i = 0; i < sorted.length; i++) {
const ev = sorted[i];
const time = new Date(ev.timestamp).getTime();

// look back at previous events within a reasonable window (e.g., 7 days)
const windowMs = 7 * 24 * 60 * 60 * 1000; // 7 days window
const prev = sorted.filter((_, idx) => idx < i && (new Date(sorted[idx].timestamp).getTime() >= time - windowMs));

// determine most common device in previous events
const prevDevices = prev.map(p => p.device).filter(Boolean) as string[];
const mostCommonDevice = mostCommon(prevDevices);

// anomaly checks
let reason = '';
let score = 0;

// Unusual location: if there is a previous event with a different location
const hasLocationCompared = prev.length > 0 && ev.location;
if (hasLocationCompared && ev.location) {
const differentLocation = prev.some(p => p.location && p.location !== ev.location);
if (differentLocation) {
reason += (reason ? '; ' : '') + 'Unusual login location';
score += 1;
}
}

// New device: compare with most common device in history
if (ev.device && mostCommonDevice && ev.device !== mostCommonDevice) {
reason += (reason ? '; ' : '') + 'New device detected';
score += 1;
}

if (reason) {
anomalies.push({ userId: user, loginEvent: ev, reason, score });
}

// update device history
if (ev.device) deviceHistory.push(ev.device);
}
}

return anomalies;
}

function mostCommon(arr: string[]): string | null {
if (arr.length === 0) return null;
const freq: Record<string, number> = {};
for (const v of arr) {
freq[v] = (freq[v] ?? 0) + 1;
}
let maxCount = 0;
let maxVal: string | null = null;
for (const k of Object.keys(freq)) {
if (freq[k] > maxCount) {
maxCount = freq[k];
maxVal = k;
}
}
return maxVal;
}
47 changes: 47 additions & 0 deletions scripts/detect-login-anomaly-demo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Demo script to show how to use login anomaly detection logic (pure JS).
// This does not integrate with the TS utilities, but provides a quick sanity check.

function mostCommon(arr){
if(!arr || arr.length===0) return null;
const freq = {};
for (const v of arr){ freq[v]=(freq[v]||0)+1; }
let max = 0; let val=null;
for (const k of Object.keys(freq)){
if (freq[k] > max){ max=freq[k]; val=k; }
}
return val;
}

function detectUnusualLogin(events){
const byUser = new Map();
for(const e of events){ if(!e.userId) continue; if(!byUser.has(e.userId)) byUser.set(e.userId,[]); byUser.get(e.userId).push(e); }
const anomalies=[];
for(const [user, list] of byUser.entries()){
const sorted = list.slice().sort((a,b)=> new Date(a.timestamp)-new Date(b.timestamp));
for(let i=0;i<sorted.length;i++){
const ev = sorted[i]; const time = new Date(ev.timestamp).getTime();
const windowMs = 7*24*60*60*1000;
const prev = sorted.filter((_,idx)=> idx<i && (new Date(sorted[idx].timestamp).getTime() >= time - windowMs));
const prevDevices = prev.map(p=>p.device).filter(Boolean);
const most = mostCommon(prevDevices);
let reason=''; let score=0;
if(prev.length>0 && ev.location){ const diff = prev.some(p=> p.location && p.location !== ev.location); if(diff){ reason += 'Unusual login location'; score++; }}
if(ev.device && most && ev.device !== most){ reason += (reason? '; ': '') + 'New device detected'; score++; }
if(reason){ anomalies.push({userId:user, loginEvent:ev, reason, score}); }
}
}
return anomalies;
}

const sample = [
{userId:'u1', ip:'1.1.1.1', location:'US', device:'Chrome-iPhone', timestamp:'2026-03-01T10:00:00Z'},
{userId:'u1', ip:'1.1.1.2', location:'US', device:'Chrome-Laptop', timestamp:'2026-03-01T12:00:00Z'},
{userId:'u1', ip:'1.1.2.3', location:'DE', device:'Chrome-Laptop', timestamp:'2026-03-01T18:00:00Z'},
{userId:'u1', ip:'1.1.3.4', location:'DE', device:'Firefox-PC', timestamp:'2026-03-02T09:00:00Z'},
{userId:'u2', ip:'9.9.9.9', location:'IN', device:'App', timestamp:'2026-03-01T11:00:00Z'},
{userId:'u2', ip:'9.9.9.10', location:'IN', device:'App', timestamp:'2026-03-01T11:30:00Z'},
{userId:'u2', ip:'9.9.9.11', location:'US', device:'App', timestamp:'2026-03-01T12:10:00Z'},
];

const anomalies = detectUnusualLogin(sample);
console.log('Anomalies found:', anomalies);