@@ -109,12 +120,15 @@ function FriendCodeLookUp() {
function AdminActions() {
const isStaff = useHasRole("STAFF");
const isAdmin = useHasRole("ADMIN");
+ const isDev = useHasRole("DEV");
return (
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS ? : null}
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS ? : null}
- {DANGEROUS_CAN_ACCESS_DEV_CONTROLS || isAdmin ? : null}
+ {DANGEROUS_CAN_ACCESS_DEV_CONTROLS || isAdmin || isDev ? (
+
+ ) : null}
{isStaff ? : null}
{isStaff ? : null}
diff --git a/app/features/auth/core/routes.server.ts b/app/features/auth/core/routes.server.ts
index d0af10012..69f57bcfa 100644
--- a/app/features/auth/core/routes.server.ts
+++ b/app/features/auth/core/routes.server.ts
@@ -3,8 +3,9 @@ import type { ActionFunction, LoaderFunction } from "react-router";
import { redirect } from "react-router";
import { z } from "zod";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
+import { requireUser } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
-import { requireRole } from "~/modules/permissions/guards.server";
+import { isAdmin, isStaff } from "~/modules/permissions/utils";
import { logger } from "~/utils/logger";
import {
canAccessLohiEndpoint,
@@ -74,19 +75,36 @@ export const logInAction: ActionFunction = async ({ request }) => {
export const impersonateAction: ActionFunction = async ({ request }) => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
- requireRole("ADMIN");
+ const user = requireUser();
+ if (!user.roles.includes("ADMIN") && !user.roles.includes("DEV")) {
+ throw new Response("Forbidden", { status: 403 });
+ }
+
+ if (user.roles.includes("DEV") && !user.roles.includes("ADMIN")) {
+ const url = new URL(request.url);
+ const targetId = Number(url.searchParams.get("id"));
+ if (isAdmin({ id: targetId }) || isStaff({ id: targetId })) {
+ throw new Response("Forbidden", { status: 403 });
+ }
+ }
}
const session = await authSessionStorage.getSession(
request.headers.get("Cookie"),
);
+ const realUserId = session.get(SESSION_KEY);
+
const url = new URL(request.url);
const rawId = url.searchParams.get("id");
const userId = Number(url.searchParams.get("id"));
if (!rawId || Number.isNaN(userId)) throw new Response(null, { status: 400 });
+ logger.info(
+ `Impersonation: user ${realUserId} started impersonating user ${userId}`,
+ );
+
session.set(IMPERSONATED_SESSION_KEY, userId);
throw redirect(ADMIN_PAGE, {
@@ -99,6 +117,13 @@ export const stopImpersonatingAction: ActionFunction = async ({ request }) => {
request.headers.get("Cookie"),
);
+ const realUserId = session.get(SESSION_KEY);
+ const impersonatedUserId = session.get(IMPERSONATED_SESSION_KEY);
+
+ logger.info(
+ `Impersonation: user ${realUserId} stopped impersonating user ${impersonatedUserId}`,
+ );
+
session.unset(IMPERSONATED_SESSION_KEY);
throw redirect(ADMIN_PAGE, {
diff --git a/app/features/auth/core/user.server.ts b/app/features/auth/core/user.server.ts
index b00e9140a..1e9a171c0 100644
--- a/app/features/auth/core/user.server.ts
+++ b/app/features/auth/core/user.server.ts
@@ -1,4 +1,4 @@
-import { IMPERSONATED_SESSION_KEY } from "./authenticator.server";
+import { IMPERSONATED_SESSION_KEY, SESSION_KEY } from "./authenticator.server";
import { authSessionStorage } from "./session.server";
import { type AuthenticatedUser, getUserContext } from "./user-context.server";
@@ -24,3 +24,13 @@ export async function isImpersonating(request: Request) {
return Boolean(session.get(IMPERSONATED_SESSION_KEY));
}
+
+export async function getRealUserId(
+ request: Request,
+): Promise {
+ const session = await authSessionStorage.getSession(
+ request.headers.get("Cookie"),
+ );
+
+ return session.get(SESSION_KEY) as number | undefined;
+}
diff --git a/app/modules/permissions/mapper.server.ts b/app/modules/permissions/mapper.server.ts
index 2078c140c..08d2f1de8 100644
--- a/app/modules/permissions/mapper.server.ts
+++ b/app/modules/permissions/mapper.server.ts
@@ -1,7 +1,7 @@
import type { UserWithPlusTier } from "~/db/tables";
import { userDiscordIdIsAged } from "~/utils/users";
import type { Role } from "./types";
-import { isAdmin, isStaff, isSupporter } from "./utils";
+import { isAdmin, isDev, isStaff, isSupporter } from "./utils";
export function userRoles(
user: Pick<
@@ -26,6 +26,10 @@ export function userRoles(
result.push("STAFF");
}
+ if (isDev(user)) {
+ result.push("DEV");
+ }
+
if (typeof user.patronTier === "number") {
result.push("MINOR_SUPPORT");
}
diff --git a/app/modules/permissions/types.ts b/app/modules/permissions/types.ts
index dd492669a..40f07127f 100644
--- a/app/modules/permissions/types.ts
+++ b/app/modules/permissions/types.ts
@@ -14,5 +14,6 @@ export type Role =
| "CALENDAR_EVENT_ADDER"
| "TOURNAMENT_ADDER"
| "API_ACCESSER"
+ | "DEV"
| "SUPPORTER" // patrons of "Supporter" tier or higher
| "MINOR_SUPPORT"; // patrons of "Support" tier or higher
diff --git a/app/modules/permissions/utils.ts b/app/modules/permissions/utils.ts
index 1ce01a570..96b81c84e 100644
--- a/app/modules/permissions/utils.ts
+++ b/app/modules/permissions/utils.ts
@@ -1,4 +1,4 @@
-import { ADMIN_ID, STAFF_IDS } from "~/features/admin/admin-constants";
+import { ADMIN_ID, DEV_IDS, STAFF_IDS } from "~/features/admin/admin-constants";
export function isAdmin(user?: { id: number }) {
return user?.id === ADMIN_ID;
@@ -10,6 +10,12 @@ export function isStaff(user?: { id: number }) {
return STAFF_IDS.includes(user.id);
}
+export function isDev(user?: { id: number }) {
+ if (!user) return false;
+
+ return DEV_IDS.includes(user.id);
+}
+
export function isSupporter(user?: { patronTier: number | null }) {
return typeof user?.patronTier === "number" && user.patronTier >= 2;
}