mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Impersonate users in prod
This commit is contained in:
parent
bd037aab6c
commit
c7a50ded65
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export {
|
||||
callbackLoader,
|
||||
impersonateAction,
|
||||
stopImpersonatingAction,
|
||||
logInAction,
|
||||
logOutAction,
|
||||
} from "./routes.server";
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
77
app/routes/admin.tsx
Normal 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;
|
||||
1
app/routes/auth/impersonate/stop.tsx
Normal file
1
app/routes/auth/impersonate/stop.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { stopImpersonatingAction as action } from "~/modules/auth";
|
||||
|
|
@ -74,6 +74,7 @@
|
|||
font-size: var(--fonts-sm);
|
||||
padding-block: var(--s-3);
|
||||
padding-inline: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.combobox-options.empty {
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user