Skip to content

Commit

Permalink
feat: add api keys (#991)
Browse files Browse the repository at this point in the history
* feat: add api keys

* chore: address pull request feedback

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
  • Loading branch information
manuel-rw and Meierschlumpf authored Oct 5, 2024
1 parent ee83757 commit b14f82b
Show file tree
Hide file tree
Showing 22 changed files with 3,375 additions and 61 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"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({
async onSuccess(data) {
openModal({
apiKey: data.randomToken,
});
await revalidatePathActionAsync("/manage/tools/api");
},
});
const t = useScopedI18n("management.page.tool.api.tab.apiKey");

const columns = useMemo<MRT_ColumnDef<RouterOutputs["apiKeys"]["getAll"][number]>[]>(
() => [
{
accessorKey: "id",
header: t("table.header.id"),
},
{
accessorKey: "user",
header: t("table.header.createdBy"),
Cell: ({ row }) => (
<Group gap={"xs"}>
<UserAvatar user={row.original.user} size={"sm"} />
<Text>{row.original.user.name}</Text>
</Group>
),
},
],
[],
);

const table = useMantineReactTable({
columns,
data: apiKeys,
renderTopToolbarCustomActions: () => (
<Button
onClick={() => {
mutate();
}}
loading={isPending}
>
{t("button.createApiToken")}
</Button>
),
enableDensityToggle: false,
state: {
density: "xs",
},
});

return (
<Stack>
<Title>{t("title")}</Title>
<MantineReactTable table={table} />
</Stack>
);
};
Original file line number Diff line number Diff line change
@@ -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 (
<Stack>
<Text>{t("description")}</Text>
<PasswordInput value={innerProps.apiKey} visible={visible} onVisibilityChange={toggle} readOnly />
<CopyButton value={innerProps.apiKey}>
{({ copy }) => (
<Button
onClick={() => {
copy();
actions.closeModal();
}}
variant="default"
fullWidth
>
{t("button")}
</Button>
)}
</CopyButton>
</Stack>
);
}).withOptions({
defaultTitle(t) {
return t("management.page.tool.api.modal.createApiToken.title");
},
});
Original file line number Diff line number Diff line change
@@ -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<string, any>) => {
req.credentials = "omit";
return req;
};

return <SwaggerUI requestInterceptor={requestInterceptor} spec={document} />;
};
36 changes: 25 additions & 11 deletions apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -21,8 +18,25 @@ export async function generateMetadata() {
};
}

export default function ApiPage() {
export default async function ApiPage() {
const document = openApiDocument(extractBaseUrlFromHeaders(headers()));

return <SwaggerUI spec={document} />;
const apiKeys = await api.apiKeys.getAll();
const t = await getScopedI18n("management.page.tool.api.tab");

return (
<Stack>
<Tabs defaultValue={"documentation"}>
<TabsList>
<TabsTab value={"documentation"}>{t("documentation.label")}</TabsTab>
<TabsTab value={"authentication"}>{t("apiKey.label")}</TabsTab>
</TabsList>
<TabsPanel value={"authentication"}>
<ApiKeysManagement apiKeys={apiKeys} />
</TabsPanel>
<TabsPanel value={"documentation"}>
<SwaggerUIClient document={document} />
</TabsPanel>
</Tabs>
</Stack>
);
}
5 changes: 1 addition & 4 deletions apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 48 additions & 3 deletions apps/nextjs/src/app/api/[...trpc]/route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,59 @@
import { createOpenApiFetchHandler } from "trpc-swagger/build/index.mjs";

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 }),
});
};

const getSessionOrDefaultFromHeadersAsync = async (apiKeyHeaderValue: string | null): Promise<Session | null> => {
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 { handler as GET, handler as POST };
export { handlerAsync as GET, handlerAsync as POST };
8 changes: 8 additions & 0 deletions packages/api/src/open-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
});
2 changes: 2 additions & 0 deletions packages/api/src/root.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -31,6 +32,7 @@ export const appRouter = createTRPCRouter({
docker: dockerRouter,
serverSettings: serverSettingsRouter,
cronJobs: cronJobsRouter,
apiKeys: apiKeysRouter,
});

// export type definition of API
Expand Down
41 changes: 41 additions & 0 deletions packages/api/src/router/apiKeys.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}),
});
Loading

0 comments on commit b14f82b

Please sign in to comment.