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}`;