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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user