diff --git a/src/components/Board/Customize/form.ts b/src/components/Board/Customize/form.ts index b65463dbc..5bd77a6e8 100644 --- a/src/components/Board/Customize/form.ts +++ b/src/components/Board/Customize/form.ts @@ -1,43 +1,10 @@ import { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core'; import { createFormContext } from '@mantine/form'; import { z } from 'zod'; - -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( - (value) => typeof value === 'string' && MANTINE_COLORS.includes(value) - ), - secondaryColor: z.custom( - (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(), - }), -}); +import { boardCustomizationSchema } from '~/validations/dashboards'; export const [ BoardCustomizationFormProvider, useBoardCustomizationFormContext, useBoardCustomizationForm, -] = createFormContext>(); +] = createFormContext>(); diff --git a/src/pages/board/[slug]/customize.tsx b/src/pages/board/[slug]/customize.tsx index a3edd6a94..389ec6e9d 100644 --- a/src/pages/board/[slug]/customize.tsx +++ b/src/pages/board/[slug]/customize.tsx @@ -1,10 +1,13 @@ import { Button, Container, Group, Paper, Stack, Text, Title } from '@mantine/core'; +import { showNotification, updateNotification } from '@mantine/notifications'; import { IconArrowLeft, IconBrush, IconChartCandle, + IconCheck, IconDragDrop, IconLayout, + IconX, TablerIconsProps, } from '@tabler/icons-react'; import { GetServerSideProps } from 'next'; @@ -12,6 +15,7 @@ import { useTranslation } from 'next-i18next'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { ReactNode } from 'react'; +import { z } from 'zod'; import { AppearanceCustomization } from '~/components/Board/Customize/Appearance/AppearanceCustomization'; import { GridstackCustomization } from '~/components/Board/Customize/Gridstack/GridstackCustomization'; import { LayoutCustomization } from '~/components/Board/Customize/Layout/LayoutCustomization'; @@ -21,44 +25,91 @@ import { useBoardCustomizationForm, } from '~/components/Board/Customize/form'; import { MainLayout } from '~/components/layout/main'; +import { createTrpcServersideHelpers } from '~/server/api/helper'; +import { getServerAuthSession } from '~/server/auth'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; 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() { const query = useRouter().query as { slug: string }; - const { t } = useTranslation([ - 'settings/customization/general', - 'settings/customization/color-selector', - ]); + const utils = api.useContext(); + const { data: config } = api.config.byName.useQuery({ name: query.slug }); + const { mutateAsync: saveCusomization, isLoading } = api.config.saveCusomization.useMutation(); + const { i18nZodResolver } = useI18nZodResolver(); const form = useBoardCustomizationForm({ initialValues: { layout: { - leftSidebarEnabled: false, - rightSidebarEnabled: false, - pingsEnabled: false, + leftSidebarEnabled: config?.settings.customization.layout.enabledLeftSidebar ?? false, + rightSidebarEnabled: config?.settings.customization.layout.enabledRightSidebar ?? false, + pingsEnabled: config?.settings.customization.layout.enabledPing ?? false, }, appearance: { - backgroundSrc: '', - primaryColor: 'red', - secondaryColor: 'orange', - shade: 8, - opacity: 50, - customCss: '', + backgroundSrc: config?.settings.customization.backgroundImageUrl ?? '', + primaryColor: config?.settings.customization.colors.primary ?? 'red', + secondaryColor: config?.settings.customization.colors.secondary ?? 'orange', + shade: (config?.settings.customization.colors.shade as number | undefined) ?? 8, + opacity: config?.settings.customization.appOpacity ?? 50, + customCss: config?.settings.customization.customCss ?? '', }, gridstack: { - sm: 3, - md: 6, - lg: 12, + sm: config?.settings.customization.gridstack?.columnCountSmall ?? 3, + md: config?.settings.customization.gridstack?.columnCountMedium ?? 6, + lg: config?.settings.customization.gridstack?.columnCountLarge ?? 12, }, pageMetadata: { - pageTitle: '', - metaTitle: '', - logoSrc: '', - faviconSrc: '', + pageTitle: config?.settings.customization.pageTitle ?? '', + metaTitle: config?.settings.customization.metaTitle ?? '', + logoSrc: config?.settings.customization.logoImageUrl ?? '', + faviconSrc: config?.settings.customization.faviconUrl ?? '', }, }, + validate: i18nZodResolver(boardCustomizationSchema), }); + const handleSubmit = async (values: z.infer) => { + 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: , + }); + }, + onError() { + updateNotification({ + id: notificationId, + title: 'Error', + message: 'Unable to save customization', + color: 'red', + icon: , + }); + }, + } + ); + }; + return ( @@ -76,7 +127,7 @@ export default function CustomizationPage() { -
+ @@ -94,7 +145,9 @@ export default function CustomizationPage() { - +
@@ -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); return { props: { + trpcState: helpers.dehydrate(), ...translations, }, }; diff --git a/src/pages/board/index.tsx b/src/pages/board/index.tsx index 8a8f8ad82..680a2a606 100644 --- a/src/pages/board/index.tsx +++ b/src/pages/board/index.tsx @@ -2,7 +2,13 @@ import { Button, ButtonProps, Text, Title, Tooltip } from '@mantine/core'; import { useHotkeys, useWindowEvent } from '@mantine/hooks'; import { openContextModal } from '@mantine/modals'; 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 { GetServerSideProps, InferGetServerSidePropsType } from 'next'; import { useSession } from 'next-auth/react'; @@ -75,6 +81,7 @@ export const HeaderActions = () => { <> + ); }; @@ -91,6 +98,18 @@ const DockerButton = () => { ); }; +const CustomizeBoardButton = () => { + const { name } = useConfigContext(); + + return ( + + + + + + ); +}; + type SpecificLinkProps = { component: typeof Link; href: string; diff --git a/src/server/api/helper.ts b/src/server/api/helper.ts new file mode 100644 index 000000000..58d1a820b --- /dev/null +++ b/src/server/api/helper.ts @@ -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 +) => + createServerSideHelpers({ + router: rootRouter, + ctx: await createTRPCContext(props as CreateNextContextOptions), + transformer: superjson, + }); diff --git a/src/server/api/routers/config.ts b/src/server/api/routers/config.ts index f2d9ee94a..87a3e6987 100644 --- a/src/server/api/routers/config.ts +++ b/src/server/api/routers/config.ts @@ -1,13 +1,19 @@ +import { MantineTheme } from '@mantine/core'; import { TRPCError } from '@trpc/server'; import Consola from 'consola'; import fs from 'fs'; import path from 'path'; import { z } from 'zod'; +import { configExists } from '~/tools/config/configExists'; +import { getFrontendConfig } from '~/tools/config/getFrontendConfig'; import { BackendConfigType, ConfigType } from '~/types/config'; +import { boardCustomizationSchema } from '~/validations/dashboards'; import { IRssWidget } from '~/widgets/rss/RssWidgetTile'; 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({ all: publicProcedure.query(async () => { @@ -17,10 +23,10 @@ export const configRouter = createTRPCRouter({ // Strip the .json extension from the file name return files.map((file) => file.replace('.json', '')); }), - delete: publicProcedure + delete: adminProcedure .input( z.object({ - name: z.string(), + name: configNameSchema, }) ) .mutation(async ({ input }) => { @@ -60,10 +66,10 @@ export const configRouter = createTRPCRouter({ message: 'Configuration deleted with success', }; }), - save: publicProcedure + save: adminProcedure .input( z.object({ - name: z.string(), + name: configNameSchema, config: z.custom((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 = { @@ -160,4 +168,59 @@ export const configRouter = createTRPCRouter({ 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'); + }), }); diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index cef582ff5..a1ec1e477 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -128,3 +128,30 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { * @see https://trpc.io/docs/procedures */ 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); diff --git a/src/validations/dashboards.ts b/src/validations/dashboards.ts index e4bc4e3d4..a4d3fc3c5 100644 --- a/src/validations/dashboards.ts +++ b/src/validations/dashboards.ts @@ -1,5 +1,40 @@ +import { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core'; import { z } from 'zod'; export const createDashboardSchemaValidation = z.object({ 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( + (value) => typeof value === 'string' && MANTINE_COLORS.includes(value) + ), + secondaryColor: z.custom( + (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(), + }), +});