feat: add user management (#134)

This commit is contained in:
Manuel
2024-02-20 21:18:47 +01:00
committed by GitHub
parent fde634d834
commit b4749e7091
12 changed files with 614 additions and 48 deletions

View File

@@ -0,0 +1,36 @@
import { notFound } from "next/navigation";
import { getScopedI18n } from "@homarr/translation/server";
import { Title } from "@homarr/ui";
import { api } from "~/trpc/server";
interface Props {
params: {
userId: string;
};
}
export async function generateMetadata({ params }: Props) {
const user = await api.user.getById({
userId: params.userId,
});
const t = await getScopedI18n("management.page.user.edit");
const metaTitle = `${t("metaTitle", { username: user?.name })} • Homarr`;
return {
title: metaTitle,
};
}
export default async function EditUserPage({ params }: Props) {
const user = await api.user.getById({
userId: params.userId,
});
if (!user) {
notFound();
}
return <Title>Edit User {user.name}!</Title>;
}

View File

@@ -0,0 +1,106 @@
"use client";
import { useMemo } from "react";
import Link from "next/link";
import type { MRT_ColumnDef } from "mantine-react-table";
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 {
ActionIcon,
Button,
Flex,
Group,
IconCheck,
IconEdit,
IconTrash,
Text,
ThemeIcon,
Title,
Tooltip,
} from "@homarr/ui";
interface UserListComponentProps {
initialUserList: RouterOutputs["user"]["getAll"];
}
export const UserListComponent = ({
initialUserList,
}: UserListComponentProps) => {
const t = useScopedI18n("management.page.user.list");
const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, {
initialData: initialUserList,
});
const columns = useMemo<
MRT_ColumnDef<RouterOutputs["user"]["getAll"][number]>[]
>(
() => [
{
accessorKey: "name",
header: "Name",
},
{
accessorKey: "email",
header: "Email",
Cell: ({ renderedCellValue, row }) => (
<Group>
{row.original.email ? renderedCellValue : <Text>-</Text>}
{row.original.emailVerified && (
<ThemeIcon radius="xl" size="sm">
<IconCheck size="1rem" />
</ThemeIcon>
)}
</Group>
),
},
],
[],
);
const table = useMantineReactTable({
columns,
data,
enableRowSelection: true,
enableColumnOrdering: true,
enableGlobalFilter: false,
enableRowActions: true,
enableDensityToggle: false,
enableFullScreenToggle: false,
getRowId: (row) => row.id,
renderRowActions: ({ row }) => (
<Flex gap="md">
<Tooltip label="Edit">
<ActionIcon
component={Link}
href={`/manage/users/${row.original.id}`}
>
<IconEdit size="1rem" />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete">
<ActionIcon color="red">
<IconTrash size="1rem" />
</ActionIcon>
</Tooltip>
</Flex>
),
renderTopToolbarCustomActions: () => (
<Button component={Link} href="/manage/users/create">
Create New User
</Button>
),
state: {
isLoading: isLoading,
},
});
return (
<>
<Title mb="md">{t("title")}</Title>
<MantineReactTable table={table} />
</>
);
};

View File

@@ -0,0 +1,74 @@
import Link from "next/link";
import { useI18n } from "@homarr/translation/client";
import {
Button,
Card,
Group,
IconArrowBackUp,
IconArrowLeft,
IconArrowRight,
IconRotate,
} from "@homarr/ui";
interface StepperNavigationComponentProps {
hasPrevious: boolean;
hasNext: boolean;
isComplete: boolean;
isLoadingNextStep: boolean;
prevStep: () => void;
nextStep: () => void;
reset: () => void;
}
export const StepperNavigationComponent = ({
hasNext,
hasPrevious,
isComplete,
isLoadingNextStep,
nextStep,
prevStep,
reset,
}: StepperNavigationComponentProps) => {
const t = useI18n();
return (
<Card shadow="md" withBorder>
{!isComplete ? (
<Group justify="space-between" wrap="nowrap">
<Button
leftSection={<IconArrowLeft size="1rem" />}
disabled={!hasPrevious || isLoadingNextStep}
onClick={prevStep}
>
{t("common.action.previous")}
</Button>
<Button
rightSection={<IconArrowRight size="1rem" />}
disabled={!hasNext || isLoadingNextStep}
loading={isLoadingNextStep}
onClick={nextStep}
>
{t("common.action.next")}
</Button>
</Group>
) : (
<Group justify="end" wrap="nowrap">
<Button
variant="light"
leftSection={<IconRotate size="1rem" />}
onClick={reset}
>
{t("management.page.user.create.buttons.createAnother")}
</Button>
<Button
leftSection={<IconArrowBackUp size="1rem" />}
component={Link}
href="/manage/users"
>
{t("management.page.user.create.buttons.return")}
</Button>
</Group>
)}
</Card>
);
};

View File

@@ -0,0 +1,156 @@
"use client";
import { useState } from "react";
import { clientApi } from "@homarr/api/client";
import { useForm, zodResolver } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import {
Avatar,
Card,
IconUserCheck,
Stack,
Stepper,
Text,
TextInput,
Title,
} from "@homarr/ui";
import { z } from "@homarr/validation";
import { StepperNavigationComponent } from "./stepper-navigation.component";
export const UserCreateStepperComponent = () => {
const t = useScopedI18n("management.page.user.create");
const stepperMax = 4;
const [active, setActive] = useState(0);
const nextStep = () =>
setActive((current) => (current < stepperMax ? current + 1 : current));
const prevStep = () =>
setActive((current) => (current > 0 ? current - 1 : current));
const hasNext = active < stepperMax;
const hasPrevious = active > 0;
const { mutateAsync, isPending } = clientApi.user.create.useMutation();
const generalForm = useForm({
initialValues: {
username: "",
email: undefined,
},
validate: zodResolver(
z.object({
username: z.string().min(1),
email: z.string().email().or(z.string().length(0).optional()),
}),
),
validateInputOnBlur: true,
validateInputOnChange: true,
});
const allForms = [generalForm];
const canNavigateToNextStep = allForms[active]?.isValid() ?? true;
const controlledGoToNextStep = async () => {
if (active + 1 === stepperMax) {
await mutateAsync({
name: generalForm.values.username,
email: generalForm.values.email,
});
}
nextStep();
};
const reset = () => {
setActive(0);
allForms.forEach((form) => {
form.reset();
});
};
return (
<>
<Title mb="md">{t("title")}</Title>
<Stepper
active={active}
onStepClick={setActive}
allowNextStepsSelect={false}
mb="md"
>
<Stepper.Step
label={t("step.personalInformation.label")}
allowStepSelect={false}
allowStepClick={false}
color={!generalForm.isValid() ? "red" : undefined}
>
<form>
<Card p="xl">
<Stack gap="md">
<TextInput
label={t("step.personalInformation.field.username.label")}
variant="filled"
withAsterisk
{...generalForm.getInputProps("username")}
/>
<TextInput
label={t("step.personalInformation.field.email.label")}
variant="filled"
{...generalForm.getInputProps("email")}
/>
</Stack>
</Card>
</form>
</Stepper.Step>
<Stepper.Step
label={t("step.preferences.label")}
description={t("step.preferences.description")}
allowStepSelect={false}
allowStepClick={false}
>
Step 2
</Stepper.Step>
<Stepper.Step
label={t("step.permissions.label")}
description={t("step.permissions.description")}
allowStepSelect={false}
allowStepClick={false}
>
3
</Stepper.Step>
<Stepper.Step
label={t("step.review.label")}
allowStepSelect={false}
allowStepClick={false}
>
<Card p="xl">
<Stack maw={300} align="center" mx="auto">
<Avatar size="xl">{generalForm.values.username}</Avatar>
<Text tt="uppercase" fw="bolder" size="xl">
{generalForm.values.username}
</Text>
</Stack>
</Card>
</Stepper.Step>
<Stepper.Completed>
<Card p="xl">
<Stack align="center" maw={300} mx="auto">
<IconUserCheck size="3rem" />
<Title order={2}>{t("step.completed.title")}</Title>
</Stack>
</Card>
</Stepper.Completed>
</Stepper>
<StepperNavigationComponent
hasNext={hasNext && canNavigateToNextStep}
hasPrevious={hasPrevious}
isComplete={active === stepperMax}
isLoadingNextStep={isPending}
nextStep={controlledGoToNextStep}
prevStep={prevStep}
reset={reset}
/>
</>
);
};

View File

@@ -0,0 +1,16 @@
import { getScopedI18n } from "@homarr/translation/server";
import { UserCreateStepperComponent } from "./_components/stepper.component";
export async function generateMetadata() {
const t = await getScopedI18n("management.page.user.create");
const metaTitle = `${t("metaTitle")} • Homarr`;
return {
title: metaTitle,
};
}
export default function CreateUserPage() {
return <UserCreateStepperComponent />;
}

View File

@@ -0,0 +1,18 @@
import { getScopedI18n } from "@homarr/translation/server";
import { api } from "~/trpc/server";
import { UserListComponent } from "./_components/user-list.component";
export async function generateMetadata() {
const t = await getScopedI18n("management.page.user.list");
const metaTitle = `${t("metaTitle")} • Homarr`;
return {
title: metaTitle,
};
}
export default async function UsersPage() {
const userList = await api.user.getAll();
return <UserListComponent initialUserList={userList} />;
}