diff --git a/apps/api/package.json b/apps/api/package.json index 25fa0e90e..16556de8f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "node src/server.js", "start": "node src/server.js", - "test": "node --test src/tests" + "test": "node --test src/tests/*.test.js" }, "dependencies": { "cors": "^2.8.5", diff --git a/apps/api/src/controllers/adminController.js b/apps/api/src/controllers/adminController.js index b1239568d..6989f3b3d 100644 --- a/apps/api/src/controllers/adminController.js +++ b/apps/api/src/controllers/adminController.js @@ -1,6 +1,63 @@ import { ok } from "../utils/response.js"; -import { getAdminMetrics } from "../services/adminService.js"; +import { + decideListing, + getAdminMetrics, + getAuditLog, + getControls, + getDisputeDetail, + getDisputes, + getModerationQueue, + getUserDetail, + getUsers, + ruleDispute, + updateControl, + updateUserStatus +} from "../services/adminService.js"; export async function metrics(req, res) { return ok(res, await getAdminMetrics()); } + +export async function users(req, res) { + return ok(res, await getUsers(req.query)); +} + +export async function userDetail(req, res) { + return ok(res, await getUserDetail(req.params.id)); +} + +export async function userStatus(req, res) { + return ok(res, await updateUserStatus(req.params.id, req.body, req.user)); +} + +export async function moderation(req, res) { + return ok(res, await getModerationQueue(req.query)); +} + +export async function moderationDecision(req, res) { + return ok(res, await decideListing(req.params.id, req.body, req.user)); +} + +export async function disputes(req, res) { + return ok(res, await getDisputes(req.query)); +} + +export async function disputeDetail(req, res) { + return ok(res, await getDisputeDetail(req.params.id)); +} + +export async function disputeRuling(req, res) { + return ok(res, await ruleDispute(req.params.id, req.body, req.user)); +} + +export async function controls(req, res) { + return ok(res, await getControls()); +} + +export async function platformControl(req, res) { + return ok(res, await updateControl(req.params.key, req.body, req.user)); +} + +export async function auditLog(req, res) { + return ok(res, await getAuditLog(req.query)); +} diff --git a/apps/api/src/middleware/auth.js b/apps/api/src/middleware/auth.js index 445a71951..e875ff4e6 100644 --- a/apps/api/src/middleware/auth.js +++ b/apps/api/src/middleware/auth.js @@ -14,3 +14,11 @@ export function authMiddleware(req, res, next) { return fail(res, "Invalid token", 401); } } + +export function requireAdmin(req, res, next) { + if (req.user?.role !== "admin") { + return fail(res, "Forbidden: admin role required", 403); + } + + return next(); +} diff --git a/apps/api/src/routes/adminRoutes.js b/apps/api/src/routes/adminRoutes.js index 4c1da76f9..0e9b1a8ac 100644 --- a/apps/api/src/routes/adminRoutes.js +++ b/apps/api/src/routes/adminRoutes.js @@ -1,8 +1,33 @@ import { Router } from "express"; -import { metrics } from "../controllers/adminController.js"; -import { authMiddleware } from "../middleware/auth.js"; +import { + auditLog, + controls, + disputeDetail, + disputeRuling, + disputes, + metrics, + moderation, + moderationDecision, + platformControl, + userDetail, + userStatus, + users +} from "../controllers/adminController.js"; +import { authMiddleware, requireAdmin } from "../middleware/auth.js"; export const adminRoutes = Router(); adminRoutes.use(authMiddleware); +adminRoutes.use(requireAdmin); adminRoutes.get("/metrics", metrics); +adminRoutes.get("/users", users); +adminRoutes.get("/users/:id", userDetail); +adminRoutes.post("/users/:id/status", userStatus); +adminRoutes.get("/moderation", moderation); +adminRoutes.post("/moderation/:id/decision", moderationDecision); +adminRoutes.get("/disputes", disputes); +adminRoutes.get("/disputes/:id", disputeDetail); +adminRoutes.post("/disputes/:id/ruling", disputeRuling); +adminRoutes.get("/controls", controls); +adminRoutes.post("/controls/:key", platformControl); +adminRoutes.get("/audit", auditLog); diff --git a/apps/api/src/services/adminService.js b/apps/api/src/services/adminService.js index 9075111aa..b39558254 100644 --- a/apps/api/src/services/adminService.js +++ b/apps/api/src/services/adminService.js @@ -1,8 +1,321 @@ +const users = [ + { + id: "usr_admin", + email: "admin@freelanceflow.test", + name: "Admin Operator", + role: "admin", + status: "active", + joinedAt: "2026-01-04T10:00:00.000Z", + trustScore: 96, + activeJobs: [], + disputeHistory: [] + }, + { + id: "usr_client_1", + email: "client@acme.test", + name: "Acme Client", + role: "client", + status: "active", + joinedAt: "2026-02-12T11:00:00.000Z", + trustScore: 82, + activeJobs: ["job_flagged_1", "job_live_2"], + disputeHistory: ["dsp_1"] + }, + { + id: "usr_freelancer_1", + email: "maya@freelance.test", + name: "Maya Dev", + role: "freelancer", + status: "active", + joinedAt: "2026-03-01T09:30:00.000Z", + trustScore: 91, + activeJobs: ["job_live_2"], + disputeHistory: ["dsp_1"] + }, + { + id: "usr_client_2", + email: "safety@market.test", + name: "Market Safety Client", + role: "client", + status: "suspended", + joinedAt: "2026-04-08T16:45:00.000Z", + trustScore: 47, + activeJobs: ["job_flagged_2"], + disputeHistory: ["dsp_2"] + } +]; + +const moderationQueue = [ + { + id: "mod_1", + jobId: "job_flagged_1", + title: "Scrape private freelancer contact lists", + postingUserId: "usr_client_1", + status: "flagged", + reason: "Automated privacy-risk classifier matched disallowed scraping language.", + reports: 3, + flaggedAt: "2026-05-20T14:20:00.000Z" + }, + { + id: "mod_2", + jobId: "job_flagged_2", + title: "Payment dispute recovery specialist", + postingUserId: "usr_client_2", + status: "escalated", + reason: "Multiple user reports mention off-platform payment requests.", + reports: 5, + flaggedAt: "2026-05-21T08:10:00.000Z" + } +]; + +const disputes = [ + { + id: "dsp_1", + jobId: "job_live_2", + freelancerId: "usr_freelancer_1", + clientId: "usr_client_1", + status: "open", + amount: 2400, + transactionId: "txn_7781", + openedAt: "2026-05-22T13:00:00.000Z", + thread: [ + { authorId: "usr_client_1", body: "Milestone was delivered late.", createdAt: "2026-05-22T13:04:00.000Z" }, + { authorId: "usr_freelancer_1", body: "Scope changed after approval; evidence attached.", createdAt: "2026-05-22T13:22:00.000Z" } + ], + evidence: [ + { id: "ev_1", type: "screenshot", label: "Approved milestone checklist" }, + { id: "ev_2", type: "message", label: "Client scope-change request" } + ] + }, + { + id: "dsp_2", + jobId: "job_flagged_2", + freelancerId: "usr_freelancer_1", + clientId: "usr_client_2", + status: "under_review", + amount: 875, + transactionId: "txn_7794", + openedAt: "2026-05-23T09:15:00.000Z", + thread: [ + { authorId: "usr_freelancer_1", body: "Client requested off-platform refund routing.", createdAt: "2026-05-23T09:18:00.000Z" } + ], + evidence: [ + { id: "ev_3", type: "message", label: "Off-platform payment request" } + ] + } +]; + +const controls = { + registrations: { key: "registrations", label: "New user registrations", enabled: true }, + jobPostings: { key: "jobPostings", label: "New job postings", enabled: true } +}; + +const notifications = []; + +const auditLog = [ + { + id: "aud_1", + adminId: "usr_admin", + actionType: "system.seeded", + targetType: "admin_panel", + targetId: "initial-state", + message: "Initial admin panel audit state loaded.", + createdAt: "2026-05-24T08:00:00.000Z" + } +]; + export async function getAdminMetrics() { + const flaggedListings = moderationQueue.filter((item) => item.status !== "approved").length; + const openDisputes = disputes.filter((dispute) => dispute.status !== "resolved").length; + return { - openJobs: 42, - activeFreelancers: 185, - flaggedAccounts: 3, - monthlyVolume: 128900 + totalUsers: users.length, + activeJobs: 2, + openDisputes, + flaggedListings, + revenueCurrentPeriod: 128900, + trustDistribution: [ + { range: "0-49", count: users.filter((user) => user.trustScore < 50).length }, + { range: "50-79", count: users.filter((user) => user.trustScore >= 50 && user.trustScore < 80).length }, + { range: "80-100", count: users.filter((user) => user.trustScore >= 80).length } + ] }; } + +export async function getUsers(query = {}) { + const filtered = users.filter((user) => { + const search = String(query.search ?? "").toLowerCase(); + const matchesSearch = !search || [user.email, user.name, user.id].some((value) => value.toLowerCase().includes(search)); + const matchesRole = !query.role || user.role === query.role; + const matchesStatus = !query.status || user.status === query.status; + const joinedAt = new Date(user.joinedAt).getTime(); + const joinedAfter = query.joinedAfter ? new Date(query.joinedAfter).getTime() : null; + const joinedBefore = query.joinedBefore ? new Date(query.joinedBefore).getTime() : null; + + return matchesSearch + && matchesRole + && matchesStatus + && (joinedAfter === null || joinedAt >= joinedAfter) + && (joinedBefore === null || joinedAt <= joinedBefore); + }); + + return paginate(filtered, query); +} + +export async function getUserDetail(id) { + const user = findById(users, id, "User"); + return { + ...user, + activeJobs: user.activeJobs.map((jobId) => ({ id: jobId, title: jobTitle(jobId) })), + disputeHistory: disputes.filter((dispute) => user.disputeHistory.includes(dispute.id)) + }; +} + +export async function updateUserStatus(id, payload = {}, admin) { + const allowed = new Set(["active", "suspended", "banned"]); + if (!allowed.has(payload.status)) { + throw new Error("status must be active, suspended, or banned"); + } + + const user = findById(users, id, "User"); + user.status = payload.status; + writeAudit(admin, "user.status", "user", id, `${user.email} set to ${payload.status}. ${payload.reason ?? ""}`.trim()); + return user; +} + +export async function getModerationQueue(query = {}) { + const filtered = moderationQueue.filter((item) => !query.status || item.status === query.status); + return paginate(filtered, query); +} + +export async function decideListing(id, payload = {}, admin) { + const allowed = new Set(["approved", "rejected", "escalated"]); + if (!allowed.has(payload.decision)) { + throw new Error("decision must be approved, rejected, or escalated"); + } + + const item = findById(moderationQueue, id, "Moderation item"); + item.status = payload.decision; + + if (payload.decision === "rejected") { + notifications.push({ + id: `ntf_${notifications.length + 1}`, + userId: item.postingUserId, + type: "listing_rejected", + message: payload.reason ?? "Your listing was rejected by moderation.", + createdAt: new Date().toISOString() + }); + } + + writeAudit(admin, "listing.decision", "job", item.jobId, `${item.title} marked ${payload.decision}. ${payload.reason ?? ""}`.trim()); + return item; +} + +export async function getDisputes(query = {}) { + const filtered = disputes.filter((dispute) => !query.status || dispute.status === query.status); + return paginate(filtered, query); +} + +export async function getDisputeDetail(id) { + return findById(disputes, id, "Dispute"); +} + +export async function ruleDispute(id, payload = {}, admin) { + const allowed = new Set(["client", "freelancer", "refund", "escalate"]); + if (!allowed.has(payload.ruling)) { + throw new Error("ruling must be client, freelancer, refund, or escalate"); + } + + const dispute = findById(disputes, id, "Dispute"); + dispute.status = payload.ruling === "escalate" ? "under_review" : "resolved"; + dispute.ruling = payload.ruling; + dispute.rulingReason = payload.reason ?? ""; + dispute.resolvedAt = payload.ruling === "escalate" ? null : new Date().toISOString(); + + notifications.push( + { + id: `ntf_${notifications.length + 1}`, + userId: dispute.clientId, + type: "dispute_ruling", + message: `Dispute ${dispute.id} updated: ${payload.ruling}.`, + createdAt: new Date().toISOString() + }, + { + id: `ntf_${notifications.length + 1}`, + userId: dispute.freelancerId, + type: "dispute_ruling", + message: `Dispute ${dispute.id} updated: ${payload.ruling}.`, + createdAt: new Date().toISOString() + } + ); + + writeAudit(admin, "dispute.ruling", "dispute", id, `Ruling ${payload.ruling}. ${payload.reason ?? ""}`.trim()); + return dispute; +} + +export async function getControls() { + return Object.values(controls); +} + +export async function updateControl(key, payload = {}, admin) { + if (!Object.hasOwn(controls, key)) { + throw new Error("Unknown platform control"); + } + + controls[key].enabled = Boolean(payload.enabled); + writeAudit(admin, "platform.control", "control", key, `${controls[key].label} set to ${controls[key].enabled}`); + return controls[key]; +} + +export async function getAuditLog(query = {}) { + const filtered = auditLog.filter((entry) => { + const createdAt = new Date(entry.createdAt).getTime(); + const from = query.from ? new Date(query.from).getTime() : null; + const to = query.to ? new Date(query.to).getTime() : null; + + return (!query.adminId || entry.adminId === query.adminId) + && (!query.actionType || entry.actionType === query.actionType) + && (from === null || createdAt >= from) + && (to === null || createdAt <= to); + }); + + return paginate(filtered.toReversed(), query); +} + +function paginate(items, query = {}) { + const page = Math.max(Number(query.page ?? 1), 1); + const pageSize = Math.min(Math.max(Number(query.pageSize ?? 10), 1), 50); + const start = (page - 1) * pageSize; + + return { + page, + pageSize, + total: items.length, + totalPages: Math.max(Math.ceil(items.length / pageSize), 1), + items: items.slice(start, start + pageSize) + }; +} + +function findById(items, id, label) { + const item = items.find((entry) => entry.id === id); + if (!item) { + throw new Error(`${label} not found`); + } + return item; +} + +function writeAudit(admin, actionType, targetType, targetId, message) { + auditLog.push({ + id: `aud_${auditLog.length + 1}`, + adminId: admin?.sub ?? "unknown_admin", + actionType, + targetType, + targetId, + message, + createdAt: new Date().toISOString() + }); +} + +function jobTitle(jobId) { + return moderationQueue.find((item) => item.jobId === jobId)?.title ?? "Active marketplace job"; +} diff --git a/apps/api/src/tests/admin.test.js b/apps/api/src/tests/admin.test.js new file mode 100644 index 000000000..92f0167c6 --- /dev/null +++ b/apps/api/src/tests/admin.test.js @@ -0,0 +1,96 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createApp } from "../app.js"; +import { signAccessToken } from "../utils/jwt.js"; + +async function withServer(run) { + const app = createApp(); + const server = app.listen(0); + + await new Promise((resolve, reject) => { + server.once("listening", resolve); + server.once("error", reject); + }); + + const { port } = server.address(); + + try { + await run(`http://127.0.0.1:${port}`); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +} + +function token(role) { + return signAccessToken({ sub: `usr_${role}`, role }); +} + +function adminHeaders() { + return { + authorization: `Bearer ${token("admin")}`, + "content-type": "application/json" + }; +} + +test("admin routes require an authenticated admin role", async () => { + await withServer(async (baseUrl) => { + const missingAuth = await fetch(`${baseUrl}/api/admin/metrics`); + assert.equal(missingAuth.status, 401); + + const clientAuth = await fetch(`${baseUrl}/api/admin/metrics`, { + headers: { authorization: `Bearer ${token("client")}` } + }); + const payload = await clientAuth.json(); + + assert.equal(clientAuth.status, 403); + assert.equal(payload.message, "Forbidden: admin role required"); + }); +}); + +test("admin metrics and paginated users expose moderation context", async () => { + await withServer(async (baseUrl) => { + const metricsResponse = await fetch(`${baseUrl}/api/admin/metrics`, { headers: adminHeaders() }); + const metrics = await metricsResponse.json(); + assert.equal(metricsResponse.status, 200); + assert.equal(metrics.data.totalUsers, 4); + assert.equal(metrics.data.openDisputes, 2); + assert.equal(metrics.data.trustDistribution.length, 3); + + const usersResponse = await fetch(`${baseUrl}/api/admin/users?role=client&page=1&pageSize=1`, { headers: adminHeaders() }); + const users = await usersResponse.json(); + assert.equal(usersResponse.status, 200); + assert.equal(users.data.pageSize, 1); + assert.equal(users.data.total, 2); + assert.equal(users.data.items[0].role, "client"); + }); +}); + +test("admin actions update state and append audit entries", async () => { + await withServer(async (baseUrl) => { + const statusResponse = await fetch(`${baseUrl}/api/admin/users/usr_client_1/status`, { + method: "POST", + headers: adminHeaders(), + body: JSON.stringify({ status: "suspended", reason: "manual review" }) + }); + const status = await statusResponse.json(); + assert.equal(statusResponse.status, 200); + assert.equal(status.data.status, "suspended"); + + const controlResponse = await fetch(`${baseUrl}/api/admin/controls/jobPostings`, { + method: "POST", + headers: adminHeaders(), + body: JSON.stringify({ enabled: false }) + }); + const control = await controlResponse.json(); + assert.equal(controlResponse.status, 200); + assert.equal(control.data.enabled, false); + + const auditResponse = await fetch(`${baseUrl}/api/admin/audit?actionType=platform.control`, { headers: adminHeaders() }); + const audit = await auditResponse.json(); + assert.equal(auditResponse.status, 200); + assert.equal(audit.data.total, 1); + assert.equal(audit.data.items[0].targetId, "jobPostings"); + }); +}); diff --git a/apps/web/app/admin/forbidden/page.tsx b/apps/web/app/admin/forbidden/page.tsx new file mode 100644 index 000000000..8d11ba7da --- /dev/null +++ b/apps/web/app/admin/forbidden/page.tsx @@ -0,0 +1,13 @@ +export default function AdminForbiddenPage() { + return ( +
+
+

403

+

Admin access required

+

+ The admin console route is guarded. Set an authenticated admin session before opening this page. +

+
+
+ ); +} diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx index 9d251466f..69e7cf888 100644 --- a/apps/web/app/admin/page.tsx +++ b/apps/web/app/admin/page.tsx @@ -1,8 +1,492 @@ +"use client"; + +import { useState } from "react"; + +type User = { + id: string; + name: string; + email: string; + role: "admin" | "client" | "freelancer"; + status: "active" | "suspended" | "banned"; + joinedAt: string; + trustScore: number; + activeJobs: string[]; + disputes: string[]; +}; + +type ModerationItem = { + id: string; + title: string; + status: "flagged" | "approved" | "rejected" | "escalated"; + reports: number; + reason: string; + owner: string; +}; + +type Dispute = { + id: string; + status: "open" | "under_review" | "resolved"; + amount: number; + parties: string; + transactionId: string; + evidence: string[]; + thread: string[]; +}; + +type AuditEntry = { + id: string; + adminId: string; + actionType: string; + targetId: string; + message: string; + createdAt: string; +}; + +const seedUsers: User[] = [ + { + id: "usr_admin", + name: "Admin Operator", + email: "admin@freelanceflow.test", + role: "admin", + status: "active", + joinedAt: "2026-01-04", + trustScore: 96, + activeJobs: [], + disputes: [] + }, + { + id: "usr_client_1", + name: "Acme Client", + email: "client@acme.test", + role: "client", + status: "active", + joinedAt: "2026-02-12", + trustScore: 82, + activeJobs: ["job_flagged_1", "job_live_2"], + disputes: ["dsp_1"] + }, + { + id: "usr_freelancer_1", + name: "Maya Dev", + email: "maya@freelance.test", + role: "freelancer", + status: "active", + joinedAt: "2026-03-01", + trustScore: 91, + activeJobs: ["job_live_2"], + disputes: ["dsp_1", "dsp_2"] + }, + { + id: "usr_client_2", + name: "Market Safety Client", + email: "safety@market.test", + role: "client", + status: "suspended", + joinedAt: "2026-04-08", + trustScore: 47, + activeJobs: ["job_flagged_2"], + disputes: ["dsp_2"] + } +]; + +const seedModeration: ModerationItem[] = [ + { + id: "mod_1", + title: "Scrape private freelancer contact lists", + status: "flagged", + reports: 3, + reason: "Automated privacy-risk classifier matched disallowed scraping language.", + owner: "Acme Client" + }, + { + id: "mod_2", + title: "Payment dispute recovery specialist", + status: "escalated", + reports: 5, + reason: "Multiple user reports mention off-platform payment requests.", + owner: "Market Safety Client" + } +]; + +const seedDisputes: Dispute[] = [ + { + id: "dsp_1", + status: "open", + amount: 2400, + parties: "Acme Client / Maya Dev", + transactionId: "txn_7781", + evidence: ["Approved milestone checklist", "Client scope-change request"], + thread: ["Client says delivery was late.", "Freelancer says scope changed after approval."] + }, + { + id: "dsp_2", + status: "under_review", + amount: 875, + parties: "Market Safety Client / Maya Dev", + transactionId: "txn_7794", + evidence: ["Off-platform payment request"], + thread: ["Freelancer reports off-platform refund routing."] + } +]; + +const seedAudit: AuditEntry[] = [ + { + id: "aud_1", + adminId: "usr_admin", + actionType: "system.seeded", + targetId: "initial-state", + message: "Initial admin panel audit state loaded.", + createdAt: "2026-05-24T08:00:00.000Z" + } +]; + export default function AdminPanelPage() { + const [users, setUsers] = useState(seedUsers); + const [moderation, setModeration] = useState(seedModeration); + const [disputes, setDisputes] = useState(seedDisputes); + const [audit, setAudit] = useState(seedAudit); + const [registrationsEnabled, setRegistrationsEnabled] = useState(true); + const [jobPostingsEnabled, setJobPostingsEnabled] = useState(true); + const [selectedUserId, setSelectedUserId] = useState(seedUsers[1].id); + const [roleFilter, setRoleFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + const [auditFilter, setAuditFilter] = useState("all"); + const [search, setSearch] = useState(""); + const [lastRefresh, setLastRefresh] = useState(new Date().toISOString()); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const filteredUsers = users.filter((user) => { + const text = `${user.name} ${user.email} ${user.id}`.toLowerCase(); + return (roleFilter === "all" || user.role === roleFilter) + && (statusFilter === "all" || user.status === statusFilter) + && (!search || text.includes(search.toLowerCase())); + }); + const filteredAudit = audit.filter((entry) => auditFilter === "all" || entry.actionType === auditFilter); + const selectedUser = users.find((user) => user.id === selectedUserId) ?? users[0]; + const openDisputes = disputes.filter((dispute) => dispute.status !== "resolved").length; + const flaggedListings = moderation.filter((item) => item.status !== "approved").length; + const revenue = disputes.reduce((sum, dispute) => sum + dispute.amount, 0); + + function writeAudit(actionType: string, targetId: string, message: string) { + setAudit((entries) => [ + { + id: `aud_${entries.length + 1}`, + adminId: "usr_admin", + actionType, + targetId, + message, + createdAt: new Date().toISOString() + }, + ...entries + ]); + } + + function refreshData() { + setLoading(true); + setError(""); + window.setTimeout(() => { + setLastRefresh(new Date().toISOString()); + setLoading(false); + }, 250); + } + + function updateUserStatus(id: string, status: User["status"]) { + setUsers((items) => items.map((user) => (user.id === id ? { ...user, status } : user))); + writeAudit("user.status", id, `User status set to ${status}`); + } + + function decideListing(id: string, status: ModerationItem["status"]) { + const reason = status === "rejected" ? window.prompt("Reason for rejection", "Policy violation confirmed") : ""; + if (status === "rejected" && !reason) { + setError("Rejected listings require a reason."); + return; + } + + setModeration((items) => items.map((item) => (item.id === id ? { ...item, status } : item))); + writeAudit("listing.decision", id, `Listing marked ${status}${reason ? `: ${reason}` : ""}`); + } + + function ruleDispute(id: string, ruling: string) { + const reason = window.prompt("Ruling reason", "Evidence reviewed by admin"); + if (!reason) { + setError("Dispute rulings require a reason."); + return; + } + + setDisputes((items) => items.map((dispute) => ( + dispute.id === id + ? { ...dispute, status: ruling === "escalate" ? "under_review" : "resolved" } + : dispute + ))); + writeAudit("dispute.ruling", id, `Dispute ruling: ${ruling}. ${reason}`); + } + + function toggleControl(key: "registrations" | "jobPostings") { + const label = key === "registrations" ? "new user registrations" : "new job postings"; + if (!window.confirm(`Change platform control for ${label}?`)) { + return; + } + + if (key === "registrations") { + setRegistrationsEnabled((value) => !value); + writeAudit("platform.control", key, `Changed ${label}`); + } else { + setJobPostingsEnabled((value) => !value); + writeAudit("platform.control", key, `Changed ${label}`); + } + } + return ( -
-

Admin Panel

-

Moderation queues, trust metrics, and platform controls are available here.

+
+
+
+

Seed data from the admin mock service

+

Admin Operations Panel

+

+ User controls, job moderation, dispute rulings, platform switches, trust metrics, and append-only audit events + are grouped into independent sections. +

+
+
+ Last refresh + {new Date(lastRefresh).toLocaleString()} + +
+
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+ + + + + +
+ +
+
+

User Management

+ Server-side route: GET /api/admin/users +
+
+ + + +
+ {filteredUsers.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + {filteredUsers.map((user) => ( + + + + + + + + + ))} + +
NameRoleStatusJoinedTrustActions
+ + {user.email} + {user.role}{user.status}{user.joinedAt}{user.trustScore} + + + +
+
+ )} + +
+ +
+
+
+

Job Moderation

+ Paginated queue from /api/admin/moderation +
+ {moderation.map((item) => ( +
+

{item.title}

+

{item.reason}

+
+
Owner
+
{item.owner}
+
Status
+
{item.status}
+
Reports
+
{item.reports}
+
+
+ + + +
+
+ ))} +
+ +
+
+

Dispute Resolution

+ Thread, evidence, transaction detail +
+ {disputes.map((dispute) => ( +
+

{dispute.id}: {dispute.parties}

+

Transaction {dispute.transactionId}, ${dispute.amount.toLocaleString()}, status {dispute.status}

+

Evidence: {dispute.evidence.join(", ")}

+

Thread: {dispute.thread.join(" / ")}

+
+ + + + +
+
+ ))} +
+
+ +
+
+
+

Trust Distribution

+ Current seed population +
+ {[ + ["0-49", users.filter((user) => user.trustScore < 50).length], + ["50-79", users.filter((user) => user.trustScore >= 50 && user.trustScore < 80).length], + ["80-100", users.filter((user) => user.trustScore >= 80).length] + ].map(([range, count]) => ( +
+ {range} +
+ {count} +
+ ))} +
+ +
+
+

Platform Controls

+ Confirmation required before changes +
+ + +
+
+ +
+
+

Audit Log

+ Append-only entries for admin actions +
+
+ +
+ {filteredAudit.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {filteredAudit.map((entry) => ( + + + + + + + + ))} + +
TimeAdminActionTargetMessage
{new Date(entry.createdAt).toLocaleString()}{entry.adminId}{entry.actionType}{entry.targetId}{entry.message}
+
+ )} +
); } + +function MetricCard({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function EmptyState({ label }: { label: string }) { + return

{label}

; +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 40e22f13c..9690f5c1a 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -4,3 +4,232 @@ a { color: inherit; text-decoration: none; } main { max-width: 960px; margin: 0 auto; padding: 2rem 1rem; } .card { background: #151c35; border: 1px solid #2a3765; border-radius: 12px; padding: 1rem; margin-bottom: 1rem; } .grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); } + +.admin-ops { + --admin-bg: #000000; + --admin-panel: #0B0C0A; + --admin-line: #263024; + --admin-text: #E9F4E6; + --admin-muted: #98A494; + --admin-signal: #00E676; + background: var(--admin-bg); + border: 1px solid var(--admin-line); + color: var(--admin-text); + font-family: "IBM Plex Mono", "JetBrains Mono", "Courier New", monospace; + font-variant-numeric: tabular-nums; + padding: 1rem; +} + +.admin-ops button, +.admin-ops input, +.admin-ops select { + background: #000000; + border: 1px solid var(--admin-line); + color: var(--admin-text); + font: inherit; + padding: 0.55rem 0.7rem; +} + +.admin-ops button:focus-visible, +.admin-ops input:focus-visible, +.admin-ops select:focus-visible { + outline: 2px solid var(--admin-signal); + outline-offset: 2px; +} + +.admin-hero, +.admin-panel, +.metric-card, +.detail-card, +.queue-card, +.admin-alert { + background: var(--admin-panel); + border: 1px solid var(--admin-line); +} + +.admin-hero { + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1fr) minmax(220px, 280px); + margin-bottom: 1rem; + padding: 1rem; +} + +.admin-hero h2 { + font-size: clamp(2rem, 7vw, 5.8rem); + letter-spacing: -0.08em; + line-height: 0.86; + margin: 0; + text-transform: uppercase; +} + +.admin-kicker, +.section-heading span, +.metric-card span, +.admin-refresh span, +.admin-ops td span { + color: var(--admin-muted); +} + +.admin-refresh { + align-content: start; + display: grid; + gap: 0.65rem; +} + +.admin-grid { + display: grid; + gap: 1rem; +} + +.metrics-grid { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + margin-bottom: 1rem; +} + +.two-column { + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + margin-bottom: 1rem; +} + +.metric-card, +.admin-panel, +.detail-card, +.queue-card, +.admin-alert { + padding: 1rem; +} + +.metric-card strong { + color: var(--admin-signal); + display: block; + font-size: 1.8rem; + margin-top: 0.35rem; +} + +.section-heading { + align-items: baseline; + border-bottom: 1px solid var(--admin-line); + display: flex; + gap: 1rem; + justify-content: space-between; + margin-bottom: 1rem; + padding-bottom: 0.75rem; +} + +.section-heading h3, +.queue-card h4, +.detail-card h4 { + margin: 0; + text-transform: uppercase; +} + +.admin-controls-row, +.button-cluster { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + margin-bottom: 1rem; +} + +.admin-controls-row label { + color: var(--admin-muted); + display: grid; + gap: 0.3rem; +} + +.table-shell { + overflow-x: auto; +} + +.admin-ops table { + border-collapse: collapse; + min-width: 760px; + width: 100%; +} + +.admin-ops th, +.admin-ops td { + border-bottom: 1px solid var(--admin-line); + padding: 0.65rem; + text-align: left; + vertical-align: top; +} + +.admin-ops th { + color: var(--admin-signal); + text-transform: uppercase; +} + +.admin-ops td:first-child { + display: grid; + gap: 0.25rem; +} + +.link-button { + border-color: transparent !important; + padding: 0 !important; + text-align: left; + text-decoration: underline; +} + +.detail-card, +.queue-card { + margin-top: 1rem; +} + +.queue-card dl { + display: grid; + gap: 0.35rem 0.75rem; + grid-template-columns: max-content 1fr; +} + +.queue-card dt { + color: var(--admin-muted); +} + +.queue-card dd { + margin: 0; +} + +.bar-row { + align-items: center; + display: grid; + gap: 0.75rem; + grid-template-columns: 72px 1fr 32px; + margin: 0.8rem 0; +} + +.bar-row div { + border: 1px solid var(--admin-line); + height: 18px; +} + +.bar-row i { + background: var(--admin-signal); + display: block; + height: 100%; +} + +.control-toggle { + display: block; + margin-bottom: 0.75rem; + text-align: left; + width: 100%; +} + +.empty-state, +.admin-alert { + color: var(--admin-signal); +} + +@media (max-width: 700px) { + .admin-hero { + grid-template-columns: 1fr; + } + + .admin-controls-row, + .button-cluster { + display: grid; + } +} diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts new file mode 100644 index 000000000..c5ed9dc97 --- /dev/null +++ b/apps/web/proxy.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; + +export function proxy(request: NextRequest) { + if (request.nextUrl.pathname !== "/admin") { + return NextResponse.next(); + } + + if (request.cookies.get("ff_role")?.value === "admin") { + return NextResponse.next(); + } + + const forbiddenUrl = request.nextUrl.clone(); + forbiddenUrl.pathname = "/admin/forbidden"; + forbiddenUrl.searchParams.set("status", "403"); + return NextResponse.rewrite(forbiddenUrl, { status: 403 }); +} + +export const config = { + matcher: ["/admin"] +};