diff --git a/client/cyber_lens/src/App.tsx b/client/cyber_lens/src/App.tsx
index 7195b62..bd3675a 100644
--- a/client/cyber_lens/src/App.tsx
+++ b/client/cyber_lens/src/App.tsx
@@ -11,6 +11,7 @@ import Footer from "./components/Footer";
import Home from "./pages/Home";
import History from "./pages/History";
import News from "./pages/News";
+
import Login from "./pages/Login";
import Signup from "./pages/Signup";
import SentEmail from "./pages/SentEmailConfirmation";
@@ -19,6 +20,8 @@ import RequestPasswordReset from "./pages/RequestPasswordReset";
import ResetPassword from "./pages/ResetPassword";
import Analytics from "./pages/Analytics";
import NewsDetail from "./pages/NewsDetail";
+import Settings from "./pages/Settings";
+import VerifyAction from "./pages/VerifyAction";
const Layout = () => {
return (
@@ -43,6 +46,7 @@ function App() {
} />
} />
} />
+ } />
{/* -------- Auth Pages (NO Navbar / Footer) -------- */}
@@ -50,6 +54,7 @@ function App() {
} />
} />
} />
+ } />
} />
} />
diff --git a/client/cyber_lens/src/components/Navbar.tsx b/client/cyber_lens/src/components/Navbar.tsx
index abf1970..a646a6d 100644
--- a/client/cyber_lens/src/components/Navbar.tsx
+++ b/client/cyber_lens/src/components/Navbar.tsx
@@ -4,7 +4,6 @@ import { useState, useEffect, useRef } from "react";
import { useAuth } from "../hooks/useAuth";
import Avatar from "./Avatar";
-
const Navbar = () => {
const [open, setOpen] = useState(false);
const [profileDropdownOpen, setProfileDropdownOpen] = useState(false);
@@ -14,20 +13,18 @@ const Navbar = () => {
const [isSigningOut, setIsSigningOut] = useState(false);
const handleLogout = () => {
- if (isSigningOut) return;
-
- setIsSigningOut(true);
-
- setTimeout(() => {
- logout();
- setProfileDropdownOpen(false);
- setOpen(false);
- navigate("/");
- setIsSigningOut(false);
- }, 800);
-};
+ if (isSigningOut) return;
+ setIsSigningOut(true);
+ setTimeout(() => {
+ logout();
+ setProfileDropdownOpen(false);
+ setOpen(false);
+ navigate("/");
+ setIsSigningOut(false);
+ }, 800);
+ };
// Close dropdown when clicking outside
useEffect(() => {
@@ -133,10 +130,14 @@ const Navbar = () => {
Profile
-
+ setProfileDropdownOpen(false)}
+ className="w-full px-4 py-2 text-left text-sm text-slate-300 hover:bg-slate-800 transition-colors flex items-center gap-3"
+ >
Settings
-
+
{/* Logout */}
@@ -154,7 +155,6 @@ const Navbar = () => {
{isSigningOut ? "Signing out…" : "Logout"}
-
)}
@@ -181,7 +181,11 @@ const Navbar = () => {
{/* Mobile Menu */}
{open && (
- setOpen(false)} className={linkClass}>
+ setOpen(false)}
+ className={linkClass}
+ >
Home
@@ -202,11 +206,11 @@ const Navbar = () => {
setOpen(false)}
className={linkClass}
>
- Analytics
+ Settings
{isAuthenticated && user ? (
@@ -223,19 +227,19 @@ const Navbar = () => {
-
-
- {isSigningOut ? "Signing out…" : "Logout"}
-
+ >
+
+ {isSigningOut ? "Signing out…" : "Logout"}
+
>
) : (
diff --git a/client/cyber_lens/src/pages/Settings.tsx b/client/cyber_lens/src/pages/Settings.tsx
new file mode 100644
index 0000000..07eeeb2
--- /dev/null
+++ b/client/cyber_lens/src/pages/Settings.tsx
@@ -0,0 +1,378 @@
+import React, { useState } from "react";
+import { httpJson } from "../utils/httpClient";
+
+const Settings: React.FC = () => {
+ // State for Change Password
+ const [currentPassword, setCurrentPassword] = useState("");
+ const [newPassword, setNewPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [pwdLoading, setPwdLoading] = useState(false);
+ const [pwdMessage, setPwdMessage] = useState<{
+ type: "success" | "error";
+ text: string;
+ } | null>(null);
+
+ // State for Delete Account
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
+ const [deleteLoading, setDeleteLoading] = useState(false);
+ const [deleteRequested, setDeleteRequested] = useState(false);
+
+ const handleChangePassword = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setPwdMessage(null);
+
+ // Validation
+ if (newPassword !== confirmPassword) {
+ setPwdMessage({ type: "error", text: "New passwords do not match" });
+ return;
+ }
+
+ if (newPassword === currentPassword) {
+ setPwdMessage({
+ type: "error",
+ text: "New password must be different from current password",
+ });
+ return;
+ }
+
+ if (newPassword.length < 8) {
+ setPwdMessage({
+ type: "error",
+ text: "New password must be at least 8 characters long",
+ });
+ return;
+ }
+
+ setPwdLoading(true);
+ try {
+ await httpJson("/auth/change-password", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ currentPassword, newPassword }),
+ });
+ setPwdMessage({
+ type: "success",
+ text: "Password updated successfully",
+ });
+ setCurrentPassword("");
+ setNewPassword("");
+ setConfirmPassword("");
+ } catch (err) {
+ setPwdMessage({
+ type: "error",
+ text: err instanceof Error ? err.message : "Failed to update password",
+ });
+ } finally {
+ setPwdLoading(false);
+ }
+ };
+
+ const handleRequestDelete = async () => {
+ setDeleteLoading(true);
+ try {
+ await httpJson("/auth/request-delete", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ });
+ setDeleteRequested(true);
+ setShowDeleteModal(false);
+ } catch (err) {
+ alert(err instanceof Error ? err.message : "Failed to request deletion");
+ } finally {
+ setDeleteLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {/* Change Password Section */}
+
+
+ {/* Danger Zone */}
+
+
+
+
+
+
+ Delete Account
+
+
+ Permanently remove your account and all associated data. This
+ action is irreversible. All lookup history and settings will be
+ wiped.
+
+
+ {deleteRequested ? (
+
+
+
+
+
+
+ Verification Required
+
+
+ A magic link has been sent to your inbox. Please click the
+ link within 15 minutes to confirm account deletion.
+
+
+
+ ) : (
+
setShowDeleteModal(true)}
+ className="inline-flex items-center justify-center px-6 py-3 border border-red-800 text-red-500 font-semibold hover:bg-red-500 hover:text-white transition-all text-sm uppercase tracking-wider"
+ >
+ Terminate Account Access
+
+ )}
+
+
+
+
+
+ {/* Delete Confirmation Modal */}
+ {showDeleteModal && (
+
+
+
+
+
+ Confirm Deletion
+
+
+ To prevent accidents, we require email verification. Click below
+ to receive a one-time magic link. Your account stays active until
+ you confirm via the email.
+
+
+
+
+ {deleteLoading ? (
+
+
+
+
+
+ Executing Request...
+
+ ) : (
+ "Dispatch Verification Link"
+ )}
+
+
setShowDeleteModal(false)}
+ className="w-full px-6 py-4 bg-transparent border border-neutral-700 text-neutral-400 font-bold hover:bg-neutral-800 hover:text-neutral-200 transition-all uppercase tracking-widest text-xs"
+ >
+ Abort Action
+
+
+
+
+ )}
+
+ );
+};
+
+export default Settings;
diff --git a/client/cyber_lens/src/pages/VerifyAction.tsx b/client/cyber_lens/src/pages/VerifyAction.tsx
new file mode 100644
index 0000000..70788ea
--- /dev/null
+++ b/client/cyber_lens/src/pages/VerifyAction.tsx
@@ -0,0 +1,133 @@
+import React, { useEffect, useState } from "react";
+import { useSearchParams, useNavigate } from "react-router-dom";
+import { httpJson } from "../utils/httpClient";
+
+const VerifyAction: React.FC = () => {
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const [status, setStatus] = useState<"verifying" | "success" | "error">(
+ "verifying",
+ );
+ const [message, setMessage] = useState("Verifying your security token...");
+
+ useEffect(() => {
+ const token = searchParams.get("token");
+ const type = searchParams.get("type");
+
+ if (!token || !type) {
+ setStatus("error");
+ setMessage("Invalid security link or missing verification data.");
+ return;
+ }
+
+ const performVerification = async () => {
+ try {
+ if (type === "delete_account") {
+ await httpJson("/auth/verify-delete", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token }),
+ });
+ setStatus("success");
+ setMessage(
+ "Your account and all associated data have been permanently removed.",
+ );
+
+ // Clear any local session/identity if applicable
+ // In this architecture, we might want to reset the anonymous-client-id
+ localStorage.removeItem("anonymous-client-id");
+
+ setTimeout(() => navigate("/"), 5000);
+ } else {
+ setStatus("error");
+ setMessage("Unrecognized verification sequence.");
+ }
+ } catch (err) {
+ setStatus("error");
+ setMessage(
+ err instanceof Error ? err.message : "Security verification failed.",
+ );
+ }
+ };
+
+ performVerification();
+ }, [searchParams, navigate]);
+
+ return (
+
+
+
+ {status === "verifying" && (
+
+ )}
+
+ {status === "success" && (
+
+
+
+ Action Confirmed
+
+
{message}
+
+ Redirecting to home page in 5 seconds...
+
+
+ )}
+
+ {status === "error" && (
+
+
+
+ Verification Error
+
+
+ {message}
+
+
navigate("/")}
+ className="w-full py-4 bg-neutral-800 text-neutral-200 font-bold hover:bg-neutral-700 transition-colors uppercase tracking-widest text-xs"
+ >
+ Return to Safety
+
+
+ )}
+
+
+
+ );
+};
+
+export default VerifyAction;
diff --git a/server/src/app.ts b/server/src/app.ts
index 80d0db0..82738e7 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -10,6 +10,7 @@ import resolveRuntimeOwner from "./middleware/resolveRuntimeOwner";
import newsRouter from "./routes/news";
import lookupRouter from "./routes/lookup";
import historyRouter from "./routes/history";
+import authRouter from "./routes/auth";
import analyticsRouter from "./routes/analytics";
import router from "./routes";
import { runNewsScraper } from "./services/newsScraper";
@@ -37,31 +38,16 @@ app.use(resolveOwner);
app.use(authenticateUserOptional);
app.use(resolveRuntimeOwner);
-// TEMPORARY MIDDLEWARE TO PRINT THE OWNER TYPE
-// app.use((req, _res, next) => {
-// const owner = req.owner;
-
-// if (owner?.type === "user") {
-// console.info(`[owner] user ${owner.id}`);
-// } else if (owner?.type === "anonymous") {
-// console.info(`[owner] anonymous ${owner.id}`);
-// } else {
-// console.info("[owner] unresolved");
-// }
-
-// next();
-// });
-
app.get("/", (_req, res) => {
res.json({ status: "ok" });
});
-app.use("/", router);
-app.use("/lookup", lookupRouter);
-app.use("/history", historyRouter);
// Protected routes: require a valid JWT
app.use("/analytics", authenticateUser, requireVerifiedEmail, analyticsRouter);
app.use("/", router);
app.use("/news", newsRouter);
+app.use("/lookup", lookupRouter);
+app.use("/history", historyRouter);
+app.use("/auth", authRouter);
export default app;
diff --git a/server/src/db/migrations/007_create_users_table.sql b/server/src/db/migrations/007_create_users_table.sql
new file mode 100644
index 0000000..48b0398
--- /dev/null
+++ b/server/src/db/migrations/007_create_users_table.sql
@@ -0,0 +1,19 @@
+-- Create users table for platform access
+CREATE TABLE IF NOT EXISTS users (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ email VARCHAR(255) UNIQUE NOT NULL,
+ password_hash VARCHAR(255) NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Create magic_links table for secure account operations (email verification, deletion, etc.)
+CREATE TABLE IF NOT EXISTS magic_links (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID REFERENCES users(id) ON DELETE CASCADE,
+ token_hash VARCHAR(255) NOT NULL,
+ expires_at TIMESTAMP NOT NULL,
+ used_at TIMESTAMP,
+ type VARCHAR(50) NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts
new file mode 100644
index 0000000..bdd0ee4
--- /dev/null
+++ b/server/src/routes/auth.ts
@@ -0,0 +1,150 @@
+import { Router, Request, Response } from "express";
+import pool from "../db";
+import crypto from "crypto";
+import {
+ createMagicLink,
+ sendMagicLinkEmail,
+ verifyMagicLink,
+} from "../utils/magicLink";
+
+const router = Router();
+
+// TODO: Use bcrypt in production
+function hashPassword(password: string): string {
+ return crypto.createHash("sha256").update(password).digest("hex");
+}
+
+
+// POST /auth/change-password
+router.post("/change-password", async (req: Request, res: Response) => {
+ const { currentPassword, newPassword } = req.body;
+ const owner = req.owner;
+
+ if (owner.type !== "user") {
+ res.status(401).json({ error: "Authenticated user account required" });
+ return;
+ }
+
+ try {
+ const userResult = await pool.query(
+ "SELECT password_hash FROM users WHERE id = $1",
+ [owner.id]
+ );
+
+ if (userResult.rows.length === 0) {
+ res.status(404).json({ error: "User profile not found" });
+ return;
+ }
+
+ const { password_hash } = userResult.rows[0];
+
+ if (hashPassword(currentPassword) !== password_hash) {
+ res.status(400).json({ error: "The current password you entered is incorrect" });
+ return;
+ }
+
+ await pool.query(
+ "UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2",
+ [hashPassword(newPassword), owner.id]
+ );
+
+ res.json({ message: "Security credentials updated successfully" });
+ } catch (error) {
+ console.error("Change password failure:", error);
+ res.status(500).json({ error: "An internal error occurred while updating security settings" });
+ }
+});
+
+// POST /auth/request-delete
+router.post("/request-delete", async (req: Request, res: Response) => {
+ const owner = req.owner;
+
+ if (owner.type !== "user") {
+ res.status(401).json({ error: "Authenticated user session required" });
+ return;
+ }
+
+ try {
+ const userResult = await pool.query("SELECT email FROM users WHERE id = $1", [
+ owner.id,
+ ]);
+
+ if (userResult.rows.length === 0) {
+ res.status(404).json({ error: "User identity not found" });
+ return;
+ }
+
+ const { email } = userResult.rows[0];
+ const token = await createMagicLink(owner.id, "delete_account");
+
+ await sendMagicLinkEmail(email, token, "delete_account");
+
+ res.json({ message: "Verification link dispatched to your email" });
+ } catch (error) {
+ console.error("Delete request failure:", error);
+ res.status(500).json({ error: "Failed to initiate account deletion sequence" });
+ }
+});
+
+// POST /auth/verify-delete
+router.post("/verify-delete", async (req: Request, res: Response) => {
+ const { token } = req.body;
+
+ if (!token) {
+ res.status(400).json({ error: "Verification token is missing" });
+ return;
+ }
+
+ try {
+ const userId = await verifyMagicLink(token, "delete_account");
+
+ if (!userId) {
+ res.status(400).json({ error: "The verification link is invalid or has expired" });
+ return;
+ }
+
+ const client = await pool.connect();
+ try {
+ await client.query("BEGIN");
+
+ // Clear all historical data associated with the user
+ await client.query(
+ "DELETE FROM ioc_history WHERE owner_type = 'user' AND owner_id = $1",
+ [userId]
+ );
+
+ // Finalize account termination
+ await client.query("DELETE FROM users WHERE id = $1", [userId]);
+
+ await client.query("COMMIT");
+ res.json({ message: "Account and associated history terminated successfully" });
+ } catch (transactionError) {
+ await client.query("ROLLBACK");
+ throw transactionError;
+ } finally {
+ client.release();
+ }
+ } catch (error) {
+ console.error("Delete verification failure:", error);
+ res.status(500).json({ error: "A system error occurred during account termination" });
+ }
+});
+
+/**
+ * MOCK SIGNUP (For Testing Purposes)
+ * Allows creation of a test user to verify Settings functionality
+ */
+router.post("/signup", async (req: Request, res: Response) => {
+ const { email, password } = req.body;
+ try {
+ const result = await pool.query(
+ "INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id",
+ [email, hashPassword(password)]
+ );
+ res.json({ success: true, userId: result.rows[0].id });
+ } catch (error) {
+ res.status(400).json({ error: "User already exists or invalid data" });
+ }
+});
+
+export default router;
diff --git a/server/src/utils/magicLink.ts b/server/src/utils/magicLink.ts
new file mode 100644
index 0000000..56db327
--- /dev/null
+++ b/server/src/utils/magicLink.ts
@@ -0,0 +1,82 @@
+import crypto from "crypto";
+import pool from "../db";
+
+// Generate secure random token
+export function generateSecureToken(): string {
+ return crypto.randomBytes(32).toString("hex");
+}
+
+
+// Hash token for storage
+export function hashToken(token: string): string {
+ return crypto.createHash("sha256").update(token).digest("hex");
+}
+
+
+/**
+ * Creates and stores a magic link in the database
+ */
+export async function createMagicLink(
+ userId: string,
+ type: "delete_account" | "verify_email",
+ expiresInMinutes = 15
+): Promise {
+ const token = generateSecureToken();
+ const tokenHash = hashToken(token);
+ const expiresAt = new Date();
+ expiresAt.setMinutes(expiresAt.getMinutes() + expiresInMinutes);
+
+ await pool.query(
+ "INSERT INTO magic_links (user_id, token_hash, expires_at, type) VALUES ($1, $2, $3, $4)",
+ [userId, tokenHash, expiresAt, type]
+ );
+
+ return token;
+}
+
+/**
+ * Validates a magic link token and returns the associated userId
+ */
+export async function verifyMagicLink(
+ token: string,
+ type: string
+): Promise {
+ const tokenHash = hashToken(token);
+
+ const result = await pool.query(
+ "SELECT user_id FROM magic_links WHERE token_hash = $1 AND type = $2 AND expires_at > NOW() AND used_at IS NULL",
+ [tokenHash, type]
+ );
+
+ if (result.rows.length === 0) {
+ return null;
+ }
+
+ const userId = result.rows[0].user_id;
+
+ // Atomically mark token as used to prevent replay attacks
+ await pool.query(
+ "UPDATE magic_links SET used_at = NOW() WHERE token_hash = $1",
+ [tokenHash]
+ );
+
+ return userId;
+}
+
+// Mock email sender - logs to console only
+export async function sendMagicLinkEmail(
+ email: string,
+ token: string,
+ type: string
+): Promise {
+ const baseUrl = process.env.CORS_ORIGIN || "http://localhost:5173";
+ // The link points to a verification endpoint that will handle the logic
+ const link = `${baseUrl}/verify-action?token=${token}&type=${type}`;
+
+ console.log("\n--- [INTERNAL MAIL SERVICE] ---");
+ console.log(`Target: ${email}`);
+ console.log(`Action: ${type.toUpperCase()}`);
+ console.log(`Verification URL: ${link}`);
+ console.log("Validity: 15 minutes (Single Use Only)");
+ console.log("-------------------------------\n");
+}
diff --git a/server/src/utils/resolveOwner.ts b/server/src/utils/resolveOwner.ts
index 8a92f52..71e490b 100644
--- a/server/src/utils/resolveOwner.ts
+++ b/server/src/utils/resolveOwner.ts
@@ -40,8 +40,16 @@ async function resolveOwner(
}
try {
- await ensureAnonymousClient(clientId);
- req.owner = { type: "anonymous", id: clientId };
+ // Check if this is a registered user
+ const userResult = await pool.query("SELECT id FROM users WHERE id = $1", [clientId]);
+
+ if (userResult.rows.length > 0) {
+ req.owner = { type: "user", id: clientId };
+ } else {
+ // Fallback to anonymous client logic
+ await ensureAnonymousClient(clientId);
+ req.owner = { type: "anonymous", id: clientId };
+ }
next();
} catch (error) {
console.error("Failed to resolve owner", error);