mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 15:48:23 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
33
apps/web-react/app/modules/permissions/guards.server.ts
Normal file
33
apps/web-react/app/modules/permissions/guards.server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import type { EntityWithPermissions, Role } from "~/modules/permissions/types";
|
||||
import { hasPermission } from "./utils";
|
||||
|
||||
/**
|
||||
* Checks if a user has the required global role.
|
||||
*
|
||||
* @throws {Response} - Throws a 403 Forbidden response if the user does not have the required role.
|
||||
*/
|
||||
export function requireRole(role: Role) {
|
||||
const user = requireUser();
|
||||
if (!user.roles.includes(role)) {
|
||||
throw new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user has the required permission to perform an action on a given entity.
|
||||
*
|
||||
* @throws {Response} - Throws a 403 Forbidden response if the user does not have the required permission.
|
||||
*/
|
||||
export function requirePermission<
|
||||
T extends EntityWithPermissions,
|
||||
K extends keyof T["permissions"],
|
||||
>(obj: T, permission: K) {
|
||||
const user = requireUser();
|
||||
|
||||
if (hasPermission(obj, permission, user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
30
apps/web-react/app/modules/permissions/hooks.ts
Normal file
30
apps/web-react/app/modules/permissions/hooks.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import type { EntityWithPermissions, Role } from "~/modules/permissions/types";
|
||||
import { hasPermission } from "./utils";
|
||||
|
||||
/**
|
||||
* Determines whether a user has a specific global role.
|
||||
*
|
||||
* @returns A boolean indicating whether the user has the specified role. Always false if user is not logged in.
|
||||
*/
|
||||
export function useHasRole(role: Role) {
|
||||
const user = useUser();
|
||||
|
||||
if (!user) return false;
|
||||
|
||||
return user.roles.includes(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a user has a specific permission for a given entity.
|
||||
*
|
||||
* @returns A boolean indicating whether the user has the specified permission. Always false if user is not logged in.
|
||||
*/
|
||||
export function useHasPermission<
|
||||
T extends EntityWithPermissions,
|
||||
K extends keyof T["permissions"],
|
||||
>(obj: T, permission: K) {
|
||||
const user = useUser();
|
||||
|
||||
return hasPermission(obj, permission, user);
|
||||
}
|
||||
81
apps/web-react/app/modules/permissions/mapper.server.ts
Normal file
81
apps/web-react/app/modules/permissions/mapper.server.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { UserWithPlusTier } from "~/utils/kysely.server";
|
||||
import { userDiscordIdIsAged } from "~/utils/users";
|
||||
import type { Role } from "./types";
|
||||
import {
|
||||
isAdmin,
|
||||
isDev,
|
||||
isQa,
|
||||
isScannerTester,
|
||||
isStaff,
|
||||
isSupporter,
|
||||
} from "./utils";
|
||||
|
||||
export function userRoles(
|
||||
user: Pick<
|
||||
UserWithPlusTier,
|
||||
| "id"
|
||||
| "discordId"
|
||||
| "plusTier"
|
||||
| "isArtist"
|
||||
| "isTournamentOrganizer"
|
||||
| "isVideoAdder"
|
||||
| "isApiAccesser"
|
||||
| "patronTier"
|
||||
>,
|
||||
) {
|
||||
const result: Array<Role> = [];
|
||||
|
||||
if (isAdmin(user)) {
|
||||
result.push("ADMIN");
|
||||
}
|
||||
|
||||
if (isStaff(user) || isAdmin(user)) {
|
||||
result.push("STAFF");
|
||||
}
|
||||
|
||||
if (isDev(user)) {
|
||||
result.push("DEV");
|
||||
}
|
||||
|
||||
if (isQa(user)) {
|
||||
result.push("QA");
|
||||
}
|
||||
|
||||
if (isScannerTester(user)) {
|
||||
result.push("SCANNER_TESTER");
|
||||
}
|
||||
|
||||
if (typeof user.patronTier === "number") {
|
||||
result.push("MINOR_SUPPORT");
|
||||
}
|
||||
|
||||
if (isSupporter(user)) {
|
||||
result.push("SUPPORTER");
|
||||
}
|
||||
|
||||
if (typeof user.plusTier === "number") {
|
||||
result.push("PLUS_SERVER_MEMBER");
|
||||
}
|
||||
|
||||
if (user.isArtist) {
|
||||
result.push("ARTIST");
|
||||
}
|
||||
|
||||
if (user.isVideoAdder) {
|
||||
result.push("VIDEO_ADDER");
|
||||
}
|
||||
|
||||
if (user.isTournamentOrganizer || isSupporter(user)) {
|
||||
result.push("TOURNAMENT_ADDER");
|
||||
}
|
||||
|
||||
if (userDiscordIdIsAged(user) || isSupporter(user)) {
|
||||
result.push("CALENDAR_EVENT_ADDER");
|
||||
}
|
||||
|
||||
if (user.isTournamentOrganizer || user.isApiAccesser || isSupporter(user)) {
|
||||
result.push("API_ACCESSER");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
21
apps/web-react/app/modules/permissions/types.ts
Normal file
21
apps/web-react/app/modules/permissions/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type Permissions = Record<string, number[]>;
|
||||
|
||||
export type EntityWithPermissions = {
|
||||
permissions: Permissions;
|
||||
};
|
||||
|
||||
/** Represents a "global" role with permissions associated to it */
|
||||
export type Role =
|
||||
| "ADMIN"
|
||||
| "STAFF"
|
||||
| "PLUS_SERVER_MEMBER"
|
||||
| "VIDEO_ADDER"
|
||||
| "ARTIST"
|
||||
| "CALENDAR_EVENT_ADDER"
|
||||
| "TOURNAMENT_ADDER"
|
||||
| "API_ACCESSER"
|
||||
| "QA"
|
||||
| "DEV"
|
||||
| "SCANNER_TESTER"
|
||||
| "SUPPORTER" // patrons of "Supporter" tier or higher
|
||||
| "MINOR_SUPPORT"; // patrons of "Support" tier or higher
|
||||
53
apps/web-react/app/modules/permissions/utils.test.ts
Normal file
53
apps/web-react/app/modules/permissions/utils.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
|
||||
const e2e = vi.hoisted(() => ({ isTestRun: false }));
|
||||
|
||||
vi.mock("~/utils/e2e", () => ({
|
||||
get IS_E2E_TEST_RUN() {
|
||||
return e2e.isTestRun;
|
||||
},
|
||||
}));
|
||||
|
||||
import { hasPermission } from "./utils";
|
||||
|
||||
const REGULAR_USER_ID = ADMIN_ID + 1;
|
||||
const OTHER_USER_ID = ADMIN_ID + 2;
|
||||
|
||||
const entity = { permissions: { EDIT: [REGULAR_USER_ID] } };
|
||||
|
||||
describe("hasPermission", () => {
|
||||
beforeEach(() => {
|
||||
e2e.isTestRun = false;
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test("returns false for a logged out user", () => {
|
||||
expect(hasPermission(entity, "EDIT", null)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for a user holding the permission", () => {
|
||||
expect(hasPermission(entity, "EDIT", { id: REGULAR_USER_ID })).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for a user not holding the permission", () => {
|
||||
expect(hasPermission(entity, "EDIT", { id: OTHER_USER_ID })).toBe(false);
|
||||
});
|
||||
|
||||
test("admin does not bypass the permission outside production", () => {
|
||||
expect(hasPermission(entity, "EDIT", { id: ADMIN_ID })).toBe(false);
|
||||
});
|
||||
|
||||
test("admin bypasses the permission in production", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
|
||||
expect(hasPermission(entity, "EDIT", { id: ADMIN_ID })).toBe(true);
|
||||
});
|
||||
|
||||
test("admin does not bypass the permission in an e2e test run", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
e2e.isTestRun = true;
|
||||
|
||||
expect(hasPermission(entity, "EDIT", { id: ADMIN_ID })).toBe(false);
|
||||
});
|
||||
});
|
||||
65
apps/web-react/app/modules/permissions/utils.ts
Normal file
65
apps/web-react/app/modules/permissions/utils.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
ADMIN_ID,
|
||||
DEV_IDS,
|
||||
QA_IDS,
|
||||
SCANNER_TESTER_IDS,
|
||||
STAFF_IDS,
|
||||
} from "~/features/admin/admin-constants";
|
||||
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
|
||||
import type { EntityWithPermissions } from "./types";
|
||||
|
||||
/**
|
||||
* Determines whether a user has a specific permission for a given entity.
|
||||
* Single source of truth shared by `requirePermission` and `useHasPermission`.
|
||||
*
|
||||
* @returns A boolean indicating whether the user has the specified permission. Always false if user is not logged in.
|
||||
*/
|
||||
export function hasPermission<
|
||||
T extends EntityWithPermissions,
|
||||
K extends keyof T["permissions"],
|
||||
>(obj: T, permission: K, user?: { id: number } | null) {
|
||||
if (!user) return false;
|
||||
|
||||
// admin can do anything in production but not in development or e2e tests for better testing
|
||||
if (
|
||||
process.env.NODE_ENV === "production" &&
|
||||
!IS_E2E_TEST_RUN &&
|
||||
isAdmin(user)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (obj.permissions as Record<K, number[]>)[permission].includes(user.id);
|
||||
}
|
||||
|
||||
export function isAdmin(user?: { id: number }) {
|
||||
return user?.id === ADMIN_ID;
|
||||
}
|
||||
|
||||
export function isStaff(user?: { id: number }) {
|
||||
if (!user) return false;
|
||||
|
||||
return STAFF_IDS.includes(user.id);
|
||||
}
|
||||
|
||||
export function isDev(user?: { id: number }) {
|
||||
if (!user) return false;
|
||||
|
||||
return DEV_IDS.includes(user.id);
|
||||
}
|
||||
|
||||
export function isQa(user?: { id: number }) {
|
||||
if (!user) return false;
|
||||
|
||||
return QA_IDS.includes(user.id);
|
||||
}
|
||||
|
||||
export function isScannerTester(user?: { id: number }) {
|
||||
if (!user) return false;
|
||||
|
||||
return SCANNER_TESTER_IDS.includes(user.id);
|
||||
}
|
||||
|
||||
export function isSupporter(user?: { patronTier: number | null }) {
|
||||
return typeof user?.patronTier === "number" && user.patronTier >= 2;
|
||||
}
|
||||
Reference in New Issue
Block a user