From 201ed203e6c7ca86d1ab38dd9950b71f0a1c1e95 Mon Sep 17 00:00:00 2001 From: Manuel <30572287+manuel-rw@users.noreply.github.com> Date: Sun, 12 May 2024 16:30:03 +0200 Subject: [PATCH] feat: add api keys --- .../manage/tools/api/components/api-keys.tsx | 81 ++++++++++ .../api/components/copy-api-key-modal.tsx | 34 ++++ .../tools/api/components/swagger-ui.tsx | 23 +++ .../app/[locale]/manage/tools/api/page.tsx | 36 +++-- .../[locale]/manage/tools/api/swagger-ui.css | 5 +- apps/nextjs/src/app/api/[...trpc]/route.ts | 55 ++++++- packages/api/src/open-api.ts | 8 + packages/api/src/root.ts | 2 + packages/api/src/router/apiKeys.ts | 41 +++++ packages/api/src/router/app.ts | 145 +++++++++++++----- packages/api/src/router/user.ts | 4 +- packages/auth/callbacks.ts | 16 ++ packages/auth/server.ts | 1 + .../db/migrations/mysql/meta/_journal.json | 2 +- .../db/migrations/sqlite/meta/_journal.json | 2 +- packages/db/schema/mysql.ts | 18 +++ packages/db/schema/sqlite.ts | 18 +++ packages/translation/src/lang/en.ts | 29 ++++ 18 files changed, 456 insertions(+), 64 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/api/components/copy-api-key-modal.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/api/components/swagger-ui.tsx create mode 100644 packages/api/src/router/apiKeys.ts diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx new file mode 100644 index 0000000000..1bd4e4d4ae --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useMemo } from "react"; +import { Button, Group, Stack, Text, Title } from "@mantine/core"; +import type { MRT_ColumnDef } from "mantine-react-table"; +import { MantineReactTable, useMantineReactTable } from "mantine-react-table"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { revalidatePathActionAsync } from "@homarr/common/client"; +import { useModalAction } from "@homarr/modals"; +import { useScopedI18n } from "@homarr/translation/client"; +import { UserAvatar } from "@homarr/ui"; + +import { CopyApiKeyModal } from "~/app/[locale]/manage/tools/api/components/copy-api-key-modal"; + +interface ApiKeysManagementProps { + apiKeys: RouterOutputs["apiKeys"]["getAll"]; +} + +export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => { + const { openModal } = useModalAction(CopyApiKeyModal); + const { mutate, isPending } = clientApi.apiKeys.create.useMutation({ + onSettled: async (data) => { + if (!data) { + return; + } + openModal({ + apiKey: data.randomToken, + }); + await revalidatePathActionAsync("/manage/tools/api"); + }, + }); + const t = useScopedI18n("management.page.tool.api.tab.apiKey"); + + const columns = useMemo[]>( + () => [ + { + accessorKey: "id", + header: t("table.header.id"), + }, + { + accessorKey: "user", + header: t("table.header.createdBy"), + Cell: ({ row }) => ( + + + {row.original.user.name} + + ), + }, + ], + [], + ); + + const table = useMantineReactTable({ + columns, + data: apiKeys, + renderTopToolbarCustomActions: () => ( + + ), + enableDensityToggle: false, + state: { + density: "xs", + }, + }); + + return ( + + {t("title")} + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/copy-api-key-modal.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/copy-api-key-modal.tsx new file mode 100644 index 0000000000..4a3ba621d6 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/copy-api-key-modal.tsx @@ -0,0 +1,34 @@ +import { Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; + +import { createModal } from "@homarr/modals"; +import { useScopedI18n } from "@homarr/translation/client"; + +export const CopyApiKeyModal = createModal<{ apiKey: string }>(({ actions, innerProps }) => { + const t = useScopedI18n("management.page.tool.api.modal.createApiToken"); + const [visible, { toggle }] = useDisclosure(false); + return ( + + {t("description")} + + + {({ copy }) => ( + + )} + + + ); +}).withOptions({ + defaultTitle(t) { + return t("management.page.tool.api.modal.createApiToken.title"); + }, +}); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/swagger-ui.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/swagger-ui.tsx new file mode 100644 index 0000000000..476a37c5aa --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/swagger-ui.tsx @@ -0,0 +1,23 @@ +"use client"; + +import type { OpenAPIV3 } from "openapi-types"; +import SwaggerUI from "swagger-ui-react"; + +// workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045 +import "../swagger-ui-dark.css"; +import "../swagger-ui-overrides.css"; +import "../swagger-ui.css"; + +interface SwaggerUIClientProps { + document: OpenAPIV3.Document; +} + +export const SwaggerUIClient = ({ document }: SwaggerUIClientProps) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const requestInterceptor = (req: Record) => { + req.credentials = "omit"; + return req; + }; + + return ; +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx index 2c2c0f1da1..dbc451dfbc 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx @@ -1,17 +1,14 @@ -import { getScopedI18n } from "@homarr/translation/server"; - -// workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045 -import "./swagger-ui-dark.css"; -import "./swagger-ui-overrides.css"; -import "./swagger-ui.css"; - import { headers } from "next/headers"; -import SwaggerUI from "swagger-ui-react"; +import { Stack, Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core"; import { openApiDocument } from "@homarr/api"; +import { api } from "@homarr/api/server"; import { extractBaseUrlFromHeaders } from "@homarr/common"; +import { getScopedI18n } from "@homarr/translation/server"; +import { SwaggerUIClient } from "~/app/[locale]/manage/tools/api/components/swagger-ui"; import { createMetaTitle } from "~/metadata"; +import { ApiKeysManagement } from "./components/api-keys"; export async function generateMetadata() { const t = await getScopedI18n("management"); @@ -21,8 +18,25 @@ export async function generateMetadata() { }; } -export default function ApiPage() { +export default async function ApiPage() { const document = openApiDocument(extractBaseUrlFromHeaders(headers())); - - return ; + const apiKeys = await api.apiKeys.getAll(); + const t = await getScopedI18n("management.page.tool.api.tab"); + + return ( + + + + {t("documentation.label")} + {t("apiKey.label")} + + + + + + + + + + ); } diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui.css b/apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui.css index 0385684c17..e02e303074 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui.css +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui.css @@ -7146,9 +7146,6 @@ } .swagger-ui .wrapper { box-sizing: border-box; - margin: 0 auto; - max-width: 1460px; - padding: 0 20px; width: 100%; } .swagger-ui .opblock-tag-section { @@ -7734,7 +7731,7 @@ background: #fff; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.15); margin: 0 0 20px; - padding: 30px 0; + padding: 30px 20px; } .swagger-ui .scheme-container .schemes { align-items: flex-end; diff --git a/apps/nextjs/src/app/api/[...trpc]/route.ts b/apps/nextjs/src/app/api/[...trpc]/route.ts index e6abf77417..788172dc4f 100644 --- a/apps/nextjs/src/app/api/[...trpc]/route.ts +++ b/apps/nextjs/src/app/api/[...trpc]/route.ts @@ -1,14 +1,59 @@ -import { createOpenApiFetchHandler } from "trpc-swagger/build/index.mjs"; +import {createOpenApiFetchHandler} from "trpc-swagger/build/index.mjs"; -import { appRouter, createTRPCContext } from "@homarr/api"; +import {appRouter, createTRPCContext} from "@homarr/api"; +import type {Session} from "@homarr/auth"; +import {createSessionAsync} from "@homarr/auth/server"; +import {db, eq} from "@homarr/db"; +import {apiKeys} from "@homarr/db/schema/sqlite"; +import {logger} from "@homarr/log"; + +const handlerAsync = async (req: Request) => { + const apiKeyHeaderValue = req.headers.get("ApiKey"); + const session: Session | null = await getSessionOrDefaultFromHeadersAsync(apiKeyHeaderValue); -const handler = (req: Request) => { return createOpenApiFetchHandler({ req, endpoint: "/", router: appRouter, - createContext: () => createTRPCContext({ session: null, headers: req.headers }), + createContext: () => createTRPCContext({session, headers: req.headers}), }); }; -export { handler as GET, handler as POST }; +const getSessionOrDefaultFromHeadersAsync = async (apiKeyHeaderValue: string | null): Promise => { + logger.info( + `Creating OpenAPI fetch handler for user ${apiKeyHeaderValue ? "with an api key" : "without an api key"}`, + ); + + if (apiKeyHeaderValue === null) { + return null; + } + + const apiKeyFromDb = await db.query.apiKeys.findFirst({ + where: eq(apiKeys.apiKey, apiKeyHeaderValue), + columns: { + id: true, + apiKey: false, + salt: false, + }, + with: { + user: { + columns: { + id: true, + name: true, + email: true, + emailVerified: true, + }, + }, + }, + }); + + if (apiKeyFromDb === undefined) { + logger.warn(`An attempt to authenticate over API has failed`); + return null; + } + + logger.info(`Read session from API request and found user ${apiKeyFromDb.user.name} (${apiKeyFromDb.user.id})`); + return await createSessionAsync(db, apiKeyFromDb.user); +} + +export {handlerAsync as GET, handlerAsync as POST}; diff --git a/packages/api/src/open-api.ts b/packages/api/src/open-api.ts index 935c682e6e..02949c882d 100644 --- a/packages/api/src/open-api.ts +++ b/packages/api/src/open-api.ts @@ -8,4 +8,12 @@ export const openApiDocument = (base: string) => version: "1.0.0", baseUrl: base, docsUrl: "https://homarr.dev", + securitySchemes: { + apikey: { + type: "apiKey", + name: "ApiKey", + description: "API key which can be obtained in the Homarr administration dashboard", + in: "header", + }, + }, }); diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index a546487392..cd528106f5 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -1,3 +1,4 @@ +import { apiKeysRouter } from "./router/apiKeys"; import { appRouter as innerAppRouter } from "./router/app"; import { boardRouter } from "./router/board"; import { cronJobsRouter } from "./router/cron-jobs"; @@ -31,6 +32,7 @@ export const appRouter = createTRPCRouter({ docker: dockerRouter, serverSettings: serverSettingsRouter, cronJobs: cronJobsRouter, + apiKeys: apiKeysRouter, }); // export type definition of API diff --git a/packages/api/src/router/apiKeys.ts b/packages/api/src/router/apiKeys.ts new file mode 100644 index 0000000000..df5fd85d06 --- /dev/null +++ b/packages/api/src/router/apiKeys.ts @@ -0,0 +1,41 @@ +import { createSaltAsync, hashPasswordAsync } from "@homarr/auth"; +import { generateSecureRandomToken } from "@homarr/common/server"; +import { createId, db } from "@homarr/db"; +import { apiKeys } from "@homarr/db/schema/sqlite"; + +import { createTRPCRouter, permissionRequiredProcedure } from "../trpc"; + +export const apiKeysRouter = createTRPCRouter({ + getAll: permissionRequiredProcedure.requiresPermission("admin").query(() => { + return db.query.apiKeys.findMany({ + columns: { + id: true, + apiKey: false, + salt: false, + }, + with: { + user: { + columns: { + id: true, + name: true, + image: true, + }, + }, + }, + }); + }), + create: permissionRequiredProcedure.requiresPermission("admin").mutation(async ({ ctx }) => { + const salt = await createSaltAsync(); + const randomToken = generateSecureRandomToken(64); + const hashedRandomToken = await hashPasswordAsync(randomToken, salt); + await db.insert(apiKeys).values({ + id: createId(), + apiKey: hashedRandomToken, + salt, + userId: ctx.session.user.id, + }); + return { + randomToken, + }; + }), +}); diff --git a/packages/api/src/router/app.ts b/packages/api/src/router/app.ts index b6319625c2..0ef3c6b236 100644 --- a/packages/api/src/router/app.ts +++ b/packages/api/src/router/app.ts @@ -7,53 +7,114 @@ import { validation, z } from "@homarr/validation"; import { createTRPCRouter, publicProcedure } from "../trpc"; export const appRouter = createTRPCRouter({ - all: publicProcedure.query(async ({ ctx }) => { - return await ctx.db.query.apps.findMany({ - orderBy: asc(apps.name), - }); - }), - selectable: publicProcedure.query(async ({ ctx }) => { - return await ctx.db.query.apps.findMany({ - columns: { - id: true, - name: true, - iconUrl: true, - }, - orderBy: asc(apps.name), - }); - }), + all: publicProcedure + .input(z.void()) + .output( + z.array( + z.object({ + name: z.string(), + id: z.string(), + description: z.string().nullable(), + iconUrl: z.string(), + href: z.string().nullable(), + }), + ), + ) + .meta({ openapi: { method: "GET", path: "/api/apps", tags: ["apps"], protect: true } }) + .query(({ ctx }) => { + return ctx.db.query.apps.findMany({ + orderBy: asc(apps.name), + }); + }), search: publicProcedure .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) })) - .query(async ({ ctx, input }) => { + .output( + z.array( + z.object({ + name: z.string(), + id: z.string(), + description: z.string().nullable(), + iconUrl: z.string(), + href: z.string().nullable(), + }), + ), + ) + .meta({ openapi: { method: "GET", path: "/api/apps/search", tags: ["apps"], protect: true } }) + .query(({ ctx, input }) => { return await ctx.db.query.apps.findMany({ where: like(apps.name, `%${input.query}%`), orderBy: asc(apps.name), limit: input.limit, }); }), - byId: publicProcedure.input(validation.common.byId).query(async ({ ctx, input }) => { - const app = await ctx.db.query.apps.findFirst({ - where: eq(apps.id, input.id), - }); - - if (!app) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "App not found", + selectable: publicProcedure + .input(z.void()) + .output( + z.array( + z.object({ + name: z.string(), + id: z.string(), + iconUrl: z.string(), + }), + ), + ) + .meta({ + openapi: { + method: "GET", + path: "/api/apps/selectable", + tags: ["apps"], + protect: true, + }, + }) + .query(({ ctx }) => { + return ctx.db.query.apps.findMany({ + columns: { + id: true, + name: true, + iconUrl: true, + }, + orderBy: asc(apps.name), + }); + }), + byId: publicProcedure + .input(validation.common.byId) + .output( + z.object({ + name: z.string(), + id: z.string(), + description: z.string().nullable(), + iconUrl: z.string(), + href: z.string().nullable(), + }), + ) + .meta({ openapi: { method: "GET", path: "/api/apps/{id}", tags: ["apps"], protect: true } }) + .query(async ({ ctx, input }) => { + const app = await ctx.db.query.apps.findFirst({ + where: eq(apps.id, input.id), }); - } - return app; - }), - create: publicProcedure.input(validation.app.manage).mutation(async ({ ctx, input }) => { - await ctx.db.insert(apps).values({ - id: createId(), - name: input.name, - description: input.description, - iconUrl: input.iconUrl, - href: input.href, - }); - }), + if (!app) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "App not found", + }); + } + + return app; + }), + create: publicProcedure + .input(validation.app.manage) + .output(z.void()) + .meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } }) + .mutation(async ({ ctx, input }) => { + await ctx.db.insert(apps).values({ + id: createId(), + name: input.name, + description: input.description, + iconUrl: input.iconUrl, + href: input.href, + }); + }), update: publicProcedure.input(validation.app.edit).mutation(async ({ ctx, input }) => { const app = await ctx.db.query.apps.findFirst({ where: eq(apps.id, input.id), @@ -76,7 +137,11 @@ export const appRouter = createTRPCRouter({ }) .where(eq(apps.id, input.id)); }), - delete: publicProcedure.input(validation.common.byId).mutation(async ({ ctx, input }) => { - await ctx.db.delete(apps).where(eq(apps.id, input.id)); - }), + delete: publicProcedure + .output(z.void()) + .meta({ openapi: { method: "DELETE", path: "/api/apps/{id}", tags: ["apps"], protect: true } }) + .input(validation.common.byId) + .mutation(async ({ ctx, input }) => { + await ctx.db.delete(apps).where(eq(apps.id, input.id)); + }), }); diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 4d10a46523..7c8f9489e2 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -70,7 +70,7 @@ export const userRouter = createTRPCRouter({ await ctx.db.delete(invites).where(inviteWhere); }), create: publicProcedure - .meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"] } }) + .meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"], protect: true } }) .input(validation.user.create) .output(z.void()) .mutation(async ({ ctx, input }) => { @@ -143,7 +143,7 @@ export const userRouter = createTRPCRouter({ }), ), ) - .meta({ openapi: { method: "GET", path: "/api/users", tags: ["users"] } }) + .meta({ openapi: { method: "GET", path: "/api/users", tags: ["users"], protect: true } }) .query(({ ctx }) => { return ctx.db.query.users.findMany({ columns: { diff --git a/packages/auth/callbacks.ts b/packages/auth/callbacks.ts index 35152cdc08..655b2db89b 100644 --- a/packages/auth/callbacks.ts +++ b/packages/auth/callbacks.ts @@ -3,6 +3,7 @@ import type { Adapter } from "@auth/core/adapters"; import dayjs from "dayjs"; import type { NextAuthConfig } from "next-auth"; +import type { Session } from "@homarr/auth"; import type { Database } from "@homarr/db"; import { eq, inArray } from "@homarr/db"; import { groupMembers, groupPermissions, users } from "@homarr/db/schema/sqlite"; @@ -30,6 +31,21 @@ export const getCurrentUserPermissionsAsync = async (db: Database, userId: strin return getPermissionsWithChildren(permissionKeys); }; +export const createSessionAsync = async ( + db: Database, + user: { id: string; email: string | null }, +): Promise => { + return { + expires: dayjs().add(1, "day").toISOString(), + user: { + ...user, + email: user.email ?? "", + permissions: await getCurrentUserPermissionsAsync(db, user.id), + colorScheme: "auto", + }, + } as Session; +}; + export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session"> => { return async ({ session, user }) => { const additionalProperties = await db.query.users.findFirst({ diff --git a/packages/auth/server.ts b/packages/auth/server.ts index 903902f62d..913086ece1 100644 --- a/packages/auth/server.ts +++ b/packages/auth/server.ts @@ -1,3 +1,4 @@ export { hasQueryAccessToIntegrationsAsync } from "./permissions/integration-query-permissions"; export { getIntegrationsWithPermissionsAsync } from "./permissions/integrations-with-permissions"; export { isProviderEnabled } from "./providers/check-provider"; +export { createSessionCallback, createSessionAsync } from "./callbacks"; diff --git a/packages/db/migrations/mysql/meta/_journal.json b/packages/db/migrations/mysql/meta/_journal.json index 59bfad2ae2..c5624d6c70 100644 --- a/packages/db/migrations/mysql/meta/_journal.json +++ b/packages/db/migrations/mysql/meta/_journal.json @@ -66,4 +66,4 @@ "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/migrations/sqlite/meta/_journal.json b/packages/db/migrations/sqlite/meta/_journal.json index edfca232c1..c8d9b9a497 100644 --- a/packages/db/migrations/sqlite/meta/_journal.json +++ b/packages/db/migrations/sqlite/meta/_journal.json @@ -66,4 +66,4 @@ "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts index 64fe30cc0b..776e7c3e4e 100644 --- a/packages/db/schema/mysql.ts +++ b/packages/db/schema/mysql.ts @@ -19,6 +19,17 @@ import type { } from "@homarr/definitions"; import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions"; +export const apiKeys = mysqlTable("apiKey", { + id: varchar("id", { length: 64 }).notNull().primaryKey(), + apiKey: text("apiKey").notNull(), + salt: text("salt").notNull(), + userId: varchar("userId", { length: 64 }) + .notNull() + .references((): AnyMySqlColumn => users.id, { + onDelete: "cascade", + }), +}); + export const users = mysqlTable("user", { id: varchar("id", { length: 64 }).notNull().primaryKey(), name: text("name"), @@ -341,6 +352,13 @@ export const serverSettings = mysqlTable("serverSetting", { value: text("value").default('{"json": {}}').notNull(), // empty superjson object }); +export const apiKeyRelations = relations(apiKeys, ({ one }) => ({ + user: one(users, { + fields: [apiKeys.userId], + references: [users.id], + }), +})); + export const searchEngines = mysqlTable("search_engine", { id: varchar("id", { length: 64 }).notNull().primaryKey(), iconUrl: text("icon_url").notNull(), diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts index eebd751144..f8fefbfbfc 100644 --- a/packages/db/schema/sqlite.ts +++ b/packages/db/schema/sqlite.ts @@ -20,6 +20,17 @@ import type { WidgetKind, } from "@homarr/definitions"; +export const apiKeys = sqliteTable("apiKey", { + id: text("id").notNull().primaryKey(), + apiKey: text("apiKey").notNull(), + salt: text("salt").notNull(), + userId: text("userId") + .notNull() + .references((): AnySQLiteColumn => users.id, { + onDelete: "cascade", + }), +}); + export const users = sqliteTable("user", { id: text("id").notNull().primaryKey(), name: text("name"), @@ -343,6 +354,13 @@ export const serverSettings = sqliteTable("serverSetting", { value: text("value").default('{"json": {}}').notNull(), // empty superjson object }); +export const apiKeyRelations = relations(apiKeys, ({ one }) => ({ + user: one(users, { + fields: [apiKeys.userId], + references: [users.id], + }), +})); + export const searchEngines = sqliteTable("search_engine", { id: text("id").notNull().primaryKey(), iconUrl: text("icon_url").notNull(), diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index 631996bc32..cef9f692f1 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -1887,6 +1887,35 @@ export default { }, }, }, + api: { + title: "API", + modal: { + createApiToken: { + title: "API token created", + description: + "API token was created. Be careful, this token is encrypted in the database and will never be transferred again to you. If you loose this token, you'll no longer be able to retrieve this specific token.", + button: "Copy and close", + }, + }, + tab: { + documentation: { + label: "Documentation", + }, + apiKey: { + label: "Authentication", + title: "API Keys", + button: { + createApiToken: "Create API token", + }, + table: { + header: { + id: "ID", + createdBy: "Created by" + } + } + }, + }, + }, }, about: { version: "Version {version}",