feat: user preferences (#470)

* wip: improve user preferences

* wip: fix translations and add user danger zone

* feat: add user delete button to danger zone

* fix: test not working

* refactor: add access checks for user edit page, improve not found behaviour, change user preference link in avatar menu to correct link

* fix: remove invalid bg for container

* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2024-05-12 16:27:56 +02:00
committed by GitHub
parent f0da1d81a6
commit db01301845
24 changed files with 961 additions and 414 deletions

View File

@@ -212,11 +212,9 @@ describe("editProfile shoud update user", () => {
// act
await caller.editProfile({
userId: id,
form: {
name: "ABC",
email: "",
},
id: id,
name: "ABC",
email: "",
});
// assert
@@ -256,11 +254,9 @@ describe("editProfile shoud update user", () => {
// act
await caller.editProfile({
userId: id,
form: {
name: "ABC",
email: "myNewEmail@gmail.com",
},
id,
name: "ABC",
email: "myNewEmail@gmail.com",
});
// assert

View File

@@ -8,7 +8,7 @@ import { invites, users } from "@homarr/db/schema/sqlite";
import { exampleChannel } from "@homarr/redis";
import { validation, z } from "@homarr/validation";
import { createTRPCRouter, publicProcedure } from "../trpc";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
export const userRouter = createTRPCRouter({
initUser: publicProcedure
@@ -60,6 +60,52 @@ export const userRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
await createUser(ctx.db, input);
}),
setProfileImage: protectedProcedure
.input(
z.object({
userId: z.string(),
// Max image size of 256KB, only png and jpeg are allowed
image: z
.string()
.regex(/^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9/+]+=*$/g)
.max(262144)
.nullable(),
}),
)
.mutation(async ({ input, ctx }) => {
// Only admins can change other users profile images
if (
ctx.session.user.id !== input.userId &&
!ctx.session.user.permissions.includes("admin")
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to change other users profile images",
});
}
const user = await ctx.db.query.users.findFirst({
columns: {
id: true,
image: true,
},
where: eq(users.id, input.userId),
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
image: input.image,
})
.where(eq(users.id, input.userId));
}),
getAll: publicProcedure.query(async ({ ctx }) => {
return ctx.db.query.users.findMany({
columns: {
@@ -83,7 +129,7 @@ export const userRouter = createTRPCRouter({
getById: publicProcedure
.input(z.object({ userId: z.string() }))
.query(async ({ input, ctx }) => {
return ctx.db.query.users.findFirst({
const user = await ctx.db.query.users.findFirst({
columns: {
id: true,
name: true,
@@ -93,38 +139,90 @@ export const userRouter = createTRPCRouter({
},
where: eq(users.id, input.userId),
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
return user;
}),
editProfile: publicProcedure
.input(
z.object({
form: validation.user.editProfile,
userId: z.string(),
}),
)
.input(validation.user.editProfile)
.mutation(async ({ input, ctx }) => {
const user = await ctx.db
.select()
.from(users)
.where(eq(users.id, input.userId))
.limit(1);
const user = await ctx.db.query.users.findFirst({
columns: { email: true },
where: eq(users.id, input.id),
});
const emailDirty =
input.form.email && user[0]?.email !== input.form.email;
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const emailDirty = input.email && user.email !== input.email;
await ctx.db
.update(users)
.set({
name: input.form.name,
email: emailDirty === true ? input.form.email : undefined,
name: input.name,
email: emailDirty === true ? input.email : undefined,
emailVerified: emailDirty === true ? null : undefined,
})
.where(eq(users.id, input.userId));
.where(eq(users.id, input.id));
}),
delete: publicProcedure.input(z.string()).mutation(async ({ input, ctx }) => {
await ctx.db.delete(users).where(eq(users.id, input));
}),
changePassword: publicProcedure
.input(validation.user.changePassword)
changePassword: protectedProcedure
.input(validation.user.changePasswordApi)
.mutation(async ({ ctx, input }) => {
const user = ctx.session.user;
// Only admins can change other users' passwords
if (!user.permissions.includes("admin") && user.id !== input.userId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
// Admins can change the password of other users without providing the previous password
const isPreviousPasswordRequired = ctx.session.user.id === input.userId;
if (isPreviousPasswordRequired) {
const dbUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
password: true,
salt: true,
},
where: eq(users.id, input.userId),
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const previousPasswordHash = await hashPassword(
input.previousPassword,
dbUser.salt ?? "",
);
const isValid = previousPasswordHash === dbUser.password;
if (!isValid) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invalid password",
});
}
}
const salt = await createSalt();
const hashedPassword = await hashPassword(input.password, salt);
await ctx.db

View File

@@ -2,6 +2,8 @@ import "dayjs/locale/en";
export default {
user: {
title: "Users",
name: "User",
page: {
login: {
title: "Log in to your account",
@@ -30,6 +32,9 @@ export default {
passwordConfirm: {
label: "Confirm password",
},
previousPassword: {
label: "Previous password",
},
},
action: {
login: {
@@ -59,6 +64,63 @@ export default {
},
},
create: "Create user",
changePassword: {
label: "Change password",
notification: {
success: {
message: "Password changed successfully",
},
error: {
message: "Unable to change password",
},
},
},
manageAvatar: {
changeImage: {
label: "Change image",
notification: {
success: {
message: "The image changed successfully",
},
error: {
message: "Unable to change image",
},
toLarge: {
title: "Image is too large",
message: "Max image size is {size}",
},
},
},
removeImage: {
label: "Remove image",
confirm: "Are you sure you want to remove the image?",
notification: {
success: {
message: "Image removed successfully",
},
error: {
message: "Unable to remove image",
},
},
},
},
editProfile: {
notification: {
success: {
message: "Profile updated successfully",
},
error: {
message: "Unable to update profile",
},
},
},
delete: {
label: "Delete user permanently",
description:
"Deletes this user including their preferences. Will not delete any boards. User will not be notified.",
confirm:
"Are you sure, that you want to delete the user {username} with his preferences?",
},
select: {
label: "Select user",
notFound: "No user found",
@@ -139,10 +201,10 @@ export default {
label: "New group",
notification: {
success: {
message: "The app was successfully created",
message: "The group was successfully created",
},
error: {
message: "The app could not be created",
message: "The group could not be created",
},
},
},
@@ -383,6 +445,7 @@ export default {
save: "Save",
saveChanges: "Save changes",
cancel: "Cancel",
delete: "Delete",
discard: "Discard",
confirm: "Confirm",
continue: "Continue",
@@ -436,19 +499,14 @@ export default {
switchToDarkMode: "Switch to dark mode",
switchToLightMode: "Switch to light mode",
management: "Management",
preferences: "Your preferences",
logout: "Logout",
login: "Login",
navigateDefaultBoard: "Navigate to default board",
loggedOut: "Logged out",
},
},
menu: {
section: {
dangerZone: {
title: "Danger Zone",
},
},
},
dangerZone: "Danger zone",
noResults: "No results found",
preview: {
show: "Show preview",
@@ -502,7 +560,6 @@ export default {
menu: {
label: {
settings: "Settings",
dangerZone: "Danger Zone",
},
},
create: {
@@ -947,7 +1004,7 @@ export default {
},
},
dangerZone: {
title: "Danger Zone",
title: "Danger zone",
action: {
rename: {
label: "Rename board",
@@ -1077,40 +1134,21 @@ export default {
},
},
user: {
back: "Back to users",
setting: {
general: {
title: "General",
},
security: {
title: "Security",
},
},
list: {
metaTitle: "Manage users",
title: "Users",
},
edit: {
metaTitle: "Edit user {username}",
section: {
profile: {
title: "Profile",
},
preferences: {
title: "Preferences",
},
security: {
title: "Security",
changePassword: {
title: "Change password",
message: {
passwordUpdated: "Updated password",
},
},
},
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",
@@ -1180,7 +1218,6 @@ export default {
setting: {
general: {
title: "General",
dangerZone: "Danger zone",
},
members: {
title: "Members",

View File

@@ -1,5 +1,5 @@
import type { AvatarProps } from "@mantine/core";
import { Avatar } from "@mantine/core";
import type { AvatarProps, MantineSize } from "@mantine/core";
export interface UserProps {
name: string | null;
@@ -8,7 +8,7 @@ export interface UserProps {
interface UserAvatarProps {
user: UserProps | null;
size: MantineSize;
size: AvatarProps["size"];
}
export const UserAvatar = ({ user, size }: UserAvatarProps) => {

View File

@@ -41,6 +41,7 @@ const registrationSchemaApi = registrationSchema.and(
);
const editProfileSchema = z.object({
id: z.string(),
name: usernameSchema,
email: z
.string()
@@ -51,10 +52,20 @@ const editProfileSchema = z.object({
.nullable(),
});
const changePasswordSchema = z.object({
userId: z.string(),
password: passwordSchema,
});
const changePasswordSchema = z
.object({
previousPassword: z.string(),
password: passwordSchema,
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "Passwords do not match",
});
const changePasswordApiSchema = changePasswordSchema.and(
z.object({ userId: z.string() }),
);
export const userSchemas = {
signIn: signInSchema,
@@ -65,4 +76,5 @@ export const userSchemas = {
password: passwordSchema,
editProfile: editProfileSchema,
changePassword: changePasswordSchema,
changePasswordApi: changePasswordApiSchema,
};