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 (
-
- );
-};
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 (
+
+
+
+ );
+};
+
+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 (
+
+ );
+};
+
+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,
};