feature: add trpc openapi (#1818)

This commit is contained in:
Manuel
2024-01-14 22:20:51 +01:00
committed by GitHub
parent 33da630db5
commit c701f723cf
18 changed files with 2177 additions and 134 deletions

View File

@@ -83,6 +83,7 @@
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"drizzle-kit": "^0.19.13", "drizzle-kit": "^0.19.13",
"drizzle-orm": "^0.28.6", "drizzle-orm": "^0.28.6",
"drizzle-zod": "^0.5.1",
"fily-publish-gridstack": "^0.0.13", "fily-publish-gridstack": "^0.0.13",
"flag-icons": "^6.9.2", "flag-icons": "^6.9.2",
"framer-motion": "^10.0.0", "framer-motion": "^10.0.0",
@@ -97,6 +98,7 @@
"next": "13.4.12", "next": "13.4.12",
"next-auth": "^4.23.0", "next-auth": "^4.23.0",
"next-i18next": "^14.0.0", "next-i18next": "^14.0.0",
"nextjs-cors": "^2.2.0",
"nzbget-api": "^0.0.3", "nzbget-api": "^0.0.3",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"react": "^18.2.0", "react": "^18.2.0",
@@ -105,6 +107,8 @@
"react-simple-code-editor": "^0.13.1", "react-simple-code-editor": "^0.13.1",
"rss-parser": "^3.12.0", "rss-parser": "^3.12.0",
"sabnzbd-api": "^1.5.0", "sabnzbd-api": "^1.5.0",
"swagger-ui-react": "^5.11.0",
"trpc-openapi": "^1.2.0",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"xml-js": "^1.6.11", "xml-js": "^1.6.11",
"xss": "^1.0.14", "xss": "^1.0.14",
@@ -122,6 +126,7 @@
"@types/node": "18.17.8", "@types/node": "18.17.8",
"@types/prismjs": "^1.26.0", "@types/prismjs": "^1.26.0",
"@types/react": "^18.2.11", "@types/react": "^18.2.11",
"@types/swagger-ui-react": "^4.18.3",
"@types/umami": "^0.1.4", "@types/umami": "^0.1.4",
"@types/uuid": "^9.0.0", "@types/uuid": "^9.0.0",
"@types/video.js": "^7.3.51", "@types/video.js": "^7.3.51",

View File

@@ -25,7 +25,8 @@
"tools": { "tools": {
"title": "Tools", "title": "Tools",
"items": { "items": {
"docker": "Docker" "docker": "Docker",
"api": "API"
} }
}, },
"about": { "about": {

View File

@@ -1,24 +1,23 @@
import { ActionIcon, Badge, Box, Group, Title, Text, Tooltip, Button } from '@mantine/core'; import { Badge, Box, Button, Group, Text, Title } from '@mantine/core';
import { openRoleChangeModal } from '~/components/Manage/User/change-user-role.modal'; import { openRoleChangeModal } from '~/components/Manage/User/change-user-role.modal';
import { IconUserDown, IconUserUp } from '@tabler/icons-react'; import { IconUserDown, IconUserUp } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { createSelectSchema } from 'drizzle-zod';
import { users } from '~/server/db/schema';
import { z } from 'zod';
const userWithoutSecrets = createSelectSchema(users).omit({
password: true,
salt: true,
});
export const ManageUserRoles = ({ user }: { export const ManageUserRoles = ({ user }: {
user: { user: z.infer<typeof userWithoutSecrets>
image: string | null;
id: string;
name: string | null;
password: string | null;
email: string | null;
emailVerified: Date | null;
salt: string | null;
isAdmin: boolean;
isOwner: boolean;
}
}) => { }) => {
const { t } = useTranslation(['manage/users/edit', 'manage/users']); const { t } = useTranslation(['manage/users/edit', 'manage/users']);
const { data: sessionData } = useSession(); const { data: sessionData } = useSession();
return ( return (
<Box maw={500}> <Box maw={500}>
<Title order={3}> <Title order={3}>
@@ -33,7 +32,7 @@ export const ManageUserRoles = ({ user }: {
{user.isAdmin ? ( {user.isAdmin ? (
<Button <Button
leftIcon={<IconUserDown size='1rem' />} leftIcon={<IconUserDown size="1rem" />}
disabled={user.id === sessionData?.user?.id || user.isOwner} disabled={user.id === sessionData?.user?.id || user.isOwner}
onClick={() => { onClick={() => {
openRoleChangeModal({ openRoleChangeModal({
@@ -47,7 +46,7 @@ export const ManageUserRoles = ({ user }: {
</Button> </Button>
) : ( ) : (
<Button <Button
leftIcon={<IconUserUp size='1rem' />} leftIcon={<IconUserUp size="1rem" />}
onClick={() => { onClick={() => {
openRoleChangeModal({ openRoleChangeModal({
name: user.name as string, name: user.name as string,

View File

@@ -8,10 +8,8 @@ import {
Indicator, Indicator,
NavLink, NavLink,
Navbar, Navbar,
Paper,
Text, Text,
ThemeIcon, ThemeIcon,
useMantineTheme,
} from '@mantine/core'; } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { import {
@@ -21,10 +19,9 @@ import {
IconBrandGithub, IconBrandGithub,
IconGitFork, IconGitFork,
IconHome, IconHome,
IconInfoCircle,
IconInfoSmall, IconInfoSmall,
IconLayoutDashboard, IconLayoutDashboard,
IconMailForward, IconMailForward, IconPlug,
IconQuestionMark, IconQuestionMark,
IconTool, IconTool,
IconUser, IconUser,
@@ -104,6 +101,10 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => {
icon: IconBrandDocker, icon: IconBrandDocker,
href: '/manage/tools/docker', href: '/manage/tools/docker',
}, },
api: {
icon: IconPlug,
href: '/manage/tools/swagger'
}
}, },
}, },
help: { help: {

View File

@@ -0,0 +1,22 @@
import { NextApiRequest, NextApiResponse } from 'next';
import cors from 'nextjs-cors';
import { createOpenApiNextHandler } from 'trpc-openapi';
import { createTRPCContext } from '~/server/api/trpc';
import { rootRouter } from '~/server/api/root';
import Consola from 'consola';
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
// Setup CORS
await cors(req, res);
// Handle incoming OpenAPI requests
return createOpenApiNextHandler({
router: rootRouter,
createContext: createTRPCContext,
onError({ error, path }) {
Consola.error(`tRPC OpenAPI error on ${path}: ${error}`);
}
})(req, res);
};
export default handler;

View File

@@ -0,0 +1,9 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { openApiDocument } from '~/server/openai';
// Respond with our OpenAPI schema
const handler = (req: NextApiRequest, res: NextApiResponse) => {
res.status(200).send(openApiDocument);
};
export default handler;

View File

@@ -0,0 +1,93 @@
import { GetServerSidePropsContext, NextPage } from 'next';
import dynamic from 'next/dynamic';
import 'swagger-ui-react/swagger-ui.css';
import React, { useEffect } from 'react';
import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import Head from 'next/head';
import { ActionIcon, Button, Group, Text, TextInput, Title, Tooltip, useMantineTheme } from '@mantine/core';
import { IconCopy, IconLockAccess } from '@tabler/icons-react';
import { useClipboard, useDisclosure } from '@mantine/hooks';
import Cookies from 'cookies';
import { getServerAuthSession } from '~/server/auth';
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
const SwaggerUI = dynamic(() => import('swagger-ui-react'), { ssr: false });
const SwaggerApiPage = ({ authenticationToken }: { authenticationToken: string }) => {
const [accessTokenRevealed, { toggle: toggleAccessTokenReveal, close: hideAccessToken }] = useDisclosure(false);
const clipboard = useClipboard({ timeout: 2500 });
useEffect(() => {
if (clipboard.copied) {
return;
}
hideAccessToken();
}, [clipboard.copied]);
const theme = useMantineTheme();
return <ManageLayout>
<Head>
<title>API Homarr</title>
</Head>
<Title mb={'md'}>API</Title>
<Text mb={'xl'}>Advanced users can use the API to interface with Homarr. The documentation is completely local,
interactive
and complies with the Open API standard. Any compatible client can import for easy usage.</Text>
<Group>
<Button onClick={toggleAccessTokenReveal} leftIcon={<IconLockAccess size={'1rem'} />} variant={'light'}>
Show your personal access token
</Button>
{accessTokenRevealed && (
<TextInput
rightSection={
<Tooltip opened={clipboard.copied} label={"Copied"}>
<ActionIcon
onClick={() => {
clipboard.copy(authenticationToken);
}}>
<IconCopy size={'1rem'} />
</ActionIcon>
</Tooltip>}
value={authenticationToken} />
)}
</Group>
<div data-color-scheme={theme.colorScheme} className={"open-api-container"}>
<SwaggerUI url="/api/openapi.json" />
</div>
</ManageLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getServerAuthSession(ctx);
const result = checkForSessionOrAskForLogin(ctx, session, () => true);
if (result) {
return result;
}
// Create a cookies instance
const cookies = new Cookies(ctx.req, ctx.res);
const authenticationToken = cookies.get('next-auth.session-token');
return {
props: {
authenticationToken: authenticationToken,
...(await getServerSideTranslations(
['layout/manage', 'manage/index'],
ctx.locale,
ctx.req,
ctx.res,
)),
},
};
}
export default SwaggerApiPage;

View File

@@ -21,7 +21,7 @@ const EditPage = () => {
const router = useRouter(); const router = useRouter();
const { isLoading, data } = api.user.details.useQuery({ userId: router.query.userId as string }); const { data } = api.user.details.useQuery({ userId: router.query.userId as string });
const metaTitle = `${t('metaTitle', { const metaTitle = `${t('metaTitle', {
username: data?.name, username: data?.name,

View File

@@ -11,12 +11,18 @@ import * as https from 'https';
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
ping: publicProcedure ping: publicProcedure
.meta({ openapi: { method: 'GET', path: '/app/ping', tags: ['app'] } })
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
configName: z.string(), configName: z.string(),
}) }),
) )
.output(z.object({
status: z.number(),
statusText: z.string(),
state: z.string()
}))
.query(async ({ input }) => { .query(async ({ input }) => {
const config = getConfig(input.configName); const config = getConfig(input.configName);
const app = config.apps.find((app) => app.id === input.id); const app = config.apps.find((app) => app.id === input.id);
@@ -62,7 +68,7 @@ export const appRouter = createTRPCRouter({
if (error.code === 'ECONNABORTED') { if (error.code === 'ECONNABORTED') {
Consola.error( Consola.error(
`Ping timed out for app with id '${input.id}' in config '${input.configName}' -> url: ${app.url})` `Ping timed out for app with id '${input.id}' in config '${input.configName}' -> url: ${app.url})`,
); );
throw new TRPCError({ throw new TRPCError({
code: 'TIMEOUT', code: 'TIMEOUT',

View File

@@ -13,30 +13,43 @@ import { writeConfig } from '~/tools/config/writeConfig';
import { configNameSchema } from '~/validations/boards'; import { configNameSchema } from '~/validations/boards';
export const boardRouter = createTRPCRouter({ export const boardRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => { all: protectedProcedure
const files = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json')); .meta({ openapi: { method: 'GET', path: '/boards/all', tags: ['board'] } })
.input(z.void())
.output(z.array(z.object({
name: z.string(),
allowGuests: z.boolean(),
countApps: z.number().min(0),
countWidgets: z.number().min(0),
countCategories: z.number().min(0),
isDefaultForUser: z.boolean(),
})))
.query(async ({ ctx }) => {
const files = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json'));
const defaultBoard = await getDefaultBoardAsync(ctx.session.user.id, 'default'); const defaultBoard = await getDefaultBoardAsync(ctx.session.user.id, 'default');
return await Promise.all( return await Promise.all(
files.map(async (file) => { files.map(async (file) => {
const name = file.replace('.json', ''); const name = file.replace('.json', '');
const config = await getFrontendConfig(name); const config = await getFrontendConfig(name);
const countApps = config.apps.length; const countApps = config.apps.length;
return { return {
name: name, name: name,
allowGuests: config.settings.access.allowGuests, allowGuests: config.settings.access.allowGuests,
countApps: countApps, countApps: countApps,
countWidgets: config.widgets.length, countWidgets: config.widgets.length,
countCategories: config.categories.length, countCategories: config.categories.length,
isDefaultForUser: name === defaultBoard, isDefaultForUser: name === defaultBoard,
}; };
}), }),
); );
}), }),
addAppsForContainers: adminProcedure addAppsForContainers: adminProcedure
.meta({ openapi: { method: 'POST', path: '/boards/add-apps', tags: ['board'] } })
.output(z.void())
.input( .input(
z.object({ z.object({
boardName: configNameSchema, boardName: configNameSchema,
@@ -89,10 +102,12 @@ export const boardRouter = createTRPCRouter({
fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8'); fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8');
}), }),
renameBoard: protectedProcedure renameBoard: protectedProcedure
.meta({ openapi: { method: 'PUT', path: '/boards/rename', tags: ['board'] } })
.input(z.object({ .input(z.object({
oldName: z.string(), oldName: z.string(),
newName: z.string().min(1), newName: z.string().min(1),
})) }))
.output(z.void())
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
if (input.oldName === 'default') { if (input.oldName === 'default') {
Consola.error(`Attempted to rename default configuration. Aborted deletion.`); Consola.error(`Attempted to rename default configuration. Aborted deletion.`);
@@ -127,9 +142,11 @@ export const boardRouter = createTRPCRouter({
Consola.info(`Deleted ${input.oldName} from file system`); Consola.info(`Deleted ${input.oldName} from file system`);
}), }),
duplicateBoard: protectedProcedure duplicateBoard: protectedProcedure
.meta({ openapi: { method: 'POST', path: '/boards/duplicate', tags: ['board'] } })
.input(z.object({ .input(z.object({
boardName: z.string(), boardName: z.string(),
})) }))
.output(z.void())
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
if (!configExists(input.boardName)) { if (!configExists(input.boardName)) {
Consola.error(`Tried to duplicate ${input.boardName} but this configuration does not exist.`); Consola.error(`Tried to duplicate ${input.boardName} but this configuration does not exist.`);

View File

@@ -15,11 +15,13 @@ import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc';
export const configRouter = createTRPCRouter({ export const configRouter = createTRPCRouter({
delete: adminProcedure delete: adminProcedure
.meta({ openapi: { method: 'DELETE', path: '/configs', tags: ['config'] } })
.input( .input(
z.object({ z.object({
name: configNameSchema, name: configNameSchema,
}), }),
) )
.output(z.object({ message: z.string() }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
if (input.name.toLowerCase() === 'default') { if (input.name.toLowerCase() === 'default') {
Consola.error('Rejected config deletion because default configuration can\'t be deleted'); Consola.error('Rejected config deletion because default configuration can\'t be deleted');
@@ -160,11 +162,21 @@ export const configRouter = createTRPCRouter({
}; };
}), }),
byName: publicProcedure byName: publicProcedure
.meta({
openapi: {
method: 'GET',
path: '/configs/byName',
tags: ['config'],
deprecated: true,
summary: 'Retrieve content of the JSON configuration. Deprecated because JSON will be removed in a future version and be replaced with a relational database.'
}
})
.input( .input(
z.object({ z.object({
name: configNameSchema, name: configNameSchema,
}), }),
) )
.output(z.custom<ConfigType>())
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
if (!configExists(input.name)) { if (!configExists(input.name)) {
throw new TRPCError({ throw new TRPCError({
@@ -177,6 +189,7 @@ export const configRouter = createTRPCRouter({
}), }),
saveCustomization: adminProcedure saveCustomization: adminProcedure
.input(boardCustomizationSchema.and(z.object({ name: configNameSchema }))) .input(boardCustomizationSchema.and(z.object({ name: configNameSchema })))
.output(z.void())
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const previousConfig = getConfig(input.name); const previousConfig = getConfig(input.name);
const newConfig = { const newConfig = {

View File

@@ -4,35 +4,40 @@ import { LocalIconsRepository } from '~/tools/server/images/local-icons-reposito
import { UnpkgIconsRepository } from '~/tools/server/images/unpkg-icons-repository'; import { UnpkgIconsRepository } from '~/tools/server/images/unpkg-icons-repository';
import { createTRPCRouter, publicProcedure } from '../trpc'; import { createTRPCRouter, publicProcedure } from '../trpc';
import { z } from 'zod';
import { NormalizedIconRepositoryResult } from '~/tools/server/images/abstract-icons-repository';
export const IconRespositories = [ export const IconRespositories = [
new LocalIconsRepository(), new LocalIconsRepository(),
new GitHubIconsRepository( new GitHubIconsRepository(
GitHubIconsRepository.walkxcode, GitHubIconsRepository.walkxcode,
'Walkxcode Dashboard Icons', 'Walkxcode Dashboard Icons',
'Walkxcode on Github' 'Walkxcode on Github',
), ),
new UnpkgIconsRepository( new UnpkgIconsRepository(
UnpkgIconsRepository.tablerRepository, UnpkgIconsRepository.tablerRepository,
'Tabler Icons', 'Tabler Icons',
'Tabler Icons - GitHub (MIT)' 'Tabler Icons - GitHub (MIT)',
), ),
new JsdelivrIconsRepository( new JsdelivrIconsRepository(
JsdelivrIconsRepository.papirusRepository, JsdelivrIconsRepository.papirusRepository,
'Papirus Icons', 'Papirus Icons',
'Papirus Development Team on GitHub (Apache 2.0)' 'Papirus Development Team on GitHub (Apache 2.0)',
), ),
new JsdelivrIconsRepository( new JsdelivrIconsRepository(
JsdelivrIconsRepository.homelabSvgAssetsRepository, JsdelivrIconsRepository.homelabSvgAssetsRepository,
'Homelab Svg Assets', 'Homelab Svg Assets',
'loganmarchione on GitHub (MIT)' 'loganmarchione on GitHub (MIT)',
), ),
]; ];
export const iconRouter = createTRPCRouter({ export const iconRouter = createTRPCRouter({
all: publicProcedure.query(async () => { all: publicProcedure
const fetches = IconRespositories.map((rep) => rep.fetch()); .meta({ openapi: { method: 'GET', path: '/icons', tags: ['icon'] } })
const data = await Promise.all(fetches); .input(z.void())
return data; .output(z.array(z.custom<NormalizedIconRepositoryResult>()))
}), .query(async () => {
const fetches = IconRespositories.map((rep) => rep.fetch());
return await Promise.all(fetches);
}),
}); });

View File

@@ -5,17 +5,26 @@ import { z } from 'zod';
import { db } from '~/server/db'; import { db } from '~/server/db';
import { invites } from '~/server/db/schema'; import { invites } from '~/server/db/schema';
import { adminProcedure, createTRPCRouter, publicProcedure } from '../../trpc'; import { adminProcedure, createTRPCRouter } from '../../trpc';
export const inviteRouter = createTRPCRouter({ export const inviteRouter = createTRPCRouter({
all: adminProcedure all: adminProcedure
.meta({ openapi: { method: 'GET', path: '/invites', tags: ['invite'] } })
.input( .input(
z.object({ z.object({
limit: z.number().min(1).max(100).default(10), limit: z.number().min(1).max(100).default(10),
page: z.number().min(0), page: z.number().min(0),
}) }),
) )
.query(async ({ ctx, input }) => { .output(z.object({
invites: z.array(z.object({
id: z.string(),
expires: z.date(),
creator: z.string().or(z.null()),
})),
countPages: z.number().min(0),
}))
.query(async ({ input }) => {
const limit = input.limit; const limit = input.limit;
const dbInvites = await db.query.invites.findMany({ const dbInvites = await db.query.invites.findMany({
limit: limit, limit: limit,
@@ -44,14 +53,20 @@ export const inviteRouter = createTRPCRouter({
}; };
}), }),
create: adminProcedure create: adminProcedure
.meta({ openapi: { method: 'POST', path: '/invites', tags: ['invite'] } })
.input( .input(
z.object({ z.object({
expiration: z expiration: z
.date() .date()
.min(dayjs().add(5, 'minutes').toDate()) .min(dayjs().add(5, 'minutes').toDate())
.max(dayjs().add(6, 'months').toDate()), .max(dayjs().add(6, 'months').toDate()),
}) }),
) )
.output(z.object({
id: z.string(),
token: z.string(),
expires: z.date(),
}))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const inviteToInsert = { const inviteToInsert = {
id: randomUUID(), id: randomUUID(),
@@ -67,7 +82,11 @@ export const inviteRouter = createTRPCRouter({
expires: inviteToInsert.expires, expires: inviteToInsert.expires,
}; };
}), }),
delete: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ input }) => { delete: adminProcedure
await db.delete(invites).where(eq(invites.id, input.id)); .meta({ openapi: { method: 'DELETE', path: '/invites', tags: ['invite'] } })
}), .input(z.object({ id: z.string() }))
.output(z.void())
.mutation(async ({ input }) => {
await db.delete(invites).where(eq(invites.id, input.id));
}),
}); });

View File

@@ -22,6 +22,7 @@ import {
updateSettingsValidationSchema, updateSettingsValidationSchema,
} from '~/validations/user'; } from '~/validations/user';
import { PossibleRoleFilter } from '~/pages/manage/users'; import { PossibleRoleFilter } from '~/pages/manage/users';
import { createSelectSchema } from 'drizzle-zod';
export const userRouter = createTRPCRouter({ export const userRouter = createTRPCRouter({
createOwnerAccount: publicProcedure.input(signUpFormSchema).mutation(async ({ ctx, input }) => { createOwnerAccount: publicProcedure.input(signUpFormSchema).mutation(async ({ ctx, input }) => {
@@ -41,6 +42,7 @@ export const userRouter = createTRPCRouter({
}); });
}), }),
updatePassword: adminProcedure updatePassword: adminProcedure
.meta({ openapi: { method: 'PUT', path: '/users/password', tags: ['user'] } })
.input( .input(
z.object({ z.object({
userId: z.string(), userId: z.string(),
@@ -48,6 +50,7 @@ export const userRouter = createTRPCRouter({
terminateExistingSessions: z.boolean(), terminateExistingSessions: z.boolean(),
}), }),
) )
.output(z.void())
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: eq(users.id, input.userId), where: eq(users.id, input.userId),
@@ -81,9 +84,13 @@ export const userRouter = createTRPCRouter({
}) })
.where(eq(users.id, input.userId)); .where(eq(users.id, input.userId));
}), }),
count: publicProcedure.query(async () => { count: publicProcedure
return await getTotalUserCountAsync(); .meta({ openapi: { method: 'GET', path: '/users/count', tags: ['user'] } })
}), .input(z.void())
.output(z.number())
.query(async () => {
return await getTotalUserCountAsync();
}),
createFromInvite: publicProcedure createFromInvite: publicProcedure
.input( .input(
signUpFormSchema.and( signUpFormSchema.and(
@@ -133,7 +140,9 @@ export const userRouter = createTRPCRouter({
.where(eq(userSettings.userId, ctx.session?.user?.id)); .where(eq(userSettings.userId, ctx.session?.user?.id));
}), }),
changeRole: adminProcedure changeRole: adminProcedure
.meta({ openapi: { method: 'PUT', path: '/users/roles', tags: ['user'] } })
.input(z.object({ id: z.string(), type: z.enum(['promote', 'demote']) })) .input(z.object({ id: z.string(), type: z.enum(['promote', 'demote']) }))
.output(z.void())
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
if (ctx.session?.user?.id === input.id) { if (ctx.session?.user?.id === input.id) {
throw new TRPCError({ throw new TRPCError({
@@ -166,11 +175,13 @@ export const userRouter = createTRPCRouter({
.where(eq(users.id, input.id)); .where(eq(users.id, input.id));
}), }),
changeLanguage: protectedProcedure changeLanguage: protectedProcedure
.meta({ openapi: { method: 'PUT', path: '/users/language', tags: ['user'] } })
.input( .input(
z.object({ z.object({
language: z.string(), language: z.string(),
}), }),
) )
.output(z.void())
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await db await db
.update(userSettings) .update(userSettings)
@@ -218,6 +229,8 @@ export const userRouter = createTRPCRouter({
}), }),
makeDefaultDashboard: protectedProcedure makeDefaultDashboard: protectedProcedure
.meta({ openapi: { method: 'POST', path: '/users/make-default-dashboard', tags: ['user'] } })
.output(z.void())
.input(z.object({ board: z.string() })) .input(z.object({ board: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await db await db
@@ -243,7 +256,20 @@ export const userRouter = createTRPCRouter({
}), }),
}), }),
) )
.query(async ({ ctx, input }) => { .output(z.object({
users: z.array(z.object({
id: z.string(),
name: z.string(),
email: z.string().or(z.null()).optional(),
isAdmin: z.boolean(),
isOwner: z.boolean(),
})),
countPages: z.number().min(0),
stats: z.object({
roles: z.record(z.number()),
}),
}))
.query(async ({ input }) => {
const roleFilter = () => { const roleFilter = () => {
if (input.search.role === PossibleRoleFilter[1].id) { if (input.search.role === PossibleRoleFilter[1].id) {
@@ -309,30 +335,54 @@ export const userRouter = createTRPCRouter({
}, },
}; };
}), }),
create: adminProcedure.input(createNewUserSchema).mutation(async ({ input }) => { create: adminProcedure
await createUserIfNotPresent(input); .meta({ openapi: { method: 'POST', path: '/users', tags: ['user'] } })
}), .input(createNewUserSchema)
details: adminProcedure.input(z.object({ userId: z.string() })).query(async ({ input }) => { .output(z.void())
return db.query.users.findFirst({ .mutation(async ({ input }) => {
where: eq(users.id, input.userId), await createUserIfNotPresent(input);
}); }),
}), details: adminProcedure
updateDetails: adminProcedure.input(z.object({ .meta({ openapi: { method: 'GET', path: '/users/getById', tags: ['user'] } })
userId: z.string(), .input(z.object({ userId: z.string() }))
username: z.string(), .output(
eMail: z.string().optional().transform(value => value?.length === 0 ? null : value), createSelectSchema(users)
})).mutation(async ({ input }) => { .omit({
await db.update(users).set({ password: true,
name: input.username, salt: true,
email: input.eMail as string | null, })
}).where(eq(users.id, input.userId)); .optional())
}), .query(async ({ input }) => {
return db.query.users.findFirst({
where: eq(users.id, input.userId),
columns: {
password: false,
salt: false,
},
});
}),
updateDetails: adminProcedure
.meta({ openapi: { method: 'PUT', path: '/users/details', tags: ['user'] } })
.input(z.object({
userId: z.string(),
username: z.string(),
eMail: z.string().optional().transform(value => value?.length === 0 ? null : value),
}))
.output(z.void())
.mutation(async ({ input }) => {
await db.update(users).set({
name: input.username,
email: input.eMail as string | null,
}).where(eq(users.id, input.userId));
}),
deleteUser: adminProcedure deleteUser: adminProcedure
.meta({ openapi: { method: 'DELETE', path: '/users', tags: ['user'] } })
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
}), }),
) )
.output(z.void())
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: eq(users.id, input.id), where: eq(users.id, input.id),

View File

@@ -13,6 +13,7 @@ import superjson from 'superjson';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
import { getServerAuthSession } from '../auth'; import { getServerAuthSession } from '../auth';
import { OpenApiMeta } from 'trpc-openapi';
/** /**
* 1. CONTEXT * 1. CONTEXT
@@ -70,7 +71,7 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
* errors on the backend. * errors on the backend.
*/ */
const t = initTRPC.context<typeof createTRPCContext>().create({ const t = initTRPC.context<typeof createTRPCContext>().meta<OpenApiMeta>().create({
transformer: superjson, transformer: superjson,
errorFormatter({ shape, error }) { errorFormatter({ shape, error }) {
return { return {

11
src/server/openai.ts Normal file
View File

@@ -0,0 +1,11 @@
import { generateOpenApiDocument } from 'trpc-openapi';
import { appRouter } from '~/server/api/routers/app';
import { rootRouter } from '~/server/api/root';
export const openApiDocument = generateOpenApiDocument(rootRouter, {
title: 'Homarr API',
description: 'OpenAPI compliant REST API built of interfacing with Homarr',
version: '1.0.0',
baseUrl: 'http://localhost:3000/api',
docsUrl: 'https://homarr.dev'
});

View File

@@ -21,63 +21,95 @@
// Styling for grid-stack main area // Styling for grid-stack main area
@for $i from 1 to 96 { @for $i from 1 to 96 {
.grid-stack>.grid-stack-item[gs-w="#{$i}"] { width: calc(100% / #{var(--gridstack-column-count)} * #{$i}) } .grid-stack > .grid-stack-item[gs-w="#{$i}"] {
.grid-stack>.grid-stack-item[gs-min-w="#{$i}"] { min-width: calc(100% / #{var(--gridstack-column-count)} * #{$i}) } width: calc(100% / #{var(--gridstack-column-count)} * #{$i})
.grid-stack>.grid-stack-item[gs-max-w="#{$i}"] { max-width: calc(100% / #{var(--gridstack-column-count)} * #{$i}) } }
.grid-stack > .grid-stack-item[gs-min-w="#{$i}"] {
min-width: calc(100% / #{var(--gridstack-column-count)} * #{$i})
}
.grid-stack > .grid-stack-item[gs-max-w="#{$i}"] {
max-width: calc(100% / #{var(--gridstack-column-count)} * #{$i})
}
} }
@for $i from 1 to 96 { @for $i from 1 to 96 {
.grid-stack>.grid-stack-item[gs-h="#{$i}"] { height: calc(#{$i}px * #{var(--gridstack-widget-width)}) } .grid-stack > .grid-stack-item[gs-h="#{$i}"] {
.grid-stack>.grid-stack-item[gs-min-h="#{$i}"] { min-height: calc(#{$i}px * #{var(--gridstack-widget-width)}) } height: calc(#{$i}px * #{var(--gridstack-widget-width)})
.grid-stack>.grid-stack-item[gs-max-h="#{$i}"] { max-height: calc(#{$i}px * #{var(--gridstack-widget-width)}) } }
.grid-stack > .grid-stack-item[gs-min-h="#{$i}"] {
min-height: calc(#{$i}px * #{var(--gridstack-widget-width)})
}
.grid-stack > .grid-stack-item[gs-max-h="#{$i}"] {
max-height: calc(#{$i}px * #{var(--gridstack-widget-width)})
}
} }
@for $i from 1 to 96 { @for $i from 1 to 96 {
.grid-stack>.grid-stack-item[gs-x="#{$i}"] { left: calc(100% / #{var(--gridstack-column-count)} * #{$i}) } .grid-stack > .grid-stack-item[gs-x="#{$i}"] {
left: calc(100% / #{var(--gridstack-column-count)} * #{$i})
}
} }
@for $i from 1 to 96 { @for $i from 1 to 96 {
.grid-stack>.grid-stack-item[gs-y="#{$i}"] { top: calc(#{$i}px * #{var(--gridstack-widget-width)}) } .grid-stack > .grid-stack-item[gs-y="#{$i}"] {
top: calc(#{$i}px * #{var(--gridstack-widget-width)})
}
} }
.grid-stack>.grid-stack-item { .grid-stack > .grid-stack-item {
min-width: #{var(--gridstack-widget-width)}; min-width: #{var(--gridstack-widget-width)};
} }
// Styling for sidebar grid-stack elements // Styling for sidebar grid-stack elements
@for $i from 1 to 96 { @for $i from 1 to 96 {
.grid-stack.grid-stack-sidebar>.grid-stack-item[gs-w="#{$i}"] { width: 128px * $i } .grid-stack.grid-stack-sidebar > .grid-stack-item[gs-w="#{$i}"] {
.grid-stack.grid-stack-sidebar>.grid-stack-item[gs-min-w="#{$i}"] { min-width: 128px * $i } width: 128px * $i
.grid-stack.grid-stack-sidebar>.grid-stack-item[gs-max-w="#{$i}"] { max-width: 128px * $i } }
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-min-w="#{$i}"] {
min-width: 128px * $i
}
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-max-w="#{$i}"] {
max-width: 128px * $i
}
} }
@for $i from 1 to 96 { @for $i from 1 to 96 {
.grid-stack.grid-stack-sidebar>.grid-stack-item[gs-h="#{$i}"] { height: 128px * $i } .grid-stack.grid-stack-sidebar > .grid-stack-item[gs-h="#{$i}"] {
.grid-stack.grid-stack-sidebar>.grid-stack-item[gs-min-h="#{$i}"] { min-height: 128px * $i } height: 128px * $i
.grid-stack.grid-stack-sidebar>.grid-stack-item[gs-max-h="#{$i}"] { max-height: 128px * $i } }
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-min-h="#{$i}"] {
min-height: 128px * $i
}
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-max-h="#{$i}"] {
max-height: 128px * $i
}
} }
@for $i from 1 to 3 { @for $i from 1 to 3 {
.grid-stack.grid-stack-sidebar>.grid-stack-item[gs-x="#{$i}"] { left: 128px * $i } .grid-stack.grid-stack-sidebar > .grid-stack-item[gs-x="#{$i}"] {
left: 128px * $i
}
} }
@for $i from 1 to 96 { @for $i from 1 to 96 {
.grid-stack.grid-stack-sidebar>.grid-stack-item[gs-y="#{$i}"] { top: 128px * $i } .grid-stack.grid-stack-sidebar > .grid-stack-item[gs-y="#{$i}"] {
top: 128px * $i
}
} }
.grid-stack.grid-stack-sidebar>.grid-stack-item { .grid-stack.grid-stack-sidebar > .grid-stack-item {
min-width: 128px; min-width: 128px;
} }
// General gridstack styling // General gridstack styling
.grid-stack>.grid-stack-item>.grid-stack-item-content, .grid-stack > .grid-stack-item > .grid-stack-item-content,
.grid-stack>.grid-stack-item>.placeholder-content { .grid-stack > .grid-stack-item > .placeholder-content {
inset: 10px; inset: 10px;
} }
.grid-stack>.grid-stack-item>.ui-resizable-se { .grid-stack > .grid-stack-item > .ui-resizable-se {
bottom: 10px; bottom: 10px;
right: 10px; right: 10px;
} }
@@ -102,8 +134,8 @@
.polka { .polka {
background-image: radial-gradient( background-image: radial-gradient(
color-mix(in srgb, var(--mantine-color-red-6) 20%, transparent) 6px, color-mix(in srgb, var(--mantine-color-red-6) 20%, transparent) 6px,
transparent 6px transparent 6px
); );
background-size: 60px 60px; background-size: 60px 60px;
} }
@@ -115,6 +147,7 @@
ul[data-type="taskList"] { ul[data-type="taskList"] {
padding-left: 17px; padding-left: 17px;
li { li {
list-style-type: none; list-style-type: none;
display: flex; display: flex;
@@ -124,6 +157,7 @@
img { img {
max-width: 100%; max-width: 100%;
&.ProseMirror-selectednode { &.ProseMirror-selectednode {
outline: 3px solid rgba(0, 65, 198, 0.8); outline: 3px solid rgba(0, 65, 198, 0.8);
} }
@@ -181,3 +215,37 @@
padding: 1rem 0; padding: 1rem 0;
overflow-x: auto; overflow-x: auto;
} }
.open-api-container[data-color-scheme="dark"] .swagger-ui {
select {
background-color: #25262b;
color: #82858e;
}
.scheme-container {
background-color: #1a1b1e !important;
}
.opblock-tag,
.info .title {
color: #bfc5d5;
}
.servers-title,
.opblock .opblock-summary-operation-id,
.opblock .opblock-summary-path,
.opblock .opblock-summary-path__deprecated,
.info li, .info p, .info table {
color: #82858e;
}
.expand-methods svg,
.expand-operation svg,
.opblock-control-arrow svg {
fill: #8c8c8c;
}
.opblock-summary-description {
color: white;
}
}

1785
yarn.lock

File diff suppressed because it is too large Load Diff