diff --git a/apps/nextjs/src/app/[locale]/manage/users/create/_components/stepper.component.tsx b/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx similarity index 77% rename from apps/nextjs/src/app/[locale]/manage/users/create/_components/stepper.component.tsx rename to apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx index cdf41ce91..2654d3b25 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/create/_components/stepper.component.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { clientApi } from "@homarr/api/client"; import { useForm, zodResolver } from "@homarr/form"; @@ -25,10 +25,15 @@ export const UserCreateStepperComponent = () => { 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 nextStep = useCallback( + () => + setActive((current) => (current < stepperMax ? current + 1 : current)), + [setActive], + ); + const prevStep = useCallback( + () => setActive((current) => (current > 0 ? current - 1 : current)), + [setActive], + ); const hasNext = active < stepperMax; const hasPrevious = active > 0; @@ -52,39 +57,51 @@ export const UserCreateStepperComponent = () => { const securityForm = useForm({ initialValues: { password: "", + confirmPassword: "", }, validate: zodResolver( - z.object({ - password: validation.user.password, - }), + z + .object({ + password: validation.user.password, + confirmPassword: z.string(), + }) + .refine((data) => data.password === data.confirmPassword, { + path: ["confirmPassword"], + message: "Passwords do not match", + }), ), validateInputOnBlur: true, validateInputOnChange: true, }); - const allForms = [generalForm, securityForm]; + const allForms = useMemo( + () => [generalForm, securityForm], + [generalForm, securityForm], + ); const isCurrentFormValid = allForms[active] ? (allForms[active]!.isValid satisfies () => boolean) : () => true; const canNavigateToNextStep = isCurrentFormValid(); - const controlledGoToNextStep = async () => { + const controlledGoToNextStep = useCallback(async () => { if (active + 1 === stepperMax) { await mutateAsync({ - name: generalForm.values.username, + username: generalForm.values.username, email: generalForm.values.email, + password: securityForm.values.password, + confirmPassword: securityForm.values.confirmPassword, }); } nextStep(); - }; + }, [active, generalForm, mutateAsync, securityForm, nextStep]); - const reset = () => { + const reset = useCallback(() => { setActive(0); allForms.forEach((form) => { form.reset(); }); - }; + }, [allForms]); return ( <> @@ -134,6 +151,12 @@ export const UserCreateStepperComponent = () => { withAsterisk {...securityForm.getInputProps("password")} /> + diff --git a/apps/nextjs/src/app/[locale]/manage/users/create/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/create/page.tsx index 1d610e4c4..5f5d28c07 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/create/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/create/page.tsx @@ -1,6 +1,6 @@ import { getScopedI18n } from "@homarr/translation/server"; -import { UserCreateStepperComponent } from "./_components/stepper.component"; +import { UserCreateStepperComponent } from "./_components/create-user-stepper"; export async function generateMetadata() { const t = await getScopedI18n("management.page.user.create"); diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 2781a9b7a..dc621ff2e 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -3,6 +3,7 @@ import "server-only"; 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 { users } from "@homarr/db/schema/sqlite"; import { validation, z } from "@homarr/validation"; @@ -26,16 +27,12 @@ export const userRouter = createTRPCRouter({ }); } - const salt = await createSalt(); - const hashedPassword = await hashPassword(input.password, salt); - - const userId = createId(); - await ctx.db.insert(schema.users).values({ - id: userId, - name: input.username, - password: hashedPassword, - salt, - }); + await createUser(ctx.db, input); + }), + create: publicProcedure + .input(validation.user.create) + .mutation(async ({ ctx, input }) => { + await createUser(ctx.db, input); }), getAll: publicProcedure.query(async () => { return db.query.users.findMany({ @@ -62,18 +59,20 @@ export const userRouter = createTRPCRouter({ where: eq(users.id, input.userId), }); }), - create: publicProcedure - .input( - z.object({ - name: z.string(), - email: z.string().email().or(z.string().length(0).optional()), - }), - ) - .mutation(async ({ input }) => { - await db.insert(users).values({ - id: createId(), - name: input.name, - email: input.email, - }); - }), }); + +const createUser = async ( + db: Database, + input: z.infer, +) => { + const salt = await createSalt(); + const hashedPassword = await hashPassword(input.password, salt); + + const userId = createId(); + await db.insert(schema.users).values({ + id: userId, + name: input.username, + password: hashedPassword, + salt, + }); +}; diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index b0c4ac34b..e9ced59ff 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -429,6 +429,9 @@ export default { password: { label: "Password", }, + confirmPassword: { + label: "Confirm password", + }, }, }, permissions: { diff --git a/packages/validation/src/user.ts b/packages/validation/src/user.ts index 818890ba2..ef64e5f0c 100644 --- a/packages/validation/src/user.ts +++ b/packages/validation/src/user.ts @@ -3,17 +3,20 @@ import { z } from "zod"; const usernameSchema = z.string().min(3).max(255); const passwordSchema = z.string().min(8).max(255); -const initUserSchema = z +const createUserSchema = z .object({ username: usernameSchema, password: passwordSchema, confirmPassword: z.string(), + email: z.string().email().optional(), }) .refine((data) => data.password === data.confirmPassword, { path: ["confirmPassword"], message: "Passwords do not match", }); +const initUserSchema = createUserSchema; + const signInSchema = z.object({ name: z.string(), password: z.string(), @@ -22,5 +25,6 @@ const signInSchema = z.object({ export const userSchemas = { signIn: signInSchema, init: initUserSchema, + create: createUserSchema, password: passwordSchema, };