feat: rebuild onboarding experience
This commit is contained in:
1 parent
d091362db2
commit
e41605640b
249 files changed
+8216
-14060
No files matched your search
@@ -66,8 +66,6 @@ homarr/
|
||||
│ ├── analytics/ # Server-side analytics (Umami)
|
||||
│ ├── server-settings/ # Server setting keys/types
|
||||
│ ├── settings/ # User-facing settings UI context
|
||||
│ ├── old-import/ # Legacy Homarr import
|
||||
│ ├── old-schema/ # Legacy Homarr zod schemas
|
||||
│ └── cli/ # Node CLI for ops (brocli)
|
||||
├── tooling/
|
||||
│ ├── typescript/ # Base tsconfig
|
||||
|
||||
@@ -263,15 +263,6 @@ Even hidden options need translation keys or the translation test fails:
|
||||
"widget.mywidget.option.columnWidths.label": "Column widths"
|
||||
```
|
||||
|
||||
### Old-import mappings
|
||||
|
||||
Add to `packages/old-import/src/widgets/options.ts`:
|
||||
|
||||
```typescript
|
||||
columnOrder: () => undefined,
|
||||
columnWidths: () => undefined,
|
||||
```
|
||||
|
||||
### Dynamic storeKey
|
||||
|
||||
Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding:
|
||||
@@ -610,13 +601,12 @@ if (isEditMode) rowContextMenuHandler = undefined;
|
||||
4. Add CSS file for transparent backgrounds (if widget context)
|
||||
5. Add responsive column visibility (if widget context)
|
||||
6. Add hidden options + hydration logic (if column persistence needed)
|
||||
7. Add old-import mappings for new options
|
||||
8. Add translation keys (including hidden option labels)
|
||||
9. Add context menu if row actions exist
|
||||
10. Add row expansion if detail view exists
|
||||
11. Add error handling on all mutations
|
||||
12. Add accessibility attributes
|
||||
13. Test at multiple widget widths
|
||||
14. Update docs in `apps/docs/docs/widgets/` if applicable
|
||||
15. Run `pnpm turbo typecheck` — catches old-import mismatches
|
||||
16. Run translation spec — catches missing keys
|
||||
7. Add translation keys (including hidden option labels)
|
||||
8. Add context menu if row actions exist
|
||||
9. Add row expansion if detail view exists
|
||||
10. Add error handling on all mutations
|
||||
11. Add accessibility attributes
|
||||
12. Test at multiple widget widths
|
||||
13. Update docs in `apps/docs/docs/widgets/` if applicable
|
||||
14. Run `pnpm turbo typecheck`
|
||||
15. Run translation spec — catches missing keys
|
||||
@@ -40,8 +40,6 @@ homarr/
|
||||
│ ├── analytics/ # Server-side analytics (Umami)
|
||||
│ ├── server-settings/ # Server setting keys/types
|
||||
│ ├── settings/ # User-facing settings UI context
|
||||
│ ├── old-import/ # Legacy Homarr import
|
||||
│ ├── old-schema/ # Legacy Homarr zod schemas
|
||||
│ └── cli/ # Node CLI for ops (brocli)
|
||||
├── tooling/
|
||||
│ ├── typescript/ # Base tsconfig
|
||||
|
||||
+1
-2
@@ -4,7 +4,6 @@ This index lists Mantine documentation pages formatted for LLMs.
|
||||
Each link points to a standalone Markdown file under the /llms path.
|
||||
|
||||
For a single consolidated file with all content, use:
|
||||
|
||||
- https://mantine.dev/llms-full.txt
|
||||
|
||||
## FAQ
|
||||
@@ -54,7 +53,7 @@ For a single consolidated file with all content, use:
|
||||
- [How to integrate custom input with use-form hook?](https://mantine.dev/llms/q-custom-input-use-form.md): Learn how to add use-form support for custom inputs
|
||||
- [How to prevent Modal from closing?](https://mantine.dev/llms/q-how-to-prevent-modal-from-closing.md): Learn how to prevent Modal from closing when the user clicks outside of it or presses the Escape key
|
||||
- [How to scroll to the top of the form if the form is submitted with errors?](https://mantine.dev/llms/q-scroll-to-the-top-of-the-form.md): Learn how to call a function when the form is submitted with errors
|
||||
- [How to update Mantine dependencies?](https://mantine.dev/llms/q-how-to-update-dependencies.md): Learn how to update @mantine/_, @mantinex/_ and postcss-preset-mantine dependencies manually or with ncu script
|
||||
- [How to update Mantine dependencies?](https://mantine.dev/llms/q-how-to-update-dependencies.md): Learn how to update @mantine/*, @mantinex/* and postcss-preset-mantine dependencies manually or with ncu script
|
||||
- [How to use Dropzone with @mantine/form?](https://mantine.dev/llms/q-how-to-use-dropzone-with-form.md): Learn how to use Dropzone with @mantine/form to handle file selection state
|
||||
- [How to use Mantine template on GitHub?](https://mantine.dev/llms/q-templates-usage.md): Learn how to create a new repository based on Mantine template
|
||||
- [I get hydration warning about data-mantine-color-scheme attribute, what does it mean?](https://mantine.dev/llms/q-color-scheme-hydration-warning.md): Learn how hydration works and how to fix warnings
|
||||
|
||||
@@ -13,8 +13,6 @@ This will be a one time occurrence and version > ``1.0`` will have less breaking
|
||||
This time more important than every; **read the breaking changes** list and **create a backup** before updating.
|
||||
Some third party installation methods may take multiple days or weeks to update to the new version.
|
||||
|
||||
See [this guide on how to import your previous Homarr dashboard](/docs/getting-started/after-the-installation#importing-a-zip-from-version-before-100)
|
||||
|
||||
## Changes at a glance
|
||||
- **Asynchronous integrations system**: In versions prior to ``1.0``, we fetched data upon each request synchronously.
|
||||
This means that the user had to wait for the requests to finish so any data would appear.
|
||||
|
||||
@@ -28,28 +28,15 @@ Please note that if you are using docker you need to specify a diffrent external
|
||||
So in your compose file the ports would be `7576:7575` instead of `7575:7575`.
|
||||
|
||||
## Import your data
|
||||
Once the installation is completed you can access the new installation. During the onboarding you'll need to select `Import from Homarr before 1.0`.
|
||||
|
||||

|
||||
:::warning Legacy guide
|
||||
|
||||
After that you can upload the zip file you downloaded in the first step.
|
||||
The pre-1.0 ZIP importer described by this historical migration guide is no longer included in current Homarr releases. Keep the old installation and export available while you recreate the required apps and boards in a supported release. SQLite database backups from current Homarr releases can still be restored from the onboarding welcome screen.
|
||||
|
||||

|
||||
|
||||
After that you'll get a list with all your boards that can be imported. You can configure how it should be imported and can select which screensizes it should import.
|
||||
|
||||

|
||||
|
||||
Once you have selected everything you can click on `Confirm import and continue` which will open the encryption key input dialog where you can input the secret key you got during the export.
|
||||
|
||||

|
||||
|
||||
After that you can continue the onboarding and you should see all your boards and data in the new installation.
|
||||
|
||||
See [After the installation](https://homarr.dev/docs/getting-started/after-the-installation) for more information.
|
||||
:::
|
||||
|
||||
## Remove the old installation
|
||||
|
||||
After you are happy with the new installation and everything is working as expected you can remove the old installation.
|
||||
Please make sure to backup the data before you remove the old installation.
|
||||
Also once the port 7575 is free again you can change the port mapping of the new installation to `7575:7575` if you want to.
|
||||
Also once the port 7575 is free again you can change the port mapping of the new installation to `7575:7575` if you want to.
|
||||
@@ -6,18 +6,10 @@ tags:
|
||||
- Configuration
|
||||
---
|
||||
|
||||
import imageStartFromScratchStepServerSettings from "./img/onboarding-from-scratch-step-server-settings.png";
|
||||
import imageStartFromScratchStepAdminUser from "./img/onboarding-from-scratch-step-admin-user.png";
|
||||
import imageStartFromScratchStepExternalAdminGroup from "./img/onboarding-from-scratch-step-external-admin-group.png";
|
||||
import imageStartFromScratchStepFinish from "./img/onboarding-from-scratch-step-finish.png";
|
||||
import manageBoardHeaderEditMode from "./img/manage-board-header-edit-mode.png";
|
||||
import manageBoardHeaderAddItem from "./img/manage-board-header-add-item-menu.png";
|
||||
import manageBoardHeaderChooseItem from "./img/manage-board-header-choose-item.png";
|
||||
import manageBoardNoApp from "./img/manage-board-no-app.png";
|
||||
import manageExportOldHomarr from "./img/old-homarr-export-data.png";
|
||||
import manageImportOldHomarrDropzone from "./img/old-homarr-import-dropzone.png";
|
||||
import manageImportOldHomarrChooseData from "./img/old-homarr-import-choose-imported-data.png";
|
||||
import manageImportDecryptionToken from "./img/old-homarr-import-decryption-token.png";
|
||||
|
||||
:::tip
|
||||
|
||||
@@ -29,117 +21,50 @@ Congratulations on installing Homarr. Now it's time to get yourself familiar wit
|
||||
|
||||
## Onboarding
|
||||
|
||||
The onboard will show the first time you start Homarr.
|
||||
This process will help you with your first steps and adding some initial data, such as the administrator account.
|
||||
Onboarding appears the first time Homarr starts. Its goal is to create a useful responsive board, not merely an
|
||||
administrator account. The setup uses one guided studio, so you can keep the safe defaults and finish quickly or review
|
||||
every detected service before anything is added to the board.
|
||||
|
||||
:::note
|
||||
|
||||
If you see this screen repeatitly after you restarted the Homarr container, your Docker mounting paths may be missing,
|
||||
If you see this screen repeatedly after restarting the Homarr container, your Docker mounting paths may be missing,
|
||||
incorrect or read-only. Read [our installation instructions](/docs/getting-started/installation/docker) again to fix this issue.
|
||||
|
||||
:::
|
||||
|
||||
You can either import a previous dashboard using the ZIP archive or start from scratch:
|
||||
### Restore or start fresh
|
||||
|
||||

|
||||
SQLite installations can restore a complete Homarr database backup from the welcome screen. This restores current
|
||||
boards, people, apps, integrations, and settings. The pre-1.0 ZIP importer is no longer included.
|
||||
|
||||
You may refresh anytime and continue from a different device - the onboarding is saved immediately on the server for your convenience.
|
||||
For a new installation, Homarr first creates either a credentials administrator or an administrator group matching your
|
||||
LDAP/OIDC provider. The browser that starts setup claims the first-run session; refreshes are safe, while another browser
|
||||
cannot change credentials or integrations during that setup.
|
||||
|
||||
### Starting from scratch
|
||||
### Setup studio
|
||||
|
||||
Depending on your auth provider configuration you will either first see the admin user creation form or the external group creation form.
|
||||
If the credentials provider was selected, you'll be prompted to create an admin user for Homarr.
|
||||
This user cannot be deleted and has full control over Homarr and all of its boards.
|
||||
You must choose a username and password (both can later be changed).
|
||||
We enforce strict password requirements when you enter your password to protect your instance if you accidentally
|
||||
expose it to the internet.
|
||||
The studio keeps related choices together:
|
||||
|
||||
<img src={imageStartFromScratchStepAdminUser} alt={"Screenshot form to create admin user"} width={300} />
|
||||
1. **Essentials** sets the default language, color scheme, analytics preference, and the usual host, IP address, or domain used by your services.
|
||||
2. **Discover** checks the actual database, Docker-compatible sockets, Kubernetes configuration, Workshop, and Assistant capabilities. Unavailable sources never block manual setup.
|
||||
3. **Connect** combines explicit `homarr.*` or Homepage Docker labels with image matching. Select several integrations and apps at once, review every URL, and use the service-specific link to open its API-key settings when one is available.
|
||||
4. **Board** previews the protected Mobile and Base layouts, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels.
|
||||
5. **Extend** explains and optionally configures Homarr Assistant, the community Workshop, and the separate Homarr MCP endpoint.
|
||||
6. **Review** tests and saves the selected connections, creates the chosen apps and widgets, and configures the exact first board without clearing unrelated data.
|
||||
|
||||
If you selected external providers (such as LDAP or OIDC), you'll be prompted to create an admin group.
|
||||
This group will be used to synchronize users from your provider to Homarr.
|
||||
When a user logs in through the provider, any of their groups that match groups in Homarr will be assigned to the user.
|
||||
The name of the group must match exactly the name of the group in the provider.
|
||||
Docker discovery reports each configured host separately. Results from reachable hosts remain selectable if another socket
|
||||
is unavailable, which is useful for remote Docker, rootless Docker, Synology, and mixed deployments. On Kubernetes or
|
||||
bare-metal installations without a Docker socket, enter services manually and use the same integration catalog.
|
||||
|
||||
<img
|
||||
src={imageStartFromScratchStepExternalAdminGroup}
|
||||
alt={"Screenshot form to create external admin group"}
|
||||
width={300}
|
||||
/>
|
||||
After setup, **Open my board** takes you directly to the configured board. The in-product tour then introduces edit mode,
|
||||
responsive layouts, widgets, and search without delaying completion.
|
||||
|
||||
Next, you can configure some basic settings for Homarr. We recommend to leave all settings at default for most users.
|
||||
Here you can also disable analytics. Please note that our analytics are completly anonymous and is fully compliant with GDPR and PECR.
|
||||
|
||||
<img
|
||||
src={imageStartFromScratchStepServerSettings}
|
||||
alt={"Screenshot of analytics and indexing settings in the onboarding"}
|
||||
width={300}
|
||||
/>
|
||||
|
||||
### Setting up integrations
|
||||
|
||||
After configuring server settings, the onboarding guides you through setting up your integrations.
|
||||
|
||||
#### Docker service discovery
|
||||
|
||||
If Docker is available (socket mounted), Homarr automatically detects running containers and matches them to known integration types.
|
||||
Matching integrations are pre-selected in the integration list, saving you from manually picking each one.
|
||||
|
||||
#### URL templating
|
||||
|
||||
You can define a base URL template to auto-fill integration URLs in bulk.
|
||||
Two modes are supported:
|
||||
|
||||
- **Subdomain mode** - e.g. `{app}.example.com` where `{app}` is replaced with the integration name
|
||||
- **Port mode** - e.g. `192.168.1.1:{port}` where `{port}` is replaced with the integration's default port
|
||||
|
||||
#### API key helper links
|
||||
|
||||
For integrations that require API keys (such as Sonarr, Radarr, SABnzbd, or Seerr), a helper link opens the relevant settings page in the target application so you can quickly copy your key.
|
||||
|
||||
#### Creating apps from Docker containers
|
||||
|
||||
You can optionally auto-create bookmark apps for all discovered Docker containers, making them immediately available on your board.
|
||||
|
||||
:::note
|
||||
|
||||
Release providers and Search.ch are configured directly in their widgets instead of the integrations page.
|
||||
|
||||
:::
|
||||
|
||||
After this step, you finished the onboarding.
|
||||
The onboarding can no longer be started again.
|
||||
From here you can click on one of the provided links to continue your journey.
|
||||
We recommend you to continue with the section [creating your first board on this page](#creating-your-first-board).
|
||||
|
||||
<img src={imageStartFromScratchStepFinish} alt={"Screenshot of the finish onboarding process with links"} width={300} />
|
||||
|
||||
### Importing a ZIP from version before 1.0.0
|
||||
|
||||
This is recommended when you used Homarr before and want to re-import your data.
|
||||
It is important that you first migrate correctly using [the list of breaking changes for 1.0](/blog/2024/09/23/version-1.0).
|
||||
|
||||
<img src={manageExportOldHomarr} alt="Export from old Homarr" style={{ maxWidth: 500 }} />
|
||||
|
||||
**How to export and import data**
|
||||
|
||||
1. Navigate to your old Homarr, open your management pages, open the tools submenu and open `Migrate to 1.0`.
|
||||
2. Check the contents that you want to export. We recommend you to export all data.
|
||||
3. Click on the export data button
|
||||
4. Save the ZIP file, that is being downloaded, on your device. If you selected "integrations" or "users", you also get an encryption / decryption key.
|
||||
Copy it and do not loose it!
|
||||
5. Go to your new >1.0.0 Homarr instance and start the onboarding. Choose `Import from Homarr before 1.0`
|
||||
6. Drag and drop your previously downloaded ZIP archive into the file upload: <br/><img src={manageImportOldHomarrDropzone} style={{ maxWidth: 500 }} />
|
||||
7. Next, choose the data that you want to import and how to import it. Since screen sizes were removed in 1.0, we create a new board for every screen size by default. We recommend you to only keep the best layout and adjust it later in 1.0.0 - but you can also import all of them and pick later.<br/><img src={manageImportOldHomarrChooseData} style={{ maxWidth: 500 }} />
|
||||
8. If you previously exported integrations or users, you will be asked to decrypt the data safely using your obtained encryption token. Paste into the modal and confirm your action.<br/><img src={manageImportDecryptionToken} style={{ maxWidth: 500 }} />
|
||||
9. Next, you can [follow the same setup as when starting from scratch](#starting-from-scratch). After you completed the onboarding, all of your data will be migrated and available.
|
||||
|
||||
## Creating your first board
|
||||
## Creating another board
|
||||
|
||||
In Homarr, you organize your apps and widgets on boards.
|
||||
Boards use fixed-size tiles so the same widget keeps the same proportions on every screen.
|
||||
Drag, resize, and section controls are available in edit mode.
|
||||
Let's create your first board by navigating here: `Manage` > `Boards` > `New board`:
|
||||
Onboarding already created the first board. To create another one, navigate to `Manage` > `Boards` > `New board`:
|
||||

|
||||
|
||||
A board must always have a name and can either be public or private.
|
||||
|
||||
@@ -51,6 +51,6 @@ Note that the ping URL only accepts `http://` and `https://` URLs — custom URI
|
||||
|
||||
## List of apps
|
||||
|
||||
On the apps management page you find all apps that exist in your Homarr instance. You can edit and delete them.
|
||||
On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page.
|
||||
|
||||

|
||||
Any signed-in user can view this list. Creating, editing, and deleting apps requires the corresponding app permission; board permissions are not required.
|
||||
@@ -20,6 +20,7 @@ You can create as many boards as you want.
|
||||
Boards can be restricted to user groups.
|
||||
A user may also have a "home board" which they will see at the root path (e.g. my-homarr.com or my-homarr.com/board).
|
||||
Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home) or via the management pages.
|
||||
The management page includes a compact structural preview of each board's Base layout so you can recognize it before opening it.
|
||||
|
||||
Names of boards must be unique because this will be used in the URL to load the desired board.
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ Check [the available integrations](/docs/category/integrations) for further info
|
||||
|
||||
## Create an integration
|
||||
|
||||
To create an integration, you can choose the supported application from the dropdown at the top right:
|
||||
To create an integration, choose **New integration** and select the supported application from the searchable grid. The grid groups related services visually and shows which widgets each integration supports.
|
||||
|
||||

|
||||
The integrations management page uses a searchable list. Each row shows the integration type and URL, with the actions you have permission to use.
|
||||
|
||||
Next, you'll need to fill out the required fields to configure the integration:
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@ tags:
|
||||
# Search Engines
|
||||
|
||||
Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `CTRL + K`.
|
||||
|
||||

|
||||
The management page provides a searchable list with the shortcut, URL template, source, and available row actions for each engine.
|
||||
|
||||
You can add any search engine that has a search URL that accepts a search term as a parameter.
|
||||
|
||||
|
||||
@@ -48,8 +48,7 @@
|
||||
"@homarr/modals": "workspace:^0.1.0",
|
||||
"@homarr/modals-collection": "workspace:^0.1.0",
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
"@homarr/old-import": "workspace:^0.1.0",
|
||||
"@homarr/old-schema": "workspace:^0.1.0",
|
||||
"@homarr/onboarding": "workspace:^0.1.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/settings": "workspace:^0.1.0",
|
||||
|
||||
@@ -4,9 +4,9 @@ import { Box, Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getRepresentativeLayoutWidth } from "../_layout-utils";
|
||||
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
|
||||
import {
|
||||
getBoardLaneColumnCount,
|
||||
|
||||
@@ -4,21 +4,21 @@ import { Badge, Box, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/cor
|
||||
import { IconLayoutGrid } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import type { BoardPreviewLayout } from "@homarr/boards/layout-preview";
|
||||
import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview";
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import type { BoardLayout } from "../../_layout-utils";
|
||||
import { getRepresentativeLayoutWidth, projectBoardLayout } from "../../_layout-utils";
|
||||
import classes from "./_layout-preview.module.css";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
layout: BoardLayout;
|
||||
layouts: BoardLayout[];
|
||||
sourceLayout: BoardLayout;
|
||||
layout: BoardPreviewLayout;
|
||||
layouts: BoardPreviewLayout[];
|
||||
sourceLayout: BoardPreviewLayout;
|
||||
apps: RouterOutputs["app"]["byIds"];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
|
||||
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||
import { getRepresentativeLayoutWidth, projectBoardLayout } from "./_layout-utils";
|
||||
|
||||
const mobile = {
|
||||
id: "mobile",
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
role: "mobile" as const,
|
||||
};
|
||||
const custom = {
|
||||
id: "tablet",
|
||||
name: "Tablet",
|
||||
columnCount: 6,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 480,
|
||||
role: "custom" as const,
|
||||
};
|
||||
const base = {
|
||||
id: "base",
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 768,
|
||||
role: "base" as const,
|
||||
};
|
||||
|
||||
describe("layout representative widths", () => {
|
||||
test("uses a phone width, activation midpoint, and desktop minimum", () => {
|
||||
const layouts = [mobile, custom, base];
|
||||
expect(getRepresentativeLayoutWidth(mobile, layouts)).toBe(390);
|
||||
expect(getRepresentativeLayoutWidth(custom, layouts)).toBe(624);
|
||||
expect(getRepresentativeLayoutWidth(base, layouts)).toBe(1280);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectBoardLayout", () => {
|
||||
test("returns exact saved positions without recursively re-projecting the active layout", () => {
|
||||
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
|
||||
const container = new ContainerSectionMockBuilder({ id: "container" })
|
||||
.addLayout({ layoutId: mobile.id, parentSectionId: rootSection.id, width: 3, height: 2 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addLayout(mobile).addSections([rootSection, container]).build();
|
||||
|
||||
expect(projectBoardLayout(board, mobile, mobile)).toEqual([
|
||||
expect.objectContaining({ id: container.id, sectionId: rootSection.id, width: 3, height: 2 }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses production ordering and sizing for items and containers", () => {
|
||||
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
|
||||
const container = new ContainerSectionMockBuilder({ id: "container" })
|
||||
.addLayout({ layoutId: base.id, parentSectionId: rootSection.id, width: 6, height: 3, xOffset: 6, yOffset: 0 })
|
||||
.build();
|
||||
const rootItem = new ItemMockBuilder({ id: "root-item", kind: "clock" })
|
||||
.addLayout({ layoutId: base.id, sectionId: rootSection.id, width: 5, height: 2, xOffset: 0, yOffset: 0 })
|
||||
.build();
|
||||
const nestedItem = new ItemMockBuilder({ id: "nested-item", kind: "clock" })
|
||||
.addLayout({ layoutId: base.id, sectionId: container.id, width: 4, height: 1, xOffset: 0, yOffset: 0 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout(mobile)
|
||||
.addSections([rootSection, container])
|
||||
.addItems([rootItem, nestedItem])
|
||||
.build();
|
||||
|
||||
const projected = projectBoardLayout(board, base, mobile);
|
||||
|
||||
expect(projected.find((element) => element.id === rootItem.id)).toMatchObject({
|
||||
type: "item",
|
||||
width: 3,
|
||||
height: 2,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
sectionId: rootSection.id,
|
||||
});
|
||||
expect(projected.find((element) => element.id === container.id)).toMatchObject({
|
||||
type: "section",
|
||||
width: 3,
|
||||
xOffset: 0,
|
||||
yOffset: 2,
|
||||
sectionId: rootSection.id,
|
||||
});
|
||||
expect(projected.find((element) => element.id === nestedItem.id)).toMatchObject({
|
||||
type: "item",
|
||||
width: 3,
|
||||
sectionId: container.id,
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores missing saved positions and supports empty boards", () => {
|
||||
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
|
||||
const itemWithoutBasePosition = new ItemMockBuilder({ id: "missing", kind: "clock" }).build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout(mobile)
|
||||
.addSection(rootSection)
|
||||
.addItem(itemWithoutBasePosition)
|
||||
.build();
|
||||
|
||||
expect(projectBoardLayout(board, base, mobile)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Card } from "@mantine/core";
|
||||
|
||||
interface InitStepCardProps {
|
||||
withBorder?: boolean;
|
||||
}
|
||||
|
||||
export const InitStepCard = ({ children, withBorder = true }: PropsWithChildren<InitStepCardProps>) => (
|
||||
<Card w="100%" withBorder={withBorder}>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
@@ -1,2 +0,0 @@
|
||||
export const onboardingContentWidth = 64 * 16;
|
||||
export const onboardingMaxWidth = "90vw";
|
||||
@@ -1,23 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
export const BackToStart = () => {
|
||||
const t = useI18n();
|
||||
const { mutateAsync, isPending } = clientApi.onboard.previousStep.useMutation();
|
||||
|
||||
const handleBackToStartAsync = async () => {
|
||||
await mutateAsync();
|
||||
await revalidatePathActionAsync("/init");
|
||||
};
|
||||
|
||||
return (
|
||||
<Button loading={isPending} variant="subtle" color="gray" fullWidth onClick={handleBackToStartAsync}>
|
||||
{t("init.backToStart")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { Button, Stack, Text } from "@mantine/core";
|
||||
import { IconBook2, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
|
||||
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { getMantineColor } from "@homarr/common";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
|
||||
export const InitFinish = async () => {
|
||||
const tFinish = await getScopedI18n("init.step.finish");
|
||||
|
||||
return (
|
||||
<InitStepCard>
|
||||
<Stack>
|
||||
<Text>{tFinish("description")}</Text>
|
||||
|
||||
<InternalLinkButton href="/auth/login?callbackUrl=/" iconProps={{ icon: IconLayoutDashboard, color: "blue" }}>
|
||||
{tFinish("action.goToBoard", { name: "dashboard" })}
|
||||
</InternalLinkButton>
|
||||
|
||||
{isProviderEnabled("credentials") && (
|
||||
<InternalLinkButton
|
||||
href="/auth/login?callbackUrl=/manage/users/invites"
|
||||
iconProps={{ icon: IconMailForward, color: "pink" }}
|
||||
>
|
||||
{tFinish("action.inviteUser")}
|
||||
</InternalLinkButton>
|
||||
)}
|
||||
|
||||
<ExternalLinkButton
|
||||
href={createDocumentationLink("/docs/getting-started/after-the-installation")}
|
||||
iconProps={{ icon: IconBook2, color: "yellow" }}
|
||||
>
|
||||
{tFinish("action.docs")}
|
||||
</ExternalLinkButton>
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
);
|
||||
};
|
||||
|
||||
interface LinkButtonProps {
|
||||
href: string;
|
||||
children: string;
|
||||
iconProps: IconProps;
|
||||
}
|
||||
|
||||
interface IconProps {
|
||||
icon: TablerIcon;
|
||||
color: MantineColor;
|
||||
}
|
||||
|
||||
const Icon = ({ icon: IcomComponent, color }: IconProps) => {
|
||||
return <IcomComponent color={getMantineColor(color, 6)} size={16} stroke={1.5} />;
|
||||
};
|
||||
|
||||
const InternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => {
|
||||
return (
|
||||
<Button variant="default" component={Link} href={href} leftSection={<Icon {...iconProps} />}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const ExternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => {
|
||||
return (
|
||||
<Button variant="default" component="a" href={href} leftSection={<Icon {...iconProps} />}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Stack, TextInput } from "@mantine/core";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { groupCreateSchema } from "@homarr/validation/group";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
|
||||
export const InitGroup = () => {
|
||||
const t = useI18n();
|
||||
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/init");
|
||||
},
|
||||
});
|
||||
const form = useZodForm(groupCreateSchema, {
|
||||
initialValues: {
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmitAsync = async (values: z.infer<typeof groupCreateSchema>) => {
|
||||
await mutateAsync(values, {
|
||||
onError(error) {
|
||||
if (error.data?.code === "CONFLICT") {
|
||||
form.setErrors({ name: t("common.zod.errors.custom.groupNameTaken") });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<InitStepCard>
|
||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t("init.step.group.form.name.label")}
|
||||
description={t("init.step.group.form.name.description")}
|
||||
withAsterisk
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
|
||||
{t("common.action.continue")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</InitStepCard>
|
||||
);
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
import { ActionIcon, Button, Group, Text } from "@mantine/core";
|
||||
import type { FileWithPath } from "@mantine/dropzone";
|
||||
import { IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import { formatBytes } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
|
||||
interface FileInfoCardProps {
|
||||
file: FileWithPath;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
||||
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
|
||||
return (
|
||||
<InitStepCard withBorder={false}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group>
|
||||
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text visibleFrom="md" c="gray.6" size="sm">
|
||||
{formatBytes(file.size)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
rightSection={<IconPencil size={16} stroke={1.5} />}
|
||||
onClick={onRemove}
|
||||
visibleFrom="md"
|
||||
>
|
||||
{tFileInfo("action.change")}
|
||||
</Button>
|
||||
<ActionIcon size="sm" variant="subtle" color="gray" hiddenFrom="md" onClick={onRemove}>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</InitStepCard>
|
||||
);
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Group, rem, Text } from "@mantine/core";
|
||||
import type { FileWithPath } from "@mantine/dropzone";
|
||||
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
|
||||
import { IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
|
||||
|
||||
import "@mantine/dropzone/styles.css";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface ImportDropZoneProps {
|
||||
loading: boolean;
|
||||
updateFile: (file: FileWithPath) => void;
|
||||
}
|
||||
|
||||
export const ImportDropZone = ({ loading, updateFile }: ImportDropZoneProps) => {
|
||||
const tDropzone = useScopedI18n("init.step.import.dropzone");
|
||||
return (
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
const firstFile = files[0];
|
||||
if (!firstFile) return;
|
||||
|
||||
updateFile(firstFile);
|
||||
}}
|
||||
acceptColor="blue.6"
|
||||
rejectColor="red.6"
|
||||
accept={[MIME_TYPES.zip, "application/x-zip-compressed"]}
|
||||
loading={loading}
|
||||
multiple={false}
|
||||
maxSize={1024 * 1024 * 1024 * 64} // 64 MB
|
||||
onReject={(rejections) => {
|
||||
console.error(
|
||||
"Rejected files",
|
||||
rejections.map(
|
||||
(rejection) =>
|
||||
`File: ${rejection.file.name} size=${rejection.file.size} fileType=${rejection.file.type}\n - ${rejection.errors.map((error) => error.message).join("\n - ")}`,
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: "none" }}>
|
||||
<Dropzone.Accept>
|
||||
<IconUpload style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-blue-6)" }} stroke={1.5} />
|
||||
</Dropzone.Accept>
|
||||
<Dropzone.Reject>
|
||||
<IconX style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-red-6)" }} stroke={1.5} />
|
||||
</Dropzone.Reject>
|
||||
<Dropzone.Idle>
|
||||
<IconFileZip style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-dimmed)" }} stroke={1.5} />
|
||||
</Dropzone.Idle>
|
||||
|
||||
<div>
|
||||
<Text size="xl" inline>
|
||||
{tDropzone("title")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" inline mt={7}>
|
||||
{tDropzone("description")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
);
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition, useState } from "react";
|
||||
import { Stack } from "@mantine/core";
|
||||
import type { FileWithPath } from "@mantine/dropzone";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { InitialOldmarrImport } from "@homarr/old-import/components";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
import { FileInfoCard } from "./file-info-card";
|
||||
import { ImportDropZone } from "./import-dropzone";
|
||||
|
||||
export const InitImport = () => {
|
||||
const [file, setFile] = useState<FileWithPath | null>(null);
|
||||
const { isPending, mutate } = clientApi.import.analyseInitialOldmarrImport.useMutation();
|
||||
const [analyseResult, setAnalyseResult] = useState<RouterOutputs["import"]["analyseInitialOldmarrImport"] | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
<InitStepCard>
|
||||
<ImportDropZone
|
||||
loading={isPending}
|
||||
updateFile={(file) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
mutate(formData, {
|
||||
onSuccess: (result) => {
|
||||
startTransition(() => {
|
||||
setAnalyseResult(result);
|
||||
setFile(file);
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</InitStepCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack w="100%" mb="sm">
|
||||
<FileInfoCard file={file} onRemove={() => setFile(null)} />
|
||||
{analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,209 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Card,
|
||||
Collapse,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { IconBrandDocker, IconCheck, IconChevronDown } from "@tabler/icons-react";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
export interface DiscoveredIntegration {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
kind: IntegrationKind;
|
||||
suggestedUrl: string;
|
||||
publishedPort: number | null;
|
||||
iconUrl: string | null;
|
||||
}
|
||||
|
||||
export interface DiscoveredApp {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
suggestedUrl: string;
|
||||
publishedPort: number | null;
|
||||
iconUrl: string | null;
|
||||
}
|
||||
|
||||
interface DockerDiscoveryIndicatorProps {
|
||||
integrations: DiscoveredIntegration[];
|
||||
apps: DiscoveredApp[];
|
||||
selectedAppIds: Set<string>;
|
||||
onToggleApp: (containerId: string) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const APP_CARD_HEIGHT = 80;
|
||||
|
||||
export const DockerDiscoveryIndicator = ({
|
||||
integrations,
|
||||
apps,
|
||||
selectedAppIds,
|
||||
onToggleApp,
|
||||
isLoading,
|
||||
}: DockerDiscoveryIndicatorProps) => {
|
||||
const hasApps = apps.length > 0;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const expandedOnce = useRef(false);
|
||||
useEffect(() => {
|
||||
if ((hasApps || integrations.length > 0) && !expandedOnce.current) {
|
||||
expandedOnce.current = true;
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [hasApps, integrations.length]);
|
||||
|
||||
const t = useScopedI18n("init.step.integrations.docker");
|
||||
const totalCount = integrations.length + apps.length;
|
||||
|
||||
if (!isLoading && totalCount === 0) return null;
|
||||
|
||||
const selectedAppCount = apps.filter((app) => selectedAppIds.has(app.containerId)).length;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
p="sm"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor: "var(--mantine-color-blue-4)",
|
||||
background: "var(--mantine-color-blue-light)",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
|
||||
<IconBrandDocker size={20} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("scanning")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("scanningDescription")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Loader size="sm" color="blue" type="dots" />
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
p="sm"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor: "var(--mantine-color-blue-4)",
|
||||
background: "var(--mantine-color-blue-light)",
|
||||
}}
|
||||
>
|
||||
<UnstyledButton onClick={() => setExpanded((prev) => !prev)} w="100%">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
|
||||
<IconBrandDocker size={20} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("detected", { count: String(totalCount) })}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("detectedBreakdown", {
|
||||
integrations: String(integrations.length),
|
||||
apps: String(apps.length),
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<IconChevronDown
|
||||
size={16}
|
||||
color="var(--mantine-color-dimmed)"
|
||||
style={{
|
||||
transform: expanded ? "rotate(180deg)" : undefined,
|
||||
transition: "transform 200ms ease",
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
<Collapse expanded={expanded}>
|
||||
<Stack gap="xs" mt="sm">
|
||||
{integrations.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
{integrations.map((integration) => (
|
||||
<Group key={integration.containerId} gap="xs" wrap="nowrap" py={2}>
|
||||
<IntegrationAvatar kind={integration.kind} size="xs" />
|
||||
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
{integration.containerName}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="blue">
|
||||
{getIntegrationName(integration.kind)}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{hasApps && (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={500}>
|
||||
{t("appSectionLabel", { count: String(selectedAppCount) })}
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah="30vh">
|
||||
<SimpleGrid cols={{ base: 3, xs: 4, sm: 5 }} spacing="xs">
|
||||
{apps.map((app) => {
|
||||
const isSelected = selectedAppIds.has(app.containerId);
|
||||
return (
|
||||
<Card
|
||||
key={app.containerId}
|
||||
h={APP_CARD_HEIGHT}
|
||||
p="xs"
|
||||
withBorder
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
|
||||
borderWidth: isSelected ? 2 : undefined,
|
||||
opacity: isSelected ? 1 : 0.6,
|
||||
}}
|
||||
onClick={() => onToggleApp(app.containerId)}
|
||||
>
|
||||
<Stack justify="space-between" h="100%" gap={4} align="center">
|
||||
<Group justify="space-between" w="100%" wrap="nowrap">
|
||||
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
{app.containerName}
|
||||
</Text>
|
||||
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
|
||||
</Group>
|
||||
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
|
||||
{app.containerName.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -1,483 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Collapse,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowRight,
|
||||
IconBrandDocker,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { IntegrationKind, UrlTemplateMode } from "@homarr/definitions";
|
||||
import { buildAppUrl, buildIntegrationUrl, getIntegrationName } from "@homarr/definitions";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
|
||||
import { IntegrationMultiSelectGrid } from "~/components/integration/integration-multi-select-grid";
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
|
||||
type Phase = "select" | "configure" | "done";
|
||||
|
||||
interface IntegrationDraft {
|
||||
kind: IntegrationKind;
|
||||
initialUrl: string;
|
||||
initialName?: string;
|
||||
dockerPort?: number | null;
|
||||
source: "manual" | "docker";
|
||||
}
|
||||
|
||||
export const InitIntegrations = () => {
|
||||
const [phase, setPhase] = useState<Phase>("select");
|
||||
const [drafts, setDrafts] = useState<Map<IntegrationKind, IntegrationDraft>>(new Map());
|
||||
const [selectedKinds, setSelectedKinds] = useState<IntegrationKind[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [baseHost, setBaseHost] = useState("");
|
||||
const [urlMode, setUrlMode] = useState<UrlTemplateMode>("subdomain");
|
||||
const t = useScopedI18n("init.step.integrations");
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation();
|
||||
const { mutateAsync: createApps } = clientApi.onboard.createAppsFromDiscovery.useMutation();
|
||||
|
||||
const { data: dockerData, isPending: isDockerLoading } = clientApi.onboard.discoverDockerServices.useQuery(
|
||||
undefined,
|
||||
{
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const dockerIntegrations = dockerData?.status === "success" ? dockerData.integrations : [];
|
||||
const dockerApps = dockerData?.status === "success" ? dockerData.apps : [];
|
||||
const totalDetected = dockerIntegrations.length + dockerApps.length;
|
||||
|
||||
const detectedKinds = useMemo(() => new Set(dockerIntegrations.map((i) => i.kind)), [dockerIntegrations]);
|
||||
|
||||
const [selectedAppIds, setSelectedAppIds] = useState<Set<string>>(new Set());
|
||||
const [appsExpanded, setAppsExpanded] = useState(false);
|
||||
|
||||
const dockerApplied = useRef(false);
|
||||
useEffect(() => {
|
||||
if (dockerData?.status !== "success" || dockerApplied.current) return;
|
||||
dockerApplied.current = true;
|
||||
|
||||
const newDrafts = new Map(drafts);
|
||||
const newKinds = new Set(selectedKinds);
|
||||
|
||||
for (const item of dockerData.integrations) {
|
||||
if (!newDrafts.has(item.kind)) {
|
||||
newDrafts.set(item.kind, {
|
||||
kind: item.kind,
|
||||
initialUrl: item.suggestedUrl,
|
||||
initialName: item.containerName,
|
||||
dockerPort: item.publishedPort,
|
||||
source: "docker",
|
||||
});
|
||||
}
|
||||
newKinds.add(item.kind);
|
||||
}
|
||||
|
||||
setDrafts(newDrafts);
|
||||
setSelectedKinds(Array.from(newKinds));
|
||||
setSelectedAppIds(new Set(dockerData.apps.map((app) => app.containerId)));
|
||||
if (dockerData.apps.length > 0) {
|
||||
setAppsExpanded(true);
|
||||
}
|
||||
}, [dockerData]);
|
||||
|
||||
const handleSelectionChange = useCallback((kinds: IntegrationKind[]) => {
|
||||
setSelectedKinds(kinds);
|
||||
}, []);
|
||||
|
||||
const handleToggleApp = useCallback((containerId: string) => {
|
||||
setSelectedAppIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(containerId)) {
|
||||
next.delete(containerId);
|
||||
} else {
|
||||
next.add(containerId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleContinueToConfig = () => {
|
||||
const newDrafts = new Map(drafts);
|
||||
for (const kind of selectedKinds) {
|
||||
const existing = newDrafts.get(kind);
|
||||
if (baseHost) {
|
||||
newDrafts.set(kind, {
|
||||
kind,
|
||||
initialUrl: buildIntegrationUrl(kind, baseHost, urlMode, existing?.dockerPort ?? undefined),
|
||||
initialName: existing?.initialName,
|
||||
dockerPort: existing?.dockerPort,
|
||||
source: existing?.source ?? "manual",
|
||||
});
|
||||
} else if (!existing) {
|
||||
newDrafts.set(kind, { kind, initialUrl: "", source: "manual" });
|
||||
}
|
||||
}
|
||||
setDrafts(newDrafts);
|
||||
setCurrentIndex(0);
|
||||
setPhase("configure");
|
||||
};
|
||||
|
||||
const finishSetupAsync = async () => {
|
||||
setPhase("done");
|
||||
try {
|
||||
const appsToCreate = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
|
||||
if (appsToCreate.length > 0) {
|
||||
await createApps(
|
||||
appsToCreate.map((app) => {
|
||||
const href = baseHost
|
||||
? buildAppUrl(app.containerName, baseHost, urlMode, app.publishedPort ?? undefined)
|
||||
: app.suggestedUrl;
|
||||
return {
|
||||
name: app.containerName,
|
||||
href: href || null,
|
||||
iconUrl: app.iconUrl,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
await setupIntegrations();
|
||||
router.refresh();
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("configure.error.title"),
|
||||
message: t("configure.error.message"),
|
||||
});
|
||||
setPhase("select");
|
||||
}
|
||||
};
|
||||
|
||||
const advanceOrFinish = () => {
|
||||
if (currentIndex + 1 >= selectedKinds.length) {
|
||||
void finishSetupAsync();
|
||||
} else {
|
||||
setCurrentIndex((i) => i + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const backToSelect = () => {
|
||||
setPhase("select");
|
||||
setCurrentIndex(0);
|
||||
};
|
||||
|
||||
if (phase === "done") {
|
||||
return (
|
||||
<InitStepCard>
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="md">
|
||||
<Loader size="lg" />
|
||||
<Text>{t("configure.finishing")}</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</InitStepCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "configure") {
|
||||
return (
|
||||
<ConfigurePhase
|
||||
selectedKinds={selectedKinds}
|
||||
currentIndex={currentIndex}
|
||||
drafts={drafts}
|
||||
baseHost={baseHost}
|
||||
setBaseHost={setBaseHost}
|
||||
urlMode={urlMode}
|
||||
setUrlMode={setUrlMode}
|
||||
onSuccess={advanceOrFinish}
|
||||
onCancel={backToSelect}
|
||||
onSkip={advanceOrFinish}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedAppCount = dockerApps.filter((app) => selectedAppIds.has(app.containerId)).length;
|
||||
|
||||
return (
|
||||
<InitStepCard>
|
||||
<Stack>
|
||||
<Text>{t("description")}</Text>
|
||||
|
||||
<Alert variant="outline" color="orange" icon={<IconAlertTriangle />} title={t("urlTemplate.label")}>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("urlTemplate.warning")}
|
||||
</Text>
|
||||
<Group gap="sm" grow>
|
||||
<TextInput
|
||||
placeholder={t("urlTemplate.placeholder")}
|
||||
value={baseHost}
|
||||
onChange={(e) => setBaseHost(e.currentTarget.value)}
|
||||
data-autofocus
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={urlMode}
|
||||
onChange={(value) => setUrlMode(value as UrlTemplateMode)}
|
||||
data={[
|
||||
{ label: t("urlTemplate.mode.subdomain"), value: "subdomain" },
|
||||
{ label: t("urlTemplate.mode.hostPort"), value: "hostPort" },
|
||||
]}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
/>
|
||||
</Group>
|
||||
{baseHost && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("urlTemplate.preview", { example: buildIntegrationUrl("sonarr", baseHost, urlMode) })}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
{isDockerLoading && (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
|
||||
<IconBrandDocker size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("docker.scanning")}
|
||||
</Text>
|
||||
<Loader size="sm" color="blue" type="dots" />
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{!isDockerLoading && totalDetected > 0 && (
|
||||
<Paper withBorder p="sm" radius="md" style={{ borderColor: "var(--mantine-color-teal-4)" }}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="teal" size="lg" radius="md">
|
||||
<IconBrandDocker size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm">
|
||||
{t("docker.summary", {
|
||||
apps: String(totalDetected),
|
||||
integrations: String(dockerIntegrations.length),
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<IntegrationMultiSelectGrid
|
||||
selectedKinds={selectedKinds}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
detectedKinds={detectedKinds}
|
||||
onboarding
|
||||
/>
|
||||
|
||||
{dockerApps.length > 0 && (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Stack gap="xs">
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => setAppsExpanded((prev) => !prev)}
|
||||
>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("docker.appSectionLabel", { count: String(selectedAppCount) })}
|
||||
</Text>
|
||||
<IconChevronDown
|
||||
size={16}
|
||||
color="var(--mantine-color-dimmed)"
|
||||
style={{
|
||||
transform: appsExpanded ? "rotate(180deg)" : undefined,
|
||||
transition: "transform 200ms ease",
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Collapse expanded={appsExpanded}>
|
||||
<SimpleGrid cols={{ base: 3, xs: 4, sm: 5 }} spacing="xs">
|
||||
{dockerApps.map((app) => {
|
||||
const isSelected = selectedAppIds.has(app.containerId);
|
||||
return (
|
||||
<Card
|
||||
key={app.containerId}
|
||||
h={80}
|
||||
p="xs"
|
||||
withBorder
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
|
||||
borderWidth: isSelected ? 2 : undefined,
|
||||
opacity: isSelected ? 1 : 0.6,
|
||||
}}
|
||||
onClick={() => handleToggleApp(app.containerId)}
|
||||
>
|
||||
<Stack justify="space-between" h="100%" gap={4} align="center">
|
||||
<Group justify="space-between" w="100%" wrap="nowrap">
|
||||
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
{app.containerName}
|
||||
</Text>
|
||||
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
|
||||
</Group>
|
||||
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
|
||||
{app.containerName.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" onClick={() => void finishSetupAsync()}>
|
||||
{t("action.skip")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleContinueToConfig}
|
||||
disabled={selectedKinds.length === 0 || undefined}
|
||||
rightSection={<IconArrowRight size={16} stroke={1.5} />}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{t("action.continue")} {selectedKinds.length > 0 && `(${selectedKinds.length})`}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
);
|
||||
};
|
||||
|
||||
interface ConfigurePhaseProps {
|
||||
selectedKinds: IntegrationKind[];
|
||||
currentIndex: number;
|
||||
drafts: Map<IntegrationKind, IntegrationDraft>;
|
||||
baseHost: string;
|
||||
setBaseHost: (value: string) => void;
|
||||
urlMode: UrlTemplateMode;
|
||||
setUrlMode: (value: UrlTemplateMode) => void;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
const ConfigurePhase = ({
|
||||
selectedKinds,
|
||||
currentIndex,
|
||||
drafts,
|
||||
baseHost,
|
||||
setBaseHost,
|
||||
urlMode,
|
||||
setUrlMode,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
onSkip,
|
||||
}: ConfigurePhaseProps) => {
|
||||
const t = useScopedI18n("init.step.integrations");
|
||||
const [helpersOpen, { toggle: toggleHelpers }] = useDisclosure(false);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- currentIndex is always within bounds of selectedKinds
|
||||
const currentKind = selectedKinds[currentIndex]!;
|
||||
const draft = drafts.get(currentKind);
|
||||
const progress = ((currentIndex + 1) / selectedKinds.length) * 100;
|
||||
const computedUrl = baseHost
|
||||
? buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined)
|
||||
: draft?.initialUrl;
|
||||
|
||||
return (
|
||||
<InitStepCard>
|
||||
<Stack>
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="xs">
|
||||
<IntegrationAvatar kind={currentKind} size="sm" />
|
||||
<Text fw={500}>{getIntegrationName(currentKind)}</Text>
|
||||
</Group>
|
||||
<Tooltip label={t("configure.helpers.tooltip")}>
|
||||
<ActionIcon variant={helpersOpen ? "light" : "subtle"} color="gray" onClick={toggleHelpers}>
|
||||
<IconSettings size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Stack gap={4}>
|
||||
<Progress value={progress} size="sm" />
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Collapse expanded={helpersOpen}>
|
||||
<Stack
|
||||
gap="xs"
|
||||
p="xs"
|
||||
style={{ background: "var(--mantine-color-default-hover)", borderRadius: "var(--mantine-radius-sm)" }}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("configure.helpers.description")}
|
||||
</Text>
|
||||
<Group gap="sm" grow>
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder={t("urlTemplate.placeholder")}
|
||||
value={baseHost}
|
||||
onChange={(e) => setBaseHost(e.currentTarget.value)}
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={urlMode}
|
||||
onChange={(value) => setUrlMode(value as UrlTemplateMode)}
|
||||
data={[
|
||||
{ label: t("urlTemplate.mode.subdomain"), value: "subdomain" },
|
||||
{ label: t("urlTemplate.mode.hostPort"), value: "hostPort" },
|
||||
]}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
/>
|
||||
</Group>
|
||||
{baseHost && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("urlTemplate.preview", {
|
||||
example: buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
|
||||
<NewIntegrationForm
|
||||
key={currentKind}
|
||||
kind={currentKind}
|
||||
initialUrl={computedUrl}
|
||||
initialName={draft?.initialName}
|
||||
onSuccess={onSuccess}
|
||||
onCancel={onCancel}
|
||||
onSkip={onSkip}
|
||||
isOnboarding
|
||||
/>
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
);
|
||||
};
|
||||
@@ -1,129 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Stack, Switch, Text } from "@mantine/core";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import type { CheckboxProps } from "@homarr/form/types";
|
||||
import { defaultServerSettings } from "@homarr/server-settings";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { settingsInitSchema } from "@homarr/validation/settings";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
|
||||
export const InitSettings = () => {
|
||||
const tSection = useScopedI18n("management.page.settings.section");
|
||||
const t = useI18n();
|
||||
const { mutateAsync } = clientApi.serverSettings.initSettings.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/init");
|
||||
},
|
||||
});
|
||||
const form = useZodForm(settingsInitSchema, { initialValues: defaultServerSettings });
|
||||
|
||||
const handleSubmitAsync = async (values: z.infer<typeof settingsInitSchema>) => {
|
||||
await mutateAsync(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||
<Stack w="100%">
|
||||
<InitStepCard>
|
||||
<Stack gap="sm">
|
||||
<Text fw={500}>{tSection("analytics.title")}</Text>
|
||||
|
||||
<Stack gap="xs">
|
||||
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
<InitStepCard>
|
||||
<Stack gap="sm">
|
||||
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
|
||||
|
||||
<Stack gap="xs">
|
||||
<CrawlingRow
|
||||
kind="noIndex"
|
||||
{...form.getInputProps("crawlingAndIndexing.noIndex", { type: "checkbox" })}
|
||||
/>
|
||||
<CrawlingRow
|
||||
kind="noFollow"
|
||||
{...form.getInputProps("crawlingAndIndexing.noFollow", { type: "checkbox" })}
|
||||
/>
|
||||
<CrawlingRow
|
||||
kind="noTranslate"
|
||||
{...form.getInputProps("crawlingAndIndexing.noTranslate", { type: "checkbox" })}
|
||||
/>
|
||||
<CrawlingRow
|
||||
kind="noSiteLinksSearchBox"
|
||||
{...form.getInputProps("crawlingAndIndexing.noSiteLinksSearchBox", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
|
||||
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
|
||||
{t("common.action.continue")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface AnalyticsRowProps {
|
||||
kind: Exclude<keyof TranslationObject["management"]["page"]["settings"]["section"]["analytics"], "title">;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AnalyticsRow = ({ kind, ...props }: AnalyticsRowProps & CheckboxProps) => {
|
||||
const tSection = useI18n("management.page.settings.section");
|
||||
|
||||
return (
|
||||
<SettingRow title={tSection(`analytics.${kind}.title`)} text={tSection(`analytics.${kind}.text`)} {...props} />
|
||||
);
|
||||
};
|
||||
|
||||
interface CrawlingRowProps {
|
||||
kind: Exclude<
|
||||
keyof TranslationObject["management"]["page"]["settings"]["section"]["crawlingAndIndexing"],
|
||||
"title" | "warning"
|
||||
>;
|
||||
}
|
||||
|
||||
const CrawlingRow = ({ kind, ...inputProps }: CrawlingRowProps & CheckboxProps) => {
|
||||
const tSection = useI18n("management.page.settings.section");
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
title={tSection(`crawlingAndIndexing.${kind}.title`)}
|
||||
text={tSection(`crawlingAndIndexing.${kind}.text`)}
|
||||
{...inputProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingRow = ({
|
||||
title,
|
||||
text,
|
||||
disabled,
|
||||
...inputProps
|
||||
}: { title: string; text: string; disabled?: boolean } & CheckboxProps) => {
|
||||
return (
|
||||
<Group wrap="nowrap" align="center">
|
||||
<Stack gap={0} style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="xs" c="gray.5">
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Switch disabled={disabled} {...inputProps} />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
import { getMantineColor } from "@homarr/common";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
import { InitSqliteRestore } from "./sqlite-restore";
|
||||
import { InitStartButton } from "./next-button";
|
||||
|
||||
export const InitStart = async () => {
|
||||
const tStart = await getScopedI18n("init.step.start");
|
||||
const isSqlite = dbEnv.DRIVER === "better-sqlite3";
|
||||
|
||||
return (
|
||||
<InitStepCard>
|
||||
<Stack>
|
||||
<Text>{tStart("description")}</Text>
|
||||
|
||||
<InitStartButton
|
||||
preferredStep={undefined}
|
||||
icon={<IconPlayerPlay color={getMantineColor("green", 6)} size={16} stroke={1.5} />}
|
||||
>
|
||||
{tStart("action.scratch")}
|
||||
</InitStartButton>
|
||||
<InitStartButton
|
||||
preferredStep="import"
|
||||
icon={<IconFileImport color={getMantineColor("cyan", 6)} size={16} stroke={1.5} />}
|
||||
>
|
||||
{tStart("action.importOldmarr")}
|
||||
</InitStartButton>
|
||||
{isSqlite && <InitSqliteRestore />}
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
);
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { OnboardingStep } from "@homarr/definitions";
|
||||
|
||||
interface InitStartButtonProps {
|
||||
icon: ReactNode;
|
||||
preferredStep: OnboardingStep | undefined;
|
||||
}
|
||||
|
||||
export const InitStartButton = ({ preferredStep, icon, children }: PropsWithChildren<InitStartButtonProps>) => {
|
||||
const { mutateAsync } = clientApi.onboard.nextStep.useMutation();
|
||||
|
||||
const handleClickAsync = async () => {
|
||||
await mutateAsync({ preferredStep });
|
||||
await revalidatePathActionAsync("/init");
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleClickAsync} variant="default" leftSection={icon}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, Stack, Text } from "@mantine/core";
|
||||
import { IconDatabaseImport } from "@tabler/icons-react";
|
||||
|
||||
import { getMantineColor } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { DatabaseRestoreFlow } from "~/components/backup";
|
||||
|
||||
export const InitSqliteRestore = () => {
|
||||
const t = useScopedI18n("init.step.start.action");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (expanded) {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Button variant="subtle" size="xs" onClick={() => setExpanded(false)}>
|
||||
{t("backToOptions")}
|
||||
</Button>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("restoreSqliteDescription")}
|
||||
</Text>
|
||||
<DatabaseRestoreFlow variant="standalone" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => setExpanded(true)}
|
||||
variant="default"
|
||||
leftSection={<IconDatabaseImport color={getMantineColor("orange", 6)} size={16} stroke={1.5} />}
|
||||
>
|
||||
{t("restoreSqlite")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Stack, TextInput } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { signIn } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { UserCreatePasswordFields } from "@homarr/forms-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { userInitSchema } from "@homarr/validation/user";
|
||||
|
||||
export const InitUserForm = () => {
|
||||
const t = useScopedI18n("user");
|
||||
const tUser = useScopedI18n("init.step.user");
|
||||
const { mutateAsync, isPending } = clientApi.user.initUser.useMutation();
|
||||
const form = useZodForm(userInitSchema, {
|
||||
initialValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmitAsync = async (values: FormType) => {
|
||||
await mutateAsync(values, {
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: tUser("notification.success.title"),
|
||||
message: tUser("notification.success.message"),
|
||||
});
|
||||
|
||||
void signIn("credentials", {
|
||||
name: values.username,
|
||||
password: values.password,
|
||||
redirect: false,
|
||||
}).then(async () => {
|
||||
await revalidatePathActionAsync("/init");
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification({
|
||||
title: tUser("notification.error.title"),
|
||||
message: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<form
|
||||
onSubmit={form.onSubmit(
|
||||
(values) => void handleSubmitAsync(values),
|
||||
(err) => console.log(err),
|
||||
)}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
|
||||
<UserCreatePasswordFields
|
||||
passwordInputProps={form.getInputProps("password")}
|
||||
confirmPasswordInputProps={form.getInputProps("confirmPassword")}
|
||||
/>
|
||||
<Button type="submit" fullWidth loading={isPending}>
|
||||
{t("action.create")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof userInitSchema>;
|
||||
@@ -1,10 +0,0 @@
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
import { InitUserForm } from "./init-user-form";
|
||||
|
||||
export const InitUser = () => {
|
||||
return (
|
||||
<InitStepCard>
|
||||
<InitUserForm />
|
||||
</InitStepCard>
|
||||
);
|
||||
};
|
||||
@@ -1,66 +1,64 @@
|
||||
import type { JSX } from "react";
|
||||
import { Box, Center, Stack, Text, Title } from "@mantine/core";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { db } from "@homarr/db";
|
||||
import type { OnboardingStep } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { normalizeOnboardingStep } from "@homarr/api/onboarding-step";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { groups } from "@homarr/db/schema";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { env as dockerEnv } from "@homarr/docker/env";
|
||||
import { OnboardingStudio } from "@homarr/onboarding";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
|
||||
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
|
||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||
import { onboardingContentWidth, onboardingMaxWidth } from "./_constants";
|
||||
import { BackToStart } from "./_steps/back";
|
||||
import { InitFinish } from "./_steps/finish/init-finish";
|
||||
import { InitGroup } from "./_steps/group/init-group";
|
||||
import { InitImport } from "./_steps/import/init-import";
|
||||
import { InitIntegrations } from "./_steps/integrations/init-integrations";
|
||||
import { InitSettings } from "./_steps/settings/init-settings";
|
||||
import { InitStart } from "./_steps/start/init-start";
|
||||
import { InitUser } from "./_steps/user/init-user";
|
||||
|
||||
const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Element>)> = {
|
||||
start: InitStart,
|
||||
import: InitImport,
|
||||
user: InitUser,
|
||||
group: InitGroup,
|
||||
settings: InitSettings,
|
||||
integrations: InitIntegrations,
|
||||
finish: InitFinish,
|
||||
};
|
||||
|
||||
const getCurrentOnboardingStepAsync = async () => {
|
||||
const value = await db.query.onboarding.findFirst();
|
||||
if (!value) return { current: "start" as const, previous: null };
|
||||
|
||||
return { current: value.step, previous: value.previousStep };
|
||||
};
|
||||
import { DatabaseRestoreFlow } from "~/components/backup";
|
||||
import { env } from "~/env";
|
||||
|
||||
export default async function InitPage() {
|
||||
const t = await getScopedI18n("init.step");
|
||||
const currentStep = await getCurrentOnboardingStepAsync();
|
||||
|
||||
const CurrentComponent = stepComponents[currentStep.current];
|
||||
const [state, session, requestHeaders, defaultGroup, firstUser, availableBoards] = await Promise.all([
|
||||
db.query.onboarding.findFirst(),
|
||||
auth(),
|
||||
headers(),
|
||||
db.query.groups.findFirst({
|
||||
where: eq(groups.name, everyoneGroup),
|
||||
columns: { homeBoardId: true },
|
||||
}),
|
||||
db.query.users.findFirst({ columns: { id: true } }),
|
||||
db.query.boards.findMany({ columns: { id: true, name: true } }),
|
||||
]);
|
||||
const homeBoardId = defaultGroup?.homeBoardId;
|
||||
const homeBoard = availableBoards.find((board) => board.id === homeBoardId);
|
||||
const initialBoard = homeBoard ?? (availableBoards.length === 1 ? availableBoards[0] : null);
|
||||
const currentStep = normalizeOnboardingStep(state?.step);
|
||||
const databaseDriver =
|
||||
dbEnv.DRIVER === "better-sqlite3" ? "sqlite" : dbEnv.DRIVER === "mysql2" ? "mysql" : "postgresql";
|
||||
const { workshopApiUrl, workshopWebUrl } = resolveHomarrUrlConfig({
|
||||
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
|
||||
workshopApiUrl: env.WORKSHOP_API_URL,
|
||||
workshopWebUrl: env.WORKSHOP_WEB_URL,
|
||||
});
|
||||
const baseUrl = extractBaseUrlFromHeaders(requestHeaders);
|
||||
|
||||
return (
|
||||
<Box mih="100dvh">
|
||||
<Center>
|
||||
<Stack align="center" mt="xl" w={onboardingContentWidth} maw={onboardingMaxWidth}>
|
||||
<HomarrLogoWithTitle size="lg" />
|
||||
<Stack gap={6} align="center">
|
||||
<Title order={3} fw={400} ta="center">
|
||||
{t(`${currentStep.current}.title`)}
|
||||
</Title>
|
||||
<Text size="sm" c="gray.5" ta="center">
|
||||
{t(`${currentStep.current}.subtitle`)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<CurrentLanguageCombobox width="100%" />
|
||||
<CurrentColorSchemeCombobox w="100%" />
|
||||
{CurrentComponent && <CurrentComponent />}
|
||||
{currentStep.previous === "start" && <BackToStart />}
|
||||
</Stack>
|
||||
</Center>
|
||||
</Box>
|
||||
<OnboardingStudio
|
||||
environment={{
|
||||
currentStep,
|
||||
databaseDriver,
|
||||
credentialsEnabled: isProviderEnabled("credentials"),
|
||||
externalAuthEnabled: isProviderEnabled("ldap") || isProviderEnabled("oidc"),
|
||||
dockerConfigured: dockerEnv.ENABLE_DOCKER,
|
||||
kubernetesConfigured: dockerEnv.ENABLE_KUBERNETES,
|
||||
workshopEnabled: true,
|
||||
workshopApiUrl,
|
||||
workshopUrl: workshopWebUrl,
|
||||
mcpEndpoint: `${baseUrl}/api/mcp`,
|
||||
canConfigurePrivileged: session?.user.permissions.includes("admin") ?? false,
|
||||
hasUsers: Boolean(firstUser),
|
||||
initialBoard: initialBoard ?? null,
|
||||
availableBoards,
|
||||
}}
|
||||
sqliteRestore={databaseDriver === "sqlite" ? <DatabaseRestoreFlow variant="standalone" /> : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ interface AppDeleteButtonProps {
|
||||
|
||||
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
const t = useScopedI18n("app.page.delete");
|
||||
const tList = useScopedI18n("app.page.list.action");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutate, isPending } = clientApi.app.delete.useMutation();
|
||||
|
||||
@@ -50,7 +51,14 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
}, [app, mutate, t, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label={t("title")}>
|
||||
<ActionIcon
|
||||
loading={isPending}
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size={44}
|
||||
onClick={onClick}
|
||||
aria-label={tList("delete", { name: app.name })}
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
@@ -16,10 +16,7 @@ export default async function AppEditPage(props: AppEditPageProps) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (
|
||||
!session?.user.permissions.includes("board-modify-all") ||
|
||||
!session?.user.permissions.includes("app-modify-all")
|
||||
) {
|
||||
if (!session?.user.permissions.includes("app-modify-all")) {
|
||||
notFound();
|
||||
}
|
||||
const app = await api.app.byId({ id: params.id });
|
||||
|
||||
@@ -10,7 +10,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
export default async function AppNewPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create")) {
|
||||
if (!session?.user.permissions.includes("app-create")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { Fragment } from "react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Text } from "@mantine/core";
|
||||
import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getSafeAppHref } from "@homarr/common";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { AppDeleteButton } from "./_app-delete-button";
|
||||
@@ -29,24 +30,38 @@ interface AppsPageProps {
|
||||
|
||||
export default async function AppsPage(props: AppsPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
if (!session.user.permissions.includes("board-modify-all")) {
|
||||
notFound();
|
||||
}
|
||||
if (!session) redirect("/auth/login");
|
||||
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
|
||||
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
|
||||
const t = await getScopedI18n("app");
|
||||
|
||||
const canCreate = session.user.permissions.includes("app-create");
|
||||
const canModify = session.user.permissions.includes("app-modify-all");
|
||||
const canDelete = session.user.permissions.includes("app-full-all");
|
||||
const hasSearch = Boolean(searchParams.search?.trim());
|
||||
|
||||
return (
|
||||
<ManagePageLayout
|
||||
const emptyState = hasSearch ? (
|
||||
<NoResults
|
||||
icon={IconBox}
|
||||
title={t("page.list.noResults.filteredTitle")}
|
||||
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
|
||||
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/apps" }}
|
||||
/>
|
||||
) : (
|
||||
<NoResults
|
||||
icon={IconBox}
|
||||
title={t("page.list.noResults.title")}
|
||||
description={t("page.list.noResults.description")}
|
||||
action={{ label: t("page.list.noResults.action"), href: "/manage/apps/new", hidden: !canCreate }}
|
||||
/>
|
||||
);
|
||||
|
||||
const page = (
|
||||
<ManageCollectionPage
|
||||
title={t("page.list.title")}
|
||||
ariaLabel={t("page.list.ariaLabel")}
|
||||
itemCount={apps.length}
|
||||
emptyState={emptyState}
|
||||
primaryAction={
|
||||
canCreate ? (
|
||||
<TourTarget id="manage-apps-create">
|
||||
@@ -56,101 +71,94 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
</TourTarget>
|
||||
) : undefined
|
||||
}
|
||||
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />}
|
||||
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
|
||||
toolbar={
|
||||
<SearchInput
|
||||
placeholder={`${t("search")}...`}
|
||||
ariaLabel={t("search")}
|
||||
defaultValue={searchParams.search}
|
||||
flexExpand
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
totalCount > searchParams.pageSize ? (
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
) : undefined
|
||||
}
|
||||
floatingPrimaryAction={canCreate}
|
||||
>
|
||||
{apps.length === 0 && <AppNoResults />}
|
||||
{apps.length > 0 && (
|
||||
<TourTarget id="manage-apps-list">
|
||||
<Stack gap="sm">
|
||||
{apps.map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</Stack>
|
||||
</TourTarget>
|
||||
)}
|
||||
</ManagePageLayout>
|
||||
{apps.map((app) => (
|
||||
<AppItem key={app.id} app={app} canModify={canModify} canDelete={canDelete} />
|
||||
))}
|
||||
</ManageCollectionPage>
|
||||
);
|
||||
|
||||
return apps.length > 0 ? <TourTarget id="manage-apps-list">{page}</TourTarget> : page;
|
||||
}
|
||||
|
||||
interface AppCardProps {
|
||||
app: RouterOutputs["app"]["all"][number];
|
||||
interface AppItemProps {
|
||||
app: RouterOutputs["app"]["getPaginated"]["items"][number];
|
||||
canModify: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
const AppCard = async ({ app }: AppCardProps) => {
|
||||
const AppItem = async ({ app, canModify, canDelete }: AppItemProps) => {
|
||||
const t = await getScopedI18n("app");
|
||||
const session = await auth();
|
||||
const descriptionLines = app.description?.split("\n");
|
||||
const safeHref = getSafeAppHref(app.href);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}>
|
||||
<Avatar
|
||||
<ManageCollectionItem
|
||||
leading={<Avatar size={36} src={app.iconUrl} radius="sm" styles={{ image: { objectFit: "contain" } }} alt="" />}
|
||||
title={
|
||||
<Text component="span" fw={600} lineClamp={1}>
|
||||
{app.name}
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
descriptionLines ? (
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{descriptionLines.map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
{index < descriptionLines.length - 1 && <br />}
|
||||
</Fragment>
|
||||
))}
|
||||
</Text>
|
||||
) : undefined
|
||||
}
|
||||
metadata={
|
||||
safeHref ? (
|
||||
<Anchor
|
||||
href={safeHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
lineClamp={1}
|
||||
size="sm"
|
||||
src={app.iconUrl}
|
||||
radius={0}
|
||||
styles={{
|
||||
image: {
|
||||
objectFit: "contain",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack gap={0} style={{ flex: "1" }}>
|
||||
<Text fw={500} lineClamp={1}>
|
||||
{app.name}
|
||||
</Text>
|
||||
{app.description && (
|
||||
<Text size="sm" c="gray.6" lineClamp={4}>
|
||||
{app.description.split("\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</Text>
|
||||
)}
|
||||
{app.href && (
|
||||
<Anchor href={app.href} lineClamp={1} size="sm" style={{ wordBreak: "break-all" }}>
|
||||
{app.href}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group>
|
||||
style={{ wordBreak: "break-all" }}
|
||||
>
|
||||
{app.href}
|
||||
</Anchor>
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
canModify || canDelete ? (
|
||||
<ActionIconGroup>
|
||||
{session?.user.permissions.includes("app-modify-all") && (
|
||||
{canModify && (
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/apps/edit/${app.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title")}
|
||||
size={44}
|
||||
aria-label={t("page.list.action.edit", { name: app.name })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{session?.user.permissions.includes("app-full-all") && <AppDeleteButton app={app} />}
|
||||
{canDelete && <AppDeleteButton app={app} />}
|
||||
</ActionIconGroup>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const AppNoResults = async () => {
|
||||
const t = await getI18n();
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<NoResults
|
||||
icon={IconBox}
|
||||
title={t("app.page.list.noResults.title")}
|
||||
action={{
|
||||
label: t("app.page.list.noResults.action"),
|
||||
href: "/manage/apps/new",
|
||||
hidden: !session?.user.permissions.includes("app-create"),
|
||||
}}
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,38 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Menu } from "@mantine/core";
|
||||
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconCategoryPlus } from "@tabler/icons-react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
|
||||
import { AddBoardModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
||||
|
||||
export const CreateBoardButton = () => {
|
||||
const t = useI18n();
|
||||
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
||||
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
||||
const { openModal } = useModalAction(AddBoardModal);
|
||||
|
||||
return (
|
||||
<ManageMobilePrimaryAction>
|
||||
<Button.Group>
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
|
||||
{t("management.page.board.action.new.label")}
|
||||
</Button>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<Button px="xs" ms={1}>
|
||||
<IconChevronDown size="1rem" />
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
||||
{t("board.action.oldImport.label")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Button.Group>
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}>
|
||||
{t("management.page.board.action.new.label")}
|
||||
</Button>
|
||||
</ManageMobilePrimaryAction>
|
||||
);
|
||||
};
|
||||
@@ -24,10 +24,11 @@ import {
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
@@ -37,7 +38,7 @@ import { CreateBoardButton } from "./_components/create-board-button";
|
||||
export default async function ManageBoardsPage() {
|
||||
const t = await getScopedI18n("management.page.board");
|
||||
const session = await auth();
|
||||
const boards = await api.board.getAllBoards();
|
||||
const boards = await api.board.getManageOverview();
|
||||
const canCreateBoards = session?.user.permissions.includes("board-create");
|
||||
|
||||
return (
|
||||
@@ -52,13 +53,31 @@ export default async function ManageBoardsPage() {
|
||||
}
|
||||
floatingPrimaryAction={canCreateBoards}
|
||||
>
|
||||
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />}
|
||||
{boards.length === 0 && (
|
||||
<NoResults icon={IconLayoutDashboard} title={t("noResults.title")} description={t("noResults.description")} />
|
||||
)}
|
||||
{boards.length > 0 && (
|
||||
<TourTarget id="manage-boards-list">
|
||||
<Grid>
|
||||
{boards.map((board) => (
|
||||
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
||||
<BoardCard board={board} />
|
||||
<BoardCard
|
||||
board={board}
|
||||
isMenuVisible={constructBoardPermissions(board, session).hasChangeAccess}
|
||||
labels={{
|
||||
visibility: t(`visibility.${board.isPublic ? "public" : "private"}`),
|
||||
preview: t("preview.label", {
|
||||
name: board.name,
|
||||
count: String(board.preview?.items.length ?? 0),
|
||||
}),
|
||||
home: t("action.setHomeBoard.badge.label"),
|
||||
homeTooltip: t("action.setHomeBoard.badge.tooltip"),
|
||||
mobileHome: t("action.setMobileHomeBoard.badge.label"),
|
||||
mobileHomeTooltip: t("action.setMobileHomeBoard.badge.tooltip"),
|
||||
open: t("action.open.label"),
|
||||
settings: t("action.settings.label"),
|
||||
}}
|
||||
/>
|
||||
</GridCol>
|
||||
))}
|
||||
</Grid>
|
||||
@@ -69,48 +88,60 @@ export default async function ManageBoardsPage() {
|
||||
}
|
||||
|
||||
interface BoardCardProps {
|
||||
board: RouterOutputs["board"]["getAllBoards"][number];
|
||||
board: RouterOutputs["board"]["getManageOverview"][number];
|
||||
isMenuVisible: boolean;
|
||||
labels: {
|
||||
visibility: string;
|
||||
preview: string;
|
||||
home: string;
|
||||
homeTooltip: string;
|
||||
mobileHome: string;
|
||||
mobileHomeTooltip: string;
|
||||
open: string;
|
||||
settings: string;
|
||||
};
|
||||
}
|
||||
|
||||
const BoardCard = async ({ board }: BoardCardProps) => {
|
||||
const t = await getScopedI18n("management.page.board");
|
||||
const { hasChangeAccess: isMenuVisible } = await getBoardPermissionsAsync(board);
|
||||
const visibility = board.isPublic ? "public" : "private";
|
||||
const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
|
||||
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card padding={0} withBorder>
|
||||
<CardSection withBorder>
|
||||
<BoardLayoutThumbnail preview={board.preview} label={labels.preview} />
|
||||
</CardSection>
|
||||
<CardSection p="sm" withBorder>
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="sm">
|
||||
<Tooltip label={t(`visibility.${visibility}`)}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: "1 1 12rem" }}>
|
||||
<Tooltip label={labels.visibility}>
|
||||
<VisibilityIcon size={20} stroke={1.5} />
|
||||
</Tooltip>
|
||||
<Text fw={700}>{board.name}</Text>
|
||||
<Text fw={700} truncate>
|
||||
{board.name}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Group gap="xs" justify="flex-end" wrap="wrap" style={{ marginInlineStart: "auto" }}>
|
||||
{board.isHome && (
|
||||
<Tooltip label={t("action.setHomeBoard.badge.tooltip")}>
|
||||
<Tooltip label={labels.homeTooltip}>
|
||||
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
|
||||
{t("action.setHomeBoard.badge.label")}
|
||||
{labels.home}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{board.isMobileHome && (
|
||||
<Tooltip label={t("action.setMobileHomeBoard.badge.tooltip")}>
|
||||
<Tooltip label={labels.mobileHomeTooltip}>
|
||||
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
|
||||
{t("action.setMobileHomeBoard.badge.label")}
|
||||
{labels.mobileHome}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{board.creator && (
|
||||
<Group gap="xs">
|
||||
<Tooltip label={board.creator.name ?? board.creator.email}>
|
||||
<UserAvatar user={board.creator} size="sm" />
|
||||
<Text>{board.creator.name}</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -123,9 +154,10 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
variant="default"
|
||||
h={44}
|
||||
fullWidth
|
||||
>
|
||||
{t("action.open.label")}
|
||||
{labels.open}
|
||||
</Button>
|
||||
{isMenuVisible && (
|
||||
<Menu position="bottom-end">
|
||||
@@ -133,8 +165,8 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
||||
<ActionIcon
|
||||
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label={t("action.settings.label")}
|
||||
size={44}
|
||||
aria-label={labels.settings}
|
||||
>
|
||||
<IconDotsVertical size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
|
||||
@@ -11,13 +10,12 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface DeleteIntegrationActionButtonProps {
|
||||
count: number;
|
||||
integration: { id: string; name: string };
|
||||
}
|
||||
|
||||
export const DeleteIntegrationActionButton = ({ count, integration }: DeleteIntegrationActionButtonProps) => {
|
||||
export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegrationActionButtonProps) => {
|
||||
const t = useScopedI18n("integration.page.delete");
|
||||
const router = useRouter();
|
||||
const tList = useScopedI18n("integration.page.list.action");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
|
||||
@@ -27,6 +25,7 @@ export const DeleteIntegrationActionButton = ({ count, integration }: DeleteInte
|
||||
loading={isPending}
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size={44}
|
||||
onClick={() => {
|
||||
openConfirmModal({
|
||||
title: t("title"),
|
||||
@@ -40,9 +39,6 @@ export const DeleteIntegrationActionButton = ({ count, integration }: DeleteInte
|
||||
title: t("notification.success.title"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
if (count === 1) {
|
||||
router.replace("/manage/integrations");
|
||||
}
|
||||
void revalidatePathActionAsync("/manage/integrations");
|
||||
void utils.integration.invalidate();
|
||||
},
|
||||
@@ -57,7 +53,7 @@ export const DeleteIntegrationActionButton = ({ count, integration }: DeleteInte
|
||||
},
|
||||
});
|
||||
}}
|
||||
aria-label={t("title")}
|
||||
aria-label={tList("delete", { name: integration.name })}
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
.root {
|
||||
border-radius: var(--mantine-radius-lg);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.item {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
border: 1px solid transparent;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
transition: background-color 150ms ease;
|
||||
overflow: hidden;
|
||||
|
||||
&[data-first="true"] {
|
||||
border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0;
|
||||
}
|
||||
|
||||
&[data-last="true"] {
|
||||
border-radius: 0 0 var(--mantine-radius-lg) var(--mantine-radius-lg);
|
||||
}
|
||||
|
||||
&[data-active] {
|
||||
z-index: 1;
|
||||
background-color: var(--mantine-color-body);
|
||||
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
border-radius: var(--mantine-radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
&[data-rotate] {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +1,80 @@
|
||||
import { Fragment } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
AccordionControl,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
ActionIcon,
|
||||
ActionIconGroup,
|
||||
Anchor,
|
||||
Divider,
|
||||
Group,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Badge, Text } from "@mantine/core";
|
||||
import { IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getSafeApplicationUrl } from "@homarr/common";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
||||
import { IntegrationAvatar, Link, SearchInput } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||
import classes from "./page.module.css";
|
||||
|
||||
const searchParamsSchema = z.object({ search: z.string().optional() });
|
||||
|
||||
interface IntegrationsPageProps {
|
||||
searchParams: Promise<{
|
||||
tab?: IntegrationKind;
|
||||
}>;
|
||||
searchParams: Promise<z.infer<typeof searchParamsSchema>>;
|
||||
}
|
||||
|
||||
export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const session = await auth();
|
||||
if (!session) redirect("/auth/login");
|
||||
|
||||
if (!session) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const integrations = await api.integration.all();
|
||||
const t = await getScopedI18n("integration");
|
||||
const canCreate = session.user.permissions.includes("integration-create");
|
||||
const hasGlobalFullAccess = session.user.permissions.includes("integration-full-all");
|
||||
const query = searchParams.search?.trim().toLocaleLowerCase() ?? "";
|
||||
const filteredIntegrations = integrations
|
||||
.filter((integration) => {
|
||||
if (!query) return true;
|
||||
return [integration.name, getIntegrationName(integration.kind), integration.url].some((value) =>
|
||||
value.toLocaleLowerCase().includes(query),
|
||||
);
|
||||
})
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
getIntegrationName(integrationA.kind).localeCompare(getIntegrationName(integrationB.kind)) ||
|
||||
integrationA.name.localeCompare(integrationB.name),
|
||||
);
|
||||
const hasSearch = query.length > 0;
|
||||
|
||||
const canCreateIntegrations = session.user.permissions.includes("integration-create");
|
||||
|
||||
return (
|
||||
<ManagePageLayout
|
||||
const page = (
|
||||
<ManageCollectionPage
|
||||
title={t("page.list.title")}
|
||||
ariaLabel={t("page.list.ariaLabel")}
|
||||
itemCount={filteredIntegrations.length}
|
||||
emptyState={
|
||||
hasSearch ? (
|
||||
<NoResults
|
||||
icon={IconPlugX}
|
||||
title={t("page.list.noResults.filteredTitle")}
|
||||
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
|
||||
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/integrations" }}
|
||||
/>
|
||||
) : (
|
||||
<NoResults
|
||||
icon={IconPlugX}
|
||||
title={t("page.list.noResults.title")}
|
||||
description={t("page.list.noResults.description")}
|
||||
action={{
|
||||
label: t("page.list.noResults.action"),
|
||||
href: "/manage/integrations/new",
|
||||
hidden: !canCreate,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
primaryAction={
|
||||
canCreateIntegrations ? (
|
||||
canCreate ? (
|
||||
<TourTarget id="manage-integrations-create">
|
||||
<MobileAffixButton component={Link} href="/manage/integrations/new">
|
||||
{t("action.create")}
|
||||
@@ -68,128 +82,83 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
</TourTarget>
|
||||
) : undefined
|
||||
}
|
||||
floatingPrimaryAction={canCreateIntegrations}
|
||||
>
|
||||
<TourTarget id="manage-integrations-list">
|
||||
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||
</TourTarget>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
interface IntegrationListProps {
|
||||
integrations: RouterOutputs["integration"]["all"];
|
||||
activeTab?: IntegrationKind;
|
||||
}
|
||||
|
||||
const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps) => {
|
||||
const t = await getScopedI18n("integration");
|
||||
const session = await auth();
|
||||
const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
|
||||
|
||||
if (integrations.length === 0) {
|
||||
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
|
||||
}
|
||||
|
||||
const groupedIntegrations = integrations.reduce(
|
||||
(acc, integration) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!acc[integration.kind]) {
|
||||
acc[integration.kind] = [];
|
||||
toolbar={
|
||||
<SearchInput
|
||||
placeholder={`${t("page.list.search")}...`}
|
||||
ariaLabel={t("page.list.search")}
|
||||
defaultValue={searchParams.search}
|
||||
flexExpand
|
||||
/>
|
||||
}
|
||||
|
||||
acc[integration.kind].push(integration);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<IntegrationKind, RouterOutputs["integration"]["all"]>,
|
||||
floatingPrimaryAction={canCreate}
|
||||
>
|
||||
{filteredIntegrations.map((integration) => (
|
||||
<IntegrationItem
|
||||
key={integration.id}
|
||||
integration={integration}
|
||||
canManage={hasGlobalFullAccess || integration.permissions.hasFullAccess}
|
||||
/>
|
||||
))}
|
||||
</ManageCollectionPage>
|
||||
);
|
||||
|
||||
const entries = objectEntries(groupedIntegrations);
|
||||
return filteredIntegrations.length > 0 ? <TourTarget id="manage-integrations-list">{page}</TourTarget> : page;
|
||||
}
|
||||
|
||||
interface IntegrationItemProps {
|
||||
integration: RouterOutputs["integration"]["all"][number];
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
const IntegrationItem = async ({ integration, canManage }: IntegrationItemProps) => {
|
||||
const t = await getScopedI18n("integration");
|
||||
const kindName = getIntegrationName(integration.kind);
|
||||
const safeUrl = getSafeApplicationUrl(integration.url);
|
||||
|
||||
return (
|
||||
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
|
||||
{entries.map(([kind, kindIntegrations], index) => (
|
||||
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
|
||||
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
|
||||
<Group>
|
||||
<Text>{getIntegrationName(kind)}</Text>
|
||||
<CountBadge count={kindIntegrations.length} />
|
||||
</Group>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Table visibleFrom="md">
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("field.name.label")}</TableTh>
|
||||
<TableTh>{t("field.url.label")}</TableTh>
|
||||
<TableTh />
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{kindIntegrations.map((integration) => (
|
||||
<TableTr key={integration.id}>
|
||||
<TableTd>{integration.name}</TableTd>
|
||||
<TableTd>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Group justify="end">
|
||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Stack gap="xs" hiddenFrom="md">
|
||||
{kindIntegrations.map((integration, integrationIndex) => (
|
||||
<Fragment key={integration.id}>
|
||||
{integrationIndex !== 0 && <Divider />}
|
||||
<Stack gap={0}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text>{integration.name}</Text>
|
||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</ActiveTabAccordion>
|
||||
<ManageCollectionItem
|
||||
leading={<IntegrationAvatar kind={integration.kind} size="md" radius="sm" />}
|
||||
title={
|
||||
<Text component="span" fw={600} lineClamp={1}>
|
||||
{integration.name}
|
||||
</Text>
|
||||
}
|
||||
badges={
|
||||
<Badge size="sm" variant="light">
|
||||
{kindName}
|
||||
</Badge>
|
||||
}
|
||||
metadata={
|
||||
safeUrl ? (
|
||||
<Anchor
|
||||
href={safeUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
lineClamp={1}
|
||||
size="sm"
|
||||
style={{ wordBreak: "break-all" }}
|
||||
>
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
canManage ? (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={t("page.list.action.edit", { name: integration.name })}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} />
|
||||
</ActionIconGroup>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -71,7 +71,11 @@ export default async function MediaListPage(props: MediaListPageProps) {
|
||||
}
|
||||
toolbar={
|
||||
<Group>
|
||||
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
|
||||
<SearchInput
|
||||
placeholder={`${t("media.search")}...`}
|
||||
ariaLabel={t("media.search")}
|
||||
defaultValue={searchParams.search}
|
||||
/>
|
||||
{session.user.permissions.includes("media-view-all") && (
|
||||
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ interface SearchEngineDeleteButtonProps {
|
||||
|
||||
export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => {
|
||||
const t = useScopedI18n("search.engine.page.delete");
|
||||
const tList = useScopedI18n("search.engine.page.list.action");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutate, isPending } = clientApi.searchEngine.delete.useMutation();
|
||||
|
||||
@@ -50,7 +51,14 @@ export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteBut
|
||||
}, [searchEngine, mutate, t, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label={t("title")}>
|
||||
<ActionIcon
|
||||
loading={isPending}
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size={44}
|
||||
onClick={onClick}
|
||||
aria-label={tList("delete", { name: searchEngine.name })}
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Badge, Text } from "@mantine/core";
|
||||
import { IconPencil, IconSearch } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getSafeApplicationUrl } from "@homarr/common";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
|
||||
@@ -27,123 +28,145 @@ interface SearchEnginesPageProps {
|
||||
|
||||
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
if (!session) redirect("/auth/login");
|
||||
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
|
||||
|
||||
const tEngine = await getScopedI18n("search.engine");
|
||||
|
||||
const t = await getScopedI18n("search.engine");
|
||||
const canCreate = session.user.permissions.includes("search-engine-create");
|
||||
const canModify = session.user.permissions.includes("search-engine-modify-all");
|
||||
const canDelete = session.user.permissions.includes("search-engine-full-all");
|
||||
const hasSearch = Boolean(searchParams.search?.trim());
|
||||
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={tEngine("page.list.title")}
|
||||
<ManageCollectionPage
|
||||
title={t("page.list.title")}
|
||||
ariaLabel={t("page.list.ariaLabel")}
|
||||
itemCount={searchEngines.length}
|
||||
emptyState={
|
||||
hasSearch ? (
|
||||
<NoResults
|
||||
icon={IconSearch}
|
||||
title={t("page.list.noResults.filteredTitle")}
|
||||
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
|
||||
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/search-engines" }}
|
||||
/>
|
||||
) : (
|
||||
<NoResults
|
||||
icon={IconSearch}
|
||||
title={t("page.list.noResults.title")}
|
||||
description={t("page.list.noResults.description")}
|
||||
action={{
|
||||
label: t("page.list.noResults.action"),
|
||||
href: "/manage/search-engines/new",
|
||||
hidden: !canCreate,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
primaryAction={
|
||||
canCreate ? (
|
||||
<MobileAffixButton component={Link} href="/manage/search-engines/new">
|
||||
{tEngine("page.create.title")}
|
||||
{t("page.create.title")}
|
||||
</MobileAffixButton>
|
||||
) : undefined
|
||||
}
|
||||
toolbar={<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />}
|
||||
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
|
||||
toolbar={
|
||||
<SearchInput
|
||||
placeholder={`${t("search")}...`}
|
||||
ariaLabel={t("search")}
|
||||
defaultValue={searchParams.search}
|
||||
flexExpand
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
totalCount > searchParams.pageSize ? (
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
) : undefined
|
||||
}
|
||||
floatingPrimaryAction={canCreate}
|
||||
>
|
||||
{searchEngines.length === 0 && <SearchEngineNoResults />}
|
||||
{searchEngines.length > 0 && (
|
||||
<Stack gap="sm">
|
||||
{searchEngines.map((searchEngine) => (
|
||||
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</ManagePageLayout>
|
||||
{searchEngines.map((searchEngine) => (
|
||||
<SearchEngineItem
|
||||
key={searchEngine.id}
|
||||
searchEngine={searchEngine}
|
||||
canModify={canModify}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
))}
|
||||
</ManageCollectionPage>
|
||||
);
|
||||
}
|
||||
|
||||
interface SearchEngineCardProps {
|
||||
interface SearchEngineItemProps {
|
||||
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
|
||||
canModify: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
||||
const SearchEngineItem = async ({ searchEngine, canModify, canDelete }: SearchEngineItemProps) => {
|
||||
const t = await getScopedI18n("search.engine");
|
||||
const session = await auth();
|
||||
const isIntegration = searchEngine.type === "fromIntegration";
|
||||
const previewUrl =
|
||||
searchEngine.type === "generic" && searchEngine.urlTemplate !== null
|
||||
? getSafeApplicationUrl(searchEngine.urlTemplate.replace("%s", "test"))
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Avatar
|
||||
<ManageCollectionItem
|
||||
leading={
|
||||
<Avatar size={36} src={searchEngine.iconUrl} radius="sm" styles={{ image: { objectFit: "contain" } }} alt="" />
|
||||
}
|
||||
title={
|
||||
<Text component="span" fw={600} lineClamp={1}>
|
||||
{searchEngine.name}
|
||||
</Text>
|
||||
}
|
||||
badges={
|
||||
<Badge size="sm" variant="light" color={isIntegration ? "blue" : "gray"}>
|
||||
{t(`page.list.type.${searchEngine.type}`)}
|
||||
</Badge>
|
||||
}
|
||||
description={
|
||||
searchEngine.description ? (
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{searchEngine.description}
|
||||
</Text>
|
||||
) : undefined
|
||||
}
|
||||
metadata={
|
||||
previewUrl ? (
|
||||
<Anchor
|
||||
href={previewUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
lineClamp={1}
|
||||
size="sm"
|
||||
src={searchEngine.iconUrl}
|
||||
radius={0}
|
||||
styles={{
|
||||
image: {
|
||||
objectFit: "contain",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack gap={0}>
|
||||
<Text fw={500} lineClamp={1}>
|
||||
{searchEngine.name}
|
||||
</Text>
|
||||
{searchEngine.description && (
|
||||
<Text size="sm" c="gray.6" lineClamp={4}>
|
||||
{searchEngine.description}
|
||||
</Text>
|
||||
)}
|
||||
{searchEngine.type === "generic" && searchEngine.urlTemplate !== null && (
|
||||
<Anchor href={searchEngine.urlTemplate.replace("%s", "test")} lineClamp={1} size="sm">
|
||||
{searchEngine.urlTemplate}
|
||||
</Anchor>
|
||||
)}
|
||||
{searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("page.list.interactive")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group>
|
||||
style={{ wordBreak: "break-all" }}
|
||||
>
|
||||
{searchEngine.urlTemplate}
|
||||
</Anchor>
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
canModify || canDelete ? (
|
||||
<ActionIconGroup>
|
||||
{session?.user.permissions.includes("search-engine-modify-all") && (
|
||||
{canModify && (
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/search-engines/edit/${searchEngine.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title")}
|
||||
size={44}
|
||||
aria-label={t("page.list.action.edit", { name: searchEngine.name })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{session?.user.permissions.includes("search-engine-full-all") && (
|
||||
<SearchEngineDeleteButton searchEngine={searchEngine} />
|
||||
)}
|
||||
{canDelete && <SearchEngineDeleteButton searchEngine={searchEngine} />}
|
||||
</ActionIconGroup>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchEngineNoResults = async () => {
|
||||
const t = await getI18n();
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<NoResults
|
||||
icon={IconSearch}
|
||||
title={t("search.engine.page.list.noResults.title")}
|
||||
action={{
|
||||
label: t("search.engine.page.list.noResults.action"),
|
||||
href: "/manage/search-engines/new",
|
||||
hidden: !session?.user.permissions.includes("search-engine-create"),
|
||||
}}
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -66,7 +66,11 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<SearchInput placeholder={`${tMembers("search")}...`} defaultValue={searchParams.search} />
|
||||
<SearchInput
|
||||
placeholder={`${tMembers("search")}...`}
|
||||
ariaLabel={tMembers("search")}
|
||||
defaultValue={searchParams.search}
|
||||
/>
|
||||
{canManageMembers && !isReserved && (
|
||||
<AddGroupMember
|
||||
groupId={group.id}
|
||||
|
||||
@@ -8,6 +8,11 @@ import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
|
||||
import {
|
||||
getOnboardingClaimTokenFromCookieHeader,
|
||||
isClaimOnlyOnboardingAccessAllowedAsync,
|
||||
} from "@homarr/api/onboarding-claim";
|
||||
import { normalizeOnboardingStep } from "@homarr/api/onboarding-step";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
||||
@@ -75,13 +80,17 @@ const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex:
|
||||
const isOnboardingActiveAsync = async (): Promise<boolean> => {
|
||||
const onboardingRow = await db.query.onboarding.findFirst();
|
||||
if (!onboardingRow) return false;
|
||||
return onboardingRow.step === "start";
|
||||
return normalizeOnboardingStep(onboardingRow.step) === "start";
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await auth();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const isOnboarding = !isAdmin && (await isOnboardingActiveAsync());
|
||||
const hasOnboardingClaim = await isClaimOnlyOnboardingAccessAllowedAsync(
|
||||
db,
|
||||
getOnboardingClaimTokenFromCookieHeader(req.headers.get("cookie")),
|
||||
);
|
||||
const isOnboarding = !isAdmin && hasOnboardingClaim && (await isOnboardingActiveAsync());
|
||||
|
||||
if (!isAdmin && !isOnboarding) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
claimOnboardingAsync,
|
||||
getOnboardingClaimTokenFromCookieHeader,
|
||||
onboardingClaimCookieName,
|
||||
} from "@homarr/api/onboarding-claim";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
const noStoreHeaders = { "Cache-Control": "no-store" };
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const fetchSite = request.headers.get("sec-fetch-site");
|
||||
if (fetchSite && fetchSite !== "same-origin" && fetchSite !== "none") {
|
||||
return NextResponse.json({ error: "Cross-site onboarding claims are not allowed." }, { status: 403 });
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
const result = await claimOnboardingAsync(db, {
|
||||
currentToken: getOnboardingClaimTokenFromCookieHeader(request.headers.get("cookie")),
|
||||
force: session?.user.permissions.includes("admin") ?? false,
|
||||
});
|
||||
if (result.status === "finished") {
|
||||
const response = NextResponse.json({ error: "Onboarding is already complete." }, { status: 409 });
|
||||
response.cookies.delete(onboardingClaimCookieName);
|
||||
return response;
|
||||
}
|
||||
if (result.status === "forbidden") {
|
||||
return NextResponse.json({ error: "Administrator access is required." }, { status: 403 });
|
||||
}
|
||||
if (result.status === "locked") {
|
||||
return NextResponse.json(
|
||||
{ error: "Another browser has already claimed onboarding.", expiresAt: result.expiresAt },
|
||||
{ status: 423, headers: noStoreHeaders },
|
||||
);
|
||||
}
|
||||
if (result.status !== "issued" && result.status !== "active") {
|
||||
return NextResponse.json({ error: "Onboarding could not be claimed." }, { status: 409 });
|
||||
}
|
||||
|
||||
const forwardedProtocol = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
||||
const secure = forwardedProtocol === "https" || new URL(request.url).protocol === "https:";
|
||||
const response = NextResponse.json(
|
||||
{ status: result.status, expiresAt: result.expiresAt },
|
||||
{ headers: noStoreHeaders },
|
||||
);
|
||||
response.cookies.set(onboardingClaimCookieName, result.token, {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure,
|
||||
path: "/",
|
||||
expires: new Date(result.expiresAt),
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
.canvas {
|
||||
min-height: 8rem;
|
||||
aspect-ratio: 16 / 7;
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-dimmed);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 15% 10%,
|
||||
color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent),
|
||||
transparent 35%
|
||||
),
|
||||
var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.lanes {
|
||||
display: grid;
|
||||
height: 100%;
|
||||
gap: rem(8px);
|
||||
padding: rem(12px);
|
||||
}
|
||||
|
||||
.lane {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: rem(3px);
|
||||
padding: rem(4px);
|
||||
overflow: hidden;
|
||||
border: rem(1px) solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background-image:
|
||||
linear-gradient(
|
||||
to right,
|
||||
color-mix(in srgb, var(--mantine-color-dimmed) 8%, transparent) rem(1px),
|
||||
transparent rem(1px)
|
||||
),
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in srgb, var(--mantine-color-dimmed) 8%, transparent) rem(1px),
|
||||
transparent rem(1px)
|
||||
);
|
||||
background-size: rem(12px) rem(12px);
|
||||
}
|
||||
|
||||
.lane[data-lane="left"],
|
||||
.lane[data-lane="right"] {
|
||||
background-color: color-mix(in srgb, var(--mantine-primary-color-light) 40%, transparent);
|
||||
}
|
||||
|
||||
.tile {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: rem(1px) solid color-mix(in srgb, var(--mantine-primary-color-filled) 32%, transparent);
|
||||
border-radius: max(rem(2px), calc(var(--mantine-radius-default) / 2));
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 18%, var(--mantine-color-body));
|
||||
box-shadow: 0 rem(1px) rem(3px) rgb(0 0 0 / 8%);
|
||||
}
|
||||
|
||||
.container {
|
||||
border-style: dashed;
|
||||
border-color: color-mix(in srgb, var(--mantine-color-teal-filled) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--mantine-color-teal-light) 55%, var(--mantine-color-body));
|
||||
}
|
||||
|
||||
@media (max-width: $mantine-breakpoint-xs) {
|
||||
.canvas {
|
||||
min-height: 7rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Box, Center } from "@mantine/core";
|
||||
import { IconLayoutGrid } from "@tabler/icons-react";
|
||||
|
||||
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
|
||||
import { projectBoardLayout } from "@homarr/boards/layout-preview";
|
||||
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import classes from "./board-layout-thumbnail.module.css";
|
||||
|
||||
interface BoardLayoutThumbnailProps {
|
||||
preview: BoardPreviewData | null;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const maxThumbnailRows = 12;
|
||||
|
||||
export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailProps) => {
|
||||
const layout = preview?.layouts.at(0);
|
||||
if (!preview || !layout) {
|
||||
return (
|
||||
<Center className={classes.canvas} role="img" aria-label={label}>
|
||||
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const elements = projectBoardLayout(preview, layout, layout);
|
||||
const roots = preview.sections.filter((section) => section.kind === "empty");
|
||||
const lanes = boardLanes.flatMap((lane) => {
|
||||
const root = roots.find((section) => getRootSectionLane(section.xOffset) === lane);
|
||||
const columnCount = getBoardLaneColumnCount(layout, lane);
|
||||
return root && columnCount > 0 ? [{ lane, root, columnCount }] : [];
|
||||
});
|
||||
if (lanes.length === 0) {
|
||||
return (
|
||||
<Center className={classes.canvas} role="img" aria-label={label}>
|
||||
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={classes.canvas} role="img" aria-label={label}>
|
||||
<div
|
||||
className={classes.lanes}
|
||||
style={{ gridTemplateColumns: lanes.map(({ columnCount }) => `${columnCount}fr`).join(" ") }}
|
||||
>
|
||||
{lanes.map(({ lane, root, columnCount }) => {
|
||||
const laneElements = elements.filter(
|
||||
(element) =>
|
||||
element.sectionId === root.id &&
|
||||
element.xOffset >= 0 &&
|
||||
element.xOffset < columnCount &&
|
||||
element.yOffset >= 0 &&
|
||||
element.yOffset < maxThumbnailRows &&
|
||||
element.width > 0 &&
|
||||
element.height > 0,
|
||||
);
|
||||
const rowCount = Math.min(
|
||||
maxThumbnailRows,
|
||||
Math.max(4, ...laneElements.map((element) => element.yOffset + element.height)),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lane}
|
||||
className={classes.lane}
|
||||
data-lane={lane}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${rowCount}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{laneElements.map((element) => (
|
||||
<span
|
||||
key={`${element.type}-${element.id}`}
|
||||
aria-hidden
|
||||
className={`${classes.tile} ${element.type === "section" ? classes.container : ""}`}
|
||||
style={{
|
||||
gridColumn: `${element.xOffset + 1} / span ${Math.min(element.width, columnCount - element.xOffset)}`,
|
||||
gridRow: `${element.yOffset + 1} / span ${Math.min(element.height, rowCount - element.yOffset)}`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -196,6 +196,7 @@ const ItemSelectModalContent = ({
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
ariaLabel={t("item.create.search")}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
|
||||
const [item] = filteredItems;
|
||||
|
||||
@@ -1,76 +1,6 @@
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import {
|
||||
featuredIntegrations,
|
||||
getIntegrationName,
|
||||
hiddenFromOnboarding,
|
||||
integrationDefs,
|
||||
integrationKinds,
|
||||
getWidgetKindsForIntegration,
|
||||
} from "@homarr/definitions";
|
||||
|
||||
export const categoryTranslationKeys: Record<string, string> = {
|
||||
dnsHole: "integration.category.dnsHole",
|
||||
mediaService: "integration.category.mediaService",
|
||||
calendar: "integration.category.calendar",
|
||||
mediaSearch: "integration.category.mediaSearch",
|
||||
mediaRelease: "integration.category.mediaRelease",
|
||||
mediaRequest: "integration.category.mediaRequest",
|
||||
downloadClient: "integration.category.downloadClient",
|
||||
usenet: "integration.category.usenet",
|
||||
torrent: "integration.category.torrent",
|
||||
miscellaneous: "integration.category.miscellaneous",
|
||||
smartHomeServer: "integration.category.smartHomeServer",
|
||||
indexerManager: "integration.category.indexerManager",
|
||||
healthMonitoring: "integration.category.healthMonitoring",
|
||||
beszel: "integration.category.beszel",
|
||||
search: "integration.category.search",
|
||||
mediaTranscoding: "integration.category.mediaTranscoding",
|
||||
networkController: "integration.category.networkController",
|
||||
notifications: "integration.category.notifications",
|
||||
firewall: "integration.category.firewall",
|
||||
photoService: "integration.category.photoService",
|
||||
notes: "integration.category.notes",
|
||||
mediaMonitoring: "integration.category.mediaMonitoring",
|
||||
speedtest: "integration.category.speedtest",
|
||||
analytics: "integration.category.analytics",
|
||||
vpn: "integration.category.vpn",
|
||||
documents: "integration.category.documents",
|
||||
mediaLibrary: "integration.category.mediaLibrary",
|
||||
uptimeMonitoring: "integration.category.uptimeMonitoring",
|
||||
};
|
||||
|
||||
export interface IntegrationGridItem {
|
||||
kind: IntegrationKind;
|
||||
name: string;
|
||||
categories: string[];
|
||||
widgets: WidgetKind[];
|
||||
}
|
||||
|
||||
export const buildSortedIntegrations = (
|
||||
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
|
||||
): IntegrationGridItem[] => {
|
||||
return integrationKinds
|
||||
.filter((kind) => {
|
||||
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
|
||||
if (options.onboarding && getWidgetKindsForIntegration(kind).length === 0) return false;
|
||||
if (!options.enableMockIntegration && kind === "mock") return false;
|
||||
return true;
|
||||
})
|
||||
.map((kind) => ({
|
||||
kind,
|
||||
name: getIntegrationName(kind),
|
||||
categories: [...new Set(integrationDefs[kind].category.flat())] as string[],
|
||||
widgets: getWidgetKindsForIntegration(kind),
|
||||
}))
|
||||
.toSorted((left, right) => {
|
||||
const leftIdx = featuredIntegrations.indexOf(left.kind);
|
||||
const rightIdx = featuredIntegrations.indexOf(right.kind);
|
||||
if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx;
|
||||
if (leftIdx !== -1) return -1;
|
||||
if (rightIdx !== -1) return 1;
|
||||
return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name);
|
||||
});
|
||||
};
|
||||
|
||||
export const filterIntegrations = (items: IntegrationGridItem[], search: string) =>
|
||||
items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim()));
|
||||
export {
|
||||
buildSortedIntegrations,
|
||||
categoryTranslationKeys,
|
||||
filterIntegrations,
|
||||
} from "@homarr/ui/integration-select-grid";
|
||||
export type { IntegrationGridItem } from "@homarr/ui/integration-select-grid";
|
||||
@@ -1,140 +1,2 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconBrandDocker, IconCheck, IconPuzzle } from "@tabler/icons-react";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
|
||||
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
|
||||
|
||||
interface IntegrationMultiSelectGridProps {
|
||||
selectedKinds: IntegrationKind[];
|
||||
onSelectionChange: (kinds: IntegrationKind[]) => void;
|
||||
enableMockIntegration?: boolean;
|
||||
onboarding?: boolean;
|
||||
detectedKinds?: Set<IntegrationKind>;
|
||||
}
|
||||
|
||||
export const IntegrationMultiSelectGrid = ({
|
||||
selectedKinds: selectedKindsArray,
|
||||
onSelectionChange,
|
||||
enableMockIntegration = false,
|
||||
onboarding = false,
|
||||
detectedKinds,
|
||||
}: IntegrationMultiSelectGridProps) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const selectedKinds = useMemo(() => new Set(selectedKindsArray), [selectedKindsArray]);
|
||||
const t = useI18n();
|
||||
|
||||
const toggleKind = useCallback(
|
||||
(kind: IntegrationKind) => {
|
||||
const current = new Set(selectedKindsArray);
|
||||
if (current.has(kind)) {
|
||||
current.delete(kind);
|
||||
} else {
|
||||
current.add(kind);
|
||||
}
|
||||
onSelectionChange(Array.from(current));
|
||||
},
|
||||
[selectedKindsArray, onSelectionChange],
|
||||
);
|
||||
|
||||
const integrations = useMemo(
|
||||
() => buildSortedIntegrations({ enableMockIntegration, onboarding }),
|
||||
[enableMockIntegration, onboarding],
|
||||
);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!detectedKinds || detectedKinds.size === 0) return integrations;
|
||||
const detected = integrations.filter((i) => detectedKinds.has(i.kind));
|
||||
const rest = integrations.filter((i) => !detectedKinds.has(i.kind));
|
||||
return [...detected, ...rest];
|
||||
}, [integrations, detectedKinds]);
|
||||
|
||||
const filtered = useMemo(() => filterIntegrations(sorted, search), [sorted, search]);
|
||||
|
||||
return (
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("integration.page.list.search")}...`}
|
||||
disableScroll={onboarding}
|
||||
disableAutoFocus={onboarding}
|
||||
>
|
||||
{filtered.map((integration) => {
|
||||
const isSelected = selectedKinds.has(integration.kind);
|
||||
const isDetected = detectedKinds?.has(integration.kind) ?? false;
|
||||
return (
|
||||
<Card
|
||||
key={integration.kind}
|
||||
h={selectGridCardHeight}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
|
||||
borderWidth: isSelected ? 2 : undefined,
|
||||
}}
|
||||
onClick={() => toggleKind(integration.kind)}
|
||||
withBorder
|
||||
>
|
||||
<Stack justify="space-between" h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||
<Text fw={500} lh={1.2} size="sm" lineClamp={2} style={{ flex: 1 }}>
|
||||
{integration.name}
|
||||
</Text>
|
||||
{isSelected && <IconCheck size={18} color="var(--mantine-color-blue-6)" style={{ flexShrink: 0 }} />}
|
||||
</Group>
|
||||
|
||||
<Stack gap={4} mt="auto">
|
||||
{isDetected && (
|
||||
<Badge variant="light" color="teal" size="xs" leftSection={<IconBrandDocker size={12} />}>
|
||||
{t("integration.grid.detected")}
|
||||
</Badge>
|
||||
)}
|
||||
<Group gap={4} wrap="wrap">
|
||||
{integration.categories.slice(0, 2).map((cat) => (
|
||||
<Badge key={cat} variant="light" size="xs">
|
||||
{categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{integration.widgets.length > 0 ? (
|
||||
<Tooltip
|
||||
multiline
|
||||
w={200}
|
||||
label={
|
||||
<Stack gap={4}>
|
||||
{integration.widgets.map((widgetKind) => (
|
||||
<Text key={widgetKind} size="xs">
|
||||
{t(`widget.${widgetKind}.name`)}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
|
||||
{t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{t("integration.grid.noWidgets")}
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
);
|
||||
};
|
||||
export { IntegrationMultiSelectGrid } from "@homarr/ui/integration-select-grid";
|
||||
export type { IntegrationMultiSelectGridProps } from "@homarr/ui/integration-select-grid";
|
||||
@@ -1,90 +1,2 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconPuzzle } from "@tabler/icons-react";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
|
||||
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
|
||||
|
||||
interface IntegrationSelectGridProps {
|
||||
onSelect: (kind: IntegrationKind) => void;
|
||||
enableMockIntegration?: boolean;
|
||||
}
|
||||
|
||||
export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false }: IntegrationSelectGridProps) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const t = useI18n();
|
||||
|
||||
const integrations = useMemo(() => buildSortedIntegrations({ enableMockIntegration }), [enableMockIntegration]);
|
||||
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
|
||||
|
||||
return (
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("integration.page.list.search")}...`}
|
||||
>
|
||||
{filtered.map((integration) => (
|
||||
<Card
|
||||
key={integration.kind}
|
||||
h={selectGridCardHeight}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => onSelect(integration.kind)}
|
||||
withBorder
|
||||
>
|
||||
<Stack justify="space-between" h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||
<Text fw={500} lh={1.2} size="sm" lineClamp={2}>
|
||||
{integration.name}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Stack gap={4} mt="auto">
|
||||
<Group gap={4} wrap="wrap">
|
||||
{integration.categories.slice(0, 2).map((cat) => (
|
||||
<Badge key={cat} variant="light" size="xs">
|
||||
{categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{integration.widgets.length > 0 ? (
|
||||
<Tooltip
|
||||
multiline
|
||||
w={200}
|
||||
label={
|
||||
<Stack gap={4}>
|
||||
{integration.widgets.map((widgetKind) => (
|
||||
<Text key={widgetKind} size="xs">
|
||||
{t(`widget.${widgetKind}.name`)}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
|
||||
{t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{t("integration.grid.noWidgets")}
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
);
|
||||
};
|
||||
export { IntegrationSelectGrid } from "@homarr/ui/integration-select-grid";
|
||||
export type { IntegrationSelectGridProps } from "@homarr/ui/integration-select-grid";
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Box, Group, Paper, Stack } from "@mantine/core";
|
||||
|
||||
import type { ManagePageLayoutProps } from "./manage-page-layout";
|
||||
import { ManagePageLayout } from "./manage-page-layout";
|
||||
|
||||
export interface ManageCollectionPageProps extends Omit<ManagePageLayoutProps, "children"> {
|
||||
ariaLabel: string;
|
||||
itemCount: number;
|
||||
emptyState: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ManageCollectionPage = ({
|
||||
ariaLabel,
|
||||
itemCount,
|
||||
emptyState,
|
||||
children,
|
||||
...layoutProps
|
||||
}: ManageCollectionPageProps) => (
|
||||
<ManagePageLayout {...layoutProps}>
|
||||
{itemCount === 0 ? (
|
||||
emptyState
|
||||
) : (
|
||||
<Stack component="ul" aria-label={ariaLabel} gap="sm" m={0} p={0} style={{ listStyle: "none" }}>
|
||||
{children}
|
||||
</Stack>
|
||||
)}
|
||||
</ManagePageLayout>
|
||||
);
|
||||
|
||||
export interface ManageCollectionItemProps {
|
||||
leading?: ReactNode;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
metadata?: ReactNode;
|
||||
badges?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export const ManageCollectionItem = ({
|
||||
leading,
|
||||
title,
|
||||
description,
|
||||
metadata,
|
||||
badges,
|
||||
actions,
|
||||
}: ManageCollectionItemProps) => (
|
||||
<Paper component="li" p="sm" withBorder radius="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm">
|
||||
{leading && <Box style={{ flexShrink: 0 }}>{leading}</Box>}
|
||||
<Stack gap={4} flex={1} miw={0}>
|
||||
<Group gap="xs" wrap="wrap">
|
||||
{title}
|
||||
{badges}
|
||||
</Group>
|
||||
{description}
|
||||
{metadata}
|
||||
</Stack>
|
||||
{actions && (
|
||||
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{actions}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
@@ -6,7 +6,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { ManageContainer } from "./manage-container";
|
||||
import { MANAGE_FLOATING_ACTION_BOTTOM_OFFSET } from "./manage-page.constants";
|
||||
|
||||
interface ManagePageLayoutProps {
|
||||
export interface ManagePageLayoutProps {
|
||||
title: ReactNode;
|
||||
primaryAction?: ReactNode;
|
||||
toolbar?: ReactNode;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Anchor, Card, Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
@@ -5,6 +6,7 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
interface NoResultsProps {
|
||||
icon: TablerIcon;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
action?: {
|
||||
label: string;
|
||||
href: string;
|
||||
@@ -12,7 +14,7 @@ interface NoResultsProps {
|
||||
};
|
||||
}
|
||||
|
||||
export const NoResults = ({ icon: Icon, title, action }: NoResultsProps) => {
|
||||
export const NoResults = ({ icon: Icon, title, description, action }: NoResultsProps) => {
|
||||
return (
|
||||
<Card bg="transparent" withBorder={false}>
|
||||
<Stack align="center" gap="sm">
|
||||
@@ -20,6 +22,11 @@ export const NoResults = ({ icon: Icon, title, action }: NoResultsProps) => {
|
||||
<Text fw={500} size="lg">
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text c="dimmed" ta="center" maw={560}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{action && !action.hidden && <Anchor href={action.href}>{action.label}</Anchor>}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -62,6 +62,16 @@ describe("locale and onboarding proxy", () => {
|
||||
expect(mocks.getDefaultLocale).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("allows administrator sign-in while onboarding is unfinished", async () => {
|
||||
mocks.getOnboardingStep.mockResolvedValue("setup");
|
||||
const { proxy } = await loadProxy();
|
||||
|
||||
const response = await proxy(createRequest("/en/auth/login"));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.getOnboardingStep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the configured default locale for a missing or invalid locale cookie", async () => {
|
||||
mocks.getDefaultLocale.mockResolvedValue("de");
|
||||
const { proxy } = await loadProxy();
|
||||
|
||||
@@ -42,8 +42,9 @@ const getDefaultLocaleDedupedAsync = () => {
|
||||
export async function proxy(request: NextRequest) {
|
||||
// Redirect to onboarding if it's not finished yet
|
||||
const pathname = request.nextUrl.pathname;
|
||||
const isOnboardingAccessRoute = /\/(?:init|auth\/login)\/?$/.test(pathname);
|
||||
|
||||
if (!isOnboardingFinished && !pathname.endsWith("/init")) {
|
||||
if (!isOnboardingFinished && !isOnboardingAccessRoute) {
|
||||
const currentOnboardingStep = await getOnboardingStepDedupedAsync();
|
||||
if (currentOnboardingStep !== "finish") {
|
||||
return NextResponse.redirect(new URL("/init", request.url));
|
||||
|
||||
+107
-8
@@ -1,12 +1,102 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { chromium } from "@playwright/test";
|
||||
import { describe, test } from "vitest";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { OnboardingActions } from "./shared/actions/onboarding-actions";
|
||||
import { OnboardingAssertions } from "./shared/assertions/onboarding-assertions";
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
|
||||
const captureScreenshotAsync = async (page: Page, name: string) => {
|
||||
const directory = process.env.HOMARR_E2E_SCREENSHOT_DIR?.trim();
|
||||
if (!directory) return;
|
||||
await mkdir(directory, { recursive: true });
|
||||
await page.screenshot({ path: path.join(directory, `onboarding-${name}.png`), fullPage: true });
|
||||
};
|
||||
|
||||
describe("Onboarding", () => {
|
||||
test.each([
|
||||
{ name: "mobile", width: 320, height: 740 },
|
||||
{ name: "tablet", width: 768, height: 1024 },
|
||||
{ name: "desktop", width: 1440, height: 900 },
|
||||
])("Onboarding is usable at the $name viewport", async ({ name, width, height }) => {
|
||||
const { localMountPath } = await createSqliteDbFileAsync();
|
||||
const homarrContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width, height }, reducedMotion: "reduce" });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/init`);
|
||||
await page.getByRole("heading", { name: "Make home feel organized." }).waitFor();
|
||||
const start = page.getByRole("button", { name: "Get started" });
|
||||
await start.focus();
|
||||
await start.press("Enter");
|
||||
await page.getByRole("heading", { name: "Create your administrator" }).waitFor();
|
||||
|
||||
await page.getByLabel("Administrator username").fill("admin");
|
||||
await page.getByLabel("Password", { exact: true }).fill("Comp(exP4sswOrd");
|
||||
await page.getByLabel("Confirm password").fill("Comp(exP4sswOrd");
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor({ timeout: 30_000 });
|
||||
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1),
|
||||
).toBe(true);
|
||||
await captureScreenshotAsync(page, `${name}-studio`);
|
||||
|
||||
const activeSection = page.locator('[aria-current="step"]');
|
||||
await activeSection.focus();
|
||||
await activeSection.press("ArrowLeft");
|
||||
await page.getByRole("heading", { name: "One board, built around your services" }).waitFor();
|
||||
expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true);
|
||||
await activeSection.press("ArrowRight");
|
||||
await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor();
|
||||
expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true);
|
||||
await activeSection.press("End");
|
||||
await page.getByRole("heading", { name: "One board, built around your services" }).waitFor();
|
||||
await activeSection.press("Home");
|
||||
await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor();
|
||||
expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true);
|
||||
|
||||
const sections = [
|
||||
["Discover", "See what this installation can actually reach"],
|
||||
["Connect", "Connect what makes the board useful"],
|
||||
["Board", "Shape the board before it is built"],
|
||||
["Extend", "Add intelligence when it helps"],
|
||||
] as const;
|
||||
for (const [section, heading] of sections) {
|
||||
await page.getByRole("button", { name: new RegExp(`^${section}`) }).click();
|
||||
await page.getByRole("heading", { name: heading }).waitFor();
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: /^Connect/ }).click();
|
||||
const sonarr = page.locator('button[aria-label="Sonarr"]');
|
||||
await sonarr.click();
|
||||
await page.getByLabel("Service URL").waitFor();
|
||||
await captureScreenshotAsync(page, `${name}-integration-details`);
|
||||
await sonarr.click();
|
||||
await page.getByLabel("Service URL").waitFor({ state: "detached" });
|
||||
|
||||
await activeSection.focus();
|
||||
await activeSection.press("End");
|
||||
await page.getByRole("heading", { name: "One board, built around your services" }).waitFor();
|
||||
await captureScreenshotAsync(page, `${name}-review`);
|
||||
|
||||
await page.getByRole("button", { name: "Build my board" }).click();
|
||||
await page.getByRole("heading", { name: "Your first board is ready." }).waitFor({ timeout: 30_000 });
|
||||
await captureScreenshotAsync(page, `${name}-finish`);
|
||||
} finally {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
test("Credentials onboarding should be successful", async () => {
|
||||
// Arrange
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
@@ -25,7 +115,15 @@ describe("Onboarding", () => {
|
||||
try {
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
await actions.startOnboardingAsync("scratch");
|
||||
await actions.startOnboardingAsync();
|
||||
|
||||
const competingContext = await browser.newContext();
|
||||
const claimResponse = await competingContext.request.post(
|
||||
`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/api/onboarding/claim`,
|
||||
);
|
||||
expect(claimResponse.status()).toBe(423);
|
||||
await competingContext.close();
|
||||
|
||||
await actions.processUserStepAsync({
|
||||
username: "admin",
|
||||
password: "Comp(exP4sswOrd",
|
||||
@@ -43,7 +141,7 @@ describe("Onboarding", () => {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("External provider onboarding setup should be successful", async () => {
|
||||
test("External provider onboarding requires sign-in before privileged setup", async () => {
|
||||
// Arrange
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
@@ -69,17 +167,18 @@ describe("Onboarding", () => {
|
||||
try {
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
await actions.startOnboardingAsync("scratch");
|
||||
await actions.startOnboardingAsync();
|
||||
await actions.processExternalGroupStepAsync({
|
||||
name: externalGroupName,
|
||||
});
|
||||
await actions.processSettingsStepAsync();
|
||||
await actions.processIntegrationsStepAsync();
|
||||
|
||||
// Assert
|
||||
await assertions.assertFinishStepVisibleAsync();
|
||||
await page.getByRole("heading", { name: "Administrator sign-in required" }).waitFor();
|
||||
expect(await page.getByRole("link", { name: "Sign in" }).getAttribute("href")).toBe(
|
||||
"/auth/login?callbackUrl=%2Finit",
|
||||
);
|
||||
await assertions.assertExternalGroupInsertedAsync(externalGroupName);
|
||||
await assertions.assertDbOnboardingStepAsync("finish");
|
||||
await assertions.assertDbOnboardingStepAsync("setup");
|
||||
} finally {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
}
|
||||
|
||||
@@ -27,14 +27,14 @@ export class OnboardingActions {
|
||||
}
|
||||
}
|
||||
|
||||
public async startOnboardingAsync(type: "scratch" | "before 1.0") {
|
||||
await this.page.locator("button", { hasText: type }).click();
|
||||
public async startOnboardingAsync() {
|
||||
await this.page.getByRole("button", { name: "Get started" }).click();
|
||||
}
|
||||
|
||||
public async processUserStepAsync(input: { username: string; password: string; confirmPassword: string }) {
|
||||
await this.page.waitForSelector("text=administrator user");
|
||||
await this.page.getByRole("heading", { name: "Create your administrator" }).waitFor();
|
||||
|
||||
await this.page.getByLabel("Username").fill(input.username);
|
||||
await this.page.getByLabel("Administrator username").fill(input.username);
|
||||
await this.page.getByLabel("Password", { exact: true }).fill(input.password);
|
||||
await this.page.getByLabel("Confirm password").fill(input.confirmPassword);
|
||||
|
||||
@@ -42,18 +42,16 @@ export class OnboardingActions {
|
||||
}
|
||||
|
||||
public async processExternalGroupStepAsync(input: { name: string }) {
|
||||
await this.page.waitForSelector("text=external provider");
|
||||
await this.page.locator("input").fill(input.name);
|
||||
await this.page.getByRole("heading", { name: "Connect your administrator group" }).waitFor();
|
||||
await this.page.getByLabel("External administrator group").fill(input.name);
|
||||
await this.page.locator("css=button[type='submit']").click();
|
||||
}
|
||||
|
||||
public async processSettingsStepAsync() {
|
||||
await this.page.waitForSelector("text=Analytics");
|
||||
await this.page.locator("css=button[type='submit']").click();
|
||||
await this.page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor();
|
||||
}
|
||||
|
||||
public async processIntegrationsStepAsync() {
|
||||
await this.page.waitForSelector("text=integrations you'd like to connect");
|
||||
await this.page.locator("button", { hasText: "Skip for now" }).click();
|
||||
await this.page.getByRole("button", { name: "Build my board" }).click();
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,6 @@ export class OnboardingAssertions {
|
||||
}
|
||||
|
||||
public async assertFinishStepVisibleAsync() {
|
||||
await this.page.waitForSelector("text=completed the setup", { timeout: 30_000 });
|
||||
await this.page.getByRole("heading", { name: "Your first board is ready." }).waitFor({ timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,13 @@ export const seedAdminUserAsync = async (db: SqliteDatabase, input: SeedAdminUse
|
||||
if (existingOnboarding) {
|
||||
await db.update(sqliteSchema.onboarding).set({
|
||||
step: "finish",
|
||||
previousStep: "settings",
|
||||
previousStep: "setup",
|
||||
});
|
||||
} else {
|
||||
await db.insert(sqliteSchema.onboarding).values({
|
||||
id: createId(),
|
||||
step: "finish",
|
||||
previousStep: "settings",
|
||||
previousStep: "setup",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"./websocket": "./src/websocket.ts",
|
||||
"./shared": "./src/shared.ts",
|
||||
"./open-api": "./src/open-api.ts",
|
||||
"./onboarding-claim": "./src/onboarding-claim.ts",
|
||||
"./assistant": "./src/assistant.ts",
|
||||
"./onboarding-step": "./src/onboarding-step.ts",
|
||||
"./custom-widget/display-data": "./src/router/custom-widget/display-data.ts",
|
||||
"./mcp": "./src/mcp.ts",
|
||||
"./custom-widget-configuration": "./src/custom-widget-configuration.ts"
|
||||
@@ -41,8 +43,6 @@
|
||||
"@homarr/docker": "workspace:^0.1.0",
|
||||
"@homarr/icons": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/old-import": "workspace:^0.1.0",
|
||||
"@homarr/old-schema": "workspace:^0.1.0",
|
||||
"@homarr/ping": "workspace:^0.1.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/request-handler": "workspace:^0.1.0",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { serverSettings } from "@homarr/db/schema";
|
||||
|
||||
export const onboardingClaimCookieName = "homarr-onboarding-claim";
|
||||
export const onboardingClaimSettingKey = "__onboarding_claim";
|
||||
export const onboardingClaimTtlMs = 4 * 60 * 60 * 1000;
|
||||
|
||||
interface StoredOnboardingClaim {
|
||||
hash: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export type ClaimOnboardingResult =
|
||||
| { status: "issued" | "active"; token: string; expiresAt: number }
|
||||
| { status: "locked"; expiresAt: number }
|
||||
| { status: "finished" | "forbidden" };
|
||||
|
||||
const hashToken = (token: string) => createHash("sha256").update(token).digest("hex");
|
||||
|
||||
const parseStoredClaim = (value: string): StoredOnboardingClaim | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<StoredOnboardingClaim>;
|
||||
if (typeof parsed.hash !== "string" || !/^[0-9a-f]{64}$/.test(parsed.hash)) return null;
|
||||
if (typeof parsed.expiresAt !== "number" || !Number.isSafeInteger(parsed.expiresAt)) return null;
|
||||
return { hash: parsed.hash, expiresAt: parsed.expiresAt };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const tokenMatches = (claim: StoredOnboardingClaim, token: string, now: number) => {
|
||||
if (claim.expiresAt <= now || token.length === 0) return false;
|
||||
const expected = Buffer.from(claim.hash, "hex");
|
||||
const actual = Buffer.from(hashToken(token), "hex");
|
||||
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||
};
|
||||
|
||||
const getClaimRowAsync = async (db: Database) =>
|
||||
await db.query.serverSettings.findFirst({ where: eq(serverSettings.settingKey, onboardingClaimSettingKey) });
|
||||
|
||||
export const getOnboardingClaimTokenFromCookieHeader = (cookieHeader: string | null) => {
|
||||
if (!cookieHeader) return undefined;
|
||||
for (const part of cookieHeader.split(";")) {
|
||||
const separator = part.indexOf("=");
|
||||
if (separator < 0) continue;
|
||||
const name = part.slice(0, separator).trim();
|
||||
if (name !== onboardingClaimCookieName) continue;
|
||||
const value = part.slice(separator + 1).trim();
|
||||
return value || undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const isOnboardingClaimValidAsync = async (db: Database, token: string | undefined, now = Date.now()) => {
|
||||
if (!token) return false;
|
||||
const row = await getClaimRowAsync(db);
|
||||
const claim = row ? parseStoredClaim(row.value) : null;
|
||||
return claim ? tokenMatches(claim, token, now) : false;
|
||||
};
|
||||
|
||||
export const isClaimOnlyOnboardingAccessAllowedAsync = async (
|
||||
db: Database,
|
||||
token: string | undefined,
|
||||
now = Date.now(),
|
||||
) => {
|
||||
const existingUser = await db.query.users.findFirst({ columns: { id: true } });
|
||||
return !existingUser && (await isOnboardingClaimValidAsync(db, token, now));
|
||||
};
|
||||
|
||||
export const claimOnboardingAsync = async (
|
||||
db: Database,
|
||||
options: { currentToken?: string; force?: boolean; now?: number; ttlMs?: number } = {},
|
||||
): Promise<ClaimOnboardingResult> => {
|
||||
const onboarding = await db.query.onboarding.findFirst();
|
||||
if (onboarding?.step === "finish") return { status: "finished" };
|
||||
const existingUser = await db.query.users.findFirst({ columns: { id: true } });
|
||||
if (existingUser && !options.force) return { status: "forbidden" };
|
||||
|
||||
const now = options.now ?? Date.now();
|
||||
const currentRow = await getClaimRowAsync(db);
|
||||
const currentClaim = currentRow ? parseStoredClaim(currentRow.value) : null;
|
||||
if (currentClaim && options.currentToken && tokenMatches(currentClaim, options.currentToken, now)) {
|
||||
return { status: "active", token: options.currentToken, expiresAt: currentClaim.expiresAt };
|
||||
}
|
||||
if (currentClaim && currentClaim.expiresAt > now && !options.force) {
|
||||
return { status: "locked", expiresAt: currentClaim.expiresAt };
|
||||
}
|
||||
|
||||
if (currentRow) {
|
||||
await db
|
||||
.delete(serverSettings)
|
||||
.where(and(eq(serverSettings.settingKey, onboardingClaimSettingKey), eq(serverSettings.value, currentRow.value)));
|
||||
}
|
||||
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const expiresAt = now + (options.ttlMs ?? onboardingClaimTtlMs);
|
||||
const value = JSON.stringify({ hash: hashToken(token), expiresAt } satisfies StoredOnboardingClaim);
|
||||
try {
|
||||
await db.insert(serverSettings).values({ settingKey: onboardingClaimSettingKey, value });
|
||||
} catch (error) {
|
||||
const winner = await getClaimRowAsync(db);
|
||||
const winnerClaim = winner ? parseStoredClaim(winner.value) : null;
|
||||
if (winnerClaim?.expiresAt && winnerClaim.expiresAt > now) {
|
||||
return { status: "locked", expiresAt: winnerClaim.expiresAt };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return { status: "issued", token, expiresAt };
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { normalizeOnboardingStep } from "./router/onboard/onboard-queries";
|
||||
@@ -16,7 +16,6 @@ export const appRouter = createTRPCRouter({
|
||||
location: lazy(() => import("./router/location").then((mod) => mod.locationRouter)),
|
||||
log: lazy(() => import("./router/log").then((mod) => mod.logRouter)),
|
||||
icon: lazy(() => import("./router/icons").then((mod) => mod.iconsRouter)),
|
||||
import: lazy(() => import("./router/import/import-router").then((mod) => mod.importRouter)),
|
||||
onboard: lazy(() => import("./router/onboard/onboard-router").then((mod) => mod.onboardRouter)),
|
||||
home: lazy(() => import("./router/home").then((mod) => mod.homeRouter)),
|
||||
docker: lazy(() => import("./router/docker/docker-router").then((mod) => mod.dockerRouter)),
|
||||
|
||||
@@ -18,8 +18,7 @@ import { AppAccessControl } from "./app/app-access-control";
|
||||
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
getPaginated: permissionRequiredProcedure
|
||||
.requiresPermission("board-modify-all")
|
||||
getPaginated: protectedProcedure
|
||||
.input(paginatedSchema)
|
||||
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
|
||||
.meta({
|
||||
|
||||
@@ -31,26 +31,22 @@ import {
|
||||
sections,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import type { BoardLane, WidgetKind } from "@homarr/definitions";
|
||||
import type { BoardLane, IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import {
|
||||
boardLanes,
|
||||
emptySuperJSON,
|
||||
everyoneGroup,
|
||||
getBoardLaneColumnCount,
|
||||
getRootSectionLane,
|
||||
getWidgetIntegrationIssue,
|
||||
getWidgetIntegrationIssueMessage,
|
||||
getPermissionsWithChildren,
|
||||
getPermissionsWithParents,
|
||||
normalizeBoardLayoutRoles,
|
||||
rootSectionOffsets,
|
||||
widgetDefaultSizes,
|
||||
widgetIntegrationLimits,
|
||||
widgetIntegrationSupport,
|
||||
widgetKindsWithOptionalIntegrations,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
import { importOldmarrAsync } from "@homarr/old-import";
|
||||
import { importJsonFileSchema } from "@homarr/old-import/shared";
|
||||
import { oldmarrConfigSchema } from "@homarr/old-schema";
|
||||
import {
|
||||
addItemToBoardSchema,
|
||||
boardByNameSchema,
|
||||
@@ -193,31 +189,8 @@ export const boardRouter = createTRPCRouter({
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||
where: eq(boardUserPermissions.userId, userId ?? ""),
|
||||
});
|
||||
|
||||
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId ?? ""),
|
||||
with: {
|
||||
group: {
|
||||
with: {
|
||||
boardPermissions: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const boardIds = permissionsOfCurrentUserWhenPresent
|
||||
.map((permission) => permission.boardId)
|
||||
.concat(
|
||||
permissionsOfCurrentUserGroupsWhenPresent
|
||||
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
|
||||
.flat(),
|
||||
);
|
||||
|
||||
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, userId ?? ""),
|
||||
});
|
||||
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
||||
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
||||
|
||||
const dbBoards = await ctx.db.query.boards.findMany({
|
||||
columns: {
|
||||
@@ -239,30 +212,121 @@ export const boardRouter = createTRPCRouter({
|
||||
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where:
|
||||
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
|
||||
? inArray(
|
||||
boardGroupPermissions.groupId,
|
||||
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
|
||||
)
|
||||
: undefined,
|
||||
where: groupPermissionWhere,
|
||||
},
|
||||
},
|
||||
// Allow viewing all boards if the user has the permission
|
||||
where: ctx.session?.user.permissions.includes("board-view-all")
|
||||
? undefined
|
||||
: or(
|
||||
eq(boards.isPublic, true),
|
||||
eq(boards.creatorId, ctx.session?.user.id ?? ""),
|
||||
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
|
||||
),
|
||||
where: getAccessibleBoardsWhere(ctx.session?.user.permissions.includes("board-view-all"), userId, boardIds),
|
||||
});
|
||||
return dbBoards.map((board) => ({
|
||||
...board,
|
||||
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
||||
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
|
||||
isHome: currentUser?.homeBoardId === board.id,
|
||||
isMobileHome: currentUser?.mobileHomeBoardId === board.id,
|
||||
}));
|
||||
}),
|
||||
getManageOverview: publicProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
||||
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
||||
|
||||
const dbBoards = await ctx.db.query.boards.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
logoImageUrl: true,
|
||||
isPublic: true,
|
||||
},
|
||||
with: {
|
||||
creator: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
userPermissions: {
|
||||
columns: { permission: true },
|
||||
where: eq(boardUserPermissions.userId, userId ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
columns: { permission: true },
|
||||
where: groupPermissionWhere,
|
||||
},
|
||||
layouts: {
|
||||
columns: {
|
||||
id: true,
|
||||
columnCount: true,
|
||||
leftGutterColumnCount: true,
|
||||
rightGutterColumnCount: true,
|
||||
breakpoint: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
sections: {
|
||||
columns: {
|
||||
id: true,
|
||||
kind: true,
|
||||
xOffset: true,
|
||||
},
|
||||
with: {
|
||||
layouts: {
|
||||
columns: {
|
||||
layoutId: true,
|
||||
parentSectionId: true,
|
||||
xOffset: true,
|
||||
yOffset: true,
|
||||
width: true,
|
||||
height: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
items: {
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
with: {
|
||||
layouts: {
|
||||
columns: {
|
||||
layoutId: true,
|
||||
sectionId: true,
|
||||
xOffset: true,
|
||||
yOffset: true,
|
||||
width: true,
|
||||
height: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: getAccessibleBoardsWhere(ctx.session?.user.permissions.includes("board-view-all"), userId, boardIds),
|
||||
});
|
||||
|
||||
return dbBoards.map(({ layouts: boardLayouts, sections: boardSections, items: boardItems, ...board }) => {
|
||||
const previewLayout =
|
||||
boardLayouts.find((layout) => layout.role === "base") ??
|
||||
boardLayouts.toSorted((first, second) => second.breakpoint - first.breakpoint).at(0);
|
||||
|
||||
return {
|
||||
...board,
|
||||
isHome: currentUser?.homeBoardId === board.id,
|
||||
isMobileHome: currentUser?.mobileHomeBoardId === board.id,
|
||||
preview: previewLayout
|
||||
? {
|
||||
layouts: [previewLayout],
|
||||
sections: boardSections.map((section) => ({
|
||||
...section,
|
||||
layouts: section.layouts.filter((layout) => layout.layoutId === previewLayout.id),
|
||||
})),
|
||||
items: boardItems.map((item) => ({
|
||||
...item,
|
||||
layouts: item.layouts.filter((layout) => layout.layoutId === previewLayout.id),
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}),
|
||||
search: publicProcedure
|
||||
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
@@ -1846,14 +1910,6 @@ export const boardRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
}),
|
||||
importOldmarrConfig: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
.input(importJsonFileSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const content = await input.file.text();
|
||||
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
|
||||
await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
|
||||
}),
|
||||
addItem: protectedProcedure
|
||||
.meta({
|
||||
openapi: { method: "POST", path: "/api/boards/items", tags: ["boards"], protect: true },
|
||||
@@ -1877,25 +1933,7 @@ export const boardRouter = createTRPCRouter({
|
||||
await validateTimetableOptionsChangeAsync(input.options);
|
||||
}
|
||||
|
||||
const supportedIntegrations = widgetIntegrationSupport[input.kind];
|
||||
const integrationLimit = widgetIntegrationLimits[input.kind] ?? Infinity;
|
||||
if (input.integrationIds.length > integrationLimit) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `${input.kind} supports at most ${integrationLimit} integration${integrationLimit === 1 ? "" : "s"}`,
|
||||
});
|
||||
}
|
||||
if (supportedIntegrations === undefined && input.integrationIds.length > 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `${input.kind} does not support integrations` });
|
||||
}
|
||||
if (
|
||||
supportedIntegrations !== undefined &&
|
||||
input.integrationIds.length === 0 &&
|
||||
!widgetKindsWithOptionalIntegrations.has(input.kind)
|
||||
) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `${input.kind} requires an integration` });
|
||||
}
|
||||
|
||||
let selectedIntegrationKinds: IntegrationKind[] = [];
|
||||
if (input.integrationIds.length > 0) {
|
||||
const existing = await ctx.db.query.integrations.findMany({
|
||||
columns: { id: true, kind: true },
|
||||
@@ -1907,19 +1945,21 @@ export const boardRouter = createTRPCRouter({
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` });
|
||||
}
|
||||
|
||||
selectedIntegrationKinds = existing.map((integration) => integration.kind);
|
||||
|
||||
await Promise.all(
|
||||
input.integrationIds.map((integrationId) =>
|
||||
throwIfIntegrationActionForbiddenAsync(ctx, eq(integrations.id, integrationId), "use"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const incompatible = existing.filter((integration) => !supportedIntegrations?.includes(integration.kind));
|
||||
if (incompatible.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `${input.kind} does not support integration kind${incompatible.length === 1 ? "" : "s"}: ${incompatible.map((integration) => integration.kind).join(", ")}`,
|
||||
});
|
||||
}
|
||||
const integrationIssue = getWidgetIntegrationIssue(input.kind, selectedIntegrationKinds);
|
||||
if (integrationIssue) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: getWidgetIntegrationIssueMessage(input.kind, integrationIssue),
|
||||
});
|
||||
}
|
||||
|
||||
return await serializeBoardItemPlacementAsync(input.boardId, async () => {
|
||||
@@ -2372,6 +2412,59 @@ const getElementsForLayout = (board: BoardForLayoutProjection, layoutId: string)
|
||||
|
||||
const protectedLayoutRepairPromises = new Map<string, Promise<void>>();
|
||||
|
||||
const getBoardAccessContextAsync = async (db: Database, userId: string | undefined) => {
|
||||
const [userPermissions, groupMemberships, currentUser] = await Promise.all([
|
||||
db.query.boardUserPermissions.findMany({
|
||||
where: eq(boardUserPermissions.userId, userId ?? ""),
|
||||
}),
|
||||
db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId ?? ""),
|
||||
with: {
|
||||
group: {
|
||||
with: {
|
||||
boardPermissions: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.query.users.findFirst({
|
||||
where: eq(users.id, userId ?? ""),
|
||||
columns: {
|
||||
homeBoardId: true,
|
||||
mobileHomeBoardId: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const boardIds = userPermissions
|
||||
.map((permission) => permission.boardId)
|
||||
.concat(
|
||||
groupMemberships.flatMap((membership) =>
|
||||
membership.group.boardPermissions.map((permission) => permission.boardId),
|
||||
),
|
||||
);
|
||||
|
||||
return { boardIds, currentUser, groupMemberships };
|
||||
};
|
||||
|
||||
const getBoardGroupPermissionWhere = (
|
||||
groupMemberships: Awaited<ReturnType<typeof getBoardAccessContextAsync>>["groupMemberships"],
|
||||
) =>
|
||||
groupMemberships.length > 0
|
||||
? inArray(
|
||||
boardGroupPermissions.groupId,
|
||||
groupMemberships.map((membership) => membership.groupId),
|
||||
)
|
||||
: eq(boardGroupPermissions.groupId, "");
|
||||
|
||||
const getAccessibleBoardsWhere = (canViewAll: boolean | undefined, userId: string | undefined, boardIds: string[]) =>
|
||||
canViewAll
|
||||
? undefined
|
||||
: or(
|
||||
eq(boards.isPublic, true),
|
||||
eq(boards.creatorId, userId ?? ""),
|
||||
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
|
||||
);
|
||||
|
||||
const getFullBoardWithWhereAsync = async (
|
||||
db: Database,
|
||||
where: SQL<unknown>,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createId } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq, handleTransactionsAsync, like, not } from "@homarr/db";
|
||||
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema";
|
||||
import { groupMembers, groupPermissions, groups, onboarding, users } from "@homarr/db/schema";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
|
||||
import {
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
} from "@homarr/validation/group";
|
||||
|
||||
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||
|
||||
export const groupRouter = createTRPCRouter({
|
||||
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
|
||||
@@ -185,18 +184,28 @@ export const groupRouter = createTRPCRouter({
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
|
||||
const groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
const groupRow = {
|
||||
id: groupId,
|
||||
name: input.name,
|
||||
position: maxPosition + 1,
|
||||
});
|
||||
};
|
||||
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
permission: "admin",
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction.insert(schema.groups).values(groupRow);
|
||||
await transaction.insert(schema.groupPermissions).values({ groupId, permission: "admin" });
|
||||
await transaction.update(schema.onboarding).set({ previousStep: "group", step: "setup" });
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction.insert(groups).values(groupRow).run();
|
||||
transaction.insert(groupPermissions).values({ groupId, permission: "admin" }).run();
|
||||
transaction.update(onboarding).set({ previousStep: "group", step: "setup" }).run();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
createGroup: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
|
||||
@@ -101,7 +101,7 @@ export const homeRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.session?.user.permissions.includes("board-modify-all")) {
|
||||
if (ctx.session?.user.permissions.includes("app-create")) {
|
||||
statistics.push({
|
||||
titleKey: "app",
|
||||
subtitleKey: "resources",
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { analyseOldmarrImportForRouterAsync, analyseOldmarrImportInputSchema } from "@homarr/old-import/analyse";
|
||||
import {
|
||||
ensureValidTokenOrThrow,
|
||||
importInitialOldmarrAsync,
|
||||
importInitialOldmarrInputSchema,
|
||||
} from "@homarr/old-import/import";
|
||||
|
||||
import { createTRPCRouter, onboardingProcedure } from "../../trpc";
|
||||
import { nextOnboardingStepAsync } from "../onboard/onboard-queries";
|
||||
|
||||
export const importRouter = createTRPCRouter({
|
||||
analyseInitialOldmarrImport: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(analyseOldmarrImportInputSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return await analyseOldmarrImportForRouterAsync(input);
|
||||
}),
|
||||
validateToken: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(
|
||||
z.object({
|
||||
checksum: z.string(),
|
||||
token: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(({ input }) => {
|
||||
try {
|
||||
ensureValidTokenOrThrow(input.checksum, input.token);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
importInitialOldmarrImport: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(importInitialOldmarrInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await importInitialOldmarrAsync(ctx.db, input);
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
});
|
||||
@@ -1,84 +1,35 @@
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
import { groups, onboarding } from "@homarr/db/schema";
|
||||
import { onboarding } from "@homarr/db/schema";
|
||||
import type { OnboardingStep } from "@homarr/definitions";
|
||||
import { credentialsAdminGroup } from "@homarr/definitions";
|
||||
|
||||
export const nextOnboardingStepAsync = async (db: Database, preferredStep: OnboardingStep | undefined) => {
|
||||
export const nextOnboardingStepAsync = async (db: Database) => {
|
||||
const { current } = await getOnboardingOrFallbackAsync(db);
|
||||
const nextStepConfiguration = nextSteps[current];
|
||||
if (!nextStepConfiguration) return;
|
||||
if (current !== "start") return;
|
||||
const nextStep = getNextOnboardingStep();
|
||||
if (!nextStep) return;
|
||||
|
||||
for (const conditionalStep of objectEntries(nextStepConfiguration)) {
|
||||
if (!conditionalStep) continue;
|
||||
const [nextStep, condition] = conditionalStep;
|
||||
if (condition === "preferred" && nextStep !== preferredStep) continue;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (typeof condition === "boolean" && !condition) continue;
|
||||
if (typeof condition === "function" && !(await condition(db))) continue;
|
||||
|
||||
await db.update(onboarding).set({
|
||||
previousStep: current,
|
||||
step: nextStep,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await db.update(onboarding).set({ previousStep: current, step: nextStep });
|
||||
};
|
||||
|
||||
export const getOnboardingOrFallbackAsync = async (db: Database) => {
|
||||
const value = await db.query.onboarding.findFirst();
|
||||
if (!value) return { current: "start" as const, previous: null };
|
||||
|
||||
return { current: value.step, previous: value.previousStep };
|
||||
return {
|
||||
current: normalizeOnboardingStep(value.step),
|
||||
previous: value.previousStep ? normalizeOnboardingStep(value.previousStep) : null,
|
||||
};
|
||||
};
|
||||
|
||||
type NextStepCondition = true | "preferred" | ((db: Database) => MaybePromise<boolean>);
|
||||
|
||||
/**
|
||||
* The below object is a definition of which can be the next step of the current one.
|
||||
* If the value is `true`, it means the step can always be the next one.
|
||||
* If the value is `preferred`, it means that the step can only be reached if the input `preferredStep` is set to the step.
|
||||
* If the value is a function, it will be called with the database instance and should return a boolean.
|
||||
* If the value or result is `false`, the step has to be skipped and the next value or callback should be checked.
|
||||
*/
|
||||
const nextSteps: Partial<Record<OnboardingStep, Partial<Record<OnboardingStep, NextStepCondition>>>> = {
|
||||
start: {
|
||||
import: "preferred" as const,
|
||||
user: () => isProviderEnabled("credentials"),
|
||||
group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"),
|
||||
settings: true,
|
||||
},
|
||||
import: {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
user: async (db: Database) => {
|
||||
if (!isProviderEnabled("credentials")) return false;
|
||||
|
||||
const adminGroup = await db.query.groups.findFirst({
|
||||
where: eq(groups.name, credentialsAdminGroup),
|
||||
with: {
|
||||
members: true,
|
||||
},
|
||||
});
|
||||
|
||||
return !adminGroup || adminGroup.members.length === 0;
|
||||
},
|
||||
group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"),
|
||||
settings: true,
|
||||
},
|
||||
user: {
|
||||
group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"),
|
||||
settings: true,
|
||||
},
|
||||
group: {
|
||||
settings: true,
|
||||
},
|
||||
settings: {
|
||||
integrations: true,
|
||||
},
|
||||
integrations: {
|
||||
finish: true,
|
||||
},
|
||||
const getNextOnboardingStep = (): OnboardingStep => {
|
||||
if (isProviderEnabled("credentials")) return "user";
|
||||
if (isProviderEnabled("ldap") || isProviderEnabled("oidc")) return "group";
|
||||
return "setup";
|
||||
};
|
||||
|
||||
export const normalizeOnboardingStep = (step: string | null | undefined): OnboardingStep => {
|
||||
if (step === "user" || step === "group" || step === "setup" || step === "finish") return step;
|
||||
if (step === "settings" || step === "integrations") return "setup";
|
||||
return "start";
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -11,10 +11,8 @@ import {
|
||||
import { boards, serverSettings } from "@homarr/db/schema";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
import { settingsInitSchema } from "@homarr/validation/settings";
|
||||
|
||||
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, publicProcedure } from "../trpc";
|
||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, publicProcedure } from "../trpc";
|
||||
|
||||
const boardServerSettingsSchema = z.object({
|
||||
homeBoardId: z.string().nullable(),
|
||||
@@ -108,13 +106,4 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
...input.value,
|
||||
} as ServerSettings[keyof ServerSettings]);
|
||||
}),
|
||||
initSettings: onboardingProcedure
|
||||
.requiresStep("settings")
|
||||
.input(settingsInitSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const currentAnalytics = await getServerSettingByKeyAsync(ctx.db, "analytics");
|
||||
await updateServerSettingByKeyAsync(ctx.db, "analytics", { ...currentAnalytics, ...input.analytics });
|
||||
await updateServerSettingByKeyAsync(ctx.db, "crawlingAndIndexing", input.crawlingAndIndexing);
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
});
|
||||
@@ -76,23 +76,23 @@ describe("all should return all apps", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPaginated should require board modification access", () => {
|
||||
test("should throw FORBIDDEN without board-modify-all", async () => {
|
||||
describe("getPaginated should return apps to authenticated users", () => {
|
||||
test("should throw UNAUTHORIZED without a session", async () => {
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
session: null,
|
||||
});
|
||||
|
||||
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("Permission denied");
|
||||
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("UNAUTHORIZED");
|
||||
});
|
||||
|
||||
test("should return apps with board-modify-all", async () => {
|
||||
test("should return apps without board permissions", async () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(["board-modify-all"]),
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
|
||||
|
||||
|
||||
@@ -287,6 +287,113 @@ describe("getAllBoards should return all boards accessable to the current user",
|
||||
);
|
||||
});
|
||||
|
||||
describe("getManageOverview", () => {
|
||||
test("returns the same accessible board set as getAllBoards", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const otherUserId = await createRandomUserAsync(db);
|
||||
const publicBoardId = createId();
|
||||
const creatorBoardId = createId();
|
||||
const directBoardId = createId();
|
||||
const groupBoardId = createId();
|
||||
const hiddenBoardId = createId();
|
||||
const groupId = createId();
|
||||
|
||||
await db.insert(users).values({ id: defaultCreatorId });
|
||||
await db.insert(boards).values([
|
||||
{ id: publicBoardId, name: "public-overview", creatorId: otherUserId, isPublic: true },
|
||||
{ id: creatorBoardId, name: "creator-overview", creatorId: defaultCreatorId, isPublic: false },
|
||||
{ id: directBoardId, name: "direct-overview", creatorId: otherUserId, isPublic: false },
|
||||
{ id: groupBoardId, name: "group-overview", creatorId: otherUserId, isPublic: false },
|
||||
{ id: hiddenBoardId, name: "hidden-overview", creatorId: otherUserId, isPublic: false },
|
||||
]);
|
||||
await db.insert(boardUserPermissions).values({
|
||||
userId: defaultCreatorId,
|
||||
boardId: directBoardId,
|
||||
permission: "view",
|
||||
});
|
||||
await db.insert(groups).values({ id: groupId, name: "overview-group", position: 1 });
|
||||
await db.insert(groupMembers).values({ userId: defaultCreatorId, groupId });
|
||||
await db.insert(boardGroupPermissions).values({ groupId, boardId: groupBoardId, permission: "view" });
|
||||
|
||||
const [summaries, overview] = await Promise.all([caller.getAllBoards(), caller.getManageOverview()]);
|
||||
|
||||
expect(overview.map((board) => board.id).toSorted()).toEqual(summaries.map((board) => board.id).toSorted());
|
||||
expect(overview.map((board) => board.id)).not.toContain(hiddenBoardId);
|
||||
});
|
||||
|
||||
test("returns one compact base-layout preview for each accessible board", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const boardId = createId();
|
||||
const hiddenBoardId = createId();
|
||||
const baseLayoutId = createId();
|
||||
const mobileLayoutId = createId();
|
||||
const rootSectionId = createId();
|
||||
const itemId = createId();
|
||||
|
||||
await db.insert(users).values({ id: defaultCreatorId });
|
||||
await db.insert(boards).values([
|
||||
{ id: boardId, name: "overview", creatorId: defaultCreatorId, isPublic: false },
|
||||
{ id: hiddenBoardId, name: "hidden", creatorId: await createRandomUserAsync(db), isPublic: false },
|
||||
]);
|
||||
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, defaultCreatorId));
|
||||
await db.insert(layouts).values([
|
||||
{
|
||||
id: mobileLayoutId,
|
||||
name: "Mobile",
|
||||
boardId,
|
||||
columnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "mobile",
|
||||
},
|
||||
{
|
||||
id: baseLayoutId,
|
||||
name: "Base",
|
||||
boardId,
|
||||
columnCount: 12,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
},
|
||||
]);
|
||||
await db.insert(sections).values({
|
||||
id: rootSectionId,
|
||||
boardId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
await db.insert(items).values({ id: itemId, boardId, kind: "clock" });
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId,
|
||||
sectionId: rootSectionId,
|
||||
layoutId: baseLayoutId,
|
||||
xOffset: 2,
|
||||
yOffset: 1,
|
||||
width: 4,
|
||||
height: 2,
|
||||
});
|
||||
|
||||
const result = await caller.getManageOverview();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: boardId,
|
||||
isHome: true,
|
||||
preview: {
|
||||
layouts: [{ id: baseLayoutId, role: "base", columnCount: 12 }],
|
||||
items: [
|
||||
{
|
||||
id: itemId,
|
||||
layouts: [{ layoutId: baseLayoutId, sectionId: rootSectionId, width: 4, height: 2 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result[0]?.preview?.layouts).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBoard should create a new board", () => {
|
||||
test("should create a new board with permission board-create", async () => {
|
||||
// Arrange
|
||||
|
||||
@@ -0,0 +1,959 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import SuperJSON from "superjson";
|
||||
import { assert, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { eq, inArray } from "@homarr/db";
|
||||
import {
|
||||
apps,
|
||||
boards,
|
||||
integrationItems,
|
||||
integrationSecrets,
|
||||
integrations,
|
||||
itemLayouts,
|
||||
items,
|
||||
layouts,
|
||||
onboarding,
|
||||
sectionLayouts,
|
||||
sections,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import { claimOnboardingAsync, isOnboardingClaimValidAsync } from "../../onboarding-claim";
|
||||
import * as integrationConnection from "../integration/integration-test-connection";
|
||||
|
||||
const listDiscoveredContainersAsync = vi.hoisted(() => vi.fn());
|
||||
const createIdMock = vi.hoisted(() => vi.fn());
|
||||
const stableAppIdForSource = (sourceId: string) => {
|
||||
const digest = createHash("sha256").update(sourceId).digest("hex").slice(0, 32);
|
||||
return `onboarding_app_${digest}`;
|
||||
};
|
||||
const stableIntegrationIdForSource = (sourceId: string) => {
|
||||
const digest = createHash("sha256").update(sourceId).digest("hex").slice(0, 32);
|
||||
return `onboarding_integration_${digest}`;
|
||||
};
|
||||
|
||||
vi.mock("@homarr/auth/server", () => ({ isProviderEnabled: () => false }));
|
||||
vi.mock("@homarr/common", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@homarr/common")>()),
|
||||
createId: createIdMock,
|
||||
}));
|
||||
vi.mock("@homarr/docker", () => ({
|
||||
dockerLabels: { hide: "homarr.hide" },
|
||||
listDiscoveredContainersAsync,
|
||||
}));
|
||||
|
||||
import { onboardRouter } from "../onboard/onboard-router";
|
||||
import { normalizeOnboardingStep } from "../onboard/onboard-queries";
|
||||
|
||||
const createCaller = (db: ReturnType<typeof createDb>, onboardingClaimToken?: string) =>
|
||||
onboardRouter.createCaller({ db, deviceType: undefined, session: null, onboardingClaimToken });
|
||||
|
||||
const completionInput = (boardId: string, name = "Configured-board") => ({
|
||||
server: {
|
||||
defaultLocale: "en" as const,
|
||||
defaultColorScheme: "dark" as const,
|
||||
analyticsEnabled: false,
|
||||
},
|
||||
board: {
|
||||
id: boardId,
|
||||
name,
|
||||
primaryColor: "#228BE6",
|
||||
secondaryColor: "#15AABF",
|
||||
itemRadius: "md" as const,
|
||||
layoutPreset: "balanced" as const,
|
||||
leftSidebar: false,
|
||||
rightSidebar: false,
|
||||
},
|
||||
selectedIntegrationIds: [] as string[],
|
||||
selectedAppIds: [] as string[],
|
||||
selectedDockerSourceIds: [] as string[],
|
||||
selectedWidgetKinds: [] as WidgetKind[],
|
||||
});
|
||||
|
||||
const rectanglesOverlap = (
|
||||
first: { xOffset: number; yOffset: number; width: number; height: number },
|
||||
second: { xOffset: number; yOffset: number; width: number; height: number },
|
||||
) =>
|
||||
first.xOffset < second.xOffset + second.width &&
|
||||
first.xOffset + first.width > second.xOffset &&
|
||||
first.yOffset < second.yOffset + second.height &&
|
||||
first.yOffset + first.height > second.yOffset;
|
||||
|
||||
const seedOnboardingAsync = async (db: ReturnType<typeof createDb>) => {
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "setup", previousStep: "group" });
|
||||
const claim = await claimOnboardingAsync(db);
|
||||
if (claim.status !== "issued") throw new Error("Expected an onboarding claim");
|
||||
return claim.token;
|
||||
};
|
||||
|
||||
const seedBoardAsync = async (
|
||||
db: ReturnType<typeof createDb>,
|
||||
input: { boardId: string; itemId?: string; itemKind?: "notebook"; withMobile?: boolean },
|
||||
) => {
|
||||
const layoutId = `${input.boardId}-base`;
|
||||
const sectionId = `${input.boardId}-section`;
|
||||
await db.insert(boards).values({
|
||||
id: input.boardId,
|
||||
name: input.boardId,
|
||||
primaryColor: "#FA5252",
|
||||
secondaryColor: "#FD7E14",
|
||||
});
|
||||
await db.insert(layouts).values({
|
||||
id: layoutId,
|
||||
boardId: input.boardId,
|
||||
name: "Base",
|
||||
role: "base",
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
});
|
||||
if (input.withMobile) {
|
||||
await db.insert(layouts).values({
|
||||
id: `${input.boardId}-mobile`,
|
||||
boardId: input.boardId,
|
||||
name: "Mobile",
|
||||
role: "mobile",
|
||||
columnCount: 3,
|
||||
breakpoint: 0,
|
||||
});
|
||||
}
|
||||
await db.insert(sections).values({
|
||||
id: sectionId,
|
||||
boardId: input.boardId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
if (input.itemId) {
|
||||
await db.insert(items).values({
|
||||
id: input.itemId,
|
||||
boardId: input.boardId,
|
||||
kind: input.itemKind ?? "notebook",
|
||||
options: SuperJSON.stringify({ content: "keep me" }),
|
||||
});
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId: input.itemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 2,
|
||||
yOffset: 3,
|
||||
width: 4,
|
||||
height: 2,
|
||||
});
|
||||
}
|
||||
return { layoutId, sectionId };
|
||||
};
|
||||
|
||||
describe("onboard.completeSetup", () => {
|
||||
beforeEach(() => {
|
||||
listDiscoveredContainersAsync.mockReset();
|
||||
createIdMock.mockReset();
|
||||
let nextId = 0;
|
||||
createIdMock.mockImplementation(() => `generated-${++nextId}`);
|
||||
});
|
||||
|
||||
it("updates only the exact target board and preserves existing content for explicit empty selections", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
const target = await seedBoardAsync(db, { boardId: "target", itemId: "existing-item" });
|
||||
await seedBoardAsync(db, { boardId: "other" });
|
||||
|
||||
const result = await createCaller(db, claimToken).completeSetup(completionInput("target"));
|
||||
|
||||
expect(result).toEqual({
|
||||
boardId: "target",
|
||||
boardName: "Configured-board",
|
||||
docker: { missingSourceIds: [], ignoredSourceIds: [], skippedWidgets: [] },
|
||||
});
|
||||
expect(await db.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({
|
||||
name: "Configured-board",
|
||||
primaryColor: "#228BE6",
|
||||
secondaryColor: "#15AABF",
|
||||
itemRadius: "md",
|
||||
});
|
||||
expect(await db.query.boards.findFirst({ where: eq(boards.id, "other") })).toMatchObject({ name: "other" });
|
||||
expect(await db.query.items.findMany()).toEqual([
|
||||
expect.objectContaining({ id: "existing-item", boardId: "target", kind: "notebook" }),
|
||||
]);
|
||||
expect(await db.query.itemLayouts.findFirst({ where: eq(itemLayouts.itemId, "existing-item") })).toMatchObject({
|
||||
layoutId: target.layoutId,
|
||||
xOffset: 2,
|
||||
yOffset: 3,
|
||||
width: 4,
|
||||
height: 2,
|
||||
});
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "finish", previousStep: "setup" });
|
||||
expect(await isOnboardingClaimValidAsync(db, claimToken)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires an exact board id when more than one board exists", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target" });
|
||||
await seedBoardAsync(db, { boardId: "other" });
|
||||
const input = {
|
||||
...completionInput("target"),
|
||||
board: { ...completionInput("target").board, id: undefined },
|
||||
};
|
||||
|
||||
await expect(createCaller(db, claimToken).completeSetup(input)).rejects.toThrow(
|
||||
"Select the exact board to configure when more than one board exists.",
|
||||
);
|
||||
expect(await db.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({ name: "target" });
|
||||
expect(await db.query.boards.findFirst({ where: eq(boards.id, "other") })).toMatchObject({ name: "other" });
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "group" });
|
||||
});
|
||||
|
||||
it("creates a complete first board when setup resumes without any boards", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
const input = {
|
||||
...completionInput("missing"),
|
||||
board: {
|
||||
...completionInput("missing").board,
|
||||
id: undefined,
|
||||
name: "Recovered-board",
|
||||
leftSidebar: true,
|
||||
rightSidebar: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
expect(await db.query.boards.findMany()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: result.boardId,
|
||||
name: "Recovered-board",
|
||||
primaryColor: "#228BE6",
|
||||
secondaryColor: "#15AABF",
|
||||
itemRadius: "md",
|
||||
}),
|
||||
]);
|
||||
expect(await db.query.layouts.findMany({ where: eq(layouts.boardId, result.boardId) })).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: "mobile", columnCount: 3, breakpoint: 0 }),
|
||||
expect.objectContaining({
|
||||
role: "base",
|
||||
columnCount: 10,
|
||||
breakpoint: 768,
|
||||
leftGutterColumnCount: 1,
|
||||
rightGutterColumnCount: 1,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(await db.query.sections.findMany({ where: eq(sections.boardId, result.boardId) })).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "empty", xOffset: 0, yOffset: 0 }),
|
||||
expect.objectContaining({ kind: "empty", xOffset: -1, yOffset: 0 }),
|
||||
expect.objectContaining({ kind: "empty", xOffset: 1, yOffset: 0 }),
|
||||
]),
|
||||
);
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "finish", previousStep: "setup" });
|
||||
});
|
||||
|
||||
it("rolls back board and layout writes when planned item insertion fails", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
const { layoutId } = await seedBoardAsync(db, { boardId: "target", itemId: "generated-id" });
|
||||
const input = completionInput("target", "Must-roll-back");
|
||||
input.selectedWidgetKinds = ["clock"];
|
||||
createIdMock.mockReturnValue("generated-id");
|
||||
|
||||
await expect(createCaller(db, claimToken).completeSetup(input)).rejects.toThrow();
|
||||
|
||||
expect(await db.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({
|
||||
name: "target",
|
||||
primaryColor: "#FA5252",
|
||||
secondaryColor: "#FD7E14",
|
||||
});
|
||||
expect(await db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) })).toMatchObject({ columnCount: 12 });
|
||||
expect(await db.query.items.findMany()).toHaveLength(1);
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "group" });
|
||||
expect(await isOnboardingClaimValidAsync(db, claimToken)).toBe(true);
|
||||
});
|
||||
|
||||
it("retains partial Docker results and places an explicitly selected label widget", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target" });
|
||||
const service = {
|
||||
sourceId: "docker:good:clock",
|
||||
containerId: "clock",
|
||||
host: "good",
|
||||
group: "Utilities",
|
||||
name: "Clock service",
|
||||
href: "http://clock:3000",
|
||||
externalId: "clock",
|
||||
widgetKind: "clock" as const,
|
||||
};
|
||||
const integrationService = {
|
||||
sourceId: "docker:good:sonarr",
|
||||
containerId: "sonarr",
|
||||
host: "good",
|
||||
group: "Media",
|
||||
name: "Sonarr",
|
||||
href: "http://sonarr:8989",
|
||||
externalId: "sonarr",
|
||||
integrationKind: "sonarr" as const,
|
||||
description: "TV automation",
|
||||
pingUrl: "http://sonarr:8989/ping",
|
||||
icon: "custom-sonarr.svg",
|
||||
};
|
||||
listDiscoveredContainersAsync.mockResolvedValue({
|
||||
hosts: [
|
||||
{ host: "good", status: "success", containers: [], services: [service, integrationService] },
|
||||
{ host: "bad", status: "unavailable", reason: "permission denied", containers: [], services: [] },
|
||||
],
|
||||
services: [service, integrationService],
|
||||
});
|
||||
|
||||
const discovery = await createCaller(db, claimToken).discoverDockerServices();
|
||||
|
||||
expect(discovery).toMatchObject({
|
||||
status: "partial",
|
||||
hosts: [
|
||||
{ host: "good", status: "success", containerCount: 0 },
|
||||
{ host: "bad", status: "unavailable", reason: "permission denied", containerCount: 0 },
|
||||
],
|
||||
apps: [{ sourceId: service.sourceId, widgetKind: "clock" }],
|
||||
integrations: [
|
||||
{
|
||||
sourceId: integrationService.sourceId,
|
||||
iconUrl: integrationService.icon,
|
||||
description: integrationService.description,
|
||||
pingUrl: integrationService.pingUrl,
|
||||
},
|
||||
],
|
||||
});
|
||||
const input = completionInput("target");
|
||||
input.selectedWidgetKinds = discovery.apps.flatMap((app) => (app.widgetKind ? [app.widgetKind] : []));
|
||||
await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
expect(await db.query.items.findMany()).toEqual([expect.objectContaining({ boardId: "target", kind: "clock" })]);
|
||||
});
|
||||
|
||||
it("persists discovery metadata on created apps and integration companion apps", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
const caller = createCaller(db, claimToken);
|
||||
const appResult = await caller.createAppsFromDiscovery([
|
||||
{
|
||||
sourceId: "docker:good:status",
|
||||
name: "Status",
|
||||
href: "http://status:3001",
|
||||
pingUrl: "http://status:3001/health",
|
||||
iconUrl: "https://icons.example/status.svg",
|
||||
description: "Internal status page",
|
||||
},
|
||||
]);
|
||||
const createdApp = appResult.apps[0];
|
||||
assert(createdApp);
|
||||
expect(await db.query.apps.findFirst({ where: eq(apps.id, createdApp.id) })).toMatchObject({
|
||||
pingUrl: "http://status:3001/health",
|
||||
iconUrl: "https://icons.example/status.svg",
|
||||
description: "Internal status page",
|
||||
});
|
||||
|
||||
const testConnection = vi.spyOn(integrationConnection, "testConnectionAsync").mockResolvedValue({ success: true });
|
||||
try {
|
||||
const integrationResult = await caller.createIntegration({
|
||||
sourceId: "docker:good:sonarr",
|
||||
name: "Sonarr",
|
||||
url: "http://sonarr:8989",
|
||||
kind: "sonarr",
|
||||
secrets: [{ kind: "apiKey", value: "secret" }],
|
||||
pingUrl: "http://sonarr:8989/ping",
|
||||
iconUrl: "https://icons.example/sonarr.svg",
|
||||
description: "TV automation",
|
||||
});
|
||||
const createdAppId = "appId" in integrationResult ? integrationResult.appId : undefined;
|
||||
expect(createdAppId).toBeDefined();
|
||||
if (!createdAppId) throw new Error("Expected integration creation to succeed");
|
||||
expect(await db.query.apps.findFirst({ where: eq(apps.id, createdAppId) })).toMatchObject({
|
||||
pingUrl: "http://sonarr:8989/ping",
|
||||
iconUrl: "https://icons.example/sonarr.svg",
|
||||
description: "TV automation",
|
||||
});
|
||||
} finally {
|
||||
testConnection.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses a manual integration and companion app across retries", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
const caller = createCaller(db, claimToken);
|
||||
const sourceId = "manual:sonarr";
|
||||
const createInput = {
|
||||
sourceId,
|
||||
name: "Sonarr",
|
||||
url: "http://sonarr:8989",
|
||||
kind: "sonarr" as const,
|
||||
secrets: [{ kind: "apiKey" as const, value: "secret" }],
|
||||
};
|
||||
const testConnection = vi.spyOn(integrationConnection, "testConnectionAsync").mockResolvedValue({ success: true });
|
||||
|
||||
try {
|
||||
const first = await caller.createIntegration(createInput);
|
||||
const second = await caller.createIntegration(createInput);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
expect(first).toEqual({
|
||||
id: stableIntegrationIdForSource(sourceId),
|
||||
appId: stableAppIdForSource(sourceId),
|
||||
});
|
||||
expect(await db.query.integrations.findMany()).toHaveLength(1);
|
||||
expect(await db.query.apps.findMany()).toHaveLength(1);
|
||||
expect(
|
||||
await db.query.integrationSecrets.findMany({
|
||||
where: eq(integrationSecrets.integrationId, stableIntegrationIdForSource(sourceId)),
|
||||
}),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
testConnection.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("re-fetches selected labels into grouped responsive containers and ignores another board target", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
const { sectionId: mainSectionId } = await seedBoardAsync(db, {
|
||||
boardId: "target",
|
||||
itemId: "existing-item",
|
||||
withMobile: true,
|
||||
});
|
||||
const groupedSource = {
|
||||
sourceId: "docker:good:sonarr",
|
||||
containerId: "sonarr",
|
||||
host: "good",
|
||||
group: "Media",
|
||||
name: "Sonarr",
|
||||
href: "http://sonarr:8989",
|
||||
externalId: "sonarr",
|
||||
widgetKind: "clock" as const,
|
||||
};
|
||||
const ignoredSource = {
|
||||
...groupedSource,
|
||||
sourceId: "docker:good:radarr",
|
||||
containerId: "radarr",
|
||||
externalId: "radarr",
|
||||
name: "Radarr",
|
||||
boardName: "other-board",
|
||||
};
|
||||
listDiscoveredContainersAsync.mockResolvedValue({
|
||||
hosts: [{ host: "good", status: "success", containers: [], services: [groupedSource, ignoredSource] }],
|
||||
services: [groupedSource, ignoredSource],
|
||||
});
|
||||
const groupedAppId = stableAppIdForSource(groupedSource.sourceId);
|
||||
const ignoredAppId = stableAppIdForSource(ignoredSource.sourceId);
|
||||
await db.insert(apps).values([
|
||||
{ id: groupedAppId, name: groupedSource.name, iconUrl: "icon", href: groupedSource.href },
|
||||
{ id: ignoredAppId, name: ignoredSource.name, iconUrl: "icon", href: ignoredSource.href },
|
||||
]);
|
||||
const input = completionInput("target");
|
||||
input.selectedAppIds = [groupedAppId, ignoredAppId];
|
||||
input.selectedDockerSourceIds = [groupedSource.sourceId, ignoredSource.sourceId, "docker:missing"];
|
||||
input.selectedWidgetKinds = ["clock"];
|
||||
|
||||
const result = await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
expect(result.docker).toEqual({
|
||||
missingSourceIds: ["docker:missing"],
|
||||
ignoredSourceIds: [ignoredSource.sourceId],
|
||||
skippedWidgets: [],
|
||||
});
|
||||
const mediaSection = await db.query.sections.findFirst({
|
||||
where: (table, operators) =>
|
||||
operators.and(operators.eq(table.boardId, "target"), operators.eq(table.kind, "container")),
|
||||
});
|
||||
expect(mediaSection).toBeDefined();
|
||||
expect(SuperJSON.parse(mediaSection?.options ?? "{}")).toMatchObject({ title: "Media", showLabel: true });
|
||||
const containerPlacements = await db.query.sectionLayouts.findMany({
|
||||
where: eq(sectionLayouts.sectionId, mediaSection?.id ?? "missing"),
|
||||
});
|
||||
expect(containerPlacements).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ layoutId: "target-base", parentSectionId: mainSectionId, width: 10 }),
|
||||
expect.objectContaining({ layoutId: "target-mobile", parentSectionId: mainSectionId, width: 3 }),
|
||||
]),
|
||||
);
|
||||
const groupedItems = await db.query.items.findMany({
|
||||
where: (table, operators) => operators.inArray(table.kind, ["app", "clock"]),
|
||||
with: { layouts: true },
|
||||
});
|
||||
expect(groupedItems).toHaveLength(2);
|
||||
expect(groupedItems.flatMap((item) => item.layouts)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ layoutId: "target-base", sectionId: mediaSection?.id }),
|
||||
expect.objectContaining({ layoutId: "target-mobile", sectionId: mediaSection?.id }),
|
||||
]),
|
||||
);
|
||||
expect(await db.query.items.findFirst({ where: eq(items.id, "existing-item") })).toBeDefined();
|
||||
expect(
|
||||
groupedItems.some(
|
||||
(item) => item.kind === "app" && SuperJSON.parse<{ appId: string }>(item.options).appId === ignoredAppId,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("places a labeled integration app and linked widget together inside its group", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target", withMobile: true });
|
||||
const source = {
|
||||
sourceId: "docker:good:sonarr",
|
||||
containerId: "sonarr",
|
||||
host: "good",
|
||||
group: "Media",
|
||||
name: "Sonarr",
|
||||
href: "http://sonarr:8989",
|
||||
externalId: "sonarr",
|
||||
integrationKind: "sonarr" as const,
|
||||
widgetKind: "calendar" as const,
|
||||
};
|
||||
listDiscoveredContainersAsync.mockResolvedValue({
|
||||
hosts: [{ host: "good", status: "success", containers: [], services: [source] }],
|
||||
services: [source],
|
||||
});
|
||||
const appId = stableAppIdForSource(source.sourceId);
|
||||
const integrationId = stableIntegrationIdForSource(source.sourceId);
|
||||
await db.insert(apps).values({ id: appId, name: source.name, iconUrl: "icon", href: source.href });
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
appId,
|
||||
name: source.name,
|
||||
url: source.href,
|
||||
kind: source.integrationKind,
|
||||
});
|
||||
const input = completionInput("target");
|
||||
input.selectedIntegrationIds = [integrationId];
|
||||
input.selectedAppIds = [appId];
|
||||
input.selectedDockerSourceIds = [source.sourceId];
|
||||
input.selectedWidgetKinds = [source.widgetKind];
|
||||
|
||||
await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
const mediaSection = await db.query.sections.findFirst({
|
||||
where: (table, operators) => operators.eq(table.kind, "container"),
|
||||
});
|
||||
const groupedItems = await db.query.items.findMany({
|
||||
where: (table, operators) => operators.inArray(table.kind, ["app", "calendar"]),
|
||||
with: { layouts: true },
|
||||
});
|
||||
expect(groupedItems).toHaveLength(2);
|
||||
expect(groupedItems.every((item) => item.layouts.every((layout) => layout.sectionId === mediaSection?.id))).toBe(
|
||||
true,
|
||||
);
|
||||
const calendar = groupedItems.find((item) => item.kind === "calendar");
|
||||
expect(calendar).toBeDefined();
|
||||
expect(
|
||||
await db.query.integrationItems.findFirst({ where: eq(integrationItems.itemId, calendar?.id ?? "") }),
|
||||
).toEqual({
|
||||
itemId: calendar?.id,
|
||||
integrationId,
|
||||
});
|
||||
});
|
||||
|
||||
it("links a labeled integration to an existing compatible widget", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
const { sectionId, layoutId } = await seedBoardAsync(db, { boardId: "target", itemId: "existing-seed" });
|
||||
await db.insert(items).values({ id: "calendar", boardId: "target", kind: "calendar" });
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId: "calendar",
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 2,
|
||||
height: 2,
|
||||
});
|
||||
const source = {
|
||||
sourceId: "docker:good:sonarr",
|
||||
containerId: "sonarr",
|
||||
host: "good",
|
||||
group: "Media",
|
||||
name: "Sonarr",
|
||||
href: "http://sonarr:8989",
|
||||
externalId: "sonarr",
|
||||
integrationKind: "sonarr" as const,
|
||||
widgetKind: "calendar" as const,
|
||||
};
|
||||
listDiscoveredContainersAsync.mockResolvedValue({
|
||||
hosts: [{ host: "good", status: "success", containers: [], services: [source] }],
|
||||
services: [source],
|
||||
});
|
||||
const integrationId = stableIntegrationIdForSource(source.sourceId);
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
name: source.name,
|
||||
url: source.href,
|
||||
kind: source.integrationKind,
|
||||
});
|
||||
const input = completionInput("target");
|
||||
input.selectedIntegrationIds = [integrationId];
|
||||
input.selectedDockerSourceIds = [source.sourceId];
|
||||
input.selectedWidgetKinds = [source.widgetKind];
|
||||
|
||||
await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
expect(await db.query.integrationItems.findFirst({ where: eq(integrationItems.itemId, "calendar") })).toEqual({
|
||||
itemId: "calendar",
|
||||
integrationId,
|
||||
});
|
||||
expect(await db.query.items.findMany({ where: eq(items.kind, "calendar") })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips integration-required and incompatible label widgets but preserves their selected apps", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target", itemId: "seeded-content" });
|
||||
await db.update(boards).set({ name: "dashboard" }).where(eq(boards.id, "target"));
|
||||
const appOnly = {
|
||||
sourceId: "docker:good:app-only",
|
||||
containerId: "app-only",
|
||||
host: "good",
|
||||
group: "Media",
|
||||
name: "App only",
|
||||
href: "http://app-only:3000",
|
||||
externalId: "app-only",
|
||||
widgetKind: "mediaServer" as const,
|
||||
};
|
||||
const incompatible = {
|
||||
...appOnly,
|
||||
sourceId: "docker:good:sonarr",
|
||||
containerId: "sonarr",
|
||||
externalId: "sonarr",
|
||||
name: "Sonarr",
|
||||
href: "http://sonarr:8989",
|
||||
integrationKind: "sonarr" as const,
|
||||
};
|
||||
const services = [appOnly, incompatible];
|
||||
listDiscoveredContainersAsync.mockResolvedValue({
|
||||
hosts: [{ host: "good", status: "success", containers: [], services }],
|
||||
services,
|
||||
});
|
||||
const appIds = services.map((source) => stableAppIdForSource(source.sourceId));
|
||||
await db.insert(apps).values(
|
||||
services.map((source) => ({
|
||||
id: stableAppIdForSource(source.sourceId),
|
||||
name: source.name,
|
||||
iconUrl: "icon",
|
||||
href: source.href,
|
||||
})),
|
||||
);
|
||||
const integrationId = stableIntegrationIdForSource(incompatible.sourceId);
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
appId: appIds[1],
|
||||
name: incompatible.name,
|
||||
url: incompatible.href,
|
||||
kind: incompatible.integrationKind,
|
||||
});
|
||||
const input = completionInput("target");
|
||||
input.selectedAppIds = appIds;
|
||||
input.selectedIntegrationIds = [integrationId];
|
||||
input.selectedDockerSourceIds = services.map((source) => source.sourceId);
|
||||
input.selectedWidgetKinds = ["mediaServer"];
|
||||
|
||||
const result = await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
expect(result.docker.skippedWidgets).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ sourceId: appOnly.sourceId, code: "integration-required" }),
|
||||
expect.objectContaining({
|
||||
sourceId: incompatible.sourceId,
|
||||
code: "incompatible-integration",
|
||||
integrationKind: "sonarr",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(await db.query.items.findMany({ where: eq(items.kind, "mediaServer") })).toHaveLength(0);
|
||||
expect(await db.query.items.findMany({ where: eq(items.kind, "app") })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("splits limited automatic integration widgets instead of over-linking one item", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target" });
|
||||
await db.insert(integrations).values([
|
||||
{ id: "navidrome", name: "Navidrome", url: "http://navidrome:4533", kind: "navidrome" },
|
||||
{ id: "audiobookshelf", name: "Audiobookshelf", url: "http://audiobookshelf:13378", kind: "audiobookshelf" },
|
||||
]);
|
||||
const input = completionInput("target");
|
||||
input.selectedIntegrationIds = ["navidrome", "audiobookshelf"];
|
||||
|
||||
await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
const audioItems = await db.query.items.findMany({ where: eq(items.kind, "audioStats") });
|
||||
expect(audioItems).toHaveLength(2);
|
||||
const links = await db.query.integrationItems.findMany({
|
||||
where: inArray(
|
||||
integrationItems.itemId,
|
||||
audioItems.map((item) => item.id),
|
||||
),
|
||||
});
|
||||
expect(links).toHaveLength(2);
|
||||
expect(new Set(links.map((link) => link.itemId)).size).toBe(2);
|
||||
});
|
||||
|
||||
it("places an ungrouped labeled app and widget in the main root", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target" });
|
||||
const source = {
|
||||
sourceId: "docker:good:status",
|
||||
containerId: "status",
|
||||
host: "good",
|
||||
name: "Status",
|
||||
href: "http://status:3001",
|
||||
externalId: "status",
|
||||
widgetKind: "clock" as const,
|
||||
};
|
||||
listDiscoveredContainersAsync.mockResolvedValue({
|
||||
hosts: [{ host: "good", status: "success", containers: [], services: [source] }],
|
||||
services: [source],
|
||||
});
|
||||
const appId = stableAppIdForSource(source.sourceId);
|
||||
await db.insert(apps).values({ id: appId, name: source.name, iconUrl: "icon", href: source.href });
|
||||
const input = completionInput("target");
|
||||
input.selectedAppIds = [appId];
|
||||
input.selectedDockerSourceIds = [source.sourceId];
|
||||
input.selectedWidgetKinds = [source.widgetKind];
|
||||
|
||||
await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
expect(await db.query.sections.findMany({ where: eq(sections.kind, "container") })).toHaveLength(0);
|
||||
expect(await db.query.items.findMany()).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ kind: "app" }), expect.objectContaining({ kind: "clock" })]),
|
||||
);
|
||||
expect((await db.query.itemLayouts.findMany()).every((layout) => layout.sectionId === "target-section")).toBe(true);
|
||||
});
|
||||
|
||||
it("packs grouped items with their actual dimensions without overlapping pending root content", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target", withMobile: true });
|
||||
const sources = [
|
||||
{
|
||||
sourceId: "docker:good:clock",
|
||||
containerId: "clock",
|
||||
host: "good",
|
||||
group: "Utilities",
|
||||
name: "Clock",
|
||||
href: "http://clock:3000",
|
||||
externalId: "clock",
|
||||
widgetKind: "clock" as const,
|
||||
},
|
||||
{
|
||||
sourceId: "docker:good:weather",
|
||||
containerId: "weather",
|
||||
host: "good",
|
||||
group: "Utilities",
|
||||
name: "Weather",
|
||||
href: "http://weather:3000",
|
||||
externalId: "weather",
|
||||
widgetKind: "weather" as const,
|
||||
},
|
||||
];
|
||||
listDiscoveredContainersAsync.mockResolvedValue({
|
||||
hosts: [{ host: "good", status: "success", containers: [], services: sources }],
|
||||
services: sources,
|
||||
});
|
||||
const appIds = sources.map((source) => stableAppIdForSource(source.sourceId));
|
||||
await db.insert(apps).values(
|
||||
sources.map((source) => ({
|
||||
id: stableAppIdForSource(source.sourceId),
|
||||
name: source.name,
|
||||
iconUrl: "icon",
|
||||
href: source.href,
|
||||
})),
|
||||
);
|
||||
const input = completionInput("target");
|
||||
input.board.leftSidebar = true;
|
||||
input.board.rightSidebar = true;
|
||||
input.selectedAppIds = appIds;
|
||||
input.selectedDockerSourceIds = sources.map((source) => source.sourceId);
|
||||
input.selectedWidgetKinds = ["bookmarks", "clock", "weather"];
|
||||
|
||||
await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
const container = await db.query.sections.findFirst({ where: (table, { eq }) => eq(table.kind, "container") });
|
||||
expect(container).toBeDefined();
|
||||
const containerLayouts = await db.query.sectionLayouts.findMany({
|
||||
where: eq(sectionLayouts.sectionId, container?.id ?? "missing"),
|
||||
});
|
||||
const groupedLayouts = await db.query.itemLayouts.findMany({
|
||||
where: eq(itemLayouts.sectionId, container?.id ?? "missing"),
|
||||
});
|
||||
for (const layoutId of ["target-base", "target-mobile"]) {
|
||||
const positions = groupedLayouts.filter((layout) => layout.layoutId === layoutId);
|
||||
const containerLayout = containerLayouts.find((layout) => layout.layoutId === layoutId);
|
||||
expect(containerLayout).toBeDefined();
|
||||
expect(Math.max(...positions.map((position) => position.yOffset + position.height))).toBeLessThanOrEqual(
|
||||
containerLayout?.height ?? 0,
|
||||
);
|
||||
for (let firstIndex = 0; firstIndex < positions.length; firstIndex += 1) {
|
||||
for (let secondIndex = firstIndex + 1; secondIndex < positions.length; secondIndex += 1) {
|
||||
const firstPosition = positions[firstIndex];
|
||||
const secondPosition = positions[secondIndex];
|
||||
assert(firstPosition);
|
||||
assert(secondPosition);
|
||||
expect(rectanglesOverlap(firstPosition, secondPosition)).toBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
const rootBookmarks = await db.query.itemLayouts.findMany({
|
||||
where: (table, { and, eq, inArray }) =>
|
||||
and(eq(table.sectionId, "target-section"), inArray(table.layoutId, ["target-base", "target-mobile"])),
|
||||
});
|
||||
for (const containerLayout of containerLayouts) {
|
||||
const rootItem = rootBookmarks.find((layout) => layout.layoutId === containerLayout.layoutId);
|
||||
expect(rootItem).toBeDefined();
|
||||
assert(rootItem);
|
||||
expect(rectanglesOverlap(rootItem, containerLayout)).toBe(false);
|
||||
expect(containerLayout.width).toBeLessThanOrEqual(containerLayout.layoutId === "target-base" ? 8 : 3);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the main root and creates enabled sidebar roots transactionally", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await db.insert(boards).values({ id: "target", name: "target" });
|
||||
await db.insert(layouts).values({
|
||||
id: "target-base",
|
||||
boardId: "target",
|
||||
name: "Base",
|
||||
role: "base",
|
||||
columnCount: 10,
|
||||
breakpoint: 768,
|
||||
});
|
||||
await db.insert(sections).values([
|
||||
{ id: "right-root", boardId: "target", kind: "empty", xOffset: 1, yOffset: 0 },
|
||||
{ id: "main-root", boardId: "target", kind: "empty", xOffset: 0, yOffset: 0 },
|
||||
]);
|
||||
const input = completionInput("target");
|
||||
input.board.leftSidebar = true;
|
||||
input.board.rightSidebar = true;
|
||||
input.selectedWidgetKinds = ["clock"];
|
||||
|
||||
await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
expect(await db.query.sections.findMany({ where: eq(sections.boardId, "target") })).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "main-root", xOffset: 0 }),
|
||||
expect.objectContaining({ id: "right-root", xOffset: 1 }),
|
||||
expect.objectContaining({ xOffset: -1, kind: "empty" }),
|
||||
]),
|
||||
);
|
||||
expect(await db.query.itemLayouts.findFirst()).toMatchObject({ sectionId: "main-root" });
|
||||
expect(await db.query.layouts.findFirst({ where: eq(layouts.id, "target-base") })).toMatchObject({
|
||||
leftGutterColumnCount: 1,
|
||||
rightGutterColumnCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("adopts one label board name for a fresh sole board and ignores sources for other board targets", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target", itemId: "seeded-content" });
|
||||
await db.update(boards).set({ name: "dashboard" }).where(eq(boards.id, "target"));
|
||||
const media = {
|
||||
sourceId: "docker:good:media",
|
||||
containerId: "media",
|
||||
host: "good",
|
||||
group: "Media",
|
||||
name: "Media",
|
||||
href: "http://media:3000",
|
||||
externalId: "media",
|
||||
boardName: "Media board",
|
||||
};
|
||||
listDiscoveredContainersAsync.mockResolvedValue({
|
||||
hosts: [{ host: "good", status: "success", containers: [], services: [media] }],
|
||||
services: [media],
|
||||
});
|
||||
const input = completionInput("target");
|
||||
input.selectedDockerSourceIds = [media.sourceId];
|
||||
|
||||
const result = await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
expect(result.boardName).toBe("Media-board");
|
||||
expect(await db.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({ name: "Media-board" });
|
||||
expect(await db.query.items.findFirst({ where: eq(items.id, "seeded-content") })).toBeDefined();
|
||||
|
||||
const conflictDb = createDb();
|
||||
const conflictClaim = await seedOnboardingAsync(conflictDb);
|
||||
await seedBoardAsync(conflictDb, { boardId: "target", itemId: "seeded-content" });
|
||||
await conflictDb.update(boards).set({ name: "dashboard" }).where(eq(boards.id, "target"));
|
||||
const other = { ...media, sourceId: "docker:good:other", containerId: "other", boardName: "Other board" };
|
||||
const ignoredIntegrationSource = {
|
||||
...other,
|
||||
sourceId: "docker:good:other-sonarr",
|
||||
containerId: "other-sonarr",
|
||||
externalId: "other-sonarr",
|
||||
integrationKind: "sonarr" as const,
|
||||
};
|
||||
listDiscoveredContainersAsync.mockResolvedValue({
|
||||
hosts: [{ host: "good", status: "success", containers: [], services: [media, other, ignoredIntegrationSource] }],
|
||||
services: [media, other, ignoredIntegrationSource],
|
||||
});
|
||||
const conflictInput = completionInput("target", "Media-board");
|
||||
const ignoredIntegrationId = stableIntegrationIdForSource(ignoredIntegrationSource.sourceId);
|
||||
await conflictDb.insert(integrations).values({
|
||||
id: ignoredIntegrationId,
|
||||
name: ignoredIntegrationSource.name,
|
||||
url: ignoredIntegrationSource.href,
|
||||
kind: ignoredIntegrationSource.integrationKind,
|
||||
});
|
||||
conflictInput.selectedIntegrationIds = [ignoredIntegrationId];
|
||||
conflictInput.selectedDockerSourceIds = [media.sourceId, other.sourceId, ignoredIntegrationSource.sourceId];
|
||||
const conflictResult = await createCaller(conflictDb, conflictClaim).completeSetup(conflictInput);
|
||||
expect(conflictResult.docker.ignoredSourceIds).toEqual([other.sourceId, ignoredIntegrationSource.sourceId]);
|
||||
expect(await conflictDb.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({
|
||||
name: "Media-board",
|
||||
});
|
||||
expect(await conflictDb.query.items.findMany({ where: eq(items.kind, "calendar") })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects setup mutations from an unclaimed browser", async () => {
|
||||
const db = createDb();
|
||||
await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target" });
|
||||
await expect(createCaller(db).completeSetup(completionInput("target"))).rejects.toThrow(
|
||||
"This onboarding session is not claimed.",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires an authenticated administrator once any user exists", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target" });
|
||||
await db.insert(users).values({ id: "owner", name: "owner" });
|
||||
await expect(createCaller(db, claimToken).completeSetup(completionInput("target"))).rejects.toThrow(
|
||||
"This onboarding session is not claimed.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onboard.nextStep", () => {
|
||||
it("advances welcome exactly once", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "start" });
|
||||
const claim = await claimOnboardingAsync(db);
|
||||
if (claim.status !== "issued") throw new Error("Expected an onboarding claim");
|
||||
const caller = createCaller(db, claim.token);
|
||||
|
||||
await caller.nextStep();
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "start" });
|
||||
await expect(caller.nextStep()).rejects.toThrow("The welcome step is already complete.");
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "start" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeOnboardingStep", () => {
|
||||
it("keeps legacy import backups eligible for restore from the start step", () => {
|
||||
expect(normalizeOnboardingStep("import")).toBe("start");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { eq } from "@homarr/db";
|
||||
import { onboarding, serverSettings, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import {
|
||||
claimOnboardingAsync,
|
||||
getOnboardingClaimTokenFromCookieHeader,
|
||||
isOnboardingClaimValidAsync,
|
||||
onboardingClaimCookieName,
|
||||
onboardingClaimSettingKey,
|
||||
} from "../../onboarding-claim";
|
||||
|
||||
describe("onboarding claim", () => {
|
||||
it("stores only a token hash and validates the claimed browser", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "start" });
|
||||
|
||||
const result = await claimOnboardingAsync(db, { now: 1_000, ttlMs: 10_000 });
|
||||
expect(result.status).toBe("issued");
|
||||
if (result.status !== "issued") throw new Error("Expected an issued claim");
|
||||
|
||||
const row = await db.query.serverSettings.findFirst({
|
||||
where: eq(serverSettings.settingKey, onboardingClaimSettingKey),
|
||||
});
|
||||
expect(row?.value).not.toContain(result.token);
|
||||
expect(await isOnboardingClaimValidAsync(db, result.token, 5_000)).toBe(true);
|
||||
expect(await isOnboardingClaimValidAsync(db, "not-the-token", 5_000)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let another browser replace a live claim", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "start" });
|
||||
const issued = await claimOnboardingAsync(db, { now: 1_000, ttlMs: 10_000 });
|
||||
if (issued.status !== "issued") throw new Error("Expected an issued claim");
|
||||
|
||||
expect(await claimOnboardingAsync(db, { now: 2_000 })).toEqual({
|
||||
status: "locked",
|
||||
expiresAt: 11_000,
|
||||
});
|
||||
expect(await claimOnboardingAsync(db, { currentToken: issued.token, now: 2_000 })).toEqual({
|
||||
status: "active",
|
||||
token: issued.token,
|
||||
expiresAt: 11_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows a new claim after expiry and invalidates the old token", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "start" });
|
||||
const first = await claimOnboardingAsync(db, { now: 1_000, ttlMs: 100 });
|
||||
if (first.status !== "issued") throw new Error("Expected an issued claim");
|
||||
|
||||
const second = await claimOnboardingAsync(db, { now: 1_101, ttlMs: 100 });
|
||||
if (second.status !== "issued") throw new Error("Expected a replacement claim");
|
||||
expect(second.token).not.toBe(first.token);
|
||||
expect(await isOnboardingClaimValidAsync(db, first.token, 1_102)).toBe(false);
|
||||
expect(await isOnboardingClaimValidAsync(db, second.token, 1_102)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses claims after setup finishes", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "finish", previousStep: "setup" });
|
||||
await expect(claimOnboardingAsync(db)).resolves.toEqual({ status: "finished" });
|
||||
});
|
||||
|
||||
it("requires an administrator to rotate claims after a user exists", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "setup", previousStep: "user" });
|
||||
await db.insert(users).values({ id: "owner", name: "owner" });
|
||||
await expect(claimOnboardingAsync(db)).resolves.toEqual({ status: "forbidden" });
|
||||
await expect(claimOnboardingAsync(db, { force: true })).resolves.toMatchObject({ status: "issued" });
|
||||
});
|
||||
|
||||
it("extracts only the exact claim cookie", () => {
|
||||
expect(
|
||||
getOnboardingClaimTokenFromCookieHeader(`theme=dark; ${onboardingClaimCookieName}=secret-token; other=x`),
|
||||
).toBe("secret-token");
|
||||
expect(getOnboardingClaimTokenFromCookieHeader(`${onboardingClaimCookieName}-copy=wrong`)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { invites, onboarding, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
|
||||
|
||||
import { claimOnboardingAsync } from "../../onboarding-claim";
|
||||
import { userRouter } from "../user";
|
||||
|
||||
const defaultOwnerId = createId();
|
||||
@@ -40,11 +41,12 @@ vi.mock("@homarr/auth/env", () => {
|
||||
describe("initUser should initialize the first user", () => {
|
||||
it("should create a user if none exists", async () => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const onboardingClaimToken = await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
onboardingClaimToken,
|
||||
});
|
||||
|
||||
await caller.initUser({
|
||||
@@ -64,11 +66,12 @@ describe("initUser should initialize the first user", () => {
|
||||
|
||||
it("should not create a user if the password and confirmPassword do not match", async () => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const onboardingClaimToken = await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
onboardingClaimToken,
|
||||
});
|
||||
|
||||
const actAsync = async () =>
|
||||
@@ -83,11 +86,12 @@ describe("initUser should initialize the first user", () => {
|
||||
|
||||
it.each([["aB2%"], ["short"]])("should reject passwords shorter than 8 characters for '%s'", async (password) => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const onboardingClaimToken = await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
onboardingClaimToken,
|
||||
});
|
||||
|
||||
const actAsync = async () =>
|
||||
@@ -102,11 +106,12 @@ describe("initUser should initialize the first user", () => {
|
||||
|
||||
it("should accept passwords without complexity requirements", async () => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const onboardingClaimToken = await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
onboardingClaimToken,
|
||||
});
|
||||
|
||||
await caller.initUser({
|
||||
@@ -126,11 +131,12 @@ describe("initUser should initialize the first user", () => {
|
||||
|
||||
it("should accept passwords with special characters like LoveHomarr<3", async () => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const onboardingClaimToken = await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
onboardingClaimToken,
|
||||
});
|
||||
|
||||
await caller.initUser({
|
||||
@@ -439,4 +445,7 @@ const createOnboardingStepAsync = async (db: Database, step: OnboardingStep) =>
|
||||
id: createId(),
|
||||
step,
|
||||
});
|
||||
const claim = await claimOnboardingAsync(db);
|
||||
if (claim.status !== "issued") throw new Error("Expected an onboarding claim");
|
||||
return claim.token;
|
||||
};
|
||||
@@ -4,12 +4,13 @@ import type { QueryResult } from "pg";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { comparePasswordsAsync, hashPasswordAsync } from "@homarr/auth";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { createId } from "@homarr/common";
|
||||
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, users } from "@homarr/db/schema";
|
||||
import { boards, 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";
|
||||
@@ -41,7 +42,6 @@ import {
|
||||
} from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
import { throwIfCredentialsDisabled } from "./invite/checks";
|
||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||
import { changeSearchPreferencesAsync, changeSearchPreferencesInputSchema } from "./user/change-search-preferences";
|
||||
|
||||
const logger = createLogger({ module: "userRouter" });
|
||||
@@ -52,25 +52,47 @@ export const userRouter = createTRPCRouter({
|
||||
.input(userInitSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfCredentialsDisabled();
|
||||
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
|
||||
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
const userId = await createUserAsync(ctx.db, input);
|
||||
const hashedPassword = await hashPasswordAsync(input.password);
|
||||
const userId = createId();
|
||||
const groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
const userRow = {
|
||||
id: userId,
|
||||
name: input.username,
|
||||
email: input.email,
|
||||
password: hashedPassword,
|
||||
};
|
||||
const groupRow = {
|
||||
id: groupId,
|
||||
name: credentialsAdminGroup,
|
||||
ownerId: userId,
|
||||
position: maxPosition + 1,
|
||||
};
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction.insert(schema.users).values(userRow);
|
||||
await transaction.insert(schema.groups).values(groupRow);
|
||||
await transaction.insert(schema.groupPermissions).values({ groupId, permission: "admin" });
|
||||
await transaction.insert(schema.groupMembers).values({ groupId, userId });
|
||||
const nextStep = isProviderEnabled("ldap") || isProviderEnabled("oidc") ? "group" : "setup";
|
||||
await transaction.update(schema.onboarding).set({ previousStep: "user", step: nextStep });
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction.insert(users).values(userRow).run();
|
||||
transaction.insert(groups).values(groupRow).run();
|
||||
transaction.insert(groupPermissions).values({ groupId, permission: "admin" }).run();
|
||||
transaction.insert(groupMembers).values({ groupId, userId }).run();
|
||||
const nextStep = isProviderEnabled("ldap") || isProviderEnabled("oidc") ? "group" : "setup";
|
||||
transaction.update(onboarding).set({ previousStep: "user", step: nextStep }).run();
|
||||
});
|
||||
},
|
||||
});
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
permission: "admin",
|
||||
});
|
||||
await ctx.db.insert(groupMembers).values({
|
||||
groupId,
|
||||
userId,
|
||||
});
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
register: publicProcedure
|
||||
.input(userRegistrationApiSchema)
|
||||
|
||||
@@ -20,6 +20,7 @@ import { db } from "@homarr/db";
|
||||
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
|
||||
|
||||
import { env } from "./env";
|
||||
import { getOnboardingClaimTokenFromCookieHeader, isClaimOnlyOnboardingAccessAllowedAsync } from "./onboarding-claim";
|
||||
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
|
||||
import type { McpMeta } from "./mcp-tools";
|
||||
|
||||
@@ -41,6 +42,7 @@ interface ApiContext {
|
||||
session: Session | null;
|
||||
deviceType: DeviceType;
|
||||
baseUrl?: `${string}://${string}`;
|
||||
onboardingClaimToken?: string;
|
||||
db: typeof db;
|
||||
}
|
||||
|
||||
@@ -58,6 +60,7 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
|
||||
session,
|
||||
deviceType: userAgent(opts.headers).device.type,
|
||||
baseUrl: extractBaseUrlFromHeaders(opts.headers),
|
||||
onboardingClaimToken: getOnboardingClaimTokenFromCookieHeader(opts.headers.get("cookie")),
|
||||
db,
|
||||
};
|
||||
};
|
||||
@@ -129,6 +132,19 @@ const baseProcedure = isDemoReadOnly ? t.procedure.use(enforceDemoModeReadOnly)
|
||||
*/
|
||||
export const publicProcedure = baseProcedure;
|
||||
|
||||
const enforceOnboardingAccess = t.middleware(async ({ ctx, next }) => {
|
||||
const isAdmin = ctx.session?.user.permissions.includes("admin") ?? false;
|
||||
if (!isAdmin) {
|
||||
const hasValidClaim = await isClaimOnlyOnboardingAccessAllowedAsync(ctx.db, ctx.onboardingClaimToken);
|
||||
if (!hasValidClaim) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "This onboarding session is not claimed." });
|
||||
}
|
||||
}
|
||||
return next({ ctx });
|
||||
});
|
||||
|
||||
export const onboardingClaimedProcedure = baseProcedure.use(enforceOnboardingAccess);
|
||||
|
||||
export const internalProcedure = t.procedure;
|
||||
|
||||
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
|
||||
@@ -177,7 +193,7 @@ export const permissionRequiredProcedure = {
|
||||
|
||||
export const onboardingProcedure = {
|
||||
requiresStep: (step: OnboardingStep) => {
|
||||
return publicProcedure.use(async ({ ctx, input, next }) => {
|
||||
return onboardingClaimedProcedure.use(async ({ ctx, input, next }) => {
|
||||
const currentStep = await getOnboardingOrFallbackAsync(ctx.db).then(({ current }) => current);
|
||||
if (currentStep !== step) {
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"exports": {
|
||||
"./context": "./src/context.tsx",
|
||||
"./layout-selection": "./src/layout-selection.ts",
|
||||
"./layout-preview": "./src/layout-preview.ts",
|
||||
"./updater": "./src/updater.ts",
|
||||
"./edit-mode": "./src/edit-mode.tsx"
|
||||
},
|
||||
@@ -25,6 +26,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { BoardPreviewData, BoardPreviewLayout } from "./layout-preview";
|
||||
import { getRepresentativeLayoutWidth, projectBoardLayout } from "./layout-preview";
|
||||
|
||||
const mobile = {
|
||||
id: "mobile",
|
||||
columnCount: 3,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
role: "mobile",
|
||||
} satisfies BoardPreviewLayout;
|
||||
const custom = {
|
||||
id: "tablet",
|
||||
columnCount: 6,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 480,
|
||||
role: "custom",
|
||||
} satisfies BoardPreviewLayout;
|
||||
const base = {
|
||||
id: "base",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
} satisfies BoardPreviewLayout;
|
||||
|
||||
const root = { id: "root", kind: "empty" as const, xOffset: 0, layouts: [] };
|
||||
|
||||
describe("layout representative widths", () => {
|
||||
test("uses phone, activation midpoint, and desktop widths", () => {
|
||||
const layouts = [mobile, custom, base];
|
||||
expect(getRepresentativeLayoutWidth(mobile, layouts)).toBe(390);
|
||||
expect(getRepresentativeLayoutWidth(custom, layouts)).toBe(624);
|
||||
expect(getRepresentativeLayoutWidth(base, layouts)).toBe(1280);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectBoardLayout", () => {
|
||||
test("keeps exact saved positions for the active layout", () => {
|
||||
const board = {
|
||||
layouts: [mobile],
|
||||
sections: [
|
||||
root,
|
||||
{
|
||||
id: "container",
|
||||
kind: "container",
|
||||
xOffset: null,
|
||||
layouts: [
|
||||
{
|
||||
layoutId: mobile.id,
|
||||
parentSectionId: root.id,
|
||||
width: 3,
|
||||
height: 2,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
} satisfies BoardPreviewData;
|
||||
|
||||
expect(projectBoardLayout(board, mobile, mobile)).toEqual([
|
||||
expect.objectContaining({ id: "container", sectionId: root.id, width: 3, height: 2 }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("reflows root items and containers while retaining nested items", () => {
|
||||
const board = {
|
||||
layouts: [mobile, base],
|
||||
sections: [
|
||||
root,
|
||||
{
|
||||
id: "container",
|
||||
kind: "container",
|
||||
xOffset: null,
|
||||
layouts: [
|
||||
{
|
||||
layoutId: base.id,
|
||||
parentSectionId: root.id,
|
||||
width: 6,
|
||||
height: 3,
|
||||
xOffset: 6,
|
||||
yOffset: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
id: "root-item",
|
||||
layouts: [
|
||||
{
|
||||
layoutId: base.id,
|
||||
sectionId: root.id,
|
||||
width: 5,
|
||||
height: 2,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "nested-item",
|
||||
layouts: [
|
||||
{
|
||||
layoutId: base.id,
|
||||
sectionId: "container",
|
||||
width: 4,
|
||||
height: 1,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} satisfies BoardPreviewData;
|
||||
|
||||
const projected = projectBoardLayout(board, base, mobile);
|
||||
|
||||
expect(projected.find((element) => element.id === "root-item")).toMatchObject({
|
||||
type: "item",
|
||||
width: 3,
|
||||
height: 2,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
sectionId: root.id,
|
||||
});
|
||||
expect(projected.find((element) => element.id === "container")).toMatchObject({
|
||||
type: "section",
|
||||
width: 3,
|
||||
xOffset: 0,
|
||||
yOffset: 2,
|
||||
sectionId: root.id,
|
||||
});
|
||||
expect(projected.find((element) => element.id === "nested-item")).toMatchObject({
|
||||
type: "item",
|
||||
width: 3,
|
||||
sectionId: "container",
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores missing positions and supports empty boards", () => {
|
||||
const board = {
|
||||
layouts: [mobile],
|
||||
sections: [root],
|
||||
items: [{ id: "missing", layouts: [] }],
|
||||
} satisfies BoardPreviewData;
|
||||
|
||||
expect(projectBoardLayout(board, base, mobile)).toEqual([]);
|
||||
});
|
||||
|
||||
test("moves fixed-rail items into the mobile canvas without overlap", () => {
|
||||
const baseWithRails = {
|
||||
...base,
|
||||
leftGutterColumnCount: 2,
|
||||
rightGutterColumnCount: 2,
|
||||
};
|
||||
const roots = [
|
||||
{ id: "left", kind: "empty" as const, xOffset: -1, layouts: [] },
|
||||
root,
|
||||
{ id: "right", kind: "empty" as const, xOffset: 1, layouts: [] },
|
||||
];
|
||||
const board = {
|
||||
layouts: [mobile, baseWithRails],
|
||||
sections: roots,
|
||||
items: roots.map((section, index) => ({
|
||||
id: `item-${section.id}`,
|
||||
layouts: [
|
||||
{
|
||||
layoutId: base.id,
|
||||
sectionId: section.id,
|
||||
width: 2,
|
||||
height: 1,
|
||||
xOffset: 0,
|
||||
yOffset: index,
|
||||
},
|
||||
],
|
||||
})),
|
||||
} satisfies BoardPreviewData;
|
||||
|
||||
const projected = projectBoardLayout(board, baseWithRails, mobile);
|
||||
|
||||
expect(projected).toHaveLength(3);
|
||||
expect(projected.every((element) => element.sectionId === root.id)).toBe(true);
|
||||
expect(projected.every((element) => element.xOffset >= 0 && element.xOffset + element.width <= 3)).toBe(true);
|
||||
for (const [index, element] of projected.entries()) {
|
||||
expect(
|
||||
projected
|
||||
.slice(index + 1)
|
||||
.some(
|
||||
(other) =>
|
||||
element.xOffset < other.xOffset + other.width &&
|
||||
element.xOffset + element.width > other.xOffset &&
|
||||
element.yOffset < other.yOffset + other.height &&
|
||||
element.yOffset + element.height > other.yOffset,
|
||||
),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns an empty preview when malformed data has no main root", () => {
|
||||
const board = {
|
||||
layouts: [mobile, base],
|
||||
sections: [{ id: "left", kind: "empty", xOffset: -1, layouts: [] }],
|
||||
items: [],
|
||||
} satisfies BoardPreviewData;
|
||||
|
||||
expect(projectBoardLayout(board, base, mobile)).toEqual([]);
|
||||
});
|
||||
});
|
||||
+62
-8
@@ -1,14 +1,64 @@
|
||||
import { generateResponsiveGridFor } from "@homarr/common";
|
||||
import type { GridAlgorithmItem } from "@homarr/common";
|
||||
import type { BoardLane } from "@homarr/definitions";
|
||||
import type { BoardLane, LayoutRole } from "@homarr/definitions";
|
||||
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { Board } from "./_types";
|
||||
export interface BoardPreviewLayout {
|
||||
id: string;
|
||||
columnCount: number;
|
||||
leftGutterColumnCount: number;
|
||||
rightGutterColumnCount: number;
|
||||
breakpoint: number;
|
||||
role: LayoutRole;
|
||||
}
|
||||
|
||||
export type BoardLayout = Board["layouts"][number];
|
||||
type LayoutGeometry = Pick<BoardLayout, "id" | "columnCount" | "leftGutterColumnCount" | "rightGutterColumnCount">;
|
||||
interface BoardPreviewItemLayout {
|
||||
layoutId: string;
|
||||
sectionId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const getRepresentativeLayoutWidth = (layout: BoardLayout, allLayouts: BoardLayout[]) => {
|
||||
interface BoardPreviewSectionLayout {
|
||||
layoutId: string;
|
||||
parentSectionId: string | null;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface BoardPreviewData {
|
||||
layouts: readonly BoardPreviewLayout[];
|
||||
sections: readonly BoardPreviewSection[];
|
||||
items: readonly {
|
||||
id: string;
|
||||
layouts: readonly BoardPreviewItemLayout[];
|
||||
}[];
|
||||
}
|
||||
|
||||
type BoardPreviewSection =
|
||||
| {
|
||||
id: string;
|
||||
kind: "empty";
|
||||
xOffset: number | null;
|
||||
layouts?: readonly BoardPreviewSectionLayout[];
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "container";
|
||||
xOffset?: number | null;
|
||||
layouts: readonly BoardPreviewSectionLayout[];
|
||||
};
|
||||
|
||||
type LayoutGeometry = Pick<
|
||||
BoardPreviewLayout,
|
||||
"id" | "columnCount" | "leftGutterColumnCount" | "rightGutterColumnCount"
|
||||
>;
|
||||
|
||||
export const getRepresentativeLayoutWidth = (layout: BoardPreviewLayout, allLayouts: readonly BoardPreviewLayout[]) => {
|
||||
if (layout.role === "mobile") return 390;
|
||||
if (layout.role === "base") return Math.max(1280, layout.breakpoint);
|
||||
|
||||
@@ -20,7 +70,11 @@ export const getRepresentativeLayoutWidth = (layout: BoardLayout, allLayouts: Bo
|
||||
return nextLayout ? Math.round((layout.breakpoint + nextLayout.breakpoint) / 2) : Math.max(1280, layout.breakpoint);
|
||||
};
|
||||
|
||||
export const projectBoardLayout = (board: Board, sourceLayout: LayoutGeometry, targetLayout: LayoutGeometry) => {
|
||||
export const projectBoardLayout = (
|
||||
board: BoardPreviewData,
|
||||
sourceLayout: LayoutGeometry,
|
||||
targetLayout: LayoutGeometry,
|
||||
) => {
|
||||
const elements = getElementsForLayout(board, sourceLayout.id);
|
||||
|
||||
if (
|
||||
@@ -34,7 +88,7 @@ export const projectBoardLayout = (board: Board, sourceLayout: LayoutGeometry, t
|
||||
|
||||
const emptyRoots = board.sections
|
||||
.filter((section) => section.kind === "empty")
|
||||
.toSorted((first, second) => first.xOffset - second.xOffset || first.id.localeCompare(second.id));
|
||||
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
|
||||
const rootByLane = new Map<BoardLane, (typeof emptyRoots)[number]>();
|
||||
for (const root of emptyRoots) {
|
||||
const lane = getRootSectionLane(root.xOffset);
|
||||
@@ -85,7 +139,7 @@ export const projectBoardLayout = (board: Board, sourceLayout: LayoutGeometry, t
|
||||
});
|
||||
};
|
||||
|
||||
const getElementsForLayout = (board: Board, layoutId: string): GridAlgorithmItem[] => [
|
||||
const getElementsForLayout = (board: BoardPreviewData, layoutId: string): GridAlgorithmItem[] => [
|
||||
...board.items.flatMap((item) => {
|
||||
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
return layout
|
||||
@@ -686,7 +686,7 @@ const seedDemoUserAsync = async (db: Database) => {
|
||||
|
||||
await db.update(onboarding).set({
|
||||
step: "finish",
|
||||
previousStep: "settings",
|
||||
previousStep: "setup",
|
||||
});
|
||||
|
||||
const integrationId = createId();
|
||||
|
||||
@@ -8,10 +8,24 @@ export type UrlTemplateMode = "hostPort" | "subdomain";
|
||||
|
||||
const getSlugForKind = (kind: IntegrationKind): string => integrationIconSlugs[kind];
|
||||
|
||||
const buildUrl = (slug: string, host: string, mode: UrlTemplateMode, port?: number): string => {
|
||||
const normalizeBaseHost = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = new URL(trimmed.includes("://") ? trimmed : `http://${trimmed}`);
|
||||
return { hostname: parsed.hostname, port: parsed.port ? Number(parsed.port) : undefined };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const buildUrl = (slug: string, rawHost: string, mode: UrlTemplateMode, port?: number): string => {
|
||||
const base = normalizeBaseHost(rawHost);
|
||||
if (!base) return "";
|
||||
const effectivePort = port ?? base.port;
|
||||
const modeBuilders: Record<UrlTemplateMode, () => string> = {
|
||||
subdomain: () => `https://${slug}.${host}`,
|
||||
hostPort: () => (port ? `http://${host}:${port}` : `http://${host}`),
|
||||
subdomain: () => (base.hostname.startsWith("[") ? "" : `https://${slug}.${base.hostname}`),
|
||||
hostPort: () => (effectivePort ? `http://${base.hostname}:${effectivePort}` : `http://${base.hostname}`),
|
||||
};
|
||||
return modeBuilders[mode]();
|
||||
};
|
||||
@@ -23,7 +37,6 @@ export const buildIntegrationUrl = (
|
||||
dockerPort?: number,
|
||||
): string => {
|
||||
const host = removeTrailingSlash(baseHost.trim());
|
||||
if (!host) return "";
|
||||
const port = dockerPort ?? getIntegrationDefaultPort(kind);
|
||||
return buildUrl(getSlugForKind(kind), host, mode, port ?? undefined);
|
||||
};
|
||||
@@ -35,7 +48,6 @@ export const buildAppUrl = (
|
||||
dockerPort?: number,
|
||||
): string => {
|
||||
const host = removeTrailingSlash(baseHost.trim());
|
||||
if (!host) return "";
|
||||
const slug = containerName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
||||
return buildUrl(slug, host, mode, dockerPort);
|
||||
};
|
||||
@@ -222,6 +222,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/technitium.svg",
|
||||
category: ["dnsHole"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/technitium-dns"),
|
||||
defaultPort: 5380,
|
||||
},
|
||||
homeAssistant: {
|
||||
name: "Home Assistant",
|
||||
@@ -230,6 +231,7 @@ export const integrationDefs = {
|
||||
category: ["smartHomeServer", "calendar"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/home-assistant"),
|
||||
defaultPort: 8123,
|
||||
apiKeySettingsPath: "/profile/security",
|
||||
},
|
||||
openmediavault: {
|
||||
name: "OpenMediaVault",
|
||||
@@ -245,13 +247,14 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/dash-dot"),
|
||||
defaultPort: 3001,
|
||||
},
|
||||
glances: {
|
||||
name: "Glances",
|
||||
secretKinds: [[]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: null,
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/glances"),
|
||||
defaultPort: 61208,
|
||||
},
|
||||
tdarr: {
|
||||
@@ -293,6 +296,7 @@ export const integrationDefs = {
|
||||
category: ["firewall"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/opnsense"),
|
||||
defaultPort: 443,
|
||||
apiKeySettingsPath: "/system_usermanager.php",
|
||||
},
|
||||
ntfy: {
|
||||
name: "ntfy",
|
||||
@@ -301,6 +305,7 @@ export const integrationDefs = {
|
||||
category: ["notifications"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/ntfy"),
|
||||
defaultPort: 80,
|
||||
apiKeySettingsPath: "/account",
|
||||
},
|
||||
gotify: {
|
||||
name: "Gotify",
|
||||
@@ -308,6 +313,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gotify.svg",
|
||||
category: ["notifications"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/gotify"),
|
||||
defaultPort: 80,
|
||||
},
|
||||
ical: {
|
||||
name: "iCal",
|
||||
@@ -323,6 +329,7 @@ export const integrationDefs = {
|
||||
category: ["notes"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/anchor"),
|
||||
defaultPort: 8080,
|
||||
apiKeySettingsPath: "/settings",
|
||||
},
|
||||
truenas: {
|
||||
name: "TrueNAS",
|
||||
@@ -347,6 +354,7 @@ export const integrationDefs = {
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/unraid"),
|
||||
defaultPort: 80,
|
||||
apiKeySettingsPath: "/Settings/ManagementAccess",
|
||||
},
|
||||
coolify: {
|
||||
name: "Coolify",
|
||||
@@ -355,6 +363,7 @@ export const integrationDefs = {
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/coolify"),
|
||||
defaultPort: 8000,
|
||||
apiKeySettingsPath: "/security/api-tokens",
|
||||
},
|
||||
immich: {
|
||||
name: "Immich",
|
||||
@@ -363,13 +372,14 @@ export const integrationDefs = {
|
||||
category: ["photoService"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/immich"),
|
||||
defaultPort: 2283,
|
||||
apiKeySettingsPath: "/user-settings",
|
||||
},
|
||||
paperlessNgx: {
|
||||
name: "Paperless-ngx",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/paperless-ngx.svg",
|
||||
category: ["documents"],
|
||||
documentationUrl: null,
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/paperless-ngx"),
|
||||
defaultPort: 8000,
|
||||
},
|
||||
patchmon: {
|
||||
@@ -388,6 +398,7 @@ export const integrationDefs = {
|
||||
category: ["mediaMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/tracearr"),
|
||||
defaultPort: 7040,
|
||||
apiKeySettingsPath: "/settings",
|
||||
},
|
||||
speedtestTracker: {
|
||||
name: "Speedtest Tracker",
|
||||
@@ -396,21 +407,23 @@ export const integrationDefs = {
|
||||
category: ["speedtest"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"),
|
||||
defaultPort: 80,
|
||||
apiKeySettingsPath: "/admin/api-tokens",
|
||||
},
|
||||
uptimeKuma: {
|
||||
name: "Uptime Kuma",
|
||||
secretKinds: [[], ["slug"], ["slug", "apiKey"], ["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/uptime-kuma.svg",
|
||||
category: ["uptimeMonitoring"],
|
||||
documentationUrl: null,
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/uptime-kuma"),
|
||||
defaultPort: 3001,
|
||||
apiKeySettingsPath: "/settings/api-keys",
|
||||
},
|
||||
audiobookshelf: {
|
||||
name: "Audiobookshelf",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/audiobookshelf.svg",
|
||||
category: ["mediaLibrary"],
|
||||
documentationUrl: null,
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/audiobookshelf"),
|
||||
defaultPort: 13378,
|
||||
apiKeySettingsPath: "/account",
|
||||
},
|
||||
@@ -419,7 +432,7 @@ export const integrationDefs = {
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg",
|
||||
category: ["mediaLibrary", "mediaService"],
|
||||
documentationUrl: null,
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/navidrome"),
|
||||
defaultPort: 4533,
|
||||
},
|
||||
umami: {
|
||||
@@ -436,7 +449,8 @@ export const integrationDefs = {
|
||||
secretKinds: [["username", "password"], []],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg",
|
||||
category: ["ups"],
|
||||
documentationUrl: null,
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/peanut"),
|
||||
defaultPort: 8080,
|
||||
},
|
||||
beszel: {
|
||||
name: "Beszel",
|
||||
@@ -452,6 +466,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gluetun.svg",
|
||||
category: ["vpn"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/gluetun"),
|
||||
defaultPort: 8000,
|
||||
},
|
||||
traefik: {
|
||||
name: "Traefik",
|
||||
@@ -466,7 +481,8 @@ export const integrationDefs = {
|
||||
secretKinds: [[], ["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
|
||||
category: ["archiving"],
|
||||
documentationUrl: null,
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/archiveteam-warrior"),
|
||||
defaultPort: 8001,
|
||||
},
|
||||
wud: {
|
||||
name: "What's Up Docker",
|
||||
@@ -524,6 +540,9 @@ export const getIntegrationDefaultPort = (kind: IntegrationKind): number | undef
|
||||
return "defaultPort" in definition ? definition.defaultPort : undefined;
|
||||
};
|
||||
|
||||
export const getIntegrationDocumentationUrl = (kind: IntegrationKind): string | null =>
|
||||
integrationDefs[kind].documentationUrl;
|
||||
|
||||
export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: IntegrationKind): string | null => {
|
||||
const definition = integrationDefs[kind];
|
||||
if (!("apiKeySettingsPath" in definition)) return null;
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
export const onboardingSteps = ["start", "import", "user", "group", "settings", "integrations", "finish"] as const;
|
||||
export const onboardingSteps = ["start", "user", "group", "setup", "finish"] as const;
|
||||
export type OnboardingStep = (typeof onboardingSteps)[number];
|
||||
|
||||
export const onboardingLayoutPresets = ["balanced", "wide", "focused"] as const;
|
||||
export type OnboardingLayoutPreset = (typeof onboardingLayoutPresets)[number];
|
||||
@@ -1,8 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getIntegrationApiKeyUrl } from "../integration";
|
||||
import { getIntegrationApiKeyUrl, integrationDefs } from "../integration";
|
||||
|
||||
describe("getIntegrationApiKeyUrl", () => {
|
||||
it("links every user-facing integration to its Homarr setup guide", () => {
|
||||
expect(
|
||||
Object.entries(integrationDefs)
|
||||
.filter(([kind, definition]) => kind !== "mock" && !definition.documentationUrl)
|
||||
.map(([kind]) => kind),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("builds URL for sonarr", () => {
|
||||
expect(getIntegrationApiKeyUrl("http://192.168.1.10:8989", "sonarr")).toBe(
|
||||
"http://192.168.1.10:8989/settings/general",
|
||||
@@ -21,10 +29,28 @@ describe("getIntegrationApiKeyUrl", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for integrations without apiKeySettingsPath", () => {
|
||||
expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull();
|
||||
it.each([
|
||||
["homeAssistant", "http://homeassistant.local:8123", "http://homeassistant.local:8123/profile/security"],
|
||||
["opnsense", "https://firewall.local", "https://firewall.local/system_usermanager.php"],
|
||||
["ntfy", "https://ntfy.local", "https://ntfy.local/account"],
|
||||
["anchor", "http://anchor.local:8080", "http://anchor.local:8080/settings"],
|
||||
["unraid", "http://tower.local", "http://tower.local/Settings/ManagementAccess"],
|
||||
["coolify", "https://coolify.local", "https://coolify.local/security/api-tokens"],
|
||||
["immich", "http://immich.local:2283", "http://immich.local:2283/user-settings"],
|
||||
["tracearr", "http://tracearr.local:7040", "http://tracearr.local:7040/settings"],
|
||||
["speedtestTracker", "http://speedtest.local", "http://speedtest.local/admin/api-tokens"],
|
||||
["uptimeKuma", "http://uptime.local:3001", "http://uptime.local:3001/settings/api-keys"],
|
||||
] as const)("builds a verified credential URL for %s", (kind, baseUrl, expected) => {
|
||||
expect(getIntegrationApiKeyUrl(baseUrl, kind)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(["plex", "piHole", "truenas"] as const)(
|
||||
"returns null when %s has no version-safe credential path",
|
||||
(kind) => {
|
||||
expect(getIntegrationApiKeyUrl("http://localhost", kind)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("returns null for empty URL", () => {
|
||||
expect(getIntegrationApiKeyUrl("", "sonarr")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getIntegrationDefaultPort } from "../integration";
|
||||
import { buildIntegrationUrl } from "../integration-url-template";
|
||||
import { buildAppUrl, buildIntegrationUrl } from "../integration-url-template";
|
||||
|
||||
describe("buildIntegrationUrl", () => {
|
||||
it("builds subdomain URL", () => {
|
||||
@@ -25,6 +25,31 @@ describe("buildIntegrationUrl", () => {
|
||||
expect(buildIntegrationUrl("sonarr", "homelab.local///", "subdomain")).toBe("https://sonarr.homelab.local");
|
||||
});
|
||||
|
||||
it("accepts full URLs but ignores their path when suggesting service URLs", () => {
|
||||
expect(buildIntegrationUrl("sonarr", "https://home.example.com/homarr", "hostPort")).toBe(
|
||||
"http://home.example.com:8989",
|
||||
);
|
||||
expect(buildAppUrl("My Service", "http://home.example.com/path", "subdomain")).toBe(
|
||||
"https://my-service.home.example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces a supplied base port instead of producing a double port", () => {
|
||||
expect(buildIntegrationUrl("sonarr", "https://home.example.com:8443/homarr", "hostPort")).toBe(
|
||||
"http://home.example.com:8989",
|
||||
);
|
||||
expect(buildAppUrl("My Service", "https://home.example.com:8443/homarr", "hostPort")).toBe(
|
||||
"http://home.example.com:8443",
|
||||
);
|
||||
});
|
||||
|
||||
it("formats IPv6 hosts safely in host-port mode", () => {
|
||||
expect(buildIntegrationUrl("sonarr", "https://[2001:db8::1]:8443/homarr", "hostPort")).toBe(
|
||||
"http://[2001:db8::1]:8989",
|
||||
);
|
||||
expect(buildIntegrationUrl("sonarr", "[2001:db8::1]", "subdomain")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for empty host", () => {
|
||||
expect(buildIntegrationUrl("sonarr", "", "subdomain")).toBe("");
|
||||
});
|
||||
@@ -37,5 +62,11 @@ describe("buildIntegrationUrl", () => {
|
||||
expect(getIntegrationDefaultPort("radarr")).toBe(7878);
|
||||
expect(getIntegrationDefaultPort("prowlarr")).toBe(9696);
|
||||
expect(getIntegrationDefaultPort("jellyfin")).toBe(8096);
|
||||
expect(getIntegrationDefaultPort("technitiumDns")).toBe(5380);
|
||||
expect(getIntegrationDefaultPort("dashDot")).toBe(3001);
|
||||
expect(getIntegrationDefaultPort("gotify")).toBe(80);
|
||||
expect(getIntegrationDefaultPort("peaNut")).toBe(8080);
|
||||
expect(getIntegrationDefaultPort("gluetun")).toBe(8000);
|
||||
expect(getIntegrationDefaultPort("archiveTeamWarrior")).toBe(8001);
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,40 @@ export const widgetIntegrationLimits: Partial<Record<WidgetKind, number>> = {
|
||||
audioStats: 1,
|
||||
};
|
||||
|
||||
export type WidgetIntegrationIssue =
|
||||
| { code: "integration-not-supported" }
|
||||
| { code: "integration-required" }
|
||||
| { code: "incompatible-integration"; incompatibleKinds: IntegrationKind[] }
|
||||
| { code: "integration-limit"; limit: number };
|
||||
|
||||
export const getWidgetIntegrationIssue = (
|
||||
widgetKind: WidgetKind,
|
||||
integrationKinds: readonly IntegrationKind[],
|
||||
): WidgetIntegrationIssue | null => {
|
||||
const supportedIntegrations = widgetIntegrationSupport[widgetKind];
|
||||
const integrationLimit = widgetIntegrationLimits[widgetKind] ?? Number.POSITIVE_INFINITY;
|
||||
if (integrationKinds.length > integrationLimit) return { code: "integration-limit", limit: integrationLimit };
|
||||
if (supportedIntegrations === undefined && integrationKinds.length > 0) return { code: "integration-not-supported" };
|
||||
if (
|
||||
supportedIntegrations !== undefined &&
|
||||
integrationKinds.length === 0 &&
|
||||
!widgetKindsWithOptionalIntegrations.has(widgetKind)
|
||||
) {
|
||||
return { code: "integration-required" };
|
||||
}
|
||||
const incompatibleKinds = integrationKinds.filter((kind) => !supportedIntegrations?.includes(kind));
|
||||
return incompatibleKinds.length > 0 ? { code: "incompatible-integration", incompatibleKinds } : null;
|
||||
};
|
||||
|
||||
export const getWidgetIntegrationIssueMessage = (widgetKind: WidgetKind, issue: WidgetIntegrationIssue) => {
|
||||
if (issue.code === "integration-limit") {
|
||||
return `${widgetKind} supports at most ${issue.limit} integration${issue.limit === 1 ? "" : "s"}`;
|
||||
}
|
||||
if (issue.code === "integration-not-supported") return `${widgetKind} does not support integrations`;
|
||||
if (issue.code === "integration-required") return `${widgetKind} requires an integration`;
|
||||
return `${widgetKind} does not support integration kind${issue.incompatibleKinds.length === 1 ? "" : "s"}: ${issue.incompatibleKinds.join(", ")}`;
|
||||
};
|
||||
|
||||
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
|
||||
const result: WidgetKind[] = [];
|
||||
for (const [widgetKind, supportedIntegrations] of Object.entries(widgetIntegrationSupport)) {
|
||||
|
||||
@@ -25,11 +25,13 @@
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"dockerode": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/dockerode": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { listDiscoveredContainersAsync } from "./list-discovered-containers";
|
||||
export { parseContainerLabels, type ParseContainerLabelsOptions } from "./parse-container-labels";
|
||||
export type { DiscoveredService, DockerDiscoveryHostResult, DockerDiscoveryResult } from "./types";
|
||||
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const listContainersA = vi.fn();
|
||||
const listContainersB = vi.fn();
|
||||
|
||||
vi.mock("../singleton", () => ({
|
||||
DockerSingleton: {
|
||||
getInstances: () => [
|
||||
{ host: "good", instance: { listContainers: listContainersA } },
|
||||
{ host: "bad", instance: { listContainers: listContainersB } },
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||
createLogger: () => ({ warn: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { listDiscoveredContainersAsync } from "./list-discovered-containers";
|
||||
|
||||
describe("listDiscoveredContainersAsync", () => {
|
||||
beforeEach(() => {
|
||||
listContainersA.mockReset();
|
||||
listContainersB.mockReset();
|
||||
});
|
||||
|
||||
it("preserves successful hosts when another Docker host fails", async () => {
|
||||
listContainersA.mockResolvedValue([
|
||||
{
|
||||
Id: "sonarr",
|
||||
Labels: {
|
||||
"homarr.name": "Sonarr",
|
||||
"homarr.group": "Media",
|
||||
"homarr.href": "http://sonarr:8989",
|
||||
},
|
||||
},
|
||||
]);
|
||||
listContainersB.mockRejectedValue(new Error("permission denied"));
|
||||
|
||||
const result = await listDiscoveredContainersAsync();
|
||||
|
||||
expect(result.services).toHaveLength(1);
|
||||
expect(result.hosts).toEqual([
|
||||
expect.objectContaining({ host: "good", status: "success" }),
|
||||
{
|
||||
host: "bad",
|
||||
status: "unavailable",
|
||||
reason: "permission denied",
|
||||
containers: [],
|
||||
services: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("times out a blackholed host without losing another host's result", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
listContainersA.mockResolvedValue([
|
||||
{
|
||||
Id: "sonarr",
|
||||
Labels: {
|
||||
"homarr.name": "Sonarr",
|
||||
"homarr.group": "Media",
|
||||
"homarr.href": "http://sonarr:8989",
|
||||
},
|
||||
},
|
||||
]);
|
||||
listContainersB.mockReturnValue(new Promise(() => undefined));
|
||||
|
||||
const resultPromise = listDiscoveredContainersAsync({}, 25);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.services).toHaveLength(1);
|
||||
expect(result.hosts).toEqual([
|
||||
expect.objectContaining({ host: "good", status: "success" }),
|
||||
expect.objectContaining({ host: "bad", status: "unavailable", reason: "Docker discovery timed out for bad" }),
|
||||
]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
|
||||
import { DockerSingleton } from "../singleton";
|
||||
import type { ParseContainerLabelsOptions } from "./parse-container-labels";
|
||||
import { parseContainerLabels } from "./parse-container-labels";
|
||||
import type { DockerDiscoveryHostResult, DockerDiscoveryResult } from "./types";
|
||||
|
||||
const logger = createLogger({ module: "dockerDiscovery" });
|
||||
export const dockerDiscoveryHostTimeoutMs = 5_000;
|
||||
|
||||
const withTimeoutAsync = async <T>(promise: Promise<T>, timeoutMs: number, host: string) => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error(`Docker discovery timed out for ${host}`)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
const reasonFromError = (error: unknown) => {
|
||||
if (error instanceof Error && error.message.trim()) return error.message;
|
||||
return "Docker is not reachable";
|
||||
};
|
||||
|
||||
export const listDiscoveredContainersAsync = async (
|
||||
options: ParseContainerLabelsOptions = {},
|
||||
timeoutMs = dockerDiscoveryHostTimeoutMs,
|
||||
): Promise<DockerDiscoveryResult> => {
|
||||
const dockerInstances = DockerSingleton.getInstances();
|
||||
const settled = await Promise.allSettled(
|
||||
dockerInstances.map(async ({ instance, host }): Promise<DockerDiscoveryHostResult> => {
|
||||
const containers = await withTimeoutAsync(instance.listContainers({ all: false }), timeoutMs, host);
|
||||
const services = containers
|
||||
.map((container) => parseContainerLabels(container, host, options))
|
||||
.filter((service) => service !== null);
|
||||
return { host, status: "success", containers, services };
|
||||
}),
|
||||
);
|
||||
|
||||
const hosts = settled.map<DockerDiscoveryHostResult>((result, index) => {
|
||||
const host = dockerInstances[index]?.host ?? "unknown";
|
||||
if (result.status === "fulfilled") return result.value;
|
||||
|
||||
logger.warn(new ErrorWithMetadata("Failed to list containers for discovery", { host }, { cause: result.reason }));
|
||||
return {
|
||||
host,
|
||||
status: "unavailable",
|
||||
reason: reasonFromError(result.reason),
|
||||
containers: [],
|
||||
services: [],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
hosts,
|
||||
services: hosts.flatMap((result) => result.services),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseContainerLabels } from "./parse-container-labels";
|
||||
|
||||
const container = (Labels: Record<string, string>, Id = "container-1") => ({ Id, Labels });
|
||||
|
||||
describe("parseContainerLabels", () => {
|
||||
it("parses complete Homarr labels", () => {
|
||||
expect(
|
||||
parseContainerLabels(
|
||||
container({
|
||||
"homarr.name": "Sonarr",
|
||||
"homarr.group": "Media",
|
||||
"homarr.href": "http://sonarr:8989",
|
||||
"homarr.icon": "sonarr.svg",
|
||||
"homarr.description": "TV",
|
||||
"homarr.ping": "http://sonarr:8989/ping",
|
||||
"homarr.id": "sonarr-main",
|
||||
"homarr.board": "dashboard",
|
||||
"homarr.integration": "sonarr",
|
||||
"homarr.widget": "calendar",
|
||||
}),
|
||||
"socket",
|
||||
),
|
||||
).toEqual({
|
||||
sourceId: "docker:socket:sonarr-main",
|
||||
containerId: "container-1",
|
||||
host: "socket",
|
||||
group: "Media",
|
||||
name: "Sonarr",
|
||||
href: "http://sonarr:8989",
|
||||
icon: "sonarr.svg",
|
||||
description: "TV",
|
||||
pingUrl: "http://sonarr:8989/ping",
|
||||
externalId: "sonarr-main",
|
||||
boardName: "dashboard",
|
||||
integrationKind: "sonarr",
|
||||
widgetKind: "calendar",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports Homepage labels when Homarr labels are absent", () => {
|
||||
expect(
|
||||
parseContainerLabels(
|
||||
container({
|
||||
"homepage.name": "Jellyfin",
|
||||
"homepage.group": "Media",
|
||||
"homepage.href": "http://jellyfin:8096",
|
||||
"homepage.icon": "jellyfin.svg",
|
||||
}),
|
||||
"remote:2375",
|
||||
),
|
||||
).toMatchObject({
|
||||
sourceId: "docker:remote:2375:container-1",
|
||||
name: "Jellyfin",
|
||||
group: "Media",
|
||||
href: "http://jellyfin:8096",
|
||||
icon: "jellyfin.svg",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a labeled service without a group for root placement", () => {
|
||||
expect(
|
||||
parseContainerLabels(
|
||||
container({
|
||||
"homarr.name": "Status",
|
||||
"homarr.href": "http://status:3001",
|
||||
"homarr.widget": "clock",
|
||||
}),
|
||||
"socket",
|
||||
),
|
||||
).toMatchObject({ name: "Status", href: "http://status:3001", group: undefined, widgetKind: "clock" });
|
||||
});
|
||||
|
||||
it("does not mix Homepage required fields into an explicitly Homarr-labeled service", () => {
|
||||
expect(
|
||||
parseContainerLabels(
|
||||
container({
|
||||
"homarr.name": "Sonarr",
|
||||
"homepage.group": "Media",
|
||||
"homepage.href": "http://sonarr:8989",
|
||||
}),
|
||||
"socket",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("can disable Homepage fallback", () => {
|
||||
expect(
|
||||
parseContainerLabels(
|
||||
container({
|
||||
"homepage.name": "Sonarr",
|
||||
"homepage.group": "Media",
|
||||
"homepage.href": "http://sonarr:8989",
|
||||
}),
|
||||
"socket",
|
||||
{ readHomepageLabels: false },
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores hidden and incomplete containers", () => {
|
||||
expect(
|
||||
parseContainerLabels(
|
||||
container({
|
||||
"homarr.hide": "true",
|
||||
"homarr.name": "Sonarr",
|
||||
"homarr.group": "Media",
|
||||
"homarr.href": "http://sonarr:8989",
|
||||
}),
|
||||
"socket",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(parseContainerLabels(container({ "homarr.name": "Sonarr" }), "socket")).toBeNull();
|
||||
});
|
||||
|
||||
it("trims values and ignores invalid integration and widget kinds", () => {
|
||||
expect(
|
||||
parseContainerLabels(
|
||||
container({
|
||||
"homarr.name": " Sonarr ",
|
||||
"homarr.group": " Media ",
|
||||
"homarr.href": " http://sonarr:8989 ",
|
||||
"homarr.integration": "not-real",
|
||||
"homarr.widget": "not-real",
|
||||
}),
|
||||
"socket",
|
||||
),
|
||||
).toMatchObject({
|
||||
name: "Sonarr",
|
||||
group: "Media",
|
||||
href: "http://sonarr:8989",
|
||||
integrationKind: undefined,
|
||||
widgetKind: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ContainerInfo } from "dockerode";
|
||||
|
||||
import { integrationKinds, widgetKinds } from "@homarr/definitions";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import { dockerLabels, homepageLabels } from "../labels";
|
||||
import type { DiscoveredService } from "./types";
|
||||
|
||||
export interface ParseContainerLabelsOptions {
|
||||
readHomepageLabels?: boolean;
|
||||
}
|
||||
|
||||
const integrationKindSet = new Set<string>(integrationKinds);
|
||||
const widgetKindSet = new Set<string>(widgetKinds);
|
||||
|
||||
const readLabel = (labels: Record<string, string>, key: string) => {
|
||||
const value = labels[key]?.trim();
|
||||
return value ? value : undefined;
|
||||
};
|
||||
|
||||
const resolveLabelValue = (
|
||||
labels: Record<string, string>,
|
||||
homarrKey: string,
|
||||
homepageKey: string,
|
||||
useHomepageFallback: boolean,
|
||||
) => readLabel(labels, homarrKey) ?? (useHomepageFallback ? readLabel(labels, homepageKey) : undefined);
|
||||
|
||||
const parseIntegrationKind = (value: string | undefined): IntegrationKind | undefined =>
|
||||
value && integrationKindSet.has(value) ? (value as IntegrationKind) : undefined;
|
||||
|
||||
const parseWidgetKind = (value: string | undefined): WidgetKind | undefined =>
|
||||
value && widgetKindSet.has(value) ? (value as WidgetKind) : undefined;
|
||||
|
||||
export const parseContainerLabels = (
|
||||
container: Pick<ContainerInfo, "Id" | "Labels">,
|
||||
host: string,
|
||||
options: ParseContainerLabelsOptions = {},
|
||||
): DiscoveredService | null => {
|
||||
const labels = container.Labels ?? {};
|
||||
if (dockerLabels.hide in labels) return null;
|
||||
|
||||
const readHomepageLabels = options.readHomepageLabels ?? true;
|
||||
const useHomepageFallback = readHomepageLabels && !readLabel(labels, dockerLabels.name);
|
||||
const name = resolveLabelValue(labels, dockerLabels.name, homepageLabels.name, useHomepageFallback);
|
||||
const href = resolveLabelValue(labels, dockerLabels.href, homepageLabels.href, useHomepageFallback);
|
||||
const group = resolveLabelValue(labels, dockerLabels.group, homepageLabels.group, useHomepageFallback);
|
||||
if (!name || !href) return null;
|
||||
|
||||
const externalId = readLabel(labels, dockerLabels.id) ?? container.Id;
|
||||
return {
|
||||
sourceId: `docker:${host}:${externalId}`,
|
||||
containerId: container.Id,
|
||||
host,
|
||||
group,
|
||||
name,
|
||||
href,
|
||||
icon: resolveLabelValue(labels, dockerLabels.icon, homepageLabels.icon, useHomepageFallback),
|
||||
description: resolveLabelValue(labels, dockerLabels.description, homepageLabels.description, useHomepageFallback),
|
||||
pingUrl: readLabel(labels, dockerLabels.ping),
|
||||
externalId,
|
||||
boardName: readLabel(labels, dockerLabels.board),
|
||||
integrationKind: parseIntegrationKind(readLabel(labels, dockerLabels.integration)),
|
||||
widgetKind: parseWidgetKind(readLabel(labels, dockerLabels.widget)),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ContainerInfo } from "dockerode";
|
||||
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
|
||||
export interface DiscoveredService {
|
||||
sourceId: string;
|
||||
containerId: string;
|
||||
host: string;
|
||||
group?: string;
|
||||
name: string;
|
||||
href: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
pingUrl?: string;
|
||||
externalId: string;
|
||||
boardName?: string;
|
||||
integrationKind?: IntegrationKind;
|
||||
widgetKind?: WidgetKind;
|
||||
}
|
||||
|
||||
export type DockerDiscoveryHostResult =
|
||||
| {
|
||||
host: string;
|
||||
status: "success";
|
||||
containers: ContainerInfo[];
|
||||
services: DiscoveredService[];
|
||||
}
|
||||
| {
|
||||
host: string;
|
||||
status: "unavailable";
|
||||
reason: string;
|
||||
containers: [];
|
||||
services: [];
|
||||
};
|
||||
|
||||
export interface DockerDiscoveryResult {
|
||||
hosts: DockerDiscoveryHostResult[];
|
||||
services: DiscoveredService[];
|
||||
}
|
||||
@@ -9,3 +9,4 @@ export const containerStates = ["created", "running", "paused", "restarting", "e
|
||||
|
||||
export type ContainerState = (typeof containerStates)[number];
|
||||
export * from "./labels";
|
||||
export * from "./discovery";
|
||||
@@ -1,4 +1,21 @@
|
||||
export const dockerLabels = {
|
||||
// Label to hide a container from Homarrs docker integration
|
||||
hide: "homarr.hide",
|
||||
group: "homarr.group",
|
||||
name: "homarr.name",
|
||||
href: "homarr.href",
|
||||
icon: "homarr.icon",
|
||||
description: "homarr.description",
|
||||
ping: "homarr.ping",
|
||||
id: "homarr.id",
|
||||
board: "homarr.board",
|
||||
integration: "homarr.integration",
|
||||
widget: "homarr.widget",
|
||||
} as const;
|
||||
|
||||
export const homepageLabels = {
|
||||
group: "homepage.group",
|
||||
name: "homepage.name",
|
||||
href: "homepage.href",
|
||||
icon: "homepage.icon",
|
||||
description: "homepage.description",
|
||||
} as const;
|
||||
Loaded 100 of 249 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user