From d8562e2990f2985169b052fd87c479b80f35f1d8 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 29 Jul 2023 10:05:05 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20working=20sign-in=20/=20sign-?= =?UTF-8?q?out,=20add=20working=20registration=20with=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- data/configs/default.json | 2 +- next-i18next.config.js | 2 +- prisma/schema.prisma | 6 + .../locales/en/authentication/register.json | 20 +++ public/locales/en/common.json | 4 + public/locales/en/zod.json | 22 +++ src/components/layout/header/Header.tsx | 1 + src/components/layout/header/SettingsMenu.tsx | 26 +++- .../header/SettingsMenu/EditModeToggle.tsx | 79 ---------- src/pages/_app.tsx | 122 ++++++++------- src/pages/login.tsx | 3 +- src/pages/register.tsx | 140 ++++++++++++++++++ src/server/api/root.ts | 2 + src/server/api/routers/user.ts | 66 +++++++++ src/server/api/trpc.ts | 2 + src/tools/server/translation-namespaces.ts | 5 +- src/utils/i18n-zod-resolver.ts | 128 ++++++++++++++++ src/validations/user.ts | 18 ++- 19 files changed, 506 insertions(+), 147 deletions(-) create mode 100644 public/locales/en/authentication/register.json create mode 100644 public/locales/en/zod.json delete mode 100644 src/components/layout/header/SettingsMenu/EditModeToggle.tsx create mode 100644 src/pages/register.tsx create mode 100644 src/server/api/routers/user.ts create mode 100644 src/utils/i18n-zod-resolver.ts diff --git a/.gitignore b/.gitignore index a7129a95d..4f96eb93c 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,7 @@ data/configs #Languages other than 'en' public/locales/* -!public/locales/en \ No newline at end of file +!public/locales/en + +#database +prisma/db.sqlite \ No newline at end of file diff --git a/data/configs/default.json b/data/configs/default.json index 6d28530f3..f22f3d3db 100644 --- a/data/configs/default.json +++ b/data/configs/default.json @@ -390,4 +390,4 @@ "appOpacity": 100 } } -} +} \ No newline at end of file diff --git a/next-i18next.config.js b/next-i18next.config.js index eed2cdc6e..3b35467c2 100644 --- a/next-i18next.config.js +++ b/next-i18next.config.js @@ -29,7 +29,7 @@ module.exports = { 'no', 'tr', 'lv', - 'hr' + 'hr', ], localeDetection: true, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3e3980a96..8949aa855 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -61,3 +61,9 @@ model VerificationToken { @@unique([identifier, token]) } + +model RegistrationToken { + id String @id @default(cuid()) + token String @unique + expires DateTime +} diff --git a/public/locales/en/authentication/register.json b/public/locales/en/authentication/register.json new file mode 100644 index 000000000..9173bb76f --- /dev/null +++ b/public/locales/en/authentication/register.json @@ -0,0 +1,20 @@ +{ + "title": "Create Account", + "text": "Please define your credentials below", + "form": { + "fields": { + "username": { + "label": "Username" + }, + "password": { + "label": "Password" + }, + "passwordConfirmation": { + "label": "Confirm password" + } + }, + "buttons": { + "submit": "Register" + } + } +} \ No newline at end of file diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 4b5059ae0..e716bef39 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -35,5 +35,9 @@ "small": "small", "medium": "medium", "large": "large" + }, + "header": { + "logout": "Logout", + "sign-in": "Sign in" } } \ No newline at end of file diff --git a/public/locales/en/zod.json b/public/locales/en/zod.json new file mode 100644 index 000000000..41acbcf94 --- /dev/null +++ b/public/locales/en/zod.json @@ -0,0 +1,22 @@ +{ + "errors": { + "default": "This field is invalid", + "required": "This field is required", + "string": { + "startsWith": "This field must start with {{startsWith}}", + "endsWith": "This field must end with {{endsWith}}", + "includes": "This field must include {{includes}}" + }, + "too_small": { + "string": "This field must be at least {{minimum}} characters long", + "number": "This field must be greater than or equal to {{minimum}}" + }, + "too_big": { + "string": "This field must be at most {{minimum}} characters long", + "number": "This field must be less than or equal to {{minimum}}" + }, + "custom": { + "password_match": "Passwords must match" + } + } +} \ No newline at end of file diff --git a/src/components/layout/header/Header.tsx b/src/components/layout/header/Header.tsx index fc464ea0f..4398efc54 100644 --- a/src/components/layout/header/Header.tsx +++ b/src/components/layout/header/Header.tsx @@ -1,5 +1,6 @@ import { Box, Group, Indicator, Header as MantineHeader, createStyles } from '@mantine/core'; import { useQuery } from '@tanstack/react-query'; +import { useSession } from 'next-auth/react'; import { REPO_URL } from '../../../../data/constants'; import { useEditModeInformationStore } from '../../../hooks/useEditModeInformation'; diff --git a/src/components/layout/header/SettingsMenu.tsx b/src/components/layout/header/SettingsMenu.tsx index 60c835cf2..01cc9c34b 100644 --- a/src/components/layout/header/SettingsMenu.tsx +++ b/src/components/layout/header/SettingsMenu.tsx @@ -1,14 +1,21 @@ import { Badge, Button, Menu } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; -import { IconInfoCircle, IconMenu2, IconSettings } from '@tabler/icons-react'; +import { + IconInfoCircle, + IconLogin, + IconLogout, + IconMenu2, + IconSettings, +} from '@tabler/icons-react'; +import { signOut, useSession } from 'next-auth/react'; import { useTranslation } from 'next-i18next'; +import Link from 'next/link'; import { useEditModeInformationStore } from '../../../hooks/useEditModeInformation'; import { AboutModal } from '../../Dashboard/Modals/AboutModal/AboutModal'; import { SettingsDrawer } from '../../Settings/SettingsDrawer'; import { useCardStyles } from '../useCardStyles'; import { ColorSchemeSwitch } from './SettingsMenu/ColorSchemeSwitch'; -import { EditModeToggle } from './SettingsMenu/EditModeToggle'; export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: string }) { const [drawerOpened, drawer] = useDisclosure(false); @@ -16,6 +23,7 @@ export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: str const [aboutModalOpened, aboutModal] = useDisclosure(false); const { classes } = useCardStyles(true); const { editModeEnabled } = useEditModeInformationStore(); + const { data: sessionData } = useSession(); return ( <> @@ -27,7 +35,6 @@ export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: str - {!editModeEnabled && ( } onClick={drawer.open}> @@ -47,6 +54,19 @@ export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: str > {t('about')} + {sessionData?.user ? ( + } onClick={() => signOut()}> + {t('header.logout')} + + ) : ( + } + component={Link} + href="/login" + > + {t('header.sign-in')} + + )} { - axios - .post('/api/configs/tryPassword', { tried: values.triedPassword, type: 'edit' }) - .then((res) => { - showNotification({ - title: 'Success', - message: 'Successfully toggled edit mode, reloading the page...', - color: 'green', - }); - setTimeout(() => { - window.location.reload(); - }, 500); - }) - .catch((_) => { - showNotification({ - title: 'Error', - message: 'Failed to toggle edit mode, please try again.', - color: 'red', - }); - }); - })} - > - - - In order to toggle edit mode, you need to enter the password you entered in the - environment variable named EDIT_MODE_PASSWORD . If it is not set, you are not - able to toggle edit mode on and off. - - - - - - ); -} - -export function EditModeToggle() { - const { editModeEnabled } = useEditModeInformationStore(); - const Icon = editModeEnabled ? IconEdit : IconEditOff; - - return ( - } - onClick={() => - openModal({ - title: 'Toggle edit mode', - centered: true, - size: 'lg', - children: , - }) - } - > - {editModeEnabled ? 'Enable edit mode' : 'Disable edit mode'} - - ); -} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 681bdff42..9c40390da 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -9,12 +9,15 @@ import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client import Consola from 'consola'; import { getCookie } from 'cookies-next'; import { GetServerSidePropsContext } from 'next'; +import { Session } from 'next-auth'; +import { SessionProvider, getSession } from 'next-auth/react'; import { appWithTranslation } from 'next-i18next'; import { AppProps } from 'next/app'; import Head from 'next/head'; import { useEffect, useState } from 'react'; import 'video.js/dist/video-js.css'; import { env } from '~/env.js'; +import { ConfigType } from '~/types/config'; import { api } from '~/utils/api'; import nextI18nextConfig from '../../next-i18next.config.js'; @@ -36,7 +39,6 @@ import { getServiceSidePackageAttributes, } from '../tools/server/getPackageVersion'; import { theme } from '../tools/server/theme/theme'; -import { ConfigType } from '~/types/config'; function App( this: any, @@ -47,13 +49,20 @@ function App( defaultColorScheme: ColorScheme; config?: ConfigType; configName?: string; + session: Session; }> ) { const { Component, pageProps } = props; - const [primaryColor, setPrimaryColor] = useState(props.pageProps.config?.settings.customization.colors.primary || 'red'); - const [secondaryColor, setSecondaryColor] = useState(props.pageProps.config?.settings.customization.colors.secondary || 'orange'); - const [primaryShade, setPrimaryShade] = useState(props.pageProps.config?.settings.customization.colors.shade || 6); + const [primaryColor, setPrimaryColor] = useState( + props.pageProps.config?.settings.customization.colors.primary || 'red' + ); + const [secondaryColor, setSecondaryColor] = useState( + props.pageProps.config?.settings.customization.colors.secondary || 'orange' + ); + const [primaryShade, setPrimaryShade] = useState( + props.pageProps.config?.settings.customization.colors.shade || 6 + ); const colorTheme = { primaryColor, secondaryColor, @@ -97,62 +106,64 @@ function App( - - - - + + + + - - - - - - - - - - - + primaryColor, + primaryShade, + colorScheme, + }} + withGlobalStyles + withNormalizeCSS + > + + + + + + + + + + + + ); } -App.getInitialProps = ({ ctx }: { ctx: GetServerSidePropsContext }) => { +App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => { if (process.env.DISABLE_EDIT_MODE === 'true') { Consola.warn( 'EXPERIMENTAL: You have disabled the edit mode. Modifications are no longer possible and any requests on the API will be dropped. If you want to disable this, unset the DISABLE_EDIT_MODE environment variable. This behaviour may be removed in future versions of Homarr' @@ -163,12 +174,15 @@ App.getInitialProps = ({ ctx }: { ctx: GetServerSidePropsContext }) => { Consola.debug(`Overriding the default color scheme with ${env.DEFAULT_COLOR_SCHEME}`); } + const session = await getSession(ctx); + return { pageProps: { colorScheme: getCookie('color-scheme', ctx) || 'light', packageAttributes: getServiceSidePackageAttributes(), editModeEnabled: process.env.DISABLE_EDIT_MODE !== 'true', defaultColorScheme: env.DEFAULT_COLOR_SCHEME, + session, }, }; }; diff --git a/src/pages/login.tsx b/src/pages/login.tsx index 330c1b8ac..c885c29c2 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -24,7 +24,7 @@ import { signInSchema } from '~/validations/user'; import { loginNamespaces } from '../tools/server/translation-namespaces'; export default function LoginPage() { - const { t } = useTranslation('authentication/login'); + const { t } = useTranslation(['authentication/login']); const queryParams = useRouter().query as { error?: 'CredentialsSignin' | (string & {}) }; const form = useForm>({ @@ -63,7 +63,6 @@ export default function LoginPage() { /> >({ + validateInputOnChange: true, + validateInputOnBlur: true, + validate: i18nZodResolver(signUpFormSchema), + }); + + const handleSubmit = (values: z.infer) => { + const notificationId = 'register'; + showNotification({ + id: notificationId, + title: 'Registering...', + message: 'Please wait...', + loading: true, + }); + void mutateAsync( + { + ...values, + registerToken: query.token, + }, + { + onSuccess() { + updateNotification({ + id: notificationId, + title: 'Account created', + message: 'Your account has been created successfully', + color: 'teal', + icon: , + }); + router.push('/login'); + }, + onError() { + updateNotification({ + id: notificationId, + title: 'Error', + message: 'Something went wrong', + color: 'red', + icon: , + }); + }, + } + ); + }; + + return ( + + + + {t('title')} + + + + {t('text')} + + +
+ + + + + + + + + +
+
+
+ ); +} + +const queryParamsSchema = z.object({ + token: z.string(), +}); + +export const getServerSideProps: GetServerSideProps = async ({ locale, query }) => { + const result = queryParamsSchema.safeParse(query); + if (!result.success) { + return { + notFound: true, + }; + } + + const token = await prisma.registrationToken.findUnique({ + where: { + token: result.data.token, + }, + }); + + if (!token || token.expires < new Date()) { + return { + notFound: true, + }; + } + + return { + props: { + ...(await serverSideTranslations(locale ?? '', registerNamespaces)), + }, + }; +}; diff --git a/src/server/api/root.ts b/src/server/api/root.ts index ced44f718..8f261c685 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -13,6 +13,7 @@ import { mediaServerRouter } from './routers/media-server'; import { overseerrRouter } from './routers/overseerr'; import { rssRouter } from './routers/rss'; import { usenetRouter } from './routers/usenet/router'; +import { userRouter } from './routers/user'; import { weatherRouter } from './routers/weather'; /** @@ -23,6 +24,7 @@ import { weatherRouter } from './routers/weather'; export const rootRouter = createTRPCRouter({ app: appRouter, rss: rssRouter, + user: userRouter, config: configRouter, docker: dockerRouter, icon: iconRouter, diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts new file mode 100644 index 000000000..50d859e95 --- /dev/null +++ b/src/server/api/routers/user.ts @@ -0,0 +1,66 @@ +import { TRPCError } from '@trpc/server'; +import bcrypt from 'bcrypt'; +import { z } from 'zod'; +import { hashPassword } from '~/utils/security'; +import { signUpFormSchema } from '~/validations/user'; + +import { createTRPCRouter, publicProcedure } from '../trpc'; + +export const userRouter = createTRPCRouter({ + register: publicProcedure + .input( + signUpFormSchema.and( + z.object({ + registerToken: z.string(), + }) + ) + ) + .mutation(async ({ ctx, input }) => { + const token = await ctx.prisma.registrationToken.findUnique({ + where: { + token: input.registerToken, + }, + }); + + if (!token || token.expires < new Date()) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Invalid registration token', + }); + } + + 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); + + const user = await ctx.prisma.user.create({ + data: { + name: input.username, + password: hashedPassword, + salt: salt, + }, + }); + await ctx.prisma.registrationToken.delete({ + where: { + id: token.id, + }, + }); + + return { + id: user.id, + name: user.name, + }; + }), +}); diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index 73198345b..3b83ba494 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -13,6 +13,7 @@ import superjson from 'superjson'; import { ZodError } from 'zod'; import { getServerAuthSession } from '../auth'; +import { prisma } from '../db'; /** * 1. CONTEXT @@ -38,6 +39,7 @@ interface CreateContextOptions { */ const createInnerTRPCContext = (opts: CreateContextOptions) => ({ session: opts.session, + prisma, }); /** diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts index 96f25690e..06e4da980 100644 --- a/src/tools/server/translation-namespaces.ts +++ b/src/tools/server/translation-namespaces.ts @@ -1,5 +1,6 @@ export const dashboardNamespaces = [ 'common', + 'zod', 'layout/element-selector/selector', 'layout/modals/add-app', 'layout/modals/change-position', @@ -48,4 +49,6 @@ export const dashboardNamespaces = [ 'widgets/location', ]; -export const loginNamespaces = ['authentication/login']; +export const loginNamespaces = ['authentication/login', 'zod']; + +export const registerNamespaces = ['authentication/register', 'zod']; diff --git a/src/utils/i18n-zod-resolver.ts b/src/utils/i18n-zod-resolver.ts new file mode 100644 index 000000000..9ab3bb0bb --- /dev/null +++ b/src/utils/i18n-zod-resolver.ts @@ -0,0 +1,128 @@ +import { zodResolver } from '@mantine/form'; +import { TFunction } from 'i18next'; +import { useTranslation } from 'next-i18next'; +import { ErrorMapCtx, ZodIssueCode, ZodSchema, ZodTooBigIssue, ZodTooSmallIssue, z } from 'zod'; + +export const useI18nZodResolver = () => { + const { t } = useTranslation('zod'); + return { + i18nZodResolver: i18nZodResolver(t), + }; +}; + +const i18nZodResolver = + (t: TFunction<'zod', undefined, 'zod'>) => + >>(schema: TSchema) => { + z.setErrorMap(zodErrorMap(t)); + return zodResolver(schema); + }; + +const handleStringError = (issue: z.ZodInvalidStringIssue, ctx: ErrorMapCtx) => { + if (typeof issue.validation === 'object') { + if ('startsWith' in issue.validation) { + return { + key: 'errors.string.startsWith', + params: { + startsWith: issue.validation.startsWith, + }, + }; + } else if ('endsWith' in issue.validation) { + return { + key: 'errors.string.endsWith', + params: { + endsWith: issue.validation.endsWith, + }, + }; + } + + return { + key: 'errors.invalid_string.includes', + params: { + includes: issue.validation.includes, + }, + }; + } + + return { + message: issue.message, + }; +}; + +const handleTooSmallError = (issue: ZodTooSmallIssue, ctx: ErrorMapCtx) => { + if (issue.type !== 'string' && issue.type !== 'number') { + return { + message: issue.message, + }; + } + + return { + key: `errors.too_small.${issue.type}`, + params: { + minimum: issue.minimum, + count: issue.minimum, + }, + }; +}; + +const handleTooBigError = (issue: ZodTooBigIssue, ctx: ErrorMapCtx) => { + if (issue.type !== 'string' && issue.type !== 'number') { + return { + message: issue.message, + }; + } + + return { + key: `errors.too_big.${issue.type}`, + params: { + maximum: issue.maximum, + count: issue.maximum, + }, + }; +}; + +const handleZodError = (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => { + if (ctx.defaultError === 'Required') { + return { + key: 'errors.required', + params: {}, + }; + } + if (issue.code === ZodIssueCode.invalid_string) { + return handleStringError(issue, ctx); + } + if (issue.code === ZodIssueCode.too_small) { + return handleTooSmallError(issue, ctx); + } + if (issue.code === ZodIssueCode.too_big) { + return handleTooBigError(issue, ctx); + } + if (issue.code === ZodIssueCode.custom && issue.params?.i18n) { + return { + key: `errors.custom.${issue.params.i18n.key}`, + }; + } + + return { + message: issue.message, + }; +}; + +function zodErrorMap(t: TFunction<'zod', undefined, 'zod'>) { + return (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => { + const error = handleZodError(issue, ctx); + if ('message' in error && error.message) + return { + message: error.message ?? ctx.defaultError, + }; + return { + message: t(error.key ?? 'errors.default', error.params ?? {}), + }; + }; +} + +export type CustomErrorParams = { + i18n: { + key: string; + params?: Record; + }; +}; diff --git a/src/validations/user.ts b/src/validations/user.ts index edb59445f..4ce6c0848 100644 --- a/src/validations/user.ts +++ b/src/validations/user.ts @@ -1,12 +1,20 @@ import { z } from 'zod'; +import { CustomErrorParams } from '~/utils/i18n-zod-resolver'; export const signInSchema = z.object({ name: z.string(), password: z.string(), }); -export const signUpFormSchema = z.object({ - username: z.string(), - password: z.string().min(8), - acceptTos: z.boolean(), -}); +export const signUpFormSchema = z + .object({ + username: z.string().min(3), + password: z.string().min(8), + passwordConfirmation: z.string().min(8), + }) + .refine((data) => data.password === data.passwordConfirmation, { + params: { + i18n: { key: 'password_match' }, + } satisfies CustomErrorParams, + path: ['passwordConfirmation'], + });