Files
homarr/packages/api/src/router/user.ts
T
ajnart 4ff2c9a7fb feat(users): add ability to disable users without deleting them
- Add disabled boolean field to user schema (PostgreSQL, MySQL, SQLite)
- Block disabled users from logging in via any auth provider (credentials, LDAP, OIDC)
- Check disabled status in session callback to prevent session replay
- Add changeDisabled API endpoint (admin-only)
- Include disabled field in getById and getAll user endpoints
- Add disable/enable user toggle in admin UI with status badge in user list
2026-07-06 02:55:39 +02:00

834 lines
23 KiB
TypeScript

import { TRPCError } from "@trpc/server";
import type { MySqlRawQueryResult } from "drizzle-orm/mysql2";
import type { QueryResult } from "pg";
import { z } from "zod/v4";
import { comparePasswordsAsync, hashPasswordAsync } from "@homarr/auth";
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Database } from "@homarr/db";
import { and, eq, handleTransactionsAsync, inArray, 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 type { SupportedAuthProvider } from "@homarr/definitions";
import { credentialsAdminGroup, supportedAuthProviders } from "@homarr/definitions";
import { byIdSchema } from "@homarr/validation/common";
import type { userBaseCreateSchema } from "@homarr/validation/user";
import {
userChangeColorSchemeSchema,
userChangeHomeBoardsSchema,
userChangePasswordApiSchema,
userChangeSearchPreferencesSchema,
userCreateSchema,
userDdgBangsSchema,
userEditProfileSchema,
userFirstDayOfWeekSchema,
userInitSchema,
userPingIconsEnabledSchema,
userRegistrationApiSchema,
} from "@homarr/validation/user";
import { convertIntersectionToZodObject } from "../schema-merger";
import {
createTRPCRouter,
isDemoMode,
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";
const logger = createLogger({ module: "userRouter" });
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);
const hashedPassword = await hashPasswordAsync(input.password);
const userId = createId();
const user = {
id: userId,
name: input.username,
password: hashedPassword,
};
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (trx) => {
await trx.insert(schema.users).values(user);
// Delete invite as it's used
const queryResult = (await trx.delete(schema.invites).where(inviteWhere)) as
| QueryResult
| MySqlRawQueryResult;
let count: number;
if (Array.isArray(queryResult)) {
count = queryResult[0].affectedRows;
} else {
count = queryResult.rowCount ?? 0;
}
if (count === 0) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invalid invite",
});
}
});
},
handleSync(db) {
db.transaction((trx) => {
trx.insert(users).values(user).run();
// Delete invite as it's used
const result = trx.delete(invites).where(inviteWhere).run();
if (result.changes === 0) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invalid invite",
});
}
});
},
});
}),
create: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
openapi: {
method: "POST",
path: "/api/users",
tags: ["users"],
protect: true,
},
mcp: {
enabled: true,
description:
"Create a new user account (admin only). REQUIRED: username (string), password (string), confirmPassword (must match password). OPTIONAL: email (string or null)",
},
})
.input(convertIntersectionToZodObject(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(),
image: z
.string()
.regex(/^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9/+]+=*$/g)
.max(350000) // approximately 256KB in base64 (256 * 1024 * 4 / 3 + prefixes)
.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",
});
}
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,
disabled: true,
}),
),
)
.meta({
openapi: {
method: "GET",
path: "/api/users",
tags: ["users"],
protect: true,
},
mcp: { enabled: true, description: "List all users (admin only)" },
})
.query(({ ctx }) => {
return ctx.db.query.users.findMany({
columns: {
id: true,
name: true,
email: true,
emailVerified: true,
image: true,
disabled: true,
},
});
}),
// Is protected because also used in board access / integration access forms
selectable: protectedProcedure
.input(z.object({ providers: z.array(z.enum(supportedAuthProviders)).optional() }).optional())
.output(
z.array(
selectUserSchema.pick({
id: true,
name: true,
image: true,
email: 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,
email: true,
},
where: input?.providers ? inArray(users.provider, input.providers) : 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,
email: 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,
email: true,
},
where: like(users.name, `%${input.query}%`),
limit: input.limit,
});
return dbUsers.map((user) => ({
id: user.id,
name: user.name ?? "",
image: user.image,
email: user.email,
}));
}),
getById: protectedProcedure
.input(z.object({ userId: z.string() }))
.output(
selectUserSchema.pick({
id: true,
name: true,
email: true,
emailVerified: true,
image: true,
provider: true,
disabled: true,
homeBoardId: true,
mobileHomeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
completedManageTour: true,
completedBoardTour: true,
}),
)
.meta({
openapi: {
method: "GET",
path: "/api/users/{userId}",
tags: ["users"],
protect: true,
},
mcp: { enabled: true, description: "Get user details by user ID. REQUIRED: userId (string)" },
})
.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,
disabled: true,
homeBoardId: true,
mobileHomeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
completedManageTour: true,
completedBoardTour: 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,
},
mcp: { enabled: true, description: "Delete a user by ID. REQUIRED: userId (string)" },
})
.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(convertIntersectionToZodObject(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,
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("Changing user password", {
actorId: ctx.session.user.id,
targetUserId: input.userId,
previousPasswordRequired: isPreviousPasswordRequired,
});
if (isPreviousPasswordRequired) {
const isValid = await comparePasswordsAsync(input.previousPassword, dbUser.password ?? "");
if (!isValid) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invalid password",
});
}
}
const hashedPassword = await hashPasswordAsync(input.password);
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, input.id));
}),
changeDdgBangs: protectedProcedure
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
.output(z.void())
.meta({
openapi: {
method: "PATCH",
path: "/api/users/ddg-bangs",
tags: ["users"],
protect: true,
deprecated: true,
},
})
.mutation(async ({ input, ctx }) => {
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({
ddgBangs: input.ddgBangs,
})
.where(eq(users.id, input.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, input.id));
}),
completeTour: protectedProcedure
.input(z.object({ tour: z.enum(["manage", "board"]) }))
.mutation(async ({ input, ctx }) => {
const columnByTour = {
manage: { completedManageTour: true },
board: { completedBoardTour: true },
} as const;
await ctx.db.update(users).set(columnByTour[input.tour]).where(eq(users.id, ctx.session.user.id));
}),
resetTours: protectedProcedure.mutation(async ({ ctx }) => {
await ctx.db
.update(users)
.set({
completedManageTour: false,
completedBoardTour: false,
})
.where(eq(users.id, ctx.session.user.id));
}),
getTourStatus: protectedProcedure.query(async ({ ctx }) => {
if (isDemoMode) {
return {
completedManageTour: true,
completedBoardTour: true,
};
}
const user = await ctx.db.query.users.findFirst({
columns: {
completedManageTour: true,
completedBoardTour: true,
},
where: eq(users.id, ctx.session.user.id),
});
return {
completedManageTour: user?.completedManageTour ?? false,
completedBoardTour: user?.completedBoardTour ?? false,
};
}),
changeDisabled: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z.object({
userId: z.string(),
disabled: z.boolean(),
}),
)
.output(z.void())
.mutation(async ({ input, ctx }) => {
await ctx.db
.update(users)
.set({
disabled: input.disabled,
})
.where(eq(users.id, input.userId));
}),
});
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => {
const hashedPassword = await hashPasswordAsync(input.password);
const userId = createId();
await db.insert(users).values({
id: userId,
name: input.username,
email: input.email,
password: hashedPassword,
});
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",
});
};