feat: protect responsive board layouts

This commit is contained in:
ajnart committed 2026-08-08 15:41:14 +02:00
1 parent fa9444323b
commit 6728228387
46 files changed
+8933 -477

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, test } from "vitest";
import { responsiveBoardLayoutsSchema } from "./board";
const mobile = { id: "mobile", name: "Mobile", columnCount: 3, breakpoint: 0, role: "mobile" as const };
const base = { id: "base", name: "Base", columnCount: 12, breakpoint: 768, role: "base" as const };
describe("responsiveBoardLayoutsSchema", () => {
test("accepts protected layouts and custom layouts strictly between them", () => {
expect(
responsiveBoardLayoutsSchema.safeParse([
mobile,
{ id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" },
base,
]).success,
).toBe(true);
});
test.each([
{ name: "missing Mobile", layouts: [{ ...mobile, role: "custom" }, base] },
{ name: "removable Base replacement", layouts: [mobile, { ...base, role: "custom" }] },
{ name: "non-zero Mobile breakpoint", layouts: [{ ...mobile, breakpoint: 320 }, base] },
{
name: "custom layout at Base breakpoint",
layouts: [mobile, { id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 768, role: "custom" }, base],
},
{
name: "duplicate layout ID",
layouts: [mobile, { ...base, id: mobile.id }],
},
])("rejects $name", ({ layouts }) => {
expect(responsiveBoardLayoutsSchema.safeParse(layouts).success).toBe(false);
});
});
+45 -8
View File
@@ -5,6 +5,7 @@ import {
backgroundImageRepeats,
backgroundImageSizes,
boardPermissions,
layoutRoles,
widgetKinds,
} from "@homarr/definitions";
@@ -70,16 +71,52 @@ export const boardSavePartialSettingsSchema = z
})
.partial();
export const boardLayoutSchema = z.object({
id: z.string(),
name: z.string().trim().nonempty().max(32),
columnCount: boardColumnCountSchema,
breakpoint: z.number().int().min(0).max(32767),
role: z.enum(layoutRoles.values),
});
export const responsiveBoardLayoutsSchema = z
.array(boardLayoutSchema)
.min(2)
.superRefine((layouts, ctx) => {
const mobileLayouts = layouts.filter((layout) => layout.role === "mobile");
const baseLayouts = layouts.filter((layout) => layout.role === "base");
const mobileLayout = mobileLayouts.at(0);
const baseLayout = baseLayouts.at(0);
if (mobileLayouts.length !== 1 || baseLayouts.length !== 1 || !mobileLayout || !baseLayout) {
ctx.addIssue({ code: "custom", message: "Boards require exactly one Mobile and one Base layout" });
return;
}
if (mobileLayout.breakpoint !== 0) {
ctx.addIssue({ code: "custom", message: "The Mobile layout breakpoint must be 0" });
}
if (layouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint)) {
ctx.addIssue({ code: "custom", message: "The Base layout must have the highest breakpoint" });
}
if (new Set(layouts.map((layout) => layout.breakpoint)).size !== layouts.length) {
ctx.addIssue({ code: "custom", message: "Layout breakpoints must be unique" });
}
if (new Set(layouts.map((layout) => layout.id)).size !== layouts.length) {
ctx.addIssue({ code: "custom", message: "Layout IDs must be unique" });
}
});
export const boardSaveLayoutsSchema = z.object({
id: z.string(),
layouts: z.array(
z.object({
id: z.string(),
name: z.string().trim().nonempty().max(32),
columnCount: boardColumnCountSchema,
breakpoint: z.number().min(0).max(32767),
}),
),
layouts: responsiveBoardLayoutsSchema,
});
export const boardResetLayoutSchema = z.object({
boardId: z.string(),
layoutId: z.string(),
});
export const boardSaveSchema = z.object({