Skip to content

Commit f23a274

Browse files
author
Mark Saroufim
committed
Add admin submission view and delete from rankings page
Admins can click a submission ID in the rankings list to open a dialog showing the submitted code, with a two-step delete confirmation. The delete proxies through kernelbot's existing DELETE /admin/submissions/{id} endpoint using the shared ADMIN_TOKEN (Bearer auth).
1 parent 24f5e07 commit f23a274

File tree

5 files changed

+204
-3
lines changed

5 files changed

+204
-3
lines changed

frontend/src/api/api.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,17 @@ export async function submitFile(form: FormData) {
216216
return data; // e.g. { submission_id, message, ... }
217217
}
218218

219+
export async function deleteSubmission(submissionId: number): Promise<void> {
220+
const res = await fetch(`/api/submission/${submissionId}`, {
221+
method: "DELETE",
222+
});
223+
if (!res.ok) {
224+
const json = await res.json();
225+
const message = json?.message || "Failed to delete submission";
226+
throw new APIError(message, res.status);
227+
}
228+
}
229+
219230
export async function fetchUserSubmissions(
220231
leaderboardId: number | string,
221232
userId: number | string,

frontend/src/pages/leaderboard/Leaderboard.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,9 @@ export default function Leaderboard() {
240240
rankings={data.rankings}
241241
leaderboardId={id}
242242
deadline={data.deadline}
243+
onRefresh={() => {
244+
if (id) call(id);
245+
}}
243246
/>
244247
<Box sx={{ my: 4, borderTop: 1, borderColor: "divider" }} />
245248
<Card>

frontend/src/pages/leaderboard/components/RankingLists.tsx

Lines changed: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { useEffect, useMemo, useState } from "react";
22
import {
33
Box,
44
Button,
5+
Dialog,
6+
DialogActions,
7+
DialogContent,
8+
DialogTitle,
59
Grid,
10+
Link as MuiLink,
611
Stack,
712
type SxProps,
813
type Theme,
@@ -12,7 +17,8 @@ import RankingTitleBadge from "./RankingTitleBadge";
1217

1318
import { formatMicroseconds } from "../../../lib/utils/ranking.ts";
1419
import { getMedalIcon } from "../../../components/common/medal.tsx";
15-
import { fetchCodes } from "../../../api/api.ts";
20+
import { deleteSubmission, fetchCodes } from "../../../api/api.ts";
21+
import CodeBlock from "../../../components/codeblock/CodeBlock";
1622
import { CodeDialog } from "./CodeDialog.tsx";
1723
import { isExpired } from "../../../lib/date/utils.ts";
1824
import { useAuthStore } from "../../../lib/store/authStore.ts";
@@ -31,6 +37,7 @@ interface RankingsListProps {
3137
rankings: Record<string, RankingItem[]>;
3238
leaderboardId?: string;
3339
deadline?: string;
40+
onRefresh?: () => void;
3441
}
3542

3643
const styles: Record<string, SxProps<Theme>> = {
@@ -86,6 +93,7 @@ export default function RankingsList({
8693
rankings,
8794
leaderboardId,
8895
deadline,
96+
onRefresh,
8997
}: RankingsListProps) {
9098
const expired = !!deadline && isExpired(deadline);
9199
const me = useAuthStore((s) => s.me);
@@ -95,6 +103,30 @@ export default function RankingsList({
95103
Math.random().toString(36).slice(2, 8),
96104
);
97105
const [codes, setCodes] = useState<Map<number, string>>(new Map());
106+
const [selectedSubmission, setSelectedSubmission] = useState<{
107+
id: number;
108+
userName: string;
109+
} | null>(null);
110+
const [confirmDelete, setConfirmDelete] = useState(false);
111+
const [deleting, setDeleting] = useState(false);
112+
const [deleteError, setDeleteError] = useState<string | null>(null);
113+
114+
const handleDelete = async () => {
115+
if (!selectedSubmission) return;
116+
setDeleting(true);
117+
setDeleteError(null);
118+
try {
119+
await deleteSubmission(selectedSubmission.id);
120+
setSelectedSubmission(null);
121+
setConfirmDelete(false);
122+
if (onRefresh) onRefresh();
123+
} catch (err: unknown) {
124+
const msg = err instanceof Error ? err.message : "Delete failed";
125+
setDeleteError(msg);
126+
} finally {
127+
setDeleting(false);
128+
}
129+
};
98130

99131
const submissionIds = useMemo(() => {
100132
if (!rankings) return [];
@@ -225,9 +257,23 @@ export default function RankingsList({
225257
)}
226258
{isAdmin && (
227259
<Grid size={2}>
228-
<Typography sx={styles.submissionId}>
260+
<MuiLink
261+
component="button"
262+
variant="body2"
263+
sx={{
264+
...styles.submissionId,
265+
cursor: "pointer",
266+
textDecoration: "underline",
267+
}}
268+
onClick={() =>
269+
setSelectedSubmission({
270+
id: item.submission_id,
271+
userName: item.user_name,
272+
})
273+
}
274+
>
229275
ID: {item.submission_id}
230-
</Typography>
276+
</MuiLink>
231277
</Grid>
232278
)}
233279
</Grid>
@@ -236,6 +282,79 @@ export default function RankingsList({
236282
</Box>
237283
);
238284
})}
285+
286+
{/* Admin submission detail + delete dialog */}
287+
{isAdmin && selectedSubmission && (
288+
<Dialog
289+
open={!!selectedSubmission}
290+
onClose={() => {
291+
setSelectedSubmission(null);
292+
setConfirmDelete(false);
293+
setDeleteError(null);
294+
}}
295+
maxWidth="md"
296+
fullWidth
297+
>
298+
<DialogTitle>
299+
Submission #{selectedSubmission.id} by {selectedSubmission.userName}
300+
</DialogTitle>
301+
<DialogContent dividers>
302+
{codes.get(selectedSubmission.id) ? (
303+
<CodeBlock code={codes.get(selectedSubmission.id)!} />
304+
) : (
305+
<Typography color="text.secondary">
306+
No code available for this submission.
307+
</Typography>
308+
)}
309+
{deleteError && (
310+
<Typography color="error" sx={{ mt: 2 }}>
311+
Error: {deleteError}
312+
</Typography>
313+
)}
314+
</DialogContent>
315+
<DialogActions>
316+
{!confirmDelete ? (
317+
<>
318+
<Button
319+
onClick={() => {
320+
setSelectedSubmission(null);
321+
setDeleteError(null);
322+
}}
323+
>
324+
Close
325+
</Button>
326+
<Button
327+
color="error"
328+
variant="contained"
329+
onClick={() => setConfirmDelete(true)}
330+
>
331+
Delete Submission
332+
</Button>
333+
</>
334+
) : (
335+
<>
336+
<Button
337+
onClick={() => {
338+
setConfirmDelete(false);
339+
setDeleteError(null);
340+
}}
341+
disabled={deleting}
342+
>
343+
Cancel
344+
</Button>
345+
<Button
346+
color="error"
347+
variant="contained"
348+
onClick={handleDelete}
349+
disabled={deleting}
350+
>
351+
{deleting ? "Deleting..." : "Confirm Delete"}
352+
</Button>
353+
</>
354+
)}
355+
</DialogActions>
356+
</Dialog>
357+
)}
239358
</Stack>
240359
);
241360
}

kernelboard/api/submission.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,73 @@ def list_codes_route():
187187
)
188188

189189

190+
@submission_bp.route("/submission/<int:submission_id>", methods=["DELETE"])
191+
@login_required
192+
def delete_submission(submission_id):
193+
"""
194+
DELETE /api/submission/<submission_id>
195+
Admin-only: deletes a submission by proxying to the cluster-manager admin endpoint.
196+
"""
197+
logger.info("[delete_submission] request for submission_id=%s", submission_id)
198+
199+
user_id, _ = get_id_and_username_from_session()
200+
if not user_id:
201+
return http_error(
202+
message="user is not logged in",
203+
status_code=http.HTTPStatus.UNAUTHORIZED,
204+
)
205+
206+
whitelist = get_whitelist()
207+
if user_id not in whitelist:
208+
logger.warning(
209+
"[delete_submission] non-admin user %s attempted delete on %s",
210+
user_id,
211+
submission_id,
212+
)
213+
return http_error(
214+
message="forbidden: admin access required",
215+
status_code=http.HTTPStatus.FORBIDDEN,
216+
)
217+
218+
admin_token = os.getenv("ADMIN_TOKEN", "")
219+
if not admin_token:
220+
logger.error("[delete_submission] ADMIN_TOKEN is not set")
221+
return http_error(
222+
message="admin API not configured",
223+
status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR,
224+
)
225+
226+
base = get_cluster_manager_endpoint()
227+
url = f"{base}/admin/submissions/{submission_id}"
228+
headers = {"Authorization": f"Bearer {admin_token}"}
229+
230+
try:
231+
resp = requests.delete(url, headers=headers, timeout=30)
232+
except requests.RequestException as e:
233+
logger.error("[delete_submission] forward failed: %s", e)
234+
return http_error(
235+
message=f"forward failed: {e}",
236+
status_code=http.HTTPStatus.BAD_GATEWAY,
237+
)
238+
239+
try:
240+
payload = resp.json()
241+
message = payload.get("message") or payload.get("detail") or resp.reason
242+
if resp.status_code == 200:
243+
return http_success(message=message, data=payload)
244+
else:
245+
return http_error(
246+
message=message,
247+
status_code=http.HTTPStatus(resp.status_code),
248+
)
249+
except Exception as e:
250+
logger.error("[delete_submission] failed: %s", e)
251+
return http_error(
252+
message=str(e),
253+
status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR,
254+
)
255+
256+
190257
def check_admin_access_codes(
191258
user_id: str, leaderboard_id: int, submission_ids: List[int]
192259
):

kernelboard/lib/env.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def check_env_vars():
2323
"DISCORD_CLIENT_ID": "preview-disabled",
2424
"DISCORD_CLIENT_SECRET": "preview-disabled",
2525
"DISCORD_CLUSTER_MANAGER_API_BASE_URL": "http://localhost:8080",
26+
"ADMIN_TOKEN": "",
2627
}
2728

2829
for var, default in optional_with_defaults.items():

0 commit comments

Comments
 (0)