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,
};