diff --git a/apps/api/package.json b/apps/api/package.json index 25fa0e90e3..f558b71caa 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 b1239568d1..4070b3b9bc 100644 --- a/apps/api/src/controllers/adminController.js +++ b/apps/api/src/controllers/adminController.js @@ -1,6 +1,96 @@ import { ok } from "../utils/response.js"; -import { getAdminMetrics } from "../services/adminService.js"; +import { + applyDisputeRuling, + applyModerationDecision, + getAdminMetrics, + getAdminOverview, + getAdminUserProfile, + getPlatformControls, + listAdminUsers, + listAuditLogs, + listDisputes, + listFlaggedListings, + updateAdminUserStatus, + updatePlatformControl +} from "../services/adminService.js"; +import { fail } from "../utils/response.js"; +import { + auditLogQuerySchema, + disputeQuerySchema, + disputeRulingSchema, + moderationDecisionSchema, + moderationQuerySchema, + platformControlSchema, + userQuerySchema, + userStatusSchema +} from "../validators/admin.js"; + +function adminId(req) { + return req.user?.sub ?? "unknown_admin"; +} export async function metrics(req, res) { return ok(res, await getAdminMetrics()); } + +export async function overview(req, res) { + return ok(res, await getAdminOverview()); +} + +export async function users(req, res) { + const query = userQuerySchema.parse(req.query); + return ok(res, await listAdminUsers(query)); +} + +export async function userProfile(req, res) { + const profile = await getAdminUserProfile(req.params.userId); + if (!profile) return fail(res, "User not found", 404); + return ok(res, profile); +} + +export async function updateUserStatus(req, res) { + const payload = userStatusSchema.parse(req.body); + const result = await updateAdminUserStatus(req.params.userId, payload, adminId(req)); + if (!result) return fail(res, "User not found", 404); + return ok(res, result); +} + +export async function moderationQueue(req, res) { + const query = moderationQuerySchema.parse(req.query); + return ok(res, await listFlaggedListings(query)); +} + +export async function moderationDecision(req, res) { + const payload = moderationDecisionSchema.parse(req.body); + const result = await applyModerationDecision(req.params.listingId, payload, adminId(req)); + if (!result) return fail(res, "Flagged listing not found", 404); + return ok(res, result); +} + +export async function disputeQueue(req, res) { + const query = disputeQuerySchema.parse(req.query); + return ok(res, await listDisputes(query)); +} + +export async function disputeRuling(req, res) { + const payload = disputeRulingSchema.parse(req.body); + const result = await applyDisputeRuling(req.params.disputeId, payload, adminId(req)); + if (!result) return fail(res, "Dispute not found", 404); + return ok(res, result); +} + +export async function controls(req, res) { + return ok(res, await getPlatformControls()); +} + +export async function updateControl(req, res) { + const payload = platformControlSchema.parse(req.body); + const result = await updatePlatformControl(req.params.control, payload, adminId(req)); + if (!result) return fail(res, "Unknown platform control", 404); + return ok(res, result); +} + +export async function auditLog(req, res) { + const query = auditLogQuerySchema.parse(req.query); + return ok(res, await listAuditLogs(query)); +} diff --git a/apps/api/src/middleware/auth.js b/apps/api/src/middleware/auth.js index 445a719510..c2425e7533 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, "Admin access required", 403); + } + + return next(); +} diff --git a/apps/api/src/routes/adminRoutes.js b/apps/api/src/routes/adminRoutes.js index 4c1da76f9a..85addc6c56 100644 --- a/apps/api/src/routes/adminRoutes.js +++ b/apps/api/src/routes/adminRoutes.js @@ -1,8 +1,32 @@ import { Router } from "express"; -import { metrics } from "../controllers/adminController.js"; -import { authMiddleware } from "../middleware/auth.js"; +import { + auditLog, + controls, + disputeQueue, + disputeRuling, + metrics, + moderationDecision, + moderationQueue, + overview, + updateControl, + updateUserStatus, + userProfile, + users +} from "../controllers/adminController.js"; +import { authMiddleware, requireAdmin } from "../middleware/auth.js"; export const adminRoutes = Router(); -adminRoutes.use(authMiddleware); +adminRoutes.use(authMiddleware, requireAdmin); +adminRoutes.get("/overview", overview); adminRoutes.get("/metrics", metrics); +adminRoutes.get("/users", users); +adminRoutes.get("/users/:userId", userProfile); +adminRoutes.patch("/users/:userId/status", updateUserStatus); +adminRoutes.get("/moderation/jobs", moderationQueue); +adminRoutes.post("/moderation/jobs/:listingId/decision", moderationDecision); +adminRoutes.get("/disputes", disputeQueue); +adminRoutes.post("/disputes/:disputeId/ruling", disputeRuling); +adminRoutes.get("/controls", controls); +adminRoutes.patch("/controls/:control", updateControl); +adminRoutes.get("/audit-log", auditLog); diff --git a/apps/api/src/services/adminData.js b/apps/api/src/services/adminData.js new file mode 100644 index 0000000000..f4a025f04d --- /dev/null +++ b/apps/api/src/services/adminData.js @@ -0,0 +1,152 @@ +export const adminUsers = [ + { + id: "usr_admin_1", + email: "admin@freelanceflow.test", + fullName: "Priya Admin", + role: "admin", + status: "active", + joinedAt: "2025-11-20T10:00:00.000Z", + trustScore: 99, + activeJobs: 0, + disputeCount: 0 + }, + { + id: "usr_client_1", + email: "client@acme.test", + fullName: "Avery Client", + role: "client", + status: "active", + joinedAt: "2026-01-12T14:30:00.000Z", + trustScore: 82, + activeJobs: 4, + disputeCount: 1 + }, + { + id: "usr_client_2", + email: "ops@northstar.test", + fullName: "Northstar Ops", + role: "client", + status: "suspended", + joinedAt: "2026-03-03T09:15:00.000Z", + trustScore: 38, + activeJobs: 1, + disputeCount: 3 + }, + { + id: "usr_free_1", + email: "maya.dev@example.test", + fullName: "Maya Dev", + role: "freelancer", + status: "active", + joinedAt: "2025-12-08T18:45:00.000Z", + trustScore: 94, + activeJobs: 2, + disputeCount: 0 + }, + { + id: "usr_free_2", + email: "jordan.ux@example.test", + fullName: "Jordan UX", + role: "freelancer", + status: "active", + joinedAt: "2026-02-22T11:20:00.000Z", + trustScore: 76, + activeJobs: 3, + disputeCount: 1 + }, + { + id: "usr_free_3", + email: "casey.copy@example.test", + fullName: "Casey Copy", + role: "freelancer", + status: "banned", + joinedAt: "2026-04-02T16:10:00.000Z", + trustScore: 14, + activeJobs: 0, + disputeCount: 5 + } +]; + +export const flaggedListings = [ + { + id: "flag_101", + jobId: "job_101", + title: "Build payment gateway with copied credentials", + ownerId: "usr_client_2", + ownerName: "Northstar Ops", + reason: "Possible credential sharing in job brief", + status: "flagged", + riskLevel: "high", + flaggedAt: "2026-05-16T09:20:00.000Z" + }, + { + id: "flag_102", + jobId: "job_102", + title: "Urgent scraper for private marketplace", + ownerId: "usr_client_1", + ownerName: "Avery Client", + reason: "Terms-of-service risk detected", + status: "flagged", + riskLevel: "medium", + flaggedAt: "2026-05-16T12:45:00.000Z" + }, + { + id: "flag_103", + jobId: "job_103", + title: "Brand-safe landing page redesign", + ownerId: "usr_client_1", + ownerName: "Avery Client", + reason: "User report: unclear payment terms", + status: "escalated", + riskLevel: "low", + flaggedAt: "2026-05-15T17:05:00.000Z" + } +]; + +export const disputes = [ + { + id: "disp_201", + jobTitle: "Migrate legacy API to Node.js", + clientId: "usr_client_1", + clientName: "Avery Client", + freelancerId: "usr_free_2", + freelancerName: "Jordan UX", + amount: 2800, + currency: "USD", + status: "open", + openedAt: "2026-05-14T13:10:00.000Z", + summary: "Client says milestone 2 is incomplete; freelancer provided deployment evidence.", + evidenceCount: 4 + }, + { + id: "disp_202", + jobTitle: "Design SaaS onboarding flows", + clientId: "usr_client_2", + clientName: "Northstar Ops", + freelancerId: "usr_free_1", + freelancerName: "Maya Dev", + amount: 900, + currency: "USD", + status: "under_review", + openedAt: "2026-05-12T20:25:00.000Z", + summary: "Both parties disagree on scope expansion after initial approval.", + evidenceCount: 7 + } +]; + +export const platformControls = { + registrationsEnabled: true, + jobPostingsEnabled: true +}; + +export const auditLogs = [ + { + id: "audit_001", + adminId: "usr_admin_1", + actionType: "platform.initialized", + targetType: "system", + targetId: "platform", + detail: "Admin panel audit log initialized", + createdAt: "2026-05-15T08:00:00.000Z" + } +]; diff --git a/apps/api/src/services/adminService.js b/apps/api/src/services/adminService.js index 9075111aaa..3ef93a9044 100644 --- a/apps/api/src/services/adminService.js +++ b/apps/api/src/services/adminService.js @@ -1,8 +1,224 @@ +import { + adminUsers, + auditLogs, + disputes, + flaggedListings, + platformControls +} from "./adminData.js"; + +function paginate(items, { page, pageSize }) { + const start = (page - 1) * pageSize; + return { + items: items.slice(start, start + pageSize), + page, + pageSize, + total: items.length, + totalPages: Math.max(1, Math.ceil(items.length / pageSize)) + }; +} + +function betweenDates(value, from, to) { + const timestamp = new Date(value).getTime(); + if (from && timestamp < new Date(from).getTime()) return false; + if (to && timestamp > new Date(to).getTime()) return false; + return true; +} + +function addAuditLog({ adminId, actionType, targetType, targetId, detail }) { + const entry = { + id: `audit_${String(auditLogs.length + 1).padStart(3, "0")}`, + adminId, + actionType, + targetType, + targetId, + detail, + createdAt: new Date().toISOString() + }; + auditLogs.unshift(entry); + return entry; +} + +function trustDistribution() { + return [ + { label: "0-49", count: adminUsers.filter((user) => user.trustScore < 50).length }, + { + label: "50-79", + count: adminUsers.filter((user) => user.trustScore >= 50 && user.trustScore < 80).length + }, + { label: "80-100", count: adminUsers.filter((user) => user.trustScore >= 80).length } + ]; +} + +export async function getAdminOverview() { + return { + metrics: { + totalUsers: adminUsers.length, + activeJobs: adminUsers.reduce((sum, user) => sum + user.activeJobs, 0), + openDisputes: disputes.filter((dispute) => dispute.status !== "resolved").length, + flaggedListings: flaggedListings.filter((listing) => listing.status === "flagged").length, + revenueCurrentPeriod: 128900 + }, + trustDistribution: trustDistribution(), + platformControls + }; +} + export async function getAdminMetrics() { + const overview = await getAdminOverview(); + return { + ...overview.metrics, + openJobs: overview.metrics.activeJobs, + activeFreelancers: adminUsers.filter((user) => user.role === "freelancer" && user.status === "active").length, + flaggedAccounts: adminUsers.filter((user) => user.status !== "active").length, + monthlyVolume: overview.metrics.revenueCurrentPeriod + }; +} + +export async function listAdminUsers(query) { + const search = query.search?.toLowerCase(); + const filtered = adminUsers.filter((user) => { + const matchesSearch = + !search || + user.fullName.toLowerCase().includes(search) || + user.email.toLowerCase().includes(search); + const matchesRole = !query.role || user.role === query.role; + const matchesStatus = !query.status || user.status === query.status; + const matchesDate = betweenDates(user.joinedAt, query.joinedFrom, query.joinedTo); + + return matchesSearch && matchesRole && matchesStatus && matchesDate; + }); + + return paginate(filtered, query); +} + +export async function getAdminUserProfile(userId) { + const user = adminUsers.find((item) => item.id === userId); + if (!user) return null; + return { - openJobs: 42, - activeFreelancers: 185, - flaggedAccounts: 3, - monthlyVolume: 128900 + ...user, + activeJobs: flaggedListings + .filter((listing) => listing.ownerId === user.id) + .map((listing) => ({ id: listing.jobId, title: listing.title, status: listing.status })), + disputeHistory: disputes.filter( + (dispute) => dispute.clientId === user.id || dispute.freelancerId === user.id + ) }; } + +export async function updateAdminUserStatus(userId, payload, adminId) { + const user = adminUsers.find((item) => item.id === userId); + if (!user) return null; + + const nextStatusByAction = { + suspend: "suspended", + reinstate: "active", + ban: "banned" + }; + + user.status = nextStatusByAction[payload.action]; + const audit = addAuditLog({ + adminId, + actionType: `user.${payload.action}`, + targetType: "user", + targetId: user.id, + detail: payload.reason ?? `User ${payload.action} action applied` + }); + + return { user, audit }; +} + +export async function listFlaggedListings(query) { + const filtered = flaggedListings.filter((listing) => !query.status || listing.status === query.status); + return paginate(filtered, query); +} + +export async function applyModerationDecision(listingId, payload, adminId) { + const listing = flaggedListings.find((item) => item.id === listingId); + if (!listing) return null; + + const nextStatusByDecision = { + approve: "approved", + reject: "rejected", + escalate: "escalated" + }; + + listing.status = nextStatusByDecision[payload.decision]; + const audit = addAuditLog({ + adminId, + actionType: `listing.${payload.decision}`, + targetType: "job", + targetId: listing.jobId, + detail: payload.reason + }); + + return { + listing, + audit, + notification: { + toUserId: listing.ownerId, + title: `Listing ${listing.status}`, + body: payload.reason + } + }; +} + +export async function listDisputes(query) { + const filtered = disputes.filter((dispute) => !query.status || dispute.status === query.status); + return paginate(filtered, query); +} + +export async function applyDisputeRuling(disputeId, payload, adminId) { + const dispute = disputes.find((item) => item.id === disputeId); + if (!dispute) return null; + + dispute.status = payload.ruling === "escalate" ? "escalated" : "resolved"; + const audit = addAuditLog({ + adminId, + actionType: `dispute.${payload.ruling}`, + targetType: "dispute", + targetId: dispute.id, + detail: payload.note + }); + + return { + dispute, + audit, + refundTriggered: payload.ruling === "favor_client" || payload.ruling === "refund", + notifications: [ + { toUserId: dispute.clientId, title: "Dispute ruling updated", body: payload.note }, + { toUserId: dispute.freelancerId, title: "Dispute ruling updated", body: payload.note } + ] + }; +} + +export async function getPlatformControls() { + return platformControls; +} + +export async function updatePlatformControl(control, payload, adminId) { + if (!(control in platformControls)) return null; + + platformControls[control] = payload.enabled; + const audit = addAuditLog({ + adminId, + actionType: `platform.${control}`, + targetType: "platform-control", + targetId: control, + detail: `${control} set to ${payload.enabled ? "enabled" : "disabled"}` + }); + + return { controls: platformControls, audit }; +} + +export async function listAuditLogs(query) { + const filtered = auditLogs.filter((entry) => { + const matchesAdmin = !query.adminId || entry.adminId === query.adminId; + const matchesAction = !query.actionType || entry.actionType === query.actionType; + const matchesDate = betweenDates(entry.createdAt, query.from, query.to); + + return matchesAdmin && matchesAction && matchesDate; + }); + + return paginate(filtered, query); +} diff --git a/apps/api/src/tests/admin.test.js b/apps/api/src/tests/admin.test.js new file mode 100644 index 0000000000..6e531e8376 --- /dev/null +++ b/apps/api/src/tests/admin.test.js @@ -0,0 +1,112 @@ +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 adminHeaders() { + return { + Authorization: `Bearer ${signAccessToken({ sub: "usr_admin_1", role: "admin" })}` + }; +} + +test("admin routes reject unauthenticated requests", async () => { + await withServer(async (baseUrl) => { + const response = await fetch(`${baseUrl}/api/admin/overview`); + const payload = await response.json(); + + assert.equal(response.status, 401); + assert.equal(payload.success, false); + }); +}); + +test("admin routes reject non-admin users", async () => { + await withServer(async (baseUrl) => { + const token = signAccessToken({ sub: "usr_client_1", role: "client" }); + const response = await fetch(`${baseUrl}/api/admin/overview`, { + headers: { Authorization: `Bearer ${token}` } + }); + const payload = await response.json(); + + assert.equal(response.status, 403); + assert.equal(payload.message, "Admin access required"); + }); +}); + +test("admin overview returns operations metrics for admins", async () => { + await withServer(async (baseUrl) => { + const response = await fetch(`${baseUrl}/api/admin/overview`, { + headers: adminHeaders() + }); + const payload = await response.json(); + + assert.equal(response.status, 200); + assert.equal(payload.success, true); + assert.equal(payload.data.metrics.totalUsers, 6); + assert.equal(payload.data.trustDistribution.length, 3); + }); +}); + +test("admin can search and suspend users with an audit entry", async () => { + await withServer(async (baseUrl) => { + const searchResponse = await fetch(`${baseUrl}/api/admin/users?search=avery&page=1&pageSize=2`, { + headers: adminHeaders() + }); + const searchPayload = await searchResponse.json(); + + assert.equal(searchResponse.status, 200); + assert.equal(searchPayload.data.total, 1); + assert.equal(searchPayload.data.items[0].id, "usr_client_1"); + + const statusResponse = await fetch(`${baseUrl}/api/admin/users/usr_client_1/status`, { + method: "PATCH", + headers: { + ...adminHeaders(), + "Content-Type": "application/json" + }, + body: JSON.stringify({ action: "suspend", reason: "Suspicious dispute pattern" }) + }); + const statusPayload = await statusResponse.json(); + + assert.equal(statusResponse.status, 200); + assert.equal(statusPayload.data.user.status, "suspended"); + assert.equal(statusPayload.data.audit.actionType, "user.suspend"); + }); +}); + +test("admin can update platform controls", async () => { + await withServer(async (baseUrl) => { + const response = await fetch(`${baseUrl}/api/admin/controls/jobPostingsEnabled`, { + method: "PATCH", + headers: { + ...adminHeaders(), + "Content-Type": "application/json" + }, + body: JSON.stringify({ enabled: false }) + }); + const payload = await response.json(); + + assert.equal(response.status, 200); + assert.equal(payload.data.controls.jobPostingsEnabled, false); + assert.equal(payload.data.audit.actionType, "platform.jobPostingsEnabled"); + }); +}); diff --git a/apps/api/src/validators/admin.js b/apps/api/src/validators/admin.js new file mode 100644 index 0000000000..a297b3dc91 --- /dev/null +++ b/apps/api/src/validators/admin.js @@ -0,0 +1,48 @@ +import { z } from "zod"; + +export const paginationQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(50).default(10) +}); + +export const userQuerySchema = paginationQuerySchema.extend({ + search: z.string().trim().optional(), + role: z.enum(["client", "freelancer", "admin"]).optional(), + status: z.enum(["active", "suspended", "banned"]).optional(), + joinedFrom: z.string().datetime().optional(), + joinedTo: z.string().datetime().optional() +}); + +export const userStatusSchema = z.object({ + action: z.enum(["suspend", "reinstate", "ban"]), + reason: z.string().trim().min(3).max(500).optional() +}); + +export const moderationQuerySchema = paginationQuerySchema.extend({ + status: z.enum(["flagged", "approved", "rejected", "escalated"]).optional() +}); + +export const moderationDecisionSchema = z.object({ + decision: z.enum(["approve", "reject", "escalate"]), + reason: z.string().trim().min(3).max(500) +}); + +export const disputeQuerySchema = paginationQuerySchema.extend({ + status: z.enum(["open", "under_review", "resolved", "escalated"]).optional() +}); + +export const disputeRulingSchema = z.object({ + ruling: z.enum(["favor_client", "favor_freelancer", "refund", "escalate"]), + note: z.string().trim().min(3).max(500) +}); + +export const platformControlSchema = z.object({ + enabled: z.boolean() +}); + +export const auditLogQuerySchema = paginationQuerySchema.extend({ + adminId: z.string().trim().optional(), + actionType: z.string().trim().optional(), + from: z.string().datetime().optional(), + to: z.string().datetime().optional() +}); diff --git a/apps/web/app/admin/AdminPanelClient.tsx b/apps/web/app/admin/AdminPanelClient.tsx new file mode 100644 index 0000000000..eaab9a878e --- /dev/null +++ b/apps/web/app/admin/AdminPanelClient.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { AdminDashboardData, AuditLogEntry } from "../../lib/adminTypes"; +import { AuditLog } from "./components/AuditLog"; +import { DisputeQueue } from "./components/DisputeQueue"; +import { ModerationQueue } from "./components/ModerationQueue"; +import { PlatformControls } from "./components/PlatformControls"; +import { SummaryCards } from "./components/SummaryCards"; +import { TrustMetrics } from "./components/TrustMetrics"; +import { UserManagementTable } from "./components/UserManagementTable"; + +type Props = { + initialData: AdminDashboardData; +}; + +function createAuditEntry(actionType: string, targetId: string, detail: string): AuditLogEntry { + return { + id: `audit_${Date.now()}`, + adminId: "usr_admin_1", + actionType, + targetType: "admin-panel", + targetId, + detail, + createdAt: new Date().toISOString() + }; +} + +export function AdminPanelClient({ initialData }: Props) { + const [data, setData] = useState(initialData); + const [roleFilter, setRoleFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + const [search, setSearch] = useState(""); + const [isRefreshing, setIsRefreshing] = useState(false); + const [lastRefresh, setLastRefresh] = useState(new Date().toLocaleString("en-US")); + + const filteredUsers = useMemo(() => { + const query = search.trim().toLowerCase(); + return data.users.filter((user) => { + const matchesSearch = + !query || + user.fullName.toLowerCase().includes(query) || + user.email.toLowerCase().includes(query); + const matchesRole = roleFilter === "all" || user.role === roleFilter; + const matchesStatus = statusFilter === "all" || user.status === statusFilter; + return matchesSearch && matchesRole && matchesStatus; + }); + }, [data.users, roleFilter, search, statusFilter]); + + function appendAudit(entry: AuditLogEntry) { + setData((current) => ({ + ...current, + auditLog: [entry, ...current.auditLog].slice(0, 12) + })); + } + + function handleRefresh() { + setIsRefreshing(true); + window.setTimeout(() => { + setLastRefresh(new Date().toLocaleString("en-US")); + setIsRefreshing(false); + appendAudit(createAuditEntry("dashboard.refresh", "overview", "Manual data refresh")); + }, 350); + } + + function handleUserStatusAction(userId: string, action: "suspend" | "reinstate" | "ban") { + const nextStatus = action === "reinstate" ? "active" : action === "suspend" ? "suspended" : "banned"; + setData((current) => ({ + ...current, + users: current.users.map((user) => (user.id === userId ? { ...user, status: nextStatus } : user)) + })); + appendAudit(createAuditEntry(`user.${action}`, userId, `User ${action} action applied`)); + } + + function handleModerationDecision(listingId: string, decision: "approve" | "reject" | "escalate") { + const nextStatus = decision === "approve" ? "approved" : decision === "reject" ? "rejected" : "escalated"; + setData((current) => ({ + ...current, + flaggedListings: current.flaggedListings.map((listing) => + listing.id === listingId ? { ...listing, status: nextStatus } : listing + ) + })); + appendAudit(createAuditEntry(`listing.${decision}`, listingId, `Listing marked ${nextStatus}`)); + } + + function handleDisputeRuling( + disputeId: string, + ruling: "favor_client" | "favor_freelancer" | "refund" | "escalate" + ) { + setData((current) => ({ + ...current, + disputes: current.disputes.map((dispute) => + dispute.id === disputeId + ? { ...dispute, status: ruling === "escalate" ? "escalated" : "resolved" } + : dispute + ) + })); + appendAudit(createAuditEntry(`dispute.${ruling}`, disputeId, "Dispute ruling applied")); + } + + function handleControlToggle(key: "registrationsEnabled" | "jobPostingsEnabled") { + const label = key === "registrationsEnabled" ? "new registrations" : "new job postings"; + if (!window.confirm(`Apply platform control change for ${label}?`)) return; + + setData((current) => ({ + ...current, + controls: { + ...current.controls, + [key]: !current.controls[key] + } + })); + appendAudit(createAuditEntry(`platform.${key}`, key, `Toggled ${label}`)); + } + + return ( +
+
+ Skip to users + Skip to moderation + Skip to controls +
+ +
+
+

Admin panel

+

Operations dashboard

+ Last refresh: {lastRefresh} +
+ +
+ + + +
+ + +
+ + + +
+ listing.status === "flagged")} + onDecision={handleModerationDecision} + /> + dispute.status !== "resolved")} + onRuling={handleDisputeRuling} + /> +
+ + +
+ ); +} diff --git a/apps/web/app/admin/components/AuditLog.tsx b/apps/web/app/admin/components/AuditLog.tsx new file mode 100644 index 0000000000..2423fffd6e --- /dev/null +++ b/apps/web/app/admin/components/AuditLog.tsx @@ -0,0 +1,51 @@ +import type { AuditLogEntry } from "../../../lib/adminTypes"; + +type Props = { + entries: AuditLogEntry[]; +}; + +export function AuditLog({ entries }: Props) { + return ( +
+
+
+

Audit log

+

Recent actions

+
+
+
+ + + + + + + + + + + + {entries.map((entry) => ( + + + + + + + + ))} + {entries.length === 0 ? ( + + + + ) : null} + +
TimeAdminActionTargetDetail
{new Date(entry.createdAt).toLocaleString("en-US")}{entry.adminId}{entry.actionType} + {entry.targetType}:{entry.targetId} + {entry.detail}
+ No audit entries. +
+
+
+ ); +} diff --git a/apps/web/app/admin/components/DisputeQueue.tsx b/apps/web/app/admin/components/DisputeQueue.tsx new file mode 100644 index 0000000000..3bfc3fce49 --- /dev/null +++ b/apps/web/app/admin/components/DisputeQueue.tsx @@ -0,0 +1,66 @@ +import type { Dispute } from "../../../lib/adminTypes"; + +type Props = { + disputes: Dispute[]; + onRuling: (disputeId: string, ruling: "favor_client" | "favor_freelancer" | "refund" | "escalate") => void; +}; + +const currencyFormatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0 +}); + +export function DisputeQueue({ disputes, onRuling }: Props) { + return ( +
+
+
+

Dispute resolution

+

Open cases

+
+
+
+ {disputes.map((dispute) => ( +
+
+
+ {dispute.jobTitle} + {dispute.status} +
+

{dispute.summary}

+ + {dispute.clientName} / {dispute.freelancerName} /{" "} + {currencyFormatter.format(dispute.amount)} / {dispute.evidenceCount} files + +
+
+ + + +
+
+ ))} + {disputes.length === 0 ?

No open disputes.

: null} +
+
+ ); +} diff --git a/apps/web/app/admin/components/ModerationQueue.tsx b/apps/web/app/admin/components/ModerationQueue.tsx new file mode 100644 index 0000000000..769d828ccb --- /dev/null +++ b/apps/web/app/admin/components/ModerationQueue.tsx @@ -0,0 +1,57 @@ +import type { FlaggedListing } from "../../../lib/adminTypes"; + +type Props = { + listings: FlaggedListing[]; + onDecision: (listingId: string, decision: "approve" | "reject" | "escalate") => void; +}; + +export function ModerationQueue({ listings, onDecision }: Props) { + return ( +
+
+
+

Job moderation

+

Flagged listings

+
+
+
+ {listings.map((listing) => ( +
+
+
+ {listing.title} + {listing.riskLevel} +
+

{listing.reason}

+ {listing.ownerName} +
+
+ + + +
+
+ ))} + {listings.length === 0 ?

No flagged listings.

: null} +
+
+ ); +} diff --git a/apps/web/app/admin/components/PlatformControls.tsx b/apps/web/app/admin/components/PlatformControls.tsx new file mode 100644 index 0000000000..1cab2267bf --- /dev/null +++ b/apps/web/app/admin/components/PlatformControls.tsx @@ -0,0 +1,38 @@ +type Props = { + controls: { + registrationsEnabled: boolean; + jobPostingsEnabled: boolean; + }; + onToggle: (key: "registrationsEnabled" | "jobPostingsEnabled") => void; +}; + +export function PlatformControls({ controls, onToggle }: Props) { + const items = [ + { key: "registrationsEnabled" as const, label: "New registrations" }, + { key: "jobPostingsEnabled" as const, label: "New job postings" } + ]; + + return ( +
+
+
+

Platform controls

+

Availability

+
+
+
+ {items.map((item) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/app/admin/components/SummaryCards.tsx b/apps/web/app/admin/components/SummaryCards.tsx new file mode 100644 index 0000000000..fd213ecf44 --- /dev/null +++ b/apps/web/app/admin/components/SummaryCards.tsx @@ -0,0 +1,32 @@ +import type { AdminDashboardData } from "../../../lib/adminTypes"; + +type Props = { + metrics: AdminDashboardData["metrics"]; +}; + +const currencyFormatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0 +}); + +export function SummaryCards({ metrics }: Props) { + const cards = [ + { label: "Total users", value: metrics.totalUsers.toLocaleString("en-US") }, + { label: "Active jobs", value: metrics.activeJobs.toLocaleString("en-US") }, + { label: "Open disputes", value: metrics.openDisputes.toLocaleString("en-US") }, + { label: "Flagged listings", value: metrics.flaggedListings.toLocaleString("en-US") }, + { label: "Revenue", value: currencyFormatter.format(metrics.revenueCurrentPeriod) } + ]; + + return ( +
+ {cards.map((card) => ( +
+ {card.label} + {card.value} +
+ ))} +
+ ); +} diff --git a/apps/web/app/admin/components/TrustMetrics.tsx b/apps/web/app/admin/components/TrustMetrics.tsx new file mode 100644 index 0000000000..5e2fdc7cdd --- /dev/null +++ b/apps/web/app/admin/components/TrustMetrics.tsx @@ -0,0 +1,29 @@ +type Props = { + distribution: Array<{ label: string; count: number }>; +}; + +export function TrustMetrics({ distribution }: Props) { + const max = Math.max(1, ...distribution.map((item) => item.count)); + + return ( +
+
+
+

Trust & metrics

+

Trust scores

+
+
+
+ {distribution.map((item) => ( +
+ {item.label} +
+
+
+ {item.count} +
+ ))} +
+
+ ); +} diff --git a/apps/web/app/admin/components/UserManagementTable.tsx b/apps/web/app/admin/components/UserManagementTable.tsx new file mode 100644 index 0000000000..e7dd9889e1 --- /dev/null +++ b/apps/web/app/admin/components/UserManagementTable.tsx @@ -0,0 +1,130 @@ +import type { AdminUser } from "../../../lib/adminTypes"; + +type Props = { + users: AdminUser[]; + roleFilter: string; + statusFilter: string; + search: string; + onRoleFilterChange: (value: string) => void; + onStatusFilterChange: (value: string) => void; + onSearchChange: (value: string) => void; + onStatusAction: (userId: string, action: "suspend" | "reinstate" | "ban") => void; +}; + +export function UserManagementTable({ + users, + roleFilter, + statusFilter, + search, + onRoleFilterChange, + onStatusFilterChange, + onSearchChange, + onStatusAction +}: Props) { + return ( +
+
+
+

User management

+

Accounts

+
+
+ + + +
+
+ +
+ + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + + ))} + {users.length === 0 ? ( + + + + ) : null} + +
UserRoleStatusTrustActivityActions
+ {user.fullName} + {user.email} + {user.role} + {user.status} + {user.trustScore} + {user.activeJobs} jobs / {user.disputeCount} disputes + +
+ + + +
+
+ No users found. +
+
+
+ ); +} diff --git a/apps/web/app/admin/forbidden/page.tsx b/apps/web/app/admin/forbidden/page.tsx new file mode 100644 index 0000000000..6fe002f06c --- /dev/null +++ b/apps/web/app/admin/forbidden/page.tsx @@ -0,0 +1,9 @@ +export default function AdminForbiddenPage() { + return ( +
+

403

+

Admin access required

+

Sign in with an administrator account to manage users, listings, disputes, and controls.

+
+ ); +} diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx index 9d251466f7..0c4b54340f 100644 --- a/apps/web/app/admin/page.tsx +++ b/apps/web/app/admin/page.tsx @@ -1,8 +1,6 @@ +import { adminDashboardData } from "../../lib/adminMock"; +import { AdminPanelClient } from "./AdminPanelClient"; + export default function AdminPanelPage() { - return ( -
-

Admin Panel

-

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

-
- ); + return ; } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 40e22f13cf..09a7192ddc 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,6 +1,145 @@ * { box-sizing: border-box; } body { margin: 0; font-family: Inter, Arial, sans-serif; background: #0b1020; color: #f2f5ff; } a { color: inherit; text-decoration: none; } -main { max-width: 960px; margin: 0 auto; padding: 2rem 1rem; } +main { max-width: 1180px; margin: 0 auto; padding: 2rem 1rem; } +button, input, select { font: inherit; } +button { cursor: pointer; } +button:disabled { cursor: not-allowed; opacity: 0.55; } .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-panel { display: grid; gap: 1rem; } +.admin-skip-links { display: flex; gap: 0.5rem; flex-wrap: wrap; } +.admin-skip-links a { + position: absolute; + transform: translateY(-180%); + background: #f8fbff; + color: #0b1020; + border-radius: 8px; + padding: 0.55rem 0.75rem; +} +.admin-skip-links a:focus { position: static; transform: none; outline: 3px solid #49dcb1; } +.admin-toolbar, +.admin-section, +.admin-metric-card { + background: #151c35; + border: 1px solid #2a3765; + border-radius: 10px; + padding: 1rem; +} +.admin-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-color: #3d8cff; +} +.admin-toolbar h2, +.admin-section h2 { margin: 0.15rem 0; } +.admin-toolbar span, +.admin-list-item span, +td span { color: #aebce2; display: block; } +.admin-toolbar button, +.admin-action-row button { + border: 1px solid #4c629d; + color: #f2f5ff; + background: #21315d; + border-radius: 8px; + padding: 0.55rem 0.75rem; +} +.admin-toolbar button:hover, +.admin-action-row button:hover { border-color: #49dcb1; } +.admin-eyebrow { + color: #49dcb1; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0; + margin: 0; + text-transform: uppercase; +} +.admin-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.75rem; +} +.admin-metric-card { min-height: 104px; } +.admin-metric-card span { color: #aebce2; display: block; } +.admin-metric-card strong { display: block; font-size: 1.8rem; margin-top: 0.75rem; } +.admin-two-column { + display: grid; + gap: 1rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.admin-section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} +.admin-filter-row, +.admin-action-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: end; } +.admin-filter-row label { display: grid; gap: 0.35rem; min-width: 150px; } +.admin-filter-row label span { color: #aebce2; font-size: 0.82rem; } +.admin-filter-row input, +.admin-filter-row select { + border: 1px solid #4c629d; + background: #0f1730; + color: #f2f5ff; + border-radius: 8px; + min-height: 38px; + padding: 0.5rem 0.65rem; +} +.admin-table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; min-width: 720px; } +th, td { border-bottom: 1px solid #26345f; padding: 0.8rem; text-align: left; vertical-align: top; } +th { color: #c9d6ff; font-size: 0.84rem; } +.admin-pill { + border: 1px solid #4c629d; + border-radius: 999px; + color: #f2f5ff; + display: inline-flex; + padding: 0.18rem 0.55rem; + width: fit-content; +} +.admin-pill-active, +.admin-risk-low { background: #173c37; border-color: #49dcb1; } +.admin-pill-suspended, +.admin-risk-medium { background: #3c3317; border-color: #f0c35a; } +.admin-pill-banned, +.admin-risk-high { background: #4b1e2b; border-color: #ff7497; } +.admin-list { display: grid; gap: 0.75rem; } +.admin-list-item { + border: 1px solid #26345f; + border-radius: 8px; + display: grid; + gap: 0.75rem; + padding: 0.9rem; +} +.admin-list-title { display: flex; gap: 0.5rem; justify-content: space-between; align-items: start; } +.admin-list-item p { color: #dbe4ff; margin: 0.45rem 0; } +.admin-bars { display: grid; gap: 0.8rem; } +.admin-bar-row { display: grid; grid-template-columns: 64px 1fr 32px; gap: 0.75rem; align-items: center; } +.admin-bar-track { background: #0f1730; border-radius: 999px; height: 12px; overflow: hidden; } +.admin-bar-fill { background: linear-gradient(90deg, #49dcb1, #3d8cff); height: 100%; } +.admin-control-list { display: grid; gap: 0.75rem; } +.admin-toggle { + align-items: center; + border: 1px solid #26345f; + border-radius: 8px; + display: flex; + justify-content: space-between; + padding: 0.85rem; +} +.admin-toggle input { inline-size: 44px; block-size: 24px; accent-color: #49dcb1; } +.admin-empty-state { color: #aebce2; padding: 1rem; text-align: center; } + +@media (max-width: 840px) { + .admin-toolbar, + .admin-section-heading, + .admin-two-column { grid-template-columns: 1fr; } + .admin-toolbar, + .admin-section-heading { align-items: stretch; flex-direction: column; } + .admin-toolbar button { width: 100%; } + .admin-filter-row label { min-width: 100%; } +} diff --git a/apps/web/lib/adminMock.ts b/apps/web/lib/adminMock.ts new file mode 100644 index 0000000000..873dc9bb72 --- /dev/null +++ b/apps/web/lib/adminMock.ts @@ -0,0 +1,152 @@ +import type { AdminDashboardData } from "./adminTypes"; + +export const adminDashboardData: AdminDashboardData = { + metrics: { + totalUsers: 6, + activeJobs: 10, + openDisputes: 2, + flaggedListings: 2, + revenueCurrentPeriod: 128900 + }, + trustDistribution: [ + { label: "0-49", count: 2 }, + { label: "50-79", count: 1 }, + { label: "80-100", count: 3 } + ], + users: [ + { + id: "usr_admin_1", + email: "admin@freelanceflow.test", + fullName: "Priya Admin", + role: "admin", + status: "active", + joinedAt: "2025-11-20T10:00:00.000Z", + trustScore: 99, + activeJobs: 0, + disputeCount: 0 + }, + { + id: "usr_client_1", + email: "client@acme.test", + fullName: "Avery Client", + role: "client", + status: "active", + joinedAt: "2026-01-12T14:30:00.000Z", + trustScore: 82, + activeJobs: 4, + disputeCount: 1 + }, + { + id: "usr_client_2", + email: "ops@northstar.test", + fullName: "Northstar Ops", + role: "client", + status: "suspended", + joinedAt: "2026-03-03T09:15:00.000Z", + trustScore: 38, + activeJobs: 1, + disputeCount: 3 + }, + { + id: "usr_free_1", + email: "maya.dev@example.test", + fullName: "Maya Dev", + role: "freelancer", + status: "active", + joinedAt: "2025-12-08T18:45:00.000Z", + trustScore: 94, + activeJobs: 2, + disputeCount: 0 + }, + { + id: "usr_free_2", + email: "jordan.ux@example.test", + fullName: "Jordan UX", + role: "freelancer", + status: "active", + joinedAt: "2026-02-22T11:20:00.000Z", + trustScore: 76, + activeJobs: 3, + disputeCount: 1 + }, + { + id: "usr_free_3", + email: "casey.copy@example.test", + fullName: "Casey Copy", + role: "freelancer", + status: "banned", + joinedAt: "2026-04-02T16:10:00.000Z", + trustScore: 14, + activeJobs: 0, + disputeCount: 5 + } + ], + flaggedListings: [ + { + id: "flag_101", + title: "Build payment gateway with copied credentials", + ownerName: "Northstar Ops", + reason: "Possible credential sharing in job brief", + status: "flagged", + riskLevel: "high", + flaggedAt: "2026-05-16T09:20:00.000Z" + }, + { + id: "flag_102", + title: "Urgent scraper for private marketplace", + ownerName: "Avery Client", + reason: "Terms-of-service risk detected", + status: "flagged", + riskLevel: "medium", + flaggedAt: "2026-05-16T12:45:00.000Z" + }, + { + id: "flag_103", + title: "Brand-safe landing page redesign", + ownerName: "Avery Client", + reason: "User report: unclear payment terms", + status: "escalated", + riskLevel: "low", + flaggedAt: "2026-05-15T17:05:00.000Z" + } + ], + disputes: [ + { + id: "disp_201", + jobTitle: "Migrate legacy API to Node.js", + clientName: "Avery Client", + freelancerName: "Jordan UX", + amount: 2800, + currency: "USD", + status: "open", + summary: "Client says milestone 2 is incomplete; freelancer provided deployment evidence.", + evidenceCount: 4 + }, + { + id: "disp_202", + jobTitle: "Design SaaS onboarding flows", + clientName: "Northstar Ops", + freelancerName: "Maya Dev", + amount: 900, + currency: "USD", + status: "under_review", + summary: "Both parties disagree on scope expansion after initial approval.", + evidenceCount: 7 + } + ], + controls: { + registrationsEnabled: true, + jobPostingsEnabled: true + }, + auditLog: [ + { + id: "audit_001", + adminId: "usr_admin_1", + actionType: "platform.initialized", + targetType: "system", + targetId: "platform", + detail: "Admin panel audit log initialized", + createdAt: "2026-05-15T08:00:00.000Z" + } + ] +}; diff --git a/apps/web/lib/adminTypes.ts b/apps/web/lib/adminTypes.ts new file mode 100644 index 0000000000..ad105efa5e --- /dev/null +++ b/apps/web/lib/adminTypes.ts @@ -0,0 +1,62 @@ +export type AdminUser = { + id: string; + email: string; + fullName: string; + role: "admin" | "client" | "freelancer"; + status: "active" | "suspended" | "banned"; + joinedAt: string; + trustScore: number; + activeJobs: number; + disputeCount: number; +}; + +export type FlaggedListing = { + id: string; + title: string; + ownerName: string; + reason: string; + status: "flagged" | "approved" | "rejected" | "escalated"; + riskLevel: "low" | "medium" | "high"; + flaggedAt: string; +}; + +export type Dispute = { + id: string; + jobTitle: string; + clientName: string; + freelancerName: string; + amount: number; + currency: string; + status: "open" | "under_review" | "resolved" | "escalated"; + summary: string; + evidenceCount: number; +}; + +export type AuditLogEntry = { + id: string; + adminId: string; + actionType: string; + targetType: string; + targetId: string; + detail: string; + createdAt: string; +}; + +export type AdminDashboardData = { + metrics: { + totalUsers: number; + activeJobs: number; + openDisputes: number; + flaggedListings: number; + revenueCurrentPeriod: number; + }; + trustDistribution: Array<{ label: string; count: number }>; + users: AdminUser[]; + flaggedListings: FlaggedListing[]; + disputes: Dispute[]; + controls: { + registrationsEnabled: boolean; + jobPostingsEnabled: boolean; + }; + auditLog: AuditLogEntry[]; +}; diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 658404ac69..865525b8e8 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,4 +1,10 @@ +const path = require("path"); + /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + turbopack: { + root: path.resolve(__dirname, "../..") + } +}; module.exports = nextConfig; diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts new file mode 100644 index 0000000000..bbe086045f --- /dev/null +++ b/apps/web/proxy.ts @@ -0,0 +1,20 @@ +import { NextResponse, type NextRequest } from "next/server"; + +export function proxy(request: NextRequest) { + if (request.nextUrl.pathname === "/admin/forbidden") { + return NextResponse.next(); + } + + const role = request.cookies.get("freelanceflow_role")?.value; + if (role !== "admin") { + const forbiddenUrl = request.nextUrl.clone(); + forbiddenUrl.pathname = "/admin/forbidden"; + return NextResponse.rewrite(forbiddenUrl, { status: 403 }); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/admin/:path*"] +};