fix(boards): add permissions to board summary schema to fix manage page crash (#5917)

* fix(boards): add permissions to board summary schema to fix manage page crash

The getAllBoards query fetches userPermissions and groupPermissions
from the DB, but boardSummarySchema (the output validator) did not
include them. tRPC stripped the fields, causing a TypeError when
getBoardPermissionsAsync called .some() on undefined.

* fix(backup): cast Buffer to Uint8Array for NextResponse body

Node 24 narrowed the BodyInit type, making Buffer incompatible.
Wrapping in Uint8Array satisfies the type constraint.

* fix(lint): remove forbidden non-null assertions across backup and board code

Replace non-null assertions with safe guards, optional chaining, and
for-of iteration so oxlint passes in CI. Also remove unused Collapse import.
This commit is contained in:
Thomas authored and GitHub committed 2026-06-10 19:51:56 +02:00
1 parent f26007a29a
commit a42af0e678
8 files changed
+34 -19

No files matched your search

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Button, Collapse, Stack, Text } from "@mantine/core";
import { Button, Stack, Text } from "@mantine/core";
import { IconDatabaseImport } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
@@ -129,14 +129,20 @@ export async function POST(req: Request) {
let tempDb: InstanceType<typeof Database> | null = null;
try {
const dbEntry = zip.getEntry("db.sqlite")!;
const dbEntry = zip.getEntry("db.sqlite");
if (!dbEntry) {
return NextResponse.json({ error: "Invalid backup: missing db.sqlite" }, { status: 400 });
}
fs.writeFileSync(tempPath, dbEntry.getData());
tempDb = new Database(tempPath);
const drizzleDb = drizzle(tempDb, { casing: DB_CASING });
migrate(drizzleDb, { migrationsFolder });
const metadataEntry = zip.getEntry("metadata.json")!;
const metadataEntry = zip.getEntry("metadata.json");
if (!metadataEntry) {
return NextResponse.json({ error: "Invalid backup: missing metadata.json" }, { status: 400 });
}
const metadata = JSON.parse(metadataEntry.getData().toString());
const rawKey = metadata.encryptionKey ?? zip.getEntry("encryption-key.txt")?.getData().toString().trim();
@@ -21,7 +21,7 @@ export async function GET() {
cachedWasm = fs.readFileSync(found);
}
return new NextResponse(cachedWasm, {
return new NextResponse(new Uint8Array(cachedWasm), {
headers: {
"Content-Type": "application/wasm",
"Cache-Control": "public, max-age=31536000, immutable",
@@ -85,7 +85,7 @@ describe("SQLite backup", () => {
const dbEntry = readBack.getEntry("db.sqlite");
expect(dbEntry).toBeDefined();
expect(dbEntry!.header.size).toBeGreaterThan(0);
expect(dbEntry?.header.size).toBeGreaterThan(0);
});
it("should produce a valid SQLite database in the ZIP", () => {
@@ -256,7 +256,7 @@ describe("SQLite backup", () => {
const app = backupDb.prepare('SELECT "name" FROM "app" WHERE "id" = ?').get("app-1") as { name: string } | undefined;
expect(app).toBeDefined();
expect(app!.name).toBe("TestApp");
expect(app?.name).toBe("TestApp");
backupDb.close();
sqlite.close();
@@ -283,7 +283,7 @@ describe("SQLite backup", () => {
const board = importDb.prepare('SELECT "name" FROM "board" WHERE "id" = ?').get("board-1") as { name: string } | undefined;
expect(board).toBeDefined();
expect(board!.name).toBe("TestBoard");
expect(board?.name).toBe("TestBoard");
importDb.close();
});
@@ -17,12 +17,12 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { MigrationFile } from "./types";
const BASE_PHASE_DURATIONS: Record<string, number> = {
const BASE_PHASE_DURATIONS = {
extracting: 1200,
encrypting: 1500,
swapping: 800,
restarting: 10000,
};
} as const;
const MIGRATION_STEP_DURATION = 100;
@@ -45,7 +45,7 @@ export const RestoreProgressPanel = ({ active, migrations, onComplete }: Restore
const steps = useMemo((): TimelineStep[] => {
const result: TimelineStep[] = [
{ key: "extracting", label: t("extracting"), icon: IconFileZip, duration: BASE_PHASE_DURATIONS.extracting! },
{ key: "extracting", label: t("extracting"), icon: IconFileZip, duration: BASE_PHASE_DURATIONS.extracting },
];
if (migrations.length > 0) {
@@ -67,9 +67,9 @@ export const RestoreProgressPanel = ({ active, migrations, onComplete }: Restore
}
result.push(
{ key: "encrypting", label: t("encrypting"), icon: IconKey, duration: BASE_PHASE_DURATIONS.encrypting! },
{ key: "swapping", label: t("swapping"), icon: IconTransform, duration: BASE_PHASE_DURATIONS.swapping! },
{ key: "restarting", label: t("restarting"), icon: IconRefresh, duration: BASE_PHASE_DURATIONS.restarting! },
{ key: "encrypting", label: t("encrypting"), icon: IconKey, duration: BASE_PHASE_DURATIONS.encrypting },
{ key: "swapping", label: t("swapping"), icon: IconTransform, duration: BASE_PHASE_DURATIONS.swapping },
{ key: "restarting", label: t("restarting"), icon: IconRefresh, duration: BASE_PHASE_DURATIONS.restarting },
);
return result;
@@ -80,10 +80,10 @@ export const RestoreProgressPanel = ({ active, migrations, onComplete }: Restore
let cancelled = false;
const run = async () => {
for (let i = 0; i < steps.length; i++) {
for (const [i, step] of steps.entries()) {
if (cancelled) return;
setCurrentStepIdx(i);
await new Promise((r) => setTimeout(r, steps[i]!.duration));
await new Promise((r) => setTimeout(r, step.duration));
}
if (!cancelled) {
onComplete?.();
@@ -109,8 +109,7 @@ export const useBackupAnalysis = () => {
};
if (pendingMigrations.length > 0) {
for (let i = 0; i < pendingMigrations.length; i++) {
const migration = pendingMigrations[i]!;
for (const [i, migration] of pendingMigrations.entries()) {
setMigrationProgress({
current: i + 1,
total: pendingMigrations.length,
+6 -2
View File
@@ -1430,8 +1430,10 @@ export const boardRouter = createTRPCRouter({
for (const il of existingInSection) {
for (let y = il.yOffset; y < il.yOffset + il.height; y++) {
while (occupied.length <= y) occupied.push(Array.from<boolean>({ length: layout.columnCount }).fill(false));
const occupiedRow = occupied[y];
if (!occupiedRow) continue;
for (let x = il.xOffset; x < il.xOffset + il.width; x++) {
occupied[y]![x] = true;
occupiedRow[x] = true;
}
}
}
@@ -1439,8 +1441,10 @@ export const boardRouter = createTRPCRouter({
let placed = false;
for (let y = 0; y < 9999 && !placed; y++) {
if (!occupied[y]) occupied.push(Array.from<boolean>({ length: layout.columnCount }).fill(false));
const occupiedRow = occupied[y];
if (!occupiedRow) continue;
for (let x = 0; x < layout.columnCount && !placed; x++) {
if (!occupied[y]![x]) {
if (!occupiedRow[x]) {
layoutRows.push({
itemId,
sectionId: emptySection.id,
+6
View File
@@ -96,6 +96,10 @@ export const boardCreateSchema = z.object({
export const boardSavePermissionsSchema = createSavePermissionsSchema(zodEnumFromArray(boardPermissions));
const boardPermissionEntrySchema = z.object({
permission: z.enum(boardPermissions),
});
export const boardSummarySchema = z.object({
id: z.string(),
name: z.string(),
@@ -111,6 +115,8 @@ export const boardSummarySchema = z.object({
.nullable(),
isHome: z.boolean(),
isMobileHome: z.boolean(),
userPermissions: z.array(boardPermissionEntrySchema),
groupPermissions: z.array(boardPermissionEntrySchema),
});
export const addItemToBoardSchema = z.object({