diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index 8afb7b47..770767cd 100644 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -79,6 +79,35 @@ export async function getPendingQueue(req, res) { (u) => !pendingCollegeRequesterIds.has(u._id.toString()) ); + const reviewHistoryByUser = new Map(); + if (individualTpoRequests.length) { + const reviews = await TpoVerificationReview.find({ + userId: { $in: individualTpoRequests.map((u) => u._id) }, + }) + .populate("reviewedBy", "displayName email") + .sort({ reviewedAt: -1 }) + .lean(); + + for (const review of reviews) { + const key = review.userId.toString(); + const history = reviewHistoryByUser.get(key) || []; + if (history.length < 5) { + history.push({ + decision: review.decision, + decisionReason: review.decisionReason, + reviewedAt: review.reviewedAt, + reviewedBy: review.reviewedBy + ? { + displayName: review.reviewedBy.displayName || null, + email: review.reviewedBy.email || null, + } + : null, + }); + reviewHistoryByUser.set(key, history); + } + } + } + const studentColleges = pendingColleges.filter( (c) => c.submittedByRole === "student" || c.submittedByRole === "auto" ); @@ -128,6 +157,7 @@ export async function getPendingQueue(req, res) { verificationStatus: u.tpoVerification?.status || "pending", additionalEvidenceRecommended: (u.tpoVerification?.emailRoleSignal || "unknown") !== "staff_candidate", evidence: u.tpoVerification?.evidence || [], + reviewHistory: reviewHistoryByUser.get(u._id.toString()) || [], reviewTarget: "user", })), ], @@ -1027,11 +1057,45 @@ async function resolvePendingTpoCollege(user) { return College.findByDomain(domain); } +function getReviewDecisionReason(req, { required = false } = {}) { + const raw = req.body?.decisionReason; + if (raw != null && typeof raw !== "string") { + return { error: "decisionReason must be a string." }; + } + if (raw == null) { + if (required) { + return { error: "A decision reason is required when rejecting a TPO verification request." }; + } + return { value: null }; + } + + const value = String(raw).trim(); + if (!value) { + if (required) { + return { error: "A decision reason is required when rejecting a TPO verification request." }; + } + return { value: null }; + } + + if (value.length > 1000) { + return { error: "Decision reason must be at most 1000 characters." }; + } + + return { value }; +} + export async function approveTpoUser(req, res) { try { const user = await User.findById(req.params.userId); if (!user || user.role !== "tpo") return res.status(404).json({ error: "TPO verification request not found." }); if (user.tpoProfile?.verified) return res.json({ success: true, alreadyVerified: true }); + if (user.tpoVerification?.status !== "pending") { + return res.status(409).json({ error: "Only a pending TPO verification request can be approved." }); + } + + const reasonResult = getReviewDecisionReason(req); + if (reasonResult.error) return res.status(400).json({ error: reasonResult.error }); + const decisionReason = reasonResult.value || "Approved through individual TPO verification."; const college = await resolvePendingTpoCollege(user); if (!college || college.status !== "verified") { @@ -1053,7 +1117,7 @@ export async function approveTpoUser(req, res) { emailRoleSignal: user.tpoVerification?.emailRoleSignal || "unknown", evidence: user.tpoVerification?.evidence || [], decision: "approved", - decisionReason: "Approved through individual TPO verification.", + decisionReason, reviewedBy: req.actingAdminDoc?._id || req.userDoc?._id, reviewedAt: now, }); @@ -1091,6 +1155,13 @@ export async function rejectTpoUser(req, res) { try { const user = await User.findById(req.params.userId); if (!user || user.role !== "tpo") return res.status(404).json({ error: "TPO verification request not found." }); + if (user.tpoVerification?.status !== "pending") { + return res.status(409).json({ error: "Only a pending TPO verification request can be rejected." }); + } + + const reasonResult = getReviewDecisionReason(req, { required: true }); + if (reasonResult.error) return res.status(400).json({ error: reasonResult.error }); + const decisionReason = reasonResult.value; const college = await resolvePendingTpoCollege(user); const reviewerId = req.actingAdminDoc?._id || req.userDoc?._id; @@ -1105,7 +1176,7 @@ export async function rejectTpoUser(req, res) { emailRoleSignal: user.tpoVerification?.emailRoleSignal || "unknown", evidence: user.tpoVerification?.evidence || [], decision: "rejected", - decisionReason: "Individual TPO verification request rejected.", + decisionReason, reviewedBy: reviewerId, reviewedAt: now, }); @@ -1115,6 +1186,10 @@ export async function rejectTpoUser(req, res) { } user.revokeRole("tpo"); + // Keep the authorization set consistent even if a lightweight test/mock + // user does not implement revokeRole exactly like the Mongoose model. + user.roles = (user.roles || []).filter((role) => role !== "tpo"); + if (!user.roles.includes("student")) user.roles.unshift("student"); user.role = "student"; user.tpoProfile = { collegeDomain: null, collegeName: null, verified: false, requestedAt: null, verifiedAt: null }; user.tpoVerification = { ...(user.tpoVerification || {}), status: "rejected" }; diff --git a/backend/controllers/adminController.test.js b/backend/controllers/adminController.test.js index 1f55d5c9..81a4be4a 100644 --- a/backend/controllers/adminController.test.js +++ b/backend/controllers/adminController.test.js @@ -1,7 +1,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("../models/TpoVerificationReview.js", () => ({ - default: { create: vi.fn().mockResolvedValue({}) }, + default: { + create: vi.fn().mockResolvedValue({}), + find: vi.fn(), + }, })); vi.mock("../models/College.js", () => ({ default: { @@ -143,6 +146,7 @@ describe("adminController", () => { beforeEach(() => { vi.clearAllMocks(); + TpoVerificationReview.find.mockReturnValue(chainableQuery([])); res = mockRes(); }); @@ -186,6 +190,59 @@ describe("adminController", () => { ); }); + it("includes recent review history for a pending individual TPO applicant", async () => { + User.find.mockReturnValueOnce(chainableQuery([])); + College.find.mockReturnValueOnce(chainableQuery([])); + User.find.mockReturnValueOnce( + chainableQuery([ + { + _id: "tpo1", + email: "staff@mit.edu", + displayName: "Staff", + tpoProfile: { collegeName: "MIT", collegeDomain: "mit.edu", requestedAt: "now" }, + tpoVerification: { + status: "pending", + emailRoleSignal: "staff_candidate", + submittedAt: "now", + evidence: [{ kind: "staff_id", label: "Staff ID" }], + }, + createdAt: "created", + }, + ]) + ); + TpoVerificationReview.find.mockReturnValueOnce( + chainableQuery([ + { + userId: "tpo1", + decision: "rejected", + decisionReason: "Previous evidence was insufficient.", + reviewedAt: "2026-09-20T10:00:00Z", + reviewedBy: "admin1", + }, + ]) + ); + + await getPendingQueue({}, res); + + expect(TpoVerificationReview.find).toHaveBeenCalledWith({ userId: { $in: ["tpo1"] } }); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + tpos: [ + expect.objectContaining({ + userId: "tpo1", + reviewTarget: "user", + reviewHistory: [ + expect.objectContaining({ + decision: "rejected", + decisionReason: "Previous evidence was insufficient.", + }), + ], + }), + ], + }) + ); + }); + it("returns 500 if the query fails", async () => { User.find.mockImplementationOnce(() => { throw new Error("db down"); @@ -245,6 +302,143 @@ describe("adminController", () => { }); }); + describe("individual TPO verification decisions", () => { + function makePendingTpo(overrides = {}) { + return makeUser({ + _id: "tpo1", + firebaseUid: "fb-tpo1", + email: "staff@mit.edu", + role: "tpo", + roles: ["student", "tpo"], + tpoProfile: { + collegeDomain: "mit.edu", + collegeName: "MIT", + verified: false, + requestedAt: "request-time", + verifiedAt: null, + }, + tpoVerification: { + status: "pending", + emailRoleSignal: "staff_candidate", + submittedEmail: "staff@mit.edu", + submittedAt: "request-time", + evidence: [], + }, + ...overrides, + }); + } + + it("approves only a pending applicant, records the review reason, and claims primary after approval", async () => { + const user = makePendingTpo(); + const college = { + _id: "c1", + name: "MIT", + status: "verified", + domains: ["mit.edu"], + }; + const admin = makeAdmin(); + User.findById.mockResolvedValueOnce(user); + College.findByDomain.mockResolvedValueOnce(college); + claimPrimaryIfNone.mockResolvedValueOnce(true); + + await approveTpoUser({ + params: { userId: "tpo1" }, + body: { decisionReason: "Staff identity matched the submitted institutional evidence." }, + userDoc: admin, + }, res); + + expect(user.tpoProfile.verified).toBe(true); + expect(user.tpoVerification.status).toBe("approved"); + expect(user.save).toHaveBeenCalledOnce(); + expect(TpoVerificationReview.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "tpo1", + collegeId: "c1", + decision: "approved", + decisionReason: "Staff identity matched the submitted institutional evidence.", + reviewedBy: "admin1", + }) + ); + expect(claimPrimaryIfNone).toHaveBeenCalledWith("c1", "tpo1"); + expect(recordAdminAction).toHaveBeenCalledWith( + expect.objectContaining({ action: "tpo.user.approve", targetType: "User", targetId: "tpo1" }) + ); + expect(res.json).toHaveBeenCalledWith({ success: true }); + }); + + it("rejects without mutating the applicant when a non-pending request is reviewed", async () => { + const user = makePendingTpo({ + tpoVerification: { + status: "rejected", + emailRoleSignal: "unknown", + submittedEmail: "staff@mit.edu", + evidence: [], + }, + }); + User.findById.mockResolvedValueOnce(user); + + await rejectTpoUser({ params: { userId: "tpo1" }, body: { decisionReason: "Insufficient evidence." }, userDoc: makeAdmin() }, res); + + expect(res.status).toHaveBeenCalledWith(409); + expect(user.save).not.toHaveBeenCalled(); + expect(TpoVerificationReview.create).not.toHaveBeenCalled(); + }); + + it("requires a reason when rejecting a pending applicant", async () => { + const user = makePendingTpo(); + User.findById.mockResolvedValueOnce(user); + + await rejectTpoUser({ params: { userId: "tpo1" }, body: {}, userDoc: makeAdmin() }, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(user.save).not.toHaveBeenCalled(); + expect(TpoVerificationReview.create).not.toHaveBeenCalled(); + }); + + it("rejects a pending applicant and preserves the reason in the immutable review", async () => { + const user = makePendingTpo(); + const college = { _id: "c1", name: "MIT", status: "verified", domains: ["mit.edu"] }; + const admin = makeAdmin(); + User.findById.mockResolvedValueOnce(user); + College.findByDomain.mockResolvedValueOnce(college); + + await rejectTpoUser({ + params: { userId: "tpo1" }, + body: { decisionReason: "The submitted staff evidence could not be verified." }, + userDoc: admin, + }, res); + + expect(user.role).toBe("student"); + expect(user.roles).toEqual(["student"]); + expect(user.tpoVerification.status).toBe("rejected"); + expect(TpoVerificationReview.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "tpo1", + collegeId: "c1", + decision: "rejected", + decisionReason: "The submitted staff evidence could not be verified.", + reviewedBy: "admin1", + }) + ); + expect(invalidateCachedUserByFirebaseUid).toHaveBeenCalledWith("fb-tpo1"); + expect(res.json).toHaveBeenCalledWith({ success: true }); + }); + + it("does not allow an oversized decision reason", async () => { + const user = makePendingTpo(); + User.findById.mockResolvedValueOnce(user); + + await rejectTpoUser({ + params: { userId: "tpo1" }, + body: { decisionReason: "x".repeat(1001) }, + userDoc: makeAdmin(), + }, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(user.save).not.toHaveBeenCalled(); + }); + }); + describe("approveTpo", () => { it("verifies the college without bulk-authorizing individual TPOs", async () => { const college = { @@ -1173,11 +1367,18 @@ describe("individual TPO verification", () => { }, tpoVerification: { status: "pending", emailRoleSignal: "student_candidate", evidence: [] }, }); + // Deliberately make revokeRole a no-op here. The controller must + // enforce the persisted authorization invariant itself: TPO is removed + // from roles and student remains available after an individual rejection. + user.revokeRole = vi.fn(); User.findById.mockResolvedValueOnce(user); College.findByDomain.mockResolvedValueOnce({ _id: "c1", name: "MIT", status: "verified", domains: ["mit.edu"] }); const res = { status: vi.fn().mockReturnThis(), json: vi.fn() }; - await rejectTpoUser({ params: { userId: "t2" }, userDoc: makeAdmin() }, res); + await rejectTpoUser( + { params: { userId: "t2" }, userDoc: makeAdmin(), body: { decisionReason: "Staff identity could not be verified." } }, + res + ); expect(user.roles).toEqual(["student"]); expect(user.role).toBe("student"); diff --git a/backend/routes/tpo.js b/backend/routes/tpo.js index 5b5ea7b7..f819e328 100644 --- a/backend/routes/tpo.js +++ b/backend/routes/tpo.js @@ -796,12 +796,11 @@ router.get("/team", requireRole("tpo", "admin"), requireVerified, resolveTpoInst // the candidate must already have signed up (and therefore already gone // through Firebase auth) before the primary can add them, mirroring the // "candidate authenticates, then institution relationship established" -// flow the phase doc describes. This is the same instant-verification -// trust decision POST /register already makes for a second TPO registering -// on an already-verified college domain (see isDomainAutoVerified/ -// existingCollege.status === "verified" above): a known institutional- -// domain account, vouched for by the college's own primary TPO, doesn't -// need a second manual admin review. +// flow the phase doc describes. Unlike self-registration, however, +// this path is an explicit authorization action by an already-verified +// primary TPO. The primary's authenticated team-management action is the +// authority for this grant; institutional email/domain checks only enforce +// that the invited account belongs to the same college. router.post("/team/invite", requireRole("tpo", "admin"), requireVerified, resolveTpoInstitution, requirePrimaryOnly, async (req, res) => { if (b2bGate(req, res)) return; diff --git a/backend/routes/tpoFlow.integration.test.js b/backend/routes/tpoFlow.integration.test.js index 3c068b6a..958b9414 100644 --- a/backend/routes/tpoFlow.integration.test.js +++ b/backend/routes/tpoFlow.integration.test.js @@ -14,7 +14,7 @@ const { default: User } = await import("../models/User.js"); const { default: College } = await import("../models/College.js"); const { requireRole } = await import("../middleware/roleGuard.js"); const { requireVerified } = await import("../middleware/requireVerified.js"); -const { approveTpo, rejectTpo, approveTpoUser } = await import("../controllers/adminController.js"); +const { approveTpo, rejectTpo, approveTpoUser, rejectTpoUser } = await import("../controllers/adminController.js"); const { claimPrimaryIfNone, transferPrimary } = await import("../services/tpoTeamService.js"); function extractRegisterHandler() { @@ -190,6 +190,63 @@ describe("TPO registration → pending → verification → TPO-only endpoint (r expect(gateOutcome).toBe("role"); }); + it("rejecting an individual TPO request demotes the applicant, preserves audit history, and allows a fresh resubmission", async () => { + const user = await seedStudent({ email: "tpo-resubmit@verified-college.ac.in" }); + await registerHandler( + { userDoc: user, log: mockLog(), body: { collegeName: "Verified College" } }, + mockRes() + ); + + const admin = await User.create({ + firebaseUid: "fb-admin-resubmit", + email: "admin-resubmit@codeclub.test", + role: "admin", + }); + const college = await College.findByDomain("verified-college.ac.in"); + await approveTpo( + { params: { collegeId: college._id.toString() }, userDoc: admin, log: mockLog() }, + mockRes() + ); + + const pendingUser = await User.findById(user._id); + const rejectRes = mockRes(); + await rejectTpoUser({ + params: { userId: user._id.toString() }, + body: { decisionReason: "The submitted staff evidence could not be verified." }, + userDoc: admin, + log: mockLog(), + }, rejectRes); + + expect(rejectRes._json).toEqual({ success: true }); + + const rejectedUser = await User.findById(user._id); + expect(rejectedUser.role).toBe("student"); + expect(rejectedUser.tpoProfile.verified).toBe(false); + expect(rejectedUser.tpoVerification.status).toBe("rejected"); + + const Review = (await import("../models/TpoVerificationReview.js")).default; + const review = await Review.findOne({ userId: user._id }).sort({ reviewedAt: -1 }); + expect(review.decision).toBe("rejected"); + expect(review.decisionReason).toBe("The submitted staff evidence could not be verified."); + + const resubmitRes = mockRes(); + await registerHandler( + { userDoc: rejectedUser, log: mockLog(), body: { collegeName: "Verified College" } }, + resubmitRes + ); + + expect(resubmitRes._json).toEqual(expect.objectContaining({ + success: true, + status: "pending", + verified: false, + })); + + const pendingAgain = await User.findById(user._id); + expect(pendingAgain.role).toBe("tpo"); + expect(pendingAgain.tpoVerification.status).toBe("pending"); + expect(pendingAgain.tpoProfile.verified).toBe(false); + }); + it("admin behavior: an admin account itself always passes any role gate, TPO included", async () => { const admin = await User.create({ firebaseUid: "fb-admin-5", email: "admin5@codeclub.test", role: "admin" }); const outcome = await runTpoOnlyGate({ userDoc: admin }); @@ -332,6 +389,13 @@ describe("TPO registration → pending → verification → TPO-only endpoint (r verified: false, requestedAt: new Date(Date.now() - 1000), }, + tpoVerification: { + status: "pending", + submittedEmail: "early-signup@some-college.ac.in", + submittedAt: new Date(Date.now() - 1000), + emailRoleSignal: "unknown", + evidence: [], + }, }); const later = await seedStudent({ email: "later-signup@some-college.ac.in", @@ -343,6 +407,13 @@ describe("TPO registration → pending → verification → TPO-only endpoint (r verified: false, requestedAt: new Date(), }, + tpoVerification: { + status: "pending", + submittedEmail: "later-signup@some-college.ac.in", + submittedAt: new Date(), + emailRoleSignal: "unknown", + evidence: [], + }, }); const admin = await User.create({ firebaseUid: "fb-admin-8", email: "admin8@codeclub.test", role: "admin" }); @@ -356,14 +427,21 @@ describe("TPO registration → pending → verification → TPO-only endpoint (r expect(reloadedLater.tpoProfile.verified).toBe(false); expect(reloadedCollege.primaryTpo).toBeNull(); + const earlierApproval = mockRes(); await approveTpoUser( { params: { userId: earlier._id.toString() }, userDoc: admin, log: mockLog() }, - mockRes() + earlierApproval ); + expect(earlierApproval._status).toBe(200); + expect(earlierApproval._json.success).toBe(true); + + const laterApproval = mockRes(); await approveTpoUser( { params: { userId: later._id.toString() }, userDoc: admin, log: mockLog() }, - mockRes() + laterApproval ); + expect(laterApproval._status).toBe(200); + expect(laterApproval._json.success).toBe(true); const approvedEarlier = await User.findById(earlier._id); const approvedLater = await User.findById(later._id); diff --git a/backend/routes/tpoRegisterHardening.test.js b/backend/routes/tpoRegisterHardening.test.js index a01774b0..e416ddd5 100644 --- a/backend/routes/tpoRegisterHardening.test.js +++ b/backend/routes/tpoRegisterHardening.test.js @@ -192,6 +192,52 @@ describe("POST /register — TPO-1 hardening: partial-failure handling", () => { })); }); + it("allows a rejected applicant to submit a fresh pending request for a verified college", async () => { + College.findByDomain.mockResolvedValueOnce({ + _id: "college-id", + status: "verified", + submittedByRole: "tpo", + domains: ["newcollege.ac.in"], + }); + + const userDoc = makeUserDoc({ + role: "student", + roles: ["student"], + tpoProfile: { + collegeDomain: null, + collegeName: null, + verified: false, + requestedAt: null, + verifiedAt: null, + }, + tpoVerification: { + status: "rejected", + emailRoleSignal: "student_candidate", + submittedEmail: "old@newcollege.ac.in", + submittedAt: new Date("2026-09-20T00:00:00Z"), + evidence: [{ kind: "manual_note", label: "Old review", reference: null, note: null }], + }, + }); + const res = mockRes(); + + await registerHandler( + { userDoc, log: mockLog(), body: { collegeName: "New College" } }, + res + ); + + expect(userDoc.grantRole).toHaveBeenCalledWith("tpo"); + expect(userDoc.tpoProfile.verified).toBe(false); + expect(userDoc.tpoVerification.status).toBe("pending"); + expect(userDoc.tpoVerification.submittedEmail).toBe("founder@newcollege.ac.in"); + expect(res._status).toBe(201); + expect(res._json).toEqual(expect.objectContaining({ + success: true, + verified: false, + status: "pending", + })); + expect(claimPrimaryIfNone).not.toHaveBeenCalled(); + }); + it("keeps an unrecognized college and TPO request pending", async () => { College.findByDomain.mockResolvedValueOnce(null); College.create.mockResolvedValueOnce({ _id: "new-college-id" }); diff --git a/docs/api-contracts.md b/docs/api-contracts.md index e1161625..d09aaaec 100644 --- a/docs/api-contracts.md +++ b/docs/api-contracts.md @@ -176,8 +176,10 @@ College email-role patterns are institution-specific advisory evidence. They may | Method | Path | Purpose | |---|---|---| | PATCH | `/api/admin/colleges/:collegeId/email-role-patterns` | Admin-only configuration of staff/student email patterns for a college. | -| POST | `/api/admin/tpo-verification/:userId/approve` | Admin-only approval of an individual pending TPO request after the college is verified. | -| POST | `/api/admin/tpo-verification/:userId/reject` | Admin-only rejection of an individual pending TPO request. | +| POST | `/api/admin/tpo-verification/:userId/approve` | Admin-only approval of an individual pending TPO request after the college is verified. Optional JSON body: `{ "decisionReason": "..." }` (max 1000 chars) for the immutable review audit. | +| POST | `/api/admin/tpo-verification/:userId/reject` | Admin-only rejection of an individual pending TPO request. Requires JSON body: `{ "decisionReason": "..." }` (1–1000 chars); rejected requests must be submitted again to re-enter the pending state. | | POST | `/api/tpo/verification/evidence` | Adds applicant-supplied verification evidence to a pending TPO request; evidence is advisory and does not grant access. | TPO registration keeps the requester pending even when the institution itself is already recognized. This separates **institution trust** from **individual TPO authorization**. Student/staff email patterns are evidence shown to the reviewer, not an authorization shortcut. + +The admin pending queue also exposes the applicant's submitted evidence and up to five most recent immutable review decisions for that user, so a resubmission can be reviewed with prior context. Review decisions are lifecycle-gated: only a request whose `tpoVerification.status` is `pending` can be approved or rejected. Individual approval is the point at which `tpoProfile.verified` and primary-TPO eligibility can be established. diff --git a/src/components/admin/VerificationQueueSection.jsx b/src/components/admin/VerificationQueueSection.jsx index 3ca36fc5..ef836ebd 100644 --- a/src/components/admin/VerificationQueueSection.jsx +++ b/src/components/admin/VerificationQueueSection.jsx @@ -9,55 +9,167 @@ import ConfirmDialog from "../ui/ConfirmDialog"; // way for the admin to undo it from here. It gets a confirmation step; // Approve stays one-click since a fast, low-friction "yes" is exactly // what a review queue should optimize for. -function QueueRow({ title, subtitle, meta, signal, evidenceHint, evidence, onApprove, onReject, busy }) { +function QueueRow({ + title, + subtitle, + meta, + signal, + evidenceHint, + evidence, + reviewHistory, + reviewTarget, + onApprove, + onReject, + busy, +}) { const [confirmingReject, setConfirmingReject] = useState(false); + const [rejectReason, setRejectReason] = useState(""); + const [showDetails, setShowDetails] = useState(false); + const isTpoReview = reviewTarget === "user"; + const hasDetails = + isTpoReview && + ((Array.isArray(evidence) && evidence.length > 0) || + (Array.isArray(reviewHistory) && reviewHistory.length > 0)); + + function closeRejectDialog() { + if (busy === "reject") return; + setConfirmingReject(false); + setRejectReason(""); + } return ( -
-
-

{title}

-

{subtitle}

- {meta &&

{meta}

} - {signal && ( -

- Email signal: {signal.replaceAll("_", " ")} - {evidenceHint ? " · additional evidence recommended" : ""} -

- )} - {Array.isArray(evidence) && evidence.length > 0 && ( -

- Evidence: {evidence.map((item) => item.label).join(", ")} -

- )} -
-
- - +
+
+
+

{title}

+

{subtitle}

+ {meta &&

{meta}

} + {signal && ( +

+ Email signal: {signal.replaceAll("_", " ")} + {evidenceHint ? " · additional evidence recommended" : ""} +

+ )} + {Array.isArray(evidence) && evidence.length > 0 && ( +

+ Evidence: {evidence.map((item) => item.label).join(", ")} +

+ )} +
+ +
+ {hasDetails && ( + + )} + + +
+ {showDetails && ( +
+ {Array.isArray(evidence) && evidence.length > 0 && ( +
+

Submitted evidence

+
+ {evidence.map((item, index) => ( +
+

{item.label}

+

+ {item.kind.replaceAll("_", " ")} + {item.reference ? ` · ${item.reference}` : ""} +

+ {item.note &&

{item.note}

} +
+ ))} +
+
+ )} + + {Array.isArray(reviewHistory) && reviewHistory.length > 0 && ( +
+

Previous review history

+
+ {reviewHistory.map((review, index) => ( +
+

+ {review.decision} + {review.reviewedAt ? ` · ${new Date(review.reviewedAt).toLocaleString()}` : ""} + {review.reviewedBy?.displayName || review.reviewedBy?.email + ? ` · ${review.reviewedBy.displayName || review.reviewedBy.email}` + : ""} +

+ {review.decisionReason && ( +

{review.decisionReason}

+ )} +
+ ))} +
+
+ )} +
+ )} + {confirmingReject && ( { + const reason = rejectReason.trim(); + if (isTpoReview && !reason) return; setConfirmingReject(false); - onReject(); + if (isTpoReview) onReject(reason); + else onReject(); + setRejectReason(""); }} - onCancel={() => setConfirmingReject(false)} - /> + onCancel={closeRejectDialog} + > + {isTpoReview && ( +
+ +