diff --git a/__tests__/admin/StepUpConfirmModal.test.jsx b/__tests__/admin/StepUpConfirmModal.test.jsx new file mode 100644 index 00000000..65737655 --- /dev/null +++ b/__tests__/admin/StepUpConfirmModal.test.jsx @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import StepUpConfirmModal from "@/components/admin/StepUpConfirmModal"; +import { resetRateLimit } from "@/lib/utils/rateLimiter"; + +vi.mock("@/lib/config/font.config", () => ({ + poppins_400: { className: "" }, + poppins_500: { className: "" }, + poppins_600: { className: "" }, +})); + +describe("StepUpConfirmModal Component (#311)", () => { + const TEST_RATE_KEY = "test_stepup_rate_key"; + + beforeEach(() => { + vi.clearAllMocks(); + resetRateLimit(TEST_RATE_KEY); + }); + + it("derives expected phrase from actionVerb and targetName", () => { + render( + + ); + + expect(screen.getByText("BAN user@example.com")).toBeInTheDocument(); + }); + + it("keeps confirm button disabled until phrase matches exact target phrase", () => { + const handleConfirm = vi.fn(); + render( + + ); + + const input = screen.getByPlaceholderText('Type "BAN user@example.com"'); + const confirmBtn = screen.getByRole("button", { name: "Confirm Action" }); + + // Initially disabled + expect(confirmBtn).toBeDisabled(); + + // Partial / wrong input + fireEvent.change(input, { target: { value: "BAN user@" } }); + expect(confirmBtn).toBeDisabled(); + + // Exact match + fireEvent.change(input, { target: { value: "BAN user@example.com" } }); + expect(confirmBtn).not.toBeDisabled(); + expect(screen.getByText("Exact Match")).toBeInTheDocument(); + }); + + it("triggers client-side rate limit and displays cooldown notice after rapid attempts", async () => { + const handleConfirm = vi.fn(); + const { rerender } = render( + + ); + + // Simulate 3 rapid confirms + for (let i = 0; i < 3; i++) { + const input = screen.getByPlaceholderText('Type "REFUND PLT-10042"'); + fireEvent.change(input, { target: { value: "REFUND PLT-10042" } }); + const submitBtn = screen.getByRole("button", { name: "Process Refund" }); + fireEvent.click(submitBtn); + } + + // On 4th attempt, cooldown notice should activate + rerender( + + ); + + expect(screen.getByText("Rapid Confirm Cooldown Active")).toBeInTheDocument(); + expect(screen.getByText(/Multiple rapid destructive actions detected/i)).toBeInTheDocument(); + }); + + it("supports role grant step-up confirmation with custom phrase", () => { + const handleConfirm = vi.fn(); + render( + + ); + + expect(screen.getByText("GRANT SUPER_ADMIN bilal@deenbridge.org")).toBeInTheDocument(); + + const input = screen.getByPlaceholderText('Type "GRANT SUPER_ADMIN bilal@deenbridge.org"'); + fireEvent.change(input, { target: { value: "GRANT SUPER_ADMIN bilal@deenbridge.org" } }); + + const btn = screen.getByRole("button", { name: "Grant Role" }); + expect(btn).not.toBeDisabled(); + fireEvent.click(btn); + + expect(handleConfirm).toHaveBeenCalledWith("GRANT SUPER_ADMIN bilal@deenbridge.org"); + }); +}); diff --git a/app/[locale]/admin/payments/disputes/page.jsx b/app/[locale]/admin/payments/disputes/page.jsx index a3c00e3e..bfd6a39b 100644 --- a/app/[locale]/admin/payments/disputes/page.jsx +++ b/app/[locale]/admin/payments/disputes/page.jsx @@ -61,8 +61,10 @@ import { Loader2, } from "lucide-react"; import { cn } from "@/lib/utils"; -import { poppins_400, poppins_500, poppins_600 } from "@/lib/config/font.config"; +import { poppins_400, poppins_500 } from "@/lib/config/font.config"; import { formatDistanceToNow } from "date-fns"; +import StepUpConfirmModal from "@/components/admin/StepUpConfirmModal"; +import { toast } from "sonner"; const DISPUTE_STATES = { open: { @@ -200,6 +202,10 @@ export default function DisputesPage() { const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 10; + // Refund Step-Up Modal State (#311) + const [refundModalOpen, setRefundModalOpen] = useState(false); + const [refundTargetDispute, setRefundTargetDispute] = useState(null); + const filteredDisputes = useMemo(() => { if (statusFilter === "all") return disputes; return disputes.filter((d) => d.state === statusFilter); @@ -239,6 +245,21 @@ export default function DisputesPage() { ); }, []); + const handleTriggerRefundStepUp = (dispute) => { + setRefundTargetDispute(dispute); + setRefundModalOpen(true); + }; + + const handleConfirmRefund = async () => { + if (!refundTargetDispute) return; + updateDisputeState( + refundTargetDispute.id, + "resolved-refund", + "Full refund issued after step-up verification." + ); + toast.success(`Refund authorized for transaction ${refundTargetDispute.transactionId}`); + }; + const requestEvidence = useCallback((disputeId, target) => { setDisputes((prev) => prev.map((d) => @@ -263,7 +284,7 @@ export default function DisputesPage() { Open - {statusCounts.open} + {statusCounts.open || 0} Awaiting Evidence - {statusCounts["awaiting-evidence"]} + {statusCounts["awaiting-evidence"] || 0} Refunded - {statusCounts["resolved-refund"]} + {statusCounts["resolved-refund"] || 0} Rejected - {statusCounts["resolved-rejected"]} + {statusCounts["resolved-rejected"] || 0} @@ -332,7 +353,7 @@ export default function DisputesPage() {
- +
Opened @@ -340,7 +361,6 @@ export default function DisputesPage() { Educator Item Amount - Age State @@ -349,7 +369,7 @@ export default function DisputesPage() { {paginatedDisputes.length === 0 ? ( @@ -399,22 +419,16 @@ export default function DisputesPage() { ${dispute.amount.toFixed(2)} - -
- - {formatAge(dispute.openedAt)} -
-
- {stateConfig.label} + {stateConfig?.label} @@ -443,48 +457,13 @@ export default function DisputesPage() { { e.stopPropagation(); - requestEvidence(dispute.id, "buyer"); + handleTriggerRefundStepUp(dispute); }} + disabled={dispute.state.startsWith("resolved")} > - - Request Evidence from Buyer - - { - e.stopPropagation(); - requestEvidence(dispute.id, "educator"); - }} - > - - Request Evidence from Educator - - - { - e.stopPropagation(); - updateDisputeState( - dispute.id, - "resolved-refund", - "Full refund issued after review." - ); - }} - > - + Resolve with Refund - { - e.stopPropagation(); - updateDisputeState( - dispute.id, - "resolved-rejected", - "Dispute rejected after review." - ); - }} - > - - Reject Dispute - @@ -538,6 +517,16 @@ export default function DisputesPage() { {selectedDispute && ( + <> + + + + Dispute {selectedDispute.id} + + + Opened {formatDate(selectedDispute.openedAt)} ·{" "} + {formatAge(selectedDispute.openedAt)} +
@@ -563,68 +552,12 @@ export default function DisputesPage() { - {/* Transaction Context */} - - -

- Linked Transaction -

-
-
- Transaction ID:{" "} - {selectedDispute.transactionId} -
-
- Amount:{" "} - - ${selectedDispute.amount.toFixed(2)} - -
-
- Item:{" "} - - {selectedDispute.item.type} - - {selectedDispute.item.name} -
-
- State:{" "} - - {DISPUTE_STATES[selectedDispute.state]?.label} - -
-
-
-
- {/* Parties */}
- {/* Buyer Statement */} - - - - {selectedDispute.buyer.name.charAt(0)} - - - Buyer + + Buyer: {selectedDispute.buyer.name} {selectedDispute.buyer.name} ·{" "} @@ -634,27 +567,14 @@ export default function DisputesPage() { -

- {selectedDispute.buyerStatement} -

+

{selectedDispute.buyerStatement}

- {/* Educator Statement */} - - - - {selectedDispute.educator.name.charAt(0)} - - - Educator + + Educator: {selectedDispute.educator.name} {selectedDispute.educator.name} ·{" "} @@ -664,13 +584,14 @@ export default function DisputesPage() { -

- {selectedDispute.educatorStatement} -

+

{selectedDispute.educatorStatement}

+ {/* Resolution Actions */} + {selectedDispute.state === "open" || selectedDispute.state === "awaiting-evidence" ? ( + {/* Evidence Request */} {selectedDispute.evidenceRequest && ( @@ -741,16 +662,10 @@ export default function DisputesPage() { variant="default" size="sm" className="bg-green-600 hover:bg-green-700" - onClick={() => - updateDisputeState( - selectedDispute.id, - "resolved-refund", - "Full refund issued after review." - ) - } + onClick={() => handleTriggerRefundStepUp(selectedDispute)} > - Resolve with Refund + Resolve with Refund (Step-Up)
+ + {/* Refund Step-Up Confirmation Modal (#311) */} + {refundTargetDispute && ( + + )} ); } diff --git a/app/[locale]/admin/users/page.jsx b/app/[locale]/admin/users/page.jsx new file mode 100644 index 00000000..247cd63d --- /dev/null +++ b/app/[locale]/admin/users/page.jsx @@ -0,0 +1,267 @@ +"use client"; + +import { useState } from "react"; +import { PageShell } from "@/components/ui/page-shell"; +import { PageHeader } from "@/components/ui/page-header"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Users, + ShieldAlert, + ShieldCheck, + UserX, + MoreVertical, + UserCheck, + Crown, +} from "lucide-react"; +import { banUser } from "@/lib/actions/admin-users"; +import StepUpConfirmModal from "@/components/admin/StepUpConfirmModal"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; + +const mockAdminUsers = [ + { + id: "usr_001", + name: "Ahmad Patel", + email: "ahmad@deenbridge.org", + role: "student", + status: "active", + walletAddress: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335WFOPVQOI3ZFZG3KA4YAOMNEB", + createdAt: "2025-01-10T10:00:00Z", + }, + { + id: "usr_002", + name: "Bilal Karim", + email: "bilal@deenbridge.org", + role: "staff", + status: "active", + walletAddress: "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567", + createdAt: "2025-02-01T14:30:00Z", + }, + { + id: "usr_003", + name: "Zaynab Idris", + email: "zaynab@deenbridge.org", + role: "educator", + status: "active", + walletAddress: "GC3BDSVU7WAKCCGLTDTBQLP3Y4S7G45P6W6Y5Z2XJ3K4L5M6N7P8Q111", + createdAt: "2025-03-12T09:15:00Z", + }, +]; + +export default function AdminUsersManagementPage() { + const [users, setUsers] = useState(mockAdminUsers); + const [selectedUser, setSelectedUser] = useState(null); + const [pendingRole, setPendingRole] = useState(null); + + // Modal open states + const [banModalOpen, setBanModalOpen] = useState(false); + const [roleModalOpen, setRoleModalOpen] = useState(false); + + const handleOpenBanModal = (user) => { + setSelectedUser(user); + setBanModalOpen(true); + }; + + const handleOpenRoleModal = (user, newRole) => { + setSelectedUser(user); + setPendingRole(newRole); + setRoleModalOpen(true); + }; + + const handleConfirmBan = async () => { + if (!selectedUser) return; + try { + const result = await banUser(selectedUser.id, { + email: selectedUser.email, + reason: "Admin moderation ban via step-up verification", + }); + + setUsers((prev) => + prev.map((u) => (u.id === selectedUser.id ? { ...u, status: "banned" } : u)) + ); + toast.success(`User ${selectedUser.email} has been banned.`); + } catch (err) { + toast.error("Failed to execute user ban."); + } + }; + + const handleConfirmRoleGrant = async () => { + if (!selectedUser || !pendingRole) return; + try { + setUsers((prev) => + prev.map((u) => + u.id === selectedUser.id ? { ...u, role: pendingRole } : u + ) + ); + toast.success( + `Role '${pendingRole.toUpperCase()}' granted to ${selectedUser.email}.` + ); + } catch (err) { + toast.error("Failed to update user role."); + } + }; + + return ( + + + + + + Platform Users ({users.length}) + + High-privilege admin operations require step-up phrase confirmation + + + +
+
+ + + User + Role + Status + Wallet + Actions + + + + {users.map((u) => ( + + +
+ + + {u.name.charAt(0)} + + +
+

{u.name}

+

{u.email}

+
+
+
+ + + + {u.role} + + + + + + {u.status} + + + + + {u.walletAddress ? `${u.walletAddress.slice(0, 6)}...${u.walletAddress.slice(-6)}` : "N/A"} + + + + + + + + + handleOpenRoleModal(u, "super_admin")}> + + Grant Super Admin Role + + handleOpenRoleModal(u, "staff")}> + + Grant Staff Role + + + handleOpenBanModal(u)} + className="text-red-600 focus:text-red-600" + disabled={u.status === "banned"} + > + + Ban User Account + + + + +
+ ))} +
+
+
+
+ + + {/* Ban Flow Step-Up Confirmation Modal */} + {selectedUser && ( + + )} + + {/* Role Grant Flow Step-Up Confirmation Modal */} + {selectedUser && pendingRole && ( + + )} + + ); +} diff --git a/components/admin/StepUpConfirmModal.jsx b/components/admin/StepUpConfirmModal.jsx new file mode 100644 index 00000000..76b8f2d9 --- /dev/null +++ b/components/admin/StepUpConfirmModal.jsx @@ -0,0 +1,228 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + AlertTriangle, + ShieldAlert, + Loader2, + Lock, + Clock, + CheckCircle, +} from "lucide-react"; +import { + checkRateLimit, + recordActionAttempt, +} from "@/lib/utils/rateLimiter"; +import { cn } from "@/lib/utils"; + +export default function StepUpConfirmModal({ + open, + onOpenChange, + title = "Confirm Sensitive Action", + description = "This action is destructive and irreversible. Please verify by typing the phrase below.", + targetName = "", + actionVerb = "CONFIRM", + expectedPhrase: customExpectedPhrase, + confirmVariant = "destructive", + confirmText = "Confirm Action", + onConfirm, + rateLimitKey = "admin_sensitive_action", +}) { + const [typedPhrase, setTypedPhrase] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [cooldownSec, setCooldownSec] = useState(0); + + // Derive expected phrase from actionVerb & targetName if not custom + const expectedPhrase = useMemo(() => { + if (customExpectedPhrase) return customExpectedPhrase; + const verb = actionVerb ? actionVerb.toUpperCase() : "CONFIRM"; + return targetName ? `${verb} ${targetName}` : verb; + }, [customExpectedPhrase, actionVerb, targetName]); + + // Check rate limit on open and set up cooldown timer if active + useEffect(() => { + if (!open) { + setTypedPhrase(""); + setIsSubmitting(false); + return; + } + + const rateStatus = checkRateLimit(rateLimitKey); + if (!rateStatus.allowed) { + setCooldownSec(rateStatus.cooldownSec); + } else { + setCooldownSec(0); + } + }, [open, rateLimitKey]); + + // Countdown timer interval for active cooldown + useEffect(() => { + if (cooldownSec <= 0) return; + + const timer = setInterval(() => { + setCooldownSec((prev) => { + if (prev <= 1) { + clearInterval(timer); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + }, [cooldownSec]); + + const isMatched = typedPhrase.trim() === expectedPhrase; + const isBlockedByCooldown = cooldownSec > 0; + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!isMatched || isBlockedByCooldown || isSubmitting) return; + + // Check rate limit right before submitting + const rateStatus = checkRateLimit(rateLimitKey); + if (!rateStatus.allowed) { + setCooldownSec(rateStatus.cooldownSec); + return; + } + + setIsSubmitting(true); + try { + recordActionAttempt(rateLimitKey); + if (onConfirm) { + await onConfirm(expectedPhrase); + } + onOpenChange(false); + } catch (err) { + console.error("Step-up action error:", err); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + +
+ + + + {title} + + + {description} + + + + {/* Target & Expected Phrase Display */} +
+
+ Target Record: + + {targetName || "N/A"} + +
+
+ + Type exact phrase to authorize: + +

+ {expectedPhrase} +

+
+
+ + {/* Rate Limit Cooldown Notice Alert */} + {isBlockedByCooldown && ( +
+ +
+

Rapid Confirm Cooldown Active

+

+ Multiple rapid destructive actions detected. Please wait{" "} + + {cooldownSec}s + {" "} + before confirming. +

+
+
+ )} + + {/* Typed Verification Input */} +
+ + setTypedPhrase(e.target.value)} + disabled={isBlockedByCooldown || isSubmitting} + autoComplete="off" + className={cn( + "text-xs font-mono transition-colors", + isMatched && "border-green-500 focus-visible:ring-green-500 bg-green-50/20" + )} + /> +
+ + + + + +
+
+
+ ); +} diff --git a/lib/utils/rateLimiter.js b/lib/utils/rateLimiter.js new file mode 100644 index 00000000..3a3074ac --- /dev/null +++ b/lib/utils/rateLimiter.js @@ -0,0 +1,89 @@ +/** + * Client-side Rate Limiter Utility for Sensitive Admin Actions (#311). + * --------------------------------------------------------------------------- + * Implements a sliding-window rate limiter to detect rapid repeated confirmations + * and enforce a cooldown period to prevent accidental or scripted fat-finger operations. + */ + +// In-memory timestamp store for rapid actions +const actionHistory = new Map(); +const activeCooldowns = new Map(); + +const DEFAULT_MAX_ATTEMPTS = 3; +const DEFAULT_WINDOW_MS = 30000; // 30 seconds +const DEFAULT_COOLDOWN_MS = 15000; // 15 seconds + +/** + * Check if an action is currently rate-limited or in a cooldown state. + * + * @param {string} [key="default"] Action identifier key (e.g. "admin_confirm_action") + * @param {number} [maxAttempts=3] Max allowed confirms in window + * @param {number} [windowMs=30000] Sliding window in ms + * @param {number} [cooldownMs=15000] Cooldown duration in ms + * @returns {{ allowed: boolean, remainingMs: number, cooldownSec: number }} + */ +export function checkRateLimit( + key = "default", + maxAttempts = DEFAULT_MAX_ATTEMPTS, + windowMs = DEFAULT_WINDOW_MS, + cooldownMs = DEFAULT_COOLDOWN_MS +) { + const now = Date.now(); + + // Check if actively in cooldown + const cooldownUntil = activeCooldowns.get(key); + if (cooldownUntil && now < cooldownUntil) { + const remainingMs = cooldownUntil - now; + return { + allowed: false, + remainingMs, + cooldownSec: Math.ceil(remainingMs / 1000), + }; + } else if (cooldownUntil) { + activeCooldowns.delete(key); + } + + // Filter timestamps within the sliding window + const history = actionHistory.get(key) || []; + const recentHistory = history.filter((ts) => now - ts < windowMs); + actionHistory.set(key, recentHistory); + + if (recentHistory.length >= maxAttempts) { + // Trigger new cooldown + const newCooldownUntil = now + cooldownMs; + activeCooldowns.set(key, newCooldownUntil); + return { + allowed: false, + remainingMs: cooldownMs, + cooldownSec: Math.ceil(cooldownMs / 1000), + }; + } + + return { + allowed: true, + remainingMs: 0, + cooldownSec: 0, + }; +} + +/** + * Record a successful confirmation action execution. + * + * @param {string} [key="default"] + */ +export function recordActionAttempt(key = "default") { + const now = Date.now(); + const history = actionHistory.get(key) || []; + history.push(now); + actionHistory.set(key, history); +} + +/** + * Reset rate limit history for a key (for testing or reset flows). + * + * @param {string} [key="default"] + */ +export function resetRateLimit(key = "default") { + actionHistory.delete(key); + activeCooldowns.delete(key); +}