Impersonate users in prod

This commit is contained in:
Kalle 2022-06-18 15:51:17 +03:00
parent bd037aab6c
commit c7a50ded65
12 changed files with 128 additions and 9 deletions

View File

@ -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 (
<div>
<Main>
<h2>Error 401 Unauthorized</h2>
{user ? (
<GetHelp />
@ -24,16 +25,16 @@ export function Catcher() {
</p>
</form>
)}
</div>
</Main>
);
}
return (
<div>
<Main>
<h2>Error {caught.status}</h2>
{caught.data ? <code>{JSON.stringify(caught.data, null, 2)}</code> : null}
<GetHelp />
</div>
</Main>
);
}

View File

@ -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";

View File

@ -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",

View File

@ -1,6 +1,7 @@
export {
callbackLoader,
impersonateAction,
stopImpersonatingAction,
logInAction,
logOutAction,
} from "./routes.server";

View File

@ -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) },
});
};

View File

@ -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));
}

View File

@ -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<User, "discordId">) {
if (process.env.NODE_ENV === "development") return true;
if (!user) return false;
return user.discordId === ADMIN_DISCORD_ID;
}

77
app/routes/admin.tsx Normal file
View File

@ -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<AdminPageLoaderData>({
isImpersonating: await isImpersonating(request),
});
};
export default function AdminPage() {
const { isImpersonating } = useLoaderData<AdminPageLoaderData>();
const fetcher = useFetcher();
const [userIdToLogInAs, setUserIdToLogInAs] = React.useState<number>();
return (
<Main>
<fetcher.Form
method="post"
action={impersonateUrl(userIdToLogInAs ?? 0)}
className="stack md"
reloadDocument
>
<h2>Impersonate user</h2>
<div>
<label>User to log in as</label>
<UserCombobox
inputName="user"
onChange={(selected) =>
setUserIdToLogInAs(
selected?.value ? Number(selected.value) : undefined
)
}
/>
</div>
<div className="stack vertical md">
<Button type="submit" disabled={!userIdToLogInAs}>
Go
</Button>
{isImpersonating ? (
<Button type="submit" formAction={STOP_IMPERSONATING_URL}>
Stop impersonating
</Button>
) : null}
</div>
</fetcher.Form>
</Main>
);
}
export const CatchBoundary = Catcher;

View File

@ -0,0 +1 @@
export { stopImpersonatingAction as action } from "~/modules/auth";

View File

@ -74,6 +74,7 @@
font-size: var(--fonts-sm);
padding-block: var(--s-3);
padding-inline: 0;
z-index: 2;
}
.combobox-options.empty {

View File

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