mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
165
apps/web-react/app/features/api/ApiRepository.server.test.ts
Normal file
165
apps/web-react/app/features/api/ApiRepository.server.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
80
apps/web-react/app/features/api/ApiRepository.server.ts
Normal file
80
apps/web-react/app/features/api/ApiRepository.server.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
40
apps/web-react/app/features/api/actions/api.server.ts
Normal file
40
apps/web-react/app/features/api/actions/api.server.ts
Normal 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;
|
||||
};
|
||||
11
apps/web-react/app/features/api/api-schemas.ts
Normal file
11
apps/web-react/app/features/api/api-schemas.ts
Normal 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"),
|
||||
}),
|
||||
]);
|
||||
1
apps/web-react/app/features/api/api-types.ts
Normal file
1
apps/web-react/app/features/api/api-types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type ApiTokenType = "read" | "write";
|
||||
127
apps/web-react/app/features/api/core/perms.test.ts
Normal file
127
apps/web-react/app/features/api/core/perms.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
23
apps/web-react/app/features/api/core/perms.ts
Normal file
23
apps/web-react/app/features/api/core/perms.ts
Normal 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);
|
||||
}
|
||||
28
apps/web-react/app/features/api/loaders/api.server.ts
Normal file
28
apps/web-react/app/features/api/loaders/api.server.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
122
apps/web-react/app/features/api/routes/api.tsx
Normal file
122
apps/web-react/app/features/api/routes/api.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user