From 41b99f191c6559bca49c7c3c3a13553c7698b781 Mon Sep 17 00:00:00 2001 From: Manuel <30572287+manuel-rw@users.noreply.github.com> Date: Tue, 5 Mar 2024 21:10:19 +0100 Subject: [PATCH] feat: add edit user page (#173) --- .../_components/dangerZone.accordion.tsx | 49 ++++++ .../_components/profile.accordion.tsx | 58 ++++++++ .../[locale]/manage/users/[userId]/page.tsx | 76 +++++++++- packages/api/src/router/test/user.spec.ts | 140 +++++++++++++++++- packages/api/src/router/user.ts | 38 ++++- packages/translation/src/lang/en.ts | 30 ++++ packages/validation/src/user.ts | 12 ++ 7 files changed, 394 insertions(+), 9 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/dangerZone.accordion.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/profile.accordion.tsx diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/dangerZone.accordion.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/dangerZone.accordion.tsx new file mode 100644 index 000000000..af9de4f1d --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/dangerZone.accordion.tsx @@ -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; +} + +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 ( + + + + + + {t("action.delete.label")} + + {t("action.delete.description")} + + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/profile.accordion.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/profile.accordion.tsx new file mode 100644 index 000000000..ca3a9eff8 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/profile.accordion.tsx @@ -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; +} + +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 ( +
+ + + + + +
+ ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/page.tsx index 696dca8cb..5798a31cd 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/page.tsx @@ -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 Edit User {user.name}!; + return ( + + + {user.name?.substring(0, 2)} + {user.name} + + + + }> + + {t("section.profile.title")} + + + + + + + + }> + + {t("section.preferences.title")} + + + + + + }> + + {t("section.security.title")} + + + + + + }> + + {t("section.dangerZone.title")} + + + + + + + + + ); } diff --git a/packages/api/src/router/test/user.spec.ts b/packages/api/src/router/test/user.spec.ts index 29ce6e349..936eb74f7 100644 --- a/packages/api/src/router/test/user.spec.ts +++ b/packages/api/src/router/test/user.spec.ts @@ -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]]); + }); }); diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index dc621ff2e..48c3cc2df 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -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 ( diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index eb57d5148..d9d10c256 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -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", diff --git a/packages/validation/src/user.ts b/packages/validation/src/user.ts index ef64e5f0c..807308d01 100644 --- a/packages/validation/src/user.ts +++ b/packages/validation/src/user.ts @@ -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, };