diff --git a/apps/extension/README.md b/apps/extension/README.md index d0b90d29..156c6ed2 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -92,7 +92,17 @@ Safari's `webRequest` support is limited, so header checks may show Unknown ther ## Account features -The **Account** tab signs in with Supabase and shows usage and alerts. The build reads `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` (and `NEXT_PUBLIC_APP_URL`, default `https://threatcrush.com`) from the environment or `apps/.env*`. Without the Supabase values, sign-in reports that it isn't configured; page checks still work. +The **Account** tab signs in with Supabase and shows your organization's detections. The build reads `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` (and `NEXT_PUBLIC_APP_URL`, default `https://threatcrush.com`) from the environment or `apps/.env*`. Without the Supabase values, sign-in reports that it isn't configured; page checks still work. + +### Detection alerts + +While you are signed in, the background worker polls `GET /api/orgs/:id/detections?status=new` for your current organization (the one selected on the website, else your only or most recently joined one) every *Event check interval* minutes (Options, default 5) and whenever the popup's Account tab opens. + +- **Popup:** the number of detections still marked *new*, how many of them are high or critical, and the five newest. *View all* and *Alerts* open `/org//detections`. +- **Notifications:** one browser notification per poll for high and critical detections that weren't in the previous poll (at most one notification per organization on screen; the next alert replaces it). Clicking it opens the detections page. Turn it off with Options → Account alerts. The first poll after signing in, or after switching organization, only records what already exists, so installing the extension doesn't replay old alerts. +- **Toolbar:** the tooltip reads "ThreatCrush: N new detections in ". The badge is not used for this: it belongs to page checks, set per tab for the page on screen, and one badge meaning two things would be ambiguous. + +New detections are found by comparing detection ids with the previous poll, not by time: `detected_at` comes from the daemon and can be older than the upload (a spooled event replayed after an outage). Signed out, it makes no requests; with no organization, it only lists your organizations. If the API can't be reached, the popup keeps the last known numbers and nothing is notified. ## Structure diff --git a/apps/extension/__tests__/detection-alerts.test.js b/apps/extension/__tests__/detection-alerts.test.js new file mode 100644 index 00000000..18102feb --- /dev/null +++ b/apps/extension/__tests__/detection-alerts.test.js @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const ORG = { id: 'org-1', slug: 'acme', name: 'Acme' }; + +const detection = (id, severity, title = `Detection ${id}`) => ({ + id, + severity, + title, + status: 'new', + detected_at: '2026-09-25T12:00:00Z', +}); + +let store; +let api; + +function fakeChrome() { + store = {}; + return { + storage: { + local: { + get: vi.fn(async (key) => (key in store ? { [key]: store[key] } : {})), + set: vi.fn(async (items) => Object.assign(store, items)), + remove: vi.fn(async (key) => { + delete store[key]; + }), + }, + }, + notifications: { create: vi.fn(async () => 'id'), onClicked: { addListener: vi.fn() }, clear: vi.fn() }, + action: { setTitle: vi.fn(async () => {}), setBadgeText: vi.fn(async () => {}) }, + tabs: { create: vi.fn() }, + runtime: { getURL: (path) => `chrome-extension://test/${path}` }, + }; +} + +/** Load the module with the API returning `pages` in order, one per poll. */ +async function load({ token = 'jwt', organizations = [ORG], pages = [] } = {}) { + const queue = [...pages]; + api = { + getAuthToken: vi.fn(async () => token), + getProfile: vi.fn(async () => ({ profile: { current_org_id: null } })), + listOrganizations: vi.fn(async () => ({ organizations })), + listDetections: vi.fn(async () => { + const next = queue.shift(); + if (next instanceof Error) throw next; + return next; + }), + }; + vi.doMock('../src/lib/api.js', () => api); + return import('../src/background/detections.js'); +} + +beforeEach(() => { + vi.resetModules(); + global.chrome = fakeChrome(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.doUnmock('../src/lib/api.js'); + vi.restoreAllMocks(); +}); + +describe('checkDetections', () => { + it('counts new detections without notifying for those that existed at the first poll', async () => { + const { checkDetections } = await load({ + pages: [{ detections: [detection('a', 'critical'), detection('b', 'low')], total: 2 }], + }); + + const result = await checkDetections(); + + expect(result).toMatchObject({ signedIn: true, org: ORG, newCount: 2, urgentCount: 1 }); + expect(api.listDetections).toHaveBeenCalledWith('org-1', expect.objectContaining({ status: 'new' })); + expect(chrome.notifications.create).not.toHaveBeenCalled(); + }); + + it('notifies only for high/critical detections that appeared since the previous poll', async () => { + const { checkDetections } = await load({ + pages: [ + { detections: [detection('a', 'critical')], total: 1 }, + { + detections: [ + detection('d', 'medium'), + detection('c', 'high', 'SSH brute force from 203.0.113.9'), + detection('a', 'critical'), + ], + total: 3, + }, + { detections: [detection('c', 'high'), detection('a', 'critical')], total: 2 }, + ], + }); + + await checkDetections(); + const second = await checkDetections(); + + expect(second.newCount).toBe(3); + expect(chrome.notifications.create).toHaveBeenCalledTimes(1); + const [id, options] = chrome.notifications.create.mock.calls[0]; + expect(id).toBe('threatcrush-detections:acme'); + expect(options.message).toBe('SSH brute force from 203.0.113.9'); + + // Nothing unseen on the third poll (one detection was acknowledged meanwhile). + const third = await checkDetections(); + expect(third.newCount).toBe(2); + expect(chrome.notifications.create).toHaveBeenCalledTimes(1); + }); + + it('does not notify for low/medium/info detections', async () => { + const { checkDetections } = await load({ + pages: [ + { detections: [], total: 0 }, + { detections: [detection('x', 'medium'), detection('y', 'low'), detection('z', 'info')], total: 3 }, + ], + }); + + await checkDetections(); + const result = await checkDetections(); + + expect(result.newCount).toBe(3); + expect(result.urgentCount).toBe(0); + expect(chrome.notifications.create).not.toHaveBeenCalled(); + }); + + it('respects the notifications setting', async () => { + const { checkDetections } = await load({ + pages: [{ detections: [], total: 0 }, { detections: [detection('a', 'critical')], total: 1 }], + }); + store.notificationsEnabled = false; + + await checkDetections(); + await checkDetections(); + + expect(chrome.notifications.create).not.toHaveBeenCalled(); + }); + + it('re-baselines instead of notifying when the current org changes', async () => { + const other = { id: 'org-2', slug: 'other', name: 'Other' }; + const { checkDetections } = await load({ + organizations: [ORG, other], + pages: [{ detections: [], total: 0 }, { detections: [detection('a', 'critical')], total: 1 }], + }); + + await checkDetections(); + api.getProfile.mockResolvedValue({ profile: { current_org_id: 'org-2' } }); + const result = await checkDetections(); + + expect(result.org).toEqual(other); + expect(chrome.notifications.create).not.toHaveBeenCalled(); + }); + + it('is a quiet no-op when signed out', async () => { + const { checkDetections } = await load({ token: null }); + store.detectionAlerts = { org: ORG, newCount: 4, seenIds: ['a'] }; + + const result = await checkDetections(); + + expect(result).toMatchObject({ signedIn: false, org: null, newCount: 0 }); + expect(api.listOrganizations).not.toHaveBeenCalled(); + expect(store.detectionAlerts).toBeUndefined(); + expect(chrome.notifications.create).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + expect(console.error).not.toHaveBeenCalled(); + }); + + it('reports no org and fetches no detections when the user has no organization', async () => { + const { checkDetections } = await load({ organizations: [] }); + + const result = await checkDetections(); + + expect(result).toMatchObject({ signedIn: true, org: null, newCount: 0 }); + expect(api.listDetections).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + expect(console.error).not.toHaveBeenCalled(); + }); + + it('keeps the last known state and does not notify when the API fails', async () => { + const { checkDetections } = await load({ + pages: [ + { detections: [detection('a', 'high')], total: 1 }, + new Error('API error: 502'), + { detections: [detection('a', 'high')], total: 1 }, + ], + }); + + await checkDetections(); + const failed = await checkDetections(); + + expect(failed).toMatchObject({ org: ORG, newCount: 1, error: expect.any(String) }); + expect(store.detectionAlerts.seenIds).toEqual(['a']); + + // Recovery: the detection seen before the outage does not notify. + await checkDetections(); + expect(chrome.notifications.create).not.toHaveBeenCalled(); + }); + + it('shares one poll between concurrent callers so an alert fires once', async () => { + const { checkDetections } = await load({ + pages: [{ detections: [], total: 0 }, { detections: [detection('a', 'critical')], total: 1 }], + }); + await checkDetections(); + + await Promise.all([checkDetections(), checkDetections()]); + + expect(api.listDetections).toHaveBeenCalledTimes(2); + expect(chrome.notifications.create).toHaveBeenCalledTimes(1); + }); +}); + +describe('notification click', () => { + it("opens the org's detections page", async () => { + const { registerDetectionAlerts } = await load(); + registerDetectionAlerts(); + const [onClick] = chrome.notifications.onClicked.addListener.mock.calls[0]; + + onClick('threatcrush-detections:acme'); + onClick('some-other-notification'); + + expect(chrome.tabs.create).toHaveBeenCalledTimes(1); + expect(chrome.tabs.create).toHaveBeenCalledWith({ url: 'https://threatcrush.com/org/acme/detections' }); + }); +}); diff --git a/apps/extension/public/icons/icon-128.png b/apps/extension/public/icons/icon-128.png index 5dbf8266..b3483052 100644 Binary files a/apps/extension/public/icons/icon-128.png and b/apps/extension/public/icons/icon-128.png differ diff --git a/apps/extension/public/icons/icon-16.png b/apps/extension/public/icons/icon-16.png index 5dbf8266..6f15f24a 100644 Binary files a/apps/extension/public/icons/icon-16.png and b/apps/extension/public/icons/icon-16.png differ diff --git a/apps/extension/public/icons/icon-32.png b/apps/extension/public/icons/icon-32.png index 5dbf8266..9d7a3933 100644 Binary files a/apps/extension/public/icons/icon-32.png and b/apps/extension/public/icons/icon-32.png differ diff --git a/apps/extension/public/icons/icon-48.png b/apps/extension/public/icons/icon-48.png index 5dbf8266..06409fc7 100644 Binary files a/apps/extension/public/icons/icon-48.png and b/apps/extension/public/icons/icon-48.png differ diff --git a/apps/extension/src/background/detections.js b/apps/extension/src/background/detections.js new file mode 100644 index 00000000..782de8f7 --- /dev/null +++ b/apps/extension/src/background/detections.js @@ -0,0 +1,177 @@ +/** + * Account alerts: poll the signed-in user's org for `new` detections, keep the + * count for the popup and toolbar tooltip, and raise a browser notification + * for high/critical detections that appeared since the previous poll. + * + * New detections are found by diffing detection ids against the previous poll + * rather than with `since=`: `since` filters on `detected_at`, + * which the daemon sets and which can predate the upload (spooled events are + * replayed later), so a time window would silently drop them. + */ + +import { getAuthToken, getProfile, listDetections, listOrganizations } from '../lib/api.js'; + +const APP_URL = import.meta.env.VITE_APP_URL || 'https://threatcrush.com'; + +export const STATE_KEY = 'detectionAlerts'; +export const NOTIFICATION_PREFIX = 'threatcrush-detections:'; +export const ALERT_SEVERITIES = new Set(['high', 'critical']); +/** Newest `new` detections fetched per poll; the route's maximum is 200. */ +const PAGE_SIZE = 100; +const RECENT_SHOWN = 5; +const DEFAULT_TITLE = 'ThreatCrush'; + +/** Popup/tooltip view of the last poll. */ +function summary(state) { + if (!state) { + return { signedIn: false, org: null, newCount: 0, urgentCount: 0, urgentPartial: false, recent: [], checkedAt: null }; + } + return { + signedIn: true, + org: state.org, + newCount: state.newCount, + urgentCount: state.urgentCount, + urgentPartial: state.urgentPartial, + recent: state.recent, + checkedAt: state.checkedAt, + }; +} + +/** The org the web app has selected, else the most recently joined one. */ +export function pickOrg(organizations, currentOrgId) { + if (!organizations?.length) return null; + return organizations.find((o) => o.id === currentOrgId) || organizations[0]; +} + +/** Detections in `detections` whose id wasn't seen by the previous poll. */ +export function unseenDetections(detections, seenIds) { + const seen = new Set(seenIds); + return detections.filter((d) => !seen.has(d.id)); +} + +export function detectionsUrl(slug) { + return `${APP_URL}/org/${encodeURIComponent(slug)}/detections`; +} + +async function currentOrg() { + const { organizations } = await listOrganizations(); + if (!organizations?.length) return null; + if (organizations.length === 1) return organizations[0]; + const { profile } = await getProfile(); + return pickOrg(organizations, profile?.current_org_id); +} + +async function notificationsEnabled() { + const { notificationsEnabled } = await chrome.storage.local.get('notificationsEnabled'); + return notificationsEnabled !== false; +} + +async function notify(org, urgent) { + if (!urgent.length || !(await notificationsEnabled())) return; + const [first] = urgent; + const title = + urgent.length === 1 + ? `${first.severity === 'critical' ? 'Critical' : 'High'} detection in ${org.name}` + : `${urgent.length} new high/critical detections in ${org.name}`; + const message = + urgent.length === 1 ? first.title : urgent.slice(0, 3).map((d) => `• ${d.title}`).join('\n'); + // One notification per org, replaced by the next poll's alert. + await chrome.notifications.create(`${NOTIFICATION_PREFIX}${org.slug}`, { + type: 'basic', + iconUrl: chrome.runtime.getURL('icons/icon-128.png'), + title, + message, + }); +} + +async function setTooltip(state) { + const title = state?.org && state.newCount > 0 + ? `${DEFAULT_TITLE}: ${state.newCount} new detection${state.newCount === 1 ? '' : 's'} in ${state.org.name}` + : DEFAULT_TITLE; + await chrome.action.setTitle({ title }).catch(() => {}); +} + +async function runCheck() { + if (!(await getAuthToken())) { + await chrome.storage.local.remove(STATE_KEY); + await setTooltip(null); + return summary(null); + } + + const previous = (await chrome.storage.local.get(STATE_KEY))[STATE_KEY] || null; + let org; + let page; + try { + org = await currentOrg(); + if (org) page = await listDetections(org.id, { status: 'new', limit: String(PAGE_SIZE) }); + } catch (err) { + // Offline, API down, or token rejected: keep the last known state. + console.warn('[ThreatCrush] Detection check failed:', err?.message || err); + return { ...summary(previous), signedIn: true, error: 'Could not reach ThreatCrush.' }; + } + + if (!org) { + const state = { + org: null, + newCount: 0, + urgentCount: 0, + urgentPartial: false, + recent: [], + seenIds: [], + checkedAt: Date.now(), + }; + await chrome.storage.local.set({ [STATE_KEY]: state }); + await setTooltip(state); + return summary(state); + } + + const detections = page.detections || []; + // First poll for this org sets the baseline: what already exists shows in + // the count, but only detections that arrive afterwards notify. + const baseline = !previous || previous.org?.id !== org.id; + const fresh = baseline ? [] : unseenDetections(detections, previous.seenIds); + + const state = { + org: { id: org.id, slug: org.slug, name: org.name }, + newCount: page.total ?? detections.length, + urgentCount: detections.filter((d) => ALERT_SEVERITIES.has(d.severity)).length, + // More new detections exist than one page holds, so urgentCount is a floor. + urgentPartial: (page.total ?? 0) > detections.length, + recent: detections.slice(0, RECENT_SHOWN).map(({ id, title, severity, detected_at }) => ({ + id, + title, + severity, + detected_at, + })), + seenIds: detections.map((d) => d.id), + checkedAt: Date.now(), + }; + await chrome.storage.local.set({ [STATE_KEY]: state }); + await setTooltip(state); + await notify(state.org, fresh.filter((d) => ALERT_SEVERITIES.has(d.severity))).catch((err) => + console.warn('[ThreatCrush] Notification failed:', err?.message || err) + ); + return summary(state); +} + +let inFlight = null; + +/** + * Poll once and return the popup summary. Concurrent callers (alarm + popup) + * share one poll so a detection can't notify twice. + */ +export function checkDetections() { + inFlight ??= runCheck().finally(() => { + inFlight = null; + }); + return inFlight; +} + +/** Open the org's detections page when an alert is clicked. Call at worker start-up. */ +export function registerDetectionAlerts() { + chrome.notifications.onClicked.addListener((id) => { + if (!id.startsWith(NOTIFICATION_PREFIX)) return; + chrome.tabs.create({ url: detectionsUrl(id.slice(NOTIFICATION_PREFIX.length)) }); + chrome.notifications.clear(id); + }); +} diff --git a/apps/extension/src/background/index.js b/apps/extension/src/background/index.js index bc281d08..6ec0e497 100644 --- a/apps/extension/src/background/index.js +++ b/apps/extension/src/background/index.js @@ -1,133 +1,50 @@ /** * ThreatCrush Background Service Worker * - * Handles periodic security event checks, badge updates, notifications, and - * the local checks on the page in each tab (see ./page-checks.js). + * Polls the signed-in account for new detections (./detections.js), raises + * notifications, and runs the local checks on the page in each tab + * (./page-checks.js). + * + * The toolbar badge belongs to page checks: it is set per tab for the page on + * screen. The account's detection count goes in the popup, the toolbar + * tooltip and notifications instead, so one badge never means two things. */ -import { getAuthToken, getUsageStats, scanUrl as apiScanUrl } from '../lib/api.js'; +import { scanUrl as apiScanUrl } from '../lib/api.js'; import { scanTargetUrl } from '../lib/page-checks.js'; +import { checkDetections, registerDetectionAlerts } from './detections.js'; import { checkTab, registerPageChecks } from './page-checks.js'; registerPageChecks(); +registerDetectionAlerts(); const ALARM_NAME = 'threatcrush-event-check'; -const CHECK_INTERVAL_MINUTES = 5; - -// Badge colors -const BADGE_COLORS = { - secure: '#00ff41', - warning: '#f59e0b', - threat: '#ef4444', -}; +const DEFAULT_INTERVAL_MINUTES = 5; -/** - * Initialize the extension on install - */ chrome.runtime.onInstalled.addListener(async () => { - console.log('[ThreatCrush] Extension installed'); - - // Set up periodic alarm for event checking + // Keep the interval chosen in Options across updates. + const { scanInterval } = await chrome.storage.local.get('scanInterval'); await chrome.alarms.create(ALARM_NAME, { - periodInMinutes: CHECK_INTERVAL_MINUTES, + periodInMinutes: scanInterval || DEFAULT_INTERVAL_MINUTES, }); - - // Set initial badge - await updateBadge({ threats: 0, warnings: 0 }); + await checkDetections(); }); -/** - * Handle alarm events - */ chrome.alarms.onAlarm.addListener(async (alarm) => { if (alarm.name === ALARM_NAME) { - await checkForEvents(); + await checkDetections(); } }); -/** - * Check for new security events - */ -async function checkForEvents() { - try { - if (!(await getAuthToken())) { - // Not logged in, clear badge - await updateBadge({ threats: 0, warnings: 0 }); - return; - } - - // Fetch real usage stats from the API - const usage = await getUsageStats(); - - const threats = usage.threats || 0; - const warnings = usage.warnings || 0; - - await updateBadge({ threats, warnings }); - - // Show notification for new threats - if (threats > 0) { - const { notificationsEnabled } = await chrome.storage.local.get('notificationsEnabled'); - if (notificationsEnabled !== false) { - chrome.notifications.create({ - type: 'basic', - iconUrl: 'icons/icon-128.png', - title: 'ThreatCrush Alert', - message: `${threats} active threat${threats > 1 ? 's' : ''} detected`, - }); - } - } - } catch (error) { - console.error('[ThreatCrush] Event check failed:', error); - } -} - -/** - * Update the extension badge - */ -async function updateBadge({ threats, warnings }) { - if (threats > 0) { - await chrome.action.setBadgeText({ text: String(threats) }); - await chrome.action.setBadgeBackgroundColor({ color: BADGE_COLORS.threat }); - } else if (warnings > 0) { - await chrome.action.setBadgeText({ text: String(warnings) }); - await chrome.action.setBadgeBackgroundColor({ color: BADGE_COLORS.warning }); - } else { - await chrome.action.setBadgeText({ text: '' }); - await chrome.action.setBadgeBackgroundColor({ color: BADGE_COLORS.secure }); - } -} - /** * Handle messages from popup/options */ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === 'GET_STATS') { - getAuthToken().then(async (token) => { - if (!token) { - sendResponse({ threats: 0, warnings: 0, eventsToday: 0, modulesRunning: 0 }); - return; - } - try { - const usage = await getUsageStats(); - sendResponse({ - threats: usage.threats || 0, - warnings: usage.warnings || 0, - eventsToday: usage.today_requests || usage.events_today || 0, - modulesRunning: 0, - }); - } catch (err) { - console.error('[ThreatCrush] GET_STATS failed:', err); - sendResponse({ threats: 0, warnings: 0, eventsToday: 0, modulesRunning: 0 }); - } - }); + checkDetections().then(sendResponse); return true; // async response } - if (message.type === 'CHECK_NOW') { - checkForEvents().then(() => sendResponse({ ok: true })); - return true; - } - if (message.type === 'PAGE_CHECKS') { checkTab(message.tabId) .then(sendResponse) diff --git a/apps/extension/src/background/page-checks.js b/apps/extension/src/background/page-checks.js index 270c59a4..ba5885c4 100644 --- a/apps/extension/src/background/page-checks.js +++ b/apps/extension/src/background/page-checks.js @@ -148,7 +148,7 @@ export async function checkTab(tabId) { async function onTabUpdated(tabId, changeInfo, tab) { if (changeInfo.status === 'loading') { - // null falls back to the global (account) badge until the new page is checked. + // null clears the tab's badge (back to the empty default) until the new page is checked. await chrome.action.setBadgeText({ tabId, text: null }).catch(() => {}); return; } diff --git a/apps/extension/src/lib/api.js b/apps/extension/src/lib/api.js index 542dea62..3917a0d4 100644 --- a/apps/extension/src/lib/api.js +++ b/apps/extension/src/lib/api.js @@ -99,6 +99,22 @@ export async function installModule(slug) { return request(`/api/modules/${slug}/install`, { method: 'POST' }); } +// ─── Organizations ─── + +/** Organizations the signed-in user belongs to: `{ organizations: [{ id, name, slug, ... }] }`. */ +export async function listOrganizations() { + return request('/api/orgs'); +} + +/** + * One page of an org's detections, newest `detected_at` first: + * `{ detections, total }`, where `total` counts every match of the filters. + */ +export async function listDetections(orgId, params = {}) { + const query = new URLSearchParams(params).toString(); + return request(`/api/orgs/${encodeURIComponent(orgId)}/detections${query ? `?${query}` : ''}`); +} + // ─── Scanning ─── export async function scanUrl(url) { diff --git a/apps/extension/src/options/App.jsx b/apps/extension/src/options/App.jsx index d9cae7a9..f111ccb0 100644 --- a/apps/extension/src/options/App.jsx +++ b/apps/extension/src/options/App.jsx @@ -178,7 +178,7 @@ export default function App() { className="w-4 h-4 accent-[#00ff41]" /> - Browser notifications for new threats + Browser notifications for new high/critical detections diff --git a/apps/extension/src/popup/App.jsx b/apps/extension/src/popup/App.jsx index 206e6d68..0d6b4f84 100644 --- a/apps/extension/src/popup/App.jsx +++ b/apps/extension/src/popup/App.jsx @@ -9,13 +9,13 @@ import PageChecks from './components/PageChecks'; function Account() { const { user, loading: authLoading } = useAuthStore(); - const { stats, fetchStats } = useEventsStore(); + const { alerts, fetchAlerts } = useEventsStore(); useEffect(() => { if (user) { - fetchStats(); + fetchAlerts(); } - }, [user, fetchStats]); + }, [user, fetchAlerts]); if (authLoading) { return ( @@ -42,29 +42,29 @@ function Account() { {/* Status Badge */} - + - {/* Quick Stats */} -
-
-
{stats.eventsToday}
-
Events Today
+ {alerts.org && ( +
+
+
{alerts.newCount}
+
New detections
+
+
+
+ {alerts.urgentCount} + {alerts.urgentPartial ? '+' : ''} +
+
High / critical
+
-
-
{stats.threats}
-
Active Threats
-
-
-
{stats.modulesRunning}
-
Modules
-
-
+ )} {/* Recent Events */} {/* Quick Actions */} - +
); } diff --git a/apps/extension/src/popup/components/EventFeed.jsx b/apps/extension/src/popup/components/EventFeed.jsx index f9f96017..f89aaf12 100644 --- a/apps/extension/src/popup/components/EventFeed.jsx +++ b/apps/extension/src/popup/components/EventFeed.jsx @@ -1,51 +1,79 @@ import React from 'react'; import { useEventsStore } from '../../store/events'; -const EVENT_ICONS = { - info: '🔵', - warning: '🟡', - threat: '🔴', - scan: '🔍', - module: '📦', +const APP_URL = import.meta.env.VITE_APP_URL || 'https://threatcrush.com'; + +const SEVERITY_DOTS = { + critical: 'bg-red-500', + high: 'bg-orange-500', + medium: 'bg-yellow-500', + low: 'bg-blue-400', + info: 'bg-gray-500', }; +function timeAgo(iso) { + const minutes = Math.round((Date.now() - new Date(iso).getTime()) / 60000); + if (!Number.isFinite(minutes)) return ''; + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes} min ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours} h ago`; + return new Date(iso).toLocaleDateString(); +} + +/** The org's newest detections still marked new. */ export default function EventFeed() { - const { events, loading } = useEventsStore(); + const { alerts, loading } = useEventsStore(); if (loading) { return (
-
Loading events...
+
Loading detections...
); } - if (events.length === 0) { + if (!alerts.org) return
; + + if (alerts.recent.length === 0) { return (
🛡️
-
No recent events
+
No new detections
); } + const detectionsUrl = `${APP_URL}/org/${encodeURIComponent(alerts.org.slug)}/detections`; + return (
-
- Recent Events +
+
New detections
+
- {events.slice(0, 5).map((event) => ( + {alerts.recent.map((d) => (
- {EVENT_ICONS[event.type] || '🔵'} +
-
{event.title}
-
{event.time}
+
{d.title}
+
+ {d.severity} · {timeAgo(d.detected_at)} +
))} diff --git a/apps/extension/src/popup/components/QuickActions.jsx b/apps/extension/src/popup/components/QuickActions.jsx index a60d80c6..44681420 100644 --- a/apps/extension/src/popup/components/QuickActions.jsx +++ b/apps/extension/src/popup/components/QuickActions.jsx @@ -2,7 +2,8 @@ import React from 'react'; const APP_URL = import.meta.env.VITE_APP_URL || 'https://threatcrush.com'; -export default function QuickActions() { +/** `orgSlug`: the org whose alerts the popup shows; Alerts opens its detections. */ +export default function QuickActions({ orgSlug }) { function openUrl(path) { chrome.tabs.create({ url: `${APP_URL}${path}` }); } @@ -17,7 +18,7 @@ export default function QuickActions() { Dashboard
{config.label}
-
- {status === 'secure' && 'No active threats detected'} - {status === 'warning' && `${stats.warnings} warning${stats.warnings > 1 ? 's' : ''} need attention`} - {status === 'threat' && `${stats.threats} active threat${stats.threats > 1 ? 's' : ''} detected`} -
+
{detail(status, alerts)}
+ {alerts.error &&
{alerts.error}
}
diff --git a/apps/extension/src/store/events.js b/apps/extension/src/store/events.js index efa135ec..c981ede0 100644 --- a/apps/extension/src/store/events.js +++ b/apps/extension/src/store/events.js @@ -1,33 +1,28 @@ import { create } from 'zustand'; +/** Shape returned by the background worker's GET_STATS (see background/detections.js). */ +export const EMPTY_ALERTS = { + signedIn: false, + org: null, + newCount: 0, + urgentCount: 0, + urgentPartial: false, + recent: [], + checkedAt: null, +}; + export const useEventsStore = create((set) => ({ - events: [], - stats: { threats: 0, warnings: 0, eventsToday: 0, modulesRunning: 0 }, + alerts: EMPTY_ALERTS, loading: false, - error: null, - fetchStats: async () => { + fetchAlerts: async () => { set({ loading: true }); try { const response = await chrome.runtime.sendMessage({ type: 'GET_STATS' }); - set({ - stats: response || { threats: 0, warnings: 0, eventsToday: 0, modulesRunning: 0 }, - events: [], - loading: false, - }); + set({ alerts: response || EMPTY_ALERTS, loading: false }); } catch (error) { - console.error('[ThreatCrush] Fetch stats error:', error); - set({ stats: { threats: 0, warnings: 0, eventsToday: 0, modulesRunning: 0 }, events: [], loading: false }); + console.error('[ThreatCrush] Fetch alerts error:', error); + set({ alerts: EMPTY_ALERTS, loading: false }); } }, - - addEvent: (event) => { - set((state) => ({ - events: [event, ...state.events].slice(0, 50), - })); - }, - - clearEvents: () => { - set({ events: [] }); - }, }));