Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96bb33299e | ||
|
|
39a1a3695e | ||
|
|
fbac0c8752 | ||
|
|
26c83d8853 | ||
|
|
4ff2c9a7fb | ||
|
|
a55dc98487 |
No files matched your search
@@ -163,65 +163,3 @@ jobs:
|
||||
run: cp .env.example .env
|
||||
- name: Build
|
||||
run: pnpm turbo build --affected
|
||||
|
||||
memory-regression:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: homarr-memory-test
|
||||
network: host
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
- name: Start container and measure memory
|
||||
run: |
|
||||
docker run -d --name homarr-mem-test -p 17575:7575 homarr-memory-test
|
||||
# Wait for container to be healthy (up to 60s)
|
||||
healthy=false
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:17575/api/health 2>/dev/null; then
|
||||
echo "Container healthy after $((i * 2))s"
|
||||
healthy=true
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$healthy" != "true" ]; then
|
||||
echo "ERROR: Container never became healthy"
|
||||
docker logs homarr-mem-test --tail 50
|
||||
exit 1
|
||||
fi
|
||||
# Warm up: hit the dashboard page a few times
|
||||
for i in $(seq 1 5); do
|
||||
curl -sf http://localhost:17575/ > /dev/null 2>&1 || true
|
||||
sleep 2
|
||||
done
|
||||
# Measure RSS after warmup
|
||||
MEM_KB=$(docker stats --no-stream --format '{{.MemUsage}}' homarr-mem-test | sed 's| /.*||')
|
||||
echo "Raw memory: $MEM_KB"
|
||||
# Convert to numeric MB (handles GiB and MiB suffixes)
|
||||
MEM_NUM=$(echo "$MEM_KB" | awk '{
|
||||
if ($0 ~ /GiB/) { gsub(/GiB/,""); print $1 * 1024 }
|
||||
else if ($0 ~ /MiB/) { gsub(/MiB/,""); print $1 }
|
||||
else if ($0 ~ /KiB/) { gsub(/KiB/,""); print $1 / 1024 }
|
||||
else { print $1 / 1048576 }
|
||||
}')
|
||||
echo "Memory usage: ${MEM_NUM} MB"
|
||||
MAX_MB=700
|
||||
if awk "BEGIN {exit !($MEM_NUM > $MAX_MB)}"; then
|
||||
echo "ERROR: Memory usage ${MEM_NUM} MB exceeds ${MAX_MB} MB threshold"
|
||||
docker logs homarr-mem-test --tail 50
|
||||
exit 1
|
||||
fi
|
||||
echo "Memory check passed: ${MEM_NUM} MB <= ${MAX_MB} MB"
|
||||
+90
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user