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'],
+ });