Skip to content

Commit aa9e2eb

Browse files
authored
Merge pull request #34 from hackclub/security
Security
2 parents 854aea1 + 9dc3fae commit aa9e2eb

6 files changed

Lines changed: 499 additions & 172 deletions

File tree

client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,6 @@
1515
},
1616
"devDependencies": {
1717
"@vitejs/plugin-react": "^4.3.4",
18-
"vite": "^5.4.11"
18+
"vite": "^6.2.0"
1919
}
2020
}

client/src/components/AdminReviewPage.css

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,3 +582,86 @@
582582
background: #1a2240;
583583
margin: 0.4rem 0;
584584
}
585+
586+
.admin-review-hours-warning {
587+
margin: 0.35rem 0 0;
588+
color: #ffd4bf;
589+
font-size: 0.85rem;
590+
font-weight: 600;
591+
}
592+
593+
.admin-review-modal-backdrop {
594+
position: fixed;
595+
inset: 0;
596+
z-index: 100;
597+
display: grid;
598+
place-items: center;
599+
padding: 1.5rem;
600+
background: rgba(8, 16, 40, 0.72);
601+
}
602+
603+
.admin-review-modal {
604+
width: min(28rem, 100%);
605+
padding: 1.25rem 1.35rem;
606+
border: 3px solid #f7cf57;
607+
border-radius: 14px;
608+
background: rgba(27, 58, 122, 0.98);
609+
color: #e8f4ff;
610+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
611+
}
612+
613+
.admin-review-modal h2 {
614+
margin: 0 0 0.65rem;
615+
color: #f7cf57;
616+
font-size: 1.2rem;
617+
}
618+
619+
.admin-review-modal p {
620+
margin: 0 0 1rem;
621+
line-height: 1.45;
622+
color: rgba(232, 244, 255, 0.9);
623+
}
624+
625+
.admin-review-hours-approve-cell {
626+
display: grid;
627+
gap: 0.35rem;
628+
}
629+
630+
.admin-review-exceed-ack {
631+
display: flex;
632+
align-items: flex-start;
633+
gap: 0.55rem;
634+
margin-bottom: 1.1rem;
635+
cursor: pointer;
636+
font-weight: 700;
637+
}
638+
639+
.admin-review-exceed-ack--inline {
640+
margin: 0.25rem 0 0;
641+
padding: 0.55rem 0.65rem;
642+
border-radius: 10px;
643+
border: 2px solid rgba(247, 207, 87, 0.55);
644+
background: rgba(247, 207, 87, 0.12);
645+
color: #ffe9a8;
646+
}
647+
648+
.admin-review-exceed-ack input {
649+
width: 1.1rem;
650+
height: 1.1rem;
651+
margin-top: 0.15rem;
652+
flex-shrink: 0;
653+
}
654+
655+
.admin-review-modal-actions {
656+
display: flex;
657+
flex-wrap: wrap;
658+
justify-content: flex-end;
659+
gap: 0.65rem;
660+
}
661+
662+
.admin-review-modal-actions .admin-review-submit:disabled {
663+
opacity: 0.45;
664+
cursor: not-allowed;
665+
transform: none;
666+
box-shadow: none;
667+
}

client/src/components/AdminReviewPage.jsx

Lines changed: 129 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,35 @@ function slackDisplay(user) {
4949
return user?.slug ? `@${user.slug}` : user?.email || "Unknown";
5050
}
5151

52-
function clampApprovalHours(value, maxHours) {
52+
function clampApprovalHours(value, maxHours, allowAboveMax = false) {
5353
if (value === "") return "";
5454
const numeric = Number.parseFloat(value);
5555
if (!Number.isFinite(numeric)) return value;
5656
if (numeric < 0) return "0";
57-
if (Number.isFinite(maxHours) && numeric > maxHours) return maxHours.toFixed(2);
57+
if (!allowAboveMax && Number.isFinite(maxHours) && numeric > maxHours) return maxHours.toFixed(2);
5858
return value;
5959
}
6060

61+
function combinedLoggedHours(project) {
62+
const journal = Number(project.journalHours ?? project.totalHours ?? 0);
63+
const hackatime = Number(project.hackatimeHours ?? 0);
64+
return Number(project.combinedHours ?? journal + hackatime);
65+
}
66+
67+
/** True when "new hours to approve" is above the logged/pending caps shown on the review page. */
68+
function approvalNeedsExceedAck(project, requestedNewHours) {
69+
if (!project || !Number.isFinite(requestedNewHours)) return false;
70+
const pendingCap = Number(project.pendingReviewHours ?? 0);
71+
const logged = combinedLoggedHours(project);
72+
const banked = Math.max(Number(project.pastApprovedHours ?? 0), Number(project.approvedHours ?? 0));
73+
const newTotal = banked + requestedNewHours;
74+
return (
75+
requestedNewHours > pendingCap + 1e-9 ||
76+
requestedNewHours > logged + 1e-9 ||
77+
newTotal > logged + 1e-9
78+
);
79+
}
80+
6181
export function AdminReviewPage({ projectId }) {
6282
if (projectId) return <AdminReviewDetail projectId={projectId} />;
6383
return <AdminReviewIndex />;
@@ -215,6 +235,8 @@ function AdminReviewDetail({ projectId }) {
215235
const [message, setMessage] = useState("");
216236
const [fraudSaving, setFraudSaving] = useState(false);
217237
const [deleteBusy, setDeleteBusy] = useState(false);
238+
const [exceedModalOpen, setExceedModalOpen] = useState(false);
239+
const [exceedAcknowledged, setExceedAcknowledged] = useState(false);
218240

219241
useEffect(() => {
220242
loadProject();
@@ -264,7 +286,7 @@ function AdminReviewDetail({ projectId }) {
264286
}
265287
}
266288

267-
async function submitReview() {
289+
async function submitReview({ acknowledgeExceedLoggedHours = false } = {}) {
268290
if (!project) return;
269291
setMessage("");
270292

@@ -273,10 +295,14 @@ function AdminReviewDetail({ projectId }) {
273295
return;
274296
}
275297

276-
const newHoursMax = Number(project.pendingReviewHours ?? 0);
277298
const requestedApprovedHours = Number.parseFloat(approvedHours) || 0;
278-
if (selectedAction === "approve" && requestedApprovedHours > newHoursMax) {
279-
setMessage(`Approval cannot exceed ${formatHours(newHoursMax)} new hours.`);
299+
const needsExceedAck =
300+
selectedAction === "approve" && approvalNeedsExceedAck(project, requestedApprovedHours);
301+
const hasExceedAck = acknowledgeExceedLoggedHours || exceedAcknowledged;
302+
303+
if (needsExceedAck && !hasExceedAck) {
304+
setExceedModalOpen(true);
305+
setMessage('Check the acknowledgment box (below the hours field or in this dialog), then submit again.');
280306
return;
281307
}
282308

@@ -287,7 +313,11 @@ function AdminReviewDetail({ projectId }) {
287313
const body =
288314
selectedAction === "reject"
289315
? { feedback }
290-
: { approvedHours: Number.parseFloat(approvedHours) || 0, feedback };
316+
: {
317+
approvedHours: requestedApprovedHours,
318+
feedback,
319+
...(hasExceedAck && needsExceedAck ? { acknowledgeExceedLoggedHours: true } : {}),
320+
};
291321

292322
try {
293323
const response = await fetch(endpoint, {
@@ -298,6 +328,8 @@ function AdminReviewDetail({ projectId }) {
298328
});
299329
const data = await response.json();
300330
if (!response.ok) throw new Error(data.error || "Failed to submit review.");
331+
setExceedModalOpen(false);
332+
setExceedAcknowledged(false);
301333
setProject((current) => ({
302334
...current,
303335
...data.project,
@@ -315,6 +347,26 @@ function AdminReviewDetail({ projectId }) {
315347
}
316348
}
317349

350+
function confirmExceedAndSubmit() {
351+
if (!exceedAcknowledged) {
352+
setMessage("Check the acknowledgment box to approve above logged hours.");
353+
return;
354+
}
355+
setExceedModalOpen(false);
356+
submitReview({ acknowledgeExceedLoggedHours: true });
357+
}
358+
359+
function handleApprovedHoursChange(rawValue) {
360+
if (!project) return;
361+
const pendingCap = Number(project.pendingReviewHours ?? 0);
362+
const next = clampApprovalHours(rawValue, pendingCap, true);
363+
setApprovedHours(next);
364+
const numeric = Number.parseFloat(next);
365+
if (Number.isFinite(numeric) && !approvalNeedsExceedAck(project, numeric)) {
366+
setExceedAcknowledged(false);
367+
}
368+
}
369+
318370
async function deleteProjectPermanently() {
319371
if (!project) return;
320372
const ok = window.confirm(
@@ -355,6 +407,12 @@ function AdminReviewDetail({ projectId }) {
355407
const newHoursPlaceholder = formatHours(newHoursMax);
356408
const approvedHoursNumber = Number.parseFloat(approvedHours);
357409
const awardPreview = Number.isFinite(approvedHoursNumber) ? approvedHoursNumber * BRICKS_PER_APPROVED_HOUR : 0;
410+
const needsExceedAckPreview =
411+
!isAlreadyReviewed &&
412+
selectedAction === "approve" &&
413+
Number.isFinite(approvedHoursNumber) &&
414+
approvalNeedsExceedAck(project, approvedHoursNumber);
415+
const loggedHoursPreview = combinedLoggedHours(project);
358416

359417
return (
360418
<main className="admin-review-page">
@@ -440,20 +498,36 @@ function AdminReviewDetail({ projectId }) {
440498
<span>New hours</span>
441499
<strong>{newHoursPlaceholder} h</strong>
442500
</div>
443-
<div>
501+
<div className="admin-review-hours-approve-cell">
444502
<span>New hours to approve</span>
445503
<input
446504
className="admin-review-hours-input"
447505
type="number"
448506
min="0"
449-
max={newHoursPlaceholder}
450507
step="0.25"
451508
placeholder={newHoursPlaceholder}
452509
value={approvedHours}
453510
disabled={isAlreadyReviewed}
454-
onChange={(event) => setApprovedHours(clampApprovalHours(event.target.value, newHoursMax))}
511+
onChange={(event) => handleApprovedHoursChange(event.target.value)}
455512
/>
456513
<small>Only this approved amount awards bricks: {formatHours(awardPreview)} bricks</small>
514+
{needsExceedAckPreview ? (
515+
<>
516+
<p className="admin-review-hours-warning">
517+
Above logged hours ({formatHours(loggedHoursPreview)} h combined
518+
{newHoursMax < loggedHoursPreview ? `, pending cap ${formatHours(newHoursMax)} h` : ""}).
519+
</p>
520+
<label className="admin-review-exceed-ack admin-review-exceed-ack--inline">
521+
<input
522+
type="checkbox"
523+
checked={exceedAcknowledged}
524+
disabled={isAlreadyReviewed}
525+
onChange={(event) => setExceedAcknowledged(event.target.checked)}
526+
/>
527+
<span>I&apos;m aware that I&apos;m giving more hours than the logged ones</span>
528+
</label>
529+
</>
530+
) : null}
457531
</div>
458532
<div>
459533
<span>Journal hours</span>
@@ -510,7 +584,7 @@ function AdminReviewDetail({ projectId }) {
510584
Comment (optional for approve; required for reject)
511585
<textarea value={feedback} onChange={(event) => setFeedback(event.target.value)} />
512586
</label>
513-
<button className="admin-review-submit" type="button" onClick={submitReview}>
587+
<button className="admin-review-submit" type="button" onClick={() => submitReview()}>
514588
Submit Review
515589
</button>
516590
</>
@@ -533,6 +607,50 @@ function AdminReviewDetail({ projectId }) {
533607
) : null}
534608
</section>
535609
</section>
610+
611+
{exceedModalOpen ? (
612+
<div
613+
className="admin-review-modal-backdrop"
614+
role="presentation"
615+
onClick={() => setExceedModalOpen(false)}
616+
>
617+
<div
618+
className="admin-review-modal"
619+
role="dialog"
620+
aria-labelledby="admin-review-exceed-title"
621+
aria-modal="true"
622+
onClick={(event) => event.stopPropagation()}
623+
>
624+
<h2 id="admin-review-exceed-title">Approve above logged hours?</h2>
625+
<p>
626+
You are approving <strong>{formatHours(approvedHoursNumber)} h</strong> new, but only{" "}
627+
<strong>{formatHours(project.combinedHours ?? 0)} h</strong> are logged (pending cap:{" "}
628+
{formatHours(newHoursMax)} h).
629+
</p>
630+
<label className="admin-review-exceed-ack">
631+
<input
632+
type="checkbox"
633+
checked={exceedAcknowledged}
634+
onChange={(event) => setExceedAcknowledged(event.target.checked)}
635+
/>
636+
<span>I&apos;m aware that I&apos;m giving more hours than the logged ones</span>
637+
</label>
638+
<div className="admin-review-modal-actions">
639+
<button type="button" className="admin-review-tool-btn" onClick={() => setExceedModalOpen(false)}>
640+
Cancel
641+
</button>
642+
<button
643+
type="button"
644+
className="admin-review-submit"
645+
disabled={!exceedAcknowledged}
646+
onClick={confirmExceedAndSubmit}
647+
>
648+
Approve anyway
649+
</button>
650+
</div>
651+
</div>
652+
</div>
653+
) : null}
536654
</main>
537655
);
538656
}

0 commit comments

Comments
 (0)