diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_disable-user-button.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_disable-user-button.tsx
new file mode 100644
index 000000000..c8b8b20df
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_disable-user-button.tsx
@@ -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 (
+
+
+
+ );
+};
diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx
index cc20a3459..6a48079ea 100644
--- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx
@@ -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) {
)}
+ }
+ />
),
},
+ {
+ accessorKey: "disabled",
+ header: t("user.field.status.label", { defaultValue: "Status" }),
+ size: 100,
+ Cell: ({ row }) =>
+ row.original.disabled ? (
+ {t("user.field.status.disabled", { defaultValue: "Disabled" })}
+ ) : null,
+ },
],
[t],
);
diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts
index c349f6cec..328784806 100644
--- a/packages/api/src/router/user.ts
+++ b/packages/api/src/router/user.ts
@@ -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,23 @@ export const userRouter = createTRPCRouter({
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, "groupIds">) => {
diff --git a/packages/auth/callbacks.ts b/packages/auth/callbacks.ts
index 51c9c83e5..dd11786c1 100644
--- a/packages/auth/callbacks.ts
+++ b/packages/auth/callbacks.ts
@@ -47,9 +47,14 @@ export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session
where: eq(users.id, user.id),
columns: {
colorScheme: true,
+ disabled: true,
},
});
+ if (additionalProperties?.disabled) {
+ return null;
+ }
+
return {
...session,
user: {
diff --git a/packages/auth/providers/credentials/authorization/basic-authorization.ts b/packages/auth/providers/credentials/authorization/basic-authorization.ts
index 2a6b96a31..0892ed54d 100644
--- a/packages/auth/providers/credentials/authorization/basic-authorization.ts
+++ b/packages/auth/providers/credentials/authorization/basic-authorization.ts
@@ -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);
diff --git a/packages/auth/providers/credentials/authorization/ldap-authorization.ts b/packages/auth/providers/credentials/authorization/ldap-authorization.ts
index 592fde682..f80f5be6d 100644
--- a/packages/auth/providers/credentials/authorization/ldap-authorization.ts
+++ b/packages/auth/providers/credentials/authorization/ldap-authorization.ts
@@ -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,
diff --git a/packages/db/migrations/mysql/0009_add_user_disabled.sql b/packages/db/migrations/mysql/0009_add_user_disabled.sql
new file mode 100644
index 000000000..20502ddba
--- /dev/null
+++ b/packages/db/migrations/mysql/0009_add_user_disabled.sql
@@ -0,0 +1 @@
+ALTER TABLE `user` ADD `disabled` boolean DEFAULT false NOT NULL;
diff --git a/packages/db/migrations/postgresql/0009_add_user_disabled.sql b/packages/db/migrations/postgresql/0009_add_user_disabled.sql
new file mode 100644
index 000000000..1319664b2
--- /dev/null
+++ b/packages/db/migrations/postgresql/0009_add_user_disabled.sql
@@ -0,0 +1 @@
+ALTER TABLE "user" ADD COLUMN "disabled" boolean DEFAULT false NOT NULL;
diff --git a/packages/db/migrations/sqlite/0009_add_user_disabled.sql b/packages/db/migrations/sqlite/0009_add_user_disabled.sql
new file mode 100644
index 000000000..cb20f9316
--- /dev/null
+++ b/packages/db/migrations/sqlite/0009_add_user_disabled.sql
@@ -0,0 +1 @@
+ALTER TABLE "user" ADD COLUMN "disabled" integer DEFAULT 0 NOT NULL;
diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts
index 71af5dbd2..7909f96a1 100644
--- a/packages/db/schema/mysql.ts
+++ b/packages/db/schema/mysql.ts
@@ -72,6 +72,7 @@ export const users = mysqlTable("user", {
image: text(),
password: text(),
provider: varchar({ length: 64 }).$type().default("credentials").notNull(),
+ disabled: boolean().default(false).notNull(),
homeBoardId: varchar({ length: 64 }).references((): AnyMySqlColumn => boards.id, {
onDelete: "set null",
}),
diff --git a/packages/db/schema/postgresql.ts b/packages/db/schema/postgresql.ts
index 092ecc64b..e95700ef9 100644
--- a/packages/db/schema/postgresql.ts
+++ b/packages/db/schema/postgresql.ts
@@ -71,6 +71,7 @@ export const users = pgTable("user", {
image: text(),
password: text(),
provider: varchar({ length: 64 }).$type().default("credentials").notNull(),
+ disabled: boolean().default(false).notNull(),
homeBoardId: varchar({ length: 64 }).references((): AnyPgColumn => boards.id, {
onDelete: "set null",
}),
diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts
index b791609d4..2730fd49d 100644
--- a/packages/db/schema/sqlite.ts
+++ b/packages/db/schema/sqlite.ts
@@ -54,6 +54,7 @@ export const users = sqliteTable("user", {
image: text(),
password: text(),
provider: text().$type().default("credentials").notNull(),
+ disabled: int({ mode: "boolean" }).default(false).notNull(),
homeBoardId: text().references((): AnySQLiteColumn => boards.id, {
onDelete: "set null",
}),