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:
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 }));
|
||||
};
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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")}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user