From 8ce0de50684d815b8b4d72a9bbf657fce5a8c122 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Tue, 1 Aug 2023 19:04:14 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20Add=20onboarding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/Common/useGradient.tsx | 4 +- src/pages/_app.tsx | 1 + src/pages/onboard.tsx | 240 +++++++++++++++++++ src/server/api/routers/user.ts | 91 ++++--- src/server/api/trpc.ts | 2 + src/tools/server/translation-namespaces.ts | 2 + 6 files changed, 309 insertions(+), 31 deletions(-) create mode 100644 src/pages/onboard.tsx diff --git a/src/components/layout/Common/useGradient.tsx b/src/components/layout/Common/useGradient.tsx index 8675a6447..419e3bc1a 100644 --- a/src/components/layout/Common/useGradient.tsx +++ b/src/components/layout/Common/useGradient.tsx @@ -2,12 +2,12 @@ import { MantineGradient } from '@mantine/core'; import { useColorTheme } from '../../../tools/color'; -export const usePrimaryGradient = (): MantineGradient => { +export const usePrimaryGradient = () => { const { primaryColor, secondaryColor } = useColorTheme(); return { from: primaryColor, to: secondaryColor, deg: 145, - }; + } satisfies MantineGradient; }; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index cc7927748..5755fc19d 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -16,6 +16,7 @@ import { AppProps } from 'next/app'; import Head from 'next/head'; import { useEffect, useState } from 'react'; import 'video.js/dist/video-js.css'; +import { z } from 'zod'; import { CommonHead } from '~/components/layout/Meta/CommonHead'; import { env } from '~/env.js'; import { ColorSchemeProvider } from '~/hooks/use-colorscheme'; diff --git a/src/pages/onboard.tsx b/src/pages/onboard.tsx new file mode 100644 index 000000000..6b8523af7 --- /dev/null +++ b/src/pages/onboard.tsx @@ -0,0 +1,240 @@ +import { + Box, + Button, + Card, + Center, + Flex, + Grid, + Group, + Image, + PasswordInput, + Stack, + Text, + TextInput, + Title, + UnstyledButton, + createStyles, + useMantineTheme, +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { useMediaQuery } from '@mantine/hooks'; +import { IconLayoutDashboard, IconUserCog } from '@tabler/icons-react'; +import { IconArrowRight, IconBook2, IconUserPlus } from '@tabler/icons-react'; +import { GetServerSideProps } from 'next'; +import Link from 'next/link'; +import { ReactNode, useMemo, useState } from 'react'; +import { z } from 'zod'; +import { prisma } from '~/server/db'; +import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; +import { onboardNamespaces } from '~/tools/server/translation-namespaces'; +import { api } from '~/utils/api'; +import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; +import { signUpFormSchema } from '~/validations/user'; + +const getStepContents = () => [FirstStepContent, SecondStepContent, ThirdStepContent] as const; + +export default function OnboardPage() { + const { fn, colors, breakpoints, colorScheme } = useMantineTheme(); + const [currentStep, setStep] = useState(0); + const next = () => setStep((prev) => prev + 1); + const isSmallerThanMd = useMediaQuery(`(max-width: ${breakpoints.sm})`); + const stepContents = useMemo(() => getStepContents(), []); + const CurrentStepComponent = useMemo(() => stepContents[currentStep], [currentStep]); + const background = colorScheme === 'dark' ? 'dark.6' : 'gray.1'; + + return ( + +
+
+ Homarr Logo +
+
+ + + {stepContents.map((_, index) => ( + + ))} + + + +
+ ); +} + +type StepProps = { + isCurrent: boolean; + isMobile: boolean; + isDark: boolean; +}; +const Step = ({ isCurrent, isMobile, isDark }: StepProps) => { + return ( + + ); +}; + +type StepContentComponent = (props: { isMobile: boolean; next: () => void }) => ReactNode; + +const FirstStepContent: StepContentComponent = ({ isMobile, next }) => { + return ( + <> + + Hi there! + Welcome to Homarr! 👋 + + + Before you can use Homarr, you need to configure a few things. + + + + ); +}; + +const SecondStepContent: StepContentComponent = ({ isMobile, next }) => { + const { mutateAsync, isLoading } = api.user.createAdminAccount.useMutation(); + const { i18nZodResolver } = useI18nZodResolver(); + + const form = useForm>({ + validate: i18nZodResolver(signUpFormSchema), + validateInputOnBlur: true, + }); + const handleSubmit = (values: z.infer) => { + void mutateAsync(values, { + onSuccess: () => { + next(); + }, + }); + }; + + return ( + <> + Configure your credentials +
+ + + + + + + + +
+ + ); +}; + +const firstActions = [ + { + icon: IconBook2, + label: 'Read the documentation', + href: 'https://homarr.dev/docs/introduction/after-the-installation', + }, + { + icon: IconUserPlus, + label: 'Invite an user', + href: '/users/invite', + }, + { + icon: IconLayoutDashboard, + label: 'Setup your board', + href: '/board', + }, + { + icon: IconUserCog, + label: 'Configure your profile', + href: '/user/preferences', + }, +]; + +const ThirdStepContent: StepContentComponent = ({ isMobile, next }) => { + const { breakpoints } = useMantineTheme(); + const { classes } = useStyles(); + + return ( + <> + Get started! 🚀 + + {firstActions.map((action) => ( + + + + + + + {action.label} + + + + + + + + ))} + + + ); +}; + +const useStyles = createStyles((theme) => ({ + button: { + '&:hover': { + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1], + }, + }, +})); + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const userCount = await prisma.user.count(); + if (userCount >= 1) { + return { + notFound: true, + }; + } + + const translations = await getServerSideTranslations( + onboardNamespaces, + ctx.locale, + ctx.req, + ctx.res + ); + + return { + props: { + ...translations, + }, + }; +}; diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 95c2392ab..5108766aa 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -1,12 +1,7 @@ +import { UserSettings } from '@prisma/client'; import { TRPCError } from '@trpc/server'; - import bcrypt from 'bcrypt'; - import { z } from 'zod'; - -import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; -import { COOKIE_COLOR_SCHEME_KEY, COOKIE_LOCALE_KEY } from '../../../../data/constants'; - import { hashPassword } from '~/utils/security'; import { colorSchemeParser, @@ -15,7 +10,26 @@ import { updateSettingsValidationSchema, } from '~/validations/user'; +import { COOKIE_COLOR_SCHEME_KEY, COOKIE_LOCALE_KEY } from '../../../../data/constants'; +import { TRPCContext, createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; + export const userRouter = createTRPCRouter({ + createAdminAccount: publicProcedure.input(signUpFormSchema).mutation(async ({ ctx, input }) => { + const userCount = await ctx.prisma.user.count(); + if (userCount > 0) { + throw new TRPCError({ + code: 'FORBIDDEN', + }); + } + + await createUserInNotExist(ctx, input, { + defaultSettings: { + colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]), + language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en', + }, + isAdmin: true, + }); + }), createFromInvite: publicProcedure .input( signUpFormSchema.and( @@ -38,19 +52,13 @@ export const userRouter = createTRPCRouter({ }); } - const existingUser = await ctx.prisma.user.findFirst({ - where: { - name: input.username, + await createUserInNotExist(ctx, input, { + defaultSettings: { + colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]), + language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en', }, }); - if (existingUser) { - throw new TRPCError({ - code: 'CONFLICT', - message: 'User already exists', - }); - } - const salt = bcrypt.genSaltSync(10); const hashedPassword = hashPassword(input.password, salt); @@ -217,19 +225,7 @@ export const userRouter = createTRPCRouter({ }; }), create: publicProcedure.input(createNewUserSchema).mutation(async ({ ctx, input }) => { - const salt = bcrypt.genSaltSync(10); - const hashedPassword = hashPassword(input.password, salt); - await ctx.prisma.user.create({ - data: { - name: input.username, - email: input.email, - password: hashedPassword, - salt: salt, - settings: { - create: {}, - }, - }, - }); + await createUserInNotExist(ctx, input); }), deleteUser: publicProcedure @@ -246,3 +242,40 @@ export const userRouter = createTRPCRouter({ }); }), }); + +const createUserInNotExist = async ( + ctx: TRPCContext, + input: z.infer, + options: { + defaultSettings?: Partial; + isAdmin?: boolean; + } | void +) => { + const existingUser = await ctx.prisma.user.findFirst({ + where: { + name: input.username, + }, + }); + + if (existingUser) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'User already exists', + }); + } + + const salt = bcrypt.genSaltSync(10); + const hashedPassword = hashPassword(input.password, salt); + await ctx.prisma.user.create({ + data: { + name: input.username, + email: input.email, + password: hashedPassword, + salt: salt, + isAdmin: options?.isAdmin ?? false, + settings: { + create: options?.defaultSettings ?? {}, + }, + }, + }); +}; diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index a1ec1e477..610ec4d05 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -44,6 +44,8 @@ const createInnerTRPCContext = (opts: CreateContextOptions) => ({ prisma, }); +export type TRPCContext = ReturnType; + /** * This is the actual context you will use in your router. It will be used to process every request * that goes through your tRPC endpoint. diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts index 1f0959322..975f6c20a 100644 --- a/src/tools/server/translation-namespaces.ts +++ b/src/tools/server/translation-namespaces.ts @@ -53,3 +53,5 @@ export const manageNamespaces = ['user/preferences', 'zod']; export const loginNamespaces = ['authentication/login', 'zod']; export const inviteNamespaces = ['authentication/invite', 'zod']; + +export const onboardNamespaces = ['common', 'zod'];