Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,165 @@
import { beforeEach, describe, expect, test } from "vitest";
import * as ApiTokenFactory from "~/db/seed/factories/ApiTokenFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as ApiRepository from "./ApiRepository.server";
const users = UserFactory.pool();
describe("findTokenByUserId", () => {
beforeEach(async () => {
await users.create(3);
});
test("returns undefined when user has no token", async () => {
const result = await ApiRepository.findTokenByUserId(users.id(1), "read");
expect(result).toBeUndefined();
});
test("finds existing token for user", async () => {
await ApiTokenFactory.create({ userId: users.id(1), type: "read" });
const result = await ApiRepository.findTokenByUserId(users.id(1), "read");
expect(result).toBeDefined();
expect(result?.userId).toBe(users.id(1));
expect(result?.token).toBeDefined();
});
test("returns correct token for specific user", async () => {
const token1 = await ApiTokenFactory.create({
userId: users.id(1),
type: "read",
});
const token2 = await ApiTokenFactory.create({
userId: users.id(2),
type: "read",
});
const result1 = await ApiRepository.findTokenByUserId(users.id(1), "read");
const result2 = await ApiRepository.findTokenByUserId(users.id(2), "read");
expect(result1?.token).toBe(token1.token);
expect(result2?.token).toBe(token2.token);
expect(result1?.token).not.toBe(result2?.token);
});
test("finds correct token by type", async () => {
await ApiTokenFactory.create({ userId: users.id(1), type: "read" });
await ApiTokenFactory.create({ userId: users.id(1), type: "write" });
const readResult = await ApiRepository.findTokenByUserId(
users.id(1),
"read",
);
const writeResult = await ApiRepository.findTokenByUserId(
users.id(1),
"write",
);
expect(readResult).toBeDefined();
expect(writeResult).toBeDefined();
expect(readResult?.token).not.toBe(writeResult?.token);
expect(readResult?.type).toBe("read");
expect(writeResult?.type).toBe("write");
});
});
describe("generateToken", () => {
beforeEach(async () => {
await users.create(3);
});
test("creates new token for user", async () => {
const result = await ApiRepository.generateToken(users.id(1), "read");
expect(result.token).toBeDefined();
expect(typeof result.token).toBe("string");
expect(result.token.length).toBeGreaterThan(0);
});
test("deletes existing token before creating new one", async () => {
const firstToken = await ApiRepository.generateToken(users.id(1), "read");
const secondToken = await ApiRepository.generateToken(users.id(1), "read");
expect(firstToken.token).not.toBe(secondToken.token);
const storedToken = await ApiRepository.findTokenByUserId(
users.id(1),
"read",
);
expect(storedToken?.token).toBe(secondToken.token);
});
test("generates unique tokens for different users", async () => {
const token1 = await ApiRepository.generateToken(users.id(1), "read");
const token2 = await ApiRepository.generateToken(users.id(2), "read");
const token3 = await ApiRepository.generateToken(users.id(3), "read");
expect(token1.token).not.toBe(token2.token);
expect(token1.token).not.toBe(token3.token);
expect(token2.token).not.toBe(token3.token);
});
test("replaces only the specific user's token", async () => {
const user1FirstToken = await ApiRepository.generateToken(
users.id(1),
"read",
);
const user2Token = await ApiRepository.generateToken(users.id(2), "read");
const user1SecondToken = await ApiRepository.generateToken(
users.id(1),
"read",
);
const result1 = await ApiRepository.findTokenByUserId(users.id(1), "read");
const result2 = await ApiRepository.findTokenByUserId(users.id(2), "read");
expect(result1?.token).toBe(user1SecondToken.token);
expect(result1?.token).not.toBe(user1FirstToken.token);
expect(result2?.token).toBe(user2Token.token);
});
test("allows same user to have both read and write tokens", async () => {
const readToken = await ApiRepository.generateToken(users.id(1), "read");
const writeToken = await ApiRepository.generateToken(users.id(1), "write");
const readResult = await ApiRepository.findTokenByUserId(
users.id(1),
"read",
);
const writeResult = await ApiRepository.findTokenByUserId(
users.id(1),
"write",
);
expect(readResult?.token).toBe(readToken.token);
expect(writeResult?.token).toBe(writeToken.token);
});
});
describe("findAllApiTokens", () => {
beforeEach(async () => {
await users.create(1);
});
test("returns empty array when no tokens exist", async () => {
const result = await ApiRepository.findAllApiTokens();
expect(result).toEqual([]);
});
test("returns array of token objects with type", async () => {
await ApiTokenFactory.create({ userId: users.id(1), type: "read" });
const result = await ApiRepository.findAllApiTokens();
expect(Array.isArray(result)).toBe(true);
expect(
result.every(
(item) =>
typeof item.token === "string" && typeof item.type === "string",
),
).toBe(true);
});
});

View File

@@ -0,0 +1,80 @@
import { nanoid } from "nanoid";
import { db } from "~/db/sql";
import type { ApiTokenType } from "~/features/api/api-types";
const API_TOKEN_LENGTH = 20;
/** Finds an API token for the given user ID and type. */
export function findTokenByUserId(userId: number, type: ApiTokenType) {
return db
.selectFrom("ApiToken")
.selectAll()
.where("userId", "=", userId)
.where("type", "=", type)
.executeTakeFirst();
}
/** Generates a new API token for the given user. Deletes any existing token of the same type before creating a new one. */
export function generateToken(userId: number, type: ApiTokenType) {
const token = nanoid(API_TOKEN_LENGTH);
return db.transaction().execute(async (trx) => {
await trx
.deleteFrom("ApiToken")
.where("userId", "=", userId)
.where("type", "=", type)
.execute();
return trx
.insertInto("ApiToken")
.values({
userId,
token,
type,
})
.returning("token")
.executeTakeFirstOrThrow();
});
}
/** Retrieves all valid API tokens and their types from users with API access. */
export async function findAllApiTokens() {
const tokens = await db
.selectFrom("ApiToken")
.innerJoin("User", "User.id", "ApiToken.userId")
.leftJoin(
"TournamentOrganizationMember",
"TournamentOrganizationMember.userId",
"ApiToken.userId",
)
.leftJoin(
"TournamentOrganization",
"TournamentOrganization.id",
"TournamentOrganizationMember.organizationId",
)
.select(["ApiToken.token", "ApiToken.type", "ApiToken.userId"])
// NOTE: permissions logic also exists in checkUserHasApiAccess function
.where((eb) =>
eb.or([
eb("User.isApiAccesser", "=", 1),
eb("User.isTournamentOrganizer", "=", 1),
eb("User.patronTier", ">=", 2),
eb.and([
eb("TournamentOrganization.isEstablished", "=", 1),
eb.or([
eb("TournamentOrganizationMember.role", "=", "ADMIN"),
eb("TournamentOrganizationMember.role", "=", "ORGANIZER"),
eb("TournamentOrganizationMember.role", "=", "STREAMER"),
]),
]),
]),
)
.groupBy("ApiToken.token")
.execute();
return tokens.map((row) => ({
token: row.token,
type: row.type,
userId: row.userId,
}));
}

View File

@@ -0,0 +1,40 @@
import type { ActionFunctionArgs } from "react-router";
import { refreshApiTokensCache } from "~/features/api-public/api-public-utils.server";
import { requireUser } from "~/features/auth/core/user.server";
import { parseRequestPayload, successToast } from "~/utils/remix.server";
import * as ApiRepository from "../ApiRepository.server";
import { apiActionSchema } from "../api-schemas";
import { checkUserHasApiAccess } from "../core/perms";
export const action = async ({ request }: ActionFunctionArgs) => {
const data = await parseRequestPayload({
request,
schema: apiActionSchema,
});
const user = requireUser();
const hasApiAccess = await checkUserHasApiAccess(user);
if (!hasApiAccess) {
throw new Response("Forbidden", { status: 403 });
}
switch (data._action) {
case "GENERATE_READ": {
await ApiRepository.generateToken(user.id, "read");
await refreshApiTokensCache();
successToast("Read token generated successfully");
break;
}
case "GENERATE_WRITE": {
await ApiRepository.generateToken(user.id, "write");
await refreshApiTokensCache();
successToast("Write token generated successfully");
break;
}
default: {
throw new Error("Invalid action");
}
}
return null;
};

View File

@@ -0,0 +1,11 @@
import { z } from "zod";
import { _action } from "~/utils/zod";
export const apiActionSchema = z.union([
z.object({
_action: _action("GENERATE_READ"),
}),
z.object({
_action: _action("GENERATE_WRITE"),
}),
]);

View File

@@ -0,0 +1 @@
export type ApiTokenType = "read" | "write";

View File

@@ -0,0 +1,127 @@
import { describe, expect, test } from "vitest";
import * as ApiTokenFactory from "~/db/seed/factories/ApiTokenFactory";
import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import * as ApiRepository from "../ApiRepository.server";
import { checkUserHasApiAccess } from "./perms";
describe("Permission logic consistency between findAllApiTokens and checkUserHasApiAccess", () => {
test("both functions grant access for isApiAccesser flag", async () => {
const { id } = await UserFactory.create(null, { roles: ["API_ACCESSER"] });
await ApiTokenFactory.create({ userId: id });
const tokens = await ApiRepository.findAllApiTokens();
const user = await UserRepository.findLeanById(id);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens).toHaveLength(1);
expect(hasAccess).toBe(true);
});
test("both functions grant access for isTournamentOrganizer flag", async () => {
const { id } = await UserFactory.create(
{},
{ roles: ["TOURNAMENT_ORGANIZER"] },
);
await ApiTokenFactory.create({ userId: id });
const tokens = await ApiRepository.findAllApiTokens();
const user = await UserRepository.findLeanById(id);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens).toHaveLength(1);
expect(hasAccess).toBe(true);
});
test("both functions grant access for patronTier >= 2", async () => {
const { id } = await UserFactory.create(null, { patronTier: 2 });
await ApiTokenFactory.create({ userId: id });
const tokens = await ApiRepository.findAllApiTokens();
const user = await UserRepository.findLeanById(id);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens).toHaveLength(1);
expect(hasAccess).toBe(true);
});
test("both functions deny access for patronTier < 2", async () => {
const { id } = await UserFactory.create(null, { patronTier: 1 });
await ApiTokenFactory.create({ userId: id });
const tokens = await ApiRepository.findAllApiTokens();
const user = await UserRepository.findLeanById(id);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens).toHaveLength(0);
expect(hasAccess).toBe(false);
});
test("both functions grant access for ADMIN/ORGANIZER/STREAMER of established org", async () => {
const [owner, admin, organizer, streamer] = await UserFactory.createMany(4);
await TournamentOrganizationFactory.create(
{ ownerId: owner.id },
{
isEstablished: true,
members: [
{ userId: admin.id, role: "ADMIN" },
{ userId: organizer.id, role: "ORGANIZER" },
{ userId: streamer.id, role: "STREAMER" },
],
},
);
for (const userId of [admin.id, organizer.id, streamer.id]) {
await ApiTokenFactory.create({ userId });
const tokens = await ApiRepository.findAllApiTokens();
const user = await UserRepository.findLeanById(userId);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens.length).toBeGreaterThan(0);
expect(hasAccess).toBe(true);
}
});
test("both functions deny access for MEMBER of established org", async () => {
const [owner, member] = await UserFactory.createMany(2);
await TournamentOrganizationFactory.create(
{ ownerId: owner.id },
{ isEstablished: true, members: [{ userId: member.id, role: "MEMBER" }] },
);
await ApiTokenFactory.create({ userId: member.id });
const tokens = await ApiRepository.findAllApiTokens();
const user = await UserRepository.findLeanById(member.id);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens).toHaveLength(0);
expect(hasAccess).toBe(false);
});
test("both functions deny access for ADMIN of non-established org", async () => {
const [owner, member] = await UserFactory.createMany(2);
await TournamentOrganizationFactory.create(
{ ownerId: owner.id },
{ members: [{ userId: member.id, role: "ADMIN" }] },
);
await ApiTokenFactory.create({ userId: member.id });
const tokens = await ApiRepository.findAllApiTokens();
const user = await UserRepository.findLeanById(member.id);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens).toHaveLength(0);
expect(hasAccess).toBe(false);
});
});

View File

@@ -0,0 +1,23 @@
import type { AuthenticatedUser } from "~/features/auth/core/user.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
/**
* Checks whether a user has permission to access the API.
* A user has API access if they either have the API_ACCESSER role (includes supporters),
* or are an admin/organizer/streamer of an established tournament organization.
*
* @param user - The authenticated user to check permissions for
* @returns True if the user has API access, false otherwise
*/
export async function checkUserHasApiAccess(user: AuthenticatedUser) {
// NOTE: permissions logic also exists in ApiRepository.findAllApiTokens function
if (user.roles.includes("API_ACCESSER")) {
return true;
}
const orgs = await TournamentOrganizationRepository.findByUserId(user.id, {
roles: ["ADMIN", "ORGANIZER", "STREAMER"],
});
return orgs.some((org) => org.isEstablished);
}

View File

@@ -0,0 +1,28 @@
import { requireUser } from "~/features/auth/core/user.server";
import * as ApiRepository from "../ApiRepository.server";
import { checkUserHasApiAccess } from "../core/perms";
export const loader = async () => {
const user = requireUser();
const hasApiAccess = await checkUserHasApiAccess(user);
if (!hasApiAccess) {
return {
hasAccess: false,
readToken: null,
writeToken: null,
};
}
const [readToken, writeToken] = await Promise.all([
ApiRepository.findTokenByUserId(user.id, "read"),
ApiRepository.findTokenByUserId(user.id, "write"),
]);
return {
hasAccess: true,
readToken: readToken?.token ?? null,
writeToken: writeToken?.token ?? null,
};
};

View File

@@ -0,0 +1,122 @@
import { Eye, RefreshCcw } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { useLoaderData } from "react-router";
import { ActionButton } from "~/components/ActionButton";
import { CopyToClipboardPopover } from "~/components/CopyToClipboardPopover";
import { SendouButton } from "~/components/elements/Button";
import { FormMessage } from "~/components/FormMessage";
import { Main } from "~/components/Main";
import { metaTags } from "~/utils/remix";
import { API_DOC_LINK } from "~/utils/urls";
import { action } from "../actions/api.server";
import { apiActionSchema } from "../api-schemas";
import { loader } from "../loaders/api.server";
export { action, loader };
export const meta: MetaFunction = (args) => {
return metaTags({
title: "API Access",
location: args.location,
});
};
export default function ApiPage() {
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["common"]);
return (
<Main className="stack lg">
<div>
<h1 className="text-lg">{t("common:api.title")}</h1>
<p className="text-sm">
<Trans t={t} i18nKey="common:api.description">
Generate an API token to access the sendou.ink API. See the
<a href={API_DOC_LINK} className="text-theme">
API documentation
</a>
for available endpoints, usage examples and guidelines to follow.
</Trans>
</p>
</div>
{!data.hasAccess ? (
<div>
<FormMessage type="info">{t("common:api.noAccess")}</FormMessage>
</div>
) : (
<div className="stack lg">
<TokenSection
token={data.readToken}
tokenType="read"
generateAction="GENERATE_READ"
/>
<TokenSection
token={data.writeToken}
tokenType="write"
generateAction="GENERATE_WRITE"
/>
</div>
)}
</Main>
);
}
function TokenSection({
token,
tokenType,
generateAction,
}: {
token: string | null;
tokenType: "read" | "write";
generateAction: "GENERATE_READ" | "GENERATE_WRITE";
}) {
const { t } = useTranslation(["common"]);
const isWriteToken = tokenType === "write";
const labelKey = isWriteToken
? "common:api.writeTokenLabel"
: "common:api.readTokenLabel";
const descriptionKey = isWriteToken
? "common:api.writeTokenDescription"
: "common:api.readTokenDescription";
return (
<div className="stack md">
<div>
<h2 className="text-md">{t(labelKey)}</h2>
<p className="text-xs text-lighter">{t(descriptionKey)}</p>
</div>
{token ? (
<div>
<CopyToClipboardPopover
url={token}
trigger={
<SendouButton icon={<Eye />}>
{t("common:api.revealButton")}
</SendouButton>
}
/>
</div>
) : null}
<ActionButton
schema={apiActionSchema}
action={generateAction}
variant={token ? "outlined" : undefined}
icon={token ? <RefreshCcw /> : undefined}
confirm={
token
? {
dialogHeading: t("common:api.regenerate.heading"),
submitButtonText: t("common:api.regenerate.confirm"),
}
: undefined
}
>
{token ? t("common:api.regenerate.button") : t("common:api.generate")}
</ActionButton>
</div>
);
}