feat: add user invite management (#338)

* feat: add invite management page

* refactor: improve existing translations

* test: add test for invite router

* feat: update mysql schema to match sqlite schema

* fix: format issues

* fix: deepsource issues

* fix: lint issues

* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2024-04-29 12:09:34 +02:00
committed by GitHub
parent 16e42d654d
commit 621f6c81ae
20 changed files with 1506 additions and 59 deletions

View File

@@ -10,10 +10,8 @@ import {
import { useForm } from "@homarr/form";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import {
SelectItemWithDescriptionBadge,
SelectWithDescriptionBadge,
} from "@homarr/ui";
import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
import { SelectWithDescriptionBadge } from "@homarr/ui";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";

View File

@@ -5,7 +5,7 @@ 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 { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { revalidatePathAction } from "~/app/revalidatePathAction";
@@ -15,7 +15,7 @@ interface ProfileAccordionProps {
}
export const ProfileAccordion = ({ user }: ProfileAccordionProps) => {
const t = useScopedI18n("management.page.user.edit.section.profile");
const t = useI18n();
const { mutate, isPending } = clientApi.user.editProfile.useMutation({
onSettled: async () => {
await revalidatePathAction("/manage/users");
@@ -42,16 +42,16 @@ export const ProfileAccordion = ({ user }: ProfileAccordionProps) => {
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label={t("form.username.label")}
label={t("user.field.username.label")}
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
label={t("form.email.label")}
label={t("user.field.email.label")}
{...form.getInputProps("email")}
/>
<Button type="submit" disabled={!form.isValid()} loading={isPending}>
Submit
{t("common.action.save")}
</Button>
</Stack>
</form>

View File

@@ -66,9 +66,7 @@ const ChangePasswordForm = ({
)}
</Title>
<PasswordInput
label={t(
"management.page.user.edit.section.security.changePassword.form.password.label",
)}
label={t("user.field.password.label")}
{...form.getInputProps("password")}
/>
</Stack>

View File

@@ -9,7 +9,7 @@ import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
interface UserListComponentProps {
initialUserList: RouterOutputs["user"]["getAll"];
@@ -18,7 +18,8 @@ interface UserListComponentProps {
export const UserListComponent = ({
initialUserList,
}: UserListComponentProps) => {
const t = useScopedI18n("management.page.user.list");
const tUserList = useScopedI18n("management.page.user.list");
const t = useI18n();
const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, {
initialData: initialUserList,
});
@@ -29,7 +30,7 @@ export const UserListComponent = ({
() => [
{
accessorKey: "name",
header: "Name",
header: t("user.field.username.label"),
grow: 100,
Cell: ({ renderedCellValue, row }) => (
<Link href={`/manage/users/${row.original.id}`}>
@@ -42,7 +43,7 @@ export const UserListComponent = ({
},
{
accessorKey: "email",
header: "Email",
header: t("user.field.email.label"),
Cell: ({ renderedCellValue, row }) => (
<Group>
{row.original.email ? renderedCellValue : <Text>-</Text>}
@@ -55,7 +56,7 @@ export const UserListComponent = ({
),
},
],
[],
[t],
);
const table = useMantineReactTable({
@@ -75,13 +76,13 @@ export const UserListComponent = ({
</Button>
),
state: {
isLoading: isLoading,
isLoading,
},
});
return (
<>
<Title mb="md">{t("title")}</Title>
<Title mb="md">{tUserList("title")}</Title>
<MantineReactTable table={table} />
</>
);

View File

@@ -22,6 +22,7 @@ import { StepperNavigationComponent } from "./stepper-navigation.component";
export const UserCreateStepperComponent = () => {
const t = useScopedI18n("management.page.user.create");
const tUserField = useScopedI18n("user.field");
const stepperMax = 4;
const [active, setActive] = useState(0);
@@ -122,14 +123,14 @@ export const UserCreateStepperComponent = () => {
<Card p="xl">
<Stack gap="md">
<TextInput
label={t("step.personalInformation.field.username.label")}
label={tUserField("username.label")}
variant="filled"
withAsterisk
{...generalForm.getInputProps("username")}
/>
<TextInput
label={t("step.personalInformation.field.email.label")}
label={tUserField("email.label")}
variant="filled"
{...generalForm.getInputProps("email")}
/>
@@ -146,13 +147,13 @@ export const UserCreateStepperComponent = () => {
<Card p="xl">
<Stack gap="md">
<PasswordInput
label={t("step.security.field.password.label")}
label={tUserField("password.label")}
variant="filled"
withAsterisk
{...securityForm.getInputProps("password")}
/>
<PasswordInput
label={t("step.security.field.confirmPassword.label")}
label={tUserField("passwordConfirm.label")}
variant="filled"
withAsterisk
{...securityForm.getInputProps("confirmPassword")}

View File

@@ -56,14 +56,14 @@ export const StepperNavigationComponent = ({
leftSection={<IconRotate size="1rem" />}
onClick={reset}
>
{t("management.page.user.create.buttons.createAnother")}
{t("management.page.user.create.action.createAnother")}
</Button>
<Button
leftSection={<IconArrowBackUp size="1rem" />}
component={Link}
href="/manage/users"
>
{t("management.page.user.create.buttons.return")}
{t("management.page.user.create.action.back")}
</Button>
</Group>
)}

View File

@@ -0,0 +1,63 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Button, CopyButton, Mark, Stack, Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { createModal } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
export const InviteCopyModal = createModal<
RouterOutputs["invite"]["createInvite"]
>(({ actions, innerProps }) => {
const t = useScopedI18n("management.page.user.invite");
const inviteUrl = useInviteUrl(innerProps);
return (
<Stack>
<Text>{t("action.copy.description")}</Text>
{/* TODO: When next-international v2 is released the descriptions bold element can be implemented, see https://github.com/QuiiBz/next-international/pull/361 for progress */}
<Link href={createPath(innerProps)}>{t("action.copy.link")}</Link>
<Stack gap="xs">
<Text fw="bold">{t("field.id.label")}:</Text>
<Mark style={{ borderRadius: 4 }} color="gray" px={5}>
{innerProps.id}
</Mark>
<Text fw="bold">{t("field.token.label")}:</Text>
<Mark style={{ borderRadius: 4 }} color="gray" px={5}>
{innerProps.token}
</Mark>
</Stack>
<CopyButton value={inviteUrl}>
{({ copy }) => (
<Button
onClick={() => {
copy();
actions.closeModal();
}}
variant="default"
fullWidth
>
{t("action.copy.button")}
</Button>
)}
</CopyButton>
</Stack>
);
}).withOptions({
defaultTitle(t) {
return t("management.page.user.invite.action.copy.title");
},
});
const createPath = ({ id, token }: RouterOutputs["invite"]["createInvite"]) =>
`/auth/invite/${id}?token=${token}`;
const useInviteUrl = ({
id,
token,
}: RouterOutputs["invite"]["createInvite"]) => {
const pathname = usePathname();
return window.location.href.replace(pathname, createPath({ id, token }));
};

View File

@@ -0,0 +1,77 @@
import React from "react";
import { Button, Group, Stack, Text } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { createModal, useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { InviteCopyModal } from "./invite-copy-modal";
dayjs.extend(relativeTime);
interface FormType {
expirationDate: Date;
}
export const InviteCreateModal = createModal<void>(({ actions }) => {
const tInvite = useScopedI18n("management.page.user.invite");
const t = useI18n();
const { openModal } = useModalAction(InviteCopyModal);
const utils = clientApi.useUtils();
const { mutate, isPending } = clientApi.invite.createInvite.useMutation();
const minDate = dayjs().add(1, "hour").toDate();
const maxDate = dayjs().add(6, "months").toDate();
const form = useForm<FormType>({
initialValues: {
expirationDate: dayjs().add(4, "hours").toDate(),
},
});
const handleSubmit = (values: FormType) => {
mutate(values, {
onSuccess: (result) => {
void utils.invite.getAll.invalidate();
actions.closeModal();
openModal(result);
},
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<Text>{tInvite("action.new.description")}</Text>
<DateTimePicker
popoverProps={{ withinPortal: true }}
minDate={minDate}
maxDate={maxDate}
withAsterisk
valueFormat="DD MMM YYYY HH:mm"
label={tInvite("field.expirationDate.label")}
variant="filled"
{...form.getInputProps("expirationDate")}
/>
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={isPending} color="teal">
{t("common.action.create")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle(t) {
return t("management.page.user.invite.action.new.title");
},
});

View File

@@ -0,0 +1,133 @@
"use client";
import { useCallback, useMemo } from "react";
import { ActionIcon, Button, Title } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table";
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { InviteCreateModal } from "./invite-create-modal";
dayjs.extend(relativeTime);
interface InviteListComponentProps {
initialInvites: RouterOutputs["invite"]["getAll"];
}
export const InviteListComponent = ({
initialInvites,
}: InviteListComponentProps) => {
const t = useScopedI18n("management.page.user.invite");
const { data, isLoading } = clientApi.invite.getAll.useQuery(undefined, {
initialData: initialInvites,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const columns = useMemo<
MRT_ColumnDef<RouterOutputs["invite"]["getAll"][number]>[]
>(
() => [
{
accessorKey: "id",
header: t("field.id.label"),
grow: 100,
Cell: ({ renderedCellValue }) => renderedCellValue,
},
{
accessorKey: "creator",
header: t("field.creator.label"),
Cell: ({ row }) => row.original.creator.name,
},
{
accessorKey: "expirationDate",
header: t("field.expirationDate.label"),
Cell: ({ row }) => dayjs(row.original.expirationDate).fromNow(false),
},
],
[t],
);
const table = useMantineReactTable({
columns,
data,
positionActionsColumn: "last",
renderRowActions: RenderRowActions,
enableRowSelection: true,
enableColumnOrdering: true,
enableGlobalFilter: false,
enableRowActions: true,
enableDensityToggle: false,
enableFullScreenToggle: false,
layoutMode: "grid-no-grow",
getRowId: (row) => row.id,
renderTopToolbarCustomActions: RenderTopToolbarCustomActions,
state: {
isLoading,
},
initialState: {
sorting: [{ id: "expirationDate", desc: false }],
},
});
return (
<>
<Title mb="md">{t("title")}</Title>
<MantineReactTable table={table} />
</>
);
};
const RenderTopToolbarCustomActions = () => {
const t = useScopedI18n("management.page.user.invite");
const { openModal } = useModalAction(InviteCreateModal);
const handleNewInvite = useCallback(() => {
openModal();
}, [openModal]);
return (
<Button color="teal" onClick={handleNewInvite}>
{t("action.new.title")}
</Button>
);
};
const RenderRowActions = ({
row,
}: {
row: MRT_Row<RouterOutputs["invite"]["getAll"][number]>;
}) => {
const t = useScopedI18n("management.page.user.invite");
const { mutate, isPending } = clientApi.invite.deleteInvite.useMutation();
const utils = clientApi.useUtils();
const { openConfirmModal } = useConfirmModal();
const handleDelete = useCallback(() => {
openConfirmModal({
title: t("action.delete.title"),
children: t("action.delete.description"),
onConfirm: () => {
mutate({ id: row.original.id });
void utils.invite.getAll.invalidate();
},
});
}, [openConfirmModal, row.original.id, mutate, utils, t]);
return (
<ActionIcon
variant="subtle"
color="red"
onClick={handleDelete}
loading={isPending}
>
<IconTrash color="red" size={20} stroke={1.5} />
</ActionIcon>
);
};

View File

@@ -0,0 +1,8 @@
import { api } from "@homarr/api/server";
import { InviteListComponent } from "./_components/invite-list";
export default async function InvitesOverviewPage() {
const initialInvites = await api.invite.getAll();
return <InviteListComponent initialInvites={initialInvites} />;
}

View File

@@ -38,9 +38,7 @@ export const AddBoardModal = createModal<InnerProps>(
>
<Stack>
<TextInput
label={t(
"management.page.board.modal.createBoard.field.name.label",
)}
label={t("board.field.name.label")}
data-autofocus
{...form.getInputProps("name")}
/>