♻️ Address pull request feedback

This commit is contained in:
Meier Lukas
2023-08-06 14:12:39 +02:00
parent 4b2c5f2816
commit 9e576f1498
53 changed files with 934 additions and 746 deletions

View File

@@ -1,4 +1,5 @@
{
"metaTitle": "Create Account",
"title": "Create Account",
"text": "Please define your credentials below",
"form": {
@@ -16,5 +17,19 @@
"buttons": {
"submit": "Create account"
}
},
"notifications": {
"loading": {
"title": "Creating account",
"text": "Please wait"
},
"success": {
"title": "Account created",
"text": "Your account has been created successfully"
},
"error": {
"title": "Error",
"text": "Something went wrong"
}
}
}

View File

@@ -1,4 +1,5 @@
{
"metaTitle": "Login",
"title": "Welcome back!",
"text": "Please enter your credentials",
"form": {

View File

@@ -1,28 +0,0 @@
{
"title": "Boards",
"cards": {
"statistics": {
"apps": "Apps",
"widgets": "Widgets",
"categories": "Categories"
},
"buttons": {
"view": "View board"
},
"menu": {
"setAsDefault": "Set as your default board",
"delete": {
"label": "Delete permanently",
"disabled": "Deletion disabled, because older Homarr components still rely on this.",
"modalTitle": "Delete board"
}
},
"badges": {
"fileSystem": "File system",
"default": "Default"
}
},
"buttons": {
"create": "Create new dashboard"
}
}

View File

@@ -35,9 +35,5 @@
"small": "small",
"medium": "medium",
"large": "large"
},
"header": {
"logout": "Logout",
"sign-in": "Sign in"
}
}

View File

@@ -16,6 +16,7 @@
"switchTheme": "Switch theme",
"preferences": "User preferences",
"defaultBoard": "Default dashboard",
"manage": "Manage",
"about": {
"label": "About",
"new": "New"
@@ -23,5 +24,11 @@
"logout": "Logout from {{username}}",
"login": "Login"
}
},
"modals": {
"movie": {
"title": "",
"topResults": "Top {{count}} results for <b>{{search}}</b>."
}
}
}

View File

@@ -0,0 +1,26 @@
{
"navigation": {
"home": {
"title": "Home"
},
"boards": {
"title": "Boards"
},
"users": {
"title": "Users",
"items": {
"manage": "Manage",
"invites": "Invites"
}
},
"help": {
"title": "Help",
"items": {
"documentation": "Documentation",
"report": "Report an issue / bug",
"discord": "Community Discord",
"contribute": "Contribute"
}
}
}
}

View File

@@ -0,0 +1,44 @@
{
"metaTitle": "Boards",
"pageTitle": "Boards",
"cards": {
"statistics": {
"apps": "Apps",
"widgets": "Widgets",
"categories": "Categories"
},
"buttons": {
"view": "View board"
},
"menu": {
"setAsDefault": "Set as your default board",
"delete": {
"label": "Delete permanently",
"disabled": "Deletion disabled, because older Homarr components still rely on this."
}
},
"badges": {
"fileSystem": "File system",
"default": "Default"
}
},
"buttons": {
"create": "Create new board"
},
"modals": {
"delete": {
"title": "Delete board",
"text": "Are you sure, that you want to delete this board? This action cannot be undone and your data will be lost permanently."
},
"create": {
"title": "Create board",
"text": "The name cannot be changed after a board has been created.",
"form": {
"name": {
"label": "Name"
},
"submit": "Create"
}
}
}
}

View File

@@ -0,0 +1,23 @@
{
"metaTitle": "Manage",
"hero": {
"title": "Welcome back, {{username}}",
"fallbackUsername": "Anonymous",
"subtitle": "Welcome to Your Application Hub. Organize, Optimize and Conquer!"
},
"quickActions": {
"title": "Quick actions",
"boards": {
"title": "Your boards",
"subtitle": "Create and manage your boards"
},
"inviteUsers": {
"title": "Invite a new user",
"subtitle": "Create and send an invitation for registration"
},
"manageUsers": {
"title": "Manage users",
"subtitle": "Delete and manage your users"
}
}
}

View File

@@ -10,7 +10,10 @@
}
},
"modals": {
"delete": "Delete user {{name}}"
"delete": {
"title": "Delete user {{name}}",
"text": "Are you sure, that you want to delete the user {{name}}? This will delete data associated with this account, but not any created dashboards by this user."
}
},
"searchDoesntMatch": "Your search does not match any entries. Please adjust your filter."
}

View File

@@ -1,4 +1,5 @@
{
"metaTitle": "Create user",
"steps": {
"account": {
"title": "First step",
@@ -15,7 +16,13 @@
"text": "Password",
"password": {
"label": "Password",
"requirement": "Includes at least 6 characters"
"requirements": {
"number": "Includes number",
"lowercase": "Includes lowercase letter",
"uppercase": "Includes uppercase letter",
"special": "Includes special character",
"length": "Includes at least {{count}} characters"
}
}
},
"finish": {
@@ -35,15 +42,20 @@
},
"notSet": "Not set",
"valid": "Valid"
}
},
"alertConfirmed": "User has been created in the database. They can now log in."
"completed": {
"alert": {
"title": "User was created",
"text": "The user was created in the database. They can now log in."
}
}
},
"buttons": {
"next": "Next",
"previous": "Previous",
"confirm": "Confirm",
"generateRandomPw": "Generate random",
"generateRandomPassword": "Generate random",
"createAnother": "Create another",
"goBack": "Go back to users"
}

View File

@@ -0,0 +1,50 @@
{
"metaTitle": "User invites",
"pageTitle": "Manage user invites",
"description": "Using invites, you can invite users to your Homarr instance. An invitation will only be valid for a certain time-span and can be used once. The expiration must be between 5 minutes and 12 months upon creation.",
"button": {
"createInvite": "Create invitation",
"deleteInvite": "Delete invite"
},
"table": {
"header": {
"id": "ID",
"creator": "Creator",
"expires": "Expires",
"action": "Actions"
},
"data": {
"expiresAt": "expired {{at}}",
"expiresIn": "in {{in}}"
}
},
"modals": {
"create": {
"title": "Create invite",
"description": "After the expiration, an invite will no longer be valid and the recipient of the invite won't be able to create an account.",
"form": {
"expires": {
"label": "Expiration date"
},
"submit": "Create"
}
},
"copy": {
"title": "Copy invitation",
"description": "Your invitation has been generated. After this modal closes, <b>you'll not be able to copy this link anymore</b>. If you do no longer wish to invite said person, you can delete this invitation any time.",
"invitationLink": "Invitation link",
"details": {
"id": "ID",
"token": "Token"
},
"button": {
"close": "Copy & Dismiss"
}
},
"delete": {
"title": "Delete invite",
"description": "Are you sure, that you want to delete this invitation? Users with this link will no longer be able to create an account using that link."
}
},
"noInvites": "There are no invitations yet."
}

View File

@@ -1,21 +0,0 @@
{
"title": "Manage user invites",
"text": "Using invites, you can invite users to your Homarr instance. An invitation will only be valid for a certain time-span and can be used once. The expiration must be between 5 minutes and 12 months upon creation.",
"button": {
"createInvite": "Create invitation",
"deleteInvite": "Delete invite"
},
"table": {
"header": {
"id": "ID",
"creator": "Creator",
"expires": "Expires",
"action": "Actions"
},
"data": {
"expiresAt": "expired {{at}}",
"expiresIn": "in {{in}}"
}
},
"noInvites": "There are no invitations yet."
}

View File

@@ -27,6 +27,10 @@
},
"population": {
"fallback": "Unknown"
},
"nothingFound": {
"title": "Nothing found",
"description": "Please try another search term"
}
}
}

View File

@@ -12,11 +12,11 @@
"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}}"
"string": "This field must be at most {{maximum}} characters long",
"number": "This field must be less than or equal to {{maximum}}"
},
"custom": {
"password_match": "Passwords must match"
"passwordMatch": "Passwords must match"
}
}
}

View File

@@ -180,25 +180,3 @@ const useStyles = createStyles(({ colors, colorScheme, radius }) => ({
},
},
}));
/*
<BackgroundChanger />
<Stack spacing="xs" my="md">
<Text>{t('settings/customization/color-selector:colors')}</Text>
<Grid>
<Grid.Col sm={12} md={6}>
<ColorSelector type="primary" defaultValue="red" />
</Grid.Col>
<Grid.Col sm={12} md={6}>
<ColorSelector type="secondary" defaultValue="orange" />
</Grid.Col>
<Grid.Col sm={12} md={6}>
<ShadeSelector />
</Grid.Col>
</Grid>
</Stack>
<DashboardTilesOpacitySelector />
<CustomCssChanger />
*/

View File

@@ -1,7 +1,7 @@
import { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core';
import { createFormContext } from '@mantine/form';
import { z } from 'zod';
import { boardCustomizationSchema } from '~/validations/dashboards';
import { boardCustomizationSchema } from '~/validations/boards';
export const [
BoardCustomizationFormProvider,

View File

@@ -168,8 +168,8 @@ const CitySelectModal = ({ opened, closeModal, query, onCitySelected }: CitySele
<Center>
<Stack align="center">
<IconAlertTriangle />
<Title order={6}>Nothing found</Title>
<Text>Nothing was found, please try again</Text>
<Title order={6}>{t('modal.table.nothingFound.title')}</Title>
<Text>{t('modal.table.nothingFound.description')}</Text>
</Stack>
</Center>
</Modal>

View File

@@ -1,16 +1,18 @@
import { Button, Group, Stack, Text, TextInput } from '@mantine/core';
import { Button, Group, Stack, Text, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { ContextModalProps, modals } from '@mantine/modals';
import { Trans, useTranslation } from 'next-i18next';
import { getStaticFallbackConfig } from '~/tools/config/getFallbackConfig';
import { api } from '~/utils/api';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
import { createDashboardSchemaValidation } from '~/validations/dashboards';
import { createBoardSchemaValidation } from '~/validations/boards';
export const CreateDashboardModal = ({ context, id, innerProps }: ContextModalProps<{}>) => {
const apiContext = api.useContext();
export const CreateBoardModal = ({ id }: ContextModalProps<{}>) => {
const { t } = useTranslation('manage/boards');
const utils = api.useContext();
const { isLoading, mutate } = api.config.save.useMutation({
onSuccess: async () => {
await apiContext.config.all.invalidate();
await utils.config.all.invalidate();
modals.close(id);
},
});
@@ -21,7 +23,7 @@ export const CreateDashboardModal = ({ context, id, innerProps }: ContextModalPr
initialValues: {
name: '',
},
validate: i18nZodResolver(createDashboardSchemaValidation),
validate: i18nZodResolver(createBoardSchemaValidation),
});
const handleSubmit = () => {
@@ -35,9 +37,13 @@ export const CreateDashboardModal = ({ context, id, innerProps }: ContextModalPr
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<Text>A name cannot be changed after a dashboard has been created.</Text>
<Text>{t('modals.create.text')}</Text>
<TextInput label="Name" withAsterisk {...form.getInputProps('name')} />
<TextInput
label={t('modals.create.form.name.label')}
withAsterisk
{...form.getInputProps('name')}
/>
<Group grow>
<Button
@@ -48,7 +54,7 @@ export const CreateDashboardModal = ({ context, id, innerProps }: ContextModalPr
color="gray"
type="button"
>
Cancel
{t('common:cancel')}
</Button>
<Button
type="submit"
@@ -57,10 +63,22 @@ export const CreateDashboardModal = ({ context, id, innerProps }: ContextModalPr
variant="light"
color="green"
>
Create
{t('modals.create.form.submit')}
</Button>
</Group>
</Stack>
</form>
);
};
export const openCreateBoardModal = () => {
modals.openContextModal({
modal: 'createBoardModal',
title: (
<Title order={4}>
<Trans i18nKey="manage/boards:modals.create.title" />
</Title>
),
innerProps: {},
});
};

View File

@@ -1,26 +1,23 @@
import { Button, Group, Stack, Text } from '@mantine/core';
import { Button, Group, Stack, Text, Title } from '@mantine/core';
import { ContextModalProps, modals } from '@mantine/modals';
import { Trans, useTranslation } from 'next-i18next';
import { api } from '~/utils/api';
export const DeleteBoardModal = ({
context,
id,
innerProps,
}: ContextModalProps<{ boardName: string; onConfirm: () => Promise<void> }>) => {
const apiContext = api.useContext();
type InnerProps = { boardName: string; onConfirm: () => Promise<void> };
export const DeleteBoardModal = ({ id, innerProps }: ContextModalProps<InnerProps>) => {
const { t } = useTranslation('manage/boards');
const utils = api.useContext();
const { isLoading, mutateAsync } = api.config.delete.useMutation({
onSuccess: async () => {
await apiContext.config.all.invalidate();
await utils.config.all.invalidate();
modals.close(id);
},
});
return (
<Stack>
<Text>
Are you sure, that you want to delete this board? This action cannot be undone and your data
will be lost permanently.
</Text>
<Text>{t('modals.delete.text')}</Text>
<Group grow>
<Button
@@ -30,7 +27,7 @@ export const DeleteBoardModal = ({
variant="light"
color="gray"
>
Cancel
{t('common:cancel')}
</Button>
<Button
onClick={async () => {
@@ -44,9 +41,21 @@ export const DeleteBoardModal = ({
variant="light"
color="red"
>
Delete
{t('common:delete')}
</Button>
</Group>
</Stack>
);
};
export const openDeleteBoardModal = (innerProps: InnerProps) => {
modals.openContextModal({
modal: 'deleteBoardModal',
title: (
<Title order={4}>
<Trans i18nKey="manage/boards:modals.delete.title" />
</Title>
),
innerProps,
});
};

View File

@@ -1,8 +1,9 @@
import { Button, Card, Flex, TextInput } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import { useForm } from '@mantine/form';
import { IconArrowRight, IconAt, IconUser } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { z } from 'zod';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
interface CreateAccountStepProps {
nextStep: ({ eMail, username }: { username: string; eMail: string }) => void;
@@ -10,7 +11,14 @@ interface CreateAccountStepProps {
defaultEmail: string;
}
export const CreateAccountStep = ({ defaultEmail, defaultUsername, nextStep }: CreateAccountStepProps) => {
export const CreateAccountStep = ({
defaultEmail,
defaultUsername,
nextStep,
}: CreateAccountStepProps) => {
const { t } = useTranslation('manage/users/create');
const { i18nZodResolver } = useI18nZodResolver();
const form = useForm({
initialValues: {
username: defaultUsername,
@@ -18,11 +26,9 @@ export const CreateAccountStep = ({ defaultEmail, defaultUsername, nextStep }: C
},
validateInputOnBlur: true,
validateInputOnChange: true,
validate: zodResolver(createAccountStepValidationSchema),
validate: i18nZodResolver(createAccountStepValidationSchema),
});
const { t } = useTranslation('user/create');
return (
<Card mih={400}>
<TextInput

View File

@@ -0,0 +1,112 @@
import { Button, Card, Group, Table, Text, Title } from '@mantine/core';
import {
IconArrowLeft,
IconCheck,
IconInfoCircle,
IconKey,
IconMail,
IconUser,
} from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { CreateAccountSchema } from '~/pages/manage/users/create';
import { api } from '~/utils/api';
type ReviewInputStepProps = {
values: CreateAccountSchema;
prevStep: () => void;
nextStep: () => void;
};
export const ReviewInputStep = ({ values, prevStep, nextStep }: ReviewInputStepProps) => {
const { t } = useTranslation('manage/users/create');
const utils = api.useContext();
const { mutateAsync: createAsync, isLoading } = api.user.create.useMutation({
onSettled: () => {
void utils.user.all.invalidate();
},
onSuccess: () => {
nextStep();
},
});
return (
<Card mih={400}>
<Title order={5}>{t('steps.finish.card.title')}</Title>
<Text mb="xl">{t('steps.finish.card.text')}</Text>
<Table mb="lg" withBorder highlightOnHover>
<thead>
<tr>
<th>{t('steps.finish.table.header.property')}</th>
<th>{t('steps.finish.table.header.value')}</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<Group spacing="xs">
<IconUser size="1rem" />
<Text>{t('steps.finish.table.header.username')}</Text>
</Group>
</td>
<td>{values.account.username}</td>
</tr>
<tr>
<td>
<Group spacing="xs">
<IconMail size="1rem" />
<Text>{t('steps.finish.table.header.email')}</Text>
</Group>
</td>
<td>
{values.account.eMail ? (
<Text>{values.account.eMail}</Text>
) : (
<Group spacing="xs">
<IconInfoCircle size="1rem" color="orange" />
<Text color="orange">{t('steps.finish.table.notSet')}</Text>
</Group>
)}
</td>
</tr>
<tr>
<td>
<Group spacing="xs">
<IconKey size="1rem" />
<Text>{t('steps.finish.table.password')}</Text>
</Group>
</td>
<td>
<Group spacing="xs">
<IconCheck size="1rem" color="green" />
<Text color="green">{t('steps.finish.table.valid')}</Text>
</Group>
</td>
</tr>
</tbody>
</Table>
<Group position="apart" noWrap>
<Button leftIcon={<IconArrowLeft size="1rem" />} onClick={prevStep} variant="light" px="xl">
{t('buttons.previous')}
</Button>
<Button
onClick={async () => {
await createAsync({
username: values.account.username,
password: values.security.password,
email: values.account.eMail === '' ? undefined : values.account.eMail,
});
}}
loading={isLoading}
rightIcon={<IconCheck size="1rem" />}
variant="light"
px="xl"
>
{t('buttons.confirm')}
</Button>
</Group>
</Card>
);
};

View File

@@ -9,7 +9,7 @@ import {
Progress,
Text,
} from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import { useForm } from '@mantine/form';
import {
IconArrowLeft,
IconArrowRight,
@@ -18,21 +18,22 @@ import {
IconKey,
IconX,
} from '@tabler/icons-react';
import { useState } from 'react';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { z } from 'zod';
import { api } from '~/utils/api';
import { passwordSchema } from '~/validations/user';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
import { minPasswordLength, passwordSchema } from '~/validations/user';
const requirements = [
{ re: /[0-9]/, label: 'Includes number' },
{ re: /[a-z]/, label: 'Includes lowercase letter' },
{ re: /[A-Z]/, label: 'Includes uppercase letter' },
{ re: /[$&+,:;=?@#|'<>.^*()%!-]/, label: 'Includes special symbol' },
{ re: /[0-9]/, label: 'number' },
{ re: /[a-z]/, label: 'lowercase' },
{ re: /[A-Z]/, label: 'uppercase' },
{ re: /[$&+,:;=?@#|'<>.^*()%!-]/, label: 'special' },
];
function getStrength(password: string) {
let multiplier = password.length > 5 ? 0 : 1;
let multiplier = password.length >= minPasswordLength ? 0 : 1;
requirements.forEach((requirement) => {
if (!requirement.re.test(password)) {
@@ -54,13 +55,16 @@ export const CreateAccountSecurityStep = ({
nextStep,
prevStep,
}: CreateAccountSecurityStepProps) => {
const { t } = useTranslation('manage/users/create');
const { i18nZodResolver } = useI18nZodResolver();
const form = useForm({
initialValues: {
password: defaultPassword,
},
validateInputOnBlur: true,
validateInputOnChange: true,
validate: zodResolver(createAccountSecurityStepValidationSchema),
validate: i18nZodResolver(createAccountSecurityStepValidationSchema),
});
const { mutateAsync, isLoading } = api.password.generate.useMutation();
@@ -74,8 +78,6 @@ export const CreateAccountSecurityStep = ({
/>
));
const { t } = useTranslation('user/create');
const strength = getStrength(form.values.password);
const color = strength === 100 ? 'teal' : strength > 50 ? 'yellow' : 'red';
@@ -114,7 +116,7 @@ export const CreateAccountSecurityStep = ({
variant="default"
mt="xl"
>
{t('buttons.generateRandomPw')}
{t('buttons.generateRandomPassword')}
</Button>
</Flex>
</div>
@@ -122,8 +124,8 @@ export const CreateAccountSecurityStep = ({
<Popover.Dropdown>
<Progress color={color} value={strength} size={5} mb="xs" />
<PasswordRequirement
label={t('steps.security.password.requirement')}
meets={form.values.password.length > 5}
label="length"
meets={form.values.password.length >= minPasswordLength}
/>
{checks}
</Popover.Dropdown>
@@ -152,6 +154,8 @@ export const CreateAccountSecurityStep = ({
};
const PasswordRequirement = ({ meets, label }: { meets: boolean; label: string }) => {
const { t } = useTranslation('manage/users/create');
return (
<Text
color={meets ? 'teal' : 'red'}
@@ -159,7 +163,12 @@ const PasswordRequirement = ({ meets, label }: { meets: boolean; label: string }
mt={7}
size="sm"
>
{meets ? <IconCheck size="0.9rem" /> : <IconX size="0.9rem" />} <Box ml={10}>{label}</Box>
{meets ? <IconCheck size="0.9rem" /> : <IconX size="0.9rem" />}{' '}
<Box ml={10}>
{t(`steps.security.password.requirements.${label}`, {
count: minPasswordLength,
})}
</Box>
</Text>
);
};

View File

@@ -1,31 +1,38 @@
import { Button, CopyButton, Mark, Stack, Text } from '@mantine/core';
import { Button, CopyButton, Mark, Stack, Text, Title } from '@mantine/core';
import { ContextModalProps, modals } from '@mantine/modals';
import { Trans, useTranslation } from 'next-i18next';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { RouterOutputs } from '~/utils/api';
export const CopyInviteModal = ({
id,
innerProps,
}: ContextModalProps<{ id: string; token: string; expire: Date }>) => {
type InnerProps = RouterOutputs['invites']['create'];
export const CopyInviteModal = ({ id, innerProps }: ContextModalProps<InnerProps>) => {
const { t } = useTranslation('manage/users/invites');
const inviteUrl = useInviteUrl(innerProps.id, innerProps.token);
return (
<Stack>
<Text>
Your invitation has been generated. After this modal closes,{' '}
<b>you'll not be able to copy this link anymore</b>. If you do no longer wish to invite said
person, you can delete this invitation any time.
<Trans
i18nKey="manage/users/invites:modals.copy.description"
components={{
b: <b />,
}}
/>
</Text>
<Link href={`/auth/invite/${innerProps.id}?token=${innerProps.token}`}>Invitation link</Link>
<Link href={`/auth/invite/${innerProps.id}?token=${innerProps.token}`}>
{t('modals.copy.invitationLink')}
</Link>
<Stack spacing="xs">
<Text weight="bold">ID:</Text>
<Text weight="bold">{t('modals.copy.details.id')}:</Text>
<Mark style={{ borderRadius: 4 }} color="gray" px={5}>
{innerProps.id}
</Mark>
<Text weight="bold">Token:</Text>
<Text weight="bold">{t('modals.copy.details.token')}:</Text>
<Mark style={{ borderRadius: 4 }} color="gray" px={5}>
{innerProps.token}
</Mark>
@@ -41,7 +48,7 @@ export const CopyInviteModal = ({
variant="default"
fullWidth
>
Copy & Dismiss
{t('modals.copy.button.close')}
</Button>
)}
</CopyButton>
@@ -54,3 +61,15 @@ const useInviteUrl = (id: string, token: string) => {
return `${window.location.href.replace(router.pathname, `/auth/invite/${id}?token=${token}`)}`;
};
export const openCopyInviteModal = (data: InnerProps) => {
modals.openContextModal({
modal: 'copyInviteModal',
title: (
<Title order={4}>
<Trans i18nKey="manage/users/invites:modals.copy.title" />
</Title>
),
innerProps: data,
});
};

View File

@@ -1,24 +1,24 @@
import { Button, Group, Stack, Text } from '@mantine/core';
import { DateInput, DateTimePicker } from '@mantine/dates';
import { useForm, zodResolver } from '@mantine/form';
import { Button, Group, Stack, Text, Title } from '@mantine/core';
import { DateTimePicker } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { ContextModalProps, modals } from '@mantine/modals';
import dayjs from 'dayjs';
import { Trans, useTranslation } from 'next-i18next';
import { api } from '~/utils/api';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
import { createInviteSchema } from '~/validations/invite';
import { openCopyInviteModal } from './copy-invite.modal';
export const CreateInviteModal = ({ id }: ContextModalProps<{}>) => {
const apiContext = api.useContext();
const { t } = useTranslation('manage/users/invites');
const utils = api.useContext();
const { isLoading, mutateAsync } = api.invites.create.useMutation({
onSuccess: async (data) => {
await apiContext.invites.all.invalidate();
await utils.invites.all.invalidate();
modals.close(id);
modals.openContextModal({
modal: 'copyInviteModal',
title: <Text weight="bold">Copy invitation</Text>,
innerProps: data,
});
openCopyInviteModal(data);
},
});
@@ -36,10 +36,7 @@ export const CreateInviteModal = ({ id }: ContextModalProps<{}>) => {
return (
<Stack>
<Text>
After the expiration, an invite will no longer be valid and the recipient of the invite
won't be able to create an account.
</Text>
<Text>{t('modals.create.description')}</Text>
<DateTimePicker
popoverProps={{ withinPortal: true }}
@@ -47,7 +44,7 @@ export const CreateInviteModal = ({ id }: ContextModalProps<{}>) => {
maxDate={maxDate}
withAsterisk
valueFormat="DD MMM YYYY hh:mm A"
label="Expiration date"
label={t('modals.create.form.expires.label')}
variant="filled"
{...form.getInputProps('expirationDate')}
/>
@@ -60,7 +57,7 @@ export const CreateInviteModal = ({ id }: ContextModalProps<{}>) => {
variant="light"
color="gray"
>
Cancel
{t('common:cancel')}
</Button>
<Button
onClick={async () => {
@@ -72,9 +69,21 @@ export const CreateInviteModal = ({ id }: ContextModalProps<{}>) => {
variant="light"
color="green"
>
Create
{t('modals.create.form.submit')}
</Button>
</Group>
</Stack>
);
};
export const openCreateInviteModal = () => {
modals.openContextModal({
modal: 'createInviteModal',
title: (
<Title order={4}>
<Trans i18nKey="manage/users/invites:modals.create.title" />
</Title>
),
innerProps: {},
});
};

View File

@@ -1,25 +1,20 @@
import { Button, Group, Stack, Text } from '@mantine/core';
import { ContextModalProps, modals } from '@mantine/modals';
import { useTranslation } from 'next-i18next';
import { api } from '~/utils/api';
export const DeleteInviteModal = ({
context,
id,
innerProps,
}: ContextModalProps<{ tokenId: string }>) => {
const apiContext = api.useContext();
const { isLoading, mutateAsync } = api.invites.delete.useMutation({
export const DeleteInviteModal = ({ id, innerProps }: ContextModalProps<{ tokenId: string }>) => {
const { t } = useTranslation('manage/users/invites');
const utils = api.useContext();
const { isLoading, mutateAsync: deleteAsync } = api.invites.delete.useMutation({
onSuccess: async () => {
await apiContext.invites.all.invalidate();
await utils.invites.all.invalidate();
modals.close(id);
},
});
return (
<Stack>
<Text>
Are you sure, that you want to delete this invitation? Users with this link will no longer
be able to create an account using that link.
</Text>
<Text>{t('modals.delete.description')}</Text>
<Group grow>
<Button
@@ -29,11 +24,11 @@ export const DeleteInviteModal = ({
variant="light"
color="gray"
>
Cancel
{t('common:cancel')}
</Button>
<Button
onClick={async () => {
await mutateAsync({
await deleteAsync({
tokenId: innerProps.tokenId,
});
}}
@@ -41,7 +36,7 @@ export const DeleteInviteModal = ({
variant="light"
color="red"
>
Delete
{t('common:delete')}
</Button>
</Group>
</Stack>

View File

@@ -0,0 +1,56 @@
import { Button, Group, Stack, Text, Title } from '@mantine/core';
import { ContextModalProps, modals } from '@mantine/modals';
import { Trans, useTranslation } from 'next-i18next';
import { api } from '~/utils/api';
type InnerProps = { id: string; name: string };
export const DeleteUserModal = ({ id, innerProps }: ContextModalProps<InnerProps>) => {
const { t } = useTranslation('manage/users');
const utils = api.useContext();
const { isLoading, mutateAsync } = api.user.deleteUser.useMutation({
onSuccess: async () => {
await utils.user.all.invalidate();
modals.close(id);
},
});
return (
<Stack>
<Text>{t('modals.delete.text', innerProps)} </Text>
<Group grow>
<Button
onClick={() => {
modals.close(id);
}}
variant="light"
color="gray"
>
{t('common:cancel')}
</Button>
<Button
onClick={async () => {
await mutateAsync(innerProps);
}}
disabled={isLoading}
variant="light"
color="red"
>
{t('common:delete')}
</Button>
</Group>
</Stack>
);
};
export const openDeleteUserModal = (user: InnerProps) => {
modals.openContextModal({
modal: 'deleteUserModal',
title: (
<Title order={4}>
<Trans i18nKey="manage/users:modals.delete.title" values={{ name: user.name }} />
</Title>
),
innerProps: user,
});
};

View File

@@ -22,17 +22,19 @@ import {
IconLayoutDashboard,
IconMailForward,
IconQuestionMark,
IconSettings2,
IconUser,
IconUsers,
TablerIconsProps,
} from '@tabler/icons-react';
import { useSession } from 'next-auth/react';
import Image from 'next/image';
import Link from 'next/link';
import { ReactNode } from 'react';
import { ReactNode, RefObject, forwardRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
import { usePackageAttributesStore } from '~/tools/client/zustands/usePackageAttributesStore';
import { type navigation } from '../../../../public/locales/en/layout/manage.json';
import { MainHeader } from '../header/Header';
interface ManageLayoutProps {
@@ -40,7 +42,8 @@ interface ManageLayoutProps {
}
export const ManageLayout = ({ children }: ManageLayoutProps) => {
const { attributes } = usePackageAttributesStore();
const { t } = useTranslation('layout/manage');
const packageVersion = usePackageAttributesStore((x) => x.attributes.packageVersion);
const theme = useMantineTheme();
const screenLargerThanMd = useScreenLargerThan('md');
@@ -51,100 +54,19 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => {
const data = useSession();
const isAdmin = data.data?.user.isAdmin ?? false;
const navigationLinks = (
<>
<NavLink
icon={
<ThemeIcon size="md" variant="light" color="red">
<IconHome size="1rem" />
</ThemeIcon>
const navigationLinkComponents = Object.entries(navigationLinks).map(([name, navigationLink]) => {
if (navigationLink.onlyAdmin && !isAdmin) {
return null;
}
label="Home"
component={Link}
href="/manage/"
/>
<NavLink
label="Boards"
icon={
<ThemeIcon size="md" variant="light" color="red">
<IconLayoutDashboard size="1rem" />
</ThemeIcon>
}
component={Link}
href="/manage/boards"
/>
{isAdmin && (
<>
<NavLink
label="Users"
icon={
<ThemeIcon size="md" variant="light" color="red">
<IconUser size="1rem" />
</ThemeIcon>
}
>
<NavLink
icon={<IconUsers size="1rem" />}
label="Manage"
component={Link}
href="/manage/users"
return (
<CustomNavigationLink
key={name}
name={name as keyof typeof navigationLinks}
navigationLink={navigationLink}
/>
<NavLink
icon={<IconMailForward size="1rem" />}
label="Invites"
component={Link}
href="/manage/users/invites"
/>
</NavLink>
<NavLink
label="Settings"
icon={
<ThemeIcon size="md" variant="light" color="red">
<IconSettings2 size="1rem" />
</ThemeIcon>
}
component={Link}
href="/manage/settings"
/>
</>
)}
<NavLink
label="Help"
icon={
<ThemeIcon size="md" variant="light" color="red">
<IconQuestionMark size="1rem" />
</ThemeIcon>
}
>
<NavLink
icon={<IconBook2 size="1rem" />}
component="a"
href="https://homarr.dev/docs/about"
label="Documentation"
/>
<NavLink
icon={<IconBrandGithub size="1rem" />}
component="a"
href="https://github.com/ajnart/homarr/issues/new/choose"
label="Report an issue / bug"
/>
<NavLink
icon={<IconBrandDiscord size="1rem" />}
component="a"
href="https://discord.com/invite/aCsmEV5RgA"
label="Community Discord"
/>
<NavLink
icon={<IconGitFork size="1rem" />}
component="a"
href="https://github.com/ajnart/homarr"
label="Contribute"
/>
</NavLink>
</>
);
});
const burgerMenu = screenLargerThanMd ? undefined : (
<Burger opened={burgerMenuOpen} onClick={toggleBurgerMenu} />
@@ -161,7 +83,7 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => {
navbar={
<Navbar width={{ base: !screenLargerThanMd ? 0 : 220 }} hidden={!screenLargerThanMd}>
<Navbar.Section pt="xs" grow>
{navigationLinks}
{navigationLinkComponents}
</Navbar.Section>
</Navbar>
}
@@ -174,9 +96,9 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => {
<Text fw="bold" size={15}>
Homarr
</Text>
{attributes.packageVersion && (
{packageVersion && (
<Text color="dimmed" size={13}>
{attributes.packageVersion}
{packageVersion}
</Text>
)}
</Flex>
@@ -189,8 +111,126 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => {
</Paper>
</AppShell>
<Drawer opened={burgerMenuOpen} onClose={closeBurgerMenu}>
{navigationLinks}
{navigationLinkComponents}
</Drawer>
</>
);
};
type Icon = (props: TablerIconsProps) => JSX.Element;
type NavigationLinkHref = {
icon: Icon;
href: string;
onlyAdmin?: boolean;
};
type NavigationLinkItems<TItemsObject> = {
icon: Icon;
items: Record<keyof TItemsObject, NavigationLinkHref>;
onlyAdmin?: boolean;
};
type CustomNavigationLinkProps = {
name: keyof typeof navigationLinks;
navigationLink: (typeof navigationLinks)[keyof typeof navigationLinks];
};
const CustomNavigationLink = forwardRef<
HTMLAnchorElement | HTMLButtonElement,
CustomNavigationLinkProps
>(({ name, navigationLink }, ref) => {
const { t } = useTranslation('layout/manage');
const commonProps = {
label: t(`navigation.${name}.title`),
icon: (
<ThemeIcon size="md" variant="light" color="red">
<navigationLink.icon size={16} />
</ThemeIcon>
),
};
if ('href' in navigationLink) {
return (
<NavLink
{...commonProps}
ref={ref as RefObject<HTMLAnchorElement>}
component={Link}
href={navigationLink.href}
/>
);
}
return (
<NavLink {...commonProps} ref={ref as RefObject<HTMLButtonElement>}>
{Object.entries(navigationLink.items).map(([itemName, item]) => {
const commonItemProps = {
label: t(`navigation.${name}.items.${itemName}`),
icon: <item.icon size={16} />,
href: item.href,
};
if (item.href.startsWith('http')) {
return <NavLink {...commonItemProps} component="a" />;
}
return <NavLink {...commonItemProps} component={Link} />;
})}
</NavLink>
);
});
type NavigationLinks = {
[key in keyof typeof navigation]: (typeof navigation)[key] extends {
items: Record<string, string>;
}
? NavigationLinkItems<(typeof navigation)[key]['items']>
: NavigationLinkHref;
};
const navigationLinks: NavigationLinks = {
home: {
icon: IconHome,
href: '/manage',
},
boards: {
icon: IconLayoutDashboard,
href: '/manage/boards',
},
users: {
icon: IconUser,
onlyAdmin: true,
items: {
manage: {
icon: IconUsers,
href: '/manage/users',
},
invites: {
icon: IconMailForward,
href: '/manage/users/invites',
},
},
},
help: {
icon: IconQuestionMark,
items: {
documentation: {
icon: IconBook2,
href: 'https://homarr.dev/docs/about',
},
report: {
icon: IconBrandGithub,
href: 'https://github.com/ajnart/homarr/issues/new/choose',
},
discord: {
icon: IconBrandDiscord,
href: 'https://discord.com/invite/aCsmEV5RgA',
},
contribute: {
icon: IconGitFork,
href: 'https://github.com/ajnart/homarr',
},
},
},
};

View File

@@ -55,7 +55,7 @@ export const AvatarMenu = () => {
{t('actions.avatar.defaultBoard')}
</Menu.Item>
<Menu.Item component={Link} href="/manage" icon={<IconHomeShare size="1rem" />}>
Manage
{t('actions.avatar.manage')}
</Menu.Item>
<Menu.Divider />
</>

View File

@@ -15,7 +15,7 @@ import {
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconDownload, IconExternalLink, IconPlayerPlay } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { Trans, useTranslation } from 'next-i18next';
import Image from 'next/image';
import { useRouter } from 'next/router';
import React, { useMemo } from 'react';
@@ -69,8 +69,9 @@ export const MovieModal = ({ opened, closeModal }: MovieModalProps) => {
type MovieResultsProps = Omit<z.infer<typeof queryParamsSchema>, 'movie'>;
const MovieResults = ({ search, type }: MovieResultsProps) => {
const { t } = useTranslation('layout/header');
const { name: configName } = useConfigContext();
const { data: overseerrResults, isLoading } = api.overseerr.search.useQuery(
const { data: movies, isLoading } = api.overseerr.search.useQuery(
{
query: search,
configName: configName!,
@@ -94,10 +95,20 @@ const MovieResults = ({ search, type }: MovieResultsProps) => {
return (
<Stack>
<Text>
Top {overseerrResults?.length} results for <b>{search}</b>
<Trans
t={t}
i18nKey="modals.movie.topResults"
values={{
count: movies?.length ?? 0,
search,
}}
components={{
b: <b />,
}}
/>
</Text>
<Grid gutter={32}>
{overseerrResults?.map((result, index: number) => (
{movies?.map((result, index: number) => (
<Grid.Col key={index} span={12} sm={6} lg={4}>
<MovieDisplay movie={result} type={type} />
</Grid.Col>

View File

@@ -21,7 +21,7 @@ const env = createEnv({
process.env.VERCEL ? z.string().min(1) : z.string().url()
),
DOCKER_HOST: z.string().optional(),
DOCKER_PORT: z.string().regex(/\d+/).transform(Number).optional(),
DOCKER_PORT: portSchema,
},
/**

View File

@@ -6,12 +6,12 @@ import { WidgetsEditModal } from '~/components/Dashboard/Tiles/Widgets/WidgetsEd
import { WidgetsRemoveModal } from '~/components/Dashboard/Tiles/Widgets/WidgetsRemoveModal';
import { CategoryEditModal } from '~/components/Dashboard/Wrappers/Category/CategoryEditModal';
import { CopyInviteModal } from './copy-invite/copy-invite.modal';
import { CreateDashboardModal } from './create-dashboard/create-dashboard.modal';
import { CreateInviteModal } from './create-invite/create-invite.modal';
import { DeleteBoardModal } from './delete-board/delete-board.modal';
import { DeleteInviteModal } from './delete-invite/delete-invite.modal';
import { DeleteUserModal } from './delete-user/delete-user.modal';
import { CreateBoardModal } from './components/Manage/Board/create-board.modal';
import { DeleteBoardModal } from './components/Manage/Board/delete-board.modal';
import { CopyInviteModal } from './components/Manage/User/Invite/copy-invite.modal';
import { CreateInviteModal } from './components/Manage/User/Invite/create-invite.modal';
import { DeleteInviteModal } from './components/Manage/User/Invite/delete-invite.modal';
import { DeleteUserModal } from './components/Manage/User/delete-user.modal';
export const modals = {
editApp: EditAppModal,
@@ -24,7 +24,7 @@ export const modals = {
deleteUserModal: DeleteUserModal,
createInviteModal: CreateInviteModal,
deleteInviteModal: DeleteInviteModal,
createDashboardModal: CreateDashboardModal,
createBoardModal: CreateBoardModal,
copyInviteModal: CopyInviteModal,
deleteBoardModal: DeleteBoardModal,
};

View File

@@ -1,49 +0,0 @@
import { Button, Group, Stack, Text } from '@mantine/core';
import { ContextModalProps, modals } from '@mantine/modals';
import { api } from '~/utils/api';
export const DeleteUserModal = ({
context,
id,
innerProps,
}: ContextModalProps<{ userId: string; username: string }>) => {
const apiContext = api.useContext();
const { isLoading, mutateAsync } = api.user.deleteUser.useMutation({
onSuccess: async () => {
await apiContext.user.all.invalidate();
modals.close(id);
},
});
return (
<Stack>
<Text>
Are you sure, that you want to delete the user {innerProps.username}? This will delete data
associated with this account, but not any created dashboards by this user.
</Text>
<Group grow>
<Button
onClick={() => {
modals.close(id);
}}
variant="light"
color="gray"
>
Cancel
</Button>
<Button
onClick={async () => {
await mutateAsync({
userId: innerProps.userId,
});
}}
disabled={isLoading}
variant="light"
color="red"
>
Delete
</Button>
</Group>
</Stack>
);
};

View File

@@ -19,7 +19,7 @@ export default function DockerMenuButton(props: any) {
const dockerEnabled = config?.settings.customization.layout.enabledDocker || false;
const { data, refetch } = api.docker.containers.useQuery(undefined, {
const { data, refetch, isLoading } = api.docker.containers.useQuery(undefined, {
enabled: dockerEnabled,
});
useHotkeys([['mod+B', () => setOpened(!opened)]]);
@@ -42,7 +42,7 @@ export default function DockerMenuButton(props: any) {
padding="xl"
position="right"
size="100%"
title={<ContainerActionBar isLoading selected={selection} reload={reload} />}
title={<ContainerActionBar isLoading={isLoading} selected={selection} reload={reload} />}
transitionProps={{
transition: 'pop',
}}

View File

@@ -20,7 +20,7 @@ import { z } from 'zod';
import { CommonHead } from '~/components/layout/Meta/CommonHead';
import { env } from '~/env.js';
import { ColorSchemeProvider } from '~/hooks/use-colorscheme';
import { modals } from '~/modals/modals';
import { modals } from '~/modals';
import { queryClient } from '~/tools/server/configurations/tanstack/queryClient.tool';
import { ConfigType } from '~/types/config';
import { api } from '~/utils/api';

View File

@@ -4,16 +4,18 @@ 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 Head from 'next/head';
import { useRouter } from 'next/router';
import { z } from 'zod';
import { getServerAuthSession } from '~/server/auth';
import { prisma } from '~/server/db';
import { inviteNamespaces } from '~/tools/server/translation-namespaces';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { api } from '~/utils/api';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
import { signUpFormSchema } from '~/validations/user';
const notificationId = 'register';
export default function AuthInvitePage() {
const { t } = useTranslation('authentication/invite');
const { i18nZodResolver } = useI18nZodResolver();
@@ -28,11 +30,10 @@ export default function AuthInvitePage() {
});
const handleSubmit = (values: z.infer<typeof signUpFormSchema>) => {
const notificationId = 'register';
showNotification({
id: notificationId,
title: 'Creating account',
message: 'Please wait...',
title: t('notifications.loading.title'),
message: `${t('notifications.loading.text')}...`,
loading: true,
});
void mutateAsync(
@@ -44,8 +45,8 @@ export default function AuthInvitePage() {
onSuccess() {
updateNotification({
id: notificationId,
title: 'Account created',
message: 'Your account has been created successfully',
title: t('notifications.success.title'),
message: t('notifications.success.text'),
color: 'teal',
icon: <IconCheck />,
});
@@ -54,8 +55,8 @@ export default function AuthInvitePage() {
onError() {
updateNotification({
id: notificationId,
title: 'Error',
message: 'Something went wrong',
title: t('notifications.error.title'),
message: t('notifications.error.text'),
color: 'red',
icon: <IconX />,
});
@@ -64,7 +65,14 @@ export default function AuthInvitePage() {
);
};
const metaTitle = `${t('metaTitle')} • Homarr`;
return (
<>
<Head>
<title>{metaTitle}</title>
</Head>
<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}>
@@ -105,6 +113,7 @@ export default function AuthInvitePage() {
</form>
</Card>
</Flex>
</>
);
}
@@ -157,7 +166,7 @@ export const getServerSideProps: GetServerSideProps = async ({
return {
props: {
...(await serverSideTranslations(locale ?? '', inviteNamespaces)),
...(await getServerSideTranslations(['authentication/invite'], locale, req, res)),
},
};
};

View File

@@ -14,17 +14,15 @@ import { IconAlertTriangle } from '@tabler/icons-react';
import { GetServerSideProps } from 'next';
import { signIn } from 'next-auth/react';
import { useTranslation } from 'next-i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { z } from 'zod';
import { getServerAuthSession } from '~/server/auth';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
import { signInSchema } from '~/validations/user';
import { loginNamespaces } from '../../tools/server/translation-namespaces';
export default function LoginPage() {
const { t } = useTranslation('authentication/login');
const queryParams = useRouter().query as { error?: 'CredentialsSignin' | (string & {}) };
@@ -54,11 +52,15 @@ export default function LoginPage() {
});
};
const metaTitle = `${t('metaTitle')} • Homarr`;
return (
<Flex h="100dvh" display="flex" w="100%" direction="column" align="center" justify="center">
<>
<Head>
<title>Login Homarr</title>
<title>{metaTitle}</title>
</Head>
<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')}
@@ -97,6 +99,7 @@ export default function LoginPage() {
</form>
</Card>
</Flex>
</>
);
}
@@ -114,8 +117,7 @@ export const getServerSideProps: GetServerSideProps = async ({ locale, req, res
return {
props: {
...(await serverSideTranslations(locale ?? 'en', loginNamespaces)),
// Will be passed to the page component as props
...(await getServerSideTranslations(['authentication/login'], locale, req, res)),
},
};
};

View File

@@ -33,7 +33,7 @@ import { boardNamespaces } from '~/tools/server/translation-namespaces';
import { firstUpperCase } from '~/tools/shared/strings';
import { api } from '~/utils/api';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
import { boardCustomizationSchema } from '~/validations/dashboards';
import { boardCustomizationSchema } from '~/validations/boards';
const notificationId = 'board-customization-notification';

View File

@@ -3,7 +3,6 @@ import {
Badge,
Button,
Card,
Flex,
Group,
LoadingOverlay,
Menu,
@@ -26,9 +25,11 @@ import {
IconTrash,
} from '@tabler/icons-react';
import { GetServerSideProps } from 'next';
import { useTranslation } from 'next-i18next';
import Head from 'next/head';
import Link from 'next/link';
import { useTranslation } from 'next-i18next';
import { openCreateBoardModal } from '~/components/Manage/Board/create-board.modal';
import { openDeleteBoardModal } from '~/components/Manage/Board/delete-board.modal';
import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
import { getServerAuthSession } from '~/server/auth';
import { sleep } from '~/tools/client/time';
@@ -47,31 +48,26 @@ const BoardsPage = () => {
const [deletingDashboards, { append, filter }] = useListState<string>([]);
const { t } = useTranslation('boards/manage');
const { t } = useTranslation('manage/boards');
const metaTitle = `${t('metaTitle')} • Homarr`;
return (
<ManageLayout>
<Head>
<title>Boards Homarr</title>
<title>{metaTitle}</title>
</Head>
<Title mb="xl">{t('title')}</Title>
<Flex justify="end" mb="md">
<Group position="apart">
<Title mb="xl">{t('pageTitle')}</Title>
<Button
onClick={() => {
modals.openContextModal({
modal: 'createDashboardModal',
title: <Text>{t('buttons.create')}</Text>,
innerProps: {},
});
}}
onClick={openCreateBoardModal}
leftIcon={<IconPlus size="1rem" />}
variant="default"
>
{t('buttons.create')}
</Button>
</Flex>
</Group>
{data && (
<SimpleGrid
@@ -167,10 +163,7 @@ const BoardsPage = () => {
<Menu.Divider />
<Menu.Item
onClick={async () => {
modals.openContextModal({
modal: 'deleteBoardModal',
title: <Text weight={500}>{t('cards.menu.delete.modalTitle')}</Text>,
innerProps: {
openDeleteBoardModal({
boardName: board.name,
onConfirm: async () => {
append(board.name);
@@ -178,7 +171,6 @@ const BoardsPage = () => {
await sleep(500);
filter((item, _) => item !== board.name);
},
},
});
}}
disabled={board.name === 'default'}
@@ -213,8 +205,8 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
const translations = await getServerSideTranslations(
manageNamespaces,
ctx.locale,
undefined,
undefined
ctx.req,
ctx.res
);
return {
props: {

View File

@@ -12,6 +12,7 @@ import {
import { IconArrowRight } from '@tabler/icons-react';
import { GetServerSideProps } from 'next';
import { useSession } from 'next-auth/react';
import { useTranslation } from 'next-i18next';
import Head from 'next/head';
import Image from 'next/image';
import Link from 'next/link';
@@ -19,24 +20,31 @@ import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
import { getServerAuthSession } from '~/server/auth';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { OnlyKeysWithStructure } from '~/types/helpers';
import { type quickActions } from '../../../public/locales/en/manage/index.json';
const ManagementPage = () => {
const { t } = useTranslation('manage/index');
const { classes } = useStyles();
const largerThanMd = useScreenLargerThan('md');
const { data: sessionData } = useSession();
const metaTitle = `${t('metaTitle')} • Homarr`;
return (
<ManageLayout>
<Head>
<title>Manage Homarr</title>
<title>{metaTitle}</title>
</Head>
<Box className={classes.box} w="100%" mih={150} p="xl" mb={50}>
<Group position="apart" noWrap>
<Stack spacing={15}>
<Title className={classes.boxTitle} order={2}>
Welcome back, {sessionData?.user?.name ?? 'Anonymous'}
{t('hero.title', {
username: sessionData?.user?.name ?? t('hero.fallbackUsername'),
})}
</Title>
<Text>Welcome to Your Application Hub. Organize, Optimize, and Conquer!</Text>
<Text>{t('hero.subtitle')}</Text>
</Stack>
<Box bg="blue" w={100} h="100%" pos="relative">
<Box
@@ -49,7 +57,7 @@ const ManagementPage = () => {
src="/imgs/logo/logo.png"
width={largerThanMd ? 200 : 100}
height={largerThanMd ? 150 : 60}
alt=""
alt="Homarr Logo"
/>
</Box>
</Box>
@@ -57,7 +65,7 @@ const ManagementPage = () => {
</Box>
<Text weight="bold" mb="md">
Quick actions
{t('quickActions.title')}
</Text>
<SimpleGrid
cols={3}
@@ -67,44 +75,46 @@ const ManagementPage = () => {
{ maxWidth: '48rem', cols: 1, spacing: 'md' },
]}
>
<UnstyledButton component={Link} href="/manage/boards">
<Card className={classes.quickActionCard}>
<Group spacing={30} noWrap>
<Stack spacing={0}>
<Text weight="bold">Your boards</Text>
<Text>Show a list of all your dashboards</Text>
</Stack>
<IconArrowRight />
</Group>
</Card>
</UnstyledButton>
<UnstyledButton component={Link} href="/manage/users/invites">
<Card className={classes.quickActionCard}>
<Group spacing={30} noWrap>
<Stack spacing={0}>
<Text weight="bold">Invite a new user</Text>
<Text>Create and send an invitation for registration</Text>
</Stack>
<IconArrowRight />
</Group>
</Card>
</UnstyledButton>
<UnstyledButton component={Link} href="/manage/users">
<Card className={classes.quickActionCard}>
<Group spacing={30} noWrap>
<Stack spacing={0}>
<Text weight="bold">Manage users</Text>
<Text>Delete and manage your users</Text>
</Stack>
<IconArrowRight />
</Group>
</Card>
</UnstyledButton>
<QuickActionCard type="boards" href="/manage/boards" />
<QuickActionCard type="inviteUsers" href="/manage/users/invites" />
<QuickActionCard type="manageUsers" href="/manage/users" />
</SimpleGrid>
</ManageLayout>
);
};
type QuickActionType = OnlyKeysWithStructure<
typeof quickActions,
{
title: string;
subtitle: string;
}
>;
type QuickActionCardProps = {
type: QuickActionType;
href: string;
};
const QuickActionCard = ({ type, href }: QuickActionCardProps) => {
const { t } = useTranslation('manage/index');
const { classes } = useStyles();
return (
<UnstyledButton component={Link} href={href}>
<Card className={classes.quickActionCard}>
<Group position="apart" noWrap>
<Stack spacing={0}>
<Text weight={500}>{t(`quickActions.${type}.title`)}</Text>
<Text>{t(`quickActions.${type}.subtitle`)}</Text>
</Stack>
<IconArrowRight />
</Group>
</Card>
</UnstyledButton>
);
};
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const session = await getServerAuthSession(ctx);
@@ -115,10 +125,10 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
}
const translations = await getServerSideTranslations(
['common'],
['layout/manage', 'manage/index'],
ctx.locale,
undefined,
undefined
ctx.req,
ctx.res
);
return {
props: {

View File

@@ -1,43 +0,0 @@
import { Text, Title } from '@mantine/core';
import { GetServerSideProps } from 'next';
import Head from 'next/head';
import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
import { getServerAuthSession } from '~/server/auth';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
const SettingsPage = () => {
return (
<ManageLayout>
<Head>
<title>Settings Homarr</title>
</Head>
<Title>Settings</Title>
<Text>Coming soon!</Text>
</ManageLayout>
);
};
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const session = await getServerAuthSession(ctx);
if (!session?.user.isAdmin) {
return {
notFound: true,
};
}
const translations = await getServerSideTranslations(
['common'],
ctx.locale,
undefined,
undefined
);
return {
props: {
...translations,
},
};
};
export default SettingsPage;

View File

@@ -1,25 +1,17 @@
import { Alert, Button, Card, Group, Stepper, Table, Text, Title } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import {
IconArrowLeft,
IconCheck,
IconInfoCircle,
IconKey,
IconMail,
IconMailCheck,
IconUser,
IconUserPlus,
} from '@tabler/icons-react';
import { Alert, Button, Group, Stepper } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconArrowLeft, IconKey, IconMailCheck, IconUser, IconUserPlus } from '@tabler/icons-react';
import { GetServerSideProps } from 'next';
import { useTranslation } from 'next-i18next';
import Head from 'next/head';
import Link from 'next/link';
import { useState } from 'react';
import { useTranslation } from 'next-i18next';
import { z } from 'zod';
import {
CreateAccountStep,
createAccountStepValidationSchema,
} from '~/components/Manage/User/Create/create-account-step';
import { ReviewInputStep } from '~/components/Manage/User/Create/review-input-step';
import {
CreateAccountSecurityStep,
createAccountSecurityStepValidationSchema,
@@ -27,15 +19,17 @@ import {
import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
import { getServerAuthSession } from '~/server/auth';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { api } from '~/utils/api';
import { manageNamespaces } from '~/tools/server/translation-namespaces';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
const CreateNewUserPage = () => {
const { t } = useTranslation('manage/users/create');
const [active, setActive] = useState(0);
const nextStep = () => setActive((current) => (current < 3 ? current + 1 : current));
const prevStep = () => setActive((current) => (current > 0 ? current - 1 : current));
const { i18nZodResolver } = useI18nZodResolver();
const form = useForm({
const form = useForm<CreateAccountSchema>({
initialValues: {
account: {
username: '',
@@ -45,30 +39,14 @@ const CreateNewUserPage = () => {
password: '',
},
},
validate: zodResolver(
z.object({
account: createAccountStepValidationSchema,
security: createAccountSecurityStepValidationSchema,
})
),
validate: i18nZodResolver(createAccountSchema),
});
const context = api.useContext();
const { mutateAsync, isLoading } = api.user.create.useMutation({
onSettled: () => {
void context.user.all.invalidate();
},
onSuccess: () => {
nextStep();
},
});
const { t } = useTranslation('user/create');
const metaTitle = `${t('metaTitle')} • Homarr`;
return (
<ManageLayout>
<Head>
<title>Create user Homarr</title>
<title>{metaTitle}</title>
</Head>
<Stepper active={active} onStepClick={setActive} breakpoint="sm" mih="100%">
@@ -111,92 +89,11 @@ const CreateNewUserPage = () => {
label={t('steps.finish.title')}
description={t('steps.finish.title')}
>
<Card mih={400}>
<Title order={5}>{t('steps.finish.card.title')}</Title>
<Text mb="xl">{t('steps.finish.card.text')}</Text>
<Table mb="lg" withBorder highlightOnHover>
<thead>
<tr>
<th>{t('steps.finish.table.header.property')}</th>
<th>{t('steps.finish.table.header.value')}</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<Group spacing="xs">
<IconUser size="1rem" />
<Text>{t('steps.finish.table.header.username')}</Text>
</Group>
</td>
<td>{form.values.account.username}</td>
</tr>
<tr>
<td>
<Group spacing="xs">
<IconMail size="1rem" />
<Text>{t('steps.finish.table.header.email')}</Text>
</Group>
</td>
<td>
{form.values.account.eMail ? (
<Text>{form.values.account.eMail}</Text>
) : (
<Group spacing="xs">
<IconInfoCircle size="1rem" color="orange" />
<Text color="orange">{t('steps.finish.table.notSet')}</Text>
</Group>
)}
</td>
</tr>
<tr>
<td>
<Group spacing="xs">
<IconKey size="1rem" />
<Text>{t('steps.finish.table.password')}</Text>
</Group>
</td>
<td>
<Group spacing="xs">
<IconCheck size="1rem" color="green" />
<Text color="green">{t('steps.finish.table.valid')}</Text>
</Group>
</td>
</tr>
</tbody>
</Table>
<Group position="apart" noWrap>
<Button
leftIcon={<IconArrowLeft size="1rem" />}
onClick={prevStep}
variant="light"
px="xl"
>
{t('buttons.previous')}
</Button>
<Button
onClick={async () => {
await mutateAsync({
username: form.values.account.username,
password: form.values.security.password,
email: form.values.account.eMail === '' ? undefined : form.values.account.eMail,
});
}}
loading={isLoading}
rightIcon={<IconCheck size="1rem" />}
variant="light"
px="xl"
>
{t('buttons.confirm')}
</Button>
</Group>
</Card>
<ReviewInputStep values={form.values} prevStep={prevStep} nextStep={nextStep} />
</Stepper.Step>
<Stepper.Completed>
<Alert title="User was created" color="green" mb="md">
{t('steps.finish.alertConfirmed')}
<Alert title={t('steps.completed.alert.title')} color="green" mb="md">
{t('steps.completed.alert.text')}
</Alert>
<Group>
@@ -225,6 +122,13 @@ const CreateNewUserPage = () => {
);
};
const createAccountSchema = z.object({
account: createAccountStepValidationSchema,
security: createAccountSecurityStepValidationSchema,
});
export type CreateAccountSchema = z.infer<typeof createAccountSchema>;
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const session = await getServerAuthSession(ctx);
@@ -237,8 +141,8 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
const translations = await getServerSideTranslations(
manageNamespaces,
ctx.locale,
undefined,
undefined
ctx.req,
ctx.res
);
return {
props: {

View File

@@ -15,15 +15,16 @@ import { useDebouncedValue } from '@mantine/hooks';
import { openContextModal } from '@mantine/modals';
import { IconPlus, IconTrash } from '@tabler/icons-react';
import { GetServerSideProps } from 'next';
import { useTranslation } from 'next-i18next';
import Head from 'next/head';
import Link from 'next/link';
import { useState } from 'react';
import { useTranslation } from 'next-i18next';
import { openDeleteUserModal } from '~/components/Manage/User/delete-user.modal';
import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
import { getServerAuthSession } from '~/server/auth';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { api } from '~/utils/api';
import { manageNamespaces } from '~/tools/server/translation-namespaces';
import { api } from '~/utils/api';
const ManageUsersPage = () => {
const [activePage, setActivePage] = useState(0);
@@ -34,7 +35,7 @@ const ManageUsersPage = () => {
search: debouncedSearch,
});
const { t } = useTranslation('user/manage');
const { t } = useTranslation('manage/users');
return (
<ManageLayout>
@@ -86,16 +87,7 @@ const ManageUsersPage = () => {
<Group>
<ActionIcon
onClick={() => {
openContextModal({
modal: 'deleteUserModal',
title: (
<Text weight="bold">{t('modals.delete', { name: user.name })}</Text>
),
innerProps: {
userId: user.id,
username: user.name ?? '',
},
});
openDeleteUserModal(user);
}}
color="red"
variant="light"
@@ -112,9 +104,7 @@ const ManageUsersPage = () => {
<tr>
<td colSpan={1}>
<Box p={15}>
<Text>
{t('searchDoesntMatch')}
</Text>
<Text>{t('searchDoesntMatch')}</Text>
</Box>
</td>
</tr>

View File

@@ -13,9 +13,10 @@ import { modals } from '@mantine/modals';
import { IconPlus, IconTrash } from '@tabler/icons-react';
import dayjs from 'dayjs';
import { GetServerSideProps } from 'next';
import { useTranslation } from 'next-i18next';
import Head from 'next/head';
import { useState } from 'react';
import { useTranslation } from 'next-i18next';
import { openCreateInviteModal } from '~/components/Manage/User/Invite/create-invite.modal';
import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
import { getServerAuthSession } from '~/server/auth';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
@@ -23,40 +24,33 @@ import { manageNamespaces } from '~/tools/server/translation-namespaces';
import { api } from '~/utils/api';
const ManageUserInvitesPage = () => {
const { classes } = useStyles();
const { t } = useTranslation('manage/users/invites');
const [activePage, setActivePage] = useState(0);
const { data } = api.invites.all.useQuery({
const { data: invites } = api.invites.all.useQuery({
page: activePage,
});
const { classes } = useStyles();
const handleFetchNextPage = async () => {
const nextPage = () => {
setActivePage((prev) => prev + 1);
};
const handleFetchPreviousPage = async () => {
const previousPage = () => {
setActivePage((prev) => prev - 1);
};
const { t } = useTranslation('user/invites');
const metaTitle = `${t('metaTitle')} • Homarr`;
return (
<ManageLayout>
<Head>
<title>User invites Homarr</title>
<title>{metaTitle}</title>
</Head>
<Title mb="md">{t('title')}</Title>
<Text mb="xl">{t('text')}</Text>
<Title mb="md">{t('pageTitle')}</Title>
<Text mb="xl">{t('description')}</Text>
<Flex justify="end" mb="md">
<Button
onClick={() => {
modals.openContextModal({
modal: 'createInviteModal',
title: 'Create invite',
innerProps: {},
});
}}
onClick={openCreateInviteModal}
leftIcon={<IconPlus size="1rem" />}
variant="default"
>
@@ -64,7 +58,7 @@ const ManageUserInvitesPage = () => {
</Button>
</Flex>
{data && (
{invites && (
<>
<Table mb="md" withBorder highlightOnHover>
<thead>
@@ -76,7 +70,7 @@ const ManageUserInvitesPage = () => {
</tr>
</thead>
<tbody>
{data.invites.map((invite, index) => (
{invites.invites.map((invite, index) => (
<tr key={index}>
<td className={classes.tableGrowCell}>
<Text lineClamp={1}>{invite.id}</Text>
@@ -114,9 +108,9 @@ const ManageUserInvitesPage = () => {
</td>
</tr>
))}
{data.invites.length === 0 && (
{invites.invites.length === 0 && (
<tr>
<td colSpan={3}>
<td colSpan={4}>
<Center p="md">
<Text color="dimmed">{t('noInvites')}</Text>
</Center>
@@ -126,18 +120,18 @@ const ManageUserInvitesPage = () => {
</tbody>
</Table>
<Pagination
total={data.countPages}
total={invites.countPages}
value={activePage + 1}
onChange={(targetPage) => {
setActivePage(targetPage - 1);
}}
onNextPage={handleFetchNextPage}
onPreviousPage={handleFetchPreviousPage}
onNextPage={nextPage}
onPreviousPage={previousPage}
onFirstPage={() => {
setActivePage(0);
}}
onLastPage={() => {
setActivePage(data.countPages - 1);
setActivePage(invites.countPages - 1);
}}
withEdges
/>
@@ -168,9 +162,10 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
const translations = await getServerSideTranslations(
manageNamespaces,
ctx.locale,
undefined,
undefined
ctx.req,
ctx.res
);
return {
props: {
...translations,

View File

@@ -26,7 +26,6 @@ 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';
@@ -225,12 +224,7 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
};
}
const translations = await getServerSideTranslations(
onboardNamespaces,
ctx.locale,
ctx.req,
ctx.res
);
const translations = await getServerSideTranslations([], ctx.locale, ctx.req, ctx.res);
return {
props: {

View File

@@ -200,12 +200,7 @@ export const getServerSideProps: GetServerSideProps = async ({ req, res, locale
await helpers.user.withSettings.prefetch();
await helpers.boards.all.prefetch();
const translations = await getServerSideTranslations(
['user/preferences'],
locale,
undefined,
undefined
);
const translations = await getServerSideTranslations(['user/preferences'], locale, req, res);
return {
props: {
...translations,

View File

@@ -7,7 +7,7 @@ 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 { boardCustomizationSchema } from '~/validations/boards';
import { IRssWidget } from '~/widgets/rss/RssWidgetTile';
import { getConfig } from '../../../tools/config/getConfig';

View File

@@ -28,7 +28,7 @@ export const userRouter = createTRPCRouter({
});
}
await createUserInNotExist(ctx, input, {
await createUserIfNotPresent(ctx, input, {
defaultSettings: {
colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]),
language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en',
@@ -62,7 +62,7 @@ export const userRouter = createTRPCRouter({
});
}
await createUserInNotExist(ctx, input, {
await createUserIfNotPresent(ctx, input, {
defaultSettings: {
colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]),
language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en',
@@ -235,7 +235,7 @@ export const userRouter = createTRPCRouter({
return {
users: users.map((user) => ({
id: user.id,
name: user.name,
name: user.name!,
email: user.email,
emailVerified: user.emailVerified,
})),
@@ -243,25 +243,25 @@ export const userRouter = createTRPCRouter({
};
}),
create: adminProcedure.input(createNewUserSchema).mutation(async ({ ctx, input }) => {
await createUserInNotExist(ctx, input);
await createUserIfNotPresent(ctx, input);
}),
deleteUser: adminProcedure
.input(
z.object({
userId: z.string(),
id: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.user.delete({
where: {
id: input.userId,
id: input.id,
},
});
}),
});
const createUserInNotExist = async (
const createUserIfNotPresent = async (
ctx: TRPCContext,
input: z.infer<typeof createNewUserSchema>,
options: {

View File

@@ -1,5 +1,6 @@
import { PrismaAdapter } from '@next-auth/prisma-adapter';
import bcrypt from 'bcryptjs';
import Consola from 'consola';
import Cookies from 'cookies';
import { type GetServerSidePropsContext, type NextApiRequest, type NextApiResponse } from 'next';
import { type DefaultSession, type NextAuthOptions, getServerSession } from 'next-auth';
@@ -150,15 +151,15 @@ export const constructAuthOptions = (
return null;
}
console.log(`user ${user.id} is trying to log in. checking password...`);
Consola.log(`user ${user.id} is trying to log in. checking password...`);
const isValidPassword = await bcrypt.compare(data.password, user.password);
if (!isValidPassword) {
console.log(`password for user ${user.id} was incorrect`);
Consola.log(`password for user ${user.id} was incorrect`);
return null;
}
console.log(`user ${user.id} successfully authorized`);
Consola.log(`user ${user.id} successfully authorized`);
return {
id: user.id,

View File

@@ -1,10 +1,7 @@
export const boardNamespaces = [
'common',
'zod',
'layout/element-selector/selector',
'layout/modals/add-app',
'layout/modals/change-position',
'layout/modals/about',
'layout/common',
'layout/header/actions/toggle-edit-mode',
'layout/mobile/drawer',
@@ -42,16 +39,9 @@ export const boardNamespaces = [
];
export const manageNamespaces = [
'user/preferences',
'user/manage',
'user/invites',
'user/create',
'boards/manage',
'zod',
'manage/common',
'manage/boards',
'manage/users',
'manage/users/invites',
'manage/users/create',
];
export const loginNamespaces = ['authentication/login', 'zod'];
export const inviteNamespaces = ['authentication/invite', 'zod'];
export const onboardNamespaces = ['common', 'zod'];

3
src/types/helpers.ts Normal file
View File

@@ -0,0 +1,3 @@
export type OnlyKeysWithStructure<T, TStructure> = {
[P in keyof T]: T[P] extends TStructure ? P : never;
}[keyof T];

View File

@@ -1,7 +1,7 @@
import { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core';
import { z } from 'zod';
export const createDashboardSchemaValidation = z.object({
export const createBoardSchemaValidation = z.object({
name: z.string().min(2).max(25),
});

View File

@@ -1,9 +1,11 @@
import { z } from 'zod';
import { CustomErrorParams } from '~/utils/i18n-zod-resolver';
export const minPasswordLength = 8;
export const passwordSchema = z
.string()
.min(8)
.min(minPasswordLength)
.max(100)
.refine((value) => /[0-9]/.test(value))
.refine((value) => /[a-z]/.test(value))
@@ -18,12 +20,12 @@ export const signInSchema = z.object({
export const signUpFormSchema = z
.object({
username: z.string().min(3),
password: z.string().min(8),
passwordConfirmation: z.string().min(8),
password: z.string().min(minPasswordLength),
passwordConfirmation: z.string().min(minPasswordLength),
})
.refine((data) => data.password === data.passwordConfirmation, {
params: {
i18n: { key: 'password_match' },
i18n: { key: 'passwordMatch' },
} satisfies CustomErrorParams,
path: ['passwordConfirmation'],
});

View File

@@ -11,7 +11,6 @@ import {
IconSun,
} from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { CustomTypeOptions } from 'i18next';
interface WeatherIconProps {
code: number;