diff --git a/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx b/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx index 508074ead..493d368f1 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx @@ -2,7 +2,7 @@ import type { PropsWithChildren } from "react"; import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core"; -import { DirectionProvider, MantineProvider } from "@mantine/core"; +import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core"; import dayjs from "dayjs"; import { clientApi } from "@homarr/api/client"; @@ -18,7 +18,12 @@ export const CustomMantineProvider = ({ const manager = useColorSchemeManager(); return ( - + {children} diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx index 8fa031ad0..0ea5c9cf0 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx @@ -2,7 +2,7 @@ import type { PropsWithChildren } from "react"; import type { MantineColorsTuple } from "@mantine/core"; -import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem } from "@mantine/core"; +import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core"; import { useRequiredBoard } from "@homarr/boards/context"; import type { ColorScheme } from "@homarr/definitions"; @@ -33,7 +33,12 @@ export const BoardMantineProvider = ({ }); return ( - + {children} ); diff --git a/apps/nextjs/src/app/[locale]/init/_steps/user/init-user-form.tsx b/apps/nextjs/src/app/[locale]/init/_steps/user/init-user-form.tsx index 3a659032f..d3f1c665f 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/user/init-user-form.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/user/init-user-form.tsx @@ -1,15 +1,15 @@ "use client"; -import { Button, PasswordInput, Stack, TextInput } from "@mantine/core"; +import { Button, Stack, TextInput } from "@mantine/core"; import type { z } from "zod/v4"; import { clientApi } from "@homarr/api/client"; import { signIn } from "@homarr/auth/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { useZodForm } from "@homarr/form"; +import { UserCreatePasswordFields } from "@homarr/forms-collection"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; -import { CustomPasswordInput } from "@homarr/ui"; import { userInitSchema } from "@homarr/validation/user"; export const InitUserForm = () => { @@ -59,12 +59,10 @@ export const InitUserForm = () => { > - - diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index b7fbf3291..2f610328a 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -148,7 +148,9 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => ); }; - const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search"); + const integrationCategories = integrationDefs[searchParams.kind].category.flat(); + const supportsSearchEngine = + integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch"); return (
void handleSubmitAsync(value))}> diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx index 4820a3a54..f0a91d904 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx @@ -107,6 +107,23 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers" refetchOnReconnect: false, }); const relativeTime = useTimeAgo(data.timestamp); + const utils = clientApi.useUtils(); + const { mutate, isPending } = clientApi.docker.invalidate.useMutation({ + async onSuccess() { + await utils.docker.getContainers.invalidate(); + showSuccessNotification({ + title: tDocker("action.refresh.notification.success.title"), + message: tDocker("action.refresh.notification.success.message"), + }); + }, + onError() { + showErrorNotification({ + title: tDocker("action.refresh.notification.error.title"), + message: tDocker("action.refresh.notification.error.message"), + }); + }, + }); + const table = useTranslatedMantineReactTable({ data: data.containers, enableDensityToggle: false, @@ -125,35 +142,11 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers" }, initialState: { density: "xs", showGlobalFilter: true }, - renderTopToolbarCustomActions: () => { - const utils = clientApi.useUtils(); - const { mutate, isPending } = clientApi.docker.invalidate.useMutation({ - async onSuccess() { - await utils.docker.getContainers.invalidate(); - showSuccessNotification({ - title: tDocker("action.refresh.notification.success.title"), - message: tDocker("action.refresh.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - title: tDocker("action.refresh.notification.error.title"), - message: tDocker("action.refresh.notification.error.message"), - }); - }, - }); - - return ( - - ); - }, + renderTopToolbarCustomActions: () => ( + + ), renderToolbarAlertBannerContent: ({ groupedAlert, table }) => { const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original); return ( diff --git a/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx b/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx index f443ae585..3997bda60 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx @@ -1,20 +1,7 @@ "use client"; import { startTransition, useCallback, useState } from "react"; -import { - Badge, - Button, - Card, - Group, - PasswordInput, - Stack, - Stepper, - Table, - Text, - TextInput, - Title, - Tooltip, -} from "@mantine/core"; +import { Badge, Button, Card, Group, Stack, Stepper, Table, Text, TextInput, Title, Tooltip } from "@mantine/core"; import { useListState } from "@mantine/hooks"; import { IconPlus, IconUserCheck } from "@tabler/icons-react"; import { z } from "zod/v4"; @@ -24,10 +11,11 @@ import type { GroupPermissionKey } from "@homarr/definitions"; import { everyoneGroup, groupPermissions } from "@homarr/definitions"; import type { IsValid } from "@homarr/form"; import { useZodForm } from "@homarr/form"; +import { UserCreatePasswordFields } from "@homarr/forms-collection"; import { useModalAction } from "@homarr/modals"; import { showErrorNotification } from "@homarr/notifications"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; -import { CustomPasswordInput, UserAvatar } from "@homarr/ui"; +import { UserAvatar } from "@homarr/ui"; import { createCustomErrorParams } from "@homarr/validation/form/i18n"; import { userPasswordSchema } from "@homarr/validation/user"; @@ -167,18 +155,10 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC - - diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 3b79dafd6..1c0c4100a 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -85,6 +85,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP ), integrationSupport: "supportedIntegrations" in currentDefinition, settings, + appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, }, { title(t) { diff --git a/deployments/prebuilt-debian/package.json b/deployments/prebuilt-debian/package.json index ed451a14e..bc90d7d22 100644 --- a/deployments/prebuilt-debian/package.json +++ b/deployments/prebuilt-debian/package.json @@ -6,9 +6,9 @@ }, "engines": { "node": ">=24.16.0", - "pnpm": ">=10.33.4" + "pnpm": ">=10.34.1" }, - "packageManager": "pnpm@10.33.4", + "packageManager": "pnpm@10.34.1", "pnpm": { "onlyBuiltDependencies": [ "better-sqlite3" diff --git a/package.json b/package.json index c333b5a8d..7c27c59b2 100644 --- a/package.json +++ b/package.json @@ -60,9 +60,9 @@ }, "engines": { "node": ">=24.16.0", - "pnpm": ">=10.33.4" + "pnpm": ">=10.34.1" }, - "packageManager": "pnpm@10.33.4", + "packageManager": "pnpm@10.34.1", "pnpm": { "onlyBuiltDependencies": [ "@tree-sitter-grammars/tree-sitter-yaml", @@ -76,14 +76,14 @@ "tree-sitter-json" ], "overrides": { - "@babel/helpers@<7.26.10": ">=7.29.2", - "@babel/runtime@<7.26.10": ">=7.29.2", + "@babel/helpers@<7.26.10": ">=7.29.7", + "@babel/runtime@<7.26.10": ">=7.29.7", "axios@>=1.0.0 <1.8.2": ">=1.16.1", "brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6", "brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6", "esbuild@<=0.24.2": ">=0.28.0", "form-data@>=4.0.0 <4.0.4": ">=4.0.5", - "hono@<4.6.5": ">=4.12.22", + "hono@<4.6.5": ">=4.12.23", "linkifyjs@<4.3.2": ">=4.3.3", "nanoid@>=4.0.0 <5.0.9": ">=5.1.11", "prismjs@<1.30.0": ">=1.30.0", @@ -93,7 +93,7 @@ "sha.js@<=2.4.11": ">=2.4.12", "tar-fs@>=3.0.0 <3.0.9": ">=3.1.2", "tar-fs@>=2.0.0 <2.1.3": ">=3.1.2", - "tmp@<=0.2.3": ">=0.2.5", + "tmp@<=0.2.3": ">=0.2.7", "vite@>=5.0.0 <=5.4.18": ">=8.0.14" }, "patchedDependencies": { diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index 5591b74ed..4375644cc 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -1,6 +1,8 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; +import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server"; +import { constructIntegrationPermissions } from "@homarr/auth/shared"; import { createId, objectEntries } from "@homarr/common"; import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; @@ -14,11 +16,9 @@ import { integrations, integrationSecrets, integrationUserPermissions, - searchEngines, } from "@homarr/db/schema"; import type { IntegrationSecretKind } from "@homarr/definitions"; import { - getIconUrl, getIntegrationKindsByCategory, getPermissionsWithParents, integrationCategories, @@ -33,6 +33,7 @@ import { integrationSavePermissionsSchema, integrationUpdateSchema, } from "@homarr/validation/integration"; +import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request"; import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; @@ -41,6 +42,7 @@ import { MissingSecretError, testConnectionAsync } from "./integration-test-conn import { mapTestConnectionError } from "./map-test-connection-error"; const logger = createLogger({ module: "integrationRouter" }); +const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch"); export const integrationRouter = createTRPCRouter({ all: protectedProcedure.query(async ({ ctx }) => { @@ -80,7 +82,7 @@ export const integrationRouter = createTRPCRouter({ }, }; }) - .sort( + .toSorted( (integrationA, integrationB) => integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), ); @@ -128,7 +130,7 @@ export const integrationRouter = createTRPCRouter({ }, }; }) - .sort( + .toSorted( (integrationA, integrationB) => integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), ); @@ -179,7 +181,7 @@ export const integrationRouter = createTRPCRouter({ }, }; }) - .sort( + .toSorted( (integrationA, integrationB) => integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), ); @@ -314,20 +316,7 @@ export const integrationRouter = createTRPCRouter({ url: input.url, }); - if ( - input.attemptSearchEngineCreation && - integrationDefs[input.kind].category.flatMap((category) => category).includes("search") - ) { - const icon = getIconUrl(input.kind); - await ctx.db.insert(searchEngines).values({ - id: createId(), - name: input.name, - integrationId, - type: "fromIntegration", - iconUrl: icon, - short: await getNextValidShortNameForSearchEngineAsync(ctx.db, input.name), - }); - } + // Media request search is a first-class Spotlight action. Do not create a search engine for it. }), update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); @@ -495,7 +484,7 @@ export const integrationRouter = createTRPCRouter({ }); return { - inherited: dbGroupPermissions.sort((permissionA, permissionB) => { + inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => { return permissionA.group.name.localeCompare(permissionB.group.name); }), users: userPermissions @@ -503,7 +492,7 @@ export const integrationRouter = createTRPCRouter({ user, permission, })) - .sort((permissionA, permissionB) => { + .toSorted((permissionA, permissionB) => { return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? ""); }), groups: dbGroupIntegrationPermission @@ -514,7 +503,7 @@ export const integrationRouter = createTRPCRouter({ }, permission, })) - .sort((permissionA, permissionB) => { + .toSorted((permissionA, permissionB) => { return permissionA.group.name.localeCompare(permissionB.group.name); }), }; @@ -618,8 +607,149 @@ export const integrationRouter = createTRPCRouter({ const integrationInstance = await createIntegrationAsync(ctx.integration); return await integrationInstance.searchAsync(encodeURI(input.query)); }), + mediaRequestSearchTargets: protectedProcedure.query(async ({ ctx }) => { + const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx); + + return integrationsWithAccess.map((integration) => { + const permissions = constructIntegrationPermissions(integration, ctx.session); + + return { + id: integration.id, + name: integration.name, + kind: integration.kind, + url: integration.url, + permissions: { + hasInteractAccess: permissions.hasInteractAccess, + }, + }; + }); + }), + searchMediaRequests: protectedProcedure + .input( + z.object({ + query: z.string(), + integrationIds: z.array(z.string()).optional(), + }), + ) + .query(async ({ ctx, input }) => { + const query = input.query.trim(); + if (query.length === 0) return []; + + const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx, input.integrationIds); + + const results = await Promise.all( + integrationsWithAccess.map(async (integration) => { + const integrationInstance = await createIntegrationAsync({ + id: integration.id, + name: integration.name, + url: integration.url, + kind: integration.kind as (typeof mediaRequestSearchKinds)[number], + externalUrl: integration.app?.href ?? null, + decryptedSecrets: integration.secrets.map((secret) => ({ + ...secret, + value: decryptSecret(secret.value), + })), + }); + const permissions = constructIntegrationPermissions(integration, ctx.session); + + return await integrationInstance.searchAsync(encodeURI(query)).then((searchResults) => + searchResults.map((result) => ({ + ...result, + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + url: integration.url, + permissions: { + hasInteractAccess: permissions.hasInteractAccess, + }, + }, + })), + ); + }), + ); + + return results.flat(); + }), + getMediaRequestOptions: protectedProcedure + .concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr", "seerr")) + .input(mediaRequestOptionsSchema) + .query(async ({ ctx, input }) => { + const integration = await createIntegrationAsync(ctx.integration); + return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId); + }), + requestMedia: protectedProcedure + .concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr", "seerr")) + .input(mediaRequestRequestSchema) + .mutation(async ({ ctx, input }) => { + const integration = await createIntegrationAsync(ctx.integration); + return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons); + }), }); +interface IntegrationRouterContext { + db: Database; + session: Parameters[2]; +} + +const getAccessibleMediaRequestSearchIntegrationsAsync = async ( + ctx: IntegrationRouterContext, + integrationIds?: string[], +) => { + const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({ + where: eq(groupMembers.userId, ctx.session?.user.id ?? ""), + }); + + const integrationsFromDb = await ctx.db.query.integrations.findMany({ + where: + integrationIds && integrationIds.length > 0 + ? and(inArray(integrations.id, integrationIds), inArray(integrations.kind, mediaRequestSearchKinds)) + : inArray(integrations.kind, mediaRequestSearchKinds), + orderBy: asc(integrations.name), + with: { + app: true, + secrets: true, + items: { + with: { + item: true, + }, + }, + userPermissions: { + where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""), + }, + groupPermissions: { + where: inArray( + integrationGroupPermissions.groupId, + groupsOfCurrentUser.map((group) => group.groupId), + ), + }, + }, + }); + + if (integrationIds && integrationsFromDb.length !== integrationIds.length) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "One or more media request search integrations were not found", + }); + } + + const integrationsWithAccess = await Promise.all( + integrationsFromDb.map(async (integration) => ({ + integration, + hasAccess: await hasQueryAccessToIntegrationsAsync(ctx.db, [integration], ctx.session), + })), + ); + + if (integrationIds && integrationsWithAccess.some(({ hasAccess }) => !hasAccess)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User does not have permission to query one or more media request search integrations", + }); + } + + return integrationsWithAccess.filter(({ hasAccess }) => hasAccess).map(({ integration }) => integration); +}; + interface UpdateSecretInput { integrationId: string; value: string; @@ -640,35 +770,6 @@ interface AddSecretInput { kind: IntegrationSecretKind; } -const getNextValidShortNameForSearchEngineAsync = async (db: Database, integrationName: string) => { - const searchEngines = await db.query.searchEngines.findMany({ - columns: { - short: true, - }, - }); - - const usedShortNames = searchEngines.flatMap((searchEngine) => searchEngine.short.toLowerCase()); - const nameByIntegrationName = integrationName.slice(0, 1).toLowerCase(); - - if (!usedShortNames.includes(nameByIntegrationName)) { - return nameByIntegrationName; - } - - // 8 is max length constraint - for (let i = 2; i < 9999999; i++) { - const generatedName = `${nameByIntegrationName}${i}`; - if (usedShortNames.includes(generatedName)) { - continue; - } - - return generatedName; - } - - throw new Error( - "Unable to automatically generate a short name. All possible variations were exhausted. Please disable the automatic creation and choose one later yourself.", - ); -}; - const addSecretAsync = async (db: Database, input: AddSecretInput) => { await db.insert(integrationSecrets).values({ kind: input.kind, diff --git a/packages/api/src/router/search-engine/search-engine-router.ts b/packages/api/src/router/search-engine/search-engine-router.ts index ddfc892a9..8c72baf6e 100644 --- a/packages/api/src/router/search-engine/search-engine-router.ts +++ b/packages/api/src/router/search-engine/search-engine-router.ts @@ -6,12 +6,9 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { asc, eq, like } from "@homarr/db"; import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries"; import { searchEngines, users } from "@homarr/db/schema"; -import { createIntegrationAsync } from "@homarr/integrations"; import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common"; import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine"; -import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; const logger = createLogger({ module: "searchEngineRouter" }); @@ -148,20 +145,6 @@ export const searchEngineRouter = createTRPCRouter({ limit: input.limit, }); }), - getMediaRequestOptions: protectedProcedure - .concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr")) - .input(mediaRequestOptionsSchema) - .query(async ({ ctx, input }) => { - const integration = await createIntegrationAsync(ctx.integration); - return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId); - }), - requestMedia: protectedProcedure - .concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr")) - .input(mediaRequestRequestSchema) - .mutation(async ({ ctx, input }) => { - const integration = await createIntegrationAsync(ctx.integration); - return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons); - }), create: permissionRequiredProcedure .requiresPermission("search-engine-create") .input(searchEngineManageSchema) diff --git a/packages/api/src/router/test/integration/integration-router.spec.ts b/packages/api/src/router/test/integration/integration-router.spec.ts index 17927b9c4..a7cdddfa2 100644 --- a/packages/api/src/router/test/integration/integration-router.spec.ts +++ b/packages/api/src/router/test/integration/integration-router.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, test, vi } from "vitest"; import type { Session } from "@homarr/auth"; import { createId } from "@homarr/common"; import { encryptSecret } from "@homarr/common/server"; -import { apps, integrations, integrationSecrets } from "@homarr/db/schema"; +import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema"; import { createDb } from "@homarr/db/test"; import type { GroupPermissionKey } from "@homarr/definitions"; @@ -59,6 +59,127 @@ describe("all should return all integrations", () => { }); }); +describe("mediaRequestSearchTargets should return accessible media search integrations", () => { + test("with integration use access should only return media request search integrations", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(["integration-use-all"]), + }); + + await db.insert(integrations).values([ + { + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }, + { + id: "2", + name: "Jellyfin", + kind: "jellyfin", + url: "http://jellyfin.local", + }, + ]); + + const result = await caller.mediaRequestSearchTargets(); + + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("1"); + expect(result[0]!.kind).toBe("jellyseerr"); + }); + + test("without access should not return media request search integrations", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }); + + const result = await caller.mediaRequestSearchTargets(); + + expect(result).toHaveLength(0); + }); + + test("with direct integration use access should return media request search integrations", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }); + await db.insert(users).values({ id: defaultUserId }); + await db.insert(integrationUserPermissions).values({ + integrationId: "1", + userId: defaultUserId, + permission: "use", + }); + + const result = await caller.mediaRequestSearchTargets(); + + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("1"); + }); +}); + +describe("searchMediaRequests should validate scoped integrations", () => { + test("with a non media search integration should throw not found", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(["integration-use-all"]), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyfin", + kind: "jellyfin", + url: "http://jellyfin.local", + }); + + await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow( + "One or more media request search integrations were not found", + ); + }); + + test("without query access to a scoped media search integration should throw forbidden", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }); + + await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow( + "User does not have permission to query one or more media request search integrations", + ); + }); +}); + describe("byId should return an integration by id", () => { test("with full access should return an integration by id", async () => { const db = createDb(); @@ -208,7 +329,7 @@ describe("create should create a new integration", () => { expect(dbSecret!.updatedAt).toEqual(fakeNow); }); - test("with create integration access should create a new integration when creating search engine", async () => { + test("with create integration access should not create a search engine for media request search integrations", async () => { const db = createDb(); const caller = integrationRouter.createCaller({ db, @@ -243,12 +364,7 @@ describe("create should create a new integration", () => { expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/); expect(dbSecret!.updatedAt).toEqual(fakeNow); - expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id); - expect(dbSearchEngine!.short).toBe("j"); - expect(dbSearchEngine!.name).toBe(input.name); - expect(dbSearchEngine!.iconUrl).toBe( - "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg", - ); + expect(dbSearchEngine).toBeUndefined(); }); test("with create integration access should create a new integration with new linked app", async () => { diff --git a/packages/api/src/router/test/user.spec.ts b/packages/api/src/router/test/user.spec.ts index b3b262846..16cabfd52 100644 --- a/packages/api/src/router/test/user.spec.ts +++ b/packages/api/src/router/test/user.spec.ts @@ -81,13 +81,7 @@ describe("initUser should initialize the first user", () => { await expect(actAsync()).rejects.toThrow("passwordsDoNotMatch"); }); - it.each([ - ["aB2%"], // too short - ["abc123DEF"], // does not contain special characters - ["abcDEFghi+"], // does not contain numbers - ["ABC123+/-"], // does not contain lowercase - ["abc123+/-"], // does not contain uppercase - ])("should throw error that password requirements do not match for '%s' as password", async (password) => { + it.each([["aB2%"], ["short"]])("should reject passwords shorter than 8 characters for '%s'", async (password) => { const db = createDb(); await createOnboardingStepAsync(db, "user"); const caller = userRouter.createCaller({ @@ -103,7 +97,55 @@ describe("initUser should initialize the first user", () => { confirmPassword: password, }); - await expect(actAsync()).rejects.toThrow("passwordRequirements"); + await expect(actAsync()).rejects.toThrow(); + }); + + it("should accept passwords without complexity requirements", async () => { + const db = createDb(); + await createOnboardingStepAsync(db, "user"); + const caller = userRouter.createCaller({ + db, + deviceType: undefined, + session: null, + }); + + await caller.initUser({ + username: "test", + password: "abc123DEF", + confirmPassword: "abc123DEF", + }); + + const user = await db.query.users.findFirst({ + columns: { + id: true, + }, + }); + + expect(user).toBeDefined(); + }); + + it("should accept passwords with special characters like LoveHomarr<3", async () => { + const db = createDb(); + await createOnboardingStepAsync(db, "user"); + const caller = userRouter.createCaller({ + db, + deviceType: undefined, + session: null, + }); + + await caller.initUser({ + username: "test", + password: "LoveHomarr<3", + confirmPassword: "LoveHomarr<3", + }); + + const user = await db.query.users.findFirst({ + columns: { + id: true, + }, + }); + + expect(user).toBeDefined(); }); }); diff --git a/packages/forms-collection/src/index.tsx b/packages/forms-collection/src/index.tsx index 218655cb5..7621bed43 100644 --- a/packages/forms-collection/src/index.tsx +++ b/packages/forms-collection/src/index.tsx @@ -5,3 +5,5 @@ export * from "./icon-picker/icon-picker"; export * from "./new-app/icon-matcher"; export * from "./upload-media/upload-media"; + +export * from "./user-create/user-create-password-fields"; diff --git a/packages/forms-collection/src/new-app/_form.tsx b/packages/forms-collection/src/new-app/_form.tsx index 05c7e3f6f..2bce21034 100644 --- a/packages/forms-collection/src/new-app/_form.tsx +++ b/packages/forms-collection/src/new-app/_form.tsx @@ -1,7 +1,7 @@ "use client"; import type { ChangeEventHandler } from "react"; -import { useEffect, useRef } from "react"; +import { useEffect, useImperativeHandle, useRef } from "react"; import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core"; import { useDebouncedValue, useDisclosure } from "@mantine/hooks"; import type { z } from "zod/v4"; @@ -17,6 +17,19 @@ import { findBestIconMatch } from "./icon-matcher"; type FormType = z.infer; +const toFormValues = (values: FormType | undefined): FormType => ({ + name: values?.name ?? "", + description: values?.description ?? "", + iconUrl: values?.iconUrl ?? "", + href: values?.href ?? "", + pingUrl: values?.pingUrl ?? "", +}); + +export interface AppFormHandle { + submit: () => Promise; + isDirty: () => boolean; +} + interface AppFormProps { showBackToOverview: boolean; buttonLabels: { @@ -24,8 +37,10 @@ interface AppFormProps { submitAndCreateAnother?: string; }; initialValues?: FormType; - handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void; + handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void | Promise; isPending: boolean; + hideButtons?: boolean; + formRef?: React.Ref; } export const AppForm = ({ @@ -34,19 +49,31 @@ export const AppForm = ({ handleSubmit: originalHandleSubmit, initialValues, isPending, + hideButtons, + formRef, }: AppFormProps) => { const t = useI18n(); const form = useZodForm(appManageSchema, { - initialValues: { - name: initialValues?.name ?? "", - description: initialValues?.description ?? "", - iconUrl: initialValues?.iconUrl ?? "", - href: initialValues?.href ?? "", - pingUrl: initialValues?.pingUrl ?? "", - }, + initialValues: toFormValues(initialValues), }); + const initialValuesKey = [ + initialValues?.name, + initialValues?.description, + initialValues?.iconUrl, + initialValues?.href, + initialValues?.pingUrl, + ].join("\0"); + + useEffect(() => { + if (!initialValues || form.isDirty()) { + return; + } + + form.initialize(toFormValues(initialValues)); + }, [initialValuesKey, form, initialValues]); + // Debounce the name value with 200ms delay const [debouncedName] = useDebouncedValue(form.values.name, 200); @@ -62,6 +89,29 @@ export const AppForm = ({ originalHandleSubmit(values, redirect, afterSuccess); }; + useImperativeHandle( + formRef, + () => ({ + submit: () => + new Promise((resolve) => { + form.onSubmit( + async (values) => { + try { + await Promise.resolve(handleSubmit(values)); + form.initialize(values); + resolve(true); + } catch { + resolve(false); + } + }, + () => resolve(false), + )(); + }), + isDirty: () => form.isDirty(), + }), + [form, handleSubmit], + ); + const [opened, { open, close }] = useDisclosure((initialValues?.pingUrl?.length ?? 0) > 0); const handleClickDifferentUrlPing: ChangeEventHandler = () => { @@ -92,32 +142,32 @@ export const AppForm = ({ } }, [debouncedName, iconsData]); - return ( - - - - -