Merge branch 'dev' into ajnart/fix-duplicate-users

This commit is contained in:
Meier Lukas
2024-05-18 13:47:25 +02:00
49 changed files with 2193 additions and 1072 deletions

View File

@@ -1,6 +1,7 @@
import { appRouter as innerAppRouter } from "./router/app";
import { boardRouter } from "./router/board";
import { groupRouter } from "./router/group";
import { homeRouter } from "./router/home";
import { iconsRouter } from "./router/icons";
import { integrationRouter } from "./router/integration";
import { inviteRouter } from "./router/invite";
@@ -21,6 +22,7 @@ export const appRouter = createTRPCRouter({
location: locationRouter,
log: logRouter,
icon: iconsRouter,
home: homeRouter,
});
// export type definition of API

View File

@@ -0,0 +1,31 @@
import { count } from "@homarr/db";
import {
apps,
boards,
groups,
integrations,
invites,
users,
} from "@homarr/db/schema/sqlite";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const homeRouter = createTRPCRouter({
getStats: protectedProcedure.query(async ({ ctx }) => {
return {
countBoards:
(await ctx.db.select({ count: count() }).from(boards))[0]?.count ?? 0,
countUsers:
(await ctx.db.select({ count: count() }).from(users))[0]?.count ?? 0,
countGroups:
(await ctx.db.select({ count: count() }).from(groups))[0]?.count ?? 0,
countInvites:
(await ctx.db.select({ count: count() }).from(invites))[0]?.count ?? 0,
countIntegrations:
(await ctx.db.select({ count: count() }).from(integrations))[0]
?.count ?? 0,
countApps:
(await ctx.db.select({ count: count() }).from(apps))[0]?.count ?? 0,
};
}),
});

View File

@@ -2,6 +2,7 @@ import { describe, expect, it, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId, eq, schema } from "@homarr/db";
import { users } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import { userRouter } from "../user";
@@ -91,7 +92,106 @@ describe("initUser should initialize the first user", () => {
await expect(act()).rejects.toThrow("too_small");
});
});
describe("register should create a user with valid invitation", () => {
test("register should create a user with valid invitation", async () => {
// Arrange
const db = createDb();
const caller = userRouter.createCaller({
db,
session: null,
});
const userId = createId();
const inviteId = createId();
const inviteToken = "123";
vi.useFakeTimers();
vi.setSystemTime(new Date(2024, 0, 3));
await db.insert(users).values({
id: userId,
});
await db.insert(schema.invites).values({
id: inviteId,
token: inviteToken,
creatorId: userId,
expirationDate: new Date(2024, 0, 5),
});
// Act
await caller.register({
inviteId,
token: inviteToken,
username: "test",
password: "12345678",
confirmPassword: "12345678",
});
// Assert
const user = await db.query.users.findMany({
columns: {
name: true,
},
});
const invite = await db.query.invites.findMany({
columns: {
id: true,
},
});
expect(user).toHaveLength(2);
expect(invite).toHaveLength(0);
});
test.each([
[{ token: "fakeToken" }, new Date(2024, 0, 3)],
[{ inviteId: "fakeInviteId" }, new Date(2024, 0, 3)],
[{}, new Date(2024, 0, 5, 0, 0, 1)],
])(
"register should throw an error with input %s and date %s if the invitation is invalid",
async (partialInput, systemTime) => {
// Arrange
const db = createDb();
const caller = userRouter.createCaller({
db,
session: null,
});
const userId = createId();
const inviteId = createId();
const inviteToken = "123";
vi.useFakeTimers();
vi.setSystemTime(systemTime);
await db.insert(users).values({
id: userId,
});
await db.insert(schema.invites).values({
id: inviteId,
token: inviteToken,
creatorId: userId,
expirationDate: new Date(2024, 0, 5),
});
// Act
const act = async () =>
await caller.register({
inviteId,
token: inviteToken,
username: "test",
password: "12345678",
confirmPassword: "12345678",
...partialInput,
});
// Assert
await expect(act()).rejects.toThrow("Invalid invite");
},
);
});
describe("editProfile shoud update user", () => {
test("editProfile should update users and not update emailVerified when email not dirty", async () => {
// arrange
const db = createDb();
@@ -112,11 +212,9 @@ describe("initUser should initialize the first user", () => {
// act
await caller.editProfile({
userId: id,
form: {
name: "ABC",
email: "",
},
id: id,
name: "ABC",
email: "",
});
// assert
@@ -156,11 +254,9 @@ describe("initUser should initialize the first user", () => {
// act
await caller.editProfile({
userId: id,
form: {
name: "ABC",
email: "myNewEmail@gmail.com",
},
id,
name: "ABC",
email: "myNewEmail@gmail.com",
});
// assert
@@ -180,7 +276,9 @@ describe("initUser should initialize the first user", () => {
image: null,
});
});
});
describe("delete should delete user", () => {
test("delete should delete user", async () => {
const db = createDb();
const caller = userRouter.createCaller({

View File

@@ -3,12 +3,12 @@ import { observable } from "@trpc/server/observable";
import { createSalt, hashPassword } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { createId, eq, schema } from "@homarr/db";
import { users } from "@homarr/db/schema/sqlite";
import { and, createId, eq, schema } from "@homarr/db";
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
@@ -29,19 +29,86 @@ export const userRouter = createTRPCRouter({
await createUser(ctx.db, input);
}),
register: publicProcedure
.input(validation.user.registrationApi)
.mutation(async ({ ctx, input }) => {
const inviteWhere = and(
eq(invites.id, input.inviteId),
eq(invites.token, input.token),
);
const dbInvite = await ctx.db.query.invites.findFirst({
columns: {
id: true,
expirationDate: true,
},
where: inviteWhere,
});
if (!dbInvite || dbInvite.expirationDate < new Date()) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invalid invite",
});
}
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, input.username);
await createUser(ctx.db, input);
// Delete invite as it's used
await ctx.db.delete(invites).where(inviteWhere);
}),
create: publicProcedure
.input(validation.user.create)
.mutation(async ({ ctx, input }) => {
const user = await ctx.db.query.users.findFirst({
where: eq(users.name, input.username.toLowerCase()),
});
if (user !== undefined) {
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, input.username);
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: "CONFLICT",
message: "User already exists",
code: "FORBIDDEN",
message: "You are not allowed to change other users profile images",
});
}
await createUser(ctx.db, input);
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({
@@ -66,7 +133,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,
@@ -76,47 +143,96 @@ export const userRouter = createTRPCRouter({
},
where: eq(users.id, input.userId),
});
}),
editProfile: publicProcedure
.input(
z.object({
form: validation.user.editProfile,
userId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const user = await ctx.db
.select()
.from(users)
.where(eq(users.id, input.userId))
.limit(1);
const existingUser = await ctx.db.query.users.findFirst({
where: eq(users.name, input.form.name.toLowerCase()),
});
if (existingUser !== undefined) {
if (!user) {
throw new TRPCError({
code: "CONFLICT",
message: `User ${input.form.name} already exists`,
code: "NOT_FOUND",
message: "User not found",
});
}
const emailDirty =
input.form.email && user[0]?.email !== input.form.email;
return user;
}),
editProfile: publicProcedure
.input(validation.user.editProfile)
.mutation(async ({ input, ctx }) => {
const user = await ctx.db.query.users.findFirst({
columns: { email: true },
where: eq(users.id, input.id),
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await checkUsernameAlreadyTakenAndThrowAsync(
ctx.db,
input.name,
input.id,
);
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
@@ -155,3 +271,21 @@ const createUser = async (
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",
});
};