diff --git a/.env.example b/.env.example index dbee997ff..ef1a361b5 100644 --- a/.env.example +++ b/.env.example @@ -42,5 +42,3 @@ VITE_SHOW_LUTI_NAV_ITEM=false VITE_VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= VAPID_EMAIL= - -PUBLIC_API_TOKENS=secret,secret2 diff --git a/app/db/tables.ts b/app/db/tables.ts index 34b86fbc2..88d42ca67 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -961,10 +961,13 @@ export interface UserFriendCode { createdAt: GeneratedAlways; } +export type ApiTokenType = "read" | "write"; + export interface ApiToken { id: GeneratedAlways; userId: number; token: string; + type: Generated; createdAt: GeneratedAlways; } diff --git a/app/features/api-public/api-public-utils.server.ts b/app/features/api-public/api-public-utils.server.ts index 71facd609..13b92d734 100644 --- a/app/features/api-public/api-public-utils.server.ts +++ b/app/features/api-public/api-public-utils.server.ts @@ -1,9 +1,16 @@ +import type { ApiTokenType } from "~/db/tables"; import * as ApiRepository from "~/features/api/ApiRepository.server"; async function loadApiTokensCache() { - const envTokens = process.env.PUBLIC_API_TOKENS?.split(",") ?? []; const dbTokens = await ApiRepository.allApiTokens(); - return new Set([...envTokens, ...dbTokens]); + + const tokenMap = new Map(); + + for (const { token, type } of dbTokens) { + tokenMap.set(token, type); + } + + return tokenMap; } let apiTokens = await loadApiTokensCache(); @@ -12,12 +19,16 @@ export async function refreshApiTokensCache() { apiTokens = await loadApiTokensCache(); } -export function requireBearerAuth(req: Request) { +function extractToken(req: Request) { const authHeader = req.headers.get("Authorization"); if (!authHeader) { throw new Response("Missing Authorization header", { status: 401 }); } - const token = authHeader.replace("Bearer ", ""); + return authHeader.replace("Bearer ", ""); +} + +export function requireBearerAuth(req: Request) { + const token = extractToken(req); if (!apiTokens.has(token)) { throw new Response("Invalid token", { status: 401 }); } diff --git a/app/features/api/ApiRepository.server.test.ts b/app/features/api/ApiRepository.server.test.ts index ea57d064b..cc4f78b4d 100644 --- a/app/features/api/ApiRepository.server.test.ts +++ b/app/features/api/ApiRepository.server.test.ts @@ -12,15 +12,15 @@ describe("findTokenByUserId", () => { }); test("returns undefined when user has no token", async () => { - const result = await ApiRepository.findTokenByUserId(1); + const result = await ApiRepository.findTokenByUserId(1, "read"); expect(result).toBeUndefined(); }); test("finds existing token for user", async () => { - await ApiRepository.generateToken(1); + await ApiRepository.generateToken(1, "read"); - const result = await ApiRepository.findTokenByUserId(1); + const result = await ApiRepository.findTokenByUserId(1, "read"); expect(result).toBeDefined(); expect(result?.userId).toBe(1); @@ -28,16 +28,30 @@ describe("findTokenByUserId", () => { }); test("returns correct token for specific user", async () => { - const token1 = await ApiRepository.generateToken(1); - const token2 = await ApiRepository.generateToken(2); + const token1 = await ApiRepository.generateToken(1, "read"); + const token2 = await ApiRepository.generateToken(2, "read"); - const result1 = await ApiRepository.findTokenByUserId(1); - const result2 = await ApiRepository.findTokenByUserId(2); + const result1 = await ApiRepository.findTokenByUserId(1, "read"); + const result2 = await ApiRepository.findTokenByUserId(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 ApiRepository.generateToken(1, "read"); + await ApiRepository.generateToken(1, "write"); + + const readResult = await ApiRepository.findTokenByUserId(1, "read"); + const writeResult = await ApiRepository.findTokenByUserId(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", () => { @@ -50,7 +64,7 @@ describe("generateToken", () => { }); test("creates new token for user", async () => { - const result = await ApiRepository.generateToken(1); + const result = await ApiRepository.generateToken(1, "read"); expect(result.token).toBeDefined(); expect(typeof result.token).toBe("string"); @@ -58,19 +72,19 @@ describe("generateToken", () => { }); test("deletes existing token before creating new one", async () => { - const firstToken = await ApiRepository.generateToken(1); - const secondToken = await ApiRepository.generateToken(1); + const firstToken = await ApiRepository.generateToken(1, "read"); + const secondToken = await ApiRepository.generateToken(1, "read"); expect(firstToken.token).not.toBe(secondToken.token); - const storedToken = await ApiRepository.findTokenByUserId(1); + const storedToken = await ApiRepository.findTokenByUserId(1, "read"); expect(storedToken?.token).toBe(secondToken.token); }); test("generates unique tokens for different users", async () => { - const token1 = await ApiRepository.generateToken(1); - const token2 = await ApiRepository.generateToken(2); - const token3 = await ApiRepository.generateToken(3); + const token1 = await ApiRepository.generateToken(1, "read"); + const token2 = await ApiRepository.generateToken(2, "read"); + const token3 = await ApiRepository.generateToken(3, "read"); expect(token1.token).not.toBe(token2.token); expect(token1.token).not.toBe(token3.token); @@ -78,17 +92,28 @@ describe("generateToken", () => { }); test("replaces only the specific user's token", async () => { - const user1FirstToken = await ApiRepository.generateToken(1); - const user2Token = await ApiRepository.generateToken(2); - const user1SecondToken = await ApiRepository.generateToken(1); + const user1FirstToken = await ApiRepository.generateToken(1, "read"); + const user2Token = await ApiRepository.generateToken(2, "read"); + const user1SecondToken = await ApiRepository.generateToken(1, "read"); - const result1 = await ApiRepository.findTokenByUserId(1); - const result2 = await ApiRepository.findTokenByUserId(2); + const result1 = await ApiRepository.findTokenByUserId(1, "read"); + const result2 = await ApiRepository.findTokenByUserId(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(1, "read"); + const writeToken = await ApiRepository.generateToken(1, "write"); + + const readResult = await ApiRepository.findTokenByUserId(1, "read"); + const writeResult = await ApiRepository.findTokenByUserId(1, "write"); + + expect(readResult?.token).toBe(readToken.token); + expect(writeResult?.token).toBe(writeToken.token); + }); }); describe("allApiTokens", () => { @@ -106,12 +131,17 @@ describe("allApiTokens", () => { expect(result).toEqual([]); }); - test("returns array of token strings", async () => { - await ApiRepository.generateToken(1); + test("returns array of token objects with type", async () => { + await ApiRepository.generateToken(1, "read"); const result = await ApiRepository.allApiTokens(); expect(Array.isArray(result)).toBe(true); - expect(result.every((token) => typeof token === "string")).toBe(true); + expect( + result.every( + (item) => + typeof item.token === "string" && typeof item.type === "string", + ), + ).toBe(true); }); }); diff --git a/app/features/api/ApiRepository.server.ts b/app/features/api/ApiRepository.server.ts index 31ef1a1c5..3756be348 100644 --- a/app/features/api/ApiRepository.server.ts +++ b/app/features/api/ApiRepository.server.ts @@ -1,48 +1,43 @@ import { nanoid } from "nanoid"; import { db } from "~/db/sql"; +import type { ApiTokenType } from "~/db/tables"; const API_TOKEN_LENGTH = 20; -/** - * Finds an API token for the given user ID. - * @returns API token record if found, undefined otherwise - */ -export function findTokenByUserId(userId: number) { +/** 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 for the user before creating a new one. - * @returns Object containing the newly generated token - */ -export function generateToken(userId: number) { +/** 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).execute(); + 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 from users with API access. - * Includes tokens from users with the isApiAccesser flag enabled (includes supporters tier 2+), - * or users who are ADMIN, ORGANIZER, or STREAMER members of established tournament organizations. - * @returns Array of valid API token strings - */ +/** Retrieves all valid API tokens and their types from users with API access. */ export async function allApiTokens() { const tokens = await db .selectFrom("ApiToken") @@ -57,7 +52,7 @@ export async function allApiTokens() { "TournamentOrganization.id", "TournamentOrganizationMember.organizationId", ) - .select("ApiToken.token") + .select(["ApiToken.token", "ApiToken.type"]) // NOTE: permissions logic also exists in checkUserHasApiAccess function .where((eb) => eb.or([ @@ -77,5 +72,5 @@ export async function allApiTokens() { .groupBy("ApiToken.token") .execute(); - return tokens.map((row) => row.token); + return tokens.map((row) => ({ token: row.token, type: row.type })); } diff --git a/app/features/api/actions/api.server.ts b/app/features/api/actions/api.server.ts index 521e0a509..1d74ee5e7 100644 --- a/app/features/api/actions/api.server.ts +++ b/app/features/api/actions/api.server.ts @@ -3,12 +3,11 @@ import { z } from "zod"; 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 { _action } from "~/utils/zod"; import * as ApiRepository from "../ApiRepository.server"; import { checkUserHasApiAccess } from "../core/perms"; const apiActionSchema = z.object({ - _action: _action("GENERATE"), + _action: z.enum(["GENERATE_READ", "GENERATE_WRITE"]), }); export const action = async ({ request }: ActionFunctionArgs) => { @@ -24,12 +23,16 @@ export const action = async ({ request }: ActionFunctionArgs) => { } switch (data._action) { - case "GENERATE": { - await ApiRepository.generateToken(user.id); - + case "GENERATE_READ": { + await ApiRepository.generateToken(user.id, "read"); await refreshApiTokensCache(); - - successToast("API token generated successfully"); + successToast("Read token generated successfully"); + break; + } + case "GENERATE_WRITE": { + await ApiRepository.generateToken(user.id, "write"); + await refreshApiTokensCache(); + successToast("Write token generated successfully"); break; } default: { diff --git a/app/features/api/core/perms.test.ts b/app/features/api/core/perms.test.ts index bc8680a65..4f1ec8f82 100644 --- a/app/features/api/core/perms.test.ts +++ b/app/features/api/core/perms.test.ts @@ -19,7 +19,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA test("both functions grant access for isApiAccesser flag", async () => { await AdminRepository.makeApiAccesserByUserId(1); - await ApiRepository.generateToken(1); + await ApiRepository.generateToken(1, "read"); const tokens = await ApiRepository.allApiTokens(); const user = await UserRepository.findLeanById(1); @@ -32,7 +32,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA test("both functions grant access for isTournamentOrganizer flag", async () => { await AdminRepository.makeTournamentOrganizerByUserId(1); - await ApiRepository.generateToken(1); + await ApiRepository.generateToken(1, "read"); const tokens = await ApiRepository.allApiTokens(); const user = await UserRepository.findLeanById(1); @@ -50,7 +50,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA patronTill: add(new Date(), { months: 3 }), }); - await ApiRepository.generateToken(1); + await ApiRepository.generateToken(1, "read"); const tokens = await ApiRepository.allApiTokens(); const user = await UserRepository.findLeanById(1); @@ -68,7 +68,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA patronTill: add(new Date(), { months: 3 }), }); - await ApiRepository.generateToken(1); + await ApiRepository.generateToken(1, "read"); const tokens = await ApiRepository.allApiTokens(); const user = await UserRepository.findLeanById(1); @@ -101,7 +101,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA badges: [], }); - await ApiRepository.generateToken(userId); + await ApiRepository.generateToken(userId, "read"); const tokens = await ApiRepository.allApiTokens(); const user = await UserRepository.findLeanById(userId); @@ -131,7 +131,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA badges: [], }); - await ApiRepository.generateToken(2); + await ApiRepository.generateToken(2, "read"); const tokens = await ApiRepository.allApiTokens(); const user = await UserRepository.findLeanById(2); @@ -158,7 +158,7 @@ describe("Permission logic consistency between allApiTokens and checkUserHasApiA badges: [], }); - await ApiRepository.generateToken(2); + await ApiRepository.generateToken(2, "read"); const tokens = await ApiRepository.allApiTokens(); const user = await UserRepository.findLeanById(2); diff --git a/app/features/api/loaders/api.server.ts b/app/features/api/loaders/api.server.ts index c498142df..ef8a33e58 100644 --- a/app/features/api/loaders/api.server.ts +++ b/app/features/api/loaders/api.server.ts @@ -10,14 +10,19 @@ export const loader = async () => { if (!hasApiAccess) { return { hasAccess: false, - apiToken: null, + readToken: null, + writeToken: null, }; } - const apiToken = await ApiRepository.findTokenByUserId(user.id); + const [readToken, writeToken] = await Promise.all([ + ApiRepository.findTokenByUserId(user.id, "read"), + ApiRepository.findTokenByUserId(user.id, "write"), + ]); return { hasAccess: true, - apiToken: apiToken?.token ?? null, + readToken: readToken?.token ?? null, + writeToken: writeToken?.token ?? null, }; }; diff --git a/app/features/api/routes/api.tsx b/app/features/api/routes/api.tsx index e7b8f7a23..92be74bdc 100644 --- a/app/features/api/routes/api.tsx +++ b/app/features/api/routes/api.tsx @@ -45,12 +45,55 @@ export default function ApiPage() {
{t("common:api.noAccess")}
- ) : data.apiToken ? ( + ) : ( +
+ + +
+ )} + + ); +} + +function TokenSection({ + token, + tokenType, + generateAction, +}: { + token: string | null; + tokenType: "read" | "write"; + generateAction: string; +}) { + 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 ( +
+
+

{t(labelKey)}

+

{t(descriptionKey)}

+
+ + {token ? (
- }> {t("common:api.revealButton")} @@ -62,12 +105,12 @@ export default function ApiPage() { } + className="mr-auto" > {t("common:api.regenerate.button")} @@ -75,11 +118,11 @@ export default function ApiPage() {
) : (
- + {t("common:api.generate")} )} - +
); } diff --git a/db-test.sqlite3 b/db-test.sqlite3 index f5216c625..7861ee433 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/api-public.spec.ts b/e2e/api-public.spec.ts index 1c85c7a2d..5c71687bb 100644 --- a/e2e/api-public.spec.ts +++ b/e2e/api-public.spec.ts @@ -1,4 +1,5 @@ -import { expect, seed, test } from "~/utils/playwright"; +import { ADMIN_ID } from "~/features/admin/admin-constants"; +import { expect, impersonate, navigate, seed, test } from "~/utils/playwright"; test.describe("Public API", () => { test("OPTIONS preflight request returns 204 with CORS headers", async ({ @@ -25,4 +26,34 @@ test.describe("Public API", () => { expect(response.headers()["access-control-allow-origin"]).toBe("*"); }); + + test("creates read API token and calls public endpoint", async ({ page }) => { + await seed(page); + await impersonate(page); + + await navigate({ page, url: "/api" }); + + await page.locator("form").first().getByRole("button").click(); + await page.waitForURL("/api"); + + await page + .getByRole("button", { name: /reveal/i }) + .first() + .click(); + + const token = await page.locator("input[readonly]").inputValue(); + expect(token).toBeTruthy(); + expect(token.length).toBe(20); + + const response = await page.request.fetch(`/api/user/${ADMIN_ID}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + expect(response.status()).toBe(200); + const data = await response.json(); + expect(data.id).toBe(ADMIN_ID); + expect(data.name).toBe("Sendou"); + }); }); diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index a9fe6feef..a1039aa30 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index bff378845..e4becfb9a 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index 5411be44b..7a0024982 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index 8a29ba532..d3b2c2602 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index b7b2980db..2fe3c6e05 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index be269a012..b60c82143 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index d80c9aaac..b42013406 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/locales/da/common.json b/locales/da/common.json index d4374b7ff..ec589f2a6 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -323,7 +323,10 @@ "api.title": "", "api.description": "", "api.noAccess": "", - "api.tokenLabel": "", + "api.readTokenLabel": "", + "api.readTokenDescription": "", + "api.writeTokenLabel": "", + "api.writeTokenDescription": "", "api.revealButton": "", "api.regenerate.heading": "", "api.regenerate.button": "", diff --git a/locales/de/common.json b/locales/de/common.json index b09eedacc..1b1ca7932 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -323,7 +323,10 @@ "api.title": "", "api.description": "", "api.noAccess": "", - "api.tokenLabel": "", + "api.readTokenLabel": "", + "api.readTokenDescription": "", + "api.writeTokenLabel": "", + "api.writeTokenDescription": "", "api.revealButton": "", "api.regenerate.heading": "", "api.regenerate.button": "", diff --git a/locales/en/common.json b/locales/en/common.json index 2681cd7b8..882aeff32 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -323,7 +323,10 @@ "api.title": "API Access", "api.description": "Generate an API token to access the sendou.ink API. See the <1>API documentation for available endpoints, usage examples and guidelines to follow.", "api.noAccess": "You do not have access to the API. Access is granted to supporters (Supporter tier or higher) and admins, organizers, or streamers of established tournament organizations.", - "api.tokenLabel": "Your API Token", + "api.readTokenLabel": "Read Token", + "api.readTokenDescription": "Use this token to access read-only API endpoints.", + "api.writeTokenLabel": "Write Token", + "api.writeTokenDescription": "Use this token to access both read and write API endpoints.", "api.revealButton": "Click to reveal", "api.regenerate.heading": "Regenerating will invalidate your current token. Any applications using the old token will stop working.", "api.regenerate.button": "Regenerate token", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 3db03a59c..311b3a91a 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -325,7 +325,10 @@ "api.title": "", "api.description": "", "api.noAccess": "", - "api.tokenLabel": "", + "api.readTokenLabel": "", + "api.readTokenDescription": "", + "api.writeTokenLabel": "", + "api.writeTokenDescription": "", "api.revealButton": "", "api.regenerate.heading": "", "api.regenerate.button": "", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index b4aebb178..3079f897b 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -325,7 +325,10 @@ "api.title": "", "api.description": "", "api.noAccess": "", - "api.tokenLabel": "", + "api.readTokenLabel": "", + "api.readTokenDescription": "", + "api.writeTokenLabel": "", + "api.writeTokenDescription": "", "api.revealButton": "", "api.regenerate.heading": "", "api.regenerate.button": "", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index b56a11010..37095a0f5 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -325,7 +325,10 @@ "api.title": "", "api.description": "", "api.noAccess": "", - "api.tokenLabel": "", + "api.readTokenLabel": "", + "api.readTokenDescription": "", + "api.writeTokenLabel": "", + "api.writeTokenDescription": "", "api.revealButton": "", "api.regenerate.heading": "", "api.regenerate.button": "", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index f44d4ce3c..bca983487 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -325,7 +325,10 @@ "api.title": "", "api.description": "", "api.noAccess": "", - "api.tokenLabel": "", + "api.readTokenLabel": "", + "api.readTokenDescription": "", + "api.writeTokenLabel": "", + "api.writeTokenDescription": "", "api.revealButton": "", "api.regenerate.heading": "", "api.regenerate.button": "", diff --git a/locales/he/common.json b/locales/he/common.json index 42bf19f48..7bcc51eb9 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -324,7 +324,10 @@ "api.title": "", "api.description": "", "api.noAccess": "", - "api.tokenLabel": "", + "api.readTokenLabel": "", + "api.readTokenDescription": "", + "api.writeTokenLabel": "", + "api.writeTokenDescription": "", "api.revealButton": "", "api.regenerate.heading": "", "api.regenerate.button": "", diff --git a/locales/it/common.json b/locales/it/common.json index 9b4ef4828..13d481872 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -325,7 +325,10 @@ "api.title": "", "api.description": "", "api.noAccess": "", - "api.tokenLabel": "", + "api.readTokenLabel": "", + "api.readTokenDescription": "", + "api.writeTokenLabel": "", + "api.writeTokenDescription": "", "api.revealButton": "", "api.regenerate.heading": "", "api.regenerate.button": "", diff --git a/locales/ja/common.json b/locales/ja/common.json index 35bcca096..19db31e73 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -319,7 +319,10 @@ "api.title": "", "api.description": "", "api.noAccess": "", - "api.tokenLabel": "", + "api.readTokenLabel": "", + "api.readTokenDescription": "", + "api.writeTokenLabel": "", + "api.writeTokenDescription": "", "api.revealButton": "", "api.regenerate.heading": "", "api.regenerate.button": "", diff --git a/locales/ko/common.json b/locales/ko/common.json index dc1b8d1c5..e334d74cd 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -319,7 +319,10 @@ "api.title": "", "api.description": "", "api.noAccess": "", - "api.tokenLabel": "", + "api.readTokenLabel": "", + "api.readTokenDescription": "", + "api.writeTokenLabel": "", + "api.writeTokenDescription": "", "api.revealButton": "", "api.regenerate.heading": "", "api.regenerate.button": "", diff --git a/locales/nl/common.json b/locales/nl/common.json index f91da199b..7e9f238ea 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -323,7 +323,10 @@ "api.title": "", "api.description": "", "api.noAccess": "", - "api.tokenLabel": "", + "api.readTokenLabel": "", + "api.readTokenDescription": "", + "api.writeTokenLabel": "", + "api.writeTokenDescription": "", "api.revealButton": "", "api.regenerate.heading": "", "api.regenerate.button": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index b9663e093..38a3270ce 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -326,7 +326,10 @@ "api.title": "", "api.description": "", "api.noAccess": "", - "api.tokenLabel": "", + "api.readTokenLabel": "", + "api.readTokenDescription": "", + "api.writeTokenLabel": "", + "api.writeTokenDescription": "", "api.revealButton": "", "api.regenerate.heading": "", "api.regenerate.button": "", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 9a97d412b..63790cc4f 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -325,7 +325,10 @@ "api.title": "", "api.description": "", "api.noAccess": "", - "api.tokenLabel": "", + "api.readTokenLabel": "", + "api.readTokenDescription": "", + "api.writeTokenLabel": "", + "api.writeTokenDescription": "", "api.revealButton": "", "api.regenerate.heading": "", "api.regenerate.button": "", diff --git a/locales/ru/common.json b/locales/ru/common.json index e25c058c0..4c16133db 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -326,7 +326,10 @@ "api.title": "", "api.description": "", "api.noAccess": "", - "api.tokenLabel": "", + "api.readTokenLabel": "", + "api.readTokenDescription": "", + "api.writeTokenLabel": "", + "api.writeTokenDescription": "", "api.revealButton": "", "api.regenerate.heading": "", "api.regenerate.button": "", diff --git a/locales/zh/common.json b/locales/zh/common.json index 12a7b675a..52c2e7961 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -319,7 +319,10 @@ "api.title": "", "api.description": "", "api.noAccess": "", - "api.tokenLabel": "", + "api.readTokenLabel": "", + "api.readTokenDescription": "", + "api.writeTokenLabel": "", + "api.writeTokenDescription": "", "api.revealButton": "", "api.regenerate.heading": "", "api.regenerate.button": "", diff --git a/migrations/117-api-token-types.js b/migrations/117-api-token-types.js new file mode 100644 index 000000000..28f5f19a8 --- /dev/null +++ b/migrations/117-api-token-types.js @@ -0,0 +1,34 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /* sql */ ` + create table "ApiToken_new" ( + "id" integer primary key, + "token" text not null unique, + "userId" integer not null, + "type" text not null default 'read', + "createdAt" integer default (strftime('%s', 'now')) not null, + foreign key ("userId") references "User"("id") on delete cascade + ) strict + `, + ).run(); + + db.prepare( + /* sql */ ` + insert into "ApiToken_new" ("id", "token", "userId", "type", "createdAt") + select "id", "token", "userId", 'read', "createdAt" + from "ApiToken" + `, + ).run(); + + db.prepare(/* sql */ "drop table ApiToken").run(); + + db.prepare( + /* sql */ `alter table "ApiToken_new" rename to "ApiToken"`, + ).run(); + + db.prepare( + /* sql */ `create unique index api_token_user_id_type on "ApiToken"("userId", "type")`, + ).run(); + })(); +}