feat: add api keys (#991)
* feat: add api keys * chore: address pull request feedback --------- Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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()));
|
||||
const apiKeys = await api.apiKeys.getAll();
|
||||
const t = await getScopedI18n("management.page.tool.api.tab");
|
||||
|
||||
return <SwaggerUI spec={document} />;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
};
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
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 { handlerAsync as GET, handlerAsync as POST };
|
||||
|
||||
Reference in New Issue
Block a user