diff --git a/app/components/Catcher.tsx b/app/components/Catcher.tsx index 917cc0c4c..0f4cb7b3e 100644 --- a/app/components/Catcher.tsx +++ b/app/components/Catcher.tsx @@ -2,6 +2,7 @@ import { useCatch } from "@remix-run/react"; import { Button } from "~/components/Button"; import { useUser } from "~/modules/auth"; import { LOG_IN_URL, SENDOU_INK_DISCORD_URL } from "~/utils/urls"; +import { Main } from "./Main"; export function Catcher() { const caught = useCatch(); @@ -10,7 +11,7 @@ export function Catcher() { switch (caught.status) { case 401: return ( -
+

Error 401 Unauthorized

{user ? ( @@ -24,16 +25,16 @@ export function Catcher() {

)} -
+ ); } return ( -
+

Error {caught.status}

{caught.data ? {JSON.stringify(caught.data, null, 2)} : null} -
+ ); } diff --git a/app/constants.ts b/app/constants.ts index 99ad0b66a..e9b366ae1 100644 --- a/app/constants.ts +++ b/app/constants.ts @@ -9,3 +9,5 @@ export const PLUS_TIERS = [1, 2, 3]; export const PLUS_UPVOTE = 1; export const PLUS_DOWNVOTE = -1; + +export const ADMIN_DISCORD_ID = "79237403620945920"; diff --git a/app/db/seed.ts b/app/db/seed.ts index d02e97f8f..444b1e9fc 100644 --- a/app/db/seed.ts +++ b/app/db/seed.ts @@ -7,8 +7,8 @@ import { import { db } from "~/db"; import { sql } from "~/db/sql"; import type { UpsertManyPlusVotesArgs } from "./models/plusVotes.server"; +import { ADMIN_DISCORD_ID } from "~/constants"; -const ADMIN_TEST_DISCORD_ID = "79237403620945920"; const ADMIN_TEST_AVATAR = "fcfd65a3bea598905abb9ca25296816b"; const NZAP_TEST_DISCORD_ID = "455039198672453645"; @@ -41,7 +41,7 @@ function wipeDB() { function adminUser() { db.users.upsert({ discordDiscriminator: "4059", - discordId: ADMIN_TEST_DISCORD_ID, + discordId: ADMIN_DISCORD_ID, discordName: "Sendou", twitch: "Sendou", youtubeId: "UCWbJLXByvsfQvTcR4HLPs5Q", diff --git a/app/modules/auth/index.ts b/app/modules/auth/index.ts index 8a5beeef1..34bd8c3be 100644 --- a/app/modules/auth/index.ts +++ b/app/modules/auth/index.ts @@ -1,6 +1,7 @@ export { callbackLoader, impersonateAction, + stopImpersonatingAction, logInAction, logOutAction, } from "./routes.server"; diff --git a/app/modules/auth/routes.server.ts b/app/modules/auth/routes.server.ts index 22449611c..39bf7653a 100644 --- a/app/modules/auth/routes.server.ts +++ b/app/modules/auth/routes.server.ts @@ -1,11 +1,14 @@ import type { ActionFunction, LoaderFunction } from "@remix-run/node"; import { redirect } from "@remix-run/node"; +import { canPerformAdminActions } from "~/permissions"; +import { ADMIN_PAGE } from "~/utils/urls"; import { authenticator, DISCORD_AUTH_KEY, IMPERSONATED_SESSION_KEY, } from "./authenticator.server"; import { authSessionStorage } from "./session.server"; +import { getUser } from "./user.server"; export const callbackLoader: LoaderFunction = async ({ request }) => { await authenticator.authenticate(DISCORD_AUTH_KEY, request, { @@ -27,8 +30,9 @@ export const logInAction: ActionFunction = async ({ request }) => { }; export const impersonateAction: ActionFunction = async ({ request }) => { - if (process.env.NODE_ENV === "production") { - throw new Response(null, { status: 400 }); + const user = await getUser(request); + if (!canPerformAdminActions(user)) { + throw new Response(null, { status: 403 }); } const session = await authSessionStorage.getSession( @@ -43,7 +47,19 @@ export const impersonateAction: ActionFunction = async ({ request }) => { session.set(IMPERSONATED_SESSION_KEY, userId); - throw redirect("/", { + throw redirect(ADMIN_PAGE, { + headers: { "Set-Cookie": await authSessionStorage.commitSession(session) }, + }); +}; + +export const stopImpersonatingAction: ActionFunction = async ({ request }) => { + const session = await authSessionStorage.getSession( + request.headers.get("Cookie") + ); + + session.unset(IMPERSONATED_SESSION_KEY); + + throw redirect(ADMIN_PAGE, { headers: { "Set-Cookie": await authSessionStorage.commitSession(session) }, }); }; diff --git a/app/modules/auth/user.server.ts b/app/modules/auth/user.server.ts index 6e7b60a52..3a0c94c77 100644 --- a/app/modules/auth/user.server.ts +++ b/app/modules/auth/user.server.ts @@ -22,3 +22,11 @@ export async function requireUser(request: Request) { return user; } + +export async function isImpersonating(request: Request) { + const session = await authSessionStorage.getSession( + request.headers.get("Cookie") + ); + + return Boolean(session.get(IMPERSONATED_SESSION_KEY)); +} diff --git a/app/permissions.ts b/app/permissions.ts index fffc9ca6d..b9ee82878 100644 --- a/app/permissions.ts +++ b/app/permissions.ts @@ -2,6 +2,7 @@ import type * as plusSuggestions from "~/db/models/plusSuggestions.server"; import { monthsVotingRange } from "./modules/plus-server"; import type { PlusSuggestion, User, UserWithPlusTier } from "./db/types"; import { allTruthy } from "./utils/arrays"; +import { ADMIN_DISCORD_ID } from "./constants"; // TODO: 1) move "root checkers" to one file and utils to one file 2) make utils const for more terseness @@ -198,3 +199,10 @@ function hasUserSuggestedThisMonth({ suggestions[0] && suggestions[0].author.id === user?.id ); } + +export function canPerformAdminActions(user?: Pick) { + if (process.env.NODE_ENV === "development") return true; + + if (!user) return false; + return user.discordId === ADMIN_DISCORD_ID; +} diff --git a/app/routes/admin.tsx b/app/routes/admin.tsx new file mode 100644 index 000000000..066002be6 --- /dev/null +++ b/app/routes/admin.tsx @@ -0,0 +1,77 @@ +import { json, redirect } from "@remix-run/node"; +import type { LoaderFunction, MetaFunction } from "@remix-run/node"; +import { useFetcher, useLoaderData } from "@remix-run/react"; +import * as React from "react"; +import { Button } from "~/components/Button"; +import { Catcher } from "~/components/Catcher"; +import { UserCombobox } from "~/components/Combobox"; +import { Main } from "~/components/Main"; +import { requireUser } from "~/modules/auth"; +import { isImpersonating } from "~/modules/auth/user.server"; +import { canPerformAdminActions } from "~/permissions"; +import { makeTitle } from "~/utils/remix"; +import { impersonateUrl, STOP_IMPERSONATING_URL } from "~/utils/urls"; + +export const meta: MetaFunction = () => { + return { + title: makeTitle("Admin page"), + }; +}; + +interface AdminPageLoaderData { + isImpersonating: boolean; +} + +export const loader: LoaderFunction = async ({ request }) => { + const user = await requireUser(request); + + if (!canPerformAdminActions(user)) { + return redirect("/"); + } + + return json({ + isImpersonating: await isImpersonating(request), + }); +}; + +export default function AdminPage() { + const { isImpersonating } = useLoaderData(); + const fetcher = useFetcher(); + const [userIdToLogInAs, setUserIdToLogInAs] = React.useState(); + + return ( +
+ +

Impersonate user

+
+ + + setUserIdToLogInAs( + selected?.value ? Number(selected.value) : undefined + ) + } + /> +
+
+ + {isImpersonating ? ( + + ) : null} +
+
+
+ ); +} + +export const CatchBoundary = Catcher; diff --git a/app/routes/auth/impersonate.tsx b/app/routes/auth/impersonate/index.tsx similarity index 100% rename from app/routes/auth/impersonate.tsx rename to app/routes/auth/impersonate/index.tsx diff --git a/app/routes/auth/impersonate/stop.tsx b/app/routes/auth/impersonate/stop.tsx new file mode 100644 index 000000000..c70064218 --- /dev/null +++ b/app/routes/auth/impersonate/stop.tsx @@ -0,0 +1 @@ +export { stopImpersonatingAction as action } from "~/modules/auth"; diff --git a/app/styles/common.css b/app/styles/common.css index a92239b72..afd56e8e1 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -74,6 +74,7 @@ font-size: var(--fonts-sm); padding-block: var(--s-3); padding-inline: 0; + z-index: 2; } .combobox-options.empty { diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 620a80ae1..c96ae6105 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -5,5 +5,9 @@ export const SENDOU_INK_GITHUB_URL = "https://github.com/Sendouc/sendou.ink"; export const LOG_IN_URL = "/auth"; export const LOG_OUT_URL = "/auth/logout"; export const PLUS_SUGGESTIONS_PAGE = "/plus/suggestions"; +export const ADMIN_PAGE = "/admin"; +export const STOP_IMPERSONATING_URL = "/auth/impersonate/stop"; export const userPage = (discordId: string) => `/u/${discordId}`; +export const impersonateUrl = (idToLogInAs: number) => + `/auth/impersonate?id=${idToLogInAs}`;