feat(api): expose existing dashboard controls (#6546)

* feat(api): expand REST coverage for full dashboard automation

Closes #6435.

Boards could only be created empty through the API. The single endpoint for
adding a widget took no geometry, so the size came from the widget default and
the position was always the next free cell. Everything else a setup needs was
reachable from the interface only.

Board composition
- POST /api/boards/items accepts an optional placement: width, height, xOffset,
  yOffset and sectionId, or a layouts array for per breakpoint control. Without
  it the previous automatic placement is unchanged.
- Adds read, update and delete for items, CRUD for sections, read and replace
  for layouts, and GET /api/boards/{id} for the whole board.
- GET /api/boards/{id}/export and POST /api/boards/import make a board
  reproducible from version control. onConflict decides what happens when the
  name is taken, so a pipeline can be re-run.
- Board access is manageable through GET/PUT /api/boards/{id}/permissions.

Remaining services
- Search engines, integrations, groups, api keys and server settings are
  exposed over OpenAPI and registered as MCP tools.
- GET /api/config/export and POST /api/config/import cover the configuration of
  a whole instance. Ids are kept so references inside opaque widget options stay
  intact, and entities are matched by natural key when the id is unknown, which
  is what makes a document from another instance applicable.
- The backup endpoints accept an ApiKey header, so a recovery job no longer
  needs a browser session.

Headless bootstrap
- homarr api-keys create prints a key without opening the web interface, which
  together with the existing recreate-admin removes the last manual step.

Notes
- No database migration, the schema already stores position and size.
- Grid coordinates and sizes in a document are bounded, and the placement scan
  no longer walks every column, so a large grid cannot occupy the server.
- Imports collect their statements first and apply them in one transaction, so a
  rejected document never leaves a board half written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: harden dashboard automation APIs

* fix: validate global home board settings

* fix: validate merged import settings

* docs(api): describe the bounded placement scan accurately

The comment still described the earlier version that walked every row up to the
last occupied one. Both axes now iterate the edges of the occupied areas, so the
cost follows the amount of content rather than the dimensions of the grid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(api): keep the search engine type narrow and record the new MCP tools

The `.output()` added to `searchEngine.getPaginated` widened `type` back to a
string through the plain select schema, which broke the management page that
switches on it. It now reuses the narrowed union the detail endpoint already has.

`mcp.spec` pins the reviewed tool inventory; the 33 tools this branch exposes are
added to it. The configuration round trip asserted the first layout of an item,
which is the mobile one half of the time and holds the same item clamped, so it
addresses the base layout by id instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(api): complete Homarr automation endpoints

* refactor(api): consolidate preferences and task docs

* refactor(api): narrow REST to dashboard automation

* refactor(api): expose existing dashboard controls only

* fix(docs): drop removed API key CLI path

---------

Co-authored-by: rfdrew <20803599+rfdrew@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Thomas Camlong <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
This commit is contained in:
authored and GitHub committed 2026-10-07 15:02:59 +02:00
1 parent 6b1a956ce3
commit 1bc614b367
14 files changed
+2680 -646

No files matched your search

+10
View File
@@ -10,6 +10,16 @@ Use the generated specification under **Management → Tools → API** or the [i
This page covers contracts that are not obvious from those schemas.
The in-app reference loads its schema from `/api/openapi?format=scalar` on your Homarr instance. `/api/openapi` provides the raw JSON specification.
REST exposes selected existing Homarr controls. Use tRPC for advanced operations; both accept the `ApiKey` header.
`PUT /api/boards/{id}/content` uses the dashboard editor's complete `sections` and `items` arrays. Omitted entries are
removed, so read the current content through tRPC `board.getBoardByName` before editing it. Widget movement and
resizing can instead use the existing item-layout endpoint.
Use `GET /api/users/preferences` and `PATCH /api/users/preferences` to read or change preferences. Send only changed
values: `{ "colorScheme": "light" }` or `{ "headerPreferences": { "visible": false } }`. Omitted values stay unchanged;
`null` clears nullable board/search references. Header fields merge; supplied zone arrays replace their zone.
Omit `userId` for yourself; another user requires admin permission. Older preference routes remain available.
![Homarr's API documentation page](../img/api.png)
## Authentication and browser access
File diff suppressed because it is too large. Load diff
+21 -7
View File
@@ -13,7 +13,7 @@ export const metadata = pageMetadata({
});
export default function ApiReferenceIndexPage() {
const pages = apiSource.getPages();
const groups = apiSource.pageTree.children.filter((node) => node.type === "folder");
return (
<DocsPage full breadcrumb={{ enabled: false }} footer={{ enabled: false }}>
@@ -25,12 +25,26 @@ export default function ApiReferenceIndexPage() {
review examples or send a test request from your browser.
</p>
</header>
<div className="mt-10 divide-y border-y">
{pages.map((page) => (
<Link key={page.url} href={page.url} className="group flex items-center gap-3 py-4 hover:bg-fd-accent/50">
<span className="min-w-0 flex-1 truncate font-medium">{page.data.title}</span>
<IconArrowRight className="text-fd-muted-foreground" aria-hidden size={17} />
</Link>
<div className="mt-10 grid gap-6 lg:grid-cols-2">
{groups.map((group) => (
<section key={String(group.name)} className="min-w-0 rounded-xl border bg-fd-card p-5">
<h2 className="text-lg font-semibold">{group.name}</h2>
<div className="mt-3 divide-y border-t">
{group.children.map((page) => {
if (page.type !== "page") return null;
return (
<Link
key={page.url}
href={page.url}
className="group flex items-center gap-3 py-3 hover:bg-fd-accent/50"
>
<span className="min-w-0 flex-1 font-medium">{page.name}</span>
<IconArrowRight className="shrink-0 text-fd-muted-foreground" aria-hidden size={17} />
</Link>
);
})}
</div>
</section>
))}
</div>
<Carbon />
+26
View File
@@ -1,4 +1,5 @@
import { generateOpenApiDocument } from "trpc-to-openapi";
import { z } from "zod/v4";
import { API_KEY_HEADER_NAME } from "@homarr/auth/api-key";
@@ -59,5 +60,30 @@ export const openApiDocument = (base: string) => {
const json = integrationBody.content["application/json"];
if (json) json.example = { integrationKind: "sonarr", method: "GET", path: "/api/v3/system/status" };
}
// The adapter omits root refinements when generating request bodies.
const preferenceBody = document.paths?.["/api/users/preferences"]?.patch?.requestBody;
if (preferenceBody && "content" in preferenceBody) {
const json = preferenceBody.content["application/json"];
if (json) {
const schema = z.toJSONSchema(userRouter["_def"].record.updatePreferences["_def"].inputs[0] as z.ZodType, {
io: "input",
});
delete schema.$schema;
json.schema = schema as typeof json.schema;
json.example = { colorScheme: "light" };
}
}
document.tags = (
[
["boards", "Build dashboards"],
["apps", "Manage apps"],
["users", "Manage users and preferences"],
["settings", "Configure the instance"],
["integrations", "Connect integrations"],
["invites", "Manage invitations"],
["certificates", "Manage trusted certificates"],
["info", "Inspect Homarr"],
] as const
).map(([name, title]) => ({ name, "x-displayName": title }));
return document;
};
+1 -1
View File
@@ -37,7 +37,7 @@ export const apiKeysRouter = createTRPCRouter({
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
enabled: false,
description: "Create a new API key for the current user (admin only)",
},
})
+434 -374
View File
@@ -1205,14 +1205,31 @@ export const boardRouter = createTRPCRouter({
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
}),
getBoardByName: publicProcedure.input(boardByNameSchema).query(async ({ input, ctx }) => {
const boardWhere = eq(sql`UPPER(${boards.name})`, input.name.toUpperCase());
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
getBoardByName: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Read a dashboard by name, including current sections, items, widget options and layout IDs used by board_saveBoard and board_saveLayouts. Only accessible boards are returned; obtain names from board_getAllBoards.",
},
})
.input(boardByNameSchema)
.query(async ({ input, ctx }) => {
const boardWhere = eq(sql`UPPER(${boards.name})`, input.name.toUpperCase());
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
}),
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
}),
getBoardSettings: protectedProcedure
.meta({
openapi: {
method: "GET",
path: "/api/boards/{id}/settings",
tags: ["boards"],
protect: true,
summary: "Read board settings",
description: "Read editable board settings. Requires modify access to the board.",
},
mcp: {
enabled: true,
description:
@@ -1263,6 +1280,21 @@ export const boardRouter = createTRPCRouter({
};
}),
saveLayouts: protectedProcedure
.meta({
openapi: {
method: "PUT",
path: "/api/boards/{id}/layouts",
tags: ["boards"],
protect: true,
summary: "Save dashboard layouts",
description: "Save the same responsive layout settings used by the dashboard editor. Requires modify access.",
},
mcp: {
enabled: true,
description:
"Save dashboard layouts, preserving one Mobile and one Base layout. Requires modify access. Supply the board id and its complete layouts array from board_getBoardByName.",
},
})
.input(boardSaveLayoutsSchema)
.output(boardSaveLayoutsSchema.shape.layouts)
.mutation(async ({ ctx, input }) => {
@@ -1534,6 +1566,14 @@ export const boardRouter = createTRPCRouter({
}),
resetLayout: protectedProcedure
.meta({
openapi: {
method: "POST",
path: "/api/boards/{boardId}/layouts/{layoutId}/reset",
tags: ["boards"],
protect: true,
summary: "Reset a dashboard layout",
description: "Reset a Mobile or custom layout from the Base layout. Requires modify access.",
},
mcp: {
enabled: true,
description:
@@ -1541,6 +1581,7 @@ export const boardRouter = createTRPCRouter({
},
})
.input(boardResetLayoutSchema)
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
@@ -1655,231 +1696,51 @@ export const boardRouter = createTRPCRouter({
})
.where(eq(boards.id, input.id));
}),
saveBoard: protectedProcedure.input(boardSaveSchema).mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
await validateWidgetConfigurationsAsync(ctx, input.items, dbBoard.items);
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: input.items,
storedItems: dbBoard.items,
});
for (const item of input.items) {
if (item.kind !== "timetable") continue;
const previousItem = dbBoard.items.find((dbItem) => dbItem.id === item.id);
let previousOptions: Record<string, unknown> | undefined;
if (previousItem?.kind === "timetable") previousOptions = previousItem.options;
await validateTimetableOptionsChangeAsync(item.options, previousOptions);
}
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
if (addedSections.length > 0) {
await transaction.insert(schema.sections).values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
yOffset: section.kind === "empty" ? section.yOffset : null,
xOffset: section.kind === "empty" ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: null,
boardId: dbBoard.id,
})),
);
const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "container")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
);
if (sectionLayoutsToInsert.length > 0) {
await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert);
}
}
const addedItems = filterAddedItems(input.items, dbBoard.items);
if (addedItems.length > 0) {
await transaction.insert(schema.items).values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
boardId: dbBoard.id,
})),
);
await transaction.insert(schema.itemLayouts).values(
addedItems.flatMap((item) =>
item.layouts.map(
(layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
height: layoutSection.height,
width: layoutSection.width,
xOffset: layoutSection.xOffset,
yOffset: layoutSection.yOffset,
}),
),
),
);
}
const inputIntegrationRelations = input.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbBoard.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
if (addedIntegrationRelations.length > 0) {
await transaction.insert(schema.integrationItems).values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
);
}
const updatedItems = filterUpdatedItems(input.items, dbBoard.items);
for (const item of updatedItems) {
await transaction
.update(schema.items)
.set({
kind: item.kind,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
})
.where(eq(schema.items.id, item.id));
for (const itemSectionLayout of item.layouts) {
await transaction
.update(schema.itemLayouts)
.set({
height: itemSectionLayout.height,
width: itemSectionLayout.width,
xOffset: itemSectionLayout.xOffset,
yOffset: itemSectionLayout.yOffset,
sectionId: itemSectionLayout.sectionId,
})
.where(
and(
eq(schema.itemLayouts.itemId, item.id),
eq(schema.itemLayouts.layoutId, itemSectionLayout.layoutId),
),
);
}
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
await transaction
.update(schema.sections)
.set({
yOffset: section.kind === "empty" ? section.yOffset : null,
xOffset: section.kind === "empty" ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: null,
})
.where(eq(schema.sections.id, section.id));
if (section.kind !== "container") continue;
for (const sectionLayout of section.layouts) {
await transaction
.update(schema.sectionLayouts)
.set({
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
parentSectionId: sectionLayout.parentSectionId,
})
.where(
and(
eq(schema.sectionLayouts.sectionId, section.id),
eq(schema.sectionLayouts.layoutId, sectionLayout.layoutId),
),
);
}
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
await transaction
.delete(schema.integrationItems)
.where(
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
),
);
}
const removedItems = filterRemovedItems(input.items, dbBoard.items);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
await transaction.delete(schema.items).where(inArray(schema.items.id, itemIds));
}
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) {
await transaction.delete(schema.sections).where(inArray(schema.sections.id, sectionIds));
}
});
saveBoard: protectedProcedure
.meta({
openapi: {
method: "PUT",
path: "/api/boards/{id}/content",
tags: ["boards"],
protect: true,
summary: "Save dashboard content",
description:
"Save the editor's complete sections and items arrays, including widget options and placements. Omitted items and sections are removed. Requires modify access; Custom Widget placement changes require admin access.",
},
handleSync(db) {
db.transaction((transaction) => {
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
mcp: {
enabled: true,
description:
"Save complete dashboard sections and items, including widget options and placements. Omitted entries are removed. Read current content with board_getBoardByName first. Requires modify access; Custom Widget placement changes require admin access.",
},
})
.input(boardSaveSchema)
.output(z.void())
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
if (addedSections.length > 0) {
transaction
.insert(sections)
.values(
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
await validateWidgetConfigurationsAsync(ctx, input.items, dbBoard.items);
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: input.items,
storedItems: dbBoard.items,
});
for (const item of input.items) {
if (item.kind !== "timetable") continue;
const previousItem = dbBoard.items.find((dbItem) => dbItem.id === item.id);
let previousOptions: Record<string, unknown> | undefined;
if (previousItem?.kind === "timetable") previousOptions = previousItem.options;
await validateTimetableOptionsChangeAsync(item.options, previousOptions);
}
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
if (addedSections.length > 0) {
await transaction.insert(schema.sections).values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
@@ -1889,36 +1750,33 @@ export const boardRouter = createTRPCRouter({
name: null,
boardId: dbBoard.id,
})),
)
.run();
const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "container")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
);
if (sectionLayoutsToInsert.length > 0) {
transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run();
const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "container")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
);
if (sectionLayoutsToInsert.length > 0) {
await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert);
}
}
}
const addedItems = filterAddedItems(input.items, dbBoard.items);
const addedItems = filterAddedItems(input.items, dbBoard.items);
if (addedItems.length > 0) {
transaction
.insert(items)
.values(
if (addedItems.length > 0) {
await transaction.insert(schema.items).values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
@@ -1926,14 +1784,11 @@ export const boardRouter = createTRPCRouter({
advancedOptions: superjson.stringify(item.advancedOptions),
boardId: dbBoard.id,
})),
)
.run();
transaction
.insert(itemLayouts)
.values(
);
await transaction.insert(schema.itemLayouts).values(
addedItems.flatMap((item) =>
item.layouts.map(
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
(layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
@@ -1944,142 +1799,347 @@ export const boardRouter = createTRPCRouter({
}),
),
),
)
.run();
}
);
}
const inputIntegrationRelations = input.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbBoard.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
const inputIntegrationRelations = input.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbBoard.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
if (addedIntegrationRelations.length > 0) {
transaction
.insert(integrationItems)
.values(
if (addedIntegrationRelations.length > 0) {
await transaction.insert(schema.integrationItems).values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
)
.run();
}
const updatedItems = filterUpdatedItems(input.items, dbBoard.items);
for (const item of updatedItems) {
transaction
.update(items)
.set({
kind: item.kind,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
})
.where(eq(items.id, item.id))
.run();
for (const itemSectionLayout of item.layouts) {
transaction
.update(itemLayouts)
.set({
height: itemSectionLayout.height,
width: itemSectionLayout.width,
xOffset: itemSectionLayout.xOffset,
yOffset: itemSectionLayout.yOffset,
sectionId: itemSectionLayout.sectionId,
})
.where(and(eq(itemLayouts.itemId, item.id), eq(itemLayouts.layoutId, itemSectionLayout.layoutId)))
.run();
);
}
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
const updatedItems = filterUpdatedItems(input.items, dbBoard.items);
for (const section of updatedSections) {
transaction
.update(sections)
.set({
yOffset: section.kind === "empty" ? section.yOffset : null,
xOffset: section.kind === "empty" ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: null,
})
.where(eq(sections.id, section.id))
.run();
if (section.kind !== "container") continue;
for (const sectionLayout of section.layouts) {
transaction
.update(sectionLayouts)
for (const item of updatedItems) {
await transaction
.update(schema.items)
.set({
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
parentSectionId: sectionLayout.parentSectionId,
kind: item.kind,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
})
.where(eq(schema.items.id, item.id));
for (const itemSectionLayout of item.layouts) {
await transaction
.update(schema.itemLayouts)
.set({
height: itemSectionLayout.height,
width: itemSectionLayout.width,
xOffset: itemSectionLayout.xOffset,
yOffset: itemSectionLayout.yOffset,
sectionId: itemSectionLayout.sectionId,
})
.where(
and(
eq(schema.itemLayouts.itemId, item.id),
eq(schema.itemLayouts.layoutId, itemSectionLayout.layoutId),
),
);
}
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
await transaction
.update(schema.sections)
.set({
yOffset: section.kind === "empty" ? section.yOffset : null,
xOffset: section.kind === "empty" ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: null,
})
.where(eq(schema.sections.id, section.id));
if (section.kind !== "container") continue;
for (const sectionLayout of section.layouts) {
await transaction
.update(schema.sectionLayouts)
.set({
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
parentSectionId: sectionLayout.parentSectionId,
})
.where(
and(
eq(schema.sectionLayouts.sectionId, section.id),
eq(schema.sectionLayouts.layoutId, sectionLayout.layoutId),
),
);
}
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
await transaction
.delete(schema.integrationItems)
.where(
and(eq(sectionLayouts.sectionId, section.id), eq(sectionLayouts.layoutId, sectionLayout.layoutId)),
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
),
);
}
const removedItems = filterRemovedItems(input.items, dbBoard.items);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
await transaction.delete(schema.items).where(inArray(schema.items.id, itemIds));
}
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) {
await transaction.delete(schema.sections).where(inArray(schema.sections.id, sectionIds));
}
});
},
handleSync(db) {
db.transaction((transaction) => {
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
if (addedSections.length > 0) {
transaction
.insert(sections)
.values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
yOffset: section.kind === "empty" ? section.yOffset : null,
xOffset: section.kind === "empty" ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: null,
boardId: dbBoard.id,
})),
)
.run();
const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "container")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
);
if (sectionLayoutsToInsert.length > 0) {
transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run();
}
}
const addedItems = filterAddedItems(input.items, dbBoard.items);
if (addedItems.length > 0) {
transaction
.insert(items)
.values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
boardId: dbBoard.id,
})),
)
.run();
transaction
.insert(itemLayouts)
.values(
addedItems.flatMap((item) =>
item.layouts.map(
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
height: layoutSection.height,
width: layoutSection.width,
xOffset: layoutSection.xOffset,
yOffset: layoutSection.yOffset,
}),
),
),
)
.run();
}
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
transaction
.delete(integrationItems)
.where(
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
const inputIntegrationRelations = input.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbBoard.items.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
)
.run();
}
);
const removedItems = filterRemovedItems(input.items, dbBoard.items);
if (addedIntegrationRelations.length > 0) {
transaction
.insert(integrationItems)
.values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
)
.run();
}
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
transaction.delete(items).where(inArray(items.id, itemIds)).run();
}
const updatedItems = filterUpdatedItems(input.items, dbBoard.items);
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
for (const item of updatedItems) {
transaction
.update(items)
.set({
kind: item.kind,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
})
.where(eq(items.id, item.id))
.run();
if (sectionIds.length > 0) {
transaction.delete(sections).where(inArray(sections.id, sectionIds)).run();
}
});
},
});
}),
for (const itemSectionLayout of item.layouts) {
transaction
.update(itemLayouts)
.set({
height: itemSectionLayout.height,
width: itemSectionLayout.width,
xOffset: itemSectionLayout.xOffset,
yOffset: itemSectionLayout.yOffset,
sectionId: itemSectionLayout.sectionId,
})
.where(and(eq(itemLayouts.itemId, item.id), eq(itemLayouts.layoutId, itemSectionLayout.layoutId)))
.run();
}
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
transaction
.update(sections)
.set({
yOffset: section.kind === "empty" ? section.yOffset : null,
xOffset: section.kind === "empty" ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: null,
})
.where(eq(sections.id, section.id))
.run();
if (section.kind !== "container") continue;
for (const sectionLayout of section.layouts) {
transaction
.update(sectionLayouts)
.set({
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
parentSectionId: sectionLayout.parentSectionId,
})
.where(
and(eq(sectionLayouts.sectionId, section.id), eq(sectionLayouts.layoutId, sectionLayout.layoutId)),
)
.run();
}
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
transaction
.delete(integrationItems)
.where(
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
),
)
.run();
}
const removedItems = filterRemovedItems(input.items, dbBoard.items);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
transaction.delete(items).where(inArray(items.id, itemIds)).run();
}
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) {
transaction.delete(sections).where(inArray(sections.id, sectionIds)).run();
}
});
},
});
}),
getBoardPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
+96 -198
View File
@@ -8,16 +8,15 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Database } from "@homarr/db";
import { and, eq, handleTransactionsAsync, inArray, like } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { boards, groupMembers, groupPermissions, groups, invites, onboarding, users } from "@homarr/db/schema";
import { groupMembers, groupPermissions, groups, invites, onboarding, users } from "@homarr/db/schema";
import { selectUserSchema } from "@homarr/db/validationSchemas";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { credentialsAdminGroup, supportedAuthProviders } from "@homarr/definitions";
import { byIdSchema } from "@homarr/validation/common";
import type { HeaderPreferences, userBaseCreateSchema } from "@homarr/validation/user";
import type { userBaseCreateSchema } from "@homarr/validation/user";
import {
getHeaderItems,
defaultHeaderPreferences,
headerPreferencesMutationSchema,
parseHeaderPreferences,
userByteUnitSystemSchema,
userChangeColorSchemeSchema,
userChangeHomeBoardsSchema,
@@ -31,8 +30,9 @@ import {
userInitSchema,
userPingIconsEnabledSchema,
userRegistrationApiSchema,
userPreferencesSchema,
userPreferencesPatchSchema,
} from "@homarr/validation/user";
import { serializeHeaderPreferences } from "@homarr/validation/header-preferences";
import { convertIntersectionToZodObject } from "../schema-merger";
import {
@@ -43,13 +43,29 @@ import {
protectedProcedure,
publicProcedure,
} from "../trpc";
import { getAccessibleBoardIdsForUserAsync } from "./board";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import { throwIfCredentialsDisabled } from "./invite/checks";
import { changeSearchPreferencesAsync, changeSearchPreferencesInputSchema } from "./user/change-search-preferences";
import { getUserPreferencesAsync, updateUserPreferencesAsync } from "./user/preferences";
const logger = createLogger({ module: "userRouter" });
const userPreferencesResponseSchema = userPreferencesSchema.extend({ userId: z.string() }).meta({
id: "UserPreferences",
example: {
userId: "example-user",
colorScheme: "light",
byteUnitSystem: "binary",
firstDayOfWeek: 0,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
homeBoardId: null,
mobileHomeBoardId: null,
defaultSearchEngineId: null,
openSearchInNewTab: true,
ddgBangs: true,
headerPreferences: defaultHeaderPreferences,
},
});
export const userRouter = createTRPCRouter({
initUser: onboardingProcedure
.requiresStep("user")
@@ -365,6 +381,26 @@ export const userRouter = createTRPCRouter({
email: user.email,
}));
}),
getPreferences: protectedProcedure
.meta({
openapi: {
method: "GET",
path: "/api/users/preferences",
tags: ["users"],
protect: true,
summary: "Get user preferences",
description:
"Read every preference as structured JSON. Omit userId for the current user; reading another user requires admin permission.",
},
mcp: {
enabled: true,
description:
"Read all preferences, including color scheme, home boards, search behavior and structured header layout. Optional userId defaults to the current user; another user requires admin permission. Use user_updatePreferences to patch supplied fields.",
},
})
.input(z.object({ userId: z.string().min(1).optional() }))
.output(userPreferencesResponseSchema)
.query(({ ctx, input }) => getUserPreferencesAsync(ctx, input.userId ?? ctx.session.user.id)),
getById: protectedProcedure
.input(z.object({ userId: z.string() }))
.output(
@@ -377,6 +413,7 @@ export const userRouter = createTRPCRouter({
provider: true,
homeBoardId: true,
mobileHomeBoardId: true,
colorScheme: true,
byteUnitSystem: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
@@ -419,6 +456,7 @@ export const userRouter = createTRPCRouter({
provider: true,
homeBoardId: true,
mobileHomeBoardId: true,
colorScheme: true,
byteUnitSystem: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
@@ -618,6 +656,37 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, input.userId));
}),
updatePreferences: protectedProcedure
.meta({
openapi: {
method: "PATCH",
path: "/api/users/preferences",
tags: ["users"],
protect: true,
summary: "Update user preferences",
description:
"Update only supplied preferences and return the result. Omitted fields remain unchanged; null clears nullable references. Header fields and zones merge, while each supplied zone array replaces that zone. The complete merged header must retain unique items and account access. New board shortcuts must be accessible to the target user. Omit userId for yourself; another user requires admin permission. All validation completes before one write.",
},
mcp: {
enabled: true,
description:
"Patch one or more user preferences and return the complete result. Read current values with user_getPreferences. All preference fields are optional; supply at least one. Omitted fields remain unchanged; null clears home-board/search-engine references. Header fields and zones merge; supplied zone arrays replace their zone and must preserve unique items and account access. Optional userId defaults to yourself; changing another user requires admin permission.",
},
})
.input(
z
.strictObject({ userId: z.string().min(1).optional(), ...userPreferencesPatchSchema.shape })
.refine((input) => Object.keys(input).some((key) => key !== "userId"), "Supply at least one preference")
.meta({
minProperties: 1,
not: { required: ["userId"], maxProperties: 1 },
}),
)
.output(userPreferencesResponseSchema)
.mutation(({ ctx, input }) => {
const { userId, ...patch } = input;
return updateUserPreferencesAsync(ctx, userId ?? ctx.session.user.id, patch);
}),
changeHomeBoards: protectedProcedure
.input(convertIntersectionToZodObject(userChangeHomeBoardsSchema.and(z.object({ userId: z.string() }))))
.output(z.void())
@@ -633,44 +702,10 @@ export const userRouter = createTRPCRouter({
},
})
.mutation(async ({ input, ctx }) => {
const user = ctx.session.user;
// Only admins can change other users passwords
if (!user.permissions.includes("admin") && user.id !== input.userId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const dbUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
},
where: eq(users.id, input.userId),
await updateUserPreferencesAsync(ctx, input.userId, {
homeBoardId: input.homeBoardId,
mobileHomeBoardId: input.mobileHomeBoardId,
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
// Only allow user to select boards they have access to
if (input.homeBoardId) {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.homeBoardId), "view");
}
if (input.mobileHomeBoardId) {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.mobileHomeBoardId), "view");
}
await ctx.db
.update(users)
.set({
homeBoardId: input.homeBoardId,
mobileHomeBoardId: input.mobileHomeBoardId,
})
.where(eq(users.id, input.userId));
}),
changeDefaultSearchEngine: protectedProcedure
.input(
@@ -692,13 +727,13 @@ export const userRouter = createTRPCRouter({
},
})
.mutation(async ({ input, ctx }) => {
await changeSearchPreferencesAsync(ctx.db, ctx.session, {
...input,
openInNewTab: undefined,
await updateUserPreferencesAsync(ctx, input.userId, {
defaultSearchEngineId: input.defaultSearchEngineId,
ddgBangs: input.ddgBangsEnabled,
});
}),
changeSearchPreferences: protectedProcedure
.input(convertIntersectionToZodObject(changeSearchPreferencesInputSchema))
.input(convertIntersectionToZodObject(userChangeSearchPreferencesSchema.and(z.object({ userId: z.string() }))))
.output(z.void())
.meta({
openapi: {
@@ -712,7 +747,11 @@ export const userRouter = createTRPCRouter({
},
})
.mutation(async ({ input, ctx }) => {
await changeSearchPreferencesAsync(ctx.db, ctx.session, input);
await updateUserPreferencesAsync(ctx, input.userId, {
defaultSearchEngineId: input.defaultSearchEngineId,
openSearchInNewTab: input.openInNewTab,
ddgBangs: input.ddgBangsEnabled,
});
}),
changeColorScheme: protectedProcedure
.input(userChangeColorSchemeSchema)
@@ -728,44 +767,13 @@ export const userRouter = createTRPCRouter({
},
})
.mutation(async ({ input, ctx }) => {
await ctx.db
.update(users)
.set({
colorScheme: input.colorScheme,
})
.where(eq(users.id, ctx.session.user.id));
await updateUserPreferencesAsync(ctx, ctx.session.user.id, { colorScheme: input.colorScheme });
}),
changeByteUnitSystem: protectedProcedure
.input(userByteUnitSystemSchema.and(byIdSchema))
.output(z.void())
.mutation(async ({ input, ctx }) => {
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const dbUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
},
where: eq(users.id, input.id),
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
byteUnitSystem: input.byteUnitSystem,
})
.where(eq(users.id, input.id));
await updateUserPreferencesAsync(ctx, input.id, { byteUnitSystem: input.byteUnitSystem });
}),
changeEnableRightClickOnWidgets: protectedProcedure
.meta({
@@ -782,37 +790,12 @@ export const userRouter = createTRPCRouter({
.input(convertIntersectionToZodObject(userEnableRightClickOnWidgetsSchema.and(byIdSchema)))
.output(z.void())
.mutation(async ({ input, ctx }) => {
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
enableRightClickOnWidgets: input.enableRightClickOnWidgets,
})
.where(eq(users.id, input.id));
await updateUserPreferencesAsync(ctx, input.id, { enableRightClickOnWidgets: input.enableRightClickOnWidgets });
}),
changePingIconsEnabled: protectedProcedure
.input(userPingIconsEnabledSchema.and(byIdSchema))
.mutation(async ({ input, ctx }) => {
// Only admins can change other users ping icons enabled
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
pingIconsEnabled: input.pingIconsEnabled,
})
.where(eq(users.id, input.id));
await updateUserPreferencesAsync(ctx, input.id, { pingIconsEnabled: input.pingIconsEnabled });
}),
changeHeaderPreferences: protectedProcedure
.meta({
@@ -825,54 +808,11 @@ export const userRouter = createTRPCRouter({
description:
"Replace a user's header layout and display preferences. New board shortcuts must reference boards the target user can view; existing shortcuts may be retained. Users can update themselves; updating another user requires admin permission.",
},
mcp: {
enabled: true,
description:
"Update the header layout preferences of a user. REQUIRED: id (user ID), headerPreferences (zones for left, center and right, visible flag, searchDisplay and logoDisplay). Admins can change any user; other users can only change their own. Board shortcut items must reference boards the target user can view",
},
})
.input(z.object({ id: z.string(), headerPreferences: headerPreferencesMutationSchema }))
.output(z.void())
.mutation(async ({ input, ctx }) => {
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const targetUser = await ctx.db.query.users.findFirst({
columns: { headerPreferences: true },
where: eq(users.id, input.id),
});
if (!targetUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const submittedBoardIds = getBoardShortcutIds(input.headerPreferences);
if (submittedBoardIds.length > 0) {
const existingBoardIds = new Set(getBoardShortcutIds(parseHeaderPreferences(targetUser.headerPreferences)));
const accessibleBoardIds = await getAccessibleBoardIdsForUserAsync(ctx.db, input.id);
const hasUnavailableShortcut = submittedBoardIds.some(
(boardId) => !accessibleBoardIds.has(boardId) && !existingBoardIds.has(boardId),
);
if (hasUnavailableShortcut) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "One or more board shortcuts are unavailable to this user",
});
}
}
await ctx.db
.update(users)
.set({
headerPreferences: serializeHeaderPreferences(input.headerPreferences),
})
.where(eq(users.id, input.id));
await updateUserPreferencesAsync(ctx, input.id, { headerPreferences: input.headerPreferences });
}),
changeDdgBangs: protectedProcedure
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
@@ -890,19 +830,7 @@ export const userRouter = createTRPCRouter({
},
})
.mutation(async ({ input, ctx }) => {
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
ddgBangs: input.ddgBangs,
})
.where(eq(users.id, input.id));
await updateUserPreferencesAsync(ctx, input.id, { ddgBangs: input.ddgBangs });
}),
changeFirstDayOfWeek: protectedProcedure
.input(convertIntersectionToZodObject(userFirstDayOfWeekSchema.and(byIdSchema)))
@@ -919,34 +847,7 @@ export const userRouter = createTRPCRouter({
},
})
.mutation(async ({ input, ctx }) => {
// Only admins can change other users first day of week
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const dbUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
},
where: eq(users.id, input.id),
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
firstDayOfWeek: input.firstDayOfWeek,
})
.where(eq(users.id, input.id));
await updateUserPreferencesAsync(ctx, input.id, { firstDayOfWeek: input.firstDayOfWeek });
}),
completeTour: protectedProcedure
.input(z.object({ tour: z.enum(["manage", "board"]) }))
@@ -990,9 +891,6 @@ export const userRouter = createTRPCRouter({
}),
});
const getBoardShortcutIds = (preferences: HeaderPreferences) =>
getHeaderItems(preferences.zones).flatMap((item) => (item.type === "board" ? [item.boardId] : []));
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => {
const hashedPassword = await hashPasswordAsync(input.password);
@@ -1,54 +0,0 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import type { Session } from "@homarr/auth";
import type { Modify } from "@homarr/common/types";
import { eq } from "@homarr/db";
import type { Database } from "@homarr/db";
import { users } from "@homarr/db/schema";
import { userChangeSearchPreferencesSchema } from "@homarr/validation/user";
export const changeSearchPreferencesInputSchema = userChangeSearchPreferencesSchema.and(
z.object({ userId: z.string() }),
);
export const changeSearchPreferencesAsync = async (
db: Database,
session: Session,
input: Modify<
z.infer<typeof changeSearchPreferencesInputSchema>,
{ openInNewTab: boolean | undefined; ddgBangsEnabled: boolean | undefined }
>,
) => {
const user = session.user;
// Only admins can change other users search preferences
if (!user.permissions.includes("admin") && user.id !== input.userId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const dbUser = await 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 db
.update(users)
.set({
defaultSearchEngineId: input.defaultSearchEngineId,
openSearchInNewTab: input.openInNewTab,
ddgBangs: input.ddgBangsEnabled,
})
.where(eq(users.id, input.userId));
};
+103
View File
@@ -0,0 +1,103 @@
import { TRPCError } from "@trpc/server";
import type { z } from "zod/v4";
import type { Session } from "@homarr/auth";
import type { Database, InferInsertModel } from "@homarr/db";
import { eq } from "@homarr/db";
import { boards, searchEngines, users } from "@homarr/db/schema";
import { userPreferencesPatchSchema, userPreferencesSchema } from "@homarr/validation/user";
import {
getHeaderItems,
parseHeaderPreferences,
serializeHeaderPreferences,
} from "@homarr/validation/header-preferences";
import { getAccessibleBoardIdsForUserAsync } from "../board";
import { throwIfActionForbiddenAsync } from "../board/board-access";
interface PreferencesContext {
db: Database;
session: Session;
}
const definedFields = <TValue extends object>(value: TValue): TValue =>
Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as TValue;
export async function getUserPreferencesAsync(ctx: PreferencesContext, userId: string) {
if (ctx.session.user.id !== userId && !ctx.session.user.permissions.includes("admin"))
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
const row = await ctx.db.query.users.findFirst({
where: eq(users.id, userId),
columns: {
colorScheme: true,
byteUnitSystem: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
homeBoardId: true,
mobileHomeBoardId: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
headerPreferences: true,
},
});
if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
return { userId, ...row, headerPreferences: parseHeaderPreferences(row.headerPreferences) };
}
export async function updateUserPreferencesAsync(
ctx: PreferencesContext,
userId: string,
input: z.infer<typeof userPreferencesPatchSchema>,
) {
const current = await getUserPreferencesAsync(ctx, userId);
const parsed = userPreferencesPatchSchema.safeParse(definedFields(input));
if (!parsed.success) throw new TRPCError({ code: "BAD_REQUEST", message: parsed.error.message, cause: parsed.error });
const patch = parsed.data;
const { headerPreferences, ...fields } = patch;
const mergedHeader = {
...current.headerPreferences,
...definedFields(headerPreferences ?? {}),
zones: { ...current.headerPreferences.zones, ...definedFields(headerPreferences?.zones ?? {}) },
};
const { userId: _userId, ...preferences } = current;
const mergedResult = userPreferencesSchema.safeParse({ ...preferences, ...fields, headerPreferences: mergedHeader });
if (!mergedResult.success)
throw new TRPCError({ code: "BAD_REQUEST", message: mergedResult.error.message, cause: mergedResult.error });
const merged = mergedResult.data;
for (const boardId of [fields.homeBoardId, fields.mobileHomeBoardId]) {
if (boardId) await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view");
}
if (fields.defaultSearchEngineId) {
const engine = await ctx.db.query.searchEngines.findFirst({
columns: { id: true },
where: eq(searchEngines.id, fields.defaultSearchEngineId),
});
if (!engine) throw new TRPCError({ code: "BAD_REQUEST", message: "Search engine not found" });
}
if (headerPreferences) {
const existingBoardIds = new Set(
getHeaderItems(current.headerPreferences.zones).flatMap((item) => (item.type === "board" ? [item.boardId] : [])),
);
const newBoardIds = getHeaderItems(merged.headerPreferences.zones).flatMap((item) => {
if (item.type !== "board" || existingBoardIds.has(item.boardId)) return [];
return [item.boardId];
});
if (newBoardIds.length > 0) {
const accessible = await getAccessibleBoardIdsForUserAsync(ctx.db, userId);
if (newBoardIds.some((boardId) => !accessible.has(boardId)))
throw new TRPCError({
code: "BAD_REQUEST",
message: "One or more board shortcuts are unavailable to this user",
});
}
}
// All references and the merged header are validated before the single preference write.
const values: Partial<InferInsertModel<typeof users>> = { ...fields };
if (headerPreferences) values.headerPreferences = serializeHeaderPreferences(merged.headerPreferences);
await ctx.db.update(users).set(values).where(eq(users.id, userId));
return { userId, ...merged };
}
+5 -2
View File
@@ -24,6 +24,7 @@ const MCP_TOOL_ALLOWLIST = {
"beszel_getSystemStats",
"board_catalog",
"board_getAllBoards",
"board_getBoardByName",
"board_getBoardSettings",
"board_search",
"calendar_findAllEvents",
@@ -85,6 +86,7 @@ const MCP_TOOL_ALLOWLIST = {
"traefik_getDashboard",
"user_getAll",
"user_getById",
"user_getPreferences",
"widgetSecrets_getConfiguredKinds",
"wud_getStats",
],
@@ -100,6 +102,8 @@ const MCP_TOOL_ALLOWLIST = {
"board_duplicateBoard",
"board_renameBoard",
"board_resetLayout",
"board_saveBoard",
"board_saveLayouts",
"board_savePartialBoardSettings",
"board_setHomeBoard",
"board_setMobileHomeBoard",
@@ -127,11 +131,10 @@ const MCP_TOOL_ALLOWLIST = {
"smartHome_executeAutomation",
"smartHome_switchEntity",
"stats_refresh",
"user_changeHeaderPreferences",
"user_updatePreferences",
"user_delete",
],
secret: [
"apiKeys_create",
"apiKeys_delete",
"customWidget_configurationRequestUser",
"customWidget_create",
@@ -1,6 +1,7 @@
// This file is auto-generated by the codegen script
// it uses the sitemap.xml to generate the HomarrDocumentationPath type
export type HomarrDocumentationPath =
| "/search"
| ""
| "/docs"
| "/docs/advanced"
+4 -2
View File
@@ -18,7 +18,8 @@ const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
const hexColorNullableSchema = hexColorSchema
.or(z.literal(""))
.nullable()
.transform((value) => (value?.trim().length === 0 ? null : value));
.transform((value) => (value?.trim().length === 0 ? null : value))
.pipe(z.string().nullable());
export const boardNameSchema = z
.string()
@@ -50,7 +51,8 @@ export const boardChangeVisibilitySchema = z.object({
const trimmedNullableString = z
.string()
.nullable()
.transform((value) => (value?.trim().length === 0 ? null : value));
.transform((value) => (value?.trim().length === 0 ? null : value))
.pipe(z.string().nullable());
export const boardSavePartialSettingsSchema = z
.object({
@@ -111,6 +111,20 @@ export const headerPreferencesSchema = z
export type HeaderPreferences = z.infer<typeof headerPreferencesSchema>;
export const headerPreferencesPatchSchema = z.strictObject({
version: z.literal(headerPreferencesVersion).optional(),
visible: headerPreferencesSchema.shape.visible.optional(),
searchDisplay: headerPreferencesSchema.shape.searchDisplay.optional(),
logoDisplay: headerPreferencesSchema.shape.logoDisplay.removeDefault().optional(),
zones: z
.strictObject({
left: headerItemListSchema.optional(),
center: headerItemListSchema.optional(),
right: headerItemListSchema.optional(),
})
.optional(),
});
const legacyHeaderItemIds = ["logo", "search", "boardSwitcher", "assistant", "docker", "user"] as const;
type LegacyHeaderItemId = (typeof legacyHeaderItemIds)[number];
+20 -6
View File
@@ -1,4 +1,3 @@
import type { DayOfWeek } from "@mantine/dates";
import { z } from "zod/v4";
import { colorSchemes } from "@homarr/definitions";
@@ -7,6 +6,7 @@ import type { TranslationObject } from "@homarr/translation";
import { zodEnumFromArray } from "./enums";
import { createCustomErrorParams } from "./form/i18n";
import { nullableEmailSchema, optionalEmailSchema } from "./email";
import { headerPreferencesSchema, headerPreferencesPatchSchema } from "./header-preferences";
export {
createBoardHeaderItem,
@@ -159,11 +159,7 @@ export const userByteUnitSystemSchema = z.object({
});
export const userFirstDayOfWeekSchema = z.object({
firstDayOfWeek: z
.number()
.min(0)
.max(6)
.transform((value) => value as DayOfWeek),
firstDayOfWeek: z.literal([0, 1, 2, 3, 4, 5, 6]),
});
export const userPingIconsEnabledSchema = z.object({
@@ -177,3 +173,21 @@ export const userEnableRightClickOnWidgetsSchema = z.object({
export const userDdgBangsSchema = z.object({
ddgBangs: z.boolean(),
});
export const userPreferencesSchema = z.strictObject({
...userChangeColorSchemeSchema.shape,
...userByteUnitSystemSchema.shape,
...userFirstDayOfWeekSchema.shape,
...userPingIconsEnabledSchema.shape,
...userEnableRightClickOnWidgetsSchema.shape,
...userChangeHomeBoardsSchema.shape,
defaultSearchEngineId: userChangeSearchPreferencesSchema.shape.defaultSearchEngineId,
openSearchInNewTab: z.boolean(),
ddgBangs: z.boolean(),
headerPreferences: headerPreferencesSchema,
});
export const userPreferencesPatchSchema = userPreferencesSchema
.partial()
.extend({ headerPreferences: headerPreferencesPatchSchema.optional() })
.refine((patch) => Object.keys(patch).length > 0, "Supply at least one preference");