Merge pull request #464 from homarr-labs/ajnart/fix-duplicate-users
This commit is contained in:
@@ -44,10 +44,15 @@ export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
|
|||||||
});
|
});
|
||||||
router.push("/auth/login");
|
router.push("/auth/login");
|
||||||
},
|
},
|
||||||
onError() {
|
onError(error) {
|
||||||
|
const message =
|
||||||
|
error.data?.code === "CONFLICT"
|
||||||
|
? t("error.usernameTaken")
|
||||||
|
: t("action.register.notification.error.message");
|
||||||
|
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
title: t("action.register.notification.error.title"),
|
title: t("action.register.notification.error.title"),
|
||||||
message: t("action.register.notification.error.message"),
|
message,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,16 +22,25 @@ export const UserProfileForm = ({ user }: UserProfileFormProps) => {
|
|||||||
async onSettled() {
|
async onSettled() {
|
||||||
await revalidatePathActionAsync("/manage/users");
|
await revalidatePathActionAsync("/manage/users");
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess(_, variables) {
|
||||||
|
// Reset form initial values to reset dirty state
|
||||||
|
form.setInitialValues({
|
||||||
|
name: variables.name,
|
||||||
|
email: variables.email ?? "",
|
||||||
|
});
|
||||||
showSuccessNotification({
|
showSuccessNotification({
|
||||||
title: t("common.notification.update.success"),
|
title: t("common.notification.update.success"),
|
||||||
message: t("user.action.editProfile.notification.success.message"),
|
message: t("user.action.editProfile.notification.success.message"),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError() {
|
onError(error) {
|
||||||
|
const message =
|
||||||
|
error.data?.code === "CONFLICT"
|
||||||
|
? t("user.error.usernameTaken")
|
||||||
|
: t("user.action.editProfile.notification.error.message");
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
title: t("common.notification.update.error"),
|
title: t("common.notification.update.error"),
|
||||||
message: t("user.action.editProfile.notification.error.message"),
|
message,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -59,7 +68,7 @@ export const UserProfileForm = ({ user }: UserProfileFormProps) => {
|
|||||||
<TextInput label={t("user.field.email.label")} {...form.getInputProps("email")} />
|
<TextInput label={t("user.field.email.label")} {...form.getInputProps("email")} />
|
||||||
|
|
||||||
<Group justify="end">
|
<Group justify="end">
|
||||||
<Button type="submit" color="teal" loading={isPending}>
|
<Button type="submit" color="teal" disabled={!form.isDirty()} loading={isPending}>
|
||||||
{t("common.action.saveChanges")}
|
{t("common.action.saveChanges")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { IconUserCheck } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { showErrorNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { validation, z } from "@homarr/validation";
|
import { validation, z } from "@homarr/validation";
|
||||||
import { createCustomErrorParams } from "@homarr/validation/form";
|
import { createCustomErrorParams } from "@homarr/validation/form";
|
||||||
@@ -26,7 +27,16 @@ export const UserCreateStepperComponent = () => {
|
|||||||
const hasNext = active < stepperMax;
|
const hasNext = active < stepperMax;
|
||||||
const hasPrevious = active > 0;
|
const hasPrevious = active > 0;
|
||||||
|
|
||||||
const { mutateAsync, isPending } = clientApi.user.create.useMutation();
|
const { mutateAsync, isPending } = clientApi.user.create.useMutation({
|
||||||
|
onError(error) {
|
||||||
|
showErrorNotification({
|
||||||
|
autoClose: false,
|
||||||
|
id: "create-user-error",
|
||||||
|
title: t("step.error.title"),
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const generalForm = useZodForm(
|
const generalForm = useZodForm(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -44,11 +44,23 @@ export const userRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!dbInvite || dbInvite.expirationDate < new Date()) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Invalid invite",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, input.username);
|
||||||
|
|
||||||
await createUserAsync(ctx.db, input);
|
await createUserAsync(ctx.db, input);
|
||||||
|
|
||||||
// Delete invite as it's used
|
// Delete invite as it's used
|
||||||
await ctx.db.delete(invites).where(inviteWhere);
|
await ctx.db.delete(invites).where(inviteWhere);
|
||||||
}),
|
}),
|
||||||
create: publicProcedure.input(validation.user.create).mutation(async ({ ctx, input }) => {
|
create: publicProcedure.input(validation.user.create).mutation(async ({ ctx, input }) => {
|
||||||
|
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, input.username);
|
||||||
|
|
||||||
await createUserAsync(ctx.db, input);
|
await createUserAsync(ctx.db, input);
|
||||||
}),
|
}),
|
||||||
setProfileImage: protectedProcedure
|
setProfileImage: protectedProcedure
|
||||||
@@ -148,6 +160,8 @@ export const userRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, input.name, input.id);
|
||||||
|
|
||||||
const emailDirty = input.email && user.email !== input.email;
|
const emailDirty = input.email && user.email !== input.email;
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(users)
|
.update(users)
|
||||||
@@ -227,12 +241,28 @@ const createUserAsync = async (db: Database, input: z.infer<typeof validation.us
|
|||||||
const salt = await createSaltAsync();
|
const salt = await createSaltAsync();
|
||||||
const hashedPassword = await hashPasswordAsync(input.password, salt);
|
const hashedPassword = await hashPasswordAsync(input.password, salt);
|
||||||
|
|
||||||
|
const username = input.username.toLowerCase();
|
||||||
|
|
||||||
const userId = createId();
|
const userId = createId();
|
||||||
await db.insert(schema.users).values({
|
await db.insert(schema.users).values({
|
||||||
id: userId,
|
id: userId,
|
||||||
name: input.username,
|
name: username,
|
||||||
email: input.email,
|
email: input.email,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
salt,
|
salt,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const checkUsernameAlreadyTakenAndThrowAsync = async (db: Database, username: string, ignoreId?: string) => {
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.name, username.toLowerCase()),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) return;
|
||||||
|
if (ignoreId && user.id === ignoreId) return;
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "Username already taken",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,16 +2,14 @@ import type { NotificationData } from "@mantine/notifications";
|
|||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IconCheck, IconX } from "@tabler/icons-react";
|
import { IconCheck, IconX } from "@tabler/icons-react";
|
||||||
|
|
||||||
type CommonNotificationProps = Pick<NotificationData, "title" | "message">;
|
export const showSuccessNotification = (props: NotificationData) =>
|
||||||
|
|
||||||
export const showSuccessNotification = (props: CommonNotificationProps) =>
|
|
||||||
notifications.show({
|
notifications.show({
|
||||||
...props,
|
...props,
|
||||||
color: "teal",
|
color: "teal",
|
||||||
icon: <IconCheck size={20} />,
|
icon: <IconCheck size={20} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const showErrorNotification = (props: CommonNotificationProps) =>
|
export const showErrorNotification = (props: NotificationData) =>
|
||||||
notifications.show({
|
notifications.show({
|
||||||
...props,
|
...props,
|
||||||
color: "red",
|
color: "red",
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export default {
|
|||||||
label: "Previous password",
|
label: "Previous password",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
error: {
|
||||||
|
usernameTaken: "Username already taken",
|
||||||
|
},
|
||||||
action: {
|
action: {
|
||||||
login: {
|
login: {
|
||||||
label: "Login",
|
label: "Login",
|
||||||
@@ -1219,6 +1222,9 @@ export default {
|
|||||||
completed: {
|
completed: {
|
||||||
title: "User created",
|
title: "User created",
|
||||||
},
|
},
|
||||||
|
error: {
|
||||||
|
title: "User creation failed",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
createAnother: "Create another user",
|
createAnother: "Create another user",
|
||||||
|
|||||||
Reference in New Issue
Block a user