feat: user preferences (#470)

* wip: improve user preferences

* wip: fix translations and add user danger zone

* feat: add user delete button to danger zone

* fix: test not working

* refactor: add access checks for user edit page, improve not found behaviour, change user preference link in avatar menu to correct link

* fix: remove invalid bg for container

* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2024-05-12 16:27:56 +02:00
committed by GitHub
parent f0da1d81a6
commit db01301845
24 changed files with 961 additions and 414 deletions

View File

@@ -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 (
<Center h="100%">
<Stack align="center">
<Title order={1} tt="uppercase">
{t("title")}
</Title>
<Text>{t("text")}</Text>
</Stack>
</Center>
);
export default function NotFound() {
return notFound();
}

View File

@@ -74,9 +74,7 @@ export const BoardCardMenuDropdown = ({
{hasFullAccess && (
<>
<Menu.Divider />
<Menu.Label c="red.7">
{tCommon("menu.section.dangerZone.title")}
</Menu.Label>
<Menu.Label c="red.7">{tCommon("dangerZone")}</Menu.Label>
<Menu.Item
c="red.7"
leftSection={<IconTrash {...iconProps} />}

View File

@@ -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 (
<Center h="100%">
<Stack align="center">
<Title order={1} tt="uppercase">
{t("title")}
</Title>
<Text>{t("text")}</Text>
</Stack>
</Center>
);
}

View File

@@ -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<RouterOutputs["user"]["getById"]>;
}
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 (
<Stack>
<Divider />
<Group justify="space-between" px="md">
<Stack gap={0}>
<Text fw="bold" size="sm">
{t("action.delete.label")}
</Text>
<Text size="sm">{t("action.delete.description")}</Text>
</Stack>
<Button onClick={handleDelete} variant="subtle" color="red">
{t("action.delete.button")}
</Button>
</Group>
</Stack>
);
};

View File

@@ -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<RouterOutputs["user"]["getById"]>;
}
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 (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label={t("user.field.username.label")}
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
label={t("user.field.email.label")}
{...form.getInputProps("email")}
/>
<Button type="submit" disabled={!form.isValid()} loading={isPending}>
{t("common.action.save")}
</Button>
</Stack>
</form>
);
};

View File

@@ -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<RouterOutputs["user"]["getById"]>;
}
export const SecurityAccordionComponent = ({
user,
}: SecurityAccordionComponentProps) => {
return (
<Stack>
<ChangePasswordForm user={user} />
</Stack>
);
};
const ChangePasswordForm = ({
user,
}: {
user: NonNullable<RouterOutputs["user"]["getById"]>;
}) => {
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 (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<Stack gap={0}>
<Title order={5}>
{t(
"management.page.user.edit.section.security.changePassword.title",
)}
</Title>
<PasswordInput
label={t("user.field.password.label")}
{...form.getInputProps("password")}
/>
</Stack>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{t("common.action.confirm")}
</Button>
</Stack>
</form>
);
};

View File

@@ -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 (
<Button onClick={handleDelete} variant="subtle" color="red">
{t("common.action.delete")}
</Button>
);
};

View File

@@ -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 (
<Box pos="relative">
<Menu
opened={opened}
keepMounted
onChange={toggle}
position="bottom-start"
withArrow
>
<Menu.Target>
<UnstyledButton onClick={toggle}>
<UserAvatar user={user} size={200} />
<Button
component="div"
pos="absolute"
bottom={0}
left={0}
size="compact-md"
fw="normal"
variant="default"
leftSection={<IconPencil size={18} stroke={1.5} />}
>
{t("common.action.edit")}
</Button>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<FileButton
onChange={handleAvatarChange}
accept="image/png,image/jpeg,image/webp,image/gif"
>
{(props) => (
<Menu.Item
{...props}
leftSection={<IconPhotoEdit size={16} stroke={1.5} />}
>
{tManageAvatar("changeImage.label")}
</Menu.Item>
)}
</FileButton>
{user.image && (
<Menu.Item
onClick={handleRemoveAvatar}
leftSection={<IconPhotoX size={16} stroke={1.5} />}
>
{tManageAvatar("removeImage.label")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Box>
);
};
const fileToBase64Async = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result?.toString() || "");
reader.onerror = reject;
});

View File

@@ -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 (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label={t("user.field.username.label")}
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
label={t("user.field.email.label")}
{...form.getInputProps("email")}
/>
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = Omit<RouterInputs["user"]["editProfile"], "id">;

View File

@@ -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;
};

View File

@@ -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<LayoutProps>) {
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 (
<Container size="xl">
<Grid>
<GridCol span={12}>
<Group justify="space-between" align="center">
<Group>
<UserAvatar user={user} size="lg" />
<Stack gap={0}>
<Title order={3}>{user.name}</Title>
<Text c="gray.5">{t("user.name")}</Text>
</Stack>
</Group>
{session?.user.permissions.includes("admin") && (
<Button
component={Link}
href="/manage/users"
color="gray"
variant="light"
>
{tUser("back")}
</Button>
)}
</Group>
</GridCol>
<GridCol span={{ xs: 12, md: 4, lg: 3, xl: 2 }}>
<Stack>
<Stack gap={0}>
<NavigationLink
href={`/manage/users/${params.userId}`}
label={tUser("setting.general.title")}
icon={<IconSettings size="1rem" stroke={1.5} />}
/>
<NavigationLink
href={`/manage/users/${params.userId}/security`}
label={tUser("setting.security.title")}
icon={<IconShieldLock size="1rem" stroke={1.5} />}
/>
</Stack>
</Stack>
</GridCol>
<GridCol span={{ xs: 12, md: 8, lg: 9, xl: 10 }}>{children}</GridCol>
</Grid>
</Container>
);
}

View File

@@ -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 (
<Stack>
<Group mb="md">
<Avatar>{user.name?.substring(0, 2)}</Avatar>
<Title>{user.name}</Title>
<Title>{tGeneral("title")}</Title>
<Group gap="xl">
<Box flex={1}>
<UserProfileForm user={user} />
</Box>
<Box w={{ base: "100%", lg: 200 }}>
<UserProfileAvatarForm user={user} />
</Box>
</Group>
<Accordion variant="separated" defaultValue="general">
<AccordionItem value="general">
<AccordionControl icon={<IconUserFilled />}>
<Text fw="bold" size="lg">
{t("section.profile.title")}
</Text>
</AccordionControl>
<AccordionPanel>
<ProfileAccordion user={user} />
</AccordionPanel>
</AccordionItem>
<AccordionItem value="preferences">
<AccordionControl icon={<IconSettingsFilled />}>
<Text fw="bold" size="lg">
{t("section.preferences.title")}
</Text>
</AccordionControl>
<AccordionPanel></AccordionPanel>
</AccordionItem>
<AccordionItem value="security">
<AccordionControl icon={<IconShieldLockFilled />}>
<Text fw="bold" size="lg">
{t("section.security.title")}
</Text>
</AccordionControl>
<AccordionPanel>
<SecurityAccordionComponent user={user} />
</AccordionPanel>
</AccordionItem>
<AccordionItem
styles={{
item: {
borderColor: "rgba(248,81,73,0.4)",
borderWidth: 4,
},
}}
value="dangerZone"
>
<AccordionControl icon={<IconAlertTriangleFilled />}>
<Text fw="bold" size="lg">
{t("section.dangerZone.title")}
</Text>
</AccordionControl>
<AccordionPanel
styles={{ content: { paddingRight: 0, paddingLeft: 0 } }}
>
<DangerZoneAccordion user={user} />
</AccordionPanel>
</AccordionItem>
</Accordion>
<DangerZoneRoot>
<DangerZoneItem
label={t("user.action.delete.label")}
description={t("user.action.delete.description")}
action={<DeleteUserButton user={user} />}
/>
</DangerZoneRoot>
</Stack>
);
}

View File

@@ -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<FormType>({
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 (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<Fieldset legend={t("user.action.changePassword.label")}>
<Stack gap="xs">
{/* Require previous password if the current user want's to change his password */}
{session?.user.id === user.id && (
<PasswordInput
withAsterisk
label={t("user.field.previousPassword.label")}
{...form.getInputProps("previousPassword")}
/>
)}
<PasswordInput
withAsterisk
label={t("user.field.password.label")}
{...form.getInputProps("password")}
/>
<PasswordInput
withAsterisk
label={t("user.field.passwordConfirm.label")}
{...form.getInputProps("confirmPassword")}
/>
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
{t("common.action.confirm")}
</Button>
</Group>
</Stack>
</Fieldset>
</Stack>
</form>
);
};
type FormType = Omit<RouterInputs["user"]["changePassword"], "userId">;

View File

@@ -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 (
<Stack>
<Title>{tSecurity("title")}</Title>
<ChangePasswordForm user={user} />
</Stack>
);
}

View File

@@ -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({
<RenameGroupForm group={group} />
<Stack gap="sm">
<Title c="red.8" order={2}>
{tGeneral("dangerZone")}
</Title>
<Card withBorder style={{ borderColor: "var(--mantine-color-red-8)" }}>
<Stack gap="sm">
<Group justify="space-between" px="md">
<Stack gap={0}>
<Text fw="bold" size="sm">
{tGroupAction("transfer.label")}
</Text>
<Text size="sm">{tGroupAction("transfer.description")}</Text>
</Stack>
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<TransferGroupOwnership group={group} />
</Group>
</Group>
<DangerZoneRoot>
<DangerZoneItem
label={tGroupAction("transfer.label")}
description={tGroupAction("transfer.description")}
action={<TransferGroupOwnership group={group} />}
/>
<CardSection>
<Divider />
</CardSection>
<Group justify="space-between" px="md">
<Stack gap={0}>
<Text fw="bold" size="sm">
{tGroupAction("delete.label")}
</Text>
<Text size="sm">{tGroupAction("delete.description")}</Text>
</Stack>
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<DeleteGroup group={group} />
</Group>
</Group>
</Stack>
</Card>
</Stack>
<DangerZoneItem
label={tGroupAction("delete.label")}
description={tGroupAction("delete.description")}
action={<DeleteGroup group={group} />}
/>
</DangerZoneRoot>
</Stack>
);
}

View File

@@ -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 }) => {
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={128}>
<Menu.Label>{t("menu.label.settings")}</Menu.Label>
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
<Menu.Item
leftSection={<IconPencil size={16} />}
onClick={openEditModal}
>
{t("action.edit")}
{tItem("action.edit")}
</Menu.Item>
<Menu.Item leftSection={<IconLayoutKanban size={16} />}>
{t("action.move")}
{tItem("action.move")}
</Menu.Item>
<Menu.Divider />
<Menu.Label c="red.6">{t("menu.label.dangerZone")}</Menu.Label>
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
<Menu.Item
c="red.6"
leftSection={<IconTrash size={16} />}
onClick={openRemoveModal}
>
{t("action.remove")}
{tItem("action.remove")}
</Menu.Item>
</Menu.Dropdown>
</Menu>

View File

@@ -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 (
<Stack gap="sm">
<Title c="red.8" order={2}>
{t("common.dangerZone")}
</Title>
<Card withBorder style={{ borderColor: "var(--mantine-color-red-8)" }}>
<Stack gap="sm">
{Array.isArray(children)
? children.map((child, index) => (
<Fragment key={index}>
{child}
{index + 1 !== children.length && (
<CardSection>
<Divider />
</CardSection>
)}
</Fragment>
))
: children}
</Stack>
</Card>
</Stack>
);
};
interface DangerZoneItemProps {
label: string;
description: string;
action: React.ReactNode;
}
export const DangerZoneItem = ({
label,
description,
action,
}: DangerZoneItemProps) => {
return (
<Group justify="space-between" px="md">
<Stack gap={0}>
<Text fw="bold" size="sm">
{label}
</Text>
<Text size="sm">{description}</Text>
</Stack>
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
{action}
</Group>
</Group>
);
};

View File

@@ -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")}
</Menu.Item>
{Boolean(session.data) && (
<Menu.Item
component={Link}
href={`/manage/users/${session.data?.user.id}`}
leftSection={<IconSettings size="1rem" />}
>
{t("preferences")}
</Menu.Item>
)}
<Menu.Item
component={Link}
href="/manage"
@@ -111,7 +121,7 @@ const LogoutModal = createModal<{ onTimeout: () => void }>(
useEffect(() => {
start();
}, []);
}, [start]);
return (
<Center h={200 - 2 * 16}>

View File

@@ -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;
};

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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) => {

View File

@@ -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,
};