diff --git a/package-lock.json b/package-lock.json index 870f3d3c..4124a5f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16157,4 +16157,4 @@ } } } -} +} \ No newline at end of file diff --git a/public/locales/en/common.json b/public/locales/en/common.json index bdb4b2f2..473f67f4 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -85,6 +85,10 @@ "create": "Create", "createNewLeaderboard": "Create new leaderboard", "deleteLeaderboard": "Delete leaderboard", + "deletionNotification": { + "successTitle": "Leaderboard deleted", + "successDescription": "Leaderboard was successfully deleted" + }, "demote": "Demote", "inviteCode": "Invite code", "join": { @@ -92,7 +96,7 @@ "genericError": "Error joining leaderboard", "join": "Join", "leaderboardCode": "Leaderboard code", - "leaderboardCodeInvalid": "Leaderboard code must start with \"ttlic_\", and be followed by 24 alphanumeric characters.", + "leaderboardCodeInvalid": "Leaderboard code must start with \"ttlic_\", and be followed by 32 alphanumeric characters.", "leaderboardCodeRequired": "Leaderboard code is required", "notFound": "Leaderboard not found" }, @@ -118,6 +122,7 @@ }, "yourPosition": "Your position", "error": { + "notFound": "Leaderboard not found", "loadingAllLeaderboards": "An error occurred while loading your leaderboard list, try again later. If the error persists, please contact us." } }, @@ -161,12 +166,22 @@ "makePublic": "Make my account public", "title": "Account visibility" }, + "sudoOperation": { + "title": "Confirm password", + "passwordLabel": "Password", + "wrongPassword": "The provided password is incorrect", + "passwordRequired": "Password is required", + "confirmButton": "Confirm" + }, "authenticationToken": { "title": "Authentication token", "tooltip": { "install": "Get your extension from here!", "label": "This token is used for authentication in your code editor." - } + }, + "passwordRequiredDescription": "Please provide your password to regenerate the authentication token", + "regenerateSuccessTitle": "Authentication token regenerated", + "regenerateSuccessDescription": "Your authentication token was successfully regenerated, please remember to update the new token to your code editors." }, "changePassword": { "confirm": { @@ -213,13 +228,13 @@ } }, "deleteAccount": { + "title": "Account deletion", "button": "Delete account", - "modal": { - "button": "Delete", - "text": "Type your password to confirm the deletion. This action can not be undone.", - "title": "Delete account" - }, - "title": "Delete account" + "modalDescription": "Type your password to confirm the deletion. This action can not be undone.", + "notification": { + "successTitle": "Account deleted", + "successDescription": "Your account and all data has been successfully deleted" + } }, "friendCode": { "title": "Friend code", diff --git a/public/locales/fi/common.json b/public/locales/fi/common.json index f7883eac..02728083 100644 --- a/public/locales/fi/common.json +++ b/public/locales/fi/common.json @@ -85,6 +85,10 @@ "create": "Luo", "createNewLeaderboard": "Luo uusi tulostaulu", "deleteLeaderboard": "Poista tulostaulu", + "deletionNotification": { + "successTitle": "Tulostaulu poistettu", + "successDescription": "Tulostaulu on poistettu onnistuneesti" + }, "demote": "Alenna", "inviteCode": "Kutsukoodi", "join": { @@ -92,7 +96,7 @@ "genericError": "Tulostauluun liittyminen epäonnistui.", "join": "Liity", "leaderboardCode": "Tulostaulukoodi", - "leaderboardCodeInvalid": "Kutsukoodin tulee alkaa \"ttlic_\", ja sen jälkeen täytyy olla 24 alphanumeerista kirjainta.", + "leaderboardCodeInvalid": "Kutsukoodin tulee alkaa \"ttlic_\", ja sen jälkeen täytyy olla 32 alphanumeerista kirjainta.", "leaderboardCodeRequired": "Kutsukoodi vaaditaan.", "notFound": "Tulostaulua ei löytynyt." }, @@ -118,6 +122,7 @@ }, "yourPosition": "Sija", "error": { + "notFound": "Tulostaulua ei löytynyt.", "loadingAllLeaderboards": "Tulostaulujesi lataamisessa tapahtui virhe, yritä uudelleen myöhemmin. Jos virhe jatkuu, ole hyvä ja ota yhteyttä meihin." } }, @@ -161,12 +166,22 @@ "makePublic": "Muuta julkiseksi", "title": "Tilin näkyvyys" }, + "sudoOperation": { + "title": "Vahvista salasana", + "passwordLabel": "Salasana", + "wrongPassword": "Antamasi salasana on väärin", + "passwordRequired": "Salasana vaaditaan", + "confirmButton": "Vahvista" + }, "authenticationToken": { "title": "Tunnistautumistunnus", "tooltip": { "install": "Asenna Testaustime-laajennus täältä!", "label": "Tätä tunnusta käytetään tunnistautumiseen koodieditorissasi." - } + }, + "passwordRequiredDescription": "Vahvista salasanasi generoidaksesi tunnistautumistunnus uudelleen", + "regenerateSuccessTitle": "Tunnistautumistunnus generoitu uudelleen", + "regenerateSuccessDescription": "Tunnistautumistunnus generoitiin uudelleen, muista päivittää uusi tunnus myös editoreihisi." }, "changePassword": { "confirm": { @@ -213,13 +228,13 @@ } }, "deleteAccount": { + "title": "Tilin poisto", "button": "Poista tili", - "modal": { - "button": "Poista", - "text": "Kirjoita salasanasi vahvistaaksesi tilisi poistaminen. Tätä toimintoa ei voi peruuttaa.", - "title": "Käyttäjän poistaminen" - }, - "title": "Poista tili" + "modalDescription": "Kirjoita salasanasi vahvistaaksesi tilisi poistaminen. Tätä toimintoa ei voi peruuttaa.", + "notification": { + "successTitle": "Tili poistettu", + "successDescription": "Tilisi ja kaikki sen tiedot on poistettu onnistuneesti" + } }, "friendCode": { "title": "Kaverikoodi", diff --git a/src/api/baseApi.ts b/src/api/baseApi.ts new file mode 100644 index 00000000..c4bc18b5 --- /dev/null +++ b/src/api/baseApi.ts @@ -0,0 +1,132 @@ +"use server"; + +import { cookies, headers } from "next/headers"; +import { GetRequestError, PostRequestError } from "../types"; + +export const getRequest = async (path: string) => { + if (process.env.NEXT_PUBLIC_API_URL == null) + throw new Error("API URL was not defined"); + + const token = cookies().get("token")?.value; + + const ip = headers().get("client-ip") ?? "Unknown IP"; + + const h = new Headers({ + "client-ip": ip, + "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", + }); + + if (token !== undefined) { + h.set("Authorization", `Bearer ${token}`); + } + + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}${path}`, { + headers: h, + cache: "no-cache", + }); + + if (!response.ok) { + if (response.status === 401) { + return { + error: GetRequestError.Unauthorized as const, + response, + }; + } + + if (response.status === 429) { + return { + error: GetRequestError.RateLimited as const, + response, + }; + } + + return { + error: GetRequestError.UnknownError as const, + path, + response, + }; + } + + const data = (await response.json()) as T; + + return data; +}; + +type WithResponseBody = + | { data: R } + | { error: PostRequestError; statusCode: number }; + +type NoResponseBody = WithResponseBody; + +async function baseFetch( + path: string, + body?: unknown, + method: string = "POST", +) { + if (process.env.NEXT_PUBLIC_API_URL == null) + throw new Error("API URL was not defined"); + + const tokenCookieName = "token"; + const token = cookies().get(tokenCookieName)?.value; + + const h = new Headers({ + "Content-Type": "application/json", + "client-ip": headers().get("client-ip") ?? "Unknown IP", + "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", + }); + + if (token) { + h.set("Authorization", `Bearer ${token}`); + } + + const response = await fetch(process.env.NEXT_PUBLIC_API_URL + path, { + method, + headers: h, + cache: "no-cache", + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + if (response.status === 429) { + return { + error: PostRequestError.RateLimited, + statusCode: response.status, + }; + } else if (response.status === 401) { + return { + error: PostRequestError.Unauthorized, + statusCode: response.status, + }; + } + + return { + error: PostRequestError.UnknownError, + statusCode: response.status, + }; + } + + return response; +} + +export async function postRequestWithResponse( + path: string, + body?: unknown, + method: string = "POST", +): Promise> { + const response = await baseFetch(path, body, method); + if ("error" in response) return response; + + const data = (await response.json()) as R; + return { data }; +} + +export async function postRequestWithoutResponse( + path: string, + body?: unknown, + method: string = "POST", +): Promise { + const response = await baseFetch(path, body, method); + if ("error" in response) return response; + + return { data: null }; +} diff --git a/src/api/friendsApi.ts b/src/api/friendsApi.ts index 7bd0d2a2..6a8584a1 100644 --- a/src/api/friendsApi.ts +++ b/src/api/friendsApi.ts @@ -1,5 +1,5 @@ import { CurrentActivityApiResponse } from "../types"; -import { cookies, headers } from "next/headers"; +import { getRequest } from "./baseApi"; export interface ApiFriendsResponseItem { username: string; @@ -11,46 +11,5 @@ export interface ApiFriendsResponseItem { status: CurrentActivityApiResponse | null; } -export const getFriendsList = async () => { - const token = cookies().get("token")?.value; - if (!token) { - return { - error: "Unauthorized" as const, - }; - } - - const friendsPromise = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/friends/list`, - { - headers: { - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - cache: "no-cache", - }, - ); - - if (!friendsPromise.ok) { - if (friendsPromise.status === 401) { - return { - error: "Unauthorized" as const, - }; - } - - if (friendsPromise.status === 429) { - return { - error: "Too many requests" as const, - }; - } - - return { - error: "Unknown error when fetching /friends/list" as const, - status: friendsPromise.status, - }; - } - - const data = (await friendsPromise.json()) as ApiFriendsResponseItem[]; - - return data; -}; +export const getFriendsList = () => + getRequest("/friends/list"); diff --git a/src/api/leaderboardApi.ts b/src/api/leaderboardApi.ts index 25db30fa..0e5e8243 100644 --- a/src/api/leaderboardApi.ts +++ b/src/api/leaderboardApi.ts @@ -1,123 +1,43 @@ "use server"; -import { cookies, headers } from "next/headers"; import { CreateLeaderboardError, GetLeaderboardError, - GetLeaderboardsError, Leaderboard, LeaderboardData, } from "../types"; +import { getRequest, postRequestWithResponse } from "./baseApi"; -export const getMyLeaderboards = async () => { - const token = cookies().get("token")?.value; - if (!token) { - return { error: GetLeaderboardsError.Unauthorized }; - } - - try { - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + "/users/@me/leaderboards", - { - headers: { - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - cache: "no-cache", - }, - ); - - if (!response.ok) { - if (response.status === 401) { - return { error: GetLeaderboardsError.Unauthorized }; - } else if (response.status === 429) { - return { error: GetLeaderboardsError.RateLimited }; - } - - const errorText = await response.text(); - console.log("Unknown error when getting user's leaderboards:", errorText); - - return { error: GetLeaderboardsError.UnknownError }; - } - - const data = (await response.json()) as Leaderboard[]; - - return data; - } catch (e: unknown) { - console.error("Unknown error when getting user's leaderboards:", e); - return { error: GetLeaderboardsError.UnknownError }; - } -}; +export const getMyLeaderboards = () => + getRequest("/users/@me/leaderboards"); export const getLeaderboard = async (leaderboardName: string) => { - const token = cookies().get("token")?.value; - if (!token) { - return { error: GetLeaderboardError.Unauthorized }; - } - - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + `/leaderboards/${leaderboardName}`, - { - headers: { - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - cache: "no-cache", - next: { - tags: [`leaderboard-${leaderboardName}`], - }, - }, + const data = await getRequest( + `/leaderboards/${leaderboardName}`, ); - if (!response.ok) { - if (response.status === 401) { - return { error: GetLeaderboardError.Unauthorized }; - } else if (response.status === 429) { - return { error: GetLeaderboardError.RateLimited }; + if ("error" in data) { + if (data.response.status === 404) { + return { error: GetLeaderboardError.LeaderboardNotFound }; } - - const errorText = await response.text(); - console.log(errorText); - - return { error: GetLeaderboardError.UnknownError }; } - const data = (await response.json()) as LeaderboardData; - return data; }; export const createLeaderboard = async (leaderboardName: string) => { - const token = cookies().get("token")?.value; - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + "/leaderboards/create", + const data = await postRequestWithResponse<{ invite_code: string }>( + "/leaderboards/create", { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - cache: "no-cache", - body: JSON.stringify({ name: leaderboardName }), + name: leaderboardName, }, ); - if (!response.ok) { - if (response.status === 409) { - return { error: CreateLeaderboardError.AlreadyExists }; - } else if (response.status === 429) { - return { error: CreateLeaderboardError.RateLimited }; - } - - console.error("Error while creating leaderboard:", await response.text()); - return { error: CreateLeaderboardError.UnknownError }; + if ("error" in data && data.statusCode === 409) { + return { + error: CreateLeaderboardError.AlreadyExists, + }; } - const data = (await response.json()) as { invite_code: string }; - return data; }; diff --git a/src/api/usersApi.ts b/src/api/usersApi.ts index ad94cf32..97cbd185 100644 --- a/src/api/usersApi.ts +++ b/src/api/usersApi.ts @@ -1,3 +1,5 @@ +"use server"; + import { ActivityDataEntry, ActivityDataSummary, @@ -6,14 +8,17 @@ import { CurrentActivityApiResponse, GetUserActivityDataError, SearchUsersApiResponse, - SearchUsersError, } from "../types"; import { cookies, headers } from "next/headers"; import { CurrentActivity } from "../components/CurrentActivity/CurrentActivity"; import { startOfDay } from "date-fns"; import { normalizeProgrammingLanguageName } from "../utils/programmingLanguagesUtils"; +import { getRequest } from "./baseApi"; export const getMe = async () => { + if (process.env.NEXT_PUBLIC_API_URL == null) + throw new Error("API URL was not defined"); + const token = cookies().get("token")?.value; if (!token) { return undefined; @@ -55,132 +60,36 @@ export const getMe = async () => { return data; }; -export const getOwnActivityData = async (username: string) => { - const token = cookies().get("token")?.value; - if (!token) { - return { - error: "Unauthorized" as const, - }; - } - - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + "/users/@me/activity/data", - { - headers: { - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - cache: "no-cache", - next: { - tags: [`activity-data-${username}`], - }, - }, - ); - - if (!response.ok) { - if (response.status === 429) { - return { - error: "Too many requests" as const, - }; - } - - return { - error: "Unknown error when fetching /users/@me/activity/data" as const, - status: response.status, - }; - } - - const data = - (await response.json()) as ApiUsersUserActivityDataResponseItem[]; - - return data; -}; - -export const getOwnActivityDataSummary = async () => { - const token = cookies().get("token")?.value; - if (!token) { - return { - error: "Unauthorized" as const, - }; - } - - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + "/users/@me/activity/summary", - { - headers: { - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - cache: "no-cache", - }, +export const getOwnActivityData = () => + getRequest( + "/users/@me/activity/data", ); - if (!response.ok) { - if (response.status === 429) { - return { - error: "Too many requests" as const, - }; - } - - return { - error: "Unknown error when fetching /users/@me/activity/summary" as const, - status: response.status, - }; - } - - const data = (await response.json()) as ActivityDataSummary; - - return data; -}; +export const getOwnActivityDataSummary = () => + getRequest("/users/@me/activity/summary"); export const getCurrentActivityStatus = async (username: string) => { - const token = cookies().get("token")?.value; - - const h = new Headers(); - if (token) { - // When https://github.com/Testaustime/testaustime-backend/issues/72 is fixed, we can - // remove this check and just use the token directly. - h.append("Authorization", `Bearer ${token}`); - } - h.append("client-ip", headers().get("client-ip") ?? "Unknown IP"); - h.append("bypass-token", process.env.RATELIMIT_IP_FORWARD_SECRET ?? ""); - - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/users/${username}/activity/current`, - { - headers: h, - cache: "no-cache", - }, + const data = await getRequest( + `/users/${username}/activity/current`, ); - if (!response.ok) { - if (response.status === 429) { - return { - error: "Too many requests" as const, - }; + if ("error" in data) { + if (data.response.status === 404) { + const errorJson = (await data.response.json()) as unknown; + if ( + errorJson !== null && + typeof errorJson === "object" && + "error" in errorJson && + typeof errorJson.error === "string" + ) { + if (errorJson.error === "The user has no active session") { + return null; + } + } } - - if (response.status === 404) { - return null; - } - - const error = await response.text(); - console.error( - `Error while fetching current activity: status ${response.status},`, - error, - ); - return { - error: - "Unknown error when fetching /users/{username}/activity/current" as const, - username, - status: response.status, - }; + return data; } - const data = (await response.json()) as CurrentActivityApiResponse; - return { language: data.heartbeat.language, startedAt: data.started, @@ -189,46 +98,17 @@ export const getCurrentActivityStatus = async (username: string) => { }; export const getUserActivityData = async (username: string) => { - const token = cookies().get("token")?.value; - - const h = new Headers(); - if (token) { - // When https://github.com/Testaustime/testaustime-backend/issues/72 is fixed, we can - // remove this check and just use the token directly. - h.append("Authorization", `Bearer ${token}`); - } - h.append("client-ip", headers().get("client-ip") ?? "Unknown IP"); - h.append("bypass-token", process.env.RATELIMIT_IP_FORWARD_SECRET ?? ""); - - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/users/${username}/activity/data`, - { - cache: "no-cache", - headers: h, - }, + const data = await getRequest( + `/users/${username}/activity/data`, ); - if (!response.ok) { - if (response.status === 401) { - return { error: GetUserActivityDataError.Unauthorized }; - } else if (response.status === 404) { - return { error: GetUserActivityDataError.NotFound }; - } else if (response.status === 429) { - return { error: GetUserActivityDataError.RateLimited }; + if ("error" in data) { + if (data.response.status === 404) { + return { error: GetUserActivityDataError.UserNotFound }; } - - const error = await response.text(); - console.error( - "Error while getting user's activity data: status", - response.status, - error, - ); - return { error: GetUserActivityDataError.UnknownError }; + return data; } - const data = - (await response.json()) as ApiUsersUserActivityDataResponseItem[]; - const mappedData: ActivityDataEntry[] = data.map((e) => ({ ...e, start_time: new Date(e.start_time), @@ -239,35 +119,7 @@ export const getUserActivityData = async (username: string) => { return mappedData; }; -export const searchUsers = async (query: string) => { - const response = await fetch( - `${ - process.env.NEXT_PUBLIC_API_URL - }/search/users?keyword=${encodeURIComponent(query)}`, - { - cache: "no-cache", - headers: { - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - }, +export const searchUsers = async (query: string) => + getRequest( + `/search/users?keyword=${encodeURIComponent(query)}`, ); - - if (!response.ok) { - if (response.status === 429) { - return { error: SearchUsersError.RateLimited }; - } - - console.error( - "Error while searching users: status", - response.status, - await response.text(), - ); - - return { error: SearchUsersError.UnknownError }; - } - - const data = (await response.json()) as SearchUsersApiResponse; - - return data; -}; diff --git a/src/app/[locale]/friends/page.tsx b/src/app/[locale]/friends/page.tsx index f686c131..6bb27008 100644 --- a/src/app/[locale]/friends/page.tsx +++ b/src/app/[locale]/friends/page.tsx @@ -10,6 +10,7 @@ import { getOwnActivityDataSummary, } from "../../../api/usersApi"; import { getFriendsList } from "../../../api/friendsApi"; +import { GetRequestError } from "../../../types"; import { getPreferences } from "../../../utils/cookieUtils"; export default async function FriendsPage({ @@ -40,9 +41,9 @@ export default async function FriendsPage({ } if ("error" in friendsList) { - if (friendsList.error === "Unauthorized") { + if (friendsList.error === GetRequestError.Unauthorized) { redirect("/login"); - } else if (friendsList.error === "Too many requests") { + } else if (friendsList.error === GetRequestError.RateLimited) { redirect("/rate-limited"); } else { throw new Error(JSON.stringify(friendsList)); @@ -50,9 +51,9 @@ export default async function FriendsPage({ } if ("error" in ownDataSummary) { - if (ownDataSummary.error === "Too many requests") { + if (ownDataSummary.error === GetRequestError.RateLimited) { redirect("/rate-limited"); - } else if (ownDataSummary.error === "Unauthorized") { + } else if (ownDataSummary.error === GetRequestError.Unauthorized) { redirect("/login"); } else { throw new Error(JSON.stringify(ownDataSummary)); @@ -60,7 +61,7 @@ export default async function FriendsPage({ } if (ownActivityStatus && "error" in ownActivityStatus) { - if (ownActivityStatus.error === "Too many requests") { + if (ownActivityStatus.error === GetRequestError.RateLimited) { redirect("/rate-limited"); } else { throw new Error(JSON.stringify(ownActivityStatus)); diff --git a/src/app/[locale]/leaderboards/JoinLeaderboardButton.tsx b/src/app/[locale]/leaderboards/JoinLeaderboardButton.tsx index bede48be..30a56ab7 100644 --- a/src/app/[locale]/leaderboards/JoinLeaderboardButton.tsx +++ b/src/app/[locale]/leaderboards/JoinLeaderboardButton.tsx @@ -3,7 +3,7 @@ import { Button } from "@mantine/core"; import { useModals } from "@mantine/modals"; import { EnterIcon } from "@radix-ui/react-icons"; -import { useSearchParams } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useCallback, useEffect } from "react"; import { JoinLeaderboardModal } from "../../../components/leaderboard/JoinLeaderboardModal"; import { useTranslation } from "react-i18next"; @@ -13,6 +13,7 @@ export const JoinLeaderboardButton = () => { const params = useSearchParams(); const urlLeaderboardCode = params.get("code"); const { t } = useTranslation(); + const router = useRouter(); const openJoinLeaderboard = useCallback(() => { const id = modals.openModal({ @@ -22,6 +23,7 @@ export const JoinLeaderboardButton = () => { { + router.refresh(); modals.closeModal(id); }} /> @@ -34,7 +36,7 @@ export const JoinLeaderboardButton = () => { }, }, }); - }, [modals, t, urlLeaderboardCode]); + }, [modals, router, t, urlLeaderboardCode]); useEffect(() => { if (urlLeaderboardCode) openJoinLeaderboard(); diff --git a/src/app/[locale]/leaderboards/[name]/DeleteLeaderboardButton.tsx b/src/app/[locale]/leaderboards/[name]/DeleteLeaderboardButton.tsx index 2fb5bd3d..5de7aa91 100644 --- a/src/app/[locale]/leaderboards/[name]/DeleteLeaderboardButton.tsx +++ b/src/app/[locale]/leaderboards/[name]/DeleteLeaderboardButton.tsx @@ -4,11 +4,11 @@ import { useTranslation } from "react-i18next"; import ButtonWithConfirmation from "../../../../components/ButtonWithConfirmation"; import { Trash2 } from "react-feather"; import { deleteLeaderboard } from "../../../../components/leaderboard/actions"; -import { DeleteLeaderboardError } from "../../../../types"; import { showNotification } from "@mantine/notifications"; import { logOutAndRedirect } from "../../../../utils/authUtils"; import { useRouter } from "next/navigation"; import { useState } from "react"; +import { PostRequestError } from "../../../../types"; type DeleteLeaderboardButtonProps = { name: string; @@ -31,9 +31,11 @@ export const DeleteLeaderboardButton = ({ setIsDeleting(true); deleteLeaderboard(name) .then(async (res) => { - if ("error" in res) { + // Using `redirect` will return undefined, but it uses the type `never` so we don't notice it. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (res && "error" in res) { switch (res.error) { - case DeleteLeaderboardError.Unauthorized: + case PostRequestError.Unauthorized: showNotification({ title: t("error"), color: "red", @@ -41,10 +43,10 @@ export const DeleteLeaderboardButton = ({ }); await logOutAndRedirect(); break; - case DeleteLeaderboardError.RateLimited: + case PostRequestError.RateLimited: router.push("/rate-limited"); break; - case DeleteLeaderboardError.UnknownError: + case PostRequestError.UnknownError: showNotification({ title: t("error"), color: "red", @@ -52,9 +54,17 @@ export const DeleteLeaderboardButton = ({ }); break; } + } else { + showNotification({ + title: t("leaderboards.deletionNotification.successTitle"), + color: "green", + message: t( + "leaderboards.deletionNotification.successDescription", + ), + }); } }) - .catch((e) => { + .catch((e: unknown) => { console.log(e); }) .finally(() => { diff --git a/src/app/[locale]/leaderboards/[name]/DemoteUserButton.tsx b/src/app/[locale]/leaderboards/[name]/DemoteUserButton.tsx index f8369cc1..5dcfe3e1 100644 --- a/src/app/[locale]/leaderboards/[name]/DemoteUserButton.tsx +++ b/src/app/[locale]/leaderboards/[name]/DemoteUserButton.tsx @@ -4,6 +4,10 @@ import { Button } from "@mantine/core"; import { DoubleArrowDownIcon } from "@radix-ui/react-icons"; import { useTranslation } from "react-i18next"; import { demoteUser } from "./actions"; +import { useRouter } from "next/navigation"; +import { showNotification } from "@mantine/notifications"; +import { PostRequestError } from "../../../../types"; +import { logOutAndRedirect } from "../../../../utils/authUtils"; type DemoteUserButtonProps = { name: string; @@ -11,6 +15,7 @@ type DemoteUserButtonProps = { }; export const DemoteUserButton = ({ name, username }: DemoteUserButtonProps) => { + const router = useRouter(); const { t } = useTranslation(); return ( @@ -20,9 +25,36 @@ export const DemoteUserButton = ({ name, username }: DemoteUserButtonProps) => { leftSection={} color="red" onClick={() => { - demoteUser(username, name).catch((e) => { - console.log(e); - }); + demoteUser(username, name) + .then(async (data) => { + if ("error" in data) { + switch (data.error) { + case PostRequestError.Unauthorized: + showNotification({ + title: t("error"), + color: "red", + message: t("errors.unauthorized"), + }); + await logOutAndRedirect(); + break; + case PostRequestError.RateLimited: + router.push("/rate-limited"); + break; + case PostRequestError.UnknownError: + showNotification({ + title: t("error"), + color: "red", + message: t("unknownErrorOccurred"), + }); + break; + } + } else { + router.refresh(); + } + }) + .catch((e: unknown) => { + console.log(e); + }); }} > {t("leaderboards.demote")} diff --git a/src/app/[locale]/leaderboards/[name]/LeaderboardInviteTokenField.tsx b/src/app/[locale]/leaderboards/[name]/LeaderboardInviteTokenField.tsx index 7cd1ab89..7e775405 100644 --- a/src/app/[locale]/leaderboards/[name]/LeaderboardInviteTokenField.tsx +++ b/src/app/[locale]/leaderboards/[name]/LeaderboardInviteTokenField.tsx @@ -3,10 +3,10 @@ import { useRouter } from "next/navigation"; import TokenField from "../../../../components/TokenField"; import { regenerateInviteCode } from "./actions"; -import { RegenerateInviteCodeError } from "../../../../types"; import { showNotification } from "@mantine/notifications"; import { useTranslation } from "react-i18next"; import { logOutAndRedirect } from "../../../../utils/authUtils"; +import { PostRequestError } from "../../../../types"; type LeaderboardInviteTokenFieldProps = { leaderboardName: string; @@ -29,12 +29,12 @@ export const LeaderboardInviteTokenField = ({ isAdmin ? async () => { const result = await regenerateInviteCode(leaderboardName); - if (result && "error" in result) { + if ("error" in result) { switch (result.error) { - case RegenerateInviteCodeError.RateLimited: + case PostRequestError.RateLimited: router.push("/rate-limited"); break; - case RegenerateInviteCodeError.Unauthorized: + case PostRequestError.Unauthorized: showNotification({ title: t("error"), color: "red", @@ -42,7 +42,7 @@ export const LeaderboardInviteTokenField = ({ }); await logOutAndRedirect(); break; - case RegenerateInviteCodeError.UnknownError: + case PostRequestError.UnknownError: showNotification({ title: t("error"), color: "red", diff --git a/src/app/[locale]/leaderboards/[name]/LeaveLeaderboardButton.tsx b/src/app/[locale]/leaderboards/[name]/LeaveLeaderboardButton.tsx index 6cd7528c..cd7ede53 100644 --- a/src/app/[locale]/leaderboards/[name]/LeaveLeaderboardButton.tsx +++ b/src/app/[locale]/leaderboards/[name]/LeaveLeaderboardButton.tsx @@ -4,10 +4,10 @@ import { ExitIcon } from "@radix-ui/react-icons"; import ButtonWithConfirmation from "../../../../components/ButtonWithConfirmation"; import { leaveLeaderboard } from "../../../../components/leaderboard/actions"; import { useTranslation } from "react-i18next"; -import { LeaveLeaderboardError } from "../../../../types"; import { showNotification } from "@mantine/notifications"; import { useRouter } from "next/navigation"; import { logOutAndRedirect } from "../../../../utils/authUtils"; +import { PostRequestError } from "../../../../types"; type LeaveLeaderboardButtonProps = { name: string; @@ -30,9 +30,11 @@ export const LeaveLeaderboardButton = ({ onClick={() => { leaveLeaderboard(name) .then(async (res) => { - if ("error" in res) { + // Using `redirect` will return undefined, but it uses the type `never` so we don't notice it. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (res && "error" in res) { switch (res.error) { - case LeaveLeaderboardError.Unauthorized: + case PostRequestError.Unauthorized: showNotification({ title: t("error"), color: "red", @@ -40,10 +42,10 @@ export const LeaveLeaderboardButton = ({ }); await logOutAndRedirect(); break; - case LeaveLeaderboardError.RateLimited: + case PostRequestError.RateLimited: router.push("/rate-limited"); break; - case LeaveLeaderboardError.UnknownError: + case PostRequestError.UnknownError: showNotification({ title: t("error"), color: "red", @@ -53,7 +55,7 @@ export const LeaveLeaderboardButton = ({ } } }) - .catch((e) => { + .catch((e: unknown) => { console.error(e); }); }} diff --git a/src/app/[locale]/leaderboards/[name]/PromoteUserButton.tsx b/src/app/[locale]/leaderboards/[name]/PromoteUserButton.tsx index e1730a51..c2b64af5 100644 --- a/src/app/[locale]/leaderboards/[name]/PromoteUserButton.tsx +++ b/src/app/[locale]/leaderboards/[name]/PromoteUserButton.tsx @@ -4,6 +4,10 @@ import { Button } from "@mantine/core"; import { DoubleArrowUpIcon } from "@radix-ui/react-icons"; import { useTranslation } from "react-i18next"; import { promoteUser } from "./actions"; +import { PostRequestError } from "../../../../types"; +import { showNotification } from "@mantine/notifications"; +import { logOutAndRedirect } from "../../../../utils/authUtils"; +import { useRouter } from "next/navigation"; type PromoteUserButtonProps = { name: string; @@ -14,6 +18,8 @@ export const PromoteUserButton = ({ name, username, }: PromoteUserButtonProps) => { + const router = useRouter(); + const { t } = useTranslation(); return ( @@ -23,7 +29,36 @@ export const PromoteUserButton = ({ leftSection={} color="green" onClick={() => { - void promoteUser(username, name); + promoteUser(username, name) + .then(async (data) => { + if ("error" in data) { + switch (data.error) { + case PostRequestError.Unauthorized: + showNotification({ + title: t("error"), + color: "red", + message: t("errors.unauthorized"), + }); + await logOutAndRedirect(); + break; + case PostRequestError.RateLimited: + router.push("/rate-limited"); + break; + case PostRequestError.UnknownError: + showNotification({ + title: t("error"), + color: "red", + message: t("unknownErrorOccurred"), + }); + break; + } + } else { + router.refresh(); + } + }) + .catch((e: unknown) => { + console.log(e); + }); }} > {t("leaderboards.promote")} diff --git a/src/app/[locale]/leaderboards/[name]/actions.ts b/src/app/[locale]/leaderboards/[name]/actions.ts index 1338f7c1..5b0e0e5c 100644 --- a/src/app/[locale]/leaderboards/[name]/actions.ts +++ b/src/app/[locale]/leaderboards/[name]/actions.ts @@ -1,154 +1,21 @@ "use server"; -import { revalidateTag } from "next/cache"; -import { cookies, headers } from "next/headers"; -import { RegenerateInviteCodeError } from "../../../../types"; +import { postRequestWithoutResponse } from "../../../../api/baseApi"; -export const regenerateInviteCode = async (leaderboardName: string) => { - const token = cookies().get("secure-access-token")?.value; +export const regenerateInviteCode = (leaderboardName: string) => + postRequestWithoutResponse(`/leaderboards/${leaderboardName}/regenerate`); - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + - `/leaderboards/${leaderboardName}/regenerate`, - { - body: "{}", // https://github.com/Testaustime/testaustime-backend/issues/93 - method: "POST", - cache: "no-cache", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - }, - ); +export const promoteUser = (username: string, leaderboardName: string) => + postRequestWithoutResponse(`/leaderboards/${leaderboardName}/promote`, { + user: username, + }); - if (!response.ok) { - if (response.status === 401) { - return { error: RegenerateInviteCodeError.Unauthorized }; - } else if (response.status === 429) { - return { error: RegenerateInviteCodeError.RateLimited }; - } +export const demoteUser = (username: string, leaderboardName: string) => + postRequestWithoutResponse(`/leaderboards/${leaderboardName}/demote`, { + user: username, + }); - return { error: RegenerateInviteCodeError.UnknownError }; - } -}; - -export const promoteUser = async ( - username: string, - leaderboardName: string, -) => { - const token = cookies().get("secure-access-token")?.value; - - if (!token) { - return { error: "Unauthorized" as const }; - } - - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + - `/leaderboards/${leaderboardName}/promote`, - { - body: JSON.stringify({ user: username }), - method: "POST", - cache: "no-cache", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - }, - ); - - if (!response.ok) { - console.log(response.status); - if (response.status === 429) { - return { error: "Too many requests" as const }; - } - - return { - error: - "Unknown error when fetching /leaderboards/{leaderboardName}/promot" as const, - leaderboardName, - status: response.status, - }; - } - - revalidateTag(`leaderboard-${leaderboardName}`); -}; - -export const demoteUser = async (username: string, leaderboardName: string) => { - const token = cookies().get("secure-access-token")?.value; - - if (!token) { - return { error: "Unauthorized" as const }; - } - - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + `/leaderboards/${leaderboardName}/demote`, - { - body: JSON.stringify({ user: username }), - method: "POST", - cache: "no-cache", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - }, - ); - - if (!response.ok) { - if (response.status === 429) { - return { error: "Too many requests" as const }; - } - - return { - error: - "Unknown error when fetching /leadeboards/{leaderboardName}/demote" as const, - leaderboardName, - status: response.status, - }; - } - - revalidateTag(`leaderboard-${leaderboardName}`); -}; - -export const kickUser = async (username: string, leaderboardName: string) => { - const token = cookies().get("secure-access-token")?.value; - - if (!token) { - return { error: "Unauthorized" as const }; - } - - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + `/leaderboards/${leaderboardName}/kick`, - { - body: JSON.stringify({ user: username }), - method: "POST", - cache: "no-cache", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - }, - ); - - if (!response.ok) { - if (response.status === 429) { - return { error: "Too many requests" as const }; - } - - return { - error: - "Unknown error when fetching /leaderboards/{leaderboardName}/kick" as const, - leaderboardName, - status: response.status, - }; - } - - revalidateTag(`leaderboard-${leaderboardName}`); -}; +export const kickUser = (username: string, leaderboardName: string) => + postRequestWithoutResponse(`/leaderboards/${leaderboardName}/kick`, { + user: username, + }); diff --git a/src/app/[locale]/leaderboards/[name]/page.tsx b/src/app/[locale]/leaderboards/[name]/page.tsx index bbe6c5cc..1f79ec31 100644 --- a/src/app/[locale]/leaderboards/[name]/page.tsx +++ b/src/app/[locale]/leaderboards/[name]/page.tsx @@ -8,6 +8,7 @@ import { TableTbody, TableThead, TableTh, + Stack, } from "@mantine/core"; import { getLeaderboard } from "../../../../api/leaderboardApi"; import { redirect } from "next/navigation"; @@ -21,7 +22,7 @@ import { LeaderboardInviteTokenField } from "./LeaderboardInviteTokenField"; import { DemoteUserButton } from "./DemoteUserButton"; import { PromoteUserButton } from "./PromoteUserButton"; import { KickUserButton } from "./KickUserButton"; -import { GetLeaderboardError } from "../../../../types"; +import { GetLeaderboardError, GetRequestError } from "../../../../types"; import { getPreferences } from "../../../../utils/cookieUtils"; import { YOU_BADGE_COLOR } from "../../../../utils/constants"; @@ -45,19 +46,26 @@ export default async function LeaderboardPage({ } } + const { t } = await initTranslations(locale, ["common"]); + const leaderboard = await getLeaderboard(name); if ("error" in leaderboard) { - if (leaderboard.error === GetLeaderboardError.RateLimited) { + if (leaderboard.error === GetRequestError.RateLimited) { redirect("/rate-limited"); - } else if (leaderboard.error === GetLeaderboardError.Unauthorized) { + } else if (leaderboard.error === GetRequestError.Unauthorized) { redirect("/login"); + } else if (leaderboard.error === GetLeaderboardError.LeaderboardNotFound) { + return ( + + {name} +
{t("leaderboards.error.notFound")}
+
+ ); } else { throw new Error(JSON.stringify(leaderboard)); } } - const { t } = await initTranslations(locale, ["common"]); - const adminUsernames = leaderboard.members .filter((m) => m.admin) .map((m) => m.username); diff --git a/src/app/[locale]/leaderboards/page.tsx b/src/app/[locale]/leaderboards/page.tsx index a7d40472..4a52cd3e 100644 --- a/src/app/[locale]/leaderboards/page.tsx +++ b/src/app/[locale]/leaderboards/page.tsx @@ -1,6 +1,6 @@ import { Group, Title } from "@mantine/core"; import { LeaderboardsList } from "../../../components/leaderboard/LeaderboardsList"; -import { GetLeaderboardsError, LeaderboardData } from "../../../types"; +import { GetRequestError, LeaderboardData } from "../../../types"; import { redirect } from "next/navigation"; import initTranslations from "../../i18n"; import { CreateNewLeaderboardButton } from "./CreateNewLeaderboardButton"; @@ -25,13 +25,13 @@ export default async function LeaderboardsPage({ const leaderboardList = await getMyLeaderboards(); if (!Array.isArray(leaderboardList)) { switch (leaderboardList.error) { - case GetLeaderboardsError.RateLimited: + case GetRequestError.RateLimited: redirect("/rate-limited"); break; - case GetLeaderboardsError.Unauthorized: + case GetRequestError.Unauthorized: redirect("/login"); break; - case GetLeaderboardsError.UnknownError: + case GetRequestError.UnknownError: return ( <> diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index 1b277020..0fcc587f 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -4,7 +4,7 @@ import { DownloadIcon } from "@radix-ui/react-icons"; import { Dashboard } from "../../components/Dashboard"; import { startOfDay } from "date-fns"; import styles from "./page.module.css"; -import { ApiUsersUserResponse } from "../../types"; +import { ApiUsersUserResponse, GetRequestError } from "../../types"; import { cookies } from "next/headers"; import initTranslations from "../i18n"; import { @@ -33,10 +33,10 @@ export default async function MainPage({ } if (me) { - const activityData = await getOwnActivityData(me.username); + const activityData = await getOwnActivityData(); if ("error" in activityData) { - if (activityData.error === "Too many requests") { + if (activityData.error === GetRequestError.RateLimited) { redirect("/rate-limited"); } throw new Error(JSON.stringify(activityData)); @@ -44,7 +44,7 @@ export default async function MainPage({ const currentActivity = await getCurrentActivityStatus(me.username); if (currentActivity && "error" in currentActivity) { - if (currentActivity.error === "Too many requests") { + if (currentActivity.error === GetRequestError.RateLimited) { redirect("/rate-limited"); } throw new Error(JSON.stringify(currentActivity)); diff --git a/src/app/[locale]/profile/AuthTokenField.tsx b/src/app/[locale]/profile/AuthTokenField.tsx index 02b5d61e..87630d31 100644 --- a/src/app/[locale]/profile/AuthTokenField.tsx +++ b/src/app/[locale]/profile/AuthTokenField.tsx @@ -4,45 +4,107 @@ import { showNotification } from "@mantine/notifications"; import TokenField from "../../../components/TokenField"; import { regenerateToken } from "./actions"; import { useTranslation } from "react-i18next"; -import { RegenerateAuthTokenError } from "../../../types"; -import { useRouter } from "next/navigation"; -import { logOutAndRedirect } from "../../../utils/authUtils"; +import { PostRequestError } from "../../../types"; +import { Button, LoadingOverlay, Text } from "@mantine/core"; +import { useModals } from "@mantine/modals"; +import { FormikPasswordInput } from "../../../components/forms/FormikPasswordInput"; +import { Form, Formik } from "formik"; +import * as Yup from "yup"; -export const AuthTokenField = ({ token }: { token: string }) => { +export const AuthTokenField = ({ + username, + token, +}: { + username: string; + token: string; +}) => { const { t } = useTranslation(); + const modals = useModals(); - const router = useRouter(); + const openPasswordModal = () => { + const id = modals.openModal({ + title: t("profile.sudoOperation.title"), + size: "xl", + children: ( +
+ + {t("profile.authenticationToken.passwordRequiredDescription")} + + { + const result = await regenerateToken(username, values.password); + if (result && "error" in result) { + showNotification({ + title: t("error"), + message: { + [PostRequestError.RateLimited]: t("rateLimitedError"), + [PostRequestError.Unauthorized]: t( + "profile.sudoOperation.wrongPassword", + ), + [PostRequestError.UnknownError]: t("unknownErrorOccurred"), + }[result.error], + color: "red", + }); + } else { + showNotification({ + title: t( + "profile.authenticationToken.regenerateSuccessTitle", + ), + message: t( + "profile.authenticationToken.regenerateSuccessDescription", + ), + color: "green", + autoClose: 15000, + }); + modals.closeModal(id); + } + }} + > + {(formik) => ( +
+ + + + )} +
+
+ ), + styles: { + title: { + fontSize: "2rem", + marginBlock: "0.5rem", + fontWeight: "bold", + }, + }, + }); + }; return ( - { - const result = await regenerateToken(); - if (result) { - switch (result.error) { - case RegenerateAuthTokenError.RateLimited: - router.push("/rate-limited"); - break; - case RegenerateAuthTokenError.Unauthorized: - showNotification({ - title: t("error"), - color: "red", - message: t("errors.unauthorized"), - }); - await logOutAndRedirect(); - break; - case RegenerateAuthTokenError.UnknownError: - showNotification({ - title: t("error"), - color: "red", - message: t("unknownErrorOccurred"), - }); - break; - } - } - }} - censorable - revealLength={4} - /> + <> + { + openPasswordModal(); + }} + censorable + revealLength={4} + /> + ); }; diff --git a/src/app/[locale]/profile/DeleteAccountButton.tsx b/src/app/[locale]/profile/DeleteAccountButton.tsx index 5da9eb7f..9dcfab1f 100644 --- a/src/app/[locale]/profile/DeleteAccountButton.tsx +++ b/src/app/[locale]/profile/DeleteAccountButton.tsx @@ -4,7 +4,6 @@ import { useModals } from "@mantine/modals"; import ButtonWithConfirmation from "../../../components/ButtonWithConfirmation"; import ConfirmAccountDeletionModal from "../../../components/ConfirmAccountDeletionModal"; import { useTranslation } from "react-i18next"; -import { deleteAccount } from "./deleteAccount"; export const DeleteAccountButton = ({ username }: { username: string }) => { const modals = useModals(); @@ -12,15 +11,12 @@ export const DeleteAccountButton = ({ username }: { username: string }) => { const openDeleteAccountModal = () => { const id = modals.openModal({ - title: t("profile.deleteAccount.modal.title"), + title: t("profile.sudoOperation.confirmButton"), size: "xl", children: ( { - modals.closeModal(id); - }} - onConfirm={async (password) => { - await deleteAccount(username, password); + username={username} + closeModal={() => { modals.closeModal(id); }} /> diff --git a/src/app/[locale]/profile/FriendCodeField.tsx b/src/app/[locale]/profile/FriendCodeField.tsx index 9a35c24d..34d60403 100644 --- a/src/app/[locale]/profile/FriendCodeField.tsx +++ b/src/app/[locale]/profile/FriendCodeField.tsx @@ -3,10 +3,10 @@ import { useRouter } from "next/navigation"; import TokenField from "../../../components/TokenField"; import { regenerateFriendCode } from "./actions"; -import { RegenerateFriendCodeError } from "../../../types"; import { showNotification } from "@mantine/notifications"; import { useTranslation } from "react-i18next"; import { logOutAndRedirect } from "../../../utils/authUtils"; +import { PostRequestError } from "../../../types"; export const FriendCodeField = ({ friendCode }: { friendCode: string }) => { const router = useRouter(); @@ -19,9 +19,9 @@ export const FriendCodeField = ({ friendCode }: { friendCode: string }) => { revealLength={4} regenerate={async () => { const result = await regenerateFriendCode(); - if (result && "error" in result) { + if ("error" in result) { switch (result.error) { - case RegenerateFriendCodeError.Unauthorized: + case PostRequestError.Unauthorized: showNotification({ title: t("error"), color: "red", @@ -29,10 +29,10 @@ export const FriendCodeField = ({ friendCode }: { friendCode: string }) => { }); await logOutAndRedirect(); break; - case RegenerateFriendCodeError.RateLimited: + case PostRequestError.RateLimited: router.push("/rate-limited"); break; - case RegenerateFriendCodeError.UnknownError: + case PostRequestError.UnknownError: showNotification({ title: t("error"), color: "red", diff --git a/src/app/[locale]/profile/ProfileVisibilityToggle.tsx b/src/app/[locale]/profile/ProfileVisibilityToggle.tsx index f3b41cc7..d153ac19 100644 --- a/src/app/[locale]/profile/ProfileVisibilityToggle.tsx +++ b/src/app/[locale]/profile/ProfileVisibilityToggle.tsx @@ -5,9 +5,9 @@ import { useTranslation } from "react-i18next"; import { changeAccountVisibility } from "./actions"; import { showNotification } from "@mantine/notifications"; import { useRouter } from "next/navigation"; -import { ChangeAccountVisibilityError } from "../../../types"; import { logOutAndRedirect } from "../../../utils/authUtils"; import { useState } from "react"; +import { PostRequestError } from "../../../types"; export const ProfileVisibilityToggle = ({ isPublic, @@ -27,12 +27,12 @@ export const ProfileVisibilityToggle = ({ setIsLoading(true); const result = await changeAccountVisibility(!isPublic); setIsLoading(false); - if (result) { + if ("error" in result) { switch (result.error) { - case ChangeAccountVisibilityError.RateLimited: + case PostRequestError.RateLimited: router.push("/rate-limited"); break; - case ChangeAccountVisibilityError.Unauthorized: + case PostRequestError.Unauthorized: showNotification({ title: t("error"), color: "red", @@ -40,7 +40,7 @@ export const ProfileVisibilityToggle = ({ }); await logOutAndRedirect(); break; - case ChangeAccountVisibilityError.UnknownError: + case PostRequestError.UnknownError: showNotification({ title: t("error"), color: "red", diff --git a/src/app/[locale]/profile/actions.ts b/src/app/[locale]/profile/actions.ts index 9631b1d2..cee1d55e 100644 --- a/src/app/[locale]/profile/actions.ts +++ b/src/app/[locale]/profile/actions.ts @@ -1,49 +1,32 @@ "use server"; -import { cookies, headers } from "next/headers"; +import { cookies } from "next/headers"; import { - ChangeAccountVisibilityError, - RegenerateAuthTokenError, - RegenerateFriendCodeError, -} from "../../../types"; + postRequestWithoutResponse, + postRequestWithResponse, +} from "../../../api/baseApi"; interface ApiAuthRegenerateResponse { token: string; } -export const regenerateToken = async () => { - const token = cookies().get("secure-access-token")?.value; - - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + "/auth/regenerate", +export const regenerateToken = async (username: string, password: string) => { + const res = await postRequestWithResponse( + "/auth/regenerate", { - method: "POST", - cache: "no-cache", - headers: { - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, + username, + password, }, ); - if (!response.ok) { - if (response.status === 401) { - return { error: RegenerateAuthTokenError.Unauthorized }; - } else if (response.status === 429) { - return { error: RegenerateAuthTokenError.RateLimited }; - } else { - console.log(response.status, await response.text()); - return { error: RegenerateAuthTokenError.UnknownError }; - } + if ("error" in res) { + return res; } - const data = (await response.json()) as ApiAuthRegenerateResponse; - const expiration = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7); - cookies().set("token", data.token, { - value: data.token, + cookies().set("token", res.data.token, { + value: res.data.token, expires: expiration, path: "/", sameSite: "strict", @@ -52,60 +35,10 @@ export const regenerateToken = async () => { }); }; -export const regenerateFriendCode = async () => { - const token = cookies().get("secure-access-token")?.value; - - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + "/friends/regenerate", - { - method: "POST", - cache: "no-cache", - headers: { - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - }, - ); - - if (!response.ok) { - if (response.status === 401) { - return { error: RegenerateFriendCodeError.Unauthorized }; - } else if (response.status === 429) { - return { error: RegenerateFriendCodeError.RateLimited }; - } - - return { error: RegenerateFriendCodeError.UnknownError }; - } -}; +export const regenerateFriendCode = () => + postRequestWithoutResponse("/friends/regenerate"); -export const changeAccountVisibility = async (isPublic: boolean) => { - const token = cookies().get("secure-access-token")?.value; - - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + "/account/settings", - { - method: "POST", - cache: "no-cache", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - body: JSON.stringify({ - public_profile: isPublic, - }), - }, - ); - - if (!response.ok) { - if (response.status === 401) { - return { error: ChangeAccountVisibilityError.Unauthorized }; - } else if (response.status === 429) { - return { error: ChangeAccountVisibilityError.RateLimited }; - } - - return { error: ChangeAccountVisibilityError.UnknownError }; - } -}; +export const changeAccountVisibility = (isPublic: boolean) => + postRequestWithoutResponse("/account/settings", { + public_profile: isPublic, + }); diff --git a/src/app/[locale]/profile/deleteAccount.ts b/src/app/[locale]/profile/deleteAccount.ts index fd36bafe..b66a9e5e 100644 --- a/src/app/[locale]/profile/deleteAccount.ts +++ b/src/app/[locale]/profile/deleteAccount.ts @@ -1,25 +1,19 @@ "use server"; -import { cookies, headers } from "next/headers"; import { logOutAndRedirect } from "../../../utils/authUtils"; +import { postRequestWithoutResponse } from "../../../api/baseApi"; export const deleteAccount = async (username: string, password: string) => { - const token = cookies().get("token")?.value; - - await fetch(process.env.NEXT_PUBLIC_API_URL + "/users/@me/delete", { - method: "DELETE", - cache: "no-cache", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - body: JSON.stringify({ + const res = await postRequestWithoutResponse( + "/users/@me/delete", + { username, password, - }), - }); + }, + "DELETE", + ); + + if ("error" in res) return res; await logOutAndRedirect(); }; diff --git a/src/app/[locale]/profile/page.tsx b/src/app/[locale]/profile/page.tsx index 09b6423c..f91d9d7e 100644 --- a/src/app/[locale]/profile/page.tsx +++ b/src/app/[locale]/profile/page.tsx @@ -114,7 +114,7 @@ export default async function ProfilePage({ > {t("profile.authenticationToken.title")} - + { const [visible, setVisible] = useState(false); @@ -49,7 +49,7 @@ export const RegistrationForm = () => { setVisible(true); const result = await register(values.username, values.password); switch (result) { - case RegistrationResult.RateLimited: + case PostRequestError.RateLimited: showNotification({ title: t("error"), color: "red", @@ -57,7 +57,7 @@ export const RegistrationForm = () => { }); setVisible(false); break; - case RegistrationResult.UnknownError: + case PostRequestError.UnknownError: showNotification({ title: t("error"), color: "red", diff --git a/src/app/[locale]/register/actions.ts b/src/app/[locale]/register/actions.ts index 188ab1b7..d801d3e0 100644 --- a/src/app/[locale]/register/actions.ts +++ b/src/app/[locale]/register/actions.ts @@ -1,9 +1,9 @@ "use server"; -import { cookies, headers } from "next/headers"; +import { cookies } from "next/headers"; import { redirect } from "next/navigation"; -import { RegistrationResult } from "../../../types"; -import { SecureAccessTokenResponse } from "../../../components/LoginForm/actions"; +import { postRequestWithResponse } from "../../../api/baseApi"; +import { PostRequestError, RegistrationResult } from "../../../types"; interface ApiAuthLoginResponse { id: number; @@ -15,56 +15,28 @@ interface ApiAuthLoginResponse { } export const register = async (username: string, password: string) => { - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + "/auth/register", + const res = await postRequestWithResponse( + "/auth/register", { - method: "POST", - cache: "no-cache", - headers: { - "Content-Type": "application/json", - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - body: JSON.stringify({ - username: username, - password: password, - }), + username, + password, }, ); - if (!response.ok) { - if (response.status === 409) { + if ("error" in res) { + if (res.statusCode === 409) { return RegistrationResult.UsernameTaken; } - if (response.status === 429) { - return RegistrationResult.RateLimited; + if (res.statusCode === 429) { + return PostRequestError.RateLimited; } - return RegistrationResult.UnknownError; + return PostRequestError.UnknownError; } - const secureAccessTokenResponse = await fetch( - process.env.NEXT_PUBLIC_API_URL + "/auth/securedaccess", - { - method: "POST", - cache: "no-cache", - headers: { - "Content-Type": "application/json", - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - body: JSON.stringify({ - username: username, - password: password, - }), - }, - ); - - const data = (await response.json()) as ApiAuthLoginResponse; - const expiration = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7); - cookies().set("token", data.auth_token, { - value: data.auth_token, + cookies().set("token", res.data.auth_token, { + value: res.data.auth_token, expires: expiration, path: "/", sameSite: "strict", @@ -72,17 +44,5 @@ export const register = async (username: string, password: string) => { httpOnly: true, }); - const secureAccessTokenData = - (await secureAccessTokenResponse.json()) as SecureAccessTokenResponse; - const secureAccessTokenExpiration = new Date(Date.now() + 1000 * 60 * 60); - cookies().set("secure-access-token", secureAccessTokenData.token, { - value: secureAccessTokenData.token, - expires: secureAccessTokenExpiration, - path: "/", - sameSite: "strict", - secure: true, - httpOnly: true, - }); - redirect("/"); }; diff --git a/src/app/[locale]/users/[username]/page.tsx b/src/app/[locale]/users/[username]/page.tsx index ce1a9be5..aced4271 100644 --- a/src/app/[locale]/users/[username]/page.tsx +++ b/src/app/[locale]/users/[username]/page.tsx @@ -5,7 +5,7 @@ import { getUserActivityData, } from "../../../../api/usersApi"; import { CurrentActivity } from "../../../../components/CurrentActivity/CurrentActivity"; -import { GetUserActivityDataError } from "../../../../types"; +import { GetRequestError, GetUserActivityDataError } from "../../../../types"; import initTranslations from "../../../i18n"; import { Stack, Title } from "@mantine/core"; @@ -19,29 +19,29 @@ export default async function UserPage({ if ("error" in data) { switch (data.error) { - case GetUserActivityDataError.NotFound: + case GetUserActivityDataError.UserNotFound: return ( {username}
{t("users.notFound")}
); - case GetUserActivityDataError.RateLimited: + case GetRequestError.RateLimited: return redirect("/rate-limited"); - case GetUserActivityDataError.Unauthorized: + case GetRequestError.Unauthorized: return redirect("/login"); - case GetUserActivityDataError.UnknownError: + case GetRequestError.UnknownError: return
{t("unknownErrorOccurred")}
; } } const decodedUsername = decodeURIComponent(username); - let currentActivity: CurrentActivity | undefined = undefined; + let currentActivity: CurrentActivity | null = null; const currentActivityResponse = await getCurrentActivityStatus(decodedUsername); if (!(currentActivityResponse && "error" in currentActivityResponse)) { - currentActivity = currentActivityResponse ?? undefined; + currentActivity = currentActivityResponse; } return ( diff --git a/src/app/[locale]/users/page.tsx b/src/app/[locale]/users/page.tsx index 1c69fcff..3d9581ba 100644 --- a/src/app/[locale]/users/page.tsx +++ b/src/app/[locale]/users/page.tsx @@ -1,6 +1,5 @@ import { redirect } from "next/navigation"; import { searchUsers } from "../../../api/usersApi"; -import { SearchUsersApiResponse, SearchUsersError } from "../../../types"; import initTranslations from "../../i18n"; import { Table, @@ -11,6 +10,9 @@ import { TableTr, } from "@mantine/core"; import Link from "next/link"; +import { showNotification } from "@mantine/notifications"; +import { logOutAndRedirect } from "../../../utils/authUtils"; +import { GetRequestError } from "../../../types"; export default async function UsersPage({ params, @@ -23,25 +25,31 @@ export default async function UsersPage({ const users = await searchUsers(keyword); const { t } = await initTranslations(params.locale, ["common"]); - let data: SearchUsersApiResponse = []; if ("error" in users) { switch (users.error) { - case SearchUsersError.RateLimited: + case GetRequestError.RateLimited: return redirect("/rate-limited"); - case SearchUsersError.UnknownError: + case GetRequestError.UnknownError: return
{t("users.search.unknownError")}
; + case GetRequestError.Unauthorized: + showNotification({ + title: t("error"), + color: "red", + message: t("errors.unauthorized"), + }); + await logOutAndRedirect(); + break; } - } else { - data = users; + return; } return (

{t("users.search.title")}

- {t("users.search.resultCount", { count: data.length, query: keyword })} + {t("users.search.resultCount", { count: users.length, query: keyword })}

- {data.length > 0 && ( + {users.length > 0 && ( @@ -49,7 +57,7 @@ export default async function UsersPage({ - {data.map((user) => ( + {users.map((user) => ( diff --git a/src/app/api/activity-status/[username]/route.ts b/src/app/api/activity-status/[username]/route.ts index 9e9aa1aa..2640f6bc 100644 --- a/src/app/api/activity-status/[username]/route.ts +++ b/src/app/api/activity-status/[username]/route.ts @@ -10,8 +10,20 @@ export const GET = async ( params: { username: string }; }, ) => { + if (process.env.NEXT_PUBLIC_API_URL == null) + throw new Error("API URL was not defined"); + const token = cookies().get("token")?.value; + const h = new Headers({ + "client-ip": headers().get("client-ip") ?? "Unknown IP", + "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", + }); + + if (token) { + h.set("Authorization", `Bearer ${token}`); + } + try { const response = await fetch( `${process.env.NEXT_PUBLIC_API_URL}/users/${String( @@ -19,11 +31,7 @@ export const GET = async ( )}/activity/current`, { method: "GET", - headers: { - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, + headers: h, cache: "no-cache", }, ); diff --git a/src/components/ChangePasswordForm/actions.ts b/src/components/ChangePasswordForm/actions.ts index 147c8434..8cb33b7d 100644 --- a/src/components/ChangePasswordForm/actions.ts +++ b/src/components/ChangePasswordForm/actions.ts @@ -1,35 +1,21 @@ "use server"; -import { cookies, headers } from "next/headers"; import { PasswordChangeResult } from "../../types"; +import { postRequestWithoutResponse } from "../../api/baseApi"; export const changePassword = async ( oldPassword: string, newPassword: string, ) => { - const token = cookies().get("token")?.value; - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + "/auth/changepassword", - { - method: "POST", - cache: "no-cache", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - body: JSON.stringify({ - old: oldPassword, - new: newPassword, - }), - }, - ); + const res = await postRequestWithoutResponse("/auth/change-password", { + old: oldPassword, + new: newPassword, + }); - if (!response.ok) { - if (response.status === 401) { + if ("error" in res) { + if (res.statusCode === 401) { return PasswordChangeResult.OldPasswordIncorrect; - } else if (response.status === 400) { + } else if (res.statusCode === 400) { return PasswordChangeResult.NewPasswordInvalid; } diff --git a/src/components/ChangeUsernameForm/ChangeUsernameForm.tsx b/src/components/ChangeUsernameForm/ChangeUsernameForm.tsx index a23fd248..a93b9f32 100644 --- a/src/components/ChangeUsernameForm/ChangeUsernameForm.tsx +++ b/src/components/ChangeUsernameForm/ChangeUsernameForm.tsx @@ -6,7 +6,7 @@ import { Button } from "@mantine/core"; import { useTranslation } from "react-i18next"; import styles from "./ChangeUsernameForm.module.css"; import { changeUsername } from "./actions"; -import { ChangeUsernameError } from "../../types"; +import { ChangeUsernameError, PostRequestError } from "../../types"; import { useRouter } from "next/navigation"; import { showNotification } from "@mantine/notifications"; import { logOutAndRedirect } from "../../utils/authUtils"; @@ -39,10 +39,10 @@ export const ChangeUsernameForm = () => { message: t("profile.changeUsername.new.invalid"), }); break; - case ChangeUsernameError.RateLimited: + case PostRequestError.RateLimited: router.push("/rate-limited"); break; - case ChangeUsernameError.Unauthorized: + case PostRequestError.Unauthorized: showNotification({ title: t("error"), color: "red", @@ -57,7 +57,7 @@ export const ChangeUsernameForm = () => { message: t("profile.changeUsername.usernameTaken"), }); break; - case ChangeUsernameError.UnknownError: + case PostRequestError.UnknownError: showNotification({ title: t("error"), color: "red", diff --git a/src/components/ChangeUsernameForm/actions.ts b/src/components/ChangeUsernameForm/actions.ts index f3c8008a..f6332ef9 100644 --- a/src/components/ChangeUsernameForm/actions.ts +++ b/src/components/ChangeUsernameForm/actions.ts @@ -1,43 +1,19 @@ "use server"; -import { cookies } from "next/headers"; import { ChangeUsernameError } from "../../types"; +import { postRequestWithoutResponse } from "../../api/baseApi"; export const changeUsername = async (newUsername: string) => { - const token = cookies().get("secure-access-token")?.value; + const res = await postRequestWithoutResponse("/auth/change-username", { + new: newUsername, + }); - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + "/auth/changeusername", - { - method: "POST", - cache: "no-cache", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - new: newUsername, - }), - }, - ); - - if (!response.ok) { - if (response.status === 400) { + if ("error" in res) { + if (res.statusCode === 400) { return { error: ChangeUsernameError.InvalidUsername }; - } else if (response.status === 401) { - return { error: ChangeUsernameError.Unauthorized }; - } else if (response.status === 429) { - return { error: ChangeUsernameError.RateLimited }; - } else if (response.status === 409) { + } else if (res.statusCode === 409) { return { error: ChangeUsernameError.UsernameTaken }; } - - const errorText = await response.text(); - console.log( - "Unknown error when changing username: status", - response.status, - errorText, - ); - return { error: ChangeUsernameError.UnknownError }; + return res; } }; diff --git a/src/components/ConfirmAccountDeletionModal/ConfirmAccountDeletionModal.tsx b/src/components/ConfirmAccountDeletionModal/ConfirmAccountDeletionModal.tsx index 53a3d0ca..87a47f71 100644 --- a/src/components/ConfirmAccountDeletionModal/ConfirmAccountDeletionModal.tsx +++ b/src/components/ConfirmAccountDeletionModal/ConfirmAccountDeletionModal.tsx @@ -6,35 +6,60 @@ import * as Yup from "yup"; import { useState } from "react"; import { Form, Formik } from "formik"; import { FormikPasswordInput } from "../forms/FormikPasswordInput"; +import { deleteAccount } from "../../app/[locale]/profile/deleteAccount"; +import { showNotification } from "@mantine/notifications"; +import { PostRequestError } from "../../types"; type ConfirmAccountDeletionModalProps = { - onCancel: () => void; - onConfirm: (password: string) => Promise; + username: string; + closeModal: () => void; }; export const ConfirmAccountDeletionModal = ({ - onCancel, - onConfirm, + username, + closeModal, }: ConfirmAccountDeletionModalProps) => { const { t } = useTranslation(); const [loading, setLoading] = useState(false); return (
- {t("profile.deleteAccount.modal.text")} + {t("profile.deleteAccount.modalDescription")} { setLoading(true); - await onConfirm(values.password); + const result = await deleteAccount(username, values.password); setLoading(false); + if (result && "error" in result) { + showNotification({ + title: t("error"), + message: { + [PostRequestError.RateLimited]: t("rateLimitedError"), + [PostRequestError.Unauthorized]: t( + "profile.sudoOperation.wrongPassword", + ), + [PostRequestError.UnknownError]: t("unknownErrorOccurred"), + }[result.error], + color: "red", + }); + } else { + showNotification({ + title: t("profile.deleteAccount.notification.successTitle"), + message: t( + "profile.deleteAccount.notification.successDescription", + ), + color: "green", + }); + closeModal(); + } }} > {() => ( @@ -48,7 +73,7 @@ export const ConfirmAccountDeletionModal = ({ > @@ -56,14 +81,14 @@ export const ConfirmAccountDeletionModal = ({ diff --git a/src/components/CurrentActivity/BlinkingDot.tsx b/src/components/CurrentActivity/BlinkingDot.tsx index bd3cefe6..ab67ef00 100644 --- a/src/components/CurrentActivity/BlinkingDot.tsx +++ b/src/components/CurrentActivity/BlinkingDot.tsx @@ -5,7 +5,11 @@ export const BlinkingDot = forwardRef< HTMLDivElement, ComponentPropsWithoutRef<"div"> >(({ className, ...props }, ref) => ( -
+
)); BlinkingDot.displayName = "BlinkingDot"; diff --git a/src/components/CurrentActivity/CurrentActivity.tsx b/src/components/CurrentActivity/CurrentActivity.tsx index f4e064d5..23be523e 100644 --- a/src/components/CurrentActivity/CurrentActivity.tsx +++ b/src/components/CurrentActivity/CurrentActivity.tsx @@ -33,7 +33,7 @@ export const CurrentActivity = (props: CurrentActivityProps) => { startedAt: data.started, }); }) - .catch((error) => { + .catch((error: unknown) => { console.error(error); }); }, 30 * 1000); diff --git a/src/components/DataCard/DataCard.tsx b/src/components/DataCard/DataCard.tsx index c0117f53..caecc60e 100644 --- a/src/components/DataCard/DataCard.tsx +++ b/src/components/DataCard/DataCard.tsx @@ -10,7 +10,7 @@ export const DataCard = ({ >) => { return ( ); diff --git a/src/components/DataCard/DataCardContainer.tsx b/src/components/DataCard/DataCardContainer.tsx index b7da7112..04730957 100644 --- a/src/components/DataCard/DataCardContainer.tsx +++ b/src/components/DataCard/DataCardContainer.tsx @@ -16,7 +16,7 @@ export const DataCardContainer = ({ styles.dataCardContainer + (withoutPadding ? "" : " " + styles.withPadding) + " " + - className + (className ?? "") } {...props} /> diff --git a/src/components/EditProjectModal/actions.ts b/src/components/EditProjectModal/actions.ts index 7d82bb2e..cc0d5291 100644 --- a/src/components/EditProjectModal/actions.ts +++ b/src/components/EditProjectModal/actions.ts @@ -1,32 +1,9 @@ "use server"; -import { cookies, headers } from "next/headers"; +import { postRequestWithoutResponse } from "../../api/baseApi"; -export const renameProject = async ( - oldProjectName: string, - newProjectName: string, -) => { - const token = cookies().get("token")?.value; - - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + "/activity/rename", - { - method: "POST", - cache: "no-cache", - body: JSON.stringify({ - from: oldProjectName, - to: newProjectName, - }), - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - }, - ); - - if (!response.ok) { - return { error: "Unknown error" as const }; - } -}; +export const renameProject = (oldProjectName: string, newProjectName: string) => + postRequestWithoutResponse("/activity/rename", { + from: oldProjectName, + to: newProjectName, + }); diff --git a/src/components/LoginForm/LoginForm.tsx b/src/components/LoginForm/LoginForm.tsx index 4c78d696..9368aee9 100644 --- a/src/components/LoginForm/LoginForm.tsx +++ b/src/components/LoginForm/LoginForm.tsx @@ -10,7 +10,7 @@ import { useSearchParams } from "next/navigation"; import { showNotification } from "@mantine/notifications"; import { useTranslation } from "react-i18next"; import { logIn } from "./actions"; -import { LoginError } from "../../types"; +import { LoginError, PostRequestError } from "../../types"; export const LoginForm = () => { const [visible, setVisible] = useState(false); @@ -41,14 +41,14 @@ export const LoginForm = () => { unsafeRedirect, ); - // Not sure why result is undefined when the login is successful. Result should be `never` in those cases - // because we call `redirect` in `logIn`. + // Using `redirect` will return undefined, but it uses the type `never` so we don't notice it. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (result) { + if (result && "error" in result) { const message = { [LoginError.InvalidCredentials]: t("loginPage.invalidCredentials"), - [LoginError.RateLimited]: t("rateLimitedError"), - [LoginError.UnknownError]: t("unknownErrorOccurred"), + [PostRequestError.Unauthorized]: t("errors.unauthorized"), + [PostRequestError.RateLimited]: t("rateLimitedError"), + [PostRequestError.UnknownError]: t("unknownErrorOccurred"), }[result.error]; showNotification({ title: t("error"), diff --git a/src/components/LoginForm/actions.ts b/src/components/LoginForm/actions.ts index 80219b96..43f9f769 100644 --- a/src/components/LoginForm/actions.ts +++ b/src/components/LoginForm/actions.ts @@ -1,8 +1,9 @@ "use server"; -import { cookies, headers } from "next/headers"; +import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { LoginError } from "../../types"; +import { postRequestWithResponse } from "../../api/baseApi"; interface ApiAuthLoginResponse { id: number; @@ -13,10 +14,6 @@ interface ApiAuthLoginResponse { is_public: boolean; } -export interface SecureAccessTokenResponse { - token: string; -} - const allowedRedirects = [ "/profile", "/friends", @@ -29,66 +26,25 @@ export const logIn = async ( password: string, unsafeRedirect: string | null, ) => { - const loginPromise = fetch(process.env.NEXT_PUBLIC_API_URL + "/auth/login", { - method: "POST", - cache: "no-cache", - headers: { - "Content-Type": "application/json", - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - body: JSON.stringify({ - username: username, - password: password, - }), - }); - - const secureAccessTokenPromise = fetch( - process.env.NEXT_PUBLIC_API_URL + "/auth/securedaccess", + const res = await postRequestWithResponse( + "/auth/login", { - method: "POST", - cache: "no-cache", - headers: { - "Content-Type": "application/json", - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - body: JSON.stringify({ - username: username, - password: password, - }), + username, + password, }, ); - const [response, secureAccessTokenResponse] = await Promise.all([ - loginPromise, - secureAccessTokenPromise, - ]); - - if (!response.ok) { - if (response.status === 401) { + if ("error" in res) { + if (res.statusCode === 401) { return { error: LoginError.InvalidCredentials }; - } else if (response.status === 429) { - return { error: LoginError.RateLimited }; } - return { error: LoginError.UnknownError }; + return res; } - if (!secureAccessTokenResponse.ok) { - if (secureAccessTokenResponse.status === 401) { - return { error: LoginError.InvalidCredentials }; - } else if (secureAccessTokenResponse.status === 429) { - return { error: LoginError.RateLimited }; - } - - return { error: LoginError.UnknownError }; - } - - const login = (await response.json()) as ApiAuthLoginResponse; const expiration = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7); - cookies().set("token", login.auth_token, { - value: login.auth_token, + cookies().set("token", res.data.auth_token, { + value: res.data.auth_token, expires: expiration, path: "/", sameSite: "strict", @@ -96,18 +52,6 @@ export const logIn = async ( httpOnly: true, }); - const secureAccessToken = - (await secureAccessTokenResponse.json()) as SecureAccessTokenResponse; - const secureAccessTokenExpiration = new Date(Date.now() + 1000 * 60 * 60); - cookies().set("secure-access-token", secureAccessToken.token, { - value: secureAccessToken.token, - expires: secureAccessTokenExpiration, - path: "/", - sameSite: "strict", - secure: true, - httpOnly: true, - }); - const unsafeRedirectCalculated = unsafeRedirect ?? "/"; redirect( diff --git a/src/components/NavigationMenuDropdown.tsx b/src/components/NavigationMenuDropdown.tsx index 055e4d4d..4afafd09 100644 --- a/src/components/NavigationMenuDropdown.tsx +++ b/src/components/NavigationMenuDropdown.tsx @@ -58,7 +58,9 @@ export const NavigationMenuDropdown = ({ color="blue" leftSection={} onClick={() => { - logOutAndRedirect().catch(console.error); + logOutAndRedirect().catch((e: unknown) => { + console.error(e); + }); }} > {t("navbar.logOut")} diff --git a/src/components/friends/AddFriendForm.tsx b/src/components/friends/AddFriendForm.tsx index 385902c5..308c88aa 100644 --- a/src/components/friends/AddFriendForm.tsx +++ b/src/components/friends/AddFriendForm.tsx @@ -8,7 +8,7 @@ import * as Yup from "yup"; import { showNotification } from "@mantine/notifications"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslation } from "react-i18next"; -import { AddFriendError } from "../../types"; +import { AddFriendError, PostRequestError } from "../../types"; import { addFriend } from "./actions"; import { useState } from "react"; @@ -47,7 +47,9 @@ export const AddFriendForm = ({ "friends.error.alreadyFriends", ), [AddFriendError.NotFound]: t("friends.error.notFound"), - [AddFriendError.UnknownError]: t("unknownErrorOccurred"), + [PostRequestError.RateLimited]: t("rateLimitedError"), + [PostRequestError.Unauthorized]: t("errors.unauthorized"), + [PostRequestError.UnknownError]: t("unknownErrorOccurred"), }[result.error], color: "red", }); diff --git a/src/components/friends/FriendListRow.tsx b/src/components/friends/FriendListRow.tsx index 4e6394f2..29a92c5c 100644 --- a/src/components/friends/FriendListRow.tsx +++ b/src/components/friends/FriendListRow.tsx @@ -5,12 +5,12 @@ import { CurrentActivityDisplay } from "../CurrentActivity/CurrentActivityDispla import { prettyDuration, TimeUnit } from "../../utils/dateUtils"; import Link from "next/link"; import { removeFriend } from "./actions"; -import { RemoveFriendError } from "../../types"; import { useRouter } from "next/navigation"; import { useTranslation } from "react-i18next"; import { showNotification } from "@mantine/notifications"; import { logOutAndRedirect } from "../../utils/authUtils"; import { useState } from "react"; +import { PostRequestError } from "../../types"; import { YOU_BADGE_COLOR } from "../../utils/constants"; type FriendListRowProps = { @@ -78,12 +78,12 @@ export const FriendListRow = ({ setIsDeleting(true); removeFriend(username) .then(async (result) => { - if (result && "error" in result) { + if ("error" in result) { switch (result.error) { - case RemoveFriendError.RateLimited: + case PostRequestError.RateLimited: router.push("/rate-limited"); break; - case RemoveFriendError.Unauthorized: + case PostRequestError.Unauthorized: showNotification({ title: t("error"), color: "red", @@ -91,7 +91,7 @@ export const FriendListRow = ({ }); await logOutAndRedirect(); break; - case RemoveFriendError.UnknownError: + case PostRequestError.UnknownError: showNotification({ title: t("error"), color: "red", diff --git a/src/components/friends/actions.ts b/src/components/friends/actions.ts index f5c7837c..94006a91 100644 --- a/src/components/friends/actions.ts +++ b/src/components/friends/actions.ts @@ -1,37 +1,20 @@ "use server"; -import { cookies, headers } from "next/headers"; -import { AddFriendError, RemoveFriendError } from "../../types"; - -export const removeFriend = async (username: string) => { - const token = cookies().get("secure-access-token")?.value; - - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + "/friends/remove", +import { + postRequestWithoutResponse, + postRequestWithResponse, +} from "../../api/baseApi"; +import { AddFriendError } from "../../types"; + +export const removeFriend = (username: string) => + postRequestWithoutResponse( + "/friends/remove", { - method: "DELETE", - body: username, - cache: "no-cache", - headers: { - "Content-Type": "text/plain", - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, + name: username, }, + "DELETE", ); - if (!response.ok) { - if (response.status === 401) { - return { error: RemoveFriendError.Unauthorized }; - } else if (response.status === 429) { - return { error: RemoveFriendError.RateLimited }; - } - - return { error: RemoveFriendError.UnknownError }; - } -}; - type ApiFriendsAddResponse = { username: string; coding_time: { @@ -42,34 +25,20 @@ type ApiFriendsAddResponse = { }; export const addFriend = async (friendCode: string) => { - const token = cookies().get("token")?.value; - - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + "/friends/add", + const res = await postRequestWithResponse( + "/friends/add", { - method: "POST", - body: friendCode, - cache: "no-cache", - headers: { - "Content-Type": "text/plain", - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, + code: friendCode, }, ); - if (!response.ok) { - if (response.status === 409) { + if ("error" in res) { + if (res.statusCode === 409) { return { error: AddFriendError.AlreadyFriends }; - } else if (response.status === 404) { + } else if (res.statusCode === 404) { return { error: AddFriendError.NotFound }; } - - return { error: AddFriendError.UnknownError }; } - const data = (await response.json()) as ApiFriendsAddResponse; - - return data; + return res; }; diff --git a/src/components/leaderboard/CreateLeaderboardModal.tsx b/src/components/leaderboard/CreateLeaderboardModal.tsx index 1d483754..955e28d9 100644 --- a/src/components/leaderboard/CreateLeaderboardModal.tsx +++ b/src/components/leaderboard/CreateLeaderboardModal.tsx @@ -6,7 +6,7 @@ import * as Yup from "yup"; import { FormikTextInput } from "../forms/FormikTextInput"; import { showNotification } from "@mantine/notifications"; import { createLeaderboard } from "../../api/leaderboardApi"; -import { CreateLeaderboardError } from "../../types"; +import { CreateLeaderboardError, PostRequestError } from "../../types"; import { useTranslation } from "react-i18next"; interface CreateLeaderboardModalProps { @@ -26,9 +26,7 @@ export const CreateLeaderboardModal = ({ }} onSubmit={async (values) => { const result = await createLeaderboard(values.leaderboardName); - if (typeof result === "object") { - onCreate(values.leaderboardName); - } else { + if ("error" in result) showNotification({ title: t("error"), color: "red", @@ -36,11 +34,15 @@ export const CreateLeaderboardModal = ({ [CreateLeaderboardError.AlreadyExists]: t( "leaderboards.leaderboardExists", ), - [CreateLeaderboardError.UnknownError]: t( + [PostRequestError.Unauthorized]: t("errors.unauthorized"), + [PostRequestError.RateLimited]: t("rateLimitedError"), + [PostRequestError.UnknownError]: t( "leaderboards.leaderboardCreateError", ), - }[result], + }[result.error], }); + else { + onCreate(values.leaderboardName); } }} validationSchema={Yup.object().shape({ diff --git a/src/components/leaderboard/JoinLeaderboardModal.tsx b/src/components/leaderboard/JoinLeaderboardModal.tsx index 049db673..1fdd5308 100644 --- a/src/components/leaderboard/JoinLeaderboardModal.tsx +++ b/src/components/leaderboard/JoinLeaderboardModal.tsx @@ -7,7 +7,7 @@ import { FormikTextInput } from "../forms/FormikTextInput"; import { EnterIcon } from "@radix-ui/react-icons"; import { useTranslation } from "react-i18next"; import { showNotification } from "@mantine/notifications"; -import { JoinLeaderboardError } from "../../types"; +import { JoinLeaderboardError, PostRequestError } from "../../types"; import { joinLeaderboard } from "./actions"; interface JoinLeaderboardModalProps { @@ -40,9 +40,7 @@ export const JoinLeaderboardModal = ({ })} onSubmit={async (values) => { const result = await joinLeaderboard(values.leaderboardCode); - if (typeof result === "object") { - onJoin(); - } else { + if ("error" in result) { showNotification({ title: t("error"), color: "red", @@ -53,11 +51,15 @@ export const JoinLeaderboardModal = ({ [JoinLeaderboardError.NotFound]: t( "leaderboards.join.notFound", ), - [JoinLeaderboardError.UnknownError]: t( + [PostRequestError.RateLimited]: t("rateLimitedError"), + [PostRequestError.Unauthorized]: t("errors.unauthorized"), + [PostRequestError.UnknownError]: t( "leaderboards.join.genericError", ), - }[result], + }[result.error], }); + } else { + onJoin(); } }} > diff --git a/src/components/leaderboard/actions.ts b/src/components/leaderboard/actions.ts index 8966cf8d..7890baa8 100644 --- a/src/components/leaderboard/actions.ts +++ b/src/components/leaderboard/actions.ts @@ -1,111 +1,50 @@ "use server"; -import { cookies, headers } from "next/headers"; -import { - DeleteLeaderboardError, - JoinLeaderboardError, - LeaveLeaderboardError, -} from "../../types"; -import { revalidateTag } from "next/cache"; import { redirect } from "next/navigation"; +import { + postRequestWithoutResponse, + postRequestWithResponse, +} from "../../api/baseApi"; +import { JoinLeaderboardError } from "../../types"; export const joinLeaderboard = async (inviteCode: string) => { - const token = cookies().get("token")?.value; - - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + "/leaderboards/join", - { - method: "POST", - cache: "no-cache", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - body: JSON.stringify({ - invite: inviteCode, - }), - }, - ); - - if (!response.ok) { - if (response.status === 409) { - return JoinLeaderboardError.AlreadyMember; - } else if (response.status === 404) { - return JoinLeaderboardError.NotFound; - } - - console.error("Error when joining leaderboard:", await response.text()); - return JoinLeaderboardError.UnknownError; - } - - const data = (await response.json()) as { + const response = await postRequestWithResponse<{ name: string; member_count: number; - }; - - revalidateTag(`leaderboard-${data.name}`); + }>("/leaderboards/join", { + invite: inviteCode, + }); + + if ("error" in response) { + if (response.statusCode === 404) { + return { error: JoinLeaderboardError.NotFound }; + } else if (response.statusCode === 409) { + return { error: JoinLeaderboardError.AlreadyMember }; + } + } - return data; + return response; }; export const leaveLeaderboard = async (leaderboardName: string) => { - const token = cookies().get("secure-access-token")?.value; - - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + `/leaderboards/${leaderboardName}/leave`, - { - method: "POST", - cache: "no-cache", - headers: { - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - }, + const data = await postRequestWithoutResponse( + `/leaderboards/${leaderboardName}/leave`, ); - - if (!response.ok) { - if (response.status === 401) { - return { error: LeaveLeaderboardError.Unauthorized }; - } else if (response.status === 429) { - return { error: LeaveLeaderboardError.RateLimited }; - } - - console.error("Error when leaving leaderboard:", await response.text()); - return { error: LeaveLeaderboardError.UnknownError }; - } + if ("error" in data) return data; redirect("/leaderboards"); }; export const deleteLeaderboard = async (leaderboardName: string) => { - const token = cookies().get("secure-access-token")?.value; - - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + `/leaderboards/${leaderboardName}`, - { - method: "DELETE", - cache: "no-cache", - headers: { - Authorization: `Bearer ${token}`, - "client-ip": headers().get("client-ip") ?? "Unknown IP", - "bypass-token": process.env.RATELIMIT_IP_FORWARD_SECRET ?? "", - }, - }, + const data = await postRequestWithoutResponse( + `/leaderboards/${leaderboardName}`, + undefined, + "DELETE", ); - if (!response.ok) { - if (response.status === 401) { - return { error: DeleteLeaderboardError.Unauthorized }; - } else if (response.status === 429) { - return { error: DeleteLeaderboardError.RateLimited }; - } - - console.error("Error when deleting leaderboard:", await response.text()); - return { error: DeleteLeaderboardError.UnknownError }; + if ("error" in data) { + return data; } - redirect("/leaderboards"); + return redirect("/leaderboards"); }; diff --git a/src/types.ts b/src/types.ts index 50eb46e3..fc4c83ca 100644 --- a/src/types.ts +++ b/src/types.ts @@ -55,15 +55,12 @@ export interface LeaderboardData { } export enum JoinLeaderboardError { - AlreadyMember, - NotFound, - UnknownError, + AlreadyMember = "AlreadyMember", + NotFound = "NotFound", } export enum CreateLeaderboardError { - AlreadyExists, - UnknownError, - RateLimited, + AlreadyExists = "AlreadyExists", } type ActivityDataSummaryEntry = { @@ -78,52 +75,23 @@ export type ActivityDataSummary = { }; export enum PasswordChangeResult { - Success, - OldPasswordIncorrect, - NewPasswordInvalid, - UnknownError, + Success = "Success", + OldPasswordIncorrect = "OldPasswordIncorrect", + NewPasswordInvalid = "NewPasswordInvalid", + UnknownError = "UnknownError", } export enum AddFriendError { - AlreadyFriends, - NotFound, - UnknownError, + AlreadyFriends = "Already friends", + NotFound = "Not found", } export enum RegistrationResult { - RateLimited, - UnknownError, - UsernameTaken, -} - -export enum GetLeaderboardError { - Unauthorized = "Unauthorized", - RateLimited = "Rate limited", - UnknownError = "Unknown error when getting leaderboard", -} - -export enum GetLeaderboardsError { - Unauthorized = "Unauthorized", - RateLimited = "Rate limited", - UnknownError = "Unknown error when getting leaderboards", + UsernameTaken = "Username taken", } export enum LoginError { - InvalidCredentials, - UnknownError, - RateLimited, -} - -export enum DeleteLeaderboardError { - Unauthorized = "Unauthorized", - RateLimited = "Rate limited", - UnknownError = "Unknown error when deleting leaderboard", -} - -export enum RegenerateAuthTokenError { - Unauthorized = "Unauthorized", - RateLimited = "Rate limited", - UnknownError = "Unknown error when regenerating auth token", + InvalidCredentials = "Invalid credentials", } export type CurrentActivityApiResponse = { @@ -137,49 +105,13 @@ export type CurrentActivityApiResponse = { }; }; -export enum ChangeAccountVisibilityError { - Unauthorized = "Unauthorized", - RateLimited = "Rate limited", - UnknownError = "Unknown error when changing account visibility", -} - -export enum RegenerateInviteCodeError { - Unauthorized = "Unauthorized", - RateLimited = "Rate limited", - UnknownError = "Unknown error when regenerating invite code", -} - -export enum RegenerateFriendCodeError { - Unauthorized = "Unauthorized", - RateLimited = "Rate limited", - UnknownError = "Unknown error when regenerating friend code", -} - -export enum LeaveLeaderboardError { - Unauthorized = "Unauthorized", - RateLimited = "Rate limited", - UnknownError = "Unknown error when leaving leaderboard", -} - -export enum RemoveFriendError { - Unauthorized = "Unauthorized", - RateLimited = "Rate limited", - UnknownError = "Unknown error when removing friend", -} - export enum ChangeUsernameError { - InvalidUsername, - Unauthorized, - UsernameTaken, - RateLimited, - UnknownError, + InvalidUsername = "Invalid username", + UsernameTaken = "Username taken", } export enum GetUserActivityDataError { - Unauthorized, - RateLimited, - UnknownError, - NotFound, + UserNotFound = "User not found", } export type SearchUsersResult = { @@ -190,7 +122,18 @@ export type SearchUsersResult = { export type SearchUsersApiResponse = SearchUsersResult[]; -export enum SearchUsersError { - RateLimited, - UnknownError, +export enum GetRequestError { + Unauthorized = "Unauthorized", + RateLimited = "RateLimited", + UnknownError = "UnknownError", +} + +export enum PostRequestError { + Unauthorized = "Unauthorized", + RateLimited = "RateLimited", + UnknownError = "UnknownError", +} + +export enum GetLeaderboardError { + LeaderboardNotFound = "Leaderboard not found", } diff --git a/src/utils/authUtils.ts b/src/utils/authUtils.ts index 4ac15466..2d13f37b 100644 --- a/src/utils/authUtils.ts +++ b/src/utils/authUtils.ts @@ -6,6 +6,5 @@ import { redirect } from "next/navigation"; // eslint-disable-next-line @typescript-eslint/require-await export const logOutAndRedirect = async () => { cookies().delete("token"); - cookies().delete("secure-access-token"); redirect("/"); }; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index dff4e945..378cc310 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -7,4 +7,4 @@ export const maxTimeUnitCookieName = "testaustime-max-time-unit"; export const DEFAULT_DAY_RANGE = "week"; export const DEFAULT_MAX_TIME_UNIT = "h"; -export const YOU_BADGE_COLOR = "green" as const; +export const YOU_BADGE_COLOR = "green";