♻️ 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

@@ -0,0 +1,84 @@
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 { createBoardSchemaValidation } from '~/validations/boards';
export const CreateBoardModal = ({ id }: ContextModalProps<{}>) => {
const { t } = useTranslation('manage/boards');
const utils = api.useContext();
const { isLoading, mutate } = api.config.save.useMutation({
onSuccess: async () => {
await utils.config.all.invalidate();
modals.close(id);
},
});
const { i18nZodResolver } = useI18nZodResolver();
const form = useForm({
initialValues: {
name: '',
},
validate: i18nZodResolver(createBoardSchemaValidation),
});
const handleSubmit = () => {
const fallbackConfig = getStaticFallbackConfig(form.values.name);
mutate({
name: form.values.name,
config: fallbackConfig,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<Text>{t('modals.create.text')}</Text>
<TextInput
label={t('modals.create.form.name.label')}
withAsterisk
{...form.getInputProps('name')}
/>
<Group grow>
<Button
onClick={() => {
modals.close(id);
}}
variant="light"
color="gray"
type="button"
>
{t('common:cancel')}
</Button>
<Button
type="submit"
onClick={async () => {}}
disabled={isLoading}
variant="light"
color="green"
>
{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

@@ -0,0 +1,61 @@
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 = { 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 utils.config.all.invalidate();
modals.close(id);
},
});
return (
<Stack>
<Text>{t('modals.delete.text')}</Text>
<Group grow>
<Button
onClick={() => {
modals.close(id);
}}
variant="light"
color="gray"
>
{t('common:cancel')}
</Button>
<Button
onClick={async () => {
modals.close(id);
await innerProps.onConfirm();
await mutateAsync({
name: innerProps.boardName,
});
}}
disabled={isLoading}
variant="light"
color="red"
>
{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

@@ -0,0 +1,75 @@
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';
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>
<Trans
i18nKey="manage/users/invites:modals.copy.description"
components={{
b: <b />,
}}
/>
</Text>
<Link href={`/auth/invite/${innerProps.id}?token=${innerProps.token}`}>
{t('modals.copy.invitationLink')}
</Link>
<Stack spacing="xs">
<Text weight="bold">{t('modals.copy.details.id')}:</Text>
<Mark style={{ borderRadius: 4 }} color="gray" px={5}>
{innerProps.id}
</Mark>
<Text weight="bold">{t('modals.copy.details.token')}:</Text>
<Mark style={{ borderRadius: 4 }} color="gray" px={5}>
{innerProps.token}
</Mark>
</Stack>
<CopyButton value={inviteUrl}>
{({ copy }) => (
<Button
onClick={() => {
copy();
modals.close(id);
}}
variant="default"
fullWidth
>
{t('modals.copy.button.close')}
</Button>
)}
</CopyButton>
</Stack>
);
};
const useInviteUrl = (id: string, token: string) => {
const router = useRouter();
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

@@ -0,0 +1,89 @@
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 { t } = useTranslation('manage/users/invites');
const utils = api.useContext();
const { isLoading, mutateAsync } = api.invites.create.useMutation({
onSuccess: async (data) => {
await utils.invites.all.invalidate();
modals.close(id);
openCopyInviteModal(data);
},
});
const { i18nZodResolver } = useI18nZodResolver();
const minDate = dayjs().add(5, 'minutes').toDate();
const maxDate = dayjs().add(6, 'months').toDate();
const form = useForm({
initialValues: {
expirationDate: dayjs().add(7, 'days').toDate(),
},
validate: i18nZodResolver(createInviteSchema),
});
return (
<Stack>
<Text>{t('modals.create.description')}</Text>
<DateTimePicker
popoverProps={{ withinPortal: true }}
minDate={minDate}
maxDate={maxDate}
withAsterisk
valueFormat="DD MMM YYYY hh:mm A"
label={t('modals.create.form.expires.label')}
variant="filled"
{...form.getInputProps('expirationDate')}
/>
<Group grow>
<Button
onClick={() => {
modals.close(id);
}}
variant="light"
color="gray"
>
{t('common:cancel')}
</Button>
<Button
onClick={async () => {
await mutateAsync({
expiration: form.values.expirationDate,
});
}}
disabled={isLoading}
variant="light"
color="green"
>
{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

@@ -0,0 +1,44 @@
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 = ({ 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 utils.invites.all.invalidate();
modals.close(id);
},
});
return (
<Stack>
<Text>{t('modals.delete.description')}</Text>
<Group grow>
<Button
onClick={() => {
modals.close(id);
}}
variant="light"
color="gray"
>
{t('common:cancel')}
</Button>
<Button
onClick={async () => {
await deleteAsync({
tokenId: innerProps.tokenId,
});
}}
disabled={isLoading}
variant="light"
color="red"
>
{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,
});
};