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
This commit is contained in:
ajnart committed 2026-07-06 02:55:39 +02:00
1 parent a55dc98487
commit 4ff2c9a7fb
13 files changed
+125 -1

No files matched your search

@@ -0,0 +1,62 @@
"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"];
}
export const DisableUserButton = ({ user }: DisableUserButtonProps) => {
const t = useI18n();
const [isPending, startTransition] = useTransition();
const changeDisabledMutation = clientApi.user.changeDisabled.useMutation();
const handleToggle = () => {
startTransition(async () => {
try {
await changeDisabledMutation.mutateAsync({
userId: user.id,
disabled: !user.disabled,
});
showSuccessNotification({
title: user.disabled
? t("user.action.enable.success", { defaultValue: "User enabled" })
: t("user.action.disable.success", { defaultValue: "User disabled" }),
message: user.disabled
? t("user.action.enable.successDescription", { defaultValue: "User has been enabled" })
: t("user.action.disable.successDescription", { defaultValue: "User has been disabled" }),
});
} catch {
showErrorNotification({
title: t("common.notification.update.error"),
message: t("common.notification.update.error"),
});
}
});
};
const tooltipLabel = user.disabled
? t("user.action.enable.description", { defaultValue: "Enable this user" })
: t("user.action.disable.description", { defaultValue: "Disable this user" });
return (
<Tooltip label={tooltipLabel} position="top">
<Button
onClick={handleToggle}
loading={isPending || changeDisabledMutation.isPending}
color={user.disabled ? "green" : "yellow"}
variant="light"
>
{user.disabled
? t("user.action.enable.label", { defaultValue: "Enable user" })
: t("user.action.disable.label", { defaultValue: "Disable user" })}
</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";
@@ -96,6 +97,15 @@ export default async function EditUserPage(props: Props) {
)}
<DangerZoneRoot>
<DangerZoneItem
label={user.disabled ? t("user.action.enable.label") : t("user.action.disable.label")}
description={
user.disabled
? t("management.page.user.action.enable.description", { defaultValue: "Re-enable access for this user" })
: t("management.page.user.action.disable.description", { defaultValue: "Prevent this user from logging in" })
}
action={<DisableUserButton user={user} />}
/>
<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 }) =>
row.original.disabled ? (
<Badge color="red">{t("user.field.status.disabled", { defaultValue: "Disabled" })}</Badge>
) : null,
},
],
[t],
);