♻️ 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", "title": "Create Account",
"text": "Please define your credentials below", "text": "Please define your credentials below",
"form": { "form": {
@@ -16,5 +17,19 @@
"buttons": { "buttons": {
"submit": "Create account" "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!", "title": "Welcome back!",
"text": "Please enter your credentials", "text": "Please enter your credentials",
"form": { "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", "small": "small",
"medium": "medium", "medium": "medium",
"large": "large" "large": "large"
},
"header": {
"logout": "Logout",
"sign-in": "Sign in"
} }
} }

View File

@@ -16,6 +16,7 @@
"switchTheme": "Switch theme", "switchTheme": "Switch theme",
"preferences": "User preferences", "preferences": "User preferences",
"defaultBoard": "Default dashboard", "defaultBoard": "Default dashboard",
"manage": "Manage",
"about": { "about": {
"label": "About", "label": "About",
"new": "New" "new": "New"
@@ -23,5 +24,11 @@
"logout": "Logout from {{username}}", "logout": "Logout from {{username}}",
"login": "Login" "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": { "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." "searchDoesntMatch": "Your search does not match any entries. Please adjust your filter."
} }

View File

@@ -1,4 +1,5 @@
{ {
"metaTitle": "Create user",
"steps": { "steps": {
"account": { "account": {
"title": "First step", "title": "First step",
@@ -15,7 +16,13 @@
"text": "Password", "text": "Password",
"password": { "password": {
"label": "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": { "finish": {
@@ -35,15 +42,20 @@
}, },
"notSet": "Not set", "notSet": "Not set",
"valid": "Valid" "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": { "buttons": {
"next": "Next", "next": "Next",
"previous": "Previous", "previous": "Previous",
"confirm": "Confirm", "confirm": "Confirm",
"generateRandomPw": "Generate random", "generateRandomPassword": "Generate random",
"createAnother": "Create another", "createAnother": "Create another",
"goBack": "Go back to users" "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": { "population": {
"fallback": "Unknown" "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}}" "number": "This field must be greater than or equal to {{minimum}}"
}, },
"too_big": { "too_big": {
"string": "This field must be at most {{minimum}} characters long", "string": "This field must be at most {{maximum}} characters long",
"number": "This field must be less than or equal to {{minimum}}" "number": "This field must be less than or equal to {{maximum}}"
}, },
"custom": { "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 { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core';
import { createFormContext } from '@mantine/form'; import { createFormContext } from '@mantine/form';
import { z } from 'zod'; import { z } from 'zod';
import { boardCustomizationSchema } from '~/validations/dashboards'; import { boardCustomizationSchema } from '~/validations/boards';
export const [ export const [
BoardCustomizationFormProvider, BoardCustomizationFormProvider,

View File

@@ -168,8 +168,8 @@ const CitySelectModal = ({ opened, closeModal, query, onCitySelected }: CitySele
<Center> <Center>
<Stack align="center"> <Stack align="center">
<IconAlertTriangle /> <IconAlertTriangle />
<Title order={6}>Nothing found</Title> <Title order={6}>{t('modal.table.nothingFound.title')}</Title>
<Text>Nothing was found, please try again</Text> <Text>{t('modal.table.nothingFound.description')}</Text>
</Stack> </Stack>
</Center> </Center>
</Modal> </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 { useForm } from '@mantine/form';
import { ContextModalProps, modals } from '@mantine/modals'; import { ContextModalProps, modals } from '@mantine/modals';
import { Trans, useTranslation } from 'next-i18next';
import { getStaticFallbackConfig } from '~/tools/config/getFallbackConfig'; import { getStaticFallbackConfig } from '~/tools/config/getFallbackConfig';
import { api } from '~/utils/api'; import { api } from '~/utils/api';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
import { createDashboardSchemaValidation } from '~/validations/dashboards'; import { createBoardSchemaValidation } from '~/validations/boards';
export const CreateDashboardModal = ({ context, id, innerProps }: ContextModalProps<{}>) => { export const CreateBoardModal = ({ id }: ContextModalProps<{}>) => {
const apiContext = api.useContext(); const { t } = useTranslation('manage/boards');
const utils = api.useContext();
const { isLoading, mutate } = api.config.save.useMutation({ const { isLoading, mutate } = api.config.save.useMutation({
onSuccess: async () => { onSuccess: async () => {
await apiContext.config.all.invalidate(); await utils.config.all.invalidate();
modals.close(id); modals.close(id);
}, },
}); });
@@ -21,7 +23,7 @@ export const CreateDashboardModal = ({ context, id, innerProps }: ContextModalPr
initialValues: { initialValues: {
name: '', name: '',
}, },
validate: i18nZodResolver(createDashboardSchemaValidation), validate: i18nZodResolver(createBoardSchemaValidation),
}); });
const handleSubmit = () => { const handleSubmit = () => {
@@ -35,9 +37,13 @@ export const CreateDashboardModal = ({ context, id, innerProps }: ContextModalPr
return ( return (
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack> <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> <Group grow>
<Button <Button
@@ -48,7 +54,7 @@ export const CreateDashboardModal = ({ context, id, innerProps }: ContextModalPr
color="gray" color="gray"
type="button" type="button"
> >
Cancel {t('common:cancel')}
</Button> </Button>
<Button <Button
type="submit" type="submit"
@@ -57,10 +63,22 @@ export const CreateDashboardModal = ({ context, id, innerProps }: ContextModalPr
variant="light" variant="light"
color="green" color="green"
> >
Create {t('modals.create.form.submit')}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</form> </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 { ContextModalProps, modals } from '@mantine/modals';
import { Trans, useTranslation } from 'next-i18next';
import { api } from '~/utils/api'; import { api } from '~/utils/api';
export const DeleteBoardModal = ({ type InnerProps = { boardName: string; onConfirm: () => Promise<void> };
context,
id, export const DeleteBoardModal = ({ id, innerProps }: ContextModalProps<InnerProps>) => {
innerProps, const { t } = useTranslation('manage/boards');
}: ContextModalProps<{ boardName: string; onConfirm: () => Promise<void> }>) => { const utils = api.useContext();
const apiContext = api.useContext();
const { isLoading, mutateAsync } = api.config.delete.useMutation({ const { isLoading, mutateAsync } = api.config.delete.useMutation({
onSuccess: async () => { onSuccess: async () => {
await apiContext.config.all.invalidate(); await utils.config.all.invalidate();
modals.close(id); modals.close(id);
}, },
}); });
return ( return (
<Stack> <Stack>
<Text> <Text>{t('modals.delete.text')}</Text>
Are you sure, that you want to delete this board? This action cannot be undone and your data
will be lost permanently.
</Text>
<Group grow> <Group grow>
<Button <Button
@@ -30,7 +27,7 @@ export const DeleteBoardModal = ({
variant="light" variant="light"
color="gray" color="gray"
> >
Cancel {t('common:cancel')}
</Button> </Button>
<Button <Button
onClick={async () => { onClick={async () => {
@@ -44,9 +41,21 @@ export const DeleteBoardModal = ({
variant="light" variant="light"
color="red" color="red"
> >
Delete {t('common:delete')}
</Button> </Button>
</Group> </Group>
</Stack> </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 { 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 { IconArrowRight, IconAt, IconUser } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { z } from 'zod'; import { z } from 'zod';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
interface CreateAccountStepProps { interface CreateAccountStepProps {
nextStep: ({ eMail, username }: { username: string; eMail: string }) => void; nextStep: ({ eMail, username }: { username: string; eMail: string }) => void;
@@ -10,7 +11,14 @@ interface CreateAccountStepProps {
defaultEmail: string; 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({ const form = useForm({
initialValues: { initialValues: {
username: defaultUsername, username: defaultUsername,
@@ -18,11 +26,9 @@ export const CreateAccountStep = ({ defaultEmail, defaultUsername, nextStep }: C
}, },
validateInputOnBlur: true, validateInputOnBlur: true,
validateInputOnChange: true, validateInputOnChange: true,
validate: zodResolver(createAccountStepValidationSchema), validate: i18nZodResolver(createAccountStepValidationSchema),
}); });
const { t } = useTranslation('user/create');
return ( return (
<Card mih={400}> <Card mih={400}>
<TextInput <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, Progress,
Text, Text,
} from '@mantine/core'; } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form'; import { useForm } from '@mantine/form';
import { import {
IconArrowLeft, IconArrowLeft,
IconArrowRight, IconArrowRight,
@@ -18,21 +18,22 @@ import {
IconKey, IconKey,
IconX, IconX,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useState } from 'react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { z } from 'zod'; import { z } from 'zod';
import { api } from '~/utils/api'; 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 = [ const requirements = [
{ re: /[0-9]/, label: 'Includes number' }, { re: /[0-9]/, label: 'number' },
{ re: /[a-z]/, label: 'Includes lowercase letter' }, { re: /[a-z]/, label: 'lowercase' },
{ re: /[A-Z]/, label: 'Includes uppercase letter' }, { re: /[A-Z]/, label: 'uppercase' },
{ re: /[$&+,:;=?@#|'<>.^*()%!-]/, label: 'Includes special symbol' }, { re: /[$&+,:;=?@#|'<>.^*()%!-]/, label: 'special' },
]; ];
function getStrength(password: string) { function getStrength(password: string) {
let multiplier = password.length > 5 ? 0 : 1; let multiplier = password.length >= minPasswordLength ? 0 : 1;
requirements.forEach((requirement) => { requirements.forEach((requirement) => {
if (!requirement.re.test(password)) { if (!requirement.re.test(password)) {
@@ -54,13 +55,16 @@ export const CreateAccountSecurityStep = ({
nextStep, nextStep,
prevStep, prevStep,
}: CreateAccountSecurityStepProps) => { }: CreateAccountSecurityStepProps) => {
const { t } = useTranslation('manage/users/create');
const { i18nZodResolver } = useI18nZodResolver();
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
password: defaultPassword, password: defaultPassword,
}, },
validateInputOnBlur: true, validateInputOnBlur: true,
validateInputOnChange: true, validateInputOnChange: true,
validate: zodResolver(createAccountSecurityStepValidationSchema), validate: i18nZodResolver(createAccountSecurityStepValidationSchema),
}); });
const { mutateAsync, isLoading } = api.password.generate.useMutation(); 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 strength = getStrength(form.values.password);
const color = strength === 100 ? 'teal' : strength > 50 ? 'yellow' : 'red'; const color = strength === 100 ? 'teal' : strength > 50 ? 'yellow' : 'red';
@@ -114,7 +116,7 @@ export const CreateAccountSecurityStep = ({
variant="default" variant="default"
mt="xl" mt="xl"
> >
{t('buttons.generateRandomPw')} {t('buttons.generateRandomPassword')}
</Button> </Button>
</Flex> </Flex>
</div> </div>
@@ -122,8 +124,8 @@ export const CreateAccountSecurityStep = ({
<Popover.Dropdown> <Popover.Dropdown>
<Progress color={color} value={strength} size={5} mb="xs" /> <Progress color={color} value={strength} size={5} mb="xs" />
<PasswordRequirement <PasswordRequirement
label={t('steps.security.password.requirement')} label="length"
meets={form.values.password.length > 5} meets={form.values.password.length >= minPasswordLength}
/> />
{checks} {checks}
</Popover.Dropdown> </Popover.Dropdown>
@@ -152,6 +154,8 @@ export const CreateAccountSecurityStep = ({
}; };
const PasswordRequirement = ({ meets, label }: { meets: boolean; label: string }) => { const PasswordRequirement = ({ meets, label }: { meets: boolean; label: string }) => {
const { t } = useTranslation('manage/users/create');
return ( return (
<Text <Text
color={meets ? 'teal' : 'red'} color={meets ? 'teal' : 'red'}
@@ -159,7 +163,12 @@ const PasswordRequirement = ({ meets, label }: { meets: boolean; label: string }
mt={7} mt={7}
size="sm" 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> </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 { ContextModalProps, modals } from '@mantine/modals';
import { Trans, useTranslation } from 'next-i18next';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { RouterOutputs } from '~/utils/api';
export const CopyInviteModal = ({ type InnerProps = RouterOutputs['invites']['create'];
id,
innerProps, export const CopyInviteModal = ({ id, innerProps }: ContextModalProps<InnerProps>) => {
}: ContextModalProps<{ id: string; token: string; expire: Date }>) => { const { t } = useTranslation('manage/users/invites');
const inviteUrl = useInviteUrl(innerProps.id, innerProps.token); const inviteUrl = useInviteUrl(innerProps.id, innerProps.token);
return ( return (
<Stack> <Stack>
<Text> <Text>
Your invitation has been generated. After this modal closes,{' '} <Trans
<b>you'll not be able to copy this link anymore</b>. If you do no longer wish to invite said i18nKey="manage/users/invites:modals.copy.description"
person, you can delete this invitation any time. components={{
b: <b />,
}}
/>
</Text> </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"> <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}> <Mark style={{ borderRadius: 4 }} color="gray" px={5}>
{innerProps.id} {innerProps.id}
</Mark> </Mark>
<Text weight="bold">Token:</Text> <Text weight="bold">{t('modals.copy.details.token')}:</Text>
<Mark style={{ borderRadius: 4 }} color="gray" px={5}> <Mark style={{ borderRadius: 4 }} color="gray" px={5}>
{innerProps.token} {innerProps.token}
</Mark> </Mark>
@@ -41,7 +48,7 @@ export const CopyInviteModal = ({
variant="default" variant="default"
fullWidth fullWidth
> >
Copy & Dismiss {t('modals.copy.button.close')}
</Button> </Button>
)} )}
</CopyButton> </CopyButton>
@@ -54,3 +61,15 @@ const useInviteUrl = (id: string, token: string) => {
return `${window.location.href.replace(router.pathname, `/auth/invite/${id}?token=${token}`)}`; 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 { Button, Group, Stack, Text, Title } from '@mantine/core';
import { DateInput, DateTimePicker } from '@mantine/dates'; import { DateTimePicker } from '@mantine/dates';
import { useForm, zodResolver } from '@mantine/form'; import { useForm } from '@mantine/form';
import { ContextModalProps, modals } from '@mantine/modals'; import { ContextModalProps, modals } from '@mantine/modals';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Trans, useTranslation } from 'next-i18next';
import { api } from '~/utils/api'; import { api } from '~/utils/api';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
import { createInviteSchema } from '~/validations/invite'; import { createInviteSchema } from '~/validations/invite';
import { openCopyInviteModal } from './copy-invite.modal';
export const CreateInviteModal = ({ id }: ContextModalProps<{}>) => { 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({ const { isLoading, mutateAsync } = api.invites.create.useMutation({
onSuccess: async (data) => { onSuccess: async (data) => {
await apiContext.invites.all.invalidate(); await utils.invites.all.invalidate();
modals.close(id); modals.close(id);
modals.openContextModal({ openCopyInviteModal(data);
modal: 'copyInviteModal',
title: <Text weight="bold">Copy invitation</Text>,
innerProps: data,
});
}, },
}); });
@@ -36,10 +36,7 @@ export const CreateInviteModal = ({ id }: ContextModalProps<{}>) => {
return ( return (
<Stack> <Stack>
<Text> <Text>{t('modals.create.description')}</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>
<DateTimePicker <DateTimePicker
popoverProps={{ withinPortal: true }} popoverProps={{ withinPortal: true }}
@@ -47,7 +44,7 @@ export const CreateInviteModal = ({ id }: ContextModalProps<{}>) => {
maxDate={maxDate} maxDate={maxDate}
withAsterisk withAsterisk
valueFormat="DD MMM YYYY hh:mm A" valueFormat="DD MMM YYYY hh:mm A"
label="Expiration date" label={t('modals.create.form.expires.label')}
variant="filled" variant="filled"
{...form.getInputProps('expirationDate')} {...form.getInputProps('expirationDate')}
/> />
@@ -60,7 +57,7 @@ export const CreateInviteModal = ({ id }: ContextModalProps<{}>) => {
variant="light" variant="light"
color="gray" color="gray"
> >
Cancel {t('common:cancel')}
</Button> </Button>
<Button <Button
onClick={async () => { onClick={async () => {
@@ -72,9 +69,21 @@ export const CreateInviteModal = ({ id }: ContextModalProps<{}>) => {
variant="light" variant="light"
color="green" color="green"
> >
Create {t('modals.create.form.submit')}
</Button> </Button>
</Group> </Group>
</Stack> </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 { Button, Group, Stack, Text } from '@mantine/core';
import { ContextModalProps, modals } from '@mantine/modals'; import { ContextModalProps, modals } from '@mantine/modals';
import { useTranslation } from 'next-i18next';
import { api } from '~/utils/api'; import { api } from '~/utils/api';
export const DeleteInviteModal = ({ export const DeleteInviteModal = ({ id, innerProps }: ContextModalProps<{ tokenId: string }>) => {
context, const { t } = useTranslation('manage/users/invites');
id, const utils = api.useContext();
innerProps, const { isLoading, mutateAsync: deleteAsync } = api.invites.delete.useMutation({
}: ContextModalProps<{ tokenId: string }>) => {
const apiContext = api.useContext();
const { isLoading, mutateAsync } = api.invites.delete.useMutation({
onSuccess: async () => { onSuccess: async () => {
await apiContext.invites.all.invalidate(); await utils.invites.all.invalidate();
modals.close(id); modals.close(id);
}, },
}); });
return ( return (
<Stack> <Stack>
<Text> <Text>{t('modals.delete.description')}</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>
<Group grow> <Group grow>
<Button <Button
@@ -29,11 +24,11 @@ export const DeleteInviteModal = ({
variant="light" variant="light"
color="gray" color="gray"
> >
Cancel {t('common:cancel')}
</Button> </Button>
<Button <Button
onClick={async () => { onClick={async () => {
await mutateAsync({ await deleteAsync({
tokenId: innerProps.tokenId, tokenId: innerProps.tokenId,
}); });
}} }}
@@ -41,7 +36,7 @@ export const DeleteInviteModal = ({
variant="light" variant="light"
color="red" color="red"
> >
Delete {t('common:delete')}
</Button> </Button>
</Group> </Group>
</Stack> </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, IconLayoutDashboard,
IconMailForward, IconMailForward,
IconQuestionMark, IconQuestionMark,
IconSettings2,
IconUser, IconUser,
IconUsers, IconUsers,
TablerIconsProps,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; 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 { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
import { usePackageAttributesStore } from '~/tools/client/zustands/usePackageAttributesStore'; import { usePackageAttributesStore } from '~/tools/client/zustands/usePackageAttributesStore';
import { type navigation } from '../../../../public/locales/en/layout/manage.json';
import { MainHeader } from '../header/Header'; import { MainHeader } from '../header/Header';
interface ManageLayoutProps { interface ManageLayoutProps {
@@ -40,7 +42,8 @@ interface ManageLayoutProps {
} }
export const ManageLayout = ({ children }: 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 theme = useMantineTheme();
const screenLargerThanMd = useScreenLargerThan('md'); const screenLargerThanMd = useScreenLargerThan('md');
@@ -51,100 +54,19 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => {
const data = useSession(); const data = useSession();
const isAdmin = data.data?.user.isAdmin ?? false; const isAdmin = data.data?.user.isAdmin ?? false;
const navigationLinks = ( const navigationLinkComponents = Object.entries(navigationLinks).map(([name, navigationLink]) => {
<> if (navigationLink.onlyAdmin && !isAdmin) {
<NavLink return null;
icon={
<ThemeIcon size="md" variant="light" color="red">
<IconHome size="1rem" />
</ThemeIcon>
} }
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 && ( return (
<> <CustomNavigationLink
<NavLink key={name}
label="Users" name={name as keyof typeof navigationLinks}
icon={ navigationLink={navigationLink}
<ThemeIcon size="md" variant="light" color="red">
<IconUser size="1rem" />
</ThemeIcon>
}
>
<NavLink
icon={<IconUsers size="1rem" />}
label="Manage"
component={Link}
href="/manage/users"
/> />
<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 : ( const burgerMenu = screenLargerThanMd ? undefined : (
<Burger opened={burgerMenuOpen} onClick={toggleBurgerMenu} /> <Burger opened={burgerMenuOpen} onClick={toggleBurgerMenu} />
@@ -161,7 +83,7 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => {
navbar={ navbar={
<Navbar width={{ base: !screenLargerThanMd ? 0 : 220 }} hidden={!screenLargerThanMd}> <Navbar width={{ base: !screenLargerThanMd ? 0 : 220 }} hidden={!screenLargerThanMd}>
<Navbar.Section pt="xs" grow> <Navbar.Section pt="xs" grow>
{navigationLinks} {navigationLinkComponents}
</Navbar.Section> </Navbar.Section>
</Navbar> </Navbar>
} }
@@ -174,9 +96,9 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => {
<Text fw="bold" size={15}> <Text fw="bold" size={15}>
Homarr Homarr
</Text> </Text>
{attributes.packageVersion && ( {packageVersion && (
<Text color="dimmed" size={13}> <Text color="dimmed" size={13}>
{attributes.packageVersion} {packageVersion}
</Text> </Text>
)} )}
</Flex> </Flex>
@@ -189,8 +111,126 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => {
</Paper> </Paper>
</AppShell> </AppShell>
<Drawer opened={burgerMenuOpen} onClose={closeBurgerMenu}> <Drawer opened={burgerMenuOpen} onClose={closeBurgerMenu}>
{navigationLinks} {navigationLinkComponents}
</Drawer> </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')} {t('actions.avatar.defaultBoard')}
</Menu.Item> </Menu.Item>
<Menu.Item component={Link} href="/manage" icon={<IconHomeShare size="1rem" />}> <Menu.Item component={Link} href="/manage" icon={<IconHomeShare size="1rem" />}>
Manage {t('actions.avatar.manage')}
</Menu.Item> </Menu.Item>
<Menu.Divider /> <Menu.Divider />
</> </>

View File

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

View File

@@ -21,7 +21,7 @@ const env = createEnv({
process.env.VERCEL ? z.string().min(1) : z.string().url() process.env.VERCEL ? z.string().min(1) : z.string().url()
), ),
DOCKER_HOST: z.string().optional(), 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 { WidgetsRemoveModal } from '~/components/Dashboard/Tiles/Widgets/WidgetsRemoveModal';
import { CategoryEditModal } from '~/components/Dashboard/Wrappers/Category/CategoryEditModal'; import { CategoryEditModal } from '~/components/Dashboard/Wrappers/Category/CategoryEditModal';
import { CopyInviteModal } from './copy-invite/copy-invite.modal'; import { CreateBoardModal } from './components/Manage/Board/create-board.modal';
import { CreateDashboardModal } from './create-dashboard/create-dashboard.modal'; import { DeleteBoardModal } from './components/Manage/Board/delete-board.modal';
import { CreateInviteModal } from './create-invite/create-invite.modal'; import { CopyInviteModal } from './components/Manage/User/Invite/copy-invite.modal';
import { DeleteBoardModal } from './delete-board/delete-board.modal'; import { CreateInviteModal } from './components/Manage/User/Invite/create-invite.modal';
import { DeleteInviteModal } from './delete-invite/delete-invite.modal'; import { DeleteInviteModal } from './components/Manage/User/Invite/delete-invite.modal';
import { DeleteUserModal } from './delete-user/delete-user.modal'; import { DeleteUserModal } from './components/Manage/User/delete-user.modal';
export const modals = { export const modals = {
editApp: EditAppModal, editApp: EditAppModal,
@@ -24,7 +24,7 @@ export const modals = {
deleteUserModal: DeleteUserModal, deleteUserModal: DeleteUserModal,
createInviteModal: CreateInviteModal, createInviteModal: CreateInviteModal,
deleteInviteModal: DeleteInviteModal, deleteInviteModal: DeleteInviteModal,
createDashboardModal: CreateDashboardModal, createBoardModal: CreateBoardModal,
copyInviteModal: CopyInviteModal, copyInviteModal: CopyInviteModal,
deleteBoardModal: DeleteBoardModal, 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 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, enabled: dockerEnabled,
}); });
useHotkeys([['mod+B', () => setOpened(!opened)]]); useHotkeys([['mod+B', () => setOpened(!opened)]]);
@@ -42,7 +42,7 @@ export default function DockerMenuButton(props: any) {
padding="xl" padding="xl"
position="right" position="right"
size="100%" size="100%"
title={<ContainerActionBar isLoading selected={selection} reload={reload} />} title={<ContainerActionBar isLoading={isLoading} selected={selection} reload={reload} />}
transitionProps={{ transitionProps={{
transition: 'pop', transition: 'pop',
}} }}

View File

@@ -20,7 +20,7 @@ import { z } from 'zod';
import { CommonHead } from '~/components/layout/Meta/CommonHead'; import { CommonHead } from '~/components/layout/Meta/CommonHead';
import { env } from '~/env.js'; import { env } from '~/env.js';
import { ColorSchemeProvider } from '~/hooks/use-colorscheme'; 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 { queryClient } from '~/tools/server/configurations/tanstack/queryClient.tool';
import { ConfigType } from '~/types/config'; import { ConfigType } from '~/types/config';
import { api } from '~/utils/api'; 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 { IconCheck, IconX } from '@tabler/icons-react';
import { GetServerSideProps } from 'next'; import { GetServerSideProps } from 'next';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { z } from 'zod'; import { z } from 'zod';
import { getServerAuthSession } from '~/server/auth'; import { getServerAuthSession } from '~/server/auth';
import { prisma } from '~/server/db'; import { prisma } from '~/server/db';
import { inviteNamespaces } from '~/tools/server/translation-namespaces'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { api } from '~/utils/api'; import { api } from '~/utils/api';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
import { signUpFormSchema } from '~/validations/user'; import { signUpFormSchema } from '~/validations/user';
const notificationId = 'register';
export default function AuthInvitePage() { export default function AuthInvitePage() {
const { t } = useTranslation('authentication/invite'); const { t } = useTranslation('authentication/invite');
const { i18nZodResolver } = useI18nZodResolver(); const { i18nZodResolver } = useI18nZodResolver();
@@ -28,11 +30,10 @@ export default function AuthInvitePage() {
}); });
const handleSubmit = (values: z.infer<typeof signUpFormSchema>) => { const handleSubmit = (values: z.infer<typeof signUpFormSchema>) => {
const notificationId = 'register';
showNotification({ showNotification({
id: notificationId, id: notificationId,
title: 'Creating account', title: t('notifications.loading.title'),
message: 'Please wait...', message: `${t('notifications.loading.text')}...`,
loading: true, loading: true,
}); });
void mutateAsync( void mutateAsync(
@@ -44,8 +45,8 @@ export default function AuthInvitePage() {
onSuccess() { onSuccess() {
updateNotification({ updateNotification({
id: notificationId, id: notificationId,
title: 'Account created', title: t('notifications.success.title'),
message: 'Your account has been created successfully', message: t('notifications.success.text'),
color: 'teal', color: 'teal',
icon: <IconCheck />, icon: <IconCheck />,
}); });
@@ -54,8 +55,8 @@ export default function AuthInvitePage() {
onError() { onError() {
updateNotification({ updateNotification({
id: notificationId, id: notificationId,
title: 'Error', title: t('notifications.error.title'),
message: 'Something went wrong', message: t('notifications.error.text'),
color: 'red', color: 'red',
icon: <IconX />, icon: <IconX />,
}); });
@@ -64,7 +65,14 @@ export default function AuthInvitePage() {
); );
}; };
const metaTitle = `${t('metaTitle')} • Homarr`;
return ( return (
<>
<Head>
<title>{metaTitle}</title>
</Head>
<Flex h="100dvh" display="flex" w="100%" direction="column" align="center" justify="center"> <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}> <Card withBorder shadow="md" p="xl" radius="md" w="90%" maw={420}>
<Title align="center" weight={900}> <Title align="center" weight={900}>
@@ -105,6 +113,7 @@ export default function AuthInvitePage() {
</form> </form>
</Card> </Card>
</Flex> </Flex>
</>
); );
} }
@@ -157,7 +166,7 @@ export const getServerSideProps: GetServerSideProps = async ({
return { return {
props: { 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 { GetServerSideProps } from 'next';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { z } from 'zod'; import { z } from 'zod';
import { getServerAuthSession } from '~/server/auth'; import { getServerAuthSession } from '~/server/auth';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
import { signInSchema } from '~/validations/user'; import { signInSchema } from '~/validations/user';
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 & {}) };
@@ -54,11 +52,15 @@ export default function LoginPage() {
}); });
}; };
const metaTitle = `${t('metaTitle')} • Homarr`;
return ( return (
<Flex h="100dvh" display="flex" w="100%" direction="column" align="center" justify="center"> <>
<Head> <Head>
<title>Login Homarr</title> <title>{metaTitle}</title>
</Head> </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}> <Card withBorder shadow="md" p="xl" radius="md" w="90%" maw={420}>
<Title align="center" weight={900}> <Title align="center" weight={900}>
{t('title')} {t('title')}
@@ -97,6 +99,7 @@ export default function LoginPage() {
</form> </form>
</Card> </Card>
</Flex> </Flex>
</>
); );
} }
@@ -114,8 +117,7 @@ export const getServerSideProps: GetServerSideProps = async ({ locale, req, res
return { return {
props: { props: {
...(await serverSideTranslations(locale ?? 'en', loginNamespaces)), ...(await getServerSideTranslations(['authentication/login'], locale, req, res)),
// Will be passed to the page component as props
}, },
}; };
}; };

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import {
import { IconArrowRight } from '@tabler/icons-react'; import { IconArrowRight } from '@tabler/icons-react';
import { GetServerSideProps } from 'next'; import { GetServerSideProps } from 'next';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useTranslation } from 'next-i18next';
import Head from 'next/head'; import Head from 'next/head';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
@@ -19,24 +20,31 @@ import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
import { useScreenLargerThan } from '~/hooks/useScreenLargerThan'; import { useScreenLargerThan } from '~/hooks/useScreenLargerThan';
import { getServerAuthSession } from '~/server/auth'; import { getServerAuthSession } from '~/server/auth';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { OnlyKeysWithStructure } from '~/types/helpers';
import { type quickActions } from '../../../public/locales/en/manage/index.json';
const ManagementPage = () => { const ManagementPage = () => {
const { t } = useTranslation('manage/index');
const { classes } = useStyles(); const { classes } = useStyles();
const largerThanMd = useScreenLargerThan('md'); const largerThanMd = useScreenLargerThan('md');
const { data: sessionData } = useSession(); const { data: sessionData } = useSession();
const metaTitle = `${t('metaTitle')} • Homarr`;
return ( return (
<ManageLayout> <ManageLayout>
<Head> <Head>
<title>Manage Homarr</title> <title>{metaTitle}</title>
</Head> </Head>
<Box className={classes.box} w="100%" mih={150} p="xl" mb={50}> <Box className={classes.box} w="100%" mih={150} p="xl" mb={50}>
<Group position="apart" noWrap> <Group position="apart" noWrap>
<Stack spacing={15}> <Stack spacing={15}>
<Title className={classes.boxTitle} order={2}> <Title className={classes.boxTitle} order={2}>
Welcome back, {sessionData?.user?.name ?? 'Anonymous'} {t('hero.title', {
username: sessionData?.user?.name ?? t('hero.fallbackUsername'),
})}
</Title> </Title>
<Text>Welcome to Your Application Hub. Organize, Optimize, and Conquer!</Text> <Text>{t('hero.subtitle')}</Text>
</Stack> </Stack>
<Box bg="blue" w={100} h="100%" pos="relative"> <Box bg="blue" w={100} h="100%" pos="relative">
<Box <Box
@@ -49,7 +57,7 @@ const ManagementPage = () => {
src="/imgs/logo/logo.png" src="/imgs/logo/logo.png"
width={largerThanMd ? 200 : 100} width={largerThanMd ? 200 : 100}
height={largerThanMd ? 150 : 60} height={largerThanMd ? 150 : 60}
alt="" alt="Homarr Logo"
/> />
</Box> </Box>
</Box> </Box>
@@ -57,7 +65,7 @@ const ManagementPage = () => {
</Box> </Box>
<Text weight="bold" mb="md"> <Text weight="bold" mb="md">
Quick actions {t('quickActions.title')}
</Text> </Text>
<SimpleGrid <SimpleGrid
cols={3} cols={3}
@@ -67,44 +75,46 @@ const ManagementPage = () => {
{ maxWidth: '48rem', cols: 1, spacing: 'md' }, { maxWidth: '48rem', cols: 1, spacing: 'md' },
]} ]}
> >
<UnstyledButton component={Link} href="/manage/boards"> <QuickActionCard type="boards" href="/manage/boards" />
<Card className={classes.quickActionCard}> <QuickActionCard type="inviteUsers" href="/manage/users/invites" />
<Group spacing={30} noWrap> <QuickActionCard type="manageUsers" href="/manage/users" />
<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>
</SimpleGrid> </SimpleGrid>
</ManageLayout> </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) => { export const getServerSideProps: GetServerSideProps = async (ctx) => {
const session = await getServerAuthSession(ctx); const session = await getServerAuthSession(ctx);
@@ -115,10 +125,10 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
} }
const translations = await getServerSideTranslations( const translations = await getServerSideTranslations(
['common'], ['layout/manage', 'manage/index'],
ctx.locale, ctx.locale,
undefined, ctx.req,
undefined ctx.res
); );
return { return {
props: { 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 { Alert, Button, Group, Stepper } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form'; import { useForm } from '@mantine/form';
import { import { IconArrowLeft, IconKey, IconMailCheck, IconUser, IconUserPlus } from '@tabler/icons-react';
IconArrowLeft,
IconCheck,
IconInfoCircle,
IconKey,
IconMail,
IconMailCheck,
IconUser,
IconUserPlus,
} from '@tabler/icons-react';
import { GetServerSideProps } from 'next'; import { GetServerSideProps } from 'next';
import { useTranslation } from 'next-i18next';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'next-i18next';
import { z } from 'zod'; import { z } from 'zod';
import { import {
CreateAccountStep, CreateAccountStep,
createAccountStepValidationSchema, createAccountStepValidationSchema,
} from '~/components/Manage/User/Create/create-account-step'; } from '~/components/Manage/User/Create/create-account-step';
import { ReviewInputStep } from '~/components/Manage/User/Create/review-input-step';
import { import {
CreateAccountSecurityStep, CreateAccountSecurityStep,
createAccountSecurityStepValidationSchema, createAccountSecurityStepValidationSchema,
@@ -27,15 +19,17 @@ import {
import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
import { getServerAuthSession } from '~/server/auth'; import { getServerAuthSession } from '~/server/auth';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { api } from '~/utils/api';
import { manageNamespaces } from '~/tools/server/translation-namespaces'; import { manageNamespaces } from '~/tools/server/translation-namespaces';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
const CreateNewUserPage = () => { const CreateNewUserPage = () => {
const { t } = useTranslation('manage/users/create');
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
const nextStep = () => setActive((current) => (current < 3 ? current + 1 : current)); const nextStep = () => setActive((current) => (current < 3 ? current + 1 : current));
const prevStep = () => setActive((current) => (current > 0 ? current - 1 : current)); const prevStep = () => setActive((current) => (current > 0 ? current - 1 : current));
const { i18nZodResolver } = useI18nZodResolver();
const form = useForm({ const form = useForm<CreateAccountSchema>({
initialValues: { initialValues: {
account: { account: {
username: '', username: '',
@@ -45,30 +39,14 @@ const CreateNewUserPage = () => {
password: '', password: '',
}, },
}, },
validate: zodResolver( validate: i18nZodResolver(createAccountSchema),
z.object({
account: createAccountStepValidationSchema,
security: createAccountSecurityStepValidationSchema,
})
),
}); });
const context = api.useContext(); const metaTitle = `${t('metaTitle')} • Homarr`;
const { mutateAsync, isLoading } = api.user.create.useMutation({
onSettled: () => {
void context.user.all.invalidate();
},
onSuccess: () => {
nextStep();
},
});
const { t } = useTranslation('user/create');
return ( return (
<ManageLayout> <ManageLayout>
<Head> <Head>
<title>Create user Homarr</title> <title>{metaTitle}</title>
</Head> </Head>
<Stepper active={active} onStepClick={setActive} breakpoint="sm" mih="100%"> <Stepper active={active} onStepClick={setActive} breakpoint="sm" mih="100%">
@@ -111,92 +89,11 @@ const CreateNewUserPage = () => {
label={t('steps.finish.title')} label={t('steps.finish.title')}
description={t('steps.finish.title')} description={t('steps.finish.title')}
> >
<Card mih={400}> <ReviewInputStep values={form.values} prevStep={prevStep} nextStep={nextStep} />
<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>
</Stepper.Step> </Stepper.Step>
<Stepper.Completed> <Stepper.Completed>
<Alert title="User was created" color="green" mb="md"> <Alert title={t('steps.completed.alert.title')} color="green" mb="md">
{t('steps.finish.alertConfirmed')} {t('steps.completed.alert.text')}
</Alert> </Alert>
<Group> <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) => { export const getServerSideProps: GetServerSideProps = async (ctx) => {
const session = await getServerAuthSession(ctx); const session = await getServerAuthSession(ctx);
@@ -237,8 +141,8 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
const translations = await getServerSideTranslations( const translations = await getServerSideTranslations(
manageNamespaces, manageNamespaces,
ctx.locale, ctx.locale,
undefined, ctx.req,
undefined ctx.res
); );
return { return {
props: { props: {

View File

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

View File

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

View File

@@ -26,7 +26,6 @@ import { ReactNode, useMemo, useState } from 'react';
import { z } from 'zod'; import { z } from 'zod';
import { prisma } from '~/server/db'; import { prisma } from '~/server/db';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { onboardNamespaces } from '~/tools/server/translation-namespaces';
import { api } from '~/utils/api'; import { api } from '~/utils/api';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
import { signUpFormSchema } from '~/validations/user'; import { signUpFormSchema } from '~/validations/user';
@@ -225,12 +224,7 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
}; };
} }
const translations = await getServerSideTranslations( const translations = await getServerSideTranslations([], ctx.locale, ctx.req, ctx.res);
onboardNamespaces,
ctx.locale,
ctx.req,
ctx.res
);
return { return {
props: { props: {

View File

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

View File

@@ -7,7 +7,7 @@ import { z } from 'zod';
import { configExists } from '~/tools/config/configExists'; import { configExists } from '~/tools/config/configExists';
import { getFrontendConfig } from '~/tools/config/getFrontendConfig'; import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
import { BackendConfigType, ConfigType } from '~/types/config'; import { BackendConfigType, ConfigType } from '~/types/config';
import { boardCustomizationSchema } from '~/validations/dashboards'; import { boardCustomizationSchema } from '~/validations/boards';
import { IRssWidget } from '~/widgets/rss/RssWidgetTile'; import { IRssWidget } from '~/widgets/rss/RssWidgetTile';
import { getConfig } from '../../../tools/config/getConfig'; 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: { defaultSettings: {
colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]), colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]),
language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en', language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en',
@@ -62,7 +62,7 @@ export const userRouter = createTRPCRouter({
}); });
} }
await createUserInNotExist(ctx, input, { await createUserIfNotPresent(ctx, input, {
defaultSettings: { defaultSettings: {
colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]), colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]),
language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en', language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en',
@@ -235,7 +235,7 @@ export const userRouter = createTRPCRouter({
return { return {
users: users.map((user) => ({ users: users.map((user) => ({
id: user.id, id: user.id,
name: user.name, name: user.name!,
email: user.email, email: user.email,
emailVerified: user.emailVerified, emailVerified: user.emailVerified,
})), })),
@@ -243,25 +243,25 @@ export const userRouter = createTRPCRouter({
}; };
}), }),
create: adminProcedure.input(createNewUserSchema).mutation(async ({ ctx, input }) => { create: adminProcedure.input(createNewUserSchema).mutation(async ({ ctx, input }) => {
await createUserInNotExist(ctx, input); await createUserIfNotPresent(ctx, input);
}), }),
deleteUser: adminProcedure deleteUser: adminProcedure
.input( .input(
z.object({ z.object({
userId: z.string(), id: z.string(),
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await ctx.prisma.user.delete({ await ctx.prisma.user.delete({
where: { where: {
id: input.userId, id: input.id,
}, },
}); });
}), }),
}); });
const createUserInNotExist = async ( const createUserIfNotPresent = async (
ctx: TRPCContext, ctx: TRPCContext,
input: z.infer<typeof createNewUserSchema>, input: z.infer<typeof createNewUserSchema>,
options: { options: {

View File

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

View File

@@ -1,10 +1,7 @@
export const boardNamespaces = [ export const boardNamespaces = [
'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',
'layout/modals/about',
'layout/common', 'layout/common',
'layout/header/actions/toggle-edit-mode', 'layout/header/actions/toggle-edit-mode',
'layout/mobile/drawer', 'layout/mobile/drawer',
@@ -42,16 +39,9 @@ export const boardNamespaces = [
]; ];
export const manageNamespaces = [ export const manageNamespaces = [
'user/preferences', 'manage/common',
'user/manage', 'manage/boards',
'user/invites', 'manage/users',
'user/create', 'manage/users/invites',
'boards/manage', 'manage/users/create',
'zod',
]; ];
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 { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core';
import { z } from 'zod'; import { z } from 'zod';
export const createDashboardSchemaValidation = z.object({ export const createBoardSchemaValidation = z.object({
name: z.string().min(2).max(25), name: z.string().min(2).max(25),
}); });

View File

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

View File

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