mirror of
https://github.com/ajnart/homarr.git
synced 2026-01-30 19:29:17 +01:00
* refactor: remove central validation export to improve typescript performance * fix: missing package exports change in validation package * chore: address pull request feedback
571 lines
18 KiB
TypeScript
571 lines
18 KiB
TypeScript
import { TRPCError } from "@trpc/server";
|
|
import { z } from "zod";
|
|
|
|
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
|
|
import type { Database } from "@homarr/db";
|
|
import { and, createId, eq, like } from "@homarr/db";
|
|
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
|
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
|
|
import { selectUserSchema } from "@homarr/db/validationSchemas";
|
|
import { credentialsAdminGroup } from "@homarr/definitions";
|
|
import type { SupportedAuthProvider } from "@homarr/definitions";
|
|
import { logger } from "@homarr/log";
|
|
import { byIdSchema } from "@homarr/validation/common";
|
|
import type { userBaseCreateSchema } from "@homarr/validation/user";
|
|
import {
|
|
userChangeColorSchemeSchema,
|
|
userChangeHomeBoardsSchema,
|
|
userChangePasswordApiSchema,
|
|
userChangeSearchPreferencesSchema,
|
|
userCreateSchema,
|
|
userEditProfileSchema,
|
|
userFirstDayOfWeekSchema,
|
|
userInitSchema,
|
|
userPingIconsEnabledSchema,
|
|
userRegistrationApiSchema,
|
|
} from "@homarr/validation/user";
|
|
|
|
import { convertIntersectionToZodObject } from "../schema-merger";
|
|
import {
|
|
createTRPCRouter,
|
|
onboardingProcedure,
|
|
permissionRequiredProcedure,
|
|
protectedProcedure,
|
|
publicProcedure,
|
|
} from "../trpc";
|
|
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
|
import { throwIfCredentialsDisabled } from "./invite/checks";
|
|
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
|
import { changeSearchPreferencesAsync, changeSearchPreferencesInputSchema } from "./user/change-search-preferences";
|
|
|
|
export const userRouter = createTRPCRouter({
|
|
initUser: onboardingProcedure
|
|
.requiresStep("user")
|
|
.input(userInitSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
throwIfCredentialsDisabled();
|
|
|
|
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
|
const userId = await createUserAsync(ctx.db, input);
|
|
const groupId = createId();
|
|
await ctx.db.insert(groups).values({
|
|
id: groupId,
|
|
name: credentialsAdminGroup,
|
|
ownerId: userId,
|
|
position: maxPosition + 1,
|
|
});
|
|
await ctx.db.insert(groupPermissions).values({
|
|
groupId,
|
|
permission: "admin",
|
|
});
|
|
await ctx.db.insert(groupMembers).values({
|
|
groupId,
|
|
userId,
|
|
});
|
|
await nextOnboardingStepAsync(ctx.db, undefined);
|
|
}),
|
|
register: publicProcedure
|
|
.input(userRegistrationApiSchema)
|
|
.output(z.void())
|
|
.mutation(async ({ ctx, input }) => {
|
|
throwIfCredentialsDisabled();
|
|
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, "credentials", input.username);
|
|
|
|
await createUserAsync(ctx.db, input);
|
|
|
|
// Delete invite as it's used
|
|
await ctx.db.delete(invites).where(inviteWhere);
|
|
}),
|
|
create: permissionRequiredProcedure
|
|
.requiresPermission("admin")
|
|
.meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"], protect: true } })
|
|
.input(userCreateSchema)
|
|
.output(z.void())
|
|
.mutation(async ({ ctx, input }) => {
|
|
throwIfCredentialsDisabled();
|
|
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
|
|
|
|
const userId = await createUserAsync(ctx.db, input);
|
|
|
|
if (input.groupIds.length >= 1) {
|
|
await ctx.db.insert(groupMembers).values(input.groupIds.map((groupId) => ({ groupId, userId })));
|
|
}
|
|
}),
|
|
setProfileImage: protectedProcedure
|
|
.output(z.void())
|
|
.meta({ openapi: { method: "PUT", path: "/api/users/profileImage", tags: ["users"], protect: true } })
|
|
.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,
|
|
provider: true,
|
|
},
|
|
where: eq(users.id, input.userId),
|
|
});
|
|
|
|
if (!user) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "User not found",
|
|
});
|
|
}
|
|
|
|
if (user.provider !== "credentials") {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "Profile image can not be changed for users with external providers",
|
|
});
|
|
}
|
|
|
|
await ctx.db
|
|
.update(users)
|
|
.set({
|
|
image: input.image,
|
|
})
|
|
.where(eq(users.id, input.userId));
|
|
}),
|
|
getAll: permissionRequiredProcedure
|
|
.requiresPermission("admin")
|
|
.input(z.void())
|
|
.output(z.array(selectUserSchema.pick({ id: true, name: true, email: true, emailVerified: true, image: true })))
|
|
.meta({ openapi: { method: "GET", path: "/api/users", tags: ["users"], protect: true } })
|
|
.query(({ ctx }) => {
|
|
return ctx.db.query.users.findMany({
|
|
columns: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
emailVerified: true,
|
|
image: true,
|
|
},
|
|
});
|
|
}),
|
|
// Is protected because also used in board access / integration access forms
|
|
selectable: protectedProcedure
|
|
.input(z.object({ excludeExternalProviders: z.boolean().default(false) }).optional())
|
|
.output(z.array(selectUserSchema.pick({ id: true, name: true, image: true })))
|
|
.meta({ openapi: { method: "GET", path: "/api/users/selectable", tags: ["users"], protect: true } })
|
|
.query(({ ctx, input }) => {
|
|
return ctx.db.query.users.findMany({
|
|
columns: {
|
|
id: true,
|
|
name: true,
|
|
image: true,
|
|
},
|
|
where: input?.excludeExternalProviders ? eq(users.provider, "credentials") : undefined,
|
|
});
|
|
}),
|
|
search: permissionRequiredProcedure
|
|
.requiresPermission("admin")
|
|
.input(
|
|
z.object({
|
|
query: z.string(),
|
|
limit: z.number().min(1).max(100).default(10),
|
|
}),
|
|
)
|
|
.output(z.array(selectUserSchema.pick({ id: true, name: true, image: true })))
|
|
.meta({ openapi: { method: "POST", path: "/api/users/search", tags: ["users"], protect: true } })
|
|
.query(async ({ input, ctx }) => {
|
|
const dbUsers = await ctx.db.query.users.findMany({
|
|
columns: {
|
|
id: true,
|
|
name: true,
|
|
image: true,
|
|
},
|
|
where: like(users.name, `%${input.query}%`),
|
|
limit: input.limit,
|
|
});
|
|
return dbUsers.map((user) => ({
|
|
id: user.id,
|
|
name: user.name ?? "",
|
|
image: user.image,
|
|
}));
|
|
}),
|
|
getById: protectedProcedure
|
|
.input(z.object({ userId: z.string() }))
|
|
.output(
|
|
selectUserSchema.pick({
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
emailVerified: true,
|
|
image: true,
|
|
provider: true,
|
|
homeBoardId: true,
|
|
mobileHomeBoardId: true,
|
|
firstDayOfWeek: true,
|
|
pingIconsEnabled: true,
|
|
defaultSearchEngineId: true,
|
|
openSearchInNewTab: true,
|
|
}),
|
|
)
|
|
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
|
|
.query(async ({ input, ctx }) => {
|
|
// Only admins can view other users details
|
|
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "You are not allowed to view other users details",
|
|
});
|
|
}
|
|
const user = await ctx.db.query.users.findFirst({
|
|
columns: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
emailVerified: true,
|
|
image: true,
|
|
provider: true,
|
|
homeBoardId: true,
|
|
mobileHomeBoardId: true,
|
|
firstDayOfWeek: true,
|
|
pingIconsEnabled: true,
|
|
defaultSearchEngineId: true,
|
|
openSearchInNewTab: true,
|
|
},
|
|
where: eq(users.id, input.userId),
|
|
});
|
|
|
|
if (!user) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "User not found",
|
|
});
|
|
}
|
|
|
|
return user;
|
|
}),
|
|
editProfile: protectedProcedure
|
|
.input(userEditProfileSchema)
|
|
.output(z.void())
|
|
.meta({ openapi: { method: "PUT", path: "/api/users/profile", tags: ["users"], protect: true } })
|
|
.mutation(async ({ input, ctx }) => {
|
|
// Only admins can view other users details
|
|
if (ctx.session.user.id !== input.id && !ctx.session.user.permissions.includes("admin")) {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "You are not allowed to edit other users details",
|
|
});
|
|
}
|
|
|
|
const user = await ctx.db.query.users.findFirst({
|
|
columns: { email: true, provider: true },
|
|
where: eq(users.id, input.id),
|
|
});
|
|
|
|
if (!user) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "User not found",
|
|
});
|
|
}
|
|
|
|
if (user.provider !== "credentials") {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "Username and email can not be changed for users with external providers",
|
|
});
|
|
}
|
|
|
|
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.name, input.id);
|
|
|
|
const emailDirty = input.email && user.email !== input.email;
|
|
await ctx.db
|
|
.update(users)
|
|
.set({
|
|
name: input.name,
|
|
email: emailDirty === true ? input.email : undefined,
|
|
emailVerified: emailDirty === true ? null : undefined,
|
|
})
|
|
.where(eq(users.id, input.id));
|
|
}),
|
|
delete: protectedProcedure
|
|
.input(z.object({ userId: z.string() }))
|
|
.output(z.void())
|
|
.meta({ openapi: { method: "DELETE", path: "/api/users/{userId}", tags: ["users"], protect: true } })
|
|
.mutation(async ({ input, ctx }) => {
|
|
// Only admins and user itself can delete a user
|
|
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "You are not allowed to delete other users",
|
|
});
|
|
}
|
|
|
|
await ctx.db.delete(users).where(eq(users.id, input.userId));
|
|
}),
|
|
changePassword: protectedProcedure
|
|
.input(userChangePasswordApiSchema)
|
|
.output(z.void())
|
|
.meta({ openapi: { method: "PATCH", path: "/api/users/{userId}/changePassword", tags: ["users"], protect: true } })
|
|
.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",
|
|
});
|
|
}
|
|
|
|
const dbUser = await ctx.db.query.users.findFirst({
|
|
columns: {
|
|
id: true,
|
|
password: true,
|
|
salt: true,
|
|
provider: true,
|
|
},
|
|
where: eq(users.id, input.userId),
|
|
});
|
|
|
|
if (!dbUser) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "User not found",
|
|
});
|
|
}
|
|
|
|
if (dbUser.provider !== "credentials") {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "Password can not be changed for users with external providers",
|
|
});
|
|
}
|
|
|
|
// Admins can change the password of other users without providing the previous password
|
|
const isPreviousPasswordRequired = ctx.session.user.id === input.userId;
|
|
|
|
logger.info(
|
|
`User ${user.id} is changing password for user ${input.userId}, previous password is required: ${isPreviousPasswordRequired}`,
|
|
);
|
|
|
|
if (isPreviousPasswordRequired) {
|
|
const previousPasswordHash = await hashPasswordAsync(input.previousPassword, dbUser.salt ?? "");
|
|
const isValid = previousPasswordHash === dbUser.password;
|
|
|
|
if (!isValid) {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "Invalid password",
|
|
});
|
|
}
|
|
}
|
|
|
|
const salt = await createSaltAsync();
|
|
const hashedPassword = await hashPasswordAsync(input.password, salt);
|
|
await ctx.db
|
|
.update(users)
|
|
.set({
|
|
password: hashedPassword,
|
|
})
|
|
.where(eq(users.id, input.userId));
|
|
}),
|
|
changeHomeBoards: protectedProcedure
|
|
.input(convertIntersectionToZodObject(userChangeHomeBoardsSchema.and(z.object({ userId: z.string() }))))
|
|
.output(z.void())
|
|
.meta({ openapi: { method: "PATCH", path: "/api/users/changeHome", tags: ["users"], protect: true } })
|
|
.mutation(async ({ input, ctx }) => {
|
|
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",
|
|
});
|
|
}
|
|
|
|
const dbUser = await ctx.db.query.users.findFirst({
|
|
columns: {
|
|
id: true,
|
|
},
|
|
where: eq(users.id, input.userId),
|
|
});
|
|
|
|
if (!dbUser) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "User not found",
|
|
});
|
|
}
|
|
|
|
// Only allow user to select boards they have access to
|
|
if (input.homeBoardId) {
|
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.homeBoardId), "view");
|
|
}
|
|
if (input.mobileHomeBoardId) {
|
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.mobileHomeBoardId), "view");
|
|
}
|
|
|
|
await ctx.db
|
|
.update(users)
|
|
.set({
|
|
homeBoardId: input.homeBoardId,
|
|
mobileHomeBoardId: input.mobileHomeBoardId,
|
|
})
|
|
.where(eq(users.id, input.userId));
|
|
}),
|
|
changeDefaultSearchEngine: protectedProcedure
|
|
.input(
|
|
convertIntersectionToZodObject(
|
|
userChangeSearchPreferencesSchema.omit({ openInNewTab: true }).and(z.object({ userId: z.string() })),
|
|
),
|
|
)
|
|
.output(z.void())
|
|
.meta({
|
|
openapi: {
|
|
method: "PATCH",
|
|
path: "/api/users/changeSearchEngine",
|
|
tags: ["users"],
|
|
protect: true,
|
|
deprecated: true,
|
|
},
|
|
})
|
|
.mutation(async ({ input, ctx }) => {
|
|
await changeSearchPreferencesAsync(ctx.db, ctx.session, {
|
|
...input,
|
|
openInNewTab: undefined,
|
|
});
|
|
}),
|
|
changeSearchPreferences: protectedProcedure
|
|
.input(convertIntersectionToZodObject(changeSearchPreferencesInputSchema))
|
|
.output(z.void())
|
|
.meta({ openapi: { method: "PATCH", path: "/api/users/search-preferences", tags: ["users"], protect: true } })
|
|
.mutation(async ({ input, ctx }) => {
|
|
await changeSearchPreferencesAsync(ctx.db, ctx.session, input);
|
|
}),
|
|
changeColorScheme: protectedProcedure
|
|
.input(userChangeColorSchemeSchema)
|
|
.output(z.void())
|
|
.meta({ openapi: { method: "PATCH", path: "/api/users/changeScheme", tags: ["users"], protect: true } })
|
|
.mutation(async ({ input, ctx }) => {
|
|
await ctx.db
|
|
.update(users)
|
|
.set({
|
|
colorScheme: input.colorScheme,
|
|
})
|
|
.where(eq(users.id, ctx.session.user.id));
|
|
}),
|
|
changePingIconsEnabled: protectedProcedure
|
|
.input(userPingIconsEnabledSchema.and(byIdSchema))
|
|
.mutation(async ({ input, ctx }) => {
|
|
// Only admins can change other users ping icons enabled
|
|
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "User not found",
|
|
});
|
|
}
|
|
|
|
await ctx.db
|
|
.update(users)
|
|
.set({
|
|
pingIconsEnabled: input.pingIconsEnabled,
|
|
})
|
|
.where(eq(users.id, ctx.session.user.id));
|
|
}),
|
|
changeFirstDayOfWeek: protectedProcedure
|
|
.input(convertIntersectionToZodObject(userFirstDayOfWeekSchema.and(byIdSchema)))
|
|
.output(z.void())
|
|
.meta({ openapi: { method: "PATCH", path: "/api/users/firstDayOfWeek", tags: ["users"], protect: true } })
|
|
.mutation(async ({ input, ctx }) => {
|
|
// Only admins can change other users first day of week
|
|
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "User not found",
|
|
});
|
|
}
|
|
|
|
const dbUser = await ctx.db.query.users.findFirst({
|
|
columns: {
|
|
id: true,
|
|
},
|
|
where: eq(users.id, input.id),
|
|
});
|
|
|
|
if (!dbUser) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "User not found",
|
|
});
|
|
}
|
|
|
|
await ctx.db
|
|
.update(users)
|
|
.set({
|
|
firstDayOfWeek: input.firstDayOfWeek,
|
|
})
|
|
.where(eq(users.id, ctx.session.user.id));
|
|
}),
|
|
});
|
|
|
|
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => {
|
|
const salt = await createSaltAsync();
|
|
const hashedPassword = await hashPasswordAsync(input.password, salt);
|
|
|
|
const userId = createId();
|
|
await db.insert(users).values({
|
|
id: userId,
|
|
name: input.username,
|
|
email: input.email,
|
|
password: hashedPassword,
|
|
salt,
|
|
});
|
|
return userId;
|
|
};
|
|
|
|
const checkUsernameAlreadyTakenAndThrowAsync = async (
|
|
db: Database,
|
|
provider: SupportedAuthProvider,
|
|
username: string,
|
|
ignoreId?: string,
|
|
) => {
|
|
const user = await db.query.users.findFirst({
|
|
where: and(eq(users.name, username), eq(users.provider, provider)),
|
|
});
|
|
|
|
if (!user) return;
|
|
if (ignoreId && user.id === ignoreId) return;
|
|
|
|
throw new TRPCError({
|
|
code: "CONFLICT",
|
|
message: "Username already taken",
|
|
});
|
|
};
|