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", }),