mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-10 07:25:48 +01:00
✨ Add working sign-in / sign-out, add working registration with token
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -56,3 +56,6 @@ data/configs
|
|||||||
#Languages other than 'en'
|
#Languages other than 'en'
|
||||||
public/locales/*
|
public/locales/*
|
||||||
!public/locales/en
|
!public/locales/en
|
||||||
|
|
||||||
|
#database
|
||||||
|
prisma/db.sqlite
|
||||||
@@ -29,7 +29,7 @@ module.exports = {
|
|||||||
'no',
|
'no',
|
||||||
'tr',
|
'tr',
|
||||||
'lv',
|
'lv',
|
||||||
'hr'
|
'hr',
|
||||||
],
|
],
|
||||||
|
|
||||||
localeDetection: true,
|
localeDetection: true,
|
||||||
|
|||||||
@@ -61,3 +61,9 @@ model VerificationToken {
|
|||||||
|
|
||||||
@@unique([identifier, token])
|
@@unique([identifier, token])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model RegistrationToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
token String @unique
|
||||||
|
expires DateTime
|
||||||
|
}
|
||||||
|
|||||||
20
public/locales/en/authentication/register.json
Normal file
20
public/locales/en/authentication/register.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,5 +35,9 @@
|
|||||||
"small": "small",
|
"small": "small",
|
||||||
"medium": "medium",
|
"medium": "medium",
|
||||||
"large": "large"
|
"large": "large"
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"logout": "Logout",
|
||||||
|
"sign-in": "Sign in"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
22
public/locales/en/zod.json
Normal file
22
public/locales/en/zod.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Box, Group, Indicator, Header as MantineHeader, createStyles } from '@mantine/core';
|
import { Box, Group, Indicator, Header as MantineHeader, createStyles } from '@mantine/core';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import { REPO_URL } from '../../../../data/constants';
|
import { REPO_URL } from '../../../../data/constants';
|
||||||
import { useEditModeInformationStore } from '../../../hooks/useEditModeInformation';
|
import { useEditModeInformationStore } from '../../../hooks/useEditModeInformation';
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import { Badge, Button, Menu } from '@mantine/core';
|
import { Badge, Button, Menu } from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
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 { useTranslation } from 'next-i18next';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { useEditModeInformationStore } from '../../../hooks/useEditModeInformation';
|
import { useEditModeInformationStore } from '../../../hooks/useEditModeInformation';
|
||||||
import { AboutModal } from '../../Dashboard/Modals/AboutModal/AboutModal';
|
import { AboutModal } from '../../Dashboard/Modals/AboutModal/AboutModal';
|
||||||
import { SettingsDrawer } from '../../Settings/SettingsDrawer';
|
import { SettingsDrawer } from '../../Settings/SettingsDrawer';
|
||||||
import { useCardStyles } from '../useCardStyles';
|
import { useCardStyles } from '../useCardStyles';
|
||||||
import { ColorSchemeSwitch } from './SettingsMenu/ColorSchemeSwitch';
|
import { ColorSchemeSwitch } from './SettingsMenu/ColorSchemeSwitch';
|
||||||
import { EditModeToggle } from './SettingsMenu/EditModeToggle';
|
|
||||||
|
|
||||||
export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: string }) {
|
export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: string }) {
|
||||||
const [drawerOpened, drawer] = useDisclosure(false);
|
const [drawerOpened, drawer] = useDisclosure(false);
|
||||||
@@ -16,6 +23,7 @@ export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: str
|
|||||||
const [aboutModalOpened, aboutModal] = useDisclosure(false);
|
const [aboutModalOpened, aboutModal] = useDisclosure(false);
|
||||||
const { classes } = useCardStyles(true);
|
const { classes } = useCardStyles(true);
|
||||||
const { editModeEnabled } = useEditModeInformationStore();
|
const { editModeEnabled } = useEditModeInformationStore();
|
||||||
|
const { data: sessionData } = useSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -27,7 +35,6 @@ export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: str
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<ColorSchemeSwitch />
|
<ColorSchemeSwitch />
|
||||||
<EditModeToggle />
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
{!editModeEnabled && (
|
{!editModeEnabled && (
|
||||||
<Menu.Item icon={<IconSettings strokeWidth={1.2} size={18} />} onClick={drawer.open}>
|
<Menu.Item icon={<IconSettings strokeWidth={1.2} size={18} />} onClick={drawer.open}>
|
||||||
@@ -47,6 +54,19 @@ export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: str
|
|||||||
>
|
>
|
||||||
{t('about')}
|
{t('about')}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
{sessionData?.user ? (
|
||||||
|
<Menu.Item icon={<IconLogout strokeWidth={1.2} size={18} />} onClick={() => signOut()}>
|
||||||
|
{t('header.logout')}
|
||||||
|
</Menu.Item>
|
||||||
|
) : (
|
||||||
|
<Menu.Item
|
||||||
|
icon={<IconLogin strokeWidth={1.2} size={18} />}
|
||||||
|
component={Link}
|
||||||
|
href="/login"
|
||||||
|
>
|
||||||
|
{t('header.sign-in')}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
<SettingsDrawer
|
<SettingsDrawer
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import { Button, Code, Menu, PasswordInput, Stack, Text } from '@mantine/core';
|
|
||||||
import { useForm } from '@mantine/form';
|
|
||||||
import { openModal } from '@mantine/modals';
|
|
||||||
import { showNotification } from '@mantine/notifications';
|
|
||||||
import { IconEdit, IconEditOff } from '@tabler/icons-react';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
import { useEditModeInformationStore } from '../../../../hooks/useEditModeInformation';
|
|
||||||
|
|
||||||
function ModalContent() {
|
|
||||||
const form = useForm({
|
|
||||||
initialValues: {
|
|
||||||
triedPassword: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={form.onSubmit((values) => {
|
|
||||||
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',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Stack>
|
|
||||||
<Text size="sm">
|
|
||||||
In order to toggle edit mode, you need to enter the password you entered in the
|
|
||||||
environment variable named <Code>EDIT_MODE_PASSWORD</Code> . If it is not set, you are not
|
|
||||||
able to toggle edit mode on and off.
|
|
||||||
</Text>
|
|
||||||
<PasswordInput
|
|
||||||
id="triedPassword"
|
|
||||||
label="Edit password"
|
|
||||||
autoFocus
|
|
||||||
required
|
|
||||||
{...form.getInputProps('triedPassword')}
|
|
||||||
/>
|
|
||||||
<Button type="submit">Submit</Button>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditModeToggle() {
|
|
||||||
const { editModeEnabled } = useEditModeInformationStore();
|
|
||||||
const Icon = editModeEnabled ? IconEdit : IconEditOff;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Menu.Item
|
|
||||||
closeMenuOnClick={false}
|
|
||||||
icon={<Icon strokeWidth={1.2} size={18} />}
|
|
||||||
onClick={() =>
|
|
||||||
openModal({
|
|
||||||
title: 'Toggle edit mode',
|
|
||||||
centered: true,
|
|
||||||
size: 'lg',
|
|
||||||
children: <ModalContent />,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{editModeEnabled ? 'Enable edit mode' : 'Disable edit mode'}
|
|
||||||
</Menu.Item>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,12 +9,15 @@ import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client
|
|||||||
import Consola from 'consola';
|
import Consola from 'consola';
|
||||||
import { getCookie } from 'cookies-next';
|
import { getCookie } from 'cookies-next';
|
||||||
import { GetServerSidePropsContext } from 'next';
|
import { GetServerSidePropsContext } from 'next';
|
||||||
|
import { Session } from 'next-auth';
|
||||||
|
import { SessionProvider, getSession } from 'next-auth/react';
|
||||||
import { appWithTranslation } from 'next-i18next';
|
import { appWithTranslation } from 'next-i18next';
|
||||||
import { AppProps } from 'next/app';
|
import { AppProps } from 'next/app';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import 'video.js/dist/video-js.css';
|
import 'video.js/dist/video-js.css';
|
||||||
import { env } from '~/env.js';
|
import { env } from '~/env.js';
|
||||||
|
import { ConfigType } from '~/types/config';
|
||||||
import { api } from '~/utils/api';
|
import { api } from '~/utils/api';
|
||||||
|
|
||||||
import nextI18nextConfig from '../../next-i18next.config.js';
|
import nextI18nextConfig from '../../next-i18next.config.js';
|
||||||
@@ -36,7 +39,6 @@ import {
|
|||||||
getServiceSidePackageAttributes,
|
getServiceSidePackageAttributes,
|
||||||
} from '../tools/server/getPackageVersion';
|
} from '../tools/server/getPackageVersion';
|
||||||
import { theme } from '../tools/server/theme/theme';
|
import { theme } from '../tools/server/theme/theme';
|
||||||
import { ConfigType } from '~/types/config';
|
|
||||||
|
|
||||||
function App(
|
function App(
|
||||||
this: any,
|
this: any,
|
||||||
@@ -47,13 +49,20 @@ function App(
|
|||||||
defaultColorScheme: ColorScheme;
|
defaultColorScheme: ColorScheme;
|
||||||
config?: ConfigType;
|
config?: ConfigType;
|
||||||
configName?: string;
|
configName?: string;
|
||||||
|
session: Session;
|
||||||
}>
|
}>
|
||||||
) {
|
) {
|
||||||
const { Component, pageProps } = props;
|
const { Component, pageProps } = props;
|
||||||
|
|
||||||
const [primaryColor, setPrimaryColor] = useState<MantineTheme['primaryColor']>(props.pageProps.config?.settings.customization.colors.primary || 'red');
|
const [primaryColor, setPrimaryColor] = useState<MantineTheme['primaryColor']>(
|
||||||
const [secondaryColor, setSecondaryColor] = useState<MantineTheme['primaryColor']>(props.pageProps.config?.settings.customization.colors.secondary || 'orange');
|
props.pageProps.config?.settings.customization.colors.primary || 'red'
|
||||||
const [primaryShade, setPrimaryShade] = useState<MantineTheme['primaryShade']>(props.pageProps.config?.settings.customization.colors.shade || 6);
|
);
|
||||||
|
const [secondaryColor, setSecondaryColor] = useState<MantineTheme['primaryColor']>(
|
||||||
|
props.pageProps.config?.settings.customization.colors.secondary || 'orange'
|
||||||
|
);
|
||||||
|
const [primaryShade, setPrimaryShade] = useState<MantineTheme['primaryShade']>(
|
||||||
|
props.pageProps.config?.settings.customization.colors.shade || 6
|
||||||
|
);
|
||||||
const colorTheme = {
|
const colorTheme = {
|
||||||
primaryColor,
|
primaryColor,
|
||||||
secondaryColor,
|
secondaryColor,
|
||||||
@@ -97,62 +106,64 @@ function App(
|
|||||||
<Head>
|
<Head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||||
</Head>
|
</Head>
|
||||||
<PersistQueryClientProvider
|
<SessionProvider session={pageProps.session}>
|
||||||
client={queryClient}
|
<PersistQueryClientProvider
|
||||||
persistOptions={{ persister: asyncStoragePersister }}
|
client={queryClient}
|
||||||
>
|
persistOptions={{ persister: asyncStoragePersister }}
|
||||||
<ColorSchemeProvider colorScheme={colorScheme} toggleColorScheme={toggleColorScheme}>
|
>
|
||||||
<ColorTheme.Provider value={colorTheme}>
|
<ColorSchemeProvider colorScheme={colorScheme} toggleColorScheme={toggleColorScheme}>
|
||||||
<MantineProvider
|
<ColorTheme.Provider value={colorTheme}>
|
||||||
theme={{
|
<MantineProvider
|
||||||
...theme,
|
theme={{
|
||||||
components: {
|
...theme,
|
||||||
Checkbox: {
|
components: {
|
||||||
styles: {
|
Checkbox: {
|
||||||
input: { cursor: 'pointer' },
|
styles: {
|
||||||
label: { cursor: 'pointer' },
|
input: { cursor: 'pointer' },
|
||||||
|
label: { cursor: 'pointer' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Switch: {
|
||||||
|
styles: {
|
||||||
|
input: { cursor: 'pointer' },
|
||||||
|
label: { cursor: 'pointer' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Switch: {
|
primaryColor,
|
||||||
styles: {
|
primaryShade,
|
||||||
input: { cursor: 'pointer' },
|
colorScheme,
|
||||||
label: { cursor: 'pointer' },
|
}}
|
||||||
},
|
withGlobalStyles
|
||||||
},
|
withNormalizeCSS
|
||||||
},
|
>
|
||||||
primaryColor,
|
<ConfigProvider {...props.pageProps}>
|
||||||
primaryShade,
|
<Notifications limit={4} position="bottom-left" />
|
||||||
colorScheme,
|
<ModalsProvider
|
||||||
}}
|
modals={{
|
||||||
withGlobalStyles
|
editApp: EditAppModal,
|
||||||
withNormalizeCSS
|
selectElement: SelectElementModal,
|
||||||
>
|
integrationOptions: WidgetsEditModal,
|
||||||
<ConfigProvider {...props.pageProps}>
|
integrationRemove: WidgetsRemoveModal,
|
||||||
<Notifications limit={4} position="bottom-left" />
|
categoryEditModal: CategoryEditModal,
|
||||||
<ModalsProvider
|
changeAppPositionModal: ChangeAppPositionModal,
|
||||||
modals={{
|
changeIntegrationPositionModal: ChangeWidgetPositionModal,
|
||||||
editApp: EditAppModal,
|
}}
|
||||||
selectElement: SelectElementModal,
|
>
|
||||||
integrationOptions: WidgetsEditModal,
|
<Component {...pageProps} />
|
||||||
integrationRemove: WidgetsRemoveModal,
|
</ModalsProvider>
|
||||||
categoryEditModal: CategoryEditModal,
|
</ConfigProvider>
|
||||||
changeAppPositionModal: ChangeAppPositionModal,
|
</MantineProvider>
|
||||||
changeIntegrationPositionModal: ChangeWidgetPositionModal,
|
</ColorTheme.Provider>
|
||||||
}}
|
</ColorSchemeProvider>
|
||||||
>
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
<Component {...pageProps} />
|
</PersistQueryClientProvider>
|
||||||
</ModalsProvider>
|
</SessionProvider>
|
||||||
</ConfigProvider>
|
|
||||||
</MantineProvider>
|
|
||||||
</ColorTheme.Provider>
|
|
||||||
</ColorSchemeProvider>
|
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
|
||||||
</PersistQueryClientProvider>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
App.getInitialProps = ({ ctx }: { ctx: GetServerSidePropsContext }) => {
|
App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => {
|
||||||
if (process.env.DISABLE_EDIT_MODE === 'true') {
|
if (process.env.DISABLE_EDIT_MODE === 'true') {
|
||||||
Consola.warn(
|
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'
|
'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}`);
|
Consola.debug(`Overriding the default color scheme with ${env.DEFAULT_COLOR_SCHEME}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = await getSession(ctx);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pageProps: {
|
pageProps: {
|
||||||
colorScheme: getCookie('color-scheme', ctx) || 'light',
|
colorScheme: getCookie('color-scheme', ctx) || 'light',
|
||||||
packageAttributes: getServiceSidePackageAttributes(),
|
packageAttributes: getServiceSidePackageAttributes(),
|
||||||
editModeEnabled: process.env.DISABLE_EDIT_MODE !== 'true',
|
editModeEnabled: process.env.DISABLE_EDIT_MODE !== 'true',
|
||||||
defaultColorScheme: env.DEFAULT_COLOR_SCHEME,
|
defaultColorScheme: env.DEFAULT_COLOR_SCHEME,
|
||||||
|
session,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { signInSchema } from '~/validations/user';
|
|||||||
import { loginNamespaces } from '../tools/server/translation-namespaces';
|
import { loginNamespaces } from '../tools/server/translation-namespaces';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { t } = useTranslation('authentication/login');
|
const { t } = useTranslation(['authentication/login']);
|
||||||
const queryParams = useRouter().query as { error?: 'CredentialsSignin' | (string & {}) };
|
const queryParams = useRouter().query as { error?: 'CredentialsSignin' | (string & {}) };
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof signInSchema>>({
|
const form = useForm<z.infer<typeof signInSchema>>({
|
||||||
@@ -63,7 +63,6 @@ export default function LoginPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
id="password"
|
|
||||||
variant="filled"
|
variant="filled"
|
||||||
label={t('form.fields.password.label')}
|
label={t('form.fields.password.label')}
|
||||||
withAsterisk
|
withAsterisk
|
||||||
|
|||||||
140
src/pages/register.tsx
Normal file
140
src/pages/register.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { Button, Card, Flex, PasswordInput, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||||
|
import { useForm, zodResolver } from '@mantine/form';
|
||||||
|
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||||
|
import { IconCheck, IconX } from '@tabler/icons-react';
|
||||||
|
import { GetServerSideProps } from 'next';
|
||||||
|
import { useTranslation } from 'next-i18next';
|
||||||
|
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { prisma } from '~/server/db';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
|
||||||
|
import { signUpFormSchema } from '~/validations/user';
|
||||||
|
|
||||||
|
import { registerNamespaces } from '../tools/server/translation-namespaces';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { t } = useTranslation('authentication/register');
|
||||||
|
const { i18nZodResolver } = useI18nZodResolver();
|
||||||
|
const router = useRouter();
|
||||||
|
const query = router.query as { token: string };
|
||||||
|
const { mutateAsync } = api.user.register.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof signUpFormSchema>>({
|
||||||
|
validateInputOnChange: true,
|
||||||
|
validateInputOnBlur: true,
|
||||||
|
validate: i18nZodResolver(signUpFormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (values: z.infer<typeof signUpFormSchema>) => {
|
||||||
|
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: <IconCheck />,
|
||||||
|
});
|
||||||
|
router.push('/login');
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
updateNotification({
|
||||||
|
id: notificationId,
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Something went wrong',
|
||||||
|
color: 'red',
|
||||||
|
icon: <IconX />,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex h="100dvh" display="flex" w="100%" direction="column" align="center" justify="center">
|
||||||
|
<Card withBorder shadow="md" p="xl" radius="md" w="90%" maw={420}>
|
||||||
|
<Title align="center" weight={900}>
|
||||||
|
{t('title')}
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Text color="dimmed" size="sm" align="center" mt={5} mb="md">
|
||||||
|
{t('text')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack>
|
||||||
|
<TextInput
|
||||||
|
variant="filled"
|
||||||
|
label={t('form.fields.username.label')}
|
||||||
|
withAsterisk
|
||||||
|
{...form.getInputProps('username')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
variant="filled"
|
||||||
|
label={t('form.fields.password.label')}
|
||||||
|
withAsterisk
|
||||||
|
{...form.getInputProps('password')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
variant="filled"
|
||||||
|
label={t('form.fields.passwordConfirmation.label')}
|
||||||
|
withAsterisk
|
||||||
|
{...form.getInputProps('passwordConfirmation')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button fullWidth type="submit">
|
||||||
|
{t('form.buttons.submit')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ import { mediaServerRouter } from './routers/media-server';
|
|||||||
import { overseerrRouter } from './routers/overseerr';
|
import { overseerrRouter } from './routers/overseerr';
|
||||||
import { rssRouter } from './routers/rss';
|
import { rssRouter } from './routers/rss';
|
||||||
import { usenetRouter } from './routers/usenet/router';
|
import { usenetRouter } from './routers/usenet/router';
|
||||||
|
import { userRouter } from './routers/user';
|
||||||
import { weatherRouter } from './routers/weather';
|
import { weatherRouter } from './routers/weather';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,6 +24,7 @@ import { weatherRouter } from './routers/weather';
|
|||||||
export const rootRouter = createTRPCRouter({
|
export const rootRouter = createTRPCRouter({
|
||||||
app: appRouter,
|
app: appRouter,
|
||||||
rss: rssRouter,
|
rss: rssRouter,
|
||||||
|
user: userRouter,
|
||||||
config: configRouter,
|
config: configRouter,
|
||||||
docker: dockerRouter,
|
docker: dockerRouter,
|
||||||
icon: iconRouter,
|
icon: iconRouter,
|
||||||
|
|||||||
66
src/server/api/routers/user.ts
Normal file
66
src/server/api/routers/user.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import superjson from 'superjson';
|
|||||||
import { ZodError } from 'zod';
|
import { ZodError } from 'zod';
|
||||||
|
|
||||||
import { getServerAuthSession } from '../auth';
|
import { getServerAuthSession } from '../auth';
|
||||||
|
import { prisma } from '../db';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 1. CONTEXT
|
* 1. CONTEXT
|
||||||
@@ -38,6 +39,7 @@ interface CreateContextOptions {
|
|||||||
*/
|
*/
|
||||||
const createInnerTRPCContext = (opts: CreateContextOptions) => ({
|
const createInnerTRPCContext = (opts: CreateContextOptions) => ({
|
||||||
session: opts.session,
|
session: opts.session,
|
||||||
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const dashboardNamespaces = [
|
export const dashboardNamespaces = [
|
||||||
'common',
|
'common',
|
||||||
|
'zod',
|
||||||
'layout/element-selector/selector',
|
'layout/element-selector/selector',
|
||||||
'layout/modals/add-app',
|
'layout/modals/add-app',
|
||||||
'layout/modals/change-position',
|
'layout/modals/change-position',
|
||||||
@@ -48,4 +49,6 @@ export const dashboardNamespaces = [
|
|||||||
'widgets/location',
|
'widgets/location',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const loginNamespaces = ['authentication/login'];
|
export const loginNamespaces = ['authentication/login', 'zod'];
|
||||||
|
|
||||||
|
export const registerNamespaces = ['authentication/register', 'zod'];
|
||||||
|
|||||||
128
src/utils/i18n-zod-resolver.ts
Normal file
128
src/utils/i18n-zod-resolver.ts
Normal file
@@ -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'>) =>
|
||||||
|
<TSchema extends ZodSchema<Record<string, any>>>(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<string, any>;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { CustomErrorParams } from '~/utils/i18n-zod-resolver';
|
||||||
|
|
||||||
export const signInSchema = z.object({
|
export const signInSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const signUpFormSchema = z.object({
|
export const signUpFormSchema = z
|
||||||
username: z.string(),
|
.object({
|
||||||
password: z.string().min(8),
|
username: z.string().min(3),
|
||||||
acceptTos: z.boolean(),
|
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'],
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user