mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-09 15:05:48 +01:00
✨ Add board customization page
This commit is contained in:
@@ -1,43 +1,10 @@
|
|||||||
import { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core';
|
import { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core';
|
||||||
import { createFormContext } from '@mantine/form';
|
import { createFormContext } from '@mantine/form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { boardCustomizationSchema } from '~/validations/dashboards';
|
||||||
const schema = z.object({
|
|
||||||
layout: z.object({
|
|
||||||
leftSidebarEnabled: z.boolean(),
|
|
||||||
rightSidebarEnabled: z.boolean(),
|
|
||||||
pingsEnabled: z.boolean(),
|
|
||||||
}),
|
|
||||||
gridstack: z.object({
|
|
||||||
sm: z.number().min(1).max(8),
|
|
||||||
md: z.number().min(3).max(16),
|
|
||||||
lg: z.number().min(5).max(20),
|
|
||||||
}),
|
|
||||||
pageMetadata: z.object({
|
|
||||||
pageTitle: z.string(),
|
|
||||||
metaTitle: z.string(),
|
|
||||||
logoSrc: z.string(),
|
|
||||||
faviconSrc: z.string(),
|
|
||||||
}),
|
|
||||||
appearance: z.object({
|
|
||||||
backgroundSrc: z.string(),
|
|
||||||
primaryColor: z.custom<MantineColor>(
|
|
||||||
(value) => typeof value === 'string' && MANTINE_COLORS.includes(value)
|
|
||||||
),
|
|
||||||
secondaryColor: z.custom<MantineColor>(
|
|
||||||
(value) => typeof value === 'string' && MANTINE_COLORS.includes(value)
|
|
||||||
),
|
|
||||||
shade: z
|
|
||||||
.number()
|
|
||||||
.min(0)
|
|
||||||
.max(DEFAULT_THEME.colors['blue'].length - 1),
|
|
||||||
opacity: z.number().min(10).max(100),
|
|
||||||
customCss: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const [
|
export const [
|
||||||
BoardCustomizationFormProvider,
|
BoardCustomizationFormProvider,
|
||||||
useBoardCustomizationFormContext,
|
useBoardCustomizationFormContext,
|
||||||
useBoardCustomizationForm,
|
useBoardCustomizationForm,
|
||||||
] = createFormContext<z.infer<typeof schema>>();
|
] = createFormContext<z.infer<typeof boardCustomizationSchema>>();
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Button, Container, Group, Paper, Stack, Text, Title } from '@mantine/core';
|
import { Button, Container, Group, Paper, Stack, Text, Title } from '@mantine/core';
|
||||||
|
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
IconArrowLeft,
|
IconArrowLeft,
|
||||||
IconBrush,
|
IconBrush,
|
||||||
IconChartCandle,
|
IconChartCandle,
|
||||||
|
IconCheck,
|
||||||
IconDragDrop,
|
IconDragDrop,
|
||||||
IconLayout,
|
IconLayout,
|
||||||
|
IconX,
|
||||||
TablerIconsProps,
|
TablerIconsProps,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { GetServerSideProps } from 'next';
|
import { GetServerSideProps } from 'next';
|
||||||
@@ -12,6 +15,7 @@ import { useTranslation } from 'next-i18next';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
import { z } from 'zod';
|
||||||
import { AppearanceCustomization } from '~/components/Board/Customize/Appearance/AppearanceCustomization';
|
import { AppearanceCustomization } from '~/components/Board/Customize/Appearance/AppearanceCustomization';
|
||||||
import { GridstackCustomization } from '~/components/Board/Customize/Gridstack/GridstackCustomization';
|
import { GridstackCustomization } from '~/components/Board/Customize/Gridstack/GridstackCustomization';
|
||||||
import { LayoutCustomization } from '~/components/Board/Customize/Layout/LayoutCustomization';
|
import { LayoutCustomization } from '~/components/Board/Customize/Layout/LayoutCustomization';
|
||||||
@@ -21,44 +25,91 @@ import {
|
|||||||
useBoardCustomizationForm,
|
useBoardCustomizationForm,
|
||||||
} from '~/components/Board/Customize/form';
|
} from '~/components/Board/Customize/form';
|
||||||
import { MainLayout } from '~/components/layout/main';
|
import { MainLayout } from '~/components/layout/main';
|
||||||
|
import { createTrpcServersideHelpers } from '~/server/api/helper';
|
||||||
|
import { getServerAuthSession } from '~/server/auth';
|
||||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||||
import { dashboardNamespaces } from '~/tools/server/translation-namespaces';
|
import { dashboardNamespaces } from '~/tools/server/translation-namespaces';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
|
||||||
|
import { boardCustomizationSchema } from '~/validations/dashboards';
|
||||||
|
|
||||||
|
const notificationId = 'board-customization-notification';
|
||||||
|
|
||||||
export default function CustomizationPage() {
|
export default function CustomizationPage() {
|
||||||
const query = useRouter().query as { slug: string };
|
const query = useRouter().query as { slug: string };
|
||||||
const { t } = useTranslation([
|
const utils = api.useContext();
|
||||||
'settings/customization/general',
|
const { data: config } = api.config.byName.useQuery({ name: query.slug });
|
||||||
'settings/customization/color-selector',
|
const { mutateAsync: saveCusomization, isLoading } = api.config.saveCusomization.useMutation();
|
||||||
]);
|
const { i18nZodResolver } = useI18nZodResolver();
|
||||||
const form = useBoardCustomizationForm({
|
const form = useBoardCustomizationForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
layout: {
|
layout: {
|
||||||
leftSidebarEnabled: false,
|
leftSidebarEnabled: config?.settings.customization.layout.enabledLeftSidebar ?? false,
|
||||||
rightSidebarEnabled: false,
|
rightSidebarEnabled: config?.settings.customization.layout.enabledRightSidebar ?? false,
|
||||||
pingsEnabled: false,
|
pingsEnabled: config?.settings.customization.layout.enabledPing ?? false,
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
backgroundSrc: '',
|
backgroundSrc: config?.settings.customization.backgroundImageUrl ?? '',
|
||||||
primaryColor: 'red',
|
primaryColor: config?.settings.customization.colors.primary ?? 'red',
|
||||||
secondaryColor: 'orange',
|
secondaryColor: config?.settings.customization.colors.secondary ?? 'orange',
|
||||||
shade: 8,
|
shade: (config?.settings.customization.colors.shade as number | undefined) ?? 8,
|
||||||
opacity: 50,
|
opacity: config?.settings.customization.appOpacity ?? 50,
|
||||||
customCss: '',
|
customCss: config?.settings.customization.customCss ?? '',
|
||||||
},
|
},
|
||||||
gridstack: {
|
gridstack: {
|
||||||
sm: 3,
|
sm: config?.settings.customization.gridstack?.columnCountSmall ?? 3,
|
||||||
md: 6,
|
md: config?.settings.customization.gridstack?.columnCountMedium ?? 6,
|
||||||
lg: 12,
|
lg: config?.settings.customization.gridstack?.columnCountLarge ?? 12,
|
||||||
},
|
},
|
||||||
pageMetadata: {
|
pageMetadata: {
|
||||||
pageTitle: '',
|
pageTitle: config?.settings.customization.pageTitle ?? '',
|
||||||
metaTitle: '',
|
metaTitle: config?.settings.customization.metaTitle ?? '',
|
||||||
logoSrc: '',
|
logoSrc: config?.settings.customization.logoImageUrl ?? '',
|
||||||
faviconSrc: '',
|
faviconSrc: config?.settings.customization.faviconUrl ?? '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
validate: i18nZodResolver(boardCustomizationSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (values: z.infer<typeof boardCustomizationSchema>) => {
|
||||||
|
if (isLoading) return;
|
||||||
|
showNotification({
|
||||||
|
id: notificationId,
|
||||||
|
title: 'Saving customization',
|
||||||
|
message: 'Please wait while we save your customization',
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
await saveCusomization(
|
||||||
|
{
|
||||||
|
name: query.slug,
|
||||||
|
...values,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSettled() {
|
||||||
|
void utils.config.byName.invalidate({ name: query.slug });
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
updateNotification({
|
||||||
|
id: notificationId,
|
||||||
|
title: 'Customization saved',
|
||||||
|
message: 'Your customization has been saved',
|
||||||
|
color: 'green',
|
||||||
|
icon: <IconCheck />,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
updateNotification({
|
||||||
|
id: notificationId,
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Unable to save customization',
|
||||||
|
color: 'red',
|
||||||
|
icon: <IconX />,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<Container>
|
<Container>
|
||||||
@@ -76,7 +127,7 @@ export default function CustomizationPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
<BoardCustomizationFormProvider form={form}>
|
<BoardCustomizationFormProvider form={form}>
|
||||||
<form>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<Stack spacing="xl">
|
<Stack spacing="xl">
|
||||||
<Stack spacing="xs">
|
<Stack spacing="xs">
|
||||||
<SectionTitle type="layout" icon={IconLayout} />
|
<SectionTitle type="layout" icon={IconLayout} />
|
||||||
@@ -94,7 +145,9 @@ export default function CustomizationPage() {
|
|||||||
<SectionTitle type="appereance" icon={IconBrush} />
|
<SectionTitle type="appereance" icon={IconBrush} />
|
||||||
<AppearanceCustomization />
|
<AppearanceCustomization />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Button type="submit">Save changes</Button>
|
<Button type="submit" loading={isLoading}>
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</BoardCustomizationFormProvider>
|
</BoardCustomizationFormProvider>
|
||||||
@@ -124,11 +177,34 @@ const SectionTitle = ({ type, icon: Icon }: SectionTitleProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = async ({ req, res, locale }) => {
|
const routeParamsSchema = z.object({
|
||||||
|
slug: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getServerSideProps: GetServerSideProps = async ({ req, res, locale, params }) => {
|
||||||
|
const routeParams = routeParamsSchema.safeParse(params);
|
||||||
|
if (!routeParams.success) {
|
||||||
|
return {
|
||||||
|
notFound: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getServerAuthSession({ req, res });
|
||||||
|
if (!session?.user.isAdmin) {
|
||||||
|
return {
|
||||||
|
notFound: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const helpers = await createTrpcServersideHelpers({ req, res });
|
||||||
|
|
||||||
|
helpers.config.byName.prefetch({ name: routeParams.data.slug });
|
||||||
|
|
||||||
const translations = await getServerSideTranslations(dashboardNamespaces, locale, req, res);
|
const translations = await getServerSideTranslations(dashboardNamespaces, locale, req, res);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
|
trpcState: helpers.dehydrate(),
|
||||||
...translations,
|
...translations,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import { Button, ButtonProps, Text, Title, Tooltip } from '@mantine/core';
|
|||||||
import { useHotkeys, useWindowEvent } from '@mantine/hooks';
|
import { useHotkeys, useWindowEvent } from '@mantine/hooks';
|
||||||
import { openContextModal } from '@mantine/modals';
|
import { openContextModal } from '@mantine/modals';
|
||||||
import { hideNotification, showNotification } from '@mantine/notifications';
|
import { hideNotification, showNotification } from '@mantine/notifications';
|
||||||
import { IconApps, IconBrandDocker, IconEditCircle, IconEditCircleOff } from '@tabler/icons-react';
|
import {
|
||||||
|
IconApps,
|
||||||
|
IconBrandDocker,
|
||||||
|
IconEditCircle,
|
||||||
|
IconEditCircleOff,
|
||||||
|
IconSettings,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
import Consola from 'consola';
|
import Consola from 'consola';
|
||||||
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
|
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
@@ -75,6 +81,7 @@ export const HeaderActions = () => {
|
|||||||
<>
|
<>
|
||||||
<DockerButton />
|
<DockerButton />
|
||||||
<ToggleEditModeButton />
|
<ToggleEditModeButton />
|
||||||
|
<CustomizeBoardButton />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -91,6 +98,18 @@ const DockerButton = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CustomizeBoardButton = () => {
|
||||||
|
const { name } = useConfigContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label="Customize board">
|
||||||
|
<HeaderActionButton component={Link} href={`/board/${name}/customize`}>
|
||||||
|
<IconSettings size={20} stroke={1.5} />
|
||||||
|
</HeaderActionButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
type SpecificLinkProps = {
|
type SpecificLinkProps = {
|
||||||
component: typeof Link;
|
component: typeof Link;
|
||||||
href: string;
|
href: string;
|
||||||
|
|||||||
16
src/server/api/helper.ts
Normal file
16
src/server/api/helper.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { createServerSideHelpers } from '@trpc/react-query/server';
|
||||||
|
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
|
||||||
|
import { GetServerSidePropsContext } from 'next';
|
||||||
|
import superjson from 'superjson';
|
||||||
|
|
||||||
|
import { rootRouter } from './root';
|
||||||
|
import { createTRPCContext } from './trpc';
|
||||||
|
|
||||||
|
export const createTrpcServersideHelpers = async (
|
||||||
|
props: Pick<GetServerSidePropsContext, 'req' | 'res'>
|
||||||
|
) =>
|
||||||
|
createServerSideHelpers({
|
||||||
|
router: rootRouter,
|
||||||
|
ctx: await createTRPCContext(props as CreateNextContextOptions),
|
||||||
|
transformer: superjson,
|
||||||
|
});
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
|
import { MantineTheme } from '@mantine/core';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import Consola from 'consola';
|
import Consola from 'consola';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { configExists } from '~/tools/config/configExists';
|
||||||
|
import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
|
||||||
import { BackendConfigType, ConfigType } from '~/types/config';
|
import { BackendConfigType, ConfigType } from '~/types/config';
|
||||||
|
import { boardCustomizationSchema } from '~/validations/dashboards';
|
||||||
import { IRssWidget } from '~/widgets/rss/RssWidgetTile';
|
import { IRssWidget } from '~/widgets/rss/RssWidgetTile';
|
||||||
|
|
||||||
import { getConfig } from '../../../tools/config/getConfig';
|
import { getConfig } from '../../../tools/config/getConfig';
|
||||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc';
|
||||||
|
|
||||||
|
const configNameSchema = z.string().regex(/^[a-zA-Z0-9-_]+$/);
|
||||||
|
|
||||||
export const configRouter = createTRPCRouter({
|
export const configRouter = createTRPCRouter({
|
||||||
all: publicProcedure.query(async () => {
|
all: publicProcedure.query(async () => {
|
||||||
@@ -17,10 +23,10 @@ export const configRouter = createTRPCRouter({
|
|||||||
// Strip the .json extension from the file name
|
// Strip the .json extension from the file name
|
||||||
return files.map((file) => file.replace('.json', ''));
|
return files.map((file) => file.replace('.json', ''));
|
||||||
}),
|
}),
|
||||||
delete: publicProcedure
|
delete: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
name: z.string(),
|
name: configNameSchema,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
@@ -60,10 +66,10 @@ export const configRouter = createTRPCRouter({
|
|||||||
message: 'Configuration deleted with success',
|
message: 'Configuration deleted with success',
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
save: publicProcedure
|
save: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
name: z.string(),
|
name: configNameSchema,
|
||||||
config: z.custom<ConfigType>((x) => !!x && typeof x === 'object'),
|
config: z.custom<ConfigType>((x) => !!x && typeof x === 'object'),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -124,6 +130,8 @@ export const configRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
|
// Settings can only be changed in the configuration file
|
||||||
|
settings: previousConfig.settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
newConfig = {
|
newConfig = {
|
||||||
@@ -160,4 +168,59 @@ export const configRouter = createTRPCRouter({
|
|||||||
message: 'Configuration saved with success',
|
message: 'Configuration saved with success',
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
byName: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
name: configNameSchema,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
if (!configExists(input.name)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'Configuration not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await getFrontendConfig(input.name);
|
||||||
|
}),
|
||||||
|
saveCusomization: adminProcedure
|
||||||
|
.input(boardCustomizationSchema.and(z.object({ name: configNameSchema })))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const previousConfig = getConfig(input.name);
|
||||||
|
const newConfig = {
|
||||||
|
...previousConfig,
|
||||||
|
settings: {
|
||||||
|
...previousConfig.settings,
|
||||||
|
customization: {
|
||||||
|
...previousConfig.settings.customization,
|
||||||
|
appOpacity: input.appearance.opacity,
|
||||||
|
backgroundImageUrl: input.appearance.backgroundSrc,
|
||||||
|
colors: {
|
||||||
|
primary: input.appearance.primaryColor,
|
||||||
|
secondary: input.appearance.secondaryColor,
|
||||||
|
shade: input.appearance.shade as MantineTheme['primaryShade'],
|
||||||
|
},
|
||||||
|
customCss: input.appearance.customCss,
|
||||||
|
faviconUrl: input.pageMetadata.faviconSrc,
|
||||||
|
gridstack: {
|
||||||
|
columnCountSmall: input.gridstack.sm,
|
||||||
|
columnCountMedium: input.gridstack.md,
|
||||||
|
columnCountLarge: input.gridstack.lg,
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
...previousConfig.settings.customization.layout,
|
||||||
|
enabledLeftSidebar: input.layout.leftSidebarEnabled,
|
||||||
|
enabledRightSidebar: input.layout.rightSidebarEnabled,
|
||||||
|
enabledPing: input.layout.pingsEnabled,
|
||||||
|
},
|
||||||
|
logoImageUrl: input.pageMetadata.logoSrc,
|
||||||
|
metaTitle: input.pageMetadata.metaTitle,
|
||||||
|
pageTitle: input.pageMetadata.pageTitle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies BackendConfigType;
|
||||||
|
const targetPath = path.join('data/configs', `${input.name}.json`);
|
||||||
|
fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8');
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -128,3 +128,30 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
|
|||||||
* @see https://trpc.io/docs/procedures
|
* @see https://trpc.io/docs/procedures
|
||||||
*/
|
*/
|
||||||
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
|
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
|
||||||
|
|
||||||
|
/** Reusable middleware that enforces users are logged in before running the procedure. */
|
||||||
|
const enforceUserIsAdmin = t.middleware(({ ctx, next }) => {
|
||||||
|
if (!ctx.session?.user) {
|
||||||
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
|
}
|
||||||
|
if (!ctx.session?.user.isAdmin) {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
// infers the `session` as non-nullable
|
||||||
|
session: { ...ctx.session, user: ctx.session.user },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin (authenticated) procedure
|
||||||
|
*
|
||||||
|
* If you want a query or mutation to ONLY be accessible to logged in admins, use this. It verifies
|
||||||
|
* the session is valid, guarantees `ctx.session.user` is not null and the user is an admin.
|
||||||
|
*
|
||||||
|
* @see https://trpc.io/docs/procedures
|
||||||
|
*/
|
||||||
|
export const adminProcedure = t.procedure.use(enforceUserIsAdmin);
|
||||||
|
|||||||
@@ -1,5 +1,40 @@
|
|||||||
|
import { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const createDashboardSchemaValidation = z.object({
|
export const createDashboardSchemaValidation = z.object({
|
||||||
name: z.string().min(2).max(25),
|
name: z.string().min(2).max(25),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const boardCustomizationSchema = z.object({
|
||||||
|
layout: z.object({
|
||||||
|
leftSidebarEnabled: z.boolean(),
|
||||||
|
rightSidebarEnabled: z.boolean(),
|
||||||
|
pingsEnabled: z.boolean(),
|
||||||
|
}),
|
||||||
|
gridstack: z.object({
|
||||||
|
sm: z.number().min(1).max(8),
|
||||||
|
md: z.number().min(3).max(16),
|
||||||
|
lg: z.number().min(5).max(20),
|
||||||
|
}),
|
||||||
|
pageMetadata: z.object({
|
||||||
|
pageTitle: z.string(),
|
||||||
|
metaTitle: z.string(),
|
||||||
|
logoSrc: z.string(),
|
||||||
|
faviconSrc: z.string(),
|
||||||
|
}),
|
||||||
|
appearance: z.object({
|
||||||
|
backgroundSrc: z.string(),
|
||||||
|
primaryColor: z.custom<MantineColor>(
|
||||||
|
(value) => typeof value === 'string' && MANTINE_COLORS.includes(value)
|
||||||
|
),
|
||||||
|
secondaryColor: z.custom<MantineColor>(
|
||||||
|
(value) => typeof value === 'string' && MANTINE_COLORS.includes(value)
|
||||||
|
),
|
||||||
|
shade: z
|
||||||
|
.number()
|
||||||
|
.min(0)
|
||||||
|
.max(DEFAULT_THEME.colors['blue'].length - 1),
|
||||||
|
opacity: z.number().min(10).max(100),
|
||||||
|
customCss: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user