feat: add user management (#134)
This commit is contained in:
36
apps/nextjs/src/app/[locale]/manage/users/[userId]/page.tsx
Normal file
36
apps/nextjs/src/app/[locale]/manage/users/[userId]/page.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
16
apps/nextjs/src/app/[locale]/manage/users/create/page.tsx
Normal file
16
apps/nextjs/src/app/[locale]/manage/users/create/page.tsx
Normal 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 />;
|
||||
}
|
||||
18
apps/nextjs/src/app/[locale]/manage/users/page.tsx
Normal file
18
apps/nextjs/src/app/[locale]/manage/users/page.tsx
Normal 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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user