Compare commits

...
Author SHA1 Message Date
ajnart 96bb33299e style: auto-fix lint and format 2026-07-06 04:32:49 +00:00
ajnart 39a1a3695e fix: address coderabbit review on #6214
- Remove invalid null return from session callback (must return session object)
- Prevent admin from disabling their own account (guard with isSelf check)
- Hide disable UI when viewing own profile
- Add user existence check to changeDisabled endpoint
- Add OpenAPI metadata to changeDisabled mutation
2026-07-06 06:31:42 +02:00
ajnart fbac0c8752 refactor: remove ternary operators for better readability
Replace ternary operators with if-statements and helper functions in:
- DisableUserButton component
- user list disabled status column
- EditUserPage disable item configuration
2026-07-06 02:59:02 +02:00
ajnart 26c83d8853 style: auto-fix lint and format 2026-07-06 00:57:10 +00:00
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
12 changed files with 181 additions and 1 deletions

No files matched your search

@@ -0,0 +1,90 @@
"use client";
import { useTransition } from "react";
import { Button, Tooltip } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
interface DisableUserButtonProps {
user: RouterOutputs["user"]["getById"];
isSelf?: boolean;
}
export const DisableUserButton = ({ user, isSelf = false }: DisableUserButtonProps) => {
const t = useI18n();
const [isPending, startTransition] = useTransition();
const changeDisabledMutation = clientApi.user.changeDisabled.useMutation();
const isDisabled = user.disabled;
const getSuccessMessage = () => {
if (isDisabled) {
return {
title: t("user.action.enable.success", { defaultValue: "User enabled" }),
message: t("user.action.enable.successDescription", { defaultValue: "User has been enabled" }),
};
}
return {
title: t("user.action.disable.success", { defaultValue: "User disabled" }),
message: t("user.action.disable.successDescription", { defaultValue: "User has been disabled" }),
};
};
const handleToggle = () => {
if (isSelf) return;
startTransition(async () => {
try {
await changeDisabledMutation.mutateAsync({
userId: user.id,
disabled: !isDisabled,
});
showSuccessNotification(getSuccessMessage());
} catch {
showErrorNotification({
title: t("common.notification.update.error"),
message: t("common.notification.update.error"),
});
}
});
};
const getTooltipLabel = () => {
if (isDisabled) {
return t("user.action.enable.description", { defaultValue: "Enable this user" });
}
return t("user.action.disable.description", { defaultValue: "Disable this user" });
};
const getButtonLabel = () => {
if (isDisabled) {
return t("user.action.enable.label", { defaultValue: "Enable user" });
}
return t("user.action.disable.label", { defaultValue: "Disable user" });
};
const getButtonColor = () => (isDisabled ? "green" : "yellow");
return (
<Tooltip
label={
isSelf
? t("user.action.disableSelf.disabled", { defaultValue: "Cannot disable your own account" })
: getTooltipLabel()
}
position="top"
>
<Button
onClick={handleToggle}
loading={isPending || changeDisabledMutation.isPending}
color={getButtonColor()}
variant="light"
disabled={isSelf}
>
{getButtonLabel()}
</Button>
</Tooltip>
);
};
@@ -11,6 +11,7 @@ import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { DisableUserButton } from "./_components/_disable-user-button";
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { ResetTours } from "./_components/_reset-tours";
@@ -61,6 +62,19 @@ export default async function EditUserPage(props: Props) {
const searchEngines = await api.searchEngine.getSelectable();
const isSelf = session?.user.id === user.id;
const isCredentialsUser = user.provider === "credentials";
const isDisabled = user.disabled;
const getDisableItemLabel = () => {
if (isDisabled) return t("user.action.enable.label");
return t("user.action.disable.label");
};
const getDisableItemDescription = () => {
if (isDisabled) {
return t("management.page.user.action.enable.description", { defaultValue: "Re-enable access for this user" });
}
return t("management.page.user.action.disable.description", { defaultValue: "Prevent this user from logging in" });
};
return (
<Stack>
@@ -96,6 +110,13 @@ export default async function EditUserPage(props: Props) {
)}
<DangerZoneRoot>
{!isSelf && (
<DangerZoneItem
label={getDisableItemLabel()}
description={getDisableItemDescription()}
action={<DisableUserButton user={user} isSelf={isSelf} />}
/>
)}
<DangerZoneItem
label={t("user.action.delete.label")}
description={t("user.action.delete.description")}
@@ -1,7 +1,7 @@
"use client";
import { useMemo } from "react";
import { Anchor, Group, Text, Tooltip } from "@mantine/core";
import { Anchor, Badge, Group, Text, Tooltip } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
@@ -52,6 +52,15 @@ export const UserListComponent = ({ initialUserList }: UserListComponentProps) =
</Group>
),
},
{
accessorKey: "disabled",
header: t("user.field.status.label", { defaultValue: "Status" }),
size: 100,
Cell: ({ row }) => {
if (!row.original.disabled) return null;
return <Badge color="red">{t("user.field.status.disabled", { defaultValue: "Disabled" })}</Badge>;
},
},
],
[t],
);
+43
View File
@@ -232,6 +232,7 @@ export const userRouter = createTRPCRouter({
email: true,
emailVerified: true,
image: true,
disabled: true,
}),
),
)
@@ -252,6 +253,7 @@ export const userRouter = createTRPCRouter({
email: true,
emailVerified: true,
image: true,
disabled: true,
},
});
}),
@@ -341,6 +343,7 @@ export const userRouter = createTRPCRouter({
emailVerified: true,
image: true,
provider: true,
disabled: true,
homeBoardId: true,
mobileHomeBoardId: true,
firstDayOfWeek: true,
@@ -377,6 +380,7 @@ export const userRouter = createTRPCRouter({
emailVerified: true,
image: true,
provider: true,
disabled: true,
homeBoardId: true,
mobileHomeBoardId: true,
firstDayOfWeek: true,
@@ -777,6 +781,45 @@ export const userRouter = createTRPCRouter({
completedBoardTour: user?.completedBoardTour ?? false,
};
}),
changeDisabled: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z.object({
userId: z.string(),
disabled: z.boolean(),
}),
)
.output(z.void())
.meta({
openapi: {
method: "PATCH",
path: "/api/users/disabled",
tags: ["users"],
protect: true,
},
})
.mutation(async ({ input, ctx }) => {
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",
});
}
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">) => {
@@ -23,6 +23,11 @@ export const authorizeWithBasicCredentialsAsync = async (
return null;
}
if (user.disabled) {
logger.warn("User is disabled", { userName: user.name });
return null;
}
logger.info("User is trying to log in. Checking password...", { userName: user.name });
const isValidPassword = await comparePasswordsAsync(credentials.password, user.password);
@@ -107,6 +107,7 @@ export const authorizeWithLdapCredentialsAsync = async (
email: true,
emailVerified: true,
provider: true,
disabled: true,
},
where: and(eq(users.email, mailResult.data), eq(users.provider, "ldap")),
});
@@ -130,6 +131,11 @@ export const authorizeWithLdapCredentialsAsync = async (
logger.info("User created successfully", { userName: credentials.name });
}
if (user.disabled) {
logger.warn("User is disabled", { userName: credentials.name });
throw new CredentialsSignin("User is disabled");
}
return {
id: user.id,
name: credentials.name,
@@ -0,0 +1 @@
ALTER TABLE `user` ADD `disabled` boolean DEFAULT false NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "disabled" boolean DEFAULT false NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "disabled" integer DEFAULT 0 NOT NULL;
+1
View File
@@ -72,6 +72,7 @@ export const users = mysqlTable("user", {
image: text(),
password: text(),
provider: varchar({ length: 64 }).$type<SupportedAuthProvider>().default("credentials").notNull(),
disabled: boolean().default(false).notNull(),
homeBoardId: varchar({ length: 64 }).references((): AnyMySqlColumn => boards.id, {
onDelete: "set null",
}),
+1
View File
@@ -71,6 +71,7 @@ export const users = pgTable("user", {
image: text(),
password: text(),
provider: varchar({ length: 64 }).$type<SupportedAuthProvider>().default("credentials").notNull(),
disabled: boolean().default(false).notNull(),
homeBoardId: varchar({ length: 64 }).references((): AnyPgColumn => boards.id, {
onDelete: "set null",
}),
+1
View File
@@ -54,6 +54,7 @@ export const users = sqliteTable("user", {
image: text(),
password: text(),
provider: text().$type<SupportedAuthProvider>().default("credentials").notNull(),
disabled: int({ mode: "boolean" }).default(false).notNull(),
homeBoardId: text().references((): AnySQLiteColumn => boards.id, {
onDelete: "set null",
}),