Compare commits

...
Author SHA1 Message Date
Releases Homarr e63c0b0e65 chore(release): 1.0.0-beta.13 [skip ci] 2025-01-14 19:04:26 +00:00
homarr-releases[bot] 8a43bf47aa chore(release): automatic release v1.0.0 2025-01-14 19:03:31 +00:00
Meier Lukas d4bb014a9b fix(category): remove not always working and ignoring dynamic sections (#1939) 2025-01-14 19:55:21 +01:00
Meier Lukas e01d74f4f8 feat(board): add mobile home board (#1910)
* feat(board): add mobile home board

* fix: add missing translations

* fix: mysql key reference with other datatype

* fix: format issue

* fix: missing trpc context arguments in tests

* fix: missing trpc context arguments in tests
2025-01-14 19:54:55 +01:00
Meier Lukas ec3bda34e0 fix(media-request): media and media-request status can have missing values (#1911) 2025-01-14 19:45:21 +01:00
Meier Lukas 5c16fb8d6c chore: add start command to run from source (#1928)
* chore: add start command to run from source

* fix: start command does not run in parallel
2025-01-14 19:44:37 +01:00
Meier Lukas 986e013ca3 fix(app): missing noreferer attribute for anchor tag (#1915) 2025-01-14 19:31:50 +01:00
Meier Lukas 1fd3fd8dfb refactor: env validation typescript and common package (#1912) 2025-01-14 19:03:38 +01:00
homarr-renovate[bot] a03a01b964 fix(deps): update mantine monorepo to ^7.16.0 (#1948)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-14 17:53:17 +00:00
Meier Lukas 4d94fce523 test: adjust nzbget test to use append instead of scan (#1952) 2025-01-14 18:39:12 +01:00
homarr-renovate[bot] 75c791801e fix(deps): update dependency eslint-config-prettier to v10 (#1937)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-14 17:48:54 +01:00
homarr-renovate[bot] 3e67b411b6 chore(deps): update dependency @types/react to v19.0.7 (#1947)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-14 09:16:54 +00:00
homarr-renovate[bot] ff1a003037 fix(deps): update dependency sass to ^1.83.4 (#1946)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-14 06:41:43 +00:00
homarr-renovate[bot] 99c69ba65d fix(deps): update dependency sass to ^1.83.3 (#1945)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-14 05:49:13 +00:00
homarr-crowdin[bot] f5cf92c49c chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-14 00:44:35 +00:00
homarr-renovate[bot] 05dc51b38e chore(deps): update pnpm to v9.15.4 (#1943)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-13 21:24:22 +00:00
homarr-renovate[bot] a53e0f7cee fix(deps): update tanstack-query monorepo to ^5.64.1 (#1935)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-13 19:27:22 +00:00
homarr-renovate[bot] f3a91ca37f chore(deps): update dependency @types/node to ^22.10.6 (#1942)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-13 19:15:16 +00:00
homarr-renovate[bot] 1e406a8a58 fix(deps): update dependency @ianvs/prettier-plugin-sort-imports to ^4.4.1 (#1936)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-13 18:49:04 +00:00
homarr-renovate[bot] 2ea763c231 fix(deps): update dependency eslint-plugin-react to ^7.37.4 (#1934)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-13 18:46:24 +00:00
homarr-renovate[bot] 579bb2a33e chore(deps): update dependency @types/react to v19.0.6 (#1933)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-13 18:45:09 +00:00
homarr-renovate[bot] feecdd36d4 fix(deps): update dependency typescript-eslint to ^8.20.0 (#1938)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-13 18:24:00 +00:00
Meier Lukas 56755d1ab4 fix: trpc removed export anything from client in react-query (#1941) 2025-01-13 18:50:51 +01:00
homarr-crowdin[bot] 6b36ec3103 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-13 00:47:42 +00:00
homarr-renovate[bot] fc6dbc92df chore(deps): update react monorepo (#1927)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-12 10:45:02 +00:00
homarr-crowdin[bot] 467d5e21ae chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-12 00:49:05 +00:00
homarr-renovate[bot] b399cb6979 fix(deps): update tanstack-query monorepo to ^5.64.0 (#1922)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-11 19:13:10 +00:00
Meier Lukas 4d69b81676 ci: add missing git config to release pipeline 2025-01-11 11:13:48 +01:00
homarr-crowdin[bot] a1fc9902a5 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-11 10:06:50 +00:00
Meier Lukas 0649220080 Merge branch 'beta' into dev 2025-01-11 10:56:33 +01:00
homarr-renovate[bot] e502fbc533 chore(deps): update dependency eslint to ^9.18.0 (#1916)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-10 21:54:40 +00:00
136 changed files with 4910 additions and 1099 deletions

No files matched your search

@@ -94,6 +94,8 @@ jobs:
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
git config user.name "Releases Homarr"
git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com"
git fetch origin dev
git checkout dev
git pull origin dev
+2
View File
@@ -54,7 +54,9 @@ yarn-error.log*
*.log
apps/tasks/tasks.cjs
apps/tasks/tasks.css
apps/websocket/wssServer.cjs
apps/websocket/wssServer.css
apps/nextjs/.million/
packages/cli/cli.cjs
+22
View File
@@ -1,3 +1,25 @@
## [1.0.0-beta.13](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.12...v1.0.0-beta.13) (2025-01-14)
### Features
* **board:** add mobile home board ([#1910](https://github.com/homarr-labs/homarr/issues/1910)) ([e01d74f](https://github.com/homarr-labs/homarr/commit/e01d74f4f89aee9a122b660e6303c356871c6ebb))
### Bug Fixes
* **app:** missing noreferer attribute for anchor tag ([#1915](https://github.com/homarr-labs/homarr/issues/1915)) ([986e013](https://github.com/homarr-labs/homarr/commit/986e013ca3eaf402d9014c5586536e05d93e9adc))
* **category:** remove not always working and ignoring dynamic sections ([#1939](https://github.com/homarr-labs/homarr/issues/1939)) ([d4bb014](https://github.com/homarr-labs/homarr/commit/d4bb014a9ba83770fd7880b48a4a5ab5362f6f75))
* **deps:** update dependency @ianvs/prettier-plugin-sort-imports to ^4.4.1 ([#1936](https://github.com/homarr-labs/homarr/issues/1936)) ([1e406a8](https://github.com/homarr-labs/homarr/commit/1e406a8a58ebdf51f59b5aa0e079baf46c6d02cf))
* **deps:** update dependency eslint-config-prettier to v10 ([#1937](https://github.com/homarr-labs/homarr/issues/1937)) ([75c7918](https://github.com/homarr-labs/homarr/commit/75c791801e593e88deb3abcacf2b91aade0871d8))
* **deps:** update dependency eslint-plugin-react to ^7.37.4 ([#1934](https://github.com/homarr-labs/homarr/issues/1934)) ([2ea763c](https://github.com/homarr-labs/homarr/commit/2ea763c23161753619a204f9bb522ab04c37a2ab))
* **deps:** update dependency sass to ^1.83.3 ([#1945](https://github.com/homarr-labs/homarr/issues/1945)) ([99c69ba](https://github.com/homarr-labs/homarr/commit/99c69ba65ddb5f8cf6bdd61518649fc21405ac87))
* **deps:** update dependency sass to ^1.83.4 ([#1946](https://github.com/homarr-labs/homarr/issues/1946)) ([ff1a003](https://github.com/homarr-labs/homarr/commit/ff1a00303728b15b8e709ce57d77a78967abab78))
* **deps:** update dependency typescript-eslint to ^8.20.0 ([#1938](https://github.com/homarr-labs/homarr/issues/1938)) ([feecdd3](https://github.com/homarr-labs/homarr/commit/feecdd36d49890022e8f479efe6cefa3f77d265a))
* **deps:** update mantine monorepo to ^7.16.0 ([#1948](https://github.com/homarr-labs/homarr/issues/1948)) ([a03a01b](https://github.com/homarr-labs/homarr/commit/a03a01b964e3ab30af6078326b91fb10817b3b98))
* **deps:** update tanstack-query monorepo to ^5.64.0 ([#1922](https://github.com/homarr-labs/homarr/issues/1922)) ([b399cb6](https://github.com/homarr-labs/homarr/commit/b399cb697945cd1bb3da2ad5222aceb9b0e71d2b))
* **deps:** update tanstack-query monorepo to ^5.64.1 ([#1935](https://github.com/homarr-labs/homarr/issues/1935)) ([a53e0f7](https://github.com/homarr-labs/homarr/commit/a53e0f7ceed9cf771609be7b04090df9744ec4e6))
* **media-request:** media and media-request status can have missing values ([#1911](https://github.com/homarr-labs/homarr/issues/1911)) ([ec3bda3](https://github.com/homarr-labs/homarr/commit/ec3bda34e0c1adacf0cfc5d7af272f9f2617fbe5))
* trpc removed export anything from client in react-query ([#1941](https://github.com/homarr-labs/homarr/issues/1941)) ([56755d1](https://github.com/homarr-labs/homarr/commit/56755d1ab4194de04faeb569020f6d19623bd29a))
## [1.0.0-beta.12](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.11...v1.0.0-beta.12) (2025-01-10)
### Features
+4 -4
View File
@@ -1,13 +1,13 @@
// Importing env files here to validate on build
import "@homarr/auth/env.mjs";
import "@homarr/db/env.mjs";
import "@homarr/common/env.mjs";
import "@homarr/auth/env";
import "@homarr/db/env";
import "@homarr/common/env";
import type { NextConfig } from "next";
import MillionLint from "@million/lint";
import createNextIntlPlugin from "next-intl/plugin";
import "./src/env.mjs";
import "./src/env.ts";
// Package path does not work... so we need to use relative path
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
+14 -14
View File
@@ -38,18 +38,18 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.15.3",
"@mantine/core": "^7.15.3",
"@mantine/dropzone": "^7.15.3",
"@mantine/hooks": "^7.15.3",
"@mantine/modals": "^7.15.3",
"@mantine/tiptap": "^7.15.3",
"@mantine/colors-generator": "^7.16.0",
"@mantine/core": "^7.16.0",
"@mantine/dropzone": "^7.16.0",
"@mantine/hooks": "^7.16.0",
"@mantine/modals": "^7.16.0",
"@mantine/tiptap": "^7.16.0",
"@million/lint": "1.0.14",
"@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.63.0",
"@tanstack/react-query-devtools": "^5.63.0",
"@tanstack/react-query-next-experimental": "5.63.0",
"@tanstack/react-query": "^5.64.1",
"@tanstack/react-query-devtools": "^5.64.1",
"@tanstack/react-query-next-experimental": "5.64.1",
"@trpc/client": "next",
"@trpc/next": "next",
"@trpc/react-query": "next",
@@ -72,7 +72,7 @@
"react-dom": "19.0.0",
"react-error-boundary": "^5.0.0",
"react-simple-code-editor": "^0.14.1",
"sass": "^1.83.1",
"sass": "^1.83.4",
"superjson": "2.2.2",
"swagger-ui-react": "^5.18.2",
"use-deep-compare-effect": "^1.8.1"
@@ -82,13 +82,13 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "3.1.0",
"@types/node": "^22.10.5",
"@types/node": "^22.10.6",
"@types/prismjs": "^1.26.5",
"@types/react": "19.0.4",
"@types/react-dom": "19.0.2",
"@types/react": "19.0.7",
"@types/react-dom": "19.0.3",
"@types/swagger-ui-react": "^4.18.3",
"concurrently": "^9.1.2",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"node-loader": "^2.1.0",
"prettier": "^3.4.2",
"typescript": "^5.7.3"
@@ -21,7 +21,7 @@ import type { AppRouter } from "@homarr/api";
import { clientApi, getTrpcUrl } from "@homarr/api/client";
import { createHeadersCallbackForSource } from "@homarr/api/shared";
import { env } from "~/env.mjs";
import { env } from "~/env";
const getWebSocketProtocol = () => {
// window is not defined on server side
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { env } from "@homarr/auth/env.mjs";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
@@ -28,7 +28,7 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { HeaderButton } from "~/components/layout/header/button";
import { env } from "~/env.mjs";
import { env } from "~/env";
import { useEditMode, useRequiredBoard } from "./_context";
export const BoardContentHeaderActions = () => {
@@ -101,7 +101,11 @@ export default async function BoardSettingsPage(props: Props) {
<BoardAccessSettings board={board} initialPermissions={permissions} />
</AccordionItemFor>
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
<DangerZoneSettingsContent hideVisibility={boardSettings.homeBoardId === board.id} />
<DangerZoneSettingsContent
hideVisibility={
boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id
}
/>
</AccordionItemFor>
</>
)}
+1 -1
View File
@@ -9,7 +9,7 @@ import "~/styles/scroll-area.scss";
import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import { env } from "@homarr/auth/env.mjs";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";
@@ -3,7 +3,7 @@
import { useCallback } from "react";
import Link from "next/link";
import { Menu } from "@mantine/core";
import { IconCopy, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import { IconCopy, IconDeviceMobile, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -43,6 +43,12 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
await revalidatePathActionAsync("/");
},
});
const setMobileHomeBoardMutation = clientApi.board.setMobileHomeBoard.useMutation({
onSettled: async () => {
// Revalidate all as it's part of the user settings, /boards page and board manage page
await revalidatePathActionAsync("/");
},
});
const deleteBoardMutation = clientApi.board.deleteBoard.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/boards");
@@ -68,6 +74,10 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
await setHomeBoardMutation.mutateAsync({ id: board.id });
}, [board.id, setHomeBoardMutation]);
const handleSetMobileHomeBoard = useCallback(async () => {
await setMobileHomeBoardMutation.mutateAsync({ id: board.id });
}, [board.id, setMobileHomeBoardMutation]);
const handleDuplicateBoard = useCallback(() => {
openDuplicateModal({
board: {
@@ -85,6 +95,9 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
{t("setHomeBoard.label")}
</Menu.Item>
<Menu.Item onClick={handleSetMobileHomeBoard} leftSection={<IconDeviceMobile {...iconProps} />}>
{t("setMobileHomeBoard.label")}
</Menu.Item>
{session?.user.permissions.includes("board-create") && (
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
{t("duplicate.label")}
@@ -15,7 +15,7 @@ import {
Title,
Tooltip,
} from "@mantine/core";
import { IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
@@ -88,6 +88,14 @@ const BoardCard = async ({ board }: BoardCardProps) => {
</Tooltip>
)}
{board.isMobileHome && (
<Tooltip label={t("action.setMobileHomeBoard.badge.tooltip")}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
{t("action.setMobileHomeBoard.badge.label")}
</Badge>
</Tooltip>
)}
{board.creator && (
<Group gap="xs">
<UserAvatar user={board.creator} size="sm" />
@@ -37,6 +37,25 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
)}
{...form.getInputProps("homeBoardId")}
/>
<SelectWithCustomItems
label={tBoard("homeBoard.mobileLabel")}
description={tBoard("homeBoard.description")}
data={selectableBoards.map((board) => ({
value: board.id,
label: board.name,
image: board.logoImageUrl,
}))}
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
<Group>
{/* eslint-disable-next-line @next/next/no-img-element */}
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
)}
{...form.getInputProps("mobileHomeBoardId")}
/>
</>
)}
</CommonSettingsForm>
@@ -18,13 +18,14 @@ interface ChangeHomeBoardFormProps {
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeHomeBoardId.useMutation({
const { mutate, isPending } = clientApi.user.changeHomeBoards.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
homeBoardId: variables.homeBoardId,
mobileHomeBoardId: variables.mobileHomeBoardId,
});
showSuccessNotification({
message: t("user.action.changeHomeBoard.notification.success.message"),
@@ -36,9 +37,10 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
});
},
});
const form = useZodForm(validation.user.changeHomeBoard, {
const form = useZodForm(validation.user.changeHomeBoards, {
initialValues: {
homeBoardId: user.homeBoardId ?? "",
mobileHomeBoardId: user.mobileHomeBoardId ?? "",
},
});
@@ -52,7 +54,18 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select w="100%" data={boardsData} {...form.getInputProps("homeBoardId")} />
<Select
label={t("management.page.user.setting.general.item.board.type.general")}
w="100%"
data={boardsData}
{...form.getInputProps("homeBoardId")}
/>
<Select
label={t("management.page.user.setting.general.item.board.type.mobile")}
w="100%"
data={boardsData}
{...form.getInputProps("mobileHomeBoardId")}
/>
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
@@ -64,4 +77,4 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
);
};
type FormType = z.infer<typeof validation.user.changeHomeBoard>;
type FormType = z.infer<typeof validation.user.changeHomeBoards>;
@@ -89,7 +89,7 @@ export default async function EditUserPage(props: Props) {
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.board")}</Title>
<Title order={2}>{tGeneral("item.board.title")}</Title>
<ChangeHomeBoardForm
user={user}
boardsData={boards.map((board) => ({
@@ -5,7 +5,7 @@ import { IconExclamationCircle } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { env } from "@homarr/auth/env.mjs";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { everyoneGroup } from "@homarr/definitions";
@@ -5,7 +5,7 @@ import { db } from "@homarr/db";
import type { WidgetKind } from "@homarr/definitions";
import { widgetImports } from "@homarr/widgets";
import { env } from "~/env.mjs";
import { env } from "~/env";
import { WidgetPreviewPageContent } from "./_content";
interface Props {
@@ -0,0 +1,111 @@
import type { Board, CategorySection, DynamicSection, EmptySection, Section } from "~/app/[locale]/boards/_types";
export interface RemoveCategoryInput {
id: string;
}
export const removeCategoryCallback =
(input: RemoveCategoryInput) =>
(previous: Board): Board => {
const currentCategory = previous.sections.find(
(section): section is CategorySection => section.kind === "category" && section.id === input.id,
);
if (!currentCategory) {
return previous;
}
const emptySectionsAbove = previous.sections.filter(
(section): section is EmptySection => section.kind === "empty" && section.yOffset < currentCategory.yOffset,
);
const aboveSection = emptySectionsAbove.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset).at(0);
const emptySectionsBelow = previous.sections.filter(
(section): section is EmptySection => section.kind === "empty" && section.yOffset > currentCategory.yOffset,
);
const removedSection = emptySectionsBelow.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset).at(0);
if (!aboveSection || !removedSection) {
return previous;
}
// Calculate the yOffset for the items in the currentCategory and removedWrapper to add them with the same offset to the aboveWrapper
const aboveYOffset = Math.max(
calculateYHeightWithOffsetForItems(aboveSection),
calculateYHeightWithOffsetForDynamicSections(previous.sections, aboveSection.id),
);
const categoryYOffset = Math.max(
calculateYHeightWithOffsetForItems(currentCategory),
calculateYHeightWithOffsetForDynamicSections(previous.sections, currentCategory.id),
);
const previousCategoryItems = currentCategory.items.map((item) => ({
...item,
yOffset: item.yOffset + aboveYOffset,
}));
const previousBelowWrapperItems = removedSection.items.map((item) => ({
...item,
yOffset: item.yOffset + aboveYOffset + categoryYOffset,
}));
return {
...previous,
sections: [
...previous.sections.filter((section) => section.yOffset < aboveSection.yOffset && section.kind !== "dynamic"),
{
...aboveSection,
items: [...aboveSection.items, ...previousCategoryItems, ...previousBelowWrapperItems],
},
...previous.sections
.filter(
(section): section is CategorySection | EmptySection =>
section.yOffset > removedSection.yOffset && section.kind !== "dynamic",
)
.map((section) => ({
...section,
position: section.yOffset - 2,
})),
...previous.sections
.filter((section): section is DynamicSection => section.kind === "dynamic")
.map((dynamicSection) => {
// Move dynamic sections from removed section to above section with required yOffset
if (dynamicSection.parentSectionId === removedSection.id) {
return {
...dynamicSection,
yOffset: dynamicSection.yOffset + aboveYOffset + categoryYOffset,
parentSectionId: aboveSection.id,
};
}
// Move dynamic sections from category to above section with required yOffset
if (dynamicSection.parentSectionId === currentCategory.id) {
return {
...dynamicSection,
yOffset: dynamicSection.yOffset + aboveYOffset,
parentSectionId: aboveSection.id,
};
}
return dynamicSection;
}),
],
};
};
const calculateYHeightWithOffsetForDynamicSections = (sections: Section[], sectionId: string) => {
return sections.reduce((acc, section) => {
if (section.kind !== "dynamic" || section.parentSectionId !== sectionId) {
return acc;
}
const yHeightWithOffset = section.yOffset + section.height;
if (yHeightWithOffset > acc) return yHeightWithOffset;
return acc;
}, 0);
};
const calculateYHeightWithOffsetForItems = (section: Section) =>
section.items.reduce((acc, item) => {
const yHeightWithOffset = item.yOffset + item.height;
if (yHeightWithOffset > acc) return yHeightWithOffset;
return acc;
}, 0);
@@ -0,0 +1,125 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { describe, expect, test } from "vitest";
import type { DynamicSection, Item, Section } from "~/app/[locale]/boards/_types";
import { removeCategoryCallback } from "../remove-category";
describe("Remove Category", () => {
test.each([
[3, [0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 5, 6], [3, 4], 2],
[5, [0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 3, 4], [5, 6], 4],
[1, [0, 1, 2, 3, 4, 5, 6], [0, 3, 4, 5, 6], [1, 2], 0],
[3, [0, 3, 6, 7, 8], [0, 7, 8], [3, 6], 0],
])(
"should remove category",
(removeId, initialYOffsets, expectedYOffsets, expectedRemovals, expectedLocationOfItems) => {
const sections = createSections(initialYOffsets);
const input = removeId.toString();
const result = removeCategoryCallback({ id: input })({ sections } as never);
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual(expectedYOffsets);
expectedRemovals.forEach((expectedRemoval) => {
expect(result.sections.find((section) => section.id === expectedRemoval.toString())).toBeUndefined();
});
const aboveSection = result.sections.find((section) => section.id === expectedLocationOfItems.toString());
expect(aboveSection?.items.map((item) => parseInt(item.id, 10))).toEqual(
expect.arrayContaining(expectedRemovals),
);
},
);
test("should correctly move items to above empty section", () => {
const initialYOffsets = [0, 1, 2, 3, 4, 5, 6];
const sections: Section[] = createSections(initialYOffsets);
const aboveSection = sections.find((section) => section.yOffset === 2)!;
aboveSection.items = [
createItem({ id: "above-1" }),
createItem({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
];
const removedCategory = sections.find((section) => section.yOffset === 3)!;
removedCategory.items = [
createItem({ id: "category-1" }),
createItem({ id: "category-2", yOffset: 2, xOffset: 4, width: 4 }),
];
const removedEmptySection = sections.find((section) => section.yOffset === 4)!;
removedEmptySection.items = [
createItem({ id: "below-1", xOffset: 5 }),
createItem({ id: "below-2", yOffset: 1, xOffset: 1, height: 2 }),
];
sections.push(
createDynamicSection({
id: "7",
parentSectionId: "3",
yOffset: 7,
height: 3,
items: [createItem({ id: "dynamic-1" })],
}),
);
const input = "3";
const result = removeCategoryCallback({ id: input })({ sections } as never);
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 5, 6, 7]);
const aboveSectionResult = result.sections.find((section) => section.id === "2")!;
expect(aboveSectionResult.items).toEqual(
expect.arrayContaining([
createItem({ id: "above-1" }),
createItem({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
createItem({ id: "category-1", yOffset: 5 }),
createItem({ id: "category-2", yOffset: 7, xOffset: 4, width: 4 }),
createItem({ id: "below-1", yOffset: 15, xOffset: 5 }),
createItem({ id: "below-2", yOffset: 16, xOffset: 1, height: 2 }),
]),
);
const dynamicSection = result.sections.find((section): section is DynamicSection => section.id === "7")!;
expect(dynamicSection.yOffset).toBe(12);
expect(dynamicSection.parentSectionId).toBe("2");
});
});
const createItem = (item: Partial<{ id: string; width: number; height: number; yOffset: number; xOffset: number }>) => {
return {
id: item.id ?? "0",
kind: "app",
options: {},
advancedOptions: {
customCssClasses: [],
},
height: item.height ?? 1,
width: item.width ?? 1,
yOffset: item.yOffset ?? 0,
xOffset: item.xOffset ?? 0,
integrationIds: [],
} satisfies Item;
};
const createDynamicSection = (
section: Partial<
Pick<DynamicSection, "id" | "height" | "width" | "yOffset" | "xOffset" | "parentSectionId" | "items">
>,
) => {
return {
id: section.id ?? "0",
kind: "dynamic",
height: section.height ?? 1,
width: section.width ?? 1,
yOffset: section.yOffset ?? 0,
xOffset: section.xOffset ?? 0,
parentSectionId: section.parentSectionId ?? "0",
items: section.items ?? [],
} satisfies DynamicSection;
};
const createSections = (initialYOffsets: number[]) => {
return initialYOffsets.map((yOffset, index) => ({
id: yOffset.toString(),
kind: index % 2 === 0 ? "empty" : "category",
name: "Category",
yOffset,
xOffset: 0,
items: [createItem({ id: yOffset.toString() })],
})) satisfies Section[];
};
@@ -2,10 +2,12 @@ import { useCallback } from "react";
import { createId } from "@homarr/db/client";
import type { CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
import type { CategorySection, EmptySection } from "~/app/[locale]/boards/_types";
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
import type { MoveCategoryInput } from "./actions/move-category";
import { moveCategoryCallback } from "./actions/move-category";
import type { RemoveCategoryInput } from "./actions/remove-category";
import { removeCategoryCallback } from "./actions/remove-category";
interface AddCategory {
name: string;
@@ -17,10 +19,6 @@ interface RenameCategory {
name: string;
}
interface RemoveCategory {
id: string;
}
export const useCategoryActions = () => {
const { updateBoard } = useUpdateBoard();
@@ -132,57 +130,8 @@ export const useCategoryActions = () => {
);
const removeCategory = useCallback(
({ id: categoryId }: RemoveCategory) => {
updateBoard((previous) => {
const currentCategory = previous.sections.find(
(section): section is CategorySection => section.kind === "category" && section.id === categoryId,
);
if (!currentCategory) return previous;
const aboveWrapper = previous.sections.find(
(section): section is EmptySection =>
section.kind === "empty" && section.yOffset === currentCategory.yOffset - 1,
);
const removedWrapper = previous.sections.find(
(section): section is EmptySection =>
section.kind === "empty" && section.yOffset === currentCategory.yOffset + 1,
);
if (!aboveWrapper || !removedWrapper) return previous;
// Calculate the yOffset for the items in the currentCategory and removedWrapper to add them with the same offset to the aboveWrapper
const aboveYOffset = calculateYHeightWithOffset(aboveWrapper);
const categoryYOffset = calculateYHeightWithOffset(currentCategory);
const previousCategoryItems = currentCategory.items.map((item) => ({
...item,
yOffset: item.yOffset + aboveYOffset,
}));
const previousBelowWrapperItems = removedWrapper.items.map((item) => ({
...item,
yOffset: item.yOffset + aboveYOffset + categoryYOffset,
}));
return {
...previous,
sections: [
...previous.sections.filter((section) => section.yOffset < currentCategory.yOffset - 1),
{
...aboveWrapper,
items: [...aboveWrapper.items, ...previousCategoryItems, ...previousBelowWrapperItems],
},
...previous.sections
.filter(
(section): section is CategorySection | EmptySection => section.yOffset >= currentCategory.yOffset + 2,
)
.map((section) => ({
...section,
position: section.yOffset - 2,
})),
],
};
});
(input: RemoveCategoryInput) => {
updateBoard(removeCategoryCallback(input));
},
[updateBoard],
);
@@ -195,10 +144,3 @@ export const useCategoryActions = () => {
removeCategory,
};
};
const calculateYHeightWithOffset = (section: Section) =>
section.items.reduce((acc, item) => {
const yHeightWithOffset = item.yOffset + item.height;
if (yHeightWithOffset > acc) return yHeightWithOffset;
return acc;
}, 0);
@@ -1,6 +1,8 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
import { shouldSkipEnvValidation } from "@homarr/common/env-validation";
export const env = createEnv({
shared: {
PORT: z.coerce.number().default(3000),
@@ -13,7 +15,7 @@ export const env = createEnv({
server: {
// Comma separated list of docker hostnames that can be used to connect to query the docker endpoints (localhost:2375,host.docker.internal:2375, ...)
DOCKER_HOSTNAMES: z.string().optional(),
DOCKER_PORTS: z.number().optional(),
DOCKER_PORTS: z.string().optional(),
},
/**
* Specify your client-side environment variables schema here.
@@ -32,6 +34,5 @@ export const env = createEnv({
DOCKER_PORTS: process.env.DOCKER_PORTS,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
skipValidation:
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint",
skipValidation: shouldSkipEnvValidation(),
});
+2 -2
View File
@@ -44,9 +44,9 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^22.10.5",
"@types/node": "^22.10.6",
"dotenv-cli": "^8.0.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"prettier": "^3.4.2",
"tsx": "4.19.2",
"typescript": "^5.7.3"
+1 -1
View File
@@ -34,7 +34,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.5.13",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"prettier": "^3.4.2",
"typescript": "^5.7.3"
}
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.0.0-beta.12",
"version": "1.0.0-beta.13",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -22,9 +22,10 @@
"lint:ws": "pnpm dlx sherif@latest",
"package:new": "turbo gen init",
"release": "semantic-release",
"test": "cross-env NODE_ENV=development vitest run --exclude e2e --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development vitest e2e",
"test:ui": "cross-env NODE_ENV=development vitest --exclude e2e --ui --coverage.enabled",
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
"typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --"
},
@@ -52,7 +53,7 @@
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^2.1.8"
},
"packageManager": "pnpm@9.15.3",
"packageManager": "pnpm@9.15.4",
"engines": {
"node": ">=22.13.0"
},
+1 -1
View File
@@ -32,7 +32,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+1 -1
View File
@@ -54,7 +54,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.34",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"prettier": "^3.4.2",
"typescript": "^5.7.3"
}
+2 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { createTRPCClient, createTRPCReact, httpLink } from "@trpc/react-query";
import { createTRPCClient, httpLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import SuperJSON from "superjson";
import type { AppRouter } from ".";
+42 -13
View File
@@ -2,7 +2,8 @@ import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { constructBoardPermissions } from "@homarr/auth/shared";
import type { Database, InferInsertModel, SQL } from "@homarr/db";
import type { DeviceType } from "@homarr/common/server";
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
import { and, createId, eq, inArray, like, or } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import {
@@ -121,6 +122,7 @@ export const boardRouter = createTRPCRouter({
return dbBoards.map((board) => ({
...board,
isHome: currentUserWhenPresent?.homeBoardId === board.id,
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
}));
}),
search: publicProcedure
@@ -194,6 +196,7 @@ export const boardRouter = createTRPCRouter({
logoImageUrl: board.logoImageUrl,
permissions: constructBoardPermissions(board, ctx.session),
isHome: currentUserWhenPresent?.homeBoardId === board.id,
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
}));
}),
createBoard: permissionRequiredProcedure
@@ -336,7 +339,10 @@ export const boardRouter = createTRPCRouter({
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
if (input.visibility !== "public" && boardSettings.homeBoardId === input.id) {
if (
input.visibility !== "public" &&
(boardSettings.homeBoardId === input.id || boardSettings.mobileHomeBoardId === input.id)
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot make home board private",
@@ -358,30 +364,30 @@ export const boardRouter = createTRPCRouter({
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
setMobileHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await ctx.db.update(users).set({ mobileHomeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
getHomeBoard: publicProcedure.query(async ({ ctx }) => {
const userId = ctx.session?.user.id;
const user = userId
? await ctx.db.query.users.findFirst({
? ((await ctx.db.query.users.findFirst({
where: eq(users.id, userId),
})
})) ?? null)
: null;
// 1. user home board, 2. home board, 3. not found
let boardWhere: SQL<unknown> | null = null;
if (user?.homeBoardId) {
boardWhere = eq(boards.id, user.homeBoardId);
} else {
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
boardWhere = boardSettings.homeBoardId ? eq(boards.id, boardSettings.homeBoardId) : null;
}
const homeBoardId = await getHomeIdBoardAsync(ctx.db, user, ctx.deviceType);
if (!boardWhere) {
if (!homeBoardId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "No home board found",
});
}
const boardWhere = eq(boards.id, homeBoardId);
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
@@ -692,6 +698,29 @@ export const boardRouter = createTRPCRouter({
}),
});
/**
* Get the home board id of the user with the given device type
* For an example of a user with deviceType = 'mobile' it would go through the following order:
* 1. user.mobileHomeBoardId
* 2. user.homeBoardId
* 3. serverSettings.mobileHomeBoardId
* 4. serverSettings.homeBoardId
* 5. show NOT_FOUND error
*/
const getHomeIdBoardAsync = async (
db: Database,
user: InferSelectModel<typeof users> | null,
deviceType: DeviceType,
) => {
const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
if (user?.[settingKey] || user?.homeBoardId) {
return user[settingKey] ?? user.homeBoardId;
} else {
const boardSettings = await getServerSettingByKeyAsync(db, "board");
return boardSettings[settingKey] ?? boardSettings.homeBoardId;
}
};
const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => {
const boards = await db.query.boards.findMany({
columns: {
+1 -1
View File
@@ -1,6 +1,6 @@
import { TRPCError } from "@trpc/server";
import { env } from "@homarr/auth/env.mjs";
import { env } from "@homarr/auth/env";
export const throwIfCredentialsDisabled = () => {
if (!env.AUTH_PROVIDERS.includes("credentials")) {
+10
View File
@@ -24,6 +24,7 @@ describe("all should return all apps", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(),
});
@@ -55,6 +56,7 @@ describe("all should return all apps", () => {
// Arrange
const caller = appRouter.createCaller({
db: createDb(),
deviceType: undefined,
session: null,
});
@@ -72,6 +74,7 @@ describe("byId should return an app by id", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
vi.spyOn(appAccessControl, "canUserSeeAppAsync").mockReturnValue(Promise.resolve(true));
@@ -103,6 +106,7 @@ describe("byId should return an app by id", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
await db.insert(apps).values([
@@ -128,6 +132,7 @@ describe("byId should return an app by id", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -145,6 +150,7 @@ describe("create should create a new app with all arguments", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-create"]),
});
const input = {
@@ -171,6 +177,7 @@ describe("create should create a new app with all arguments", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-create"]),
});
const input = {
@@ -199,6 +206,7 @@ describe("update should update an app", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-modify-all"]),
});
@@ -237,6 +245,7 @@ describe("update should update an app", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-modify-all"]),
});
@@ -261,6 +270,7 @@ describe("delete should delete an app", () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["app-full-all"]),
});
+32 -29
View File
@@ -52,7 +52,7 @@ describe("getAllBoards should return all boards accessable to the current user",
test("without session it should return only public boards", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: null });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: null });
const user1 = await createRandomUserAsync(db);
const user2 = await createRandomUserAsync(db);
@@ -85,6 +85,7 @@ describe("getAllBoards should return all boards accessable to the current user",
const db = createDb();
const caller = boardRouter.createCaller({
db,
deviceType: undefined,
session: {
user: {
id: defaultCreatorId,
@@ -124,7 +125,7 @@ describe("getAllBoards should return all boards accessable to the current user",
test("with session user beeing creator it should return all private boards of them", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const user1 = await createRandomUserAsync(db);
const user2 = await createRandomUserAsync(db);
@@ -168,6 +169,7 @@ describe("getAllBoards should return all boards accessable to the current user",
const db = createDb();
const caller = boardRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -232,6 +234,7 @@ describe("getAllBoards should return all boards accessable to the current user",
const db = createDb();
const caller = boardRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -290,7 +293,7 @@ describe("createBoard should create a new board", () => {
permissions: ["board-create"] satisfies GroupPermissionKey[],
},
};
const caller = boardRouter.createCaller({ db, session });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
await db.insert(users).values({
id: defaultCreatorId,
@@ -316,7 +319,7 @@ describe("createBoard should create a new board", () => {
test("should throw error when user has no board-create permission", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.createBoard({ name: "newBoard", columnCount: 12, isPublic: true });
@@ -330,7 +333,7 @@ describe("rename board should rename board", () => {
test("should rename board", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
await db.insert(users).values({
@@ -358,7 +361,7 @@ describe("rename board should rename board", () => {
test("should throw error when similar board name exists", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
await db.insert(users).values({
id: defaultCreatorId,
@@ -385,7 +388,7 @@ describe("rename board should rename board", () => {
test("should throw error when board not found", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.renameBoard({ id: "nonExistentBoardId", name: "newName" });
@@ -401,7 +404,7 @@ describe("changeBoardVisibility should change board visibility", () => {
async (visibility) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
await db.insert(users).values({
@@ -436,7 +439,7 @@ describe("deleteBoard should delete board", () => {
test("should delete board", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
await db.insert(users).values({
@@ -463,7 +466,7 @@ describe("deleteBoard should delete board", () => {
test("should throw error when board not found", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.deleteBoard({ id: "nonExistentBoardId" });
@@ -478,7 +481,7 @@ describe("getHomeBoard should return home board", () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const fullBoardProps = await createFullBoardAsync(db, "home");
await db
@@ -502,7 +505,7 @@ describe("getHomeBoard should return home board", () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const fullBoardProps = await createFullBoardAsync(db, "home");
await db.insert(serverSettings).values({
@@ -523,7 +526,7 @@ describe("getHomeBoard should return home board", () => {
test("should throw error when home board not configured in serverSettings", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
await createFullBoardAsync(db, "home");
// Act
@@ -539,7 +542,7 @@ describe("getBoardByName should return board by name", () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const fullBoardProps = await createFullBoardAsync(db, name);
@@ -557,7 +560,7 @@ describe("getBoardByName should return board by name", () => {
it("should throw error when not present", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
await createFullBoardAsync(db, "default");
// Act
@@ -573,7 +576,7 @@ describe("savePartialBoardSettings should save general settings", () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const newPageTitle = "newPageTitle";
const newMetaTitle = "newMetaTitle";
@@ -633,7 +636,7 @@ describe("savePartialBoardSettings should save general settings", () => {
it("should throw error when board not found", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const actAsync = async () =>
await caller.savePartialBoardSettings({
@@ -652,7 +655,7 @@ describe("saveBoard should save full board", () => {
it("should remove section when not present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
@@ -689,7 +692,7 @@ describe("saveBoard should save full board", () => {
it("should remove item when not present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, itemId, sectionId } = await createFullBoardAsync(db, "default");
@@ -744,7 +747,7 @@ describe("saveBoard should save full board", () => {
it("should remove integration reference when not present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const anotherIntegration = {
id: createId(),
kind: "adGuardHome",
@@ -814,7 +817,7 @@ describe("saveBoard should save full board", () => {
async (partialSection) => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
@@ -867,7 +870,7 @@ describe("saveBoard should save full board", () => {
it("should add item when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
@@ -931,7 +934,7 @@ describe("saveBoard should save full board", () => {
it("should add integration reference when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const integration = {
id: createId(),
kind: "plex",
@@ -998,7 +1001,7 @@ describe("saveBoard should save full board", () => {
});
it("should update section when present in input", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
const newSectionId = createId();
@@ -1056,7 +1059,7 @@ describe("saveBoard should save full board", () => {
it("should update item when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, itemId, sectionId } = await createFullBoardAsync(db, "default");
@@ -1112,7 +1115,7 @@ describe("saveBoard should save full board", () => {
});
it("should fail when board not found", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const actAsync = async () =>
await caller.saveBoard({
@@ -1128,7 +1131,7 @@ describe("getBoardPermissions should return board permissions", () => {
test("should return board permissions", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const user1 = await createRandomUserAsync(db);
@@ -1202,7 +1205,7 @@ describe("saveUserBoardPermissions should save user board permissions", () => {
async (permission) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const user1 = await createRandomUserAsync(db);
@@ -1244,7 +1247,7 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
async (permission) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
await db.insert(users).values({
@@ -52,6 +52,7 @@ describe("All procedures should only be accessible for users with admin permissi
// Arrange
const caller = dockerRouter.createCaller({
db: null as unknown as Database,
deviceType: undefined,
session: createSessionWithPermissions("admin"),
});
@@ -68,6 +69,7 @@ describe("All procedures should only be accessible for users with admin permissi
);
const caller = dockerRouter.createCaller({
db: null as unknown as Database,
deviceType: undefined,
session: createSessionWithPermissions(...groupPermissionsWithoutAdmin),
});
@@ -81,6 +83,7 @@ describe("All procedures should only be accessible for users with admin permissi
// Arrange
const caller = dockerRouter.createCaller({
db: null as unknown as Database,
deviceType: undefined,
session: null,
});
+34 -34
View File
@@ -1,7 +1,7 @@
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import * as env from "@homarr/auth/env.mjs";
import * as env from "@homarr/auth/env";
import { createId, eq } from "@homarr/db";
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
@@ -37,7 +37,7 @@ describe("paginated should return a list of groups with pagination", () => {
async (page, expectedCount) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values(
[1, 2, 3, 4, 5].map((number) => ({
@@ -60,7 +60,7 @@ describe("paginated should return a list of groups with pagination", () => {
test("with 5 groups in database and pagesize set to 3 it should return total count 5", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values(
[1, 2, 3, 4, 5].map((number) => ({
@@ -81,7 +81,7 @@ describe("paginated should return a list of groups with pagination", () => {
test("groups should contain id, name, email and image of members", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const user = createDummyUser();
await db.insert(users).values(user);
@@ -117,7 +117,7 @@ describe("paginated should return a list of groups with pagination", () => {
async (query, expectedCount, firstKey) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values(
["first", "second", "third", "forth", "fifth"].map((key, index) => ({
@@ -140,7 +140,7 @@ describe("paginated should return a list of groups with pagination", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.getPaginated({});
@@ -154,7 +154,7 @@ describe("byId should return group by id including members and permissions", ()
test('should return group with id "1" with members and permissions', async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const user = createDummyUser();
const groupId = "1";
@@ -197,7 +197,7 @@ describe("byId should return group by id including members and permissions", ()
test("with group id 1 and group 2 in database it should throw NOT_FOUND error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: "2",
@@ -214,7 +214,7 @@ describe("byId should return group by id including members and permissions", ()
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.getById({ id: "1" });
@@ -228,7 +228,7 @@ describe("create should create group in database", () => {
test("with valid input (64 character name) and non existing name it should be successful", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const name = "a".repeat(64);
await db.insert(users).values(defaultSession.user);
@@ -252,7 +252,7 @@ describe("create should create group in database", () => {
test("with more than 64 characters name it should fail while validation", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const longName = "a".repeat(65);
// Act
@@ -273,7 +273,7 @@ describe("create should create group in database", () => {
])("with similar name %s it should fail to create %s", async (similarName, nameToCreate) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: createId(),
@@ -290,7 +290,7 @@ describe("create should create group in database", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () => await caller.createGroup({ name: "test" });
@@ -307,7 +307,7 @@ describe("update should update name with value that is no duplicate", () => {
])("update should update name from %s to %s normalized", async (initialValue, updateValue, expectedValue) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values([
@@ -340,7 +340,7 @@ describe("update should update name with value that is no duplicate", () => {
])("with similar name %s it should fail to update %s", async (updateValue, initialDuplicate) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values([
@@ -368,7 +368,7 @@ describe("update should update name with value that is no duplicate", () => {
test("with non existing id it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: createId(),
@@ -389,7 +389,7 @@ describe("update should update name with value that is no duplicate", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
@@ -407,7 +407,7 @@ describe("savePermissions should save permissions for group", () => {
test("with existing group and permissions it should save permissions", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values({
@@ -437,7 +437,7 @@ describe("savePermissions should save permissions for group", () => {
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: createId(),
@@ -458,7 +458,7 @@ describe("savePermissions should save permissions for group", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
@@ -476,7 +476,7 @@ describe("transferOwnership should transfer ownership of group", () => {
test("with existing group and user it should transfer ownership", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const newUserId = createId();
@@ -513,7 +513,7 @@ describe("transferOwnership should transfer ownership of group", () => {
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: createId(),
@@ -534,7 +534,7 @@ describe("transferOwnership should transfer ownership of group", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
@@ -552,7 +552,7 @@ describe("deleteGroup should delete group", () => {
test("with existing group it should delete group", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
await db.insert(groups).values([
@@ -581,7 +581,7 @@ describe("deleteGroup should delete group", () => {
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(groups).values({
id: createId(),
@@ -601,7 +601,7 @@ describe("deleteGroup should delete group", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
@@ -620,7 +620,7 @@ describe("addMember should add member to group", () => {
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials"] } as never);
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const userId = createId();
@@ -658,7 +658,7 @@ describe("addMember should add member to group", () => {
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(users).values({
id: createId(),
@@ -679,7 +679,7 @@ describe("addMember should add member to group", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
@@ -697,7 +697,7 @@ describe("addMember should add member to group", () => {
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["ldap"] } as never);
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const userId = createId();
@@ -735,7 +735,7 @@ describe("removeMember should remove member from group", () => {
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials"] } as never);
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const userId = createId();
@@ -776,7 +776,7 @@ describe("removeMember should remove member from group", () => {
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await db.insert(users).values({
id: createId(),
@@ -797,7 +797,7 @@ describe("removeMember should remove member from group", () => {
test("without admin permissions it should throw unauthorized error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
// Act
const actAsync = async () =>
@@ -815,7 +815,7 @@ describe("removeMember should remove member from group", () => {
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["ldap"] } as never);
const caller = groupRouter.createCaller({ db, session: adminSession });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const userId = createId();
@@ -33,6 +33,7 @@ describe("all should return all integrations", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(),
});
@@ -63,6 +64,7 @@ describe("byId should return an integration by id", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
@@ -89,6 +91,7 @@ describe("byId should return an integration by id", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
@@ -100,6 +103,7 @@ describe("byId should return an integration by id", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
@@ -147,6 +151,7 @@ describe("byId should return an integration by id", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
@@ -172,6 +177,7 @@ describe("create should create a new integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
const input = {
@@ -206,6 +212,7 @@ describe("create should create a new integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
const input = {
@@ -249,6 +256,7 @@ describe("create should create a new integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
const input = {
@@ -272,6 +280,7 @@ describe("update should update an integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
@@ -346,6 +355,7 @@ describe("update should update an integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
@@ -364,6 +374,7 @@ describe("update should update an integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
@@ -386,6 +397,7 @@ describe("delete should delete an integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
@@ -419,6 +431,7 @@ describe("delete should delete an integration", () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-interact-all"]),
});
+6 -1
View File
@@ -24,7 +24,7 @@ vi.mock("@homarr/auth", async () => {
});
// Mock the env module to return the credentials provider
vi.mock("@homarr/auth/env.mjs", () => {
vi.mock("@homarr/auth/env", () => {
return {
env: {
AUTH_PROVIDERS: ["credentials"],
@@ -38,6 +38,7 @@ describe("all should return all existing invites without sensitive informations"
const db = createDb();
const caller = inviteRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -72,6 +73,7 @@ describe("all should return all existing invites without sensitive informations"
const db = createDb();
const caller = inviteRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -111,6 +113,7 @@ describe("create should create a new invite expiring on the specified date with
const db = createDb();
const caller = inviteRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await db.insert(users).values({
@@ -142,6 +145,7 @@ describe("delete should remove invite by id", () => {
const db = createDb();
const caller = inviteRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -179,6 +183,7 @@ describe("delete should remove invite by id", () => {
const db = createDb();
const caller = inviteRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -26,6 +26,7 @@ describe("getAll server settings", () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -44,6 +45,7 @@ describe("getAll server settings", () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -58,6 +60,7 @@ describe("saveSettings", () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
+9 -1
View File
@@ -28,7 +28,7 @@ vi.mock("@homarr/auth", async () => {
});
// Mock the env module to return the credentials provider
vi.mock("@homarr/auth/env.mjs", () => {
vi.mock("@homarr/auth/env", () => {
return {
env: {
AUTH_PROVIDERS: ["credentials"],
@@ -42,6 +42,7 @@ describe("initUser should initialize the first user", () => {
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -65,6 +66,7 @@ describe("initUser should initialize the first user", () => {
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -89,6 +91,7 @@ describe("initUser should initialize the first user", () => {
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -109,6 +112,7 @@ describe("register should create a user with valid invitation", () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -164,6 +168,7 @@ describe("register should create a user with valid invitation", () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
@@ -206,6 +211,7 @@ describe("editProfile shoud update user", () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -242,6 +248,7 @@ describe("editProfile shoud update user", () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -277,6 +284,7 @@ describe("delete should delete user", () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
@@ -18,6 +18,7 @@ describe("ping should call sendPingRequestAsync with url and return result", ()
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
spy.mockImplementation(() => Promise.resolve({ error: "error" }));
@@ -37,6 +38,7 @@ describe("ping should call sendPingRequestAsync with url and return result", ()
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
spy.mockImplementation(() => Promise.resolve({ statusCode: 200 }));
+9 -3
View File
@@ -3,7 +3,7 @@ import { TRPCError } from "@trpc/server";
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { and, createId, eq, like } from "@homarr/db";
import { groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
import { selectUserSchema } from "@homarr/db/validationSchemas";
import { credentialsAdminGroup } from "@homarr/definitions";
import type { SupportedAuthProvider } from "@homarr/definitions";
@@ -18,6 +18,7 @@ import {
protectedProcedure,
publicProcedure,
} from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import { throwIfCredentialsDisabled } from "./invite/checks";
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
@@ -209,6 +210,7 @@ export const userRouter = createTRPCRouter({
image: true,
provider: true,
homeBoardId: true,
mobileHomeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
defaultSearchEngineId: true,
@@ -232,6 +234,7 @@ export const userRouter = createTRPCRouter({
image: true,
provider: true,
homeBoardId: true,
mobileHomeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
defaultSearchEngineId: true,
@@ -373,8 +376,8 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, input.userId));
}),
changeHomeBoardId: protectedProcedure
.input(convertIntersectionToZodObject(validation.user.changeHomeBoard.and(z.object({ userId: z.string() }))))
changeHomeBoards: protectedProcedure
.input(convertIntersectionToZodObject(validation.user.changeHomeBoards.and(z.object({ userId: z.string() }))))
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/users/changeHome", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => {
@@ -401,10 +404,13 @@ export const userRouter = createTRPCRouter({
});
}
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.userId), "view");
await ctx.db
.update(users)
.set({
homeBoardId: input.homeBoardId,
mobileHomeBoardId: input.mobileHomeBoardId,
})
.where(eq(users.id, input.userId));
}),
+2
View File
@@ -12,6 +12,7 @@ import type { OpenApiMeta } from "trpc-to-openapi";
import type { Session } from "@homarr/auth";
import { FlattenError } from "@homarr/common";
import { userAgent } from "@homarr/common/server";
import { db } from "@homarr/db";
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
import { logger } from "@homarr/log";
@@ -39,6 +40,7 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
return {
session,
deviceType: userAgent(opts.headers).device.type,
db,
};
};
+1 -1
View File
@@ -8,7 +8,7 @@ import type { SupportedAuthProvider } from "@homarr/definitions";
import { createAdapter } from "./adapter";
import { createSessionCallback } from "./callbacks";
import { env } from "./env.mjs";
import { env } from "./env";
import { createSignInEventHandler } from "./events";
import { createCredentialsConfiguration, createLdapConfiguration } from "./providers/credentials/credentials-provider";
import { EmptyNextAuthProvider } from "./providers/empty/empty-provider";
+5 -40
View File
@@ -1,10 +1,9 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
const trueStrings = ["1", "yes", "t", "true"];
const falseStrings = ["0", "no", "f", "false"];
import { createBooleanSchema, createDurationSchema, shouldSkipEnvValidation } from "@homarr/common/env-validation";
import { supportedAuthProviders } from "@homarr/definitions";
const supportedAuthProviders = ["credentials", "oidc", "ldap"];
const authProvidersSchema = z
.string()
.min(1)
@@ -14,7 +13,7 @@ const authProvidersSchema = z
.toLowerCase()
.split(",")
.filter((provider) => {
if (supportedAuthProviders.includes(provider)) return true;
if (supportedAuthProviders.some((supportedProvider) => supportedProvider === provider)) return true;
else if (!provider)
console.log("One or more of the entries for AUTH_PROVIDER could not be parsed and/or returned null.");
else console.log(`The value entered for AUTH_PROVIDER "${provider}" is incorrect.`);
@@ -23,41 +22,7 @@ const authProvidersSchema = z
)
.default("credentials");
const createDurationSchema = (defaultValue) =>
z
.string()
.regex(/^\d+[smhd]?$/)
.default(defaultValue)
.transform((duration) => {
const lastChar = duration[duration.length - 1];
if (!isNaN(Number(lastChar))) {
return Number(defaultValue);
}
const multipliers = {
s: 1,
m: 60,
h: 60 * 60,
d: 60 * 60 * 24,
};
const numberDuration = Number(duration.slice(0, -1));
const multiplier = multipliers[lastChar];
return numberDuration * multiplier;
});
const booleanSchema = z
.string()
.default("false")
.transform((value, ctx) => {
const normalized = value.trim().toLowerCase();
if (trueStrings.includes(normalized)) return true;
if (falseStrings.includes(normalized)) return false;
throw new Error(`Invalid boolean value for ${ctx.path.join(".")}`);
});
const skipValidation = Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION);
const skipValidation = shouldSkipEnvValidation();
const authProviders = skipValidation ? [] : authProvidersSchema.parse(process.env.AUTH_PROVIDERS);
export const env = createEnv({
@@ -71,7 +36,7 @@ export const env = createEnv({
AUTH_OIDC_CLIENT_ID: z.string().min(1),
AUTH_OIDC_CLIENT_SECRET: z.string().min(1),
AUTH_OIDC_CLIENT_NAME: z.string().min(1).default("OIDC"),
AUTH_OIDC_AUTO_LOGIN: booleanSchema,
AUTH_OIDC_AUTO_LOGIN: createBooleanSchema(false),
AUTH_OIDC_SCOPE_OVERWRITE: z.string().min(1).default("openid email profile groups"),
AUTH_OIDC_GROUPS_ATTRIBUTE: z.string().default("groups"), // Is used in the signIn event to assign the correct groups, key is from object of decoded id_token
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
+1 -1
View File
@@ -8,7 +8,7 @@ import { groupMembers, groups, users } from "@homarr/db/schema";
import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { env } from "./env.mjs";
import { env } from "./env";
import { extractProfileName } from "./providers/oidc/oidc-provider";
export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["events"], undefined>["signIn"] => {
+2 -2
View File
@@ -11,7 +11,7 @@
"./client": "./client.ts",
"./server": "./server.ts",
"./shared": "./shared.ts",
"./env.mjs": "./env.mjs"
"./env": "./env.ts"
},
"main": "./index.ts",
"types": "./index.ts",
@@ -45,7 +45,7 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "5.0.2",
"@types/cookies": "0.9.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"prettier": "^3.4.2",
"typescript": "^5.7.3"
}
+1 -1
View File
@@ -1,6 +1,6 @@
import type { SupportedAuthProvider } from "@homarr/definitions";
import { env } from "../env.mjs";
import { env } from "../env";
export const isProviderEnabled = (provider: SupportedAuthProvider) => {
// The question mark is placed there because isProviderEnabled is called during static build of about page
@@ -7,7 +7,7 @@ import { logger } from "@homarr/log";
import type { validation } from "@homarr/validation";
import { z } from "@homarr/validation";
import { env } from "../../../env.mjs";
import { env } from "../../../env";
import { LdapClient } from "../ldap-client";
export const authorizeWithLdapCredentialsAsync = async (
@@ -3,7 +3,7 @@ import { Client } from "ldapts";
import { objectEntries } from "@homarr/common";
import { env } from "../../env.mjs";
import { env } from "../../env";
export interface BindOptions {
distinguishedName: string;
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Provider } from "next-auth/providers";
import { env } from "../env.mjs";
import { env } from "../env";
export const filterProviders = (providers: Exclude<Provider, () => unknown>[]) => {
// During build this will be undefined, so we default to an empty array
@@ -2,7 +2,7 @@ import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapte
import type { OIDCConfig } from "@auth/core/providers";
import type { Profile } from "@auth/core/types";
import { env } from "../../env.mjs";
import { env } from "../../env";
import { createRedirectUri } from "../../redirect";
export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profile> => ({
@@ -9,7 +9,7 @@ import { createDb } from "@homarr/db/test";
import { authorizeWithLdapCredentialsAsync } from "../credentials/authorization/ldap-authorization";
import * as ldapClient from "../credentials/ldap-client";
vi.mock("../../env.mjs", () => ({
vi.mock("../../env", () => ({
env: {
AUTH_LDAP_BIND_DN: "bind_dn",
AUTH_LDAP_BIND_PASSWORD: "bind_password",
+1 -1
View File
@@ -11,7 +11,7 @@ import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
import { createSignInEventHandler } from "../events";
vi.mock("../env.mjs", () => {
vi.mock("../env", () => {
return {
env: {
AUTH_OIDC_GROUPS_ATTRIBUTE: "someRandomGroupsKey",
+1 -1
View File
@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
@@ -2,6 +2,8 @@ import { randomBytes } from "crypto";
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
import { shouldSkipEnvValidation } from "./src/env-validation";
const errorSuffix = `, please generate a 64 character secret in hex format or use the following: "${randomBytes(32).toString("hex")}"`;
export const env = createEnv({
@@ -23,6 +25,5 @@ export const env = createEnv({
runtimeEnv: {
SECRET_ENCRYPTION_KEY: process.env.SECRET_ENCRYPTION_KEY,
},
skipValidation:
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint",
skipValidation: shouldSkipEnvValidation(),
});
+5 -3
View File
@@ -9,7 +9,8 @@
"./types": "./src/types.ts",
"./server": "./src/server.ts",
"./client": "./src/client.ts",
"./env.mjs": "./env.mjs"
"./env": "./env.ts",
"./env-validation": "./src/env-validation.ts"
},
"typesVersions": {
"*": {
@@ -30,13 +31,14 @@
"dayjs": "^1.11.13",
"next": "15.1.4",
"react": "19.0.0",
"react-dom": "19.0.0"
"react-dom": "19.0.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import crypto from "crypto";
import { env } from "../env.mjs";
import { env } from "../env";
const algorithm = "aes-256-cbc"; //Using AES encryption
+42
View File
@@ -0,0 +1,42 @@
import { z } from "zod";
const trueStrings = ["1", "yes", "t", "true"];
const falseStrings = ["0", "no", "f", "false"];
export const createBooleanSchema = (defaultValue: boolean) =>
z
.string()
.default(defaultValue.toString())
.transform((value, ctx) => {
const normalized = value.trim().toLowerCase();
if (trueStrings.includes(normalized)) return true;
if (falseStrings.includes(normalized)) return false;
throw new Error(`Invalid boolean value for ${ctx.path.join(".")}`);
});
export const createDurationSchema = (defaultValue: `${number}${"s" | "m" | "h" | "d"}`) =>
z
.string()
.regex(/^\d+[smhd]?$/)
.default(defaultValue)
.transform((duration) => {
const lastChar = duration[duration.length - 1] as "s" | "m" | "h" | "d";
if (!isNaN(Number(lastChar))) {
return Number(defaultValue);
}
const multipliers = {
s: 1,
m: 60,
h: 60 * 60,
d: 60 * 60 * 24,
};
const numberDuration = Number(duration.slice(0, -1));
const multiplier = multipliers[lastChar];
return numberDuration * multiplier;
});
export const shouldSkipEnvValidation = () =>
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint";
+1
View File
@@ -1,2 +1,3 @@
export * from "./security";
export * from "./encryption";
export * from "./user-agent";
+11
View File
@@ -0,0 +1,11 @@
import { userAgent as userAgentNextServer } from "next/server";
import type { Modify } from "./types";
export const userAgent = (headers: Headers) => {
return userAgentNextServer({ headers }) as Omit<ReturnType<typeof userAgentNextServer>, "device"> & {
device: Modify<ReturnType<typeof userAgentNextServer>["device"], { type: DeviceType }>;
};
};
export type DeviceType = "console" | "mobile" | "tablet" | "smarttv" | "wearable" | "embedded" | undefined;
+1 -1
View File
@@ -30,7 +30,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+1 -1
View File
@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+1 -1
View File
@@ -32,7 +32,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+1 -1
View File
@@ -45,7 +45,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
@@ -1,4 +1,4 @@
import { env } from "@homarr/auth/env.mjs";
import { env } from "@homarr/auth/env";
import { NEVER } from "@homarr/cron-jobs-core/expressions";
import { db, eq, inArray } from "@homarr/db";
import { sessions, users } from "@homarr/db/schema";
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Config } from "drizzle-kit";
import { env } from "../env.mjs";
import { env } from "../env";
export default {
dialect: "mysql",
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Config } from "drizzle-kit";
import { env } from "../env.mjs";
import { env } from "../env";
export default {
dialect: "sqlite",
+1 -1
View File
@@ -7,7 +7,7 @@ import mysql from "mysql2";
import { logger } from "@homarr/log";
import { env } from "./env.mjs";
import { env } from "./env";
import * as mysqlSchema from "./schema/mysql";
import * as sqliteSchema from "./schema/sqlite";
+5 -4
View File
@@ -1,12 +1,14 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
import { shouldSkipEnvValidation } from "@homarr/common/env-validation";
const drivers = {
betterSqlite3: "better-sqlite3",
mysql2: "mysql2",
};
} as const;
const isDriver = (driver) => process.env.DB_DRIVER === driver;
const isDriver = (driver: (typeof drivers)[keyof typeof drivers]) => process.env.DB_DRIVER === driver;
const isUsingDbHost = Boolean(process.env.DB_HOST);
const onlyAllowUrl = isDriver(drivers.betterSqlite3);
const urlRequired = onlyAllowUrl || !isUsingDbHost;
@@ -55,6 +57,5 @@ export const env = createEnv({
DB_NAME: process.env.DB_NAME,
DB_PORT: process.env.DB_PORT,
},
skipValidation:
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint",
skipValidation: shouldSkipEnvValidation(),
});
@@ -0,0 +1,2 @@
ALTER TABLE `user` ADD `mobile_home_board_id` varchar(64);--> statement-breakpoint
ALTER TABLE `user` ADD CONSTRAINT `user_mobile_home_board_id_board_id_fk` FOREIGN KEY (`mobile_home_board_id`) REFERENCES `board`(`id`) ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large. Load diff
@@ -141,6 +141,13 @@
"when": 1735651231818,
"tag": "0019_crazy_marvel_zombies",
"breakpoints": true
},
{
"idx": 20,
"version": "5",
"when": 1736514409126,
"tag": "0020_salty_doorman",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { migrate } from "drizzle-orm/mysql2/migrator";
import mysql from "mysql2";
import type { Database } from "../..";
import { env } from "../../env.mjs";
import { env } from "../../env";
import * as mysqlSchema from "../../schema/mysql";
import { seedDataAsync } from "../seed";
@@ -0,0 +1 @@
ALTER TABLE `user` ADD `mobile_home_board_id` text REFERENCES board(id);
File diff suppressed because it is too large. Load diff
@@ -141,6 +141,13 @@
"when": 1735651175378,
"tag": "0019_steady_darkhawk",
"breakpoints": true
},
{
"idx": 20,
"version": "6",
"when": 1736510755691,
"tag": "0020_empty_hellfire_club",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -2,7 +2,7 @@ import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { env } from "../../env.mjs";
import { env } from "../../env";
import * as sqliteSchema from "../../schema/sqlite";
import { seedDataAsync } from "../seed";
+2 -2
View File
@@ -11,7 +11,7 @@
"./test": "./test/index.ts",
"./queries": "./queries/index.ts",
"./validationSchemas": "./validationSchemas.ts",
"./env.mjs": "./env.mjs"
"./env": "./env.ts"
},
"main": "./index.ts",
"types": "./index.ts",
@@ -58,7 +58,7 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "7.6.12",
"dotenv-cli": "^8.0.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"prettier": "^3.4.2",
"tsx": "4.19.2",
"typescript": "^5.7.3"
+3
View File
@@ -62,6 +62,9 @@ export const users = mysqlTable("user", {
homeBoardId: varchar({ length: 64 }).references((): AnyMySqlColumn => boards.id, {
onDelete: "set null",
}),
mobileHomeBoardId: varchar({ length: 64 }).references((): AnyMySqlColumn => boards.id, {
onDelete: "set null",
}),
defaultSearchEngineId: varchar({ length: 64 }).references(() => searchEngines.id, {
onDelete: "set null",
}),
+3
View File
@@ -45,6 +45,9 @@ export const users = sqliteTable("user", {
homeBoardId: text().references((): AnySQLiteColumn => boards.id, {
onDelete: "set null",
}),
mobileHomeBoardId: text().references((): AnySQLiteColumn => boards.id, {
onDelete: "set null",
}),
defaultSearchEngineId: text().references(() => searchEngines.id, {
onDelete: "set null",
}),
+1 -1
View File
@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+2 -2
View File
@@ -26,13 +26,13 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^7.15.3"
"@mantine/form": "^7.16.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+1 -1
View File
@@ -31,7 +31,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+1 -1
View File
@@ -42,7 +42,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/xml2js": "^0.4.14",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
@@ -47,6 +47,7 @@ export enum MediaRequestStatus {
PendingApproval = 1,
Approved = 2,
Declined = 3,
Failed = 4,
}
export enum MediaAvailability {
@@ -55,4 +56,5 @@ export enum MediaAvailability {
Processing = 3,
PartiallyAvailable = 4,
Available = 5,
Blacklisted = 6,
}
+26 -20
View File
@@ -1,3 +1,4 @@
import { readFile } from "fs/promises";
import { join } from "path";
import type { StartedTestContainer } from "testcontainers";
import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers";
@@ -169,29 +170,34 @@ const nzbGetAddItemAsync = async (
password: string,
integration: NzbGetIntegration,
) => {
// Add nzb file in the watch folder
await container.copyFilesToContainer([
{
source: join(__dirname, "/volumes/usenet/test_download_100MB.nzb"),
target: "/downloads/nzb/test_download_100MB.nzb",
},
]);
const fileContent = await readFile(join(__dirname, "/volumes/usenet/test_download_100MB.nzb"), "base64");
// Trigger scanning of the watch folder (Only available way to add an item except "append" which is too complex and unnecessary)
await fetch(`http://${container.getHost()}:${container.getMappedPort(6789)}/${username}:${password}/jsonrpc`, {
method: "POST",
body: JSON.stringify({ method: "scan" }),
body: JSON.stringify({
method: "append",
params: [
"/downloads/nzb/test_download_100MB.nzb", // NZBFilename
fileContent, // Content
"", // Category
0, // Priority
true, // AddToTop
false, // Paused
"random", // DupeKey
1000, // DupeScore
"all", // DupeMode
[], // PPParameters
],
}),
});
// Retries up to 10000 times to let NzbGet scan and process the nzb (1 retry should suffice tbh but NzbGet is slow)
for (let i = 0; i < 10000; i++) {
const {
items: [item],
} = await integration.getClientJobsAndStatusAsync();
if (item) {
// Remove the added time because NzbGet doesn't return it properly in this specific case
const { added: _, ...itemRest } = item;
return itemRest;
}
const {
items: [item],
} = await integration.getClientJobsAndStatusAsync();
if (!item) {
throw new Error("No item found");
}
// Throws if it can't find the item
throw new Error("No item found");
return item;
};
+1 -1
View File
@@ -34,7 +34,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+2 -2
View File
@@ -32,7 +32,7 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.15.3",
"@mantine/core": "^7.16.0",
"@tabler/icons-react": "^3.28.1",
"dayjs": "^1.11.13",
"next": "15.1.4",
@@ -43,7 +43,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+3 -3
View File
@@ -24,15 +24,15 @@
"dependencies": {
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.15.3",
"@mantine/hooks": "^7.15.3",
"@mantine/core": "^7.16.0",
"@mantine/hooks": "^7.16.0",
"react": "19.0.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+2 -2
View File
@@ -24,14 +24,14 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/ui": "workspace:^0.1.0",
"@mantine/notifications": "^7.15.3",
"@mantine/notifications": "^7.16.0",
"@tabler/icons-react": "^3.28.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+3 -3
View File
@@ -37,8 +37,8 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.15.3",
"@mantine/hooks": "^7.15.3",
"@mantine/core": "^7.16.0",
"@mantine/hooks": "^7.16.0",
"adm-zip": "0.5.16",
"next": "15.1.4",
"react": "19.0.0",
@@ -52,7 +52,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/adm-zip": "0.5.7",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+1 -1
View File
@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+1 -1
View File
@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+1 -1
View File
@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+1 -1
View File
@@ -37,7 +37,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+1 -1
View File
@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+1
View File
@@ -27,6 +27,7 @@ export const defaultServerSettings = {
},
board: {
homeBoardId: null as string | null,
mobileHomeBoardId: null as string | null,
},
appearance: {
defaultColorScheme: "light" as ColorScheme,
+4 -4
View File
@@ -32,9 +32,9 @@
"@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.15.3",
"@mantine/hooks": "^7.15.3",
"@mantine/spotlight": "^7.15.3",
"@mantine/core": "^7.16.0",
"@mantine/hooks": "^7.16.0",
"@mantine/spotlight": "^7.16.0",
"@tabler/icons-react": "^3.28.1",
"jotai": "^2.11.0",
"next": "15.1.4",
@@ -46,7 +46,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
@@ -1,5 +1,5 @@
import { Group, Stack, Text } from "@mantine/core";
import { IconHome, IconLayoutDashboard, IconLink, IconSettings } from "@tabler/icons-react";
import { IconDeviceMobile, IconHome, IconLayoutDashboard, IconLink, IconSettings } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
@@ -59,6 +59,30 @@ const boardChildrenOptions = createChildrenOptions<Board>({
};
},
},
{
key: "mobileBoard",
Component: () => {
const t = useI18n();
return (
<Group mx="md" my="sm">
<IconDeviceMobile stroke={1.5} />
<Text>{t("search.mode.appIntegrationBoard.group.board.children.action.mobileBoard.label")}</Text>
</Group>
);
},
useInteraction(option) {
const { mutateAsync } = clientApi.board.setMobileHomeBoard.useMutation();
return {
type: "javaScript",
// eslint-disable-next-line no-restricted-syntax
async onSelect() {
await mutateAsync({ id: option.id });
},
};
},
},
{
key: "settings",
Component: () => {
+1 -1
View File
@@ -41,7 +41,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+7 -4
View File
@@ -912,7 +912,7 @@
},
"remove": {
"title": "删除动态部分",
"message": "您确定要删除这个动态部分吗?项目将在父部分的同一位置移动。"
"message": "你确定你要删除这个动态部分?项目将被移动到父节的相同位置。"
}
},
"category": {
@@ -927,8 +927,8 @@
"remove": "删除分类",
"moveUp": "上移",
"moveDown": "下移",
"createAbove": "新分类之上",
"createBelow": "新分类之下"
"createAbove": "上方新建分类",
"createBelow": "下方新建分类"
},
"create": {
"title": "新建分类",
@@ -966,6 +966,7 @@
},
"create": {
"title": "选择要添加的项目",
"search": "筛选项目",
"addToBoard": "添加到面板"
},
"moveResize": {
@@ -1091,7 +1092,8 @@
"adsBlockedTodayPercentage": "今日屏蔽",
"dnsQueriesToday": "今日查询",
"domainsBeingBlocked": "黑名单上的域"
}
},
"domainsTooltip": "由于多个集成,Homarr 无法计算被阻止的域的准确数量"
},
"dnsHoleControls": {
"name": "DNS Hole 控制",
@@ -1280,6 +1282,7 @@
},
"error": {
"noUrl": "没有提供 iFrame 网址",
"unsupportedProtocol": "提供的URL使用了不支持的协议。请使用({supportedProtocols})中的一个",
"noBrowerSupport": "您的浏览器不支持 iframe。请更新您的浏览器。"
}
},
+4 -1
View File
@@ -966,6 +966,7 @@
},
"create": {
"title": "",
"search": "",
"addToBoard": ""
},
"moveResize": {
@@ -1091,7 +1092,8 @@
"adsBlockedTodayPercentage": "Zablokováno dnes",
"dnsQueriesToday": "Dotazů dnes",
"domainsBeingBlocked": ""
}
},
"domainsTooltip": ""
},
"dnsHoleControls": {
"name": "",
@@ -1280,6 +1282,7 @@
},
"error": {
"noUrl": "Nebyla zadána URL adresa iFrame",
"unsupportedProtocol": "",
"noBrowerSupport": "Váš prohlížeč nepodporuje iFrame. Aktualizujte, prosím, svůj prohlížeč."
}
},
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more