diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index c01e17bdf..2e9c4c6f5 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -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 diff --git a/apps/docs/public/api/open-api-schema.json b/apps/docs/public/api/open-api-schema.json index e0e67aebb..2f1c81211 100644 --- a/apps/docs/public/api/open-api-schema.json +++ b/apps/docs/public/api/open-api-schema.json @@ -9,6 +9,40 @@ "url": "http://localhost:3000" } ], + "tags": [ + { + "name": "boards", + "x-displayName": "Build dashboards" + }, + { + "name": "apps", + "x-displayName": "Manage apps" + }, + { + "name": "users", + "x-displayName": "Manage users and preferences" + }, + { + "name": "settings", + "x-displayName": "Configure the instance" + }, + { + "name": "integrations", + "x-displayName": "Connect integrations" + }, + { + "name": "invites", + "x-displayName": "Manage invitations" + }, + { + "name": "certificates", + "x-displayName": "Manage trusted certificates" + }, + { + "name": "info", + "x-displayName": "Inspect Homarr" + } + ], "externalDocs": { "url": "https://homarr.dev" }, @@ -2025,6 +2059,246 @@ } }, "/api/boards/{id}/settings": { + "get": { + "operationId": "boardRouter-getBoardSettings", + "summary": "Read board settings", + "description": "Read editable board settings. Requires modify access to the board.", + "tags": [ + "boards" + ], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pageTitle": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metaTitle": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "logoImageUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "faviconImageUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "backgroundImageUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "backgroundImageAttachment": { + "type": "string", + "enum": [ + "fixed", + "scroll" + ] + }, + "backgroundImageRepeat": { + "type": "string", + "enum": [ + "repeat", + "repeat-x", + "repeat-y", + "no-repeat" + ] + }, + "backgroundImageSize": { + "type": "string", + "enum": [ + "cover", + "contain" + ] + }, + "primaryColor": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "secondaryColor": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "customCss": { + "type": "string", + "maxLength": 16384 + }, + "iconColor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "itemRadius": { + "anyOf": [ + { + "type": "string", + "const": "xs" + }, + { + "type": "string", + "const": "sm" + }, + { + "type": "string", + "const": "md" + }, + { + "type": "string", + "const": "lg" + }, + { + "type": "string", + "const": "xl" + } + ] + }, + "disableStatus": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "pageTitle", + "metaTitle", + "logoImageUrl", + "faviconImageUrl", + "backgroundImageUrl", + "backgroundImageAttachment", + "backgroundImageRepeat", + "backgroundImageSize", + "primaryColor", + "secondaryColor", + "opacity", + "customCss", + "iconColor", + "itemRadius", + "disableStatus", + "id", + "name" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "401": { + "description": "Authorization not provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } + }, + "403": { + "description": "Insufficient access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + }, "patch": { "operationId": "boardRouter-savePartialBoardSettings", "summary": "Update board settings", @@ -2257,6 +2531,840 @@ } } }, + "/api/boards/{id}/layouts": { + "put": { + "operationId": "boardRouter-saveLayouts", + "summary": "Save dashboard layouts", + "description": "Save the same responsive layout settings used by the dashboard editor. Requires modify access.", + "tags": [ + "boards" + ], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "layouts": { + "minItems": 2, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "columnCount": { + "type": "number", + "minimum": 1, + "maximum": 24 + }, + "leftGutterColumnCount": { + "default": 0, + "type": "integer", + "minimum": 0, + "maximum": 3 + }, + "rightGutterColumnCount": { + "default": 0, + "type": "integer", + "minimum": 0, + "maximum": 3 + }, + "breakpoint": { + "type": "integer", + "minimum": 0, + "maximum": 32767 + }, + "role": { + "type": "string", + "enum": [ + "mobile", + "base", + "custom" + ] + } + }, + "required": [ + "id", + "name", + "columnCount", + "breakpoint", + "role" + ] + } + } + }, + "required": [ + "layouts" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "minItems": 2, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "columnCount": { + "type": "number", + "minimum": 1, + "maximum": 24 + }, + "leftGutterColumnCount": { + "default": 0, + "type": "integer", + "minimum": 0, + "maximum": 3 + }, + "rightGutterColumnCount": { + "default": 0, + "type": "integer", + "minimum": 0, + "maximum": 3 + }, + "breakpoint": { + "type": "integer", + "minimum": 0, + "maximum": 32767 + }, + "role": { + "type": "string", + "enum": [ + "mobile", + "base", + "custom" + ] + } + }, + "required": [ + "id", + "name", + "columnCount", + "leftGutterColumnCount", + "rightGutterColumnCount", + "breakpoint", + "role" + ], + "additionalProperties": false + } + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "401": { + "description": "Authorization not provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } + }, + "403": { + "description": "Insufficient access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + } + }, + "/api/boards/{boardId}/layouts/{layoutId}/reset": { + "post": { + "operationId": "boardRouter-resetLayout", + "summary": "Reset a dashboard layout", + "description": "Reset a Mobile or custom layout from the Base layout. Requires modify access.", + "tags": [ + "boards" + ], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "boardId", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "layoutId", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "401": { + "description": "Authorization not provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } + }, + "403": { + "description": "Insufficient access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + } + }, + "/api/boards/{id}/content": { + "put": { + "operationId": "boardRouter-saveBoard", + "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.", + "tags": [ + "boards" + ], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sections": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string", + "const": "empty" + }, + "yOffset": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "xOffset": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "id", + "kind", + "yOffset", + "xOffset" + ] + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string", + "const": "container" + }, + "options": { + "default": { + "title": "", + "customCssClasses": [], + "borderColor": "", + "showLabel": true, + "collapsible": false, + "showOpenAll": false, + "scrollable": false + }, + "type": "object", + "properties": { + "title": { + "default": "", + "type": "string", + "maxLength": 64 + }, + "customCssClasses": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "borderColor": { + "default": "", + "type": "string" + }, + "showLabel": { + "default": true, + "type": "boolean" + }, + "collapsible": { + "default": false, + "type": "boolean" + }, + "showOpenAll": { + "default": false, + "type": "boolean" + }, + "scrollable": { + "default": false, + "type": "boolean" + } + } + }, + "collapsed": { + "default": false, + "type": "boolean" + }, + "layouts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "layoutId": { + "type": "string" + }, + "yOffset": { + "type": "integer", + "minimum": 0, + "maximum": 32767 + }, + "xOffset": { + "type": "integer", + "minimum": 0, + "maximum": 32767 + }, + "width": { + "type": "integer", + "minimum": 1, + "maximum": 32767 + }, + "height": { + "type": "integer", + "minimum": 1, + "maximum": 32767 + }, + "parentSectionId": { + "type": "string" + } + }, + "required": [ + "layoutId", + "yOffset", + "xOffset", + "width", + "height", + "parentSectionId" + ] + } + } + }, + "required": [ + "id", + "kind", + "layouts" + ] + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string", + "const": "dynamic" + }, + "options": { + "default": { + "title": "", + "customCssClasses": [], + "borderColor": "", + "showLabel": true, + "collapsible": false, + "showOpenAll": false, + "scrollable": false + }, + "type": "object", + "properties": { + "title": { + "default": "", + "type": "string", + "maxLength": 64 + }, + "customCssClasses": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "borderColor": { + "default": "", + "type": "string" + }, + "showLabel": { + "default": true, + "type": "boolean" + }, + "collapsible": { + "default": false, + "type": "boolean" + }, + "showOpenAll": { + "default": false, + "type": "boolean" + }, + "scrollable": { + "default": false, + "type": "boolean" + } + } + }, + "collapsed": { + "default": false, + "type": "boolean" + }, + "layouts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "layoutId": { + "type": "string" + }, + "yOffset": { + "type": "integer", + "minimum": 0, + "maximum": 32767 + }, + "xOffset": { + "type": "integer", + "minimum": 0, + "maximum": 32767 + }, + "width": { + "type": "integer", + "minimum": 1, + "maximum": 32767 + }, + "height": { + "type": "integer", + "minimum": 1, + "maximum": 32767 + }, + "parentSectionId": { + "type": "string" + } + }, + "required": [ + "layoutId", + "yOffset", + "xOffset", + "width", + "height", + "parentSectionId" + ] + } + } + }, + "required": [ + "id", + "kind", + "layouts" + ] + } + ] + } + }, + "items": { + "type": "array", + "items": { + "allOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "stats", + "clock", + "weather", + "airQuality", + "countdown", + "timer", + "app", + "iframe", + "video", + "notebook", + "anchorNote", + "dnsHoleSummary", + "dnsHoleControls", + "smartHome-entityState", + "smartHome-executeAutomation", + "stockPrice", + "mediaServer", + "calendar", + "downloads", + "mediaRequests-requestList", + "mediaRequests-requestStats", + "mediaTranscoding", + "mediaMissing", + "minecraftServerStatus", + "networkControllerSummary", + "networkControllerStatus", + "rssFeed", + "bookmarks", + "indexerManager", + "healthMonitoring", + "releases", + "mediaReleases", + "dockerContainers", + "firewall", + "notifications", + "systemResources", + "coolify", + "systemDisks", + "timetable", + "immich-serverStats", + "immich-albumCarousel", + "paperlessNgx", + "patchmon", + "bazarr", + "tracearr", + "speedtestTracker", + "uptimeKuma", + "audioStats", + "umami", + "vpn", + "archiveTeamWarrior", + "ups", + "beszelSystemTable", + "beszelSystemGrid", + "beszelAlerts", + "beszelSystemStats", + "traefik", + "customApi", + "assistant", + "wud", + "llamacpp", + "wazuhAgents", + "wazuhAlerts", + "wazuhSummary", + "wazuhTimeline", + "wazuhTopList", + "wazuhVulnerabilities", + "wazuhFim", + "wazuhAuthFailures" + ] + }, + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "kind", + "options" + ] + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "layouts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "layoutId": { + "type": "string" + }, + "yOffset": { + "type": "integer", + "minimum": 0, + "maximum": 32767 + }, + "xOffset": { + "type": "integer", + "minimum": 0, + "maximum": 32767 + }, + "width": { + "type": "integer", + "minimum": 1, + "maximum": 32767 + }, + "height": { + "type": "integer", + "minimum": 1, + "maximum": 32767 + }, + "sectionId": { + "type": "string" + } + }, + "required": [ + "layoutId", + "yOffset", + "xOffset", + "width", + "height", + "sectionId" + ] + } + }, + "integrationIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "advancedOptions": { + "type": "object", + "properties": { + "title": { + "default": null, + "anyOf": [ + { + "type": "string", + "maxLength": 64 + }, + { + "type": "null" + } + ] + }, + "customCssClasses": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "borderColor": { + "default": "", + "type": "string" + } + } + } + }, + "required": [ + "id", + "layouts", + "integrationIds", + "advancedOptions" + ] + } + ] + } + } + }, + "required": [ + "sections", + "items" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "401": { + "description": "Authorization not provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } + }, + "403": { + "description": "Insufficient access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + } + }, "/api/boards/items": { "post": { "operationId": "boardRouter-addItem", @@ -3845,6 +4953,470 @@ } } }, + "/api/users/preferences": { + "get": { + "operationId": "userRouter-getPreferences", + "summary": "Get user preferences", + "description": "Read every preference as structured JSON. Omit userId for the current user; reading another user requires admin permission.", + "tags": [ + "users" + ], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "userId", + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPreferences" + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "401": { + "description": "Authorization not provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } + }, + "403": { + "description": "Insufficient access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + }, + "patch": { + "operationId": "userRouter-updatePreferences", + "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.", + "tags": [ + "users" + ], + "security": [ + { + "apikey": [] + } + ], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "minLength": 1 + }, + "colorScheme": { + "type": "string", + "enum": [ + "auto", + "light", + "dark" + ] + }, + "byteUnitSystem": { + "type": "string", + "enum": [ + "binary", + "decimal" + ] + }, + "firstDayOfWeek": { + "type": "number", + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ] + }, + "pingIconsEnabled": { + "type": "boolean" + }, + "enableRightClickOnWidgets": { + "type": "boolean" + }, + "homeBoardId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "mobileHomeBoardId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "defaultSearchEngineId": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "openSearchInNewTab": { + "type": "boolean" + }, + "ddgBangs": { + "type": "boolean" + }, + "headerPreferences": { + "type": "object", + "properties": { + "version": { + "type": "number", + "const": 3 + }, + "visible": { + "type": "boolean" + }, + "searchDisplay": { + "type": "string", + "enum": [ + "input", + "icon" + ] + }, + "logoDisplay": { + "type": "string", + "enum": [ + "logo", + "logoAndText" + ] + }, + "zones": { + "type": "object", + "properties": { + "left": { + "maxItems": 100, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "builtin" + }, + "id": { + "type": "string", + "enum": [ + "logo", + "search", + "home", + "boardSwitcher", + "assistant", + "docker", + "boardEdit", + "boardSettings", + "settings", + "themeToggle", + "user" + ] + } + }, + "required": [ + "type", + "id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "board" + }, + "boardId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "required": [ + "type", + "boardId" + ], + "additionalProperties": false + } + ] + } + }, + "center": { + "maxItems": 100, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "builtin" + }, + "id": { + "type": "string", + "enum": [ + "logo", + "search", + "home", + "boardSwitcher", + "assistant", + "docker", + "boardEdit", + "boardSettings", + "settings", + "themeToggle", + "user" + ] + } + }, + "required": [ + "type", + "id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "board" + }, + "boardId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "required": [ + "type", + "boardId" + ], + "additionalProperties": false + } + ] + } + }, + "right": { + "maxItems": 100, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "builtin" + }, + "id": { + "type": "string", + "enum": [ + "logo", + "search", + "home", + "boardSwitcher", + "assistant", + "docker", + "boardEdit", + "boardSettings", + "settings", + "themeToggle", + "user" + ] + } + }, + "required": [ + "type", + "id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "board" + }, + "boardId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "required": [ + "type", + "boardId" + ], + "additionalProperties": false + } + ] + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "minProperties": 1, + "not": { + "required": [ + "userId" + ], + "maxProperties": 1 + } + }, + "example": { + "colorScheme": "light" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPreferences" + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "401": { + "description": "Authorization not provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } + }, + "403": { + "description": "Insufficient access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + } + }, "/api/users/{userId}": { "get": { "operationId": "userRouter-getById", @@ -3942,6 +5514,9 @@ } ] }, + "colorScheme": { + "type": "string" + }, "byteUnitSystem": { "type": "string", "enum": [ @@ -3995,6 +5570,7 @@ "provider", "homeBoardId", "mobileHomeBoardId", + "colorScheme", "byteUnitSystem", "firstDayOfWeek", "pingIconsEnabled", @@ -5468,8 +7044,15 @@ "properties": { "firstDayOfWeek": { "type": "number", - "minimum": 0, - "maximum": 6 + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ] }, "id": { "type": "string" @@ -6573,6 +8156,366 @@ "issues": [] } }, + "UserPreferences": { + "type": "object", + "properties": { + "colorScheme": { + "type": "string", + "enum": [ + "auto", + "light", + "dark" + ] + }, + "byteUnitSystem": { + "type": "string", + "enum": [ + "binary", + "decimal" + ] + }, + "firstDayOfWeek": { + "type": "number", + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ] + }, + "pingIconsEnabled": { + "type": "boolean" + }, + "enableRightClickOnWidgets": { + "type": "boolean" + }, + "homeBoardId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "mobileHomeBoardId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "defaultSearchEngineId": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "openSearchInNewTab": { + "type": "boolean" + }, + "ddgBangs": { + "type": "boolean" + }, + "headerPreferences": { + "type": "object", + "properties": { + "version": { + "type": "number", + "const": 3 + }, + "visible": { + "type": "boolean" + }, + "searchDisplay": { + "type": "string", + "enum": [ + "input", + "icon" + ] + }, + "logoDisplay": { + "default": "logoAndText", + "type": "string", + "enum": [ + "logo", + "logoAndText" + ] + }, + "zones": { + "type": "object", + "properties": { + "left": { + "maxItems": 100, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "builtin" + }, + "id": { + "type": "string", + "enum": [ + "logo", + "search", + "home", + "boardSwitcher", + "assistant", + "docker", + "boardEdit", + "boardSettings", + "settings", + "themeToggle", + "user" + ] + } + }, + "required": [ + "type", + "id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "board" + }, + "boardId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "required": [ + "type", + "boardId" + ], + "additionalProperties": false + } + ], + "type": "object" + } + }, + "center": { + "maxItems": 100, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "builtin" + }, + "id": { + "type": "string", + "enum": [ + "logo", + "search", + "home", + "boardSwitcher", + "assistant", + "docker", + "boardEdit", + "boardSettings", + "settings", + "themeToggle", + "user" + ] + } + }, + "required": [ + "type", + "id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "board" + }, + "boardId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "required": [ + "type", + "boardId" + ], + "additionalProperties": false + } + ], + "type": "object" + } + }, + "right": { + "maxItems": 100, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "builtin" + }, + "id": { + "type": "string", + "enum": [ + "logo", + "search", + "home", + "boardSwitcher", + "assistant", + "docker", + "boardEdit", + "boardSettings", + "settings", + "themeToggle", + "user" + ] + } + }, + "required": [ + "type", + "id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "board" + }, + "boardId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "required": [ + "type", + "boardId" + ], + "additionalProperties": false + } + ], + "type": "object" + } + } + }, + "required": [ + "left", + "center", + "right" + ], + "additionalProperties": false + } + }, + "required": [ + "version", + "visible", + "searchDisplay", + "logoDisplay", + "zones" + ], + "additionalProperties": false + }, + "userId": { + "type": "string" + } + }, + "required": [ + "colorScheme", + "byteUnitSystem", + "firstDayOfWeek", + "pingIconsEnabled", + "enableRightClickOnWidgets", + "homeBoardId", + "mobileHomeBoardId", + "defaultSearchEngineId", + "openSearchInNewTab", + "ddgBangs", + "headerPreferences", + "userId" + ], + "additionalProperties": false, + "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": { + "version": 3, + "visible": true, + "searchDisplay": "input", + "logoDisplay": "logoAndText", + "zones": { + "left": [ + { + "type": "builtin", + "id": "logo" + } + ], + "center": [ + { + "type": "builtin", + "id": "search" + } + ], + "right": [ + { + "type": "builtin", + "id": "boardEdit" + }, + { + "type": "builtin", + "id": "boardSettings" + }, + { + "type": "builtin", + "id": "user" + } + ] + } + } + } + }, "__schema1": { "anyOf": [ { diff --git a/apps/docs/src/app/api-reference/page.tsx b/apps/docs/src/app/api-reference/page.tsx index d7f0386d4..8a019d09b 100644 --- a/apps/docs/src/app/api-reference/page.tsx +++ b/apps/docs/src/app/api-reference/page.tsx @@ -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 ( @@ -25,12 +25,26 @@ export default function ApiReferenceIndexPage() { review examples or send a test request from your browser.

-
- {pages.map((page) => ( - - {page.data.title} - - +
+ {groups.map((group) => ( +
+

{group.name}

+
+ {group.children.map((page) => { + if (page.type !== "page") return null; + return ( + + {page.name} + + + ); + })} +
+
))}
diff --git a/packages/api/src/open-api.ts b/packages/api/src/open-api.ts index cecd5a3ea..3e384d883 100644 --- a/packages/api/src/open-api.ts +++ b/packages/api/src/open-api.ts @@ -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; }; diff --git a/packages/api/src/router/apiKeys.ts b/packages/api/src/router/apiKeys.ts index 26bbb4552..5d5562254 100644 --- a/packages/api/src/router/apiKeys.ts +++ b/packages/api/src/router/apiKeys.ts @@ -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)", }, }) diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 6249d4db4..81e8c87a3 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -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 | 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 => ({ - 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 => ({ - 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 | 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 => ({ - 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 => ({ + 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 => ({ + (layoutSection): InferInsertModel => ({ 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 => ({ + 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 => ({ + 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"); diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index ec7839742..33683e459 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -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, "groupIds">) => { const hashedPassword = await hashPasswordAsync(input.password); diff --git a/packages/api/src/router/user/change-search-preferences.ts b/packages/api/src/router/user/change-search-preferences.ts deleted file mode 100644 index 41b69bfa4..000000000 --- a/packages/api/src/router/user/change-search-preferences.ts +++ /dev/null @@ -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, - { 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)); -}; diff --git a/packages/api/src/router/user/preferences.ts b/packages/api/src/router/user/preferences.ts new file mode 100644 index 000000000..2ea3fab18 --- /dev/null +++ b/packages/api/src/router/user/preferences.ts @@ -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 = (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, +) { + 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> = { ...fields }; + if (headerPreferences) values.headerPreferences = serializeHeaderPreferences(merged.headerPreferences); + await ctx.db.update(users).set(values).where(eq(users.id, userId)); + return { userId, ...merged }; +} diff --git a/packages/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts index 7b4bb3f15..27be6b291 100644 --- a/packages/api/src/test/mcp.spec.ts +++ b/packages/api/src/test/mcp.spec.ts @@ -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", diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 31464816e..6e26e995c 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -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" diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts index da76e7ecf..7b98562ab 100644 --- a/packages/validation/src/board.ts +++ b/packages/validation/src/board.ts @@ -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({ diff --git a/packages/validation/src/header-preferences.ts b/packages/validation/src/header-preferences.ts index 1235809b5..c4270a869 100644 --- a/packages/validation/src/header-preferences.ts +++ b/packages/validation/src/header-preferences.ts @@ -111,6 +111,20 @@ export const headerPreferencesSchema = z export type HeaderPreferences = z.infer; +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]; diff --git a/packages/validation/src/user.ts b/packages/validation/src/user.ts index 375c7784c..71c59d7f9 100644 --- a/packages/validation/src/user.ts +++ b/packages/validation/src/user.ts @@ -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");