diff --git a/package.json b/package.json index 0f2da87..422d694 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build-storybook": "storybook build" }, "dependencies": { + "@radix-ui/react-toast": "^1.2.1", "@t3-oss/env-nextjs": "^0.10.1", "axios": "^1.7.7", "class-variance-authority": "^0.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5bfb60..9ad8525 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@radix-ui/react-toast': + specifier: ^1.2.1 + version: 1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@t3-oss/env-nextjs': specifier: ^0.10.1 version: 0.10.1(typescript@5.5.4)(zod@3.23.8) @@ -1214,6 +1217,163 @@ packages: webpack-plugin-serve: optional: true + '@radix-ui/primitive@1.1.0': + resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==} + + '@radix-ui/react-collection@1.1.0': + resolution: {integrity: sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.0': + resolution: {integrity: sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.0': + resolution: {integrity: sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.0': + resolution: {integrity: sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.1': + resolution: {integrity: sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.0': + resolution: {integrity: sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.0.0': + resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.1.0': + resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-toast@1.2.1': + resolution: {integrity: sha512-5trl7piMXcZiCq7MW6r8YYmu0bK5qDpTWz+FdEPdKyft2UixkspheYbjbrLXVN5NGKHFbOP7lm8eD0biiSqZqg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.0': + resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.1.0': + resolution: {integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.0': + resolution: {integrity: sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.0': + resolution: {integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.1.0': + resolution: {integrity: sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -6258,6 +6418,136 @@ snapshots: type-fest: 2.19.0 webpack-hot-middleware: 2.26.1 + '@radix-ui/primitive@1.1.0': {} + + '@radix-ui/react-collection@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.5)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.5 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-compose-refs@1.1.0(@types/react@18.3.5)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.5 + + '@radix-ui/react-context@1.1.0(@types/react@18.3.5)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.5 + + '@radix-ui/react-dismissable-layer@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.0(@types/react@18.3.5)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.5 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-portal@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.5)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.5 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-presence@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.5)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.5 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-primitive@2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.5)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.5 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-slot@1.1.0(@types/react@18.3.5)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.5)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.5 + + '@radix-ui/react-toast@1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.5 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.5)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.5 + + '@radix-ui/react-use-controllable-state@1.1.0(@types/react@18.3.5)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.5)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.5 + + '@radix-ui/react-use-escape-keydown@1.1.0(@types/react@18.3.5)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.5)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.5 + + '@radix-ui/react-use-layout-effect@1.1.0(@types/react@18.3.5)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.5 + + '@radix-ui/react-visually-hidden@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.5 + '@types/react-dom': 18.3.0 + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.10.4': {} diff --git a/src/app/challenge/page.tsx b/src/app/challenge/page.tsx index f5bf703..ec9b8ce 100644 --- a/src/app/challenge/page.tsx +++ b/src/app/challenge/page.tsx @@ -9,6 +9,8 @@ import Navbar from "~/components/navbar"; import { type ChallengeItem } from "~/components/challengeCard"; import { BACKEND_URL, CHALLENGE_IP } from "~/lib/constants"; import axios from "axios"; +import { CgSpinner } from "react-icons/cg"; +import { useToast } from "~/components/hooks/use-toast"; const challengeTypes = [ "Miscellaneous", @@ -67,18 +69,27 @@ type StatusType = "on" | "off" | "starting" | "stopping"; const ChallengePage: React.FC = () => { const [challenge, setChallenge] = useState(null); + const [loading, setLoading] = useState(true); const [flag, setFlag] = useState(""); const [response, setResponse] = useState(""); const [correct, setCorrect] = useState(""); + const [submiting, setSubmiting] = useState(false); const [ports, setPorts] = useState([]); const [status, setStatus] = useState("off"); + const { toast } = useToast(); useEffect(() => { if (!window.localStorage.getItem("token")) { window.location.href = "/signin"; + console.error("You need to be signed in to view this page"); + toast({ + title: "Error", + description: "You need to be signed in to view this page", + duration: 5000, + }); return; } - + const id = window.localStorage.getItem("challenge"); void axios .get< @@ -106,11 +117,27 @@ const ChallengePage: React.FC = () => { types: getTypesFromMask(data.tags), } as unknown as ChallengeData; setChallenge(chall); + setLoading(false); + }) + .catch((err) => { + console.error(err); + toast({ + title: "Error", + description: "Failed to fetch challenge", + duration: 5000, + }); + setLoading(false); }); - }, []); + }, [toast]); const showHintResponse = (msgCode: number) => { + if (msgCode === -1) return; if (!(window as unknown as { debugMode: boolean }).debugMode) return; + toast({ + title: "Hint Response", + description: msgCodes[msgCode] ?? "", + duration: 5000, + }); setResponse(msgCodes[msgCode] ?? ""); setTimeout(() => { setResponse(""); @@ -118,21 +145,32 @@ const ChallengePage: React.FC = () => { }; const startInstance = async () => { + setStatus("starting"); const id = window.localStorage.getItem("challenge"); const res = ( - await axios.post<{ - msg_code: number; - ports: number[]; - ctd_id: number[]; - }>( - `${BACKEND_URL}/ctf/${id}/start`, - {}, - { - headers: { - Authorization: `Bearer ${window.localStorage.getItem("token")}`, + await axios + .post<{ + msg_code: number; + ports: number[]; + ctd_id: number[]; + }>( + `${BACKEND_URL}/ctf/${id}/start`, + {}, + { + headers: { + Authorization: `Bearer ${window.localStorage.getItem("token")}`, + }, }, - }, - ) + ) + .catch((err) => { + console.error(err); + toast({ + title: "Error", + description: "Failed to start instance", + duration: 5000, + }); + return { data: { msg_code: -1, ports: [] } }; + }) ).data; showHintResponse(res.msg_code); if (res.msg_code === 3) { @@ -142,18 +180,29 @@ const ChallengePage: React.FC = () => { }; const stopInstance = async () => { + setStatus("stopping"); const id = window.localStorage.getItem("challenge"); - const res = await axios.post<{ - msg_code: number; - }>( - `${BACKEND_URL}/ctf/${id}/stop`, - {}, - { - headers: { - Authorization: `Bearer ${window.localStorage.getItem("token")}`, + const res = await axios + .post<{ + msg_code: number; + }>( + `${BACKEND_URL}/ctf/${id}/stop`, + {}, + { + headers: { + Authorization: `Bearer ${window.localStorage.getItem("token")}`, + }, }, - }, - ); + ) + .catch((err) => { + console.error(err); + toast({ + title: "Error", + description: "Failed to stop instance", + duration: 5000, + }); + return { data: { msg_code: -1 } }; + }); showHintResponse(res.data.msg_code); if (res.data.msg_code === 4 || res.data.msg_code === 6) { setPorts([]); @@ -162,17 +211,28 @@ const ChallengePage: React.FC = () => { }; const killAll = async () => { - const res = await axios.post<{ - msg_code: number; - }>( - BACKEND_URL + "ctf/stopall", - {}, - { - headers: { - Authorization: `Bearer ${window.localStorage.getItem("token")}`, + setStatus("stopping"); + const res = await axios + .post<{ + msg_code: number; + }>( + BACKEND_URL + "/ctf/stopall", + {}, + { + headers: { + Authorization: `Bearer ${window.localStorage.getItem("token")}`, + }, }, - }, - ); + ) + .catch((err) => { + console.error(err); + toast({ + title: "Error", + description: "Failed to stop all instances", + duration: 5000, + }); + return { data: { msg_code: -1 } }; + }); showHintResponse(res.data.msg_code); if (res.data.msg_code === 5) { setPorts([]); @@ -181,105 +241,144 @@ const ChallengePage: React.FC = () => { }; const submitFlag = async () => { + setSubmiting(true); const id = window.localStorage.getItem("challenge"); - const res = await axios.post<{ - msg_code?: number; - status?: boolean; - }>( - `${BACKEND_URL}/ctf/${id}/flag`, - { - flag, - }, - { - headers: { - Authorization: `Bearer ${window.localStorage.getItem("token")}`, + const res = await axios + .post<{ + msg_code?: number; + status?: boolean; + }>( + `${BACKEND_URL}/ctf/${id}/flag`, + { + flag, }, - }, - ); + { + headers: { + Authorization: `Bearer ${window.localStorage.getItem("token")}`, + }, + }, + ) + .catch((err) => { + console.error(err); + toast({ + title: "Error", + description: "Failed to submit flag", + duration: 5000, + }); + return { data: { msg_code: -1, status: false } }; + }); if (res.data.msg_code) { showHintResponse(res.data.msg_code); setCorrect("Invalid flag!"); } - if (res.status) setCorrect("Correct flag!"); + if (res.data.status) { + setCorrect("Correct flag!"); + } + setSubmiting(false); }; return ( -
+
-
-
-
- window.history.back()} - /> -
-
- - {challenge?.title.toUpperCase() ?? "Loading..."} - -
- - {challenge?.description.toUpperCase() ?? "Loading..."} + {loading && ( + + )} + {!challenge && !loading && ( + + Challenge not found + + )} + {challenge && ( +
+
+
+ window.history.back()} + /> +
+
+ + {challenge?.title.toUpperCase() ?? "Not Found"} +
+ + {challenge?.description.toUpperCase() ?? "Not Found"} + -
- {/* +
+ {/* EASY */} - - {challenge?.points ?? 0} POINTS - - {/* + + {challenge?.points ?? 0} POINTS + + {/* SOLVED COUNT */} -
- - {response} - -
- - {ports.map((port) => ( -
- - {CHALLENGE_IP}:{port} +
+ + {response}
- ))} - {status === "on" ? ( - - ) : ( - - )} + {ports.map((port) => ( +
+ + {CHALLENGE_IP}:{port} + +
+ ))} -
- {correct} -
- setFlag(v.target.value)} - /> - +
+ {status === "on" ? ( + + ) : ( + + )} + {(status === "starting" || status === "stopping") && ( + + + + )} +
+ +
+ {correct} +
+ setFlag(v.target.value)} + /> + +
-
-
- +
+ +
-
+ )}
); }; diff --git a/src/app/challenges/page.tsx b/src/app/challenges/page.tsx index 686d699..38397f5 100644 --- a/src/app/challenges/page.tsx +++ b/src/app/challenges/page.tsx @@ -1,7 +1,9 @@ "use client"; import axios from "axios"; import { useEffect, useState } from "react"; +import { CgSpinner } from "react-icons/cg"; import ChallengeCard, { type ChallengeItem } from "~/components/challengeCard"; +import { useToast } from "~/components/hooks/use-toast"; import Navbar from "~/components/navbar"; import Text from "~/components/text"; import { BACKEND_URL } from "~/lib/constants"; @@ -35,10 +37,17 @@ function getTypesFromMask(mask: number) { export default function ChallengesPage() { const [type, setType] = useState("all"); const [challenges, setChallenges] = useState([]); + const [loading, setLoading] = useState(true); + const { toast } = useToast(); useEffect(() => { if (!window.localStorage.getItem("token")) { window.location.href = "/signin"; + toast({ + title: "Error", + description: "You need to be signed in to view this page", + duration: 5000, + }); return; } @@ -67,8 +76,17 @@ export default function ChallengesPage() { } as unknown as ChallengeData; }); setChallenges(challenges); + setLoading(false); + }) + .catch(() => { + toast({ + title: "Error", + description: "Failed to fetch challenges", + duration: 5000, + }); + setLoading(false); }); - }, []); + }, [toast]); const setChallenge = (id: string) => { window.localStorage.setItem("challenge", id); @@ -100,7 +118,11 @@ export default function ChallengesPage() {
{challenges.length === 0 && ( - Loading... + {!loading ? ( + "Not yet started. Check back later!" + ) : ( + + )} )} {challenges diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d52d0c3..361092f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import "~/styles/globals.css"; import { GeistSans } from "geist/font/sans"; import { type Metadata } from "next"; +import { Toaster } from "~/components/toaster"; export const metadata: Metadata = { title: "Cyber-0-Day", @@ -14,7 +15,10 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode }>) { return ( - {children} + + {children} + + ); } diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx index a3663d7..2f4995c 100644 --- a/src/app/leaderboard/page.tsx +++ b/src/app/leaderboard/page.tsx @@ -12,6 +12,8 @@ import InputBox from "~/components/inputbox"; import axios from "axios"; import { BACKEND_URL } from "~/lib/constants"; import Navbar from "~/components/navbar"; +import { CgSpinner } from "react-icons/cg"; +import { useToast } from "~/components/hooks/use-toast"; // Define the interface for the leaderboard data interface LeaderboardData { @@ -24,10 +26,16 @@ const LeaderboardPage: React.FC = () => { const [leaderboardData, setLeaderboardData] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { toast } = useToast(); useEffect(() => { if (!window.localStorage.getItem("token")) { window.location.href = "/signin"; + toast({ + title: "Error", + description: "You need to be signed in to view this page", + duration: 5000, + }); return; } @@ -42,6 +50,12 @@ const LeaderboardPage: React.FC = () => { const data: LeaderboardData[] = response.data; setLeaderboardData(data); } catch (err: any) { + console.error(err); + toast({ + title: "Error", + description: "Failed to fetch leaderboard data", + duration: 5000, + }); setError(err.message); } finally { setLoading(false); @@ -49,7 +63,7 @@ const LeaderboardPage: React.FC = () => { }; void fetchLeaderboardData(); - }, []); + }, [toast]); const filteredData = leaderboardData.filter((entry) => entry.name.toLowerCase().includes(searchQuery.toLowerCase()), @@ -77,7 +91,9 @@ const LeaderboardPage: React.FC = () => { {/* Leaderboard Table */}
{loading ? ( -
Loading...
+
+ +
) : error ? (
Error: {error}
) : ( diff --git a/src/components/LinkButton.tsx b/src/components/LinkButton.tsx index 44695a6..35fce6c 100644 --- a/src/components/LinkButton.tsx +++ b/src/components/LinkButton.tsx @@ -24,7 +24,7 @@ export interface LinkButtonProps extends React.AnchorHTMLAttributes, VariantProps { className?: string; - children?: string; + children?: React.ReactNode; href: string; } diff --git a/src/components/hooks/use-toast.tsx b/src/components/hooks/use-toast.tsx new file mode 100644 index 0000000..7936598 --- /dev/null +++ b/src/components/hooks/use-toast.tsx @@ -0,0 +1,192 @@ +"use client"; + +// Inspired by react-hot-toast library +import * as React from "react"; + +import type { ToastActionElement, ToastProps } from "~/components/toast"; + +const TOAST_LIMIT = 1; +const TOAST_REMOVE_DELAY = 1000000; + +type ToasterToast = ToastProps & { + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + action?: ToastActionElement; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const; + +let count = 0; + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER; + return count.toString(); +} + +type ActionType = typeof actionTypes; + +type Action = + | { + type: ActionType["ADD_TOAST"]; + toast: ToasterToast; + } + | { + type: ActionType["UPDATE_TOAST"]; + toast: Partial; + } + | { + type: ActionType["DISMISS_TOAST"]; + toastId?: ToasterToast["id"]; + } + | { + type: ActionType["REMOVE_TOAST"]; + toastId?: ToasterToast["id"]; + }; + +interface State { + toasts: ToasterToast[]; +} + +const toastTimeouts = new Map>(); + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return; + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }); + }, TOAST_REMOVE_DELAY); + + toastTimeouts.set(toastId, timeout); +}; + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + }; + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t, + ), + }; + + case "DISMISS_TOAST": { + const { toastId } = action; + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId); + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id); + }); + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t, + ), + }; + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + }; + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + }; + } +}; + +const listeners: Array<(state: State) => void> = []; + +let memoryState: State = { toasts: [] }; + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action); + listeners.forEach((listener) => { + listener(memoryState); + }); +} + +type Toast = Omit; + +function toast({ ...props }: Toast) { + const id = genId(); + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }); + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss(); + }, + }, + }); + + return { + id: id, + dismiss, + update, + }; +} + +function useToast() { + const [state, setState] = React.useState(memoryState); + + React.useEffect(() => { + listeners.push(setState); + return () => { + const index = listeners.indexOf(setState); + if (index > -1) { + listeners.splice(index, 1); + } + }; + }, [state]); + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + }; +} + +export { useToast, toast }; diff --git a/src/components/signin/signin-form.tsx b/src/components/signin/signin-form.tsx index 7502d69..f5b2dab 100644 --- a/src/components/signin/signin-form.tsx +++ b/src/components/signin/signin-form.tsx @@ -7,6 +7,8 @@ import LinkButton from "../LinkButton"; // Ensure correct path import { cn } from "~/lib/utils"; import { BACKEND_URL } from "~/lib/constants"; import axios from "axios"; +import { CgSpinner } from "react-icons/cg"; +import { useToast } from "../hooks/use-toast"; interface SignInFormProps { className?: string; @@ -17,14 +19,21 @@ const SignInForm = ({ className }: SignInFormProps) => { name: "", password: "", }); + const [submitting, setSubmitting] = useState(false); + const { toast } = useToast(); useEffect(() => { const token = window.localStorage.getItem("token"); if (token) { window.location.href = "/challenges"; + toast({ + title: "Redirecting", + description: "You are signed in", + duration: 5000, + }); } - }, []); + }, [toast]); // Handler for input changes const handleChange = (e: React.ChangeEvent) => { @@ -38,20 +47,39 @@ const SignInForm = ({ className }: SignInFormProps) => { if (!formData.name || !formData.password) return; + setSubmitting(true); const res = ( - await axios.post(`${BACKEND_URL}/auth/login`, { - name: formData.name, - password: formData.password, - }) + await axios + .post(`${BACKEND_URL}/auth/login`, { + name: formData.name, + password: formData.password, + }) + .catch((err) => { + console.error(err); + toast({ + title: "Error", + description: "Failed to sign in", + duration: 5000, + }); + setSubmitting(false); + return { data: { access_token: "" } }; + }) ).data as { access_token: string }; - window.localStorage.setItem("token", res.access_token); - window.location.href = "/signin"; + setSubmitting(false); + + if (res.access_token) { + window.localStorage.setItem("token", res.access_token); + window.location.href = "/signin"; + } }; return (
{/* Sign In Header */} @@ -82,8 +110,12 @@ const SignInForm = ({ className }: SignInFormProps) => { /> {/* Sign In Button */} - diff --git a/src/components/signup/signup-form.tsx b/src/components/signup/signup-form.tsx index 12cd3ca..fe08416 100644 --- a/src/components/signup/signup-form.tsx +++ b/src/components/signup/signup-form.tsx @@ -7,6 +7,9 @@ import { cn } from "~/lib/utils"; import { BACKEND_URL } from "~/lib/constants"; import LinkButton from "../LinkButton"; import axios from "axios"; +import { BiMinus } from "react-icons/bi"; +import { CgSpinner } from "react-icons/cg"; +import { useToast } from "../hooks/use-toast"; interface SignUpFormProps { className?: string; @@ -30,14 +33,21 @@ const SignUpForm = ({ className }: SignUpFormProps) => { teamMember3RegNo: "", count: 0, }); + const [submitting, setSubmitting] = useState(false); + const { toast } = useToast(); useEffect(() => { const token = window.localStorage.getItem("token"); if (token) { window.location.href = "/challenges"; + toast({ + title: "Redirecting", + description: "You are signed in", + duration: 5000, + }); } - }, []); + }, [toast]); // Handler for input changes const handleChange = (e: React.ChangeEvent) => { @@ -52,21 +62,38 @@ const SignUpForm = ({ className }: SignUpFormProps) => { const tags = []; if (formData.teamLeadRegNo) tags.push(formData.teamLeadRegNo); - if (formData.teamMember2RegNo) tags.push(formData.teamMember2RegNo); - if (formData.teamMember3RegNo) tags.push(formData.teamMember3RegNo); + if (formData.teamMember2RegNo && formData.count > 0) + tags.push(formData.teamMember2RegNo); + if (formData.teamMember3RegNo && formData.count > 1) + tags.push(formData.teamMember3RegNo); if (!formData.teamName || !formData.password || tags.length < 1) return; + setSubmitting(true); const res = ( - await axios.post(`${BACKEND_URL}/auth/signup`, { - name: formData.teamName, - password: formData.password, - tags, - }) + await axios + .post(`${BACKEND_URL}/auth/signup`, { + name: formData.teamName, + password: formData.password, + tags, + }) + .catch((err) => { + console.error(err); + toast({ + title: "Error", + description: "Failed to sign up", + duration: 5000, + }); + setSubmitting(false); + return { data: { access_token: "" } }; + }) ).data as { access_token: string }; + setSubmitting(false); - window.localStorage.setItem("token", res.access_token ?? ""); - window.location.href = "/signup"; + if (res.access_token) { + window.localStorage.setItem("token", res.access_token); + window.location.href = "/signup"; + } }; const handleAdd = () => { @@ -75,9 +102,18 @@ const SignUpForm = ({ className }: SignUpFormProps) => { setFormData((prev) => ({ ...prev, count: prev.count + 1 })); }; + const handleRemove = () => { + if (formData.count <= 0) return; + + setFormData((prev) => ({ ...prev, count: prev.count - 1 })); + }; + return (
SIGNUP @@ -96,6 +132,7 @@ const SignUpForm = ({ className }: SignUpFormProps) => { { /> )} +
+ + +
+ - -
diff --git a/src/components/toast.tsx b/src/components/toast.tsx new file mode 100644 index 0000000..2ead76a --- /dev/null +++ b/src/components/toast.tsx @@ -0,0 +1,129 @@ +"use client"; + +import * as React from "react"; +import * as ToastPrimitives from "@radix-ui/react-toast"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "~/lib/utils"; +import { BiX } from "react-icons/bi"; + +const ToastProvider = ToastPrimitives.Provider; + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastViewport.displayName = ToastPrimitives.Viewport.displayName; + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ); +}); +Toast.displayName = ToastPrimitives.Root.displayName; + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastAction.displayName = ToastPrimitives.Action.displayName; + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +ToastClose.displayName = ToastPrimitives.Close.displayName; + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastTitle.displayName = ToastPrimitives.Title.displayName; + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastDescription.displayName = ToastPrimitives.Description.displayName; + +type ToastProps = React.ComponentPropsWithoutRef; + +type ToastActionElement = React.ReactElement; + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +}; diff --git a/src/components/toaster.tsx b/src/components/toaster.tsx new file mode 100644 index 0000000..5bccbc1 --- /dev/null +++ b/src/components/toaster.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useToast } from "~/components/hooks/use-toast"; +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "~/components/toast"; + +export function Toaster() { + const { toasts } = useToast(); + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ); + })} + +
+ ); +}