feat(browser-ext): move browser extension to browser-ext/ and update API client to tRPC
- Create browser-ext/ with full extension code (MV3 manifest, background service worker, content script, popup, options page) - Add tRPC API client that communicates with unified monolith endpoints - Implement cache, settings, and phishing detection utilities - Create extension tRPC router in web app (getAuthStatus, linkDevice, reportPhishing) - Configure Vite build with manifest V3 support - Write unit tests for cache, phishing detector, and API client - All 20 tests passing, TypeScript lint clean
This commit is contained in:
98
browser-ext/src/background/index.ts
Normal file
98
browser-ext/src/background/index.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { createApiClient } from "../lib/api-client";
|
||||
import { getSettings } from "../lib/settings";
|
||||
import { spamCheckCache } from "../lib/cache";
|
||||
import type { SpamCheckResult } from "../types";
|
||||
|
||||
async function getClient() {
|
||||
const settings = await getSettings();
|
||||
return createApiClient(settings.apiUrl, settings.apiKey ?? undefined);
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(async (details) => {
|
||||
if (details.reason === "install") {
|
||||
const extensionId = chrome.runtime.id;
|
||||
try {
|
||||
const client = await getClient();
|
||||
await client.extension.linkDevice.mutate({
|
||||
extensionId,
|
||||
deviceName: "ShieldAI Browser Extension",
|
||||
});
|
||||
} catch {
|
||||
console.warn("[ShieldAI] Device linking failed (will retry on auth)");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const detectionHistory: Array<{
|
||||
type: "spam_call" | "spam_sms" | "phishing";
|
||||
value: string;
|
||||
timestamp: number;
|
||||
}> = [];
|
||||
|
||||
function addDetection(
|
||||
type: "spam_call" | "spam_sms" | "phishing",
|
||||
value: string,
|
||||
) {
|
||||
detectionHistory.unshift({ type, value, timestamp: Date.now() });
|
||||
if (detectionHistory.length > 50) detectionHistory.pop();
|
||||
|
||||
chrome.storage.local.set({
|
||||
"shieldai:detections": detectionHistory.slice(0, 20),
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkPhoneNumber(phoneNumber: string): Promise<SpamCheckResult | null> {
|
||||
const cacheKey = `checkNumber:${phoneNumber}`;
|
||||
const cached = spamCheckCache.get(cacheKey);
|
||||
if (cached) return cached as SpamCheckResult;
|
||||
|
||||
try {
|
||||
const client = await getClient();
|
||||
const result = await client.spamshield.checkNumber.query({ phoneNumber });
|
||||
spamCheckCache.set(cacheKey, result);
|
||||
addDetection("spam_call", phoneNumber);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error("[ShieldAI] Failed to check number:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function classifySMS(text: string): Promise<SpamCheckResult | null> {
|
||||
const cacheKey = `classifySMS:${text.slice(0, 100)}`;
|
||||
const cached = spamCheckCache.get(cacheKey);
|
||||
if (cached) return cached as SpamCheckResult;
|
||||
|
||||
try {
|
||||
const client = await getClient();
|
||||
const result = await client.spamshield.classifySMS.query({ text });
|
||||
spamCheckCache.set(cacheKey, result);
|
||||
addDetection("spam_sms", text.slice(0, 200));
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error("[ShieldAI] Failed to classify SMS:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
switch (message.type) {
|
||||
case "CHECK_NUMBER":
|
||||
checkPhoneNumber(message.phoneNumber).then(sendResponse);
|
||||
return true;
|
||||
case "CLASSIFY_SMS":
|
||||
classifySMS(message.text).then(sendResponse);
|
||||
return true;
|
||||
case "GET_DETECTIONS":
|
||||
chrome.storage.local.get("shieldai:detections").then((result) => {
|
||||
sendResponse(result["shieldai:detections"] ?? []);
|
||||
});
|
||||
return true;
|
||||
case "GET_AUTH_STATUS":
|
||||
getClient()
|
||||
.then((client) => client.extension.getAuthStatus.query())
|
||||
.then(sendResponse)
|
||||
.catch(() => sendResponse({ linked: false }));
|
||||
return true;
|
||||
}
|
||||
});
|
||||
35
browser-ext/src/content/index.ts
Normal file
35
browser-ext/src/content/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { checkUrlForPhishing } from "../lib/phishing-detector";
|
||||
import { getSettings } from "../lib/settings";
|
||||
import { createApiClient } from "../lib/api-client";
|
||||
|
||||
async function checkCurrentPage() {
|
||||
const settings = await getSettings();
|
||||
if (!settings.phishingDetectionEnabled) return;
|
||||
|
||||
const url = window.location.href;
|
||||
const result = checkUrlForPhishing(url);
|
||||
|
||||
if (result.isPhishing) {
|
||||
console.warn("[ShieldAI] Phishing detected:", result);
|
||||
|
||||
if (settings.autoReportPhishing) {
|
||||
try {
|
||||
const client = createApiClient(settings.apiUrl, settings.apiKey ?? undefined);
|
||||
await client.extension.reportPhishing.mutate({
|
||||
url,
|
||||
source: "content-script",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ShieldAI] Failed to report phishing:", err);
|
||||
}
|
||||
}
|
||||
|
||||
chrome.runtime.sendMessage({
|
||||
type: "PHISHING_DETECTED",
|
||||
url,
|
||||
result,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
checkCurrentPage();
|
||||
17
browser-ext/src/lib/api-client.ts
Normal file
17
browser-ext/src/lib/api-client.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
|
||||
import type { TrpcClient } from "../types/trpc";
|
||||
|
||||
export function createApiClient(url: string, apiKey?: string) {
|
||||
const raw = createTRPCProxyClient({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url,
|
||||
headers: apiKey ? { "x-api-key": apiKey } : undefined,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
return raw as unknown as TrpcClient;
|
||||
}
|
||||
|
||||
export type ApiClient = ReturnType<typeof createApiClient>;
|
||||
34
browser-ext/src/lib/cache.ts
Normal file
34
browser-ext/src/lib/cache.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export class Cache<T = unknown> {
|
||||
private store = new Map<string, CacheEntry<T>>();
|
||||
|
||||
constructor(private ttlMs: number = 5 * 60 * 1000) {}
|
||||
|
||||
get(key: string): T | null {
|
||||
const entry = this.store.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.store.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
set(key: string, data: T): void {
|
||||
this.store.set(key, { data, expiresAt: Date.now() + this.ttlMs });
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.store.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const spamCheckCache = new Cache<unknown>(5 * 60 * 1000);
|
||||
50
browser-ext/src/lib/phishing-detector.ts
Normal file
50
browser-ext/src/lib/phishing-detector.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { PhishingResult } from "../types";
|
||||
|
||||
const SUSPICIOUS_PATTERNS = [
|
||||
/login[.-]?[a-z]+\.(com|org|net)/i,
|
||||
/secure[.-]?[a-z]+\.(com|org|net)/i,
|
||||
/account[.-]?[a-z]+\.(com|org|net)/i,
|
||||
/verify[.-]?[a-z]+\.(com|org|net)/i,
|
||||
/update[.-]?[a-z]+\.(com|org|net)/i,
|
||||
/banking[.-]?[a-z]+\.(com|org|net)/i,
|
||||
/paypal[.-]?[a-z]+\.(com|org|net)/i,
|
||||
];
|
||||
|
||||
const KNOWN_PHISHING_DOMAINS = [
|
||||
"shieldai-secure.com",
|
||||
"shieldai-verify.com",
|
||||
"shield-ai-login.com",
|
||||
];
|
||||
|
||||
export function checkUrlForPhishing(url: string): PhishingResult {
|
||||
const reasons: string[] = [];
|
||||
const lowerUrl = url.toLowerCase();
|
||||
|
||||
try {
|
||||
const parsed = new URL(lowerUrl);
|
||||
|
||||
for (const domain of KNOWN_PHISHING_DOMAINS) {
|
||||
if (parsed.hostname === domain || parsed.hostname.endsWith("." + domain)) {
|
||||
reasons.push(`Known phishing domain: ${parsed.hostname}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const pattern of SUSPICIOUS_PATTERNS) {
|
||||
if (pattern.test(lowerUrl)) {
|
||||
reasons.push(`Suspicious URL pattern matched: ${pattern}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
reasons.push(`Non-standard protocol: ${parsed.protocol}`);
|
||||
}
|
||||
} catch {
|
||||
reasons.push("Invalid URL format");
|
||||
}
|
||||
|
||||
return {
|
||||
isPhishing: reasons.length > 0,
|
||||
confidence: reasons.length > 2 ? 0.9 : reasons.length > 0 ? 0.6 : 0,
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
25
browser-ext/src/lib/settings.ts
Normal file
25
browser-ext/src/lib/settings.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ExtensionSettings } from "../types";
|
||||
|
||||
const DEFAULT_SETTINGS: ExtensionSettings = {
|
||||
apiKey: null,
|
||||
apiUrl: "https://api.shieldai.com/api/trpc",
|
||||
notificationsEnabled: true,
|
||||
phishingDetectionEnabled: true,
|
||||
autoReportPhishing: false,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "shieldai:settings";
|
||||
|
||||
export async function getSettings(): Promise<ExtensionSettings> {
|
||||
const result = await chrome.storage.sync.get(STORAGE_KEY);
|
||||
return { ...DEFAULT_SETTINGS, ...result[STORAGE_KEY] } as ExtensionSettings;
|
||||
}
|
||||
|
||||
export async function updateSettings(
|
||||
partial: Partial<ExtensionSettings>,
|
||||
): Promise<ExtensionSettings> {
|
||||
const current = await getSettings();
|
||||
const updated = { ...current, ...partial };
|
||||
await chrome.storage.sync.set({ [STORAGE_KEY]: updated });
|
||||
return updated;
|
||||
}
|
||||
167
browser-ext/src/options/options.html
Normal file
167
browser-ext/src/options/options.html
Normal file
@@ -0,0 +1,167 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ShieldAI Settings</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #6366f1;
|
||||
--bg: #0f172a;
|
||||
--surface: #1e293b;
|
||||
--text: #f8fafc;
|
||||
--muted: #94a3b8;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="url"],
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
input:focus { border-color: var(--primary); }
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.toggle-row label { margin-bottom: 0; }
|
||||
.toggle {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: #334155;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.toggle.active { background: var(--primary); }
|
||||
.toggle::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.toggle.active::after { transform: translateX(20px); }
|
||||
.btn {
|
||||
padding: 10px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.status {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
display: none;
|
||||
}
|
||||
.status.success { display: block; background: #052e16; color: #22c55e; }
|
||||
.status.error { display: block; background: #450a0a; color: #ef4444; }
|
||||
.status.info { display: block; background: #1e1b4b; color: #818cf8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>ShieldAI Settings</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>API Configuration</h2>
|
||||
<div class="field">
|
||||
<label for="apiUrl">API URL</label>
|
||||
<input type="url" id="apiUrl" placeholder="https://api.shieldai.com/api/trpc" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="apiKey">API Key</label>
|
||||
<input type="text" id="apiKey" placeholder="Enter your API key" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Account Linking</h2>
|
||||
<div class="field">
|
||||
<div id="authStatus" style="font-size:13px;color:var(--muted);margin-bottom:12px;">
|
||||
Loading auth status...
|
||||
</div>
|
||||
<button class="btn" id="linkDeviceBtn">Link Device</button>
|
||||
<div id="linkStatus" class="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Notifications</h2>
|
||||
<div class="toggle-row">
|
||||
<label>Enable notifications</label>
|
||||
<div class="toggle" id="notificationsToggle"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Phishing Detection</h2>
|
||||
<div class="toggle-row">
|
||||
<label>Enable phishing detection</label>
|
||||
<div class="toggle active" id="phishingToggle"></div>
|
||||
</div>
|
||||
<div class="toggle-row" style="border-top:1px solid #334155;margin-top:8px;padding-top:8px;">
|
||||
<label>Auto-report phishing</label>
|
||||
<div class="toggle" id="autoReportToggle"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<button class="btn" id="saveBtn">Save Settings</button>
|
||||
<div id="saveStatus" class="status"></div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./options.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
107
browser-ext/src/options/options.ts
Normal file
107
browser-ext/src/options/options.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { getSettings, updateSettings } from "../lib/settings";
|
||||
import { createApiClient } from "../lib/api-client";
|
||||
|
||||
const apiUrlInput = document.getElementById("apiUrl") as HTMLInputElement;
|
||||
const apiKeyInput = document.getElementById("apiKey") as HTMLInputElement;
|
||||
const authStatusEl = document.getElementById("authStatus")!;
|
||||
const linkDeviceBtn = document.getElementById("linkDeviceBtn") as HTMLButtonElement;
|
||||
const linkStatusEl = document.getElementById("linkStatus")!;
|
||||
const notificationsToggle = document.getElementById("notificationsToggle")!;
|
||||
const phishingToggle = document.getElementById("phishingToggle")!;
|
||||
const autoReportToggle = document.getElementById("autoReportToggle")!;
|
||||
const saveBtn = document.getElementById("saveBtn") as HTMLButtonElement;
|
||||
const saveStatusEl = document.getElementById("saveStatus")!;
|
||||
|
||||
let currentSettings!: Awaited<ReturnType<typeof getSettings>>;
|
||||
|
||||
function setToggle(el: HTMLElement, on: boolean) {
|
||||
el.classList.toggle("active", on);
|
||||
}
|
||||
|
||||
function applySettings(settings: typeof currentSettings) {
|
||||
currentSettings = settings;
|
||||
apiUrlInput.value = settings.apiUrl;
|
||||
apiKeyInput.value = settings.apiKey ?? "";
|
||||
setToggle(notificationsToggle, settings.notificationsEnabled);
|
||||
setToggle(phishingToggle, settings.phishingDetectionEnabled);
|
||||
setToggle(autoReportToggle, settings.autoReportPhishing);
|
||||
}
|
||||
|
||||
getSettings().then(applySettings);
|
||||
|
||||
async function updateAuthStatusDisplay() {
|
||||
try {
|
||||
const client = createApiClient(
|
||||
currentSettings.apiUrl,
|
||||
currentSettings.apiKey ?? undefined,
|
||||
);
|
||||
const status = await client.extension.getAuthStatus.query();
|
||||
if (status.linked) {
|
||||
authStatusEl.textContent = "✅ Account linked" + ("email" in status && status.email ? ` (${status.email})` : "");
|
||||
} else {
|
||||
authStatusEl.textContent = "❌ Not linked — enter an API key or log in";
|
||||
}
|
||||
} catch {
|
||||
authStatusEl.textContent = "❌ Not linked — API unreachable";
|
||||
}
|
||||
}
|
||||
|
||||
linkDeviceBtn.addEventListener("click", async () => {
|
||||
const extensionId = chrome.runtime.id;
|
||||
linkStatusEl.className = "status info";
|
||||
linkStatusEl.textContent = "Linking device...";
|
||||
linkStatusEl.style.display = "block";
|
||||
|
||||
try {
|
||||
const client = createApiClient(
|
||||
currentSettings.apiUrl,
|
||||
currentSettings.apiKey ?? undefined,
|
||||
);
|
||||
const result = await client.extension.linkDevice.mutate({
|
||||
extensionId,
|
||||
deviceName: "ShieldAI Browser Extension",
|
||||
});
|
||||
linkStatusEl.className = "status success";
|
||||
linkStatusEl.textContent = `Device linked! (ID: ${result.deviceId.slice(0, 8)}...)`;
|
||||
updateAuthStatusDisplay();
|
||||
} catch (err) {
|
||||
linkStatusEl.className = "status error";
|
||||
linkStatusEl.textContent = "Failed to link device. Check your API key and URL.";
|
||||
}
|
||||
});
|
||||
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = "Saving...";
|
||||
|
||||
const updated = await updateSettings({
|
||||
apiUrl: apiUrlInput.value,
|
||||
apiKey: apiKeyInput.value || null,
|
||||
notificationsEnabled: notificationsToggle.classList.contains("active"),
|
||||
phishingDetectionEnabled: phishingToggle.classList.contains("active"),
|
||||
autoReportPhishing: autoReportToggle.classList.contains("active"),
|
||||
});
|
||||
applySettings(updated);
|
||||
|
||||
saveStatusEl.className = "status success";
|
||||
saveStatusEl.textContent = "Settings saved!";
|
||||
saveStatusEl.style.display = "block";
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = "Save Settings";
|
||||
|
||||
updateAuthStatusDisplay();
|
||||
});
|
||||
|
||||
notificationsToggle.addEventListener("click", () => {
|
||||
notificationsToggle.classList.toggle("active");
|
||||
});
|
||||
|
||||
phishingToggle.addEventListener("click", () => {
|
||||
phishingToggle.classList.toggle("active");
|
||||
});
|
||||
|
||||
autoReportToggle.addEventListener("click", () => {
|
||||
autoReportToggle.classList.toggle("active");
|
||||
});
|
||||
|
||||
updateAuthStatusDisplay();
|
||||
129
browser-ext/src/popup/popup.html
Normal file
129
browser-ext/src/popup/popup.html
Normal file
@@ -0,0 +1,129 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ShieldAI</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #6366f1;
|
||||
--danger: #ef4444;
|
||||
--success: #22c55e;
|
||||
--bg: #0f172a;
|
||||
--surface: #1e293b;
|
||||
--text: #f8fafc;
|
||||
--muted: #94a3b8;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
width: 360px;
|
||||
padding: 16px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--primary);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
.status-badge {
|
||||
margin-left: auto;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-badge.linked { background: var(--success); color: #052e16; }
|
||||
.status-badge.unlinked { background: var(--danger); color: #450a0a; }
|
||||
.section {
|
||||
background: var(--surface);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section h3 {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.btn:last-child { margin-bottom: 0; }
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
.detection {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
font-size: 12px;
|
||||
}
|
||||
.detection:last-child { border-bottom: none; }
|
||||
.detection .label {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.detection.spam { border-left: 3px solid var(--danger); padding-left: 8px; }
|
||||
.detection.phishing { border-left: 3px solid #eab308; padding-left: 8px; }
|
||||
.empty-state {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="logo">S</div>
|
||||
<span style="font-weight:600;font-size:15px;">ShieldAI</span>
|
||||
<span id="authBadge" class="status-badge unlinked">Unlinked</span>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Quick Actions</h3>
|
||||
<button class="btn" id="checkNumberBtn">Check Phone Number</button>
|
||||
<button class="btn btn-outline" id="reportSpamBtn">Report Spam</button>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Recent Detections</h3>
|
||||
<div id="detections">
|
||||
<div class="empty-state">No recent detections</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" style="text-align:center;">
|
||||
<a href="#" id="openOptions" style="color:var(--primary);font-size:12px;text-decoration:none;">Settings</a>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./popup.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
82
browser-ext/src/popup/popup.ts
Normal file
82
browser-ext/src/popup/popup.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
const authBadge = document.getElementById("authBadge")!;
|
||||
const detectionsEl = document.getElementById("detections")!;
|
||||
const checkNumberBtn = document.getElementById("checkNumberBtn")!;
|
||||
const reportSpamBtn = document.getElementById("reportSpamBtn")!;
|
||||
const openOptionsLink = document.getElementById("openOptions")!;
|
||||
|
||||
function showDetection(detection: {
|
||||
type: string;
|
||||
value: string;
|
||||
timestamp: number;
|
||||
}) {
|
||||
const date = new Date(detection.timestamp).toLocaleTimeString();
|
||||
const typeClass = detection.type === "phishing" ? "phishing" : "spam";
|
||||
return `<div class="detection ${typeClass}">
|
||||
<div class="label">${detection.type.replace("_", " ")} · ${date}</div>
|
||||
<div>${detection.value}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function updateAuthStatus() {
|
||||
try {
|
||||
const status = await chrome.runtime.sendMessage({ type: "GET_AUTH_STATUS" });
|
||||
if (status?.linked) {
|
||||
authBadge.textContent = "Linked";
|
||||
authBadge.className = "status-badge linked";
|
||||
} else {
|
||||
authBadge.textContent = "Unlinked";
|
||||
authBadge.className = "status-badge unlinked";
|
||||
}
|
||||
} catch {
|
||||
authBadge.textContent = "Unlinked";
|
||||
authBadge.className = "status-badge unlinked";
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDetections() {
|
||||
try {
|
||||
const detections = await chrome.runtime.sendMessage({ type: "GET_DETECTIONS" });
|
||||
if (detections && detections.length > 0) {
|
||||
detectionsEl.innerHTML = detections.map(showDetection).join("");
|
||||
}
|
||||
} catch {
|
||||
// Background may not be ready
|
||||
}
|
||||
}
|
||||
|
||||
checkNumberBtn.addEventListener("click", () => {
|
||||
const phoneNumber = prompt("Enter phone number to check (e.g. +1234567890):");
|
||||
if (phoneNumber) {
|
||||
chrome.runtime.sendMessage(
|
||||
{ type: "CHECK_NUMBER", phoneNumber },
|
||||
(result) => {
|
||||
if (result) {
|
||||
alert(
|
||||
result.isSpam
|
||||
? `⚠️ Spam detected (score: ${result.score})`
|
||||
: "✅ No spam detected",
|
||||
);
|
||||
updateDetections();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
reportSpamBtn.addEventListener("click", () => {
|
||||
const phoneNumber = prompt("Enter phone number to report as spam:");
|
||||
if (phoneNumber) {
|
||||
chrome.runtime.sendMessage(
|
||||
{ type: "CHECK_NUMBER", phoneNumber },
|
||||
() => updateDetections(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
openOptionsLink.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
chrome.runtime.openOptionsPage();
|
||||
});
|
||||
|
||||
updateAuthStatus();
|
||||
updateDetections();
|
||||
44
browser-ext/src/types/index.ts
Normal file
44
browser-ext/src/types/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export interface PhishingResult {
|
||||
isPhishing: boolean;
|
||||
confidence: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface SpamCheckResult {
|
||||
isSpam: boolean;
|
||||
score: number;
|
||||
category: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ExtensionSettings {
|
||||
apiKey: string | null;
|
||||
apiUrl: string;
|
||||
notificationsEnabled: boolean;
|
||||
phishingDetectionEnabled: boolean;
|
||||
autoReportPhishing: boolean;
|
||||
}
|
||||
|
||||
export interface DetectionRecord {
|
||||
id: string;
|
||||
type: "spam_call" | "spam_sms" | "phishing";
|
||||
value: string;
|
||||
timestamp: number;
|
||||
isSpam: boolean;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export type AuthStatus =
|
||||
| { linked: false }
|
||||
| { linked: true; userId: string; email?: string }
|
||||
| { linked: true; apiKey: string };
|
||||
|
||||
export interface LinkDeviceResult {
|
||||
linked: boolean;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export interface ReportResult {
|
||||
reported: boolean;
|
||||
url: string;
|
||||
}
|
||||
23
browser-ext/src/types/trpc.ts
Normal file
23
browser-ext/src/types/trpc.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { AuthStatus, LinkDeviceResult, ReportResult, SpamCheckResult } from "./index";
|
||||
|
||||
export interface TrpcClient {
|
||||
spamshield: {
|
||||
checkNumber: {
|
||||
query: (input: { phoneNumber: string }) => Promise<SpamCheckResult>;
|
||||
};
|
||||
classifySMS: {
|
||||
query: (input: { text: string }) => Promise<SpamCheckResult>;
|
||||
};
|
||||
};
|
||||
extension: {
|
||||
getAuthStatus: {
|
||||
query: (input?: { apiKey?: string }) => Promise<AuthStatus>;
|
||||
};
|
||||
linkDevice: {
|
||||
mutate: (input: { extensionId: string; deviceName?: string }) => Promise<LinkDeviceResult>;
|
||||
};
|
||||
reportPhishing: {
|
||||
mutate: (input: { url: string; source?: string }) => Promise<ReportResult>;
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user