mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-15 17:56:21 +01:00
feature: add trpc openapi (#1818)
This commit is contained in:
@@ -83,6 +83,7 @@
|
||||
"dotenv": "^16.3.1",
|
||||
"drizzle-kit": "^0.19.13",
|
||||
"drizzle-orm": "^0.28.6",
|
||||
"drizzle-zod": "^0.5.1",
|
||||
"fily-publish-gridstack": "^0.0.13",
|
||||
"flag-icons": "^6.9.2",
|
||||
"framer-motion": "^10.0.0",
|
||||
@@ -97,6 +98,7 @@
|
||||
"next": "13.4.12",
|
||||
"next-auth": "^4.23.0",
|
||||
"next-i18next": "^14.0.0",
|
||||
"nextjs-cors": "^2.2.0",
|
||||
"nzbget-api": "^0.0.3",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.2.0",
|
||||
@@ -105,6 +107,8 @@
|
||||
"react-simple-code-editor": "^0.13.1",
|
||||
"rss-parser": "^3.12.0",
|
||||
"sabnzbd-api": "^1.5.0",
|
||||
"swagger-ui-react": "^5.11.0",
|
||||
"trpc-openapi": "^1.2.0",
|
||||
"uuid": "^9.0.0",
|
||||
"xml-js": "^1.6.11",
|
||||
"xss": "^1.0.14",
|
||||
@@ -122,6 +126,7 @@
|
||||
"@types/node": "18.17.8",
|
||||
"@types/prismjs": "^1.26.0",
|
||||
"@types/react": "^18.2.11",
|
||||
"@types/swagger-ui-react": "^4.18.3",
|
||||
"@types/umami": "^0.1.4",
|
||||
"@types/uuid": "^9.0.0",
|
||||
"@types/video.js": "^7.3.51",
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
"tools": {
|
||||
"title": "Tools",
|
||||
"items": {
|
||||
"docker": "Docker"
|
||||
"docker": "Docker",
|
||||
"api": "API"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
|
||||
@@ -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 { IconUserDown, IconUserUp } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
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 }: {
|
||||
user: {
|
||||
image: string | null;
|
||||
id: string;
|
||||
name: string | null;
|
||||
password: string | null;
|
||||
email: string | null;
|
||||
emailVerified: Date | null;
|
||||
salt: string | null;
|
||||
isAdmin: boolean;
|
||||
isOwner: boolean;
|
||||
}
|
||||
user: z.infer<typeof userWithoutSecrets>
|
||||
}) => {
|
||||
const { t } = useTranslation(['manage/users/edit', 'manage/users']);
|
||||
const { data: sessionData } = useSession();
|
||||
|
||||
return (
|
||||
<Box maw={500}>
|
||||
<Title order={3}>
|
||||
@@ -33,7 +32,7 @@ export const ManageUserRoles = ({ user }: {
|
||||
|
||||
{user.isAdmin ? (
|
||||
<Button
|
||||
leftIcon={<IconUserDown size='1rem' />}
|
||||
leftIcon={<IconUserDown size="1rem" />}
|
||||
disabled={user.id === sessionData?.user?.id || user.isOwner}
|
||||
onClick={() => {
|
||||
openRoleChangeModal({
|
||||
@@ -47,7 +46,7 @@ export const ManageUserRoles = ({ user }: {
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
leftIcon={<IconUserUp size='1rem' />}
|
||||
leftIcon={<IconUserUp size="1rem" />}
|
||||
onClick={() => {
|
||||
openRoleChangeModal({
|
||||
name: user.name as string,
|
||||
|
||||
@@ -8,10 +8,8 @@ import {
|
||||
Indicator,
|
||||
NavLink,
|
||||
Navbar,
|
||||
Paper,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
@@ -21,10 +19,9 @@ import {
|
||||
IconBrandGithub,
|
||||
IconGitFork,
|
||||
IconHome,
|
||||
IconInfoCircle,
|
||||
IconInfoSmall,
|
||||
IconLayoutDashboard,
|
||||
IconMailForward,
|
||||
IconMailForward, IconPlug,
|
||||
IconQuestionMark,
|
||||
IconTool,
|
||||
IconUser,
|
||||
@@ -104,6 +101,10 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => {
|
||||
icon: IconBrandDocker,
|
||||
href: '/manage/tools/docker',
|
||||
},
|
||||
api: {
|
||||
icon: IconPlug,
|
||||
href: '/manage/tools/swagger'
|
||||
}
|
||||
},
|
||||
},
|
||||
help: {
|
||||
|
||||
22
src/pages/api/[...trpc].ts
Normal file
22
src/pages/api/[...trpc].ts
Normal 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;
|
||||
9
src/pages/api/openapi.json.ts
Normal file
9
src/pages/api/openapi.json.ts
Normal 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;
|
||||
93
src/pages/manage/tools/swagger.tsx
Normal file
93
src/pages/manage/tools/swagger.tsx
Normal 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;
|
||||
@@ -21,7 +21,7 @@ const EditPage = () => {
|
||||
|
||||
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', {
|
||||
username: data?.name,
|
||||
|
||||
@@ -11,12 +11,18 @@ import * as https from 'https';
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
ping: publicProcedure
|
||||
.meta({ openapi: { method: 'GET', path: '/app/ping', tags: ['app'] } })
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
configName: z.string(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.output(z.object({
|
||||
status: z.number(),
|
||||
statusText: z.string(),
|
||||
state: z.string()
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
const app = config.apps.find((app) => app.id === input.id);
|
||||
@@ -62,7 +68,7 @@ export const appRouter = createTRPCRouter({
|
||||
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
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({
|
||||
code: 'TIMEOUT',
|
||||
|
||||
@@ -13,7 +13,18 @@ import { writeConfig } from '~/tools/config/writeConfig';
|
||||
import { configNameSchema } from '~/validations/boards';
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
all: protectedProcedure.query(async ({ ctx }) => {
|
||||
all: protectedProcedure
|
||||
.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');
|
||||
@@ -37,6 +48,8 @@ export const boardRouter = createTRPCRouter({
|
||||
);
|
||||
}),
|
||||
addAppsForContainers: adminProcedure
|
||||
.meta({ openapi: { method: 'POST', path: '/boards/add-apps', tags: ['board'] } })
|
||||
.output(z.void())
|
||||
.input(
|
||||
z.object({
|
||||
boardName: configNameSchema,
|
||||
@@ -89,10 +102,12 @@ export const boardRouter = createTRPCRouter({
|
||||
fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8');
|
||||
}),
|
||||
renameBoard: protectedProcedure
|
||||
.meta({ openapi: { method: 'PUT', path: '/boards/rename', tags: ['board'] } })
|
||||
.input(z.object({
|
||||
oldName: z.string(),
|
||||
newName: z.string().min(1),
|
||||
}))
|
||||
.output(z.void())
|
||||
.mutation(async ({ input }) => {
|
||||
if (input.oldName === 'default') {
|
||||
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`);
|
||||
}),
|
||||
duplicateBoard: protectedProcedure
|
||||
.meta({ openapi: { method: 'POST', path: '/boards/duplicate', tags: ['board'] } })
|
||||
.input(z.object({
|
||||
boardName: z.string(),
|
||||
}))
|
||||
.output(z.void())
|
||||
.mutation(async ({ input }) => {
|
||||
if (!configExists(input.boardName)) {
|
||||
Consola.error(`Tried to duplicate ${input.boardName} but this configuration does not exist.`);
|
||||
|
||||
@@ -15,11 +15,13 @@ import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
export const configRouter = createTRPCRouter({
|
||||
delete: adminProcedure
|
||||
.meta({ openapi: { method: 'DELETE', path: '/configs', tags: ['config'] } })
|
||||
.input(
|
||||
z.object({
|
||||
name: configNameSchema,
|
||||
}),
|
||||
)
|
||||
.output(z.object({ message: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
if (input.name.toLowerCase() === 'default') {
|
||||
Consola.error('Rejected config deletion because default configuration can\'t be deleted');
|
||||
@@ -160,11 +162,21 @@ export const configRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
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(
|
||||
z.object({
|
||||
name: configNameSchema,
|
||||
}),
|
||||
)
|
||||
.output(z.custom<ConfigType>())
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (!configExists(input.name)) {
|
||||
throw new TRPCError({
|
||||
@@ -177,6 +189,7 @@ export const configRouter = createTRPCRouter({
|
||||
}),
|
||||
saveCustomization: adminProcedure
|
||||
.input(boardCustomizationSchema.and(z.object({ name: configNameSchema })))
|
||||
.output(z.void())
|
||||
.mutation(async ({ input }) => {
|
||||
const previousConfig = getConfig(input.name);
|
||||
const newConfig = {
|
||||
|
||||
@@ -4,35 +4,40 @@ import { LocalIconsRepository } from '~/tools/server/images/local-icons-reposito
|
||||
import { UnpkgIconsRepository } from '~/tools/server/images/unpkg-icons-repository';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
import { z } from 'zod';
|
||||
import { NormalizedIconRepositoryResult } from '~/tools/server/images/abstract-icons-repository';
|
||||
|
||||
export const IconRespositories = [
|
||||
new LocalIconsRepository(),
|
||||
new GitHubIconsRepository(
|
||||
GitHubIconsRepository.walkxcode,
|
||||
'Walkxcode Dashboard Icons',
|
||||
'Walkxcode on Github'
|
||||
'Walkxcode on Github',
|
||||
),
|
||||
new UnpkgIconsRepository(
|
||||
UnpkgIconsRepository.tablerRepository,
|
||||
'Tabler Icons',
|
||||
'Tabler Icons - GitHub (MIT)'
|
||||
'Tabler Icons - GitHub (MIT)',
|
||||
),
|
||||
new JsdelivrIconsRepository(
|
||||
JsdelivrIconsRepository.papirusRepository,
|
||||
'Papirus Icons',
|
||||
'Papirus Development Team on GitHub (Apache 2.0)'
|
||||
'Papirus Development Team on GitHub (Apache 2.0)',
|
||||
),
|
||||
new JsdelivrIconsRepository(
|
||||
JsdelivrIconsRepository.homelabSvgAssetsRepository,
|
||||
'Homelab Svg Assets',
|
||||
'loganmarchione on GitHub (MIT)'
|
||||
'loganmarchione on GitHub (MIT)',
|
||||
),
|
||||
];
|
||||
|
||||
export const iconRouter = createTRPCRouter({
|
||||
all: publicProcedure.query(async () => {
|
||||
all: publicProcedure
|
||||
.meta({ openapi: { method: 'GET', path: '/icons', tags: ['icon'] } })
|
||||
.input(z.void())
|
||||
.output(z.array(z.custom<NormalizedIconRepositoryResult>()))
|
||||
.query(async () => {
|
||||
const fetches = IconRespositories.map((rep) => rep.fetch());
|
||||
const data = await Promise.all(fetches);
|
||||
return data;
|
||||
return await Promise.all(fetches);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -5,17 +5,26 @@ import { z } from 'zod';
|
||||
import { db } from '~/server/db';
|
||||
import { invites } from '~/server/db/schema';
|
||||
|
||||
import { adminProcedure, createTRPCRouter, publicProcedure } from '../../trpc';
|
||||
import { adminProcedure, createTRPCRouter } from '../../trpc';
|
||||
|
||||
export const inviteRouter = createTRPCRouter({
|
||||
all: adminProcedure
|
||||
.meta({ openapi: { method: 'GET', path: '/invites', tags: ['invite'] } })
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.number().min(1).max(100).default(10),
|
||||
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 dbInvites = await db.query.invites.findMany({
|
||||
limit: limit,
|
||||
@@ -44,14 +53,20 @@ export const inviteRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
create: adminProcedure
|
||||
.meta({ openapi: { method: 'POST', path: '/invites', tags: ['invite'] } })
|
||||
.input(
|
||||
z.object({
|
||||
expiration: z
|
||||
.date()
|
||||
.min(dayjs().add(5, 'minutes').toDate())
|
||||
.max(dayjs().add(6, 'months').toDate()),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.output(z.object({
|
||||
id: z.string(),
|
||||
token: z.string(),
|
||||
expires: z.date(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const inviteToInsert = {
|
||||
id: randomUUID(),
|
||||
@@ -67,7 +82,11 @@ export const inviteRouter = createTRPCRouter({
|
||||
expires: inviteToInsert.expires,
|
||||
};
|
||||
}),
|
||||
delete: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ input }) => {
|
||||
delete: adminProcedure
|
||||
.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));
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
updateSettingsValidationSchema,
|
||||
} from '~/validations/user';
|
||||
import { PossibleRoleFilter } from '~/pages/manage/users';
|
||||
import { createSelectSchema } from 'drizzle-zod';
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
createOwnerAccount: publicProcedure.input(signUpFormSchema).mutation(async ({ ctx, input }) => {
|
||||
@@ -41,6 +42,7 @@ export const userRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
updatePassword: adminProcedure
|
||||
.meta({ openapi: { method: 'PUT', path: '/users/password', tags: ['user'] } })
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
@@ -48,6 +50,7 @@ export const userRouter = createTRPCRouter({
|
||||
terminateExistingSessions: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.output(z.void())
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, input.userId),
|
||||
@@ -81,7 +84,11 @@ export const userRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(users.id, input.userId));
|
||||
}),
|
||||
count: publicProcedure.query(async () => {
|
||||
count: publicProcedure
|
||||
.meta({ openapi: { method: 'GET', path: '/users/count', tags: ['user'] } })
|
||||
.input(z.void())
|
||||
.output(z.number())
|
||||
.query(async () => {
|
||||
return await getTotalUserCountAsync();
|
||||
}),
|
||||
createFromInvite: publicProcedure
|
||||
@@ -133,7 +140,9 @@ export const userRouter = createTRPCRouter({
|
||||
.where(eq(userSettings.userId, ctx.session?.user?.id));
|
||||
}),
|
||||
changeRole: adminProcedure
|
||||
.meta({ openapi: { method: 'PUT', path: '/users/roles', tags: ['user'] } })
|
||||
.input(z.object({ id: z.string(), type: z.enum(['promote', 'demote']) }))
|
||||
.output(z.void())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (ctx.session?.user?.id === input.id) {
|
||||
throw new TRPCError({
|
||||
@@ -166,11 +175,13 @@ export const userRouter = createTRPCRouter({
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
changeLanguage: protectedProcedure
|
||||
.meta({ openapi: { method: 'PUT', path: '/users/language', tags: ['user'] } })
|
||||
.input(
|
||||
z.object({
|
||||
language: z.string(),
|
||||
}),
|
||||
)
|
||||
.output(z.void())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await db
|
||||
.update(userSettings)
|
||||
@@ -218,6 +229,8 @@ export const userRouter = createTRPCRouter({
|
||||
}),
|
||||
|
||||
makeDefaultDashboard: protectedProcedure
|
||||
.meta({ openapi: { method: 'POST', path: '/users/make-default-dashboard', tags: ['user'] } })
|
||||
.output(z.void())
|
||||
.input(z.object({ board: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
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 = () => {
|
||||
if (input.search.role === PossibleRoleFilter[1].id) {
|
||||
@@ -309,30 +335,54 @@ export const userRouter = createTRPCRouter({
|
||||
},
|
||||
};
|
||||
}),
|
||||
create: adminProcedure.input(createNewUserSchema).mutation(async ({ input }) => {
|
||||
create: adminProcedure
|
||||
.meta({ openapi: { method: 'POST', path: '/users', tags: ['user'] } })
|
||||
.input(createNewUserSchema)
|
||||
.output(z.void())
|
||||
.mutation(async ({ input }) => {
|
||||
await createUserIfNotPresent(input);
|
||||
}),
|
||||
details: adminProcedure.input(z.object({ userId: z.string() })).query(async ({ input }) => {
|
||||
details: adminProcedure
|
||||
.meta({ openapi: { method: 'GET', path: '/users/getById', tags: ['user'] } })
|
||||
.input(z.object({ userId: z.string() }))
|
||||
.output(
|
||||
createSelectSchema(users)
|
||||
.omit({
|
||||
password: true,
|
||||
salt: true,
|
||||
})
|
||||
.optional())
|
||||
.query(async ({ input }) => {
|
||||
return db.query.users.findFirst({
|
||||
where: eq(users.id, input.userId),
|
||||
columns: {
|
||||
password: false,
|
||||
salt: false,
|
||||
},
|
||||
});
|
||||
}),
|
||||
updateDetails: adminProcedure.input(z.object({
|
||||
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),
|
||||
})).mutation(async ({ input }) => {
|
||||
}))
|
||||
.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
|
||||
.meta({ openapi: { method: 'DELETE', path: '/users', tags: ['user'] } })
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.output(z.void())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, input.id),
|
||||
|
||||
@@ -13,6 +13,7 @@ import superjson from 'superjson';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
import { getServerAuthSession } from '../auth';
|
||||
import { OpenApiMeta } from 'trpc-openapi';
|
||||
|
||||
/**
|
||||
* 1. CONTEXT
|
||||
@@ -70,7 +71,7 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
|
||||
* errors on the backend.
|
||||
*/
|
||||
|
||||
const t = initTRPC.context<typeof createTRPCContext>().create({
|
||||
const t = initTRPC.context<typeof createTRPCContext>().meta<OpenApiMeta>().create({
|
||||
transformer: superjson,
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
|
||||
11
src/server/openai.ts
Normal file
11
src/server/openai.ts
Normal 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'
|
||||
});
|
||||
@@ -21,24 +21,40 @@
|
||||
|
||||
// Styling for grid-stack main area
|
||||
@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-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}) }
|
||||
.grid-stack > .grid-stack-item[gs-w="#{$i}"] {
|
||||
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 {
|
||||
.grid-stack>.grid-stack-item[gs-h="#{$i}"] { 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)}) }
|
||||
.grid-stack > .grid-stack-item[gs-h="#{$i}"] {
|
||||
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 {
|
||||
.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 {
|
||||
.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 {
|
||||
@@ -47,24 +63,40 @@
|
||||
|
||||
// Styling for sidebar grid-stack elements
|
||||
@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-min-w="#{$i}"] { min-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-w="#{$i}"] {
|
||||
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 {
|
||||
.grid-stack.grid-stack-sidebar>.grid-stack-item[gs-h="#{$i}"] { 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 }
|
||||
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-h="#{$i}"] {
|
||||
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 {
|
||||
.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 {
|
||||
.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 {
|
||||
@@ -115,6 +147,7 @@
|
||||
|
||||
ul[data-type="taskList"] {
|
||||
padding-left: 17px;
|
||||
|
||||
li {
|
||||
list-style-type: none;
|
||||
display: flex;
|
||||
@@ -124,6 +157,7 @@
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
|
||||
&.ProseMirror-selectednode {
|
||||
outline: 3px solid rgba(0, 65, 198, 0.8);
|
||||
}
|
||||
@@ -181,3 +215,37 @@
|
||||
padding: 1rem 0;
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user