Merge pull request #464 from homarr-labs/ajnart/fix-duplicate-users

This commit is contained in:
Thomas Camlong
2024-05-19 23:44:46 +02:00
committed by GitHub
6 changed files with 70 additions and 12 deletions

View File

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

View File

@@ -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>

View File

@@ -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({

View File

@@ -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",
});
};

View File

@@ -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",

View File

@@ -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",