diff --git a/app/src/components/household/HouseholdManager.tsx b/app/src/components/household/HouseholdManager.tsx new file mode 100644 index 0000000..932074b --- /dev/null +++ b/app/src/components/household/HouseholdManager.tsx @@ -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([]); + const [email, setEmail] = useState(''); + const [name, setName] = useState(''); + const [role, setRole] = useState(''); + + 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 ( +
+

Household Collaboration

+
+ setEmail(e.target.value)} + style={{ padding: '8px 10px', borderRadius: 4, border: '1px solid #d1d5db' }} + /> + setName(e.target.value)} + style={{ padding: '8px 10px', borderRadius: 4, border: '1px solid #d1d5db' }} + /> + setRole(e.target.value)} + style={{ padding: '8px 10px', borderRadius: 4, border: '1px solid #d1d5db' }} + /> + +
+
    + {members.map((m) => ( +
  • + + {m.email} {m.name ? `(${m.name})` : ''} {m.role ? `- ${m.role}` : ''} + + joined {new Date(m.joinedAt).toLocaleDateString()} + + + +
  • + ))} + {members.length === 0 && ( +
  • No members yet. Add someone to collaborate on this FinMind household.
  • + )} +
+
+ ); +}; + +export default HouseholdManager; diff --git a/app/src/utils/household.ts b/app/src/utils/household.ts new file mode 100644 index 0000000..941949a --- /dev/null +++ b/app/src/utils/household.ts @@ -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); +} diff --git a/app/src/utils/loginDetector.ts b/app/src/utils/loginDetector.ts new file mode 100644 index 0000000..ddfedd6 --- /dev/null +++ b/app/src/utils/loginDetector.ts @@ -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(); + + 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 = {}; + 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; +} diff --git a/scripts/detect-login-anomaly-demo.js b/scripts/detect-login-anomaly-demo.js new file mode 100644 index 0000000..c7a40f6 --- /dev/null +++ b/scripts/detect-login-anomaly-demo.js @@ -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 idx= 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);