feat: add edit user page (#173)

This commit is contained in:
Manuel
2024-03-05 21:10:19 +01:00
committed by GitHub
parent 8c9adb31f3
commit 41b99f191c
7 changed files with 394 additions and 9 deletions

View File

@@ -0,0 +1,49 @@
"use client";
import React from "react";
import { useRouter } from "next/router";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { Button, Divider, Group, Stack, Text } from "@homarr/ui";
import { revalidatePathAction } from "~/app/revalidatePathAction";
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: async () => {
await router.push("/manage/users");
await revalidatePathAction("/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

@@ -0,0 +1,58 @@
"use client";
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 { Button, Stack, TextInput } from "@homarr/ui";
import { validation } from "@homarr/validation";
import { revalidatePathAction } from "~/app/revalidatePathAction";
interface ProfileAccordionProps {
user: NonNullable<RouterOutputs["user"]["getById"]>;
}
export const ProfileAccordion = ({ user }: ProfileAccordionProps) => {
const t = useScopedI18n("management.page.user.edit.section.profile");
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("form.username.label")}
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
label={t("form.email.label")}
{...form.getInputProps("email")}
/>
<Button type="submit" disabled={!form.isValid()} loading={isPending}>
Submit
</Button>
</Stack>
</form>
);
};

View File

@@ -1,9 +1,25 @@
import { notFound } from "next/navigation";
import { getScopedI18n } from "@homarr/translation/server";
import { Title } from "@homarr/ui";
import {
Accordion,
AccordionControl,
AccordionItem,
AccordionPanel,
Avatar,
Group,
IconAlertTriangleFilled,
IconSettingsFilled,
IconShieldLockFilled,
IconUserFilled,
Stack,
Text,
Title,
} from "@homarr/ui";
import { api } from "~/trpc/server";
import { DangerZoneAccordion } from "./_components/dangerZone.accordion";
import { ProfileAccordion } from "./_components/profile.accordion";
interface Props {
params: {
@@ -24,6 +40,7 @@ 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,
});
@@ -32,5 +49,60 @@ export default async function EditUserPage({ params }: Props) {
notFound();
}
return <Title>Edit User {user.name}!</Title>;
return (
<Stack>
<Group mb="md">
<Avatar>{user.name?.substring(0, 2)}</Avatar>
<Title>{user.name}</Title>
</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></AccordionPanel>
</AccordionItem>
<AccordionItem
styles={{
item: {
"--__item-border-color": "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>
</Stack>
);
}

View File

@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { schema } from "@homarr/db";
import { createId, eq, schema } from "@homarr/db";
import { createDb } from "@homarr/db/test";
import { userRouter } from "../user";
@@ -91,4 +91,140 @@ describe("initUser should initialize the first user", () => {
await expect(act()).rejects.toThrow("too_small");
});
test("editProfile should update users and not update emailVerified when email not dirty", async () => {
// arrange
const db = createDb();
const caller = userRouter.createCaller({
db,
session: null,
});
const id = createId();
const emailVerified = new Date(2024, 0, 5);
await db.insert(schema.users).values({
id,
name: "TEST 1",
email: "abc@gmail.com",
emailVerified,
});
// act
await caller.editProfile({
userId: id,
form: {
name: "ABC",
email: "",
},
});
// assert
const user = await db
.select()
.from(schema.users)
.where(eq(schema.users.id, id));
expect(user).toHaveLength(1);
expect(user[0]).toStrictEqual({
id,
name: "ABC",
email: "abc@gmail.com",
emailVerified,
salt: null,
password: null,
image: null,
});
});
test("editProfile should update users and update emailVerified when email dirty", async () => {
// arrange
const db = createDb();
const caller = userRouter.createCaller({
db,
session: null,
});
const id = createId();
await db.insert(schema.users).values({
id,
name: "TEST 1",
email: "abc@gmail.com",
emailVerified: new Date(2024, 0, 5),
});
// act
await caller.editProfile({
userId: id,
form: {
name: "ABC",
email: "myNewEmail@gmail.com",
},
});
// assert
const user = await db
.select()
.from(schema.users)
.where(eq(schema.users.id, id));
expect(user).toHaveLength(1);
expect(user[0]).toStrictEqual({
id,
name: "ABC",
email: "myNewEmail@gmail.com",
emailVerified: null,
salt: null,
password: null,
image: null,
});
});
test("delete should delete user", async () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
session: null,
});
const userToDelete = createId();
const initialUsers = [
{
id: createId(),
name: "User 1",
email: null,
emailVerified: null,
image: null,
password: null,
salt: null,
},
{
id: userToDelete,
name: "User 2",
email: null,
emailVerified: null,
image: null,
password: null,
salt: null,
},
{
id: createId(),
name: "User 3",
email: null,
emailVerified: null,
image: null,
password: null,
salt: null,
},
];
await db.insert(schema.users).values(initialUsers);
await caller.delete(userToDelete);
const usersInDb = await db.select().from(schema.users);
expect(usersInDb).toStrictEqual([initialUsers[0], initialUsers[2]]);
});
});

View File

@@ -4,7 +4,7 @@ import { TRPCError } from "@trpc/server";
import { createSalt, hashPassword } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { createId, db, eq, schema } from "@homarr/db";
import { createId, eq, schema } from "@homarr/db";
import { users } from "@homarr/db/schema/sqlite";
import { validation, z } from "@homarr/validation";
@@ -34,8 +34,8 @@ export const userRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
await createUser(ctx.db, input);
}),
getAll: publicProcedure.query(async () => {
return db.query.users.findMany({
getAll: publicProcedure.query(async ({ ctx }) => {
return ctx.db.query.users.findMany({
columns: {
id: true,
name: true,
@@ -47,8 +47,8 @@ export const userRouter = createTRPCRouter({
}),
getById: publicProcedure
.input(z.object({ userId: z.string() }))
.query(async ({ input }) => {
return db.query.users.findFirst({
.query(async ({ input, ctx }) => {
return ctx.db.query.users.findFirst({
columns: {
id: true,
name: true,
@@ -59,6 +59,34 @@ export const userRouter = createTRPCRouter({
where: eq(users.id, input.userId),
});
}),
editProfile: publicProcedure
.input(
z.object({
form: validation.user.editProfile,
userId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const user = await ctx.db
.select()
.from(users)
.where(eq(users.id, input.userId))
.limit(1);
const emailDirty =
input.form.email && user[0]?.email !== input.form.email;
await ctx.db
.update(users)
.set({
name: input.form.name,
email: emailDirty === true ? input.form.email : undefined,
emailVerified: emailDirty === true ? null : undefined,
})
.where(eq(users.id, input.userId));
}),
delete: publicProcedure.input(z.string()).mutation(async ({ input, ctx }) => {
await ctx.db.delete(users).where(eq(users.id, input));
}),
});
const createUser = async (

View File

@@ -568,6 +568,36 @@ export default {
},
edit: {
metaTitle: "Edit user {username}",
section: {
profile: {
title: "Profile",
form: {
username: {
label: "Username",
},
email: {
label: "E-Mail",
},
},
},
preferences: {
title: "Preferences",
},
security: {
title: "Security",
},
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",

View File

@@ -22,9 +22,21 @@ const signInSchema = z.object({
password: z.string(),
});
const editProfileSchema = z.object({
name: usernameSchema,
email: z
.string()
.email()
.or(z.literal(""))
.transform((value) => (value === "" ? null : value))
.optional()
.nullable(),
});
export const userSchemas = {
signIn: signInSchema,
init: initUserSchema,
create: createUserSchema,
password: passwordSchema,
editProfile: editProfileSchema,
};