feat: rebuild onboarding experience

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:32:11 +02:00
1 parent d091362db2
commit e41605640b
249 files changed
+8216 -14060

No files matched your search

-2
View File
@@ -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
+9 -19
View File
@@ -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
-2
View File
@@ -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
View File
@@ -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`.
![Onboarding initial step](./img/onboarding-step-1.png)
:::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.
![Onboarding import dropzone](./img/old-homarr-import-dropzone.png)
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.
![Onboarding import list](./img/old-homarr-import-choose-imported-data.png)
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.
![Onboarding import key input](./img/old-homarr-import-decryption-token.png)
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
![Screenshot of Step 1 onboarding](./img/onboarding-step-1.png)
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`:
![](./img/manage-create-board-button.png)
A board must always have a name and can either be public or private.
+2 -2
View File
@@ -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.
![Apps list](./img/app-list.png)
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.
![](./img/create-integration-dropdown.png)
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`.
![](./img/search-engines.png)
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.
+1 -2
View File
@@ -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>
);
};
+56 -58
View File
@@ -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}
+11 -2
View File
@@ -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;
+8 -1
View File
@@ -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>
+10
View File
@@ -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();
+2 -1
View File
@@ -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
View File
@@ -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()]);
}
+8 -10
View File
@@ -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 });
}
}
+2 -2
View File
@@ -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",
});
}
+2 -2
View File
@@ -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",
+112
View File
@@ -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 };
};
+1
View File
@@ -0,0 +1 @@
export { normalizeOnboardingStep } from "./router/onboard/onboard-queries";
-1
View File
@@ -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)),
+1 -2
View File
@@ -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({
+176 -83
View File
@@ -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>,
+18 -9
View File
@@ -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")
+1 -1
View File
@@ -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
+1 -12
View File
@@ -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);
}),
});
+6 -6
View File
@@ -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" });
+107
View File
@@ -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();
});
});
+14 -5
View File
@@ -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;
};
+35 -13
View File
@@ -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)
+17 -1
View File
@@ -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({
+3
View File
@@ -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:"
},
+215
View File
@@ -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([]);
});
});
@@ -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
+1 -1
View File
@@ -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);
};
+26 -7
View File
@@ -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;
+4 -1
View File
@@ -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)) {
+3 -1
View File
@@ -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:"
}
}
+3
View File
@@ -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)),
};
};
+39
View File
@@ -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[];
}
+1
View File
@@ -9,3 +9,4 @@ export const containerStates = ["created", "running", "paused", "restarting", "e
export type ContainerState = (typeof containerStates)[number];
export * from "./labels";
export * from "./discovery";
+18 -1
View File
@@ -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