diff --git a/apps/nextjs/src/app/[locale]/manage/[...not-found]/page.tsx b/apps/nextjs/src/app/[locale]/manage/[...not-found]/page.tsx index 8579e45c4..d7b31a978 100644 --- a/apps/nextjs/src/app/[locale]/manage/[...not-found]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/[...not-found]/page.tsx @@ -1,17 +1,5 @@ -import { Center, Stack, Text, Title } from "@mantine/core"; +import { notFound } from "next/navigation"; -import { getScopedI18n } from "@homarr/translation/server"; - -export default async function NotFound() { - const t = await getScopedI18n("management.notFound"); - return ( -
- - - {t("title")} - - {t("text")} - -
- ); +export default function NotFound() { + return notFound(); } diff --git a/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx index 46f0a3c82..beda37acf 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx @@ -74,9 +74,7 @@ export const BoardCardMenuDropdown = ({ {hasFullAccess && ( <> - - {tCommon("menu.section.dangerZone.title")} - + {tCommon("dangerZone")} } diff --git a/apps/nextjs/src/app/[locale]/manage/not-found.tsx b/apps/nextjs/src/app/[locale]/manage/not-found.tsx new file mode 100644 index 000000000..8579e45c4 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/not-found.tsx @@ -0,0 +1,17 @@ +import { Center, Stack, Text, Title } from "@mantine/core"; + +import { getScopedI18n } from "@homarr/translation/server"; + +export default async function NotFound() { + const t = await getScopedI18n("management.notFound"); + return ( +
+ + + {t("title")} + + {t("text")} + +
+ ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/dangerZone.accordion.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/dangerZone.accordion.tsx deleted file mode 100644 index 94cf52259..000000000 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/dangerZone.accordion.tsx +++ /dev/null @@ -1,46 +0,0 @@ -"use client"; - -import React from "react"; -import { useRouter } from "next/navigation"; -import { Button, Divider, Group, Stack, Text } from "@mantine/core"; - -import type { RouterOutputs } from "@homarr/api"; -import { clientApi } from "@homarr/api/client"; -import { useScopedI18n } from "@homarr/translation/client"; - -interface DangerZoneAccordionProps { - user: NonNullable; -} - -export const DangerZoneAccordion = ({ user }: DangerZoneAccordionProps) => { - const t = useScopedI18n("management.page.user.edit.section.dangerZone"); - const router = useRouter(); - const { mutateAsync: mutateUserDeletionAsync } = - clientApi.user.delete.useMutation({ - onSettled: () => { - router.push("/manage/users"); - }, - }); - - const handleDelete = React.useCallback( - async () => await mutateUserDeletionAsync(user.id), - [user, mutateUserDeletionAsync], - ); - - return ( - - - - - - {t("action.delete.label")} - - {t("action.delete.description")} - - - - - ); -}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/profile.accordion.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/profile.accordion.tsx deleted file mode 100644 index 00a65d0ea..000000000 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/profile.accordion.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import { Button, Stack, TextInput } from "@mantine/core"; - -import type { RouterOutputs } from "@homarr/api"; -import { clientApi } from "@homarr/api/client"; -import { useForm, zodResolver } from "@homarr/form"; -import { useI18n } from "@homarr/translation/client"; -import { validation } from "@homarr/validation"; - -import { revalidatePathAction } from "~/app/revalidatePathAction"; - -interface ProfileAccordionProps { - user: NonNullable; -} - -export const ProfileAccordion = ({ user }: ProfileAccordionProps) => { - const t = useI18n(); - const { mutate, isPending } = clientApi.user.editProfile.useMutation({ - onSettled: async () => { - await revalidatePathAction("/manage/users"); - }, - }); - const form = useForm({ - initialValues: { - name: user.name ?? "", - email: user.email ?? "", - }, - validate: zodResolver(validation.user.editProfile), - validateInputOnBlur: true, - validateInputOnChange: true, - }); - - const handleSubmit = () => { - mutate({ - userId: user.id, - form: form.values, - }); - }; - - return ( -
- - - - - -
- ); -}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/security.accordion.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/security.accordion.tsx deleted file mode 100644 index 6bbae085a..000000000 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/security.accordion.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client"; - -import { Button, PasswordInput, Stack, Title } from "@mantine/core"; - -import type { RouterOutputs } from "@homarr/api"; -import { clientApi } from "@homarr/api/client"; -import { useForm, zodResolver } from "@homarr/form"; -import { showSuccessNotification } from "@homarr/notifications"; -import { useI18n } from "@homarr/translation/client"; -import { validation } from "@homarr/validation"; - -import { revalidatePathAction } from "~/app/revalidatePathAction"; - -interface SecurityAccordionComponentProps { - user: NonNullable; -} - -export const SecurityAccordionComponent = ({ - user, -}: SecurityAccordionComponentProps) => { - return ( - - - - ); -}; - -const ChangePasswordForm = ({ - user, -}: { - user: NonNullable; -}) => { - const t = useI18n(); - const { mutate, isPending } = clientApi.user.changePassword.useMutation({ - onSettled: async () => { - await revalidatePathAction(`/manage/users/${user.id}`); - showSuccessNotification({ - title: t( - "management.page.user.edit.section.security.changePassword.message.passwordUpdated", - ), - message: "", - }); - }, - }); - const form = useForm({ - initialValues: { - userId: user.id, - password: "", - }, - validate: zodResolver(validation.user.changePassword), - validateInputOnBlur: true, - validateInputOnChange: true, - }); - - const handleSubmit = () => { - mutate(form.values); - }; - - return ( -
- - - - {t( - "management.page.user.edit.section.security.changePassword.title", - )} - - - - - -
- ); -}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/_delete-user-button.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/_delete-user-button.tsx new file mode 100644 index 000000000..6ef7e4ead --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/_delete-user-button.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@mantine/core"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { useConfirmModal } from "@homarr/modals"; +import { useI18n } from "@homarr/translation/client"; + +import { revalidatePathAction } from "~/app/revalidatePathAction"; + +interface DeleteUserButtonProps { + user: RouterOutputs["user"]["getById"]; +} + +export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => { + const t = useI18n(); + const router = useRouter(); + const { mutateAsync: mutateUserDeletionAsync } = + clientApi.user.delete.useMutation({ + async onSuccess() { + await revalidatePathAction("/manage/users").then(() => + router.push("/manage/users"), + ); + }, + }); + const { openConfirmModal } = useConfirmModal(); + + const handleDelete = useCallback( + () => + openConfirmModal({ + title: t("user.action.delete.label"), + children: t("user.action.delete.confirm", { username: user.name }), + async onConfirm() { + await mutateUserDeletionAsync(user.id); + }, + }), + [user, mutateUserDeletionAsync, openConfirmModal, t], + ); + + return ( + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/_profile-avatar-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/_profile-avatar-form.tsx new file mode 100644 index 000000000..3880fb9d4 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/_profile-avatar-form.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useCallback } from "react"; +import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { useConfirmModal } from "@homarr/modals"; +import { + showErrorNotification, + showSuccessNotification, +} from "@homarr/notifications"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import { UserAvatar } from "@homarr/ui"; + +import { revalidatePathAction } from "~/app/revalidatePathAction"; + +interface UserProfileAvatarForm { + user: RouterOutputs["user"]["getById"]; +} + +export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => { + const { mutate } = clientApi.user.setProfileImage.useMutation(); + const [opened, { toggle }] = useDisclosure(false); + const { openConfirmModal } = useConfirmModal(); + const t = useI18n(); + const tManageAvatar = useScopedI18n("user.action.manageAvatar"); + + const handleAvatarChange = useCallback( + async (file: File | null) => { + if (!file) { + return; + } + + const base64Url = await fileToBase64Async(file); + + mutate( + { + userId: user.id, + image: base64Url, + }, + { + async onSuccess() { + // Revalidate all as the avatar is used in multiple places + await revalidatePathAction("/"); + showSuccessNotification({ + message: tManageAvatar( + "changeImage.notification.success.message", + ), + }); + }, + onError(error) { + if (error.shape?.data.code === "BAD_REQUEST") { + showErrorNotification({ + title: tManageAvatar("changeImage.notification.toLarge.title"), + message: tManageAvatar( + "changeImage.notification.toLarge.message", + { size: "256KB" }, + ), + }); + } else { + showErrorNotification({ + message: tManageAvatar( + "changeImage.notification.error.message", + ), + }); + } + }, + }, + ); + }, + [mutate, user.id, tManageAvatar], + ); + + const handleRemoveAvatar = useCallback(() => { + openConfirmModal({ + title: tManageAvatar("removeImage.label"), + children: tManageAvatar("removeImage.confirm"), + onConfirm() { + mutate( + { + userId: user.id, + image: null, + }, + { + async onSuccess() { + // Revalidate all as the avatar is used in multiple places + await revalidatePathAction("/"); + showSuccessNotification({ + message: tManageAvatar( + "removeImage.notification.success.message", + ), + }); + }, + onError() { + showErrorNotification({ + message: tManageAvatar( + "removeImage.notification.error.message", + ), + }); + }, + }, + ); + }, + }); + }, [mutate, user.id, openConfirmModal, tManageAvatar]); + + return ( + + + + + + + + + + + {(props) => ( + } + > + {tManageAvatar("changeImage.label")} + + )} + + {user.image && ( + } + > + {tManageAvatar("removeImage.label")} + + )} + + + + ); +}; + +const fileToBase64Async = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result?.toString() || ""); + reader.onerror = reject; + }); diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/_profile-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/_profile-form.tsx new file mode 100644 index 000000000..af2028300 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/_profile-form.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useCallback } from "react"; +import { Button, Group, Stack, TextInput } from "@mantine/core"; + +import type { RouterInputs, RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { useForm, zodResolver } from "@homarr/form"; +import { + showErrorNotification, + showSuccessNotification, +} from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; +import { validation } from "@homarr/validation"; + +import { revalidatePathAction } from "~/app/revalidatePathAction"; + +interface UserProfileFormProps { + user: RouterOutputs["user"]["getById"]; +} + +export const UserProfileForm = ({ user }: UserProfileFormProps) => { + const t = useI18n(); + const { mutate, isPending } = clientApi.user.editProfile.useMutation({ + async onSettled() { + await revalidatePathAction("/manage/users"); + }, + onSuccess() { + showSuccessNotification({ + title: t("common.notification.update.success"), + message: t("user.action.editProfile.notification.success.message"), + }); + }, + onError() { + showErrorNotification({ + title: t("common.notification.update.error"), + message: t("user.action.editProfile.notification.error.message"), + }); + }, + }); + const form = useForm({ + initialValues: { + name: user.name ?? "", + email: user.email ?? "", + }, + validate: zodResolver(validation.user.editProfile.omit({ id: true })), + validateInputOnBlur: true, + validateInputOnChange: true, + }); + + const handleSubmit = useCallback( + (values: FormType) => { + mutate({ + ...values, + id: user.id, + }); + }, + [user.id, mutate], + ); + + return ( +
+ + + + + + + + +
+ ); +}; + +type FormType = Omit; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/access.ts b/apps/nextjs/src/app/[locale]/manage/users/[userId]/access.ts new file mode 100644 index 000000000..c5ed1ac30 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/access.ts @@ -0,0 +1,20 @@ +import type { Session } from "@homarr/auth"; + +export const canAccessUserEditPage = ( + session: Session | null, + userId: string, +) => { + if (!session) { + return false; + } + + if (session.user.id === userId) { + return true; + } + + if (session.user.permissions.includes("admin")) { + return true; + } + + return false; +}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/layout.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/layout.tsx new file mode 100644 index 000000000..56c798610 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/layout.tsx @@ -0,0 +1,88 @@ +import type { PropsWithChildren } from "react"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { + Button, + Container, + Grid, + GridCol, + Group, + Stack, + Text, + Title, +} from "@mantine/core"; +import { IconSettings, IconShieldLock } from "@tabler/icons-react"; + +import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; +import { getI18n, getScopedI18n } from "@homarr/translation/server"; +import { UserAvatar } from "@homarr/ui"; + +import { catchTrpcNotFound } from "~/errors/trpc-not-found"; +import { NavigationLink } from "../groups/[id]/_navigation"; +import { canAccessUserEditPage } from "./access"; + +interface LayoutProps { + params: { userId: string }; +} + +export default async function Layout({ + children, + params, +}: PropsWithChildren) { + const session = await auth(); + const t = await getI18n(); + const tUser = await getScopedI18n("management.page.user"); + const user = await api.user + .getById({ userId: params.userId }) + .catch(catchTrpcNotFound); + + if (!canAccessUserEditPage(session, user.id)) { + notFound(); + } + + return ( + + + + + + + + {user.name} + {t("user.name")} + + + {session?.user.permissions.includes("admin") && ( + + )} + + + + + + } + /> + } + /> + + + + {children} + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/page.tsx index 2133828bc..9a5c5aade 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/page.tsx @@ -1,28 +1,19 @@ import { notFound } from "next/navigation"; -import { - Accordion, - AccordionControl, - AccordionItem, - AccordionPanel, - Avatar, - Group, - Stack, - Text, - Title, -} from "@mantine/core"; -import { - IconAlertTriangleFilled, - IconSettingsFilled, - IconShieldLockFilled, - IconUserFilled, -} from "@tabler/icons-react"; +import { Box, Group, Stack, Title } from "@mantine/core"; import { api } from "@homarr/api/server"; -import { getScopedI18n } from "@homarr/translation/server"; +import { auth } from "@homarr/auth/next"; +import { getI18n, getScopedI18n } from "@homarr/translation/server"; -import { DangerZoneAccordion } from "./_components/dangerZone.accordion"; -import { ProfileAccordion } from "./_components/profile.accordion"; -import { SecurityAccordionComponent } from "./_components/security.accordion"; +import { + DangerZoneItem, + DangerZoneRoot, +} from "~/components/manage/danger-zone"; +import { catchTrpcNotFound } from "~/errors/trpc-not-found"; +import { DeleteUserButton } from "./_delete-user-button"; +import { UserProfileAvatarForm } from "./_profile-avatar-form"; +import { UserProfileForm } from "./_profile-form"; +import { canAccessUserEditPage } from "./access"; interface Props { params: { @@ -31,9 +22,17 @@ interface Props { } export async function generateMetadata({ params }: Props) { - const user = await api.user.getById({ - userId: params.userId, - }); + const session = await auth(); + const user = await api.user + .getById({ + userId: params.userId, + }) + .catch(() => null); + + if (!user || !canAccessUserEditPage(session, user.id)) { + return {}; + } + const t = await getScopedI18n("management.page.user.edit"); const metaTitle = `${t("metaTitle", { username: user?.name })} • Homarr`; @@ -43,71 +42,38 @@ export async function generateMetadata({ params }: Props) { } export default async function EditUserPage({ params }: Props) { - const t = await getScopedI18n("management.page.user.edit"); - const user = await api.user.getById({ - userId: params.userId, - }); + const t = await getI18n(); + const tGeneral = await getScopedI18n("management.page.user.setting.general"); + const session = await auth(); + const user = await api.user + .getById({ + userId: params.userId, + }) + .catch(catchTrpcNotFound); - if (!user) { + if (!canAccessUserEditPage(session, user.id)) { notFound(); } return ( - - {user.name?.substring(0, 2)} - {user.name} + {tGeneral("title")} + + + + + + + - - - }> - - {t("section.profile.title")} - - - - - - - - }> - - {t("section.preferences.title")} - - - - - - }> - - {t("section.security.title")} - - - - - - - - }> - - {t("section.dangerZone.title")} - - - - - - - + + + } + /> + ); } diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/_change-password-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/_change-password-form.tsx new file mode 100644 index 000000000..ff031fd20 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/_change-password-form.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { Button, Fieldset, Group, PasswordInput, Stack } from "@mantine/core"; + +import type { RouterInputs, RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { useSession } from "@homarr/auth/client"; +import { useForm, zodResolver } from "@homarr/form"; +import { + showErrorNotification, + showSuccessNotification, +} from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; +import { validation } from "@homarr/validation"; + +import { revalidatePathAction } from "~/app/revalidatePathAction"; + +interface ChangePasswordFormProps { + user: RouterOutputs["user"]["getById"]; +} + +export const ChangePasswordForm = ({ user }: ChangePasswordFormProps) => { + const { data: session } = useSession(); + const t = useI18n(); + const { mutate, isPending } = clientApi.user.changePassword.useMutation({ + async onSettled() { + await revalidatePathAction(`/manage/users/${user.id}`); + }, + onSuccess() { + showSuccessNotification({ + message: t("user.action.changePassword.notification.success.message"), + }); + }, + onError() { + showErrorNotification({ + message: t("user.action.changePassword.notification.error.message"), + }); + }, + }); + const form = useForm({ + initialValues: { + previousPassword: "", + password: "", + confirmPassword: "", + }, + validate: zodResolver(validation.user.changePassword), + validateInputOnBlur: true, + validateInputOnChange: true, + }); + + const handleSubmit = (values: FormType) => { + mutate( + { + userId: user.id, + ...values, + }, + { + onSettled() { + form.reset(); + }, + }, + ); + }; + + return ( +
+ +
+ + {/* Require previous password if the current user want's to change his password */} + {session?.user.id === user.id && ( + + )} + + + + + + + + + +
+
+
+ ); +}; + +type FormType = Omit; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/page.tsx new file mode 100644 index 000000000..0ce00f304 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/page.tsx @@ -0,0 +1,40 @@ +import { notFound } from "next/navigation"; +import { Stack, Title } from "@mantine/core"; + +import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; +import { getScopedI18n } from "@homarr/translation/server"; + +import { catchTrpcNotFound } from "~/errors/trpc-not-found"; +import { canAccessUserEditPage } from "../access"; +import { ChangePasswordForm } from "./_change-password-form"; + +interface Props { + params: { + userId: string; + }; +} + +export default async function UserSecurityPage({ params }: Props) { + const session = await auth(); + const tSecurity = await getScopedI18n( + "management.page.user.setting.security", + ); + const user = await api.user + .getById({ + userId: params.userId, + }) + .catch(catchTrpcNotFound); + + if (!canAccessUserEditPage(session, user.id)) { + notFound(); + } + + return ( + + {tSecurity("title")} + + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/page.tsx index ee81954bf..2c8e1fa4d 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/page.tsx @@ -1,16 +1,12 @@ -import { - Card, - CardSection, - Divider, - Group, - Stack, - Text, - Title, -} from "@mantine/core"; +import { Stack, Title } from "@mantine/core"; import { api } from "@homarr/api/server"; import { getScopedI18n } from "@homarr/translation/server"; +import { + DangerZoneItem, + DangerZoneRoot, +} from "~/components/manage/danger-zone"; import { DeleteGroup } from "./_delete-group"; import { RenameGroupForm } from "./_rename-group-form"; import { TransferGroupOwnership } from "./_transfer-group-ownership"; @@ -34,42 +30,19 @@ export default async function GroupsDetailPage({ - - - {tGeneral("dangerZone")} - - - - - - - {tGroupAction("transfer.label")} - - {tGroupAction("transfer.description")} - - - - - + + } + /> - - - - - - - - {tGroupAction("delete.label")} - - {tGroupAction("delete.description")} - - - - - - - - + } + /> + ); } diff --git a/apps/nextjs/src/components/board/sections/content.tsx b/apps/nextjs/src/components/board/sections/content.tsx index d034e109c..ea0c7da92 100644 --- a/apps/nextjs/src/components/board/sections/content.tsx +++ b/apps/nextjs/src/components/board/sections/content.tsx @@ -16,7 +16,7 @@ import { useAtomValue } from "jotai"; import { clientApi } from "@homarr/api/client"; import { useConfirmModal, useModalAction } from "@homarr/modals"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, @@ -115,7 +115,8 @@ const BoardItem = ({ item, ...dimensions }: ItemProps) => { }; const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => { - const t = useScopedI18n("item"); + const tItem = useScopedI18n("item"); + const t = useI18n(); const { openModal } = useModalAction(WidgetEditModal); const { openConfirmModal } = useConfirmModal(); const isEditMode = useAtomValue(editModeAtom); @@ -160,8 +161,8 @@ const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => { const openRemoveModal = () => { openConfirmModal({ - title: t("remove.title"), - children: t("remove.message"), + title: tItem("remove.title"), + children: tItem("remove.message"), onConfirm: () => { removeItem({ itemId: item.id }); }, @@ -182,24 +183,24 @@ const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => { - {t("menu.label.settings")} + {tItem("menu.label.settings")} } onClick={openEditModal} > - {t("action.edit")} + {tItem("action.edit")} }> - {t("action.move")} + {tItem("action.move")} - {t("menu.label.dangerZone")} + {t("common.dangerZone")} } onClick={openRemoveModal} > - {t("action.remove")} + {tItem("action.remove")} diff --git a/apps/nextjs/src/components/manage/danger-zone.tsx b/apps/nextjs/src/components/manage/danger-zone.tsx new file mode 100644 index 000000000..7359ca62e --- /dev/null +++ b/apps/nextjs/src/components/manage/danger-zone.tsx @@ -0,0 +1,70 @@ +import { Fragment } from "react"; +import { + Card, + CardSection, + Divider, + Group, + Stack, + Text, + Title, +} from "@mantine/core"; + +import { getI18n } from "@homarr/translation/server"; + +interface DangerZoneRootProps { + children: React.ReactNode[] | React.ReactNode; +} + +export const DangerZoneRoot = async ({ children }: DangerZoneRootProps) => { + const t = await getI18n(); + + return ( + + + {t("common.dangerZone")} + + + + {Array.isArray(children) + ? children.map((child, index) => ( + + {child} + {index + 1 !== children.length && ( + + + + )} + + )) + : children} + + + + ); +}; + +interface DangerZoneItemProps { + label: string; + description: string; + action: React.ReactNode; +} + +export const DangerZoneItem = ({ + label, + description, + action, +}: DangerZoneItemProps) => { + return ( + + + + {label} + + {description} + + + {action} + + + ); +}; diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx index a8bb39d27..2ffa79c4e 100644 --- a/apps/nextjs/src/components/user-avatar-menu.tsx +++ b/apps/nextjs/src/components/user-avatar-menu.tsx @@ -18,6 +18,7 @@ import { IconLogin, IconLogout, IconMoon, + IconSettings, IconSun, IconTool, } from "@tabler/icons-react"; @@ -71,6 +72,15 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => { > {t("navigateDefaultBoard")}
+ {Boolean(session.data) && ( + } + > + {t("preferences")} + + )} void }>( useEffect(() => { start(); - }, []); + }, [start]); return (
diff --git a/apps/nextjs/src/errors/trpc-not-found.ts b/apps/nextjs/src/errors/trpc-not-found.ts new file mode 100644 index 000000000..3ae693203 --- /dev/null +++ b/apps/nextjs/src/errors/trpc-not-found.ts @@ -0,0 +1,12 @@ +import "server-only"; + +import { notFound } from "next/navigation"; +import { TRPCError } from "@trpc/server"; + +export const catchTrpcNotFound = (err: unknown) => { + if (err instanceof TRPCError && err.code === "NOT_FOUND") { + notFound(); + } + + throw err; +}; diff --git a/packages/api/src/router/test/user.spec.ts b/packages/api/src/router/test/user.spec.ts index b41faf9c6..9e1c68406 100644 --- a/packages/api/src/router/test/user.spec.ts +++ b/packages/api/src/router/test/user.spec.ts @@ -212,11 +212,9 @@ describe("editProfile shoud update user", () => { // act await caller.editProfile({ - userId: id, - form: { - name: "ABC", - email: "", - }, + id: id, + name: "ABC", + email: "", }); // assert @@ -256,11 +254,9 @@ describe("editProfile shoud update user", () => { // act await caller.editProfile({ - userId: id, - form: { - name: "ABC", - email: "myNewEmail@gmail.com", - }, + id, + name: "ABC", + email: "myNewEmail@gmail.com", }); // assert diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 06b538a90..02bffd3f3 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -8,7 +8,7 @@ import { invites, users } from "@homarr/db/schema/sqlite"; import { exampleChannel } from "@homarr/redis"; import { validation, z } from "@homarr/validation"; -import { createTRPCRouter, publicProcedure } from "../trpc"; +import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const userRouter = createTRPCRouter({ initUser: publicProcedure @@ -60,6 +60,52 @@ export const userRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { await createUser(ctx.db, input); }), + setProfileImage: protectedProcedure + .input( + z.object({ + userId: z.string(), + // Max image size of 256KB, only png and jpeg are allowed + image: z + .string() + .regex(/^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9/+]+=*$/g) + .max(262144) + .nullable(), + }), + ) + .mutation(async ({ input, ctx }) => { + // Only admins can change other users profile images + if ( + ctx.session.user.id !== input.userId && + !ctx.session.user.permissions.includes("admin") + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You are not allowed to change other users profile images", + }); + } + + const user = await ctx.db.query.users.findFirst({ + columns: { + id: true, + image: true, + }, + where: eq(users.id, input.userId), + }); + + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + await ctx.db + .update(users) + .set({ + image: input.image, + }) + .where(eq(users.id, input.userId)); + }), getAll: publicProcedure.query(async ({ ctx }) => { return ctx.db.query.users.findMany({ columns: { @@ -83,7 +129,7 @@ export const userRouter = createTRPCRouter({ getById: publicProcedure .input(z.object({ userId: z.string() })) .query(async ({ input, ctx }) => { - return ctx.db.query.users.findFirst({ + const user = await ctx.db.query.users.findFirst({ columns: { id: true, name: true, @@ -93,38 +139,90 @@ export const userRouter = createTRPCRouter({ }, where: eq(users.id, input.userId), }); + + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + return user; }), editProfile: publicProcedure - .input( - z.object({ - form: validation.user.editProfile, - userId: z.string(), - }), - ) + .input(validation.user.editProfile) .mutation(async ({ input, ctx }) => { - const user = await ctx.db - .select() - .from(users) - .where(eq(users.id, input.userId)) - .limit(1); + const user = await ctx.db.query.users.findFirst({ + columns: { email: true }, + where: eq(users.id, input.id), + }); - const emailDirty = - input.form.email && user[0]?.email !== input.form.email; + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + const emailDirty = input.email && user.email !== input.email; await ctx.db .update(users) .set({ - name: input.form.name, - email: emailDirty === true ? input.form.email : undefined, + name: input.name, + email: emailDirty === true ? input.email : undefined, emailVerified: emailDirty === true ? null : undefined, }) - .where(eq(users.id, input.userId)); + .where(eq(users.id, input.id)); }), delete: publicProcedure.input(z.string()).mutation(async ({ input, ctx }) => { await ctx.db.delete(users).where(eq(users.id, input)); }), - changePassword: publicProcedure - .input(validation.user.changePassword) + changePassword: protectedProcedure + .input(validation.user.changePasswordApi) .mutation(async ({ ctx, input }) => { + const user = ctx.session.user; + // Only admins can change other users' passwords + if (!user.permissions.includes("admin") && user.id !== input.userId) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + // Admins can change the password of other users without providing the previous password + const isPreviousPasswordRequired = ctx.session.user.id === input.userId; + + if (isPreviousPasswordRequired) { + const dbUser = await ctx.db.query.users.findFirst({ + columns: { + id: true, + password: true, + salt: true, + }, + where: eq(users.id, input.userId), + }); + + if (!dbUser) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + const previousPasswordHash = await hashPassword( + input.previousPassword, + dbUser.salt ?? "", + ); + const isValid = previousPasswordHash === dbUser.password; + + if (!isValid) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Invalid password", + }); + } + } + const salt = await createSalt(); const hashedPassword = await hashPassword(input.password, salt); await ctx.db diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index 5f7cb09e4..5f13b297a 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -2,6 +2,8 @@ import "dayjs/locale/en"; export default { user: { + title: "Users", + name: "User", page: { login: { title: "Log in to your account", @@ -30,6 +32,9 @@ export default { passwordConfirm: { label: "Confirm password", }, + previousPassword: { + label: "Previous password", + }, }, action: { login: { @@ -59,6 +64,63 @@ export default { }, }, create: "Create user", + changePassword: { + label: "Change password", + notification: { + success: { + message: "Password changed successfully", + }, + error: { + message: "Unable to change password", + }, + }, + }, + manageAvatar: { + changeImage: { + label: "Change image", + notification: { + success: { + message: "The image changed successfully", + }, + error: { + message: "Unable to change image", + }, + toLarge: { + title: "Image is too large", + message: "Max image size is {size}", + }, + }, + }, + removeImage: { + label: "Remove image", + confirm: "Are you sure you want to remove the image?", + notification: { + success: { + message: "Image removed successfully", + }, + error: { + message: "Unable to remove image", + }, + }, + }, + }, + editProfile: { + notification: { + success: { + message: "Profile updated successfully", + }, + error: { + message: "Unable to update profile", + }, + }, + }, + delete: { + label: "Delete user permanently", + description: + "Deletes this user including their preferences. Will not delete any boards. User will not be notified.", + confirm: + "Are you sure, that you want to delete the user {username} with his preferences?", + }, select: { label: "Select user", notFound: "No user found", @@ -139,10 +201,10 @@ export default { label: "New group", notification: { success: { - message: "The app was successfully created", + message: "The group was successfully created", }, error: { - message: "The app could not be created", + message: "The group could not be created", }, }, }, @@ -383,6 +445,7 @@ export default { save: "Save", saveChanges: "Save changes", cancel: "Cancel", + delete: "Delete", discard: "Discard", confirm: "Confirm", continue: "Continue", @@ -436,19 +499,14 @@ export default { switchToDarkMode: "Switch to dark mode", switchToLightMode: "Switch to light mode", management: "Management", + preferences: "Your preferences", logout: "Logout", login: "Login", navigateDefaultBoard: "Navigate to default board", loggedOut: "Logged out", }, }, - menu: { - section: { - dangerZone: { - title: "Danger Zone", - }, - }, - }, + dangerZone: "Danger zone", noResults: "No results found", preview: { show: "Show preview", @@ -502,7 +560,6 @@ export default { menu: { label: { settings: "Settings", - dangerZone: "Danger Zone", }, }, create: { @@ -947,7 +1004,7 @@ export default { }, }, dangerZone: { - title: "Danger Zone", + title: "Danger zone", action: { rename: { label: "Rename board", @@ -1077,40 +1134,21 @@ export default { }, }, user: { + back: "Back to users", + setting: { + general: { + title: "General", + }, + security: { + title: "Security", + }, + }, list: { metaTitle: "Manage users", title: "Users", }, edit: { metaTitle: "Edit user {username}", - section: { - profile: { - title: "Profile", - }, - preferences: { - title: "Preferences", - }, - security: { - title: "Security", - changePassword: { - title: "Change password", - message: { - passwordUpdated: "Updated password", - }, - }, - }, - dangerZone: { - title: "Danger zone", - action: { - delete: { - label: "Delete user permanently", - description: - "Deletes this user including their preferences. Will not delete any boards. User will not be notified.", - button: "Delete", - }, - }, - }, - }, }, create: { metaTitle: "Create user", @@ -1180,7 +1218,6 @@ export default { setting: { general: { title: "General", - dangerZone: "Danger zone", }, members: { title: "Members", diff --git a/packages/ui/src/components/user-avatar.tsx b/packages/ui/src/components/user-avatar.tsx index 873528f8d..41369225c 100644 --- a/packages/ui/src/components/user-avatar.tsx +++ b/packages/ui/src/components/user-avatar.tsx @@ -1,5 +1,5 @@ +import type { AvatarProps } from "@mantine/core"; import { Avatar } from "@mantine/core"; -import type { AvatarProps, MantineSize } from "@mantine/core"; export interface UserProps { name: string | null; @@ -8,7 +8,7 @@ export interface UserProps { interface UserAvatarProps { user: UserProps | null; - size: MantineSize; + size: AvatarProps["size"]; } export const UserAvatar = ({ user, size }: UserAvatarProps) => { diff --git a/packages/validation/src/user.ts b/packages/validation/src/user.ts index fbce8b80e..02bf45acc 100644 --- a/packages/validation/src/user.ts +++ b/packages/validation/src/user.ts @@ -41,6 +41,7 @@ const registrationSchemaApi = registrationSchema.and( ); const editProfileSchema = z.object({ + id: z.string(), name: usernameSchema, email: z .string() @@ -51,10 +52,20 @@ const editProfileSchema = z.object({ .nullable(), }); -const changePasswordSchema = z.object({ - userId: z.string(), - password: passwordSchema, -}); +const changePasswordSchema = z + .object({ + previousPassword: z.string(), + password: passwordSchema, + confirmPassword: z.string(), + }) + .refine((data) => data.password === data.confirmPassword, { + path: ["confirmPassword"], + message: "Passwords do not match", + }); + +const changePasswordApiSchema = changePasswordSchema.and( + z.object({ userId: z.string() }), +); export const userSchemas = { signIn: signInSchema, @@ -65,4 +76,5 @@ export const userSchemas = { password: passwordSchema, editProfile: editProfileSchema, changePassword: changePasswordSchema, + changePasswordApi: changePasswordApiSchema, };