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:
14 files changed
+2680
-646
No files matched your search
@@ -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.
|
||||
|
||||

|
||||
|
||||
## Authentication and browser access
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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");
|
||||
Reference in new issue
Block a user