Compare commits

...
Author SHA1 Message Date
Thomas Camlong 114d9ce321 Merge branch 'dev' into feat/cnmdk-preferences
Integrates latest dev changes including media request search as
first-class spotlight mode (#5823), bookmarks withBorder option,
password field extraction, and other fixes. Resolved spotlight.tsx
conflict to combine children stack navigation with media request
search event handling. Added missing withBorder mapping in old-import.
2026-06-05 16:18:05 +02:00
Thomas 3d3e3059a9 feat: media request search as first-class spotlight mode (#5823) 2026-06-05 16:09:14 +02:00
homarr-crowdin[bot]andMeier Lukas 9ae8cb2768 chore(lang): updated translations from crowdin (#5863)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-06-05 13:48:42 +02:00
Thomas 99346f288b feat(widgets): add in-place app editing in widget edit modal (#5809) 2026-06-05 12:31:50 +02:00
Thomas 41fe5928f1 fix: add bookmark widget option to toggle card borders (#5854) 2026-06-04 11:07:31 +02:00
homarr-renovate[bot] 6eb59d6f53 chore(deps): update tiptap monorepo to v3.24.0 (#5858)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-04 08:58:12 +00:00
Afonso Jorge Ramos 2c6b7a818e fix(immich): respect app color scheme in server-stats widget (#5859) 2026-06-04 09:50:02 +02:00
Thomas fd10788838 fix(auth): relax password complexity requirements to suggestions-only (#5764) 2026-06-04 07:56:34 +02:00
Thomas fe4598f722 fix(docker): restore container selection toolbar broken by Mantine v9 (#5855) 2026-06-03 10:03:37 +02:00
Thomas a755692d81 fix(ui): restore pre-v9 light variant colors (#5856) 2026-06-03 06:49:38 +00:00
homarr-renovate[bot] 3ff0d72ba1 chore(deps): update dependency next-intl to v4.13.0 (#5853)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-03 04:21:57 +00:00
homarr-renovate[bot] a63a191203 chore(deps): update turbo monorepo to ^2.9.16 (#5851)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 23:54:09 +00:00
homarr-renovate[bot] 1be906a0cd chore(deps): update dependency isomorphic-dompurify to ^3.15.0 (#5852)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 21:55:53 +00:00
homarr-renovate[bot] 63d8fa8c26 chore(deps): update dependency @ctrl/qbittorrent to ^9.12.2 (#5834)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 18:47:29 +00:00
homarr-renovate[bot] b5dd6ae8ef chore(deps): update babel monorepo to >=7.29.7 (#5830)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 14:11:26 +00:00
homarr-renovate[bot] d540ad374a chore(deps): update pnpm to v10.34.0 (#5846)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 09:17:24 +00:00
homarr-renovate[bot] 6a971c1986 chore(deps): update mantine monorepo to ^9.2.2 (#5844)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 04:13:28 +00:00
homarr-crowdin[bot] d86fe3ddf0 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-06-02 02:23:34 +00:00
homarr-renovate[bot] 06db8960a7 chore(deps): update turbo monorepo to ^2.9.15 (#5841)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-01 23:12:09 +00:00
homarr-renovate[bot] a5719acf34 chore(deps): update dependency tmp@<=0.2.3 to >=0.2.7 (#5848)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-01 21:06:21 +00:00
homarr-renovate[bot] d1d597c8e1 chore(deps): update testcontainers-node monorepo to ^12.0.1 (#5845)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-01 16:48:41 +00:00
ajnart 3a1adab3e5 style: auto-fix lint and format 2026-06-01 08:31:53 +00:00
Thomas Camlong e67d603570 fix(spotlight): remove all non-null assertions to pass oxlint
- Refactor groupDispatchers to accept userId as a parameter instead of closing over nullable userId
- Replace mutationGroup! with a guard clause
- Use optional chaining for preferenceChildrenOptionsByKey lookup with fallback
2026-06-01 10:30:41 +02:00
Thomas Camlong 0449716805 fix(spotlight): address PR review feedback
- Clear optimistic store on user/session change to prevent stale values leaking across logins
- Move dispatcher lookup before setOptimistic to avoid permanent ghost entries when no dispatcher exists
- Inline dayjs.weekdays() calls to reflect runtime locale changes instead of caching at module load
- Remove unused UserPreferenceKey import
2026-06-01 10:26:08 +02:00
homarr-renovate[bot] 657e42e4bb chore(deps): update dependency mysql2 to v3.22.4 (#5839)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-31 18:12:08 +00:00
homarr-renovate[bot] 256ebcb864 chore(deps): update dependency oxlint to ^1.67.0 (#5837)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-31 16:18:04 +00:00
homarr-renovate[bot] 72e86dee01 chore(deps): update dependency ioredis to v5.11.0 (#5838)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-31 14:53:18 +00:00
homarr-renovate[bot] b7910b16e0 chore(deps): update dependency oxfmt to ^0.52.0 (#5836)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-31 11:08:29 +00:00
homarr-renovate[bot] 5a83bb6e39 chore(deps): update dependency dayjs to ^1.11.21 (#5835)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-31 08:54:20 +00:00
homarr-renovate[bot] 433b66582d chore(deps): update dependency hono@<4.6.5 to >=4.12.23 (#5828)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-30 09:25:22 +00:00
homarr-renovate[bot] acbcb6818b chore(deps): update dependency react-error-boundary to ^6.1.2 (#5829)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-30 06:54:05 +00:00
homarr-crowdin[bot] 07d991a0a1 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-05-30 05:27:45 +00:00
homarr-renovate[bot] 92364e9574 chore(deps): update tanstack-query monorepo to ^5.100.14 (#5813)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-29 21:15:51 +00:00
Thomas e0a77948e2 Merge branch 'dev' into feat/cnmdk-preferences 2026-05-29 22:01:10 +02:00
ThomasandMeier Lukas 2991777b0a feat(users): rework general settings (#4915)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-05-29 21:22:55 +02:00
ThomasandMeier Lukas 6cc2ac56a9 fix: prevent GitHub releases check from blocking app on API failure (#5807)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-05-29 21:14:51 +02:00
homarr-update-contributors[bot] 92086e060f chore: update contributors (#5818) 2026-05-29 20:52:54 +02:00
ajnart a2c4b29bfd style: auto-fix lint and format 2026-05-29 17:52:12 +00:00
Thomas Camlong 5501ee6a7d refactor(spotlight): registry-driven preferences with data-driven hook
Replace 5 per-preference children files with a single
preference-registry.tsx that auto-generates CMDK children via factory
functions. Rewrite useUserPreferences from ~347 lines to ~160 lines by
deriving mutation dispatch, pending state, and optimistic values from
preference definitions.

Simplify settings-children.tsx and groups.tsx to iterate over
visiblePreferenceDefinitions instead of maintaining manual mappings.

Deleted: board.tsx, color-scheme.tsx, first-day-of-week.tsx,
search-engine.tsx, registry.ts

Net reduction: ~261 lines across 9 files.
2026-05-29 17:26:27 +02:00
Thomas Camlong f6592b27a1 refactor(settings): add mutationGroup to preference definitions
Enrich userPreferenceDefinitions with mutationGroup field to express
which tRPC mutation handles each preference key. Move
visiblePreferenceDefinitions helper from spotlight registry to the
shared settings package.

This enables data-driven mutation dispatch in the CMDK hook and
eliminates manual key-to-group mappings.
2026-05-29 17:26:02 +02:00
Thomas Camlong 970b92cd95 Merge remote-tracking branch 'origin/feat/rework-perferences' into feat/cnmdk-preferences 2026-05-29 17:05:49 +02:00
Thomas Camlong e182d28605 continue working on user prefernces in command palette 2026-05-29 16:23:29 +02:00
Thomas Camlong 19d86daa87 wip: user preferences in command palette 2026-05-29 15:37:00 +02:00
homarr-renovate[bot] 2aa5ab95e6 chore(deps): update dependency trpc-to-openapi to ^3.3.0 (#5802)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-29 11:44:26 +00:00
Meier Lukas 1bdfc44ba1 Merge branch 'dev' into feat/rework-perferences 2026-05-29 11:08:03 +02:00
Meier Lukas 650f761325 ci(format): fails to apply format due to conflict between --write and --check (#5815) 2026-05-29 11:07:48 +02:00
David be61602576 feat(auth): support custom token endpoint auth methods (#5806) 2026-05-29 10:51:28 +02:00
Meier Lukas 0ab0be0aa4 Merge branch 'dev' into feat/rework-perferences 2026-05-29 10:28:31 +02:00
homarr-crowdin[bot] d01834d975 chore(lang): updated translations from crowdin (#5814) 2026-05-29 10:07:48 +02:00
Thomas Camlong 4a67bf0ef3 fix(ui): use imported theme instead of undefined createTheme
createTheme was called without being imported, causing a runtime
ReferenceError on every page load. Use the already-imported theme
object from @homarr/ui which contains the full Mantine configuration.
2026-05-28 15:54:37 +02:00
Thomas Camlong cf998b474b Merge branch 'dev' into feat/rework-perferences
Resolve conflicts by keeping the consolidated general settings form,
porting the firstDayOfWeek Radio.Group fix from dev, and retaining
mobile avatar layout improvements from this branch.
2026-05-28 11:11:27 +02:00
Thomas Camlong 737e2f8c42 chore(users): remove unused general settings form components
These were superseded by UserGeneralSettingsForm in the preferences rework.
2026-05-28 11:09:52 +02:00
Thomas 4454e3fb7a fix: matine ui fixes for concistency (#5733) 2026-05-28 10:59:03 +02:00
Thomas c252b4f9ac feat(manage): unified management page layout (#5758) 2026-05-28 10:56:46 +02:00
homarr-renovate[bot] 4f2b3cf3ee chore(deps): update node.js to v24.16.0 (#5790)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-28 08:09:15 +00:00
Vivaan Dhawan b19549b601 fix: parse Speedtest Tracker created_at as local time (#5751) 2026-05-28 07:48:00 +00:00
homarr-renovate[bot] a0cc68798d chore(deps): update mantine monorepo to ^9.2.1 (#5801)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-28 03:50:55 +00:00
homarr-renovate[bot] 9a538941ee chore(deps): update dependency ws to ^8.21.0 (#5804)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-27 23:20:59 +00:00
homarr-renovate[bot] fc12500b3e chore(deps): update tanstack-query monorepo to ^5.100.13 (#5803)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-27 21:45:28 +00:00
homarr-renovate[bot] 64dccec362 chore(deps): update dependency oxfmt to ^0.51.0 (#5799)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-27 16:46:40 +00:00
homarr-renovate[bot]andThomas Camlong f7ea1b33a0 chore(deps): update mantine monorepo to v9 (#5411)
Co-authored-by: Thomas Camlong <thomas@ajnart.fr>
2026-05-27 17:29:52 +02:00
homarr-renovate[bot] fe3cebb902 chore(deps): update dependency hono@<4.6.5 to >=4.12.22 (#5797)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-27 13:06:33 +00:00
homarr-renovate[bot] ba1d213870 chore(deps): update dependency oxlint to ^1.66.0 (#5798)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-27 10:00:11 +00:00
Thomas 8f03a38de5 perf(boards): parallelize board page data fetching (#5736) 2026-05-27 07:44:37 +00:00
Thomas Camlong c33b09c5d4 fix(users): fix mobile layout and add missing ddgBangsEnabled field to general settings form
- Make avatar responsive on mobile (120px) vs desktop (200px) and center on mobile
- Add missing ddgBangsEnabled field to form schema, initial values, mutation, and UI
- Fix async function naming to satisfy lint rule
2026-05-18 17:25:35 +02:00
Thomas bc0627ca9c Merge branch 'dev' into feat/rework-perferences 2026-05-18 17:18:28 +02:00
Meier Lukas c4ab2c35ab fix: format issue 2026-05-01 16:38:53 +02:00
Thomas d44e7e9b52 Merge branch 'dev' into feat/rework-perferences 2026-05-01 10:00:32 +02:00
Thomas 1340880fef Merge branch 'dev' into feat/rework-perferences 2026-03-15 16:08:05 +01:00
Thomas Camlong e79efe06bb refactor(manage): update user settings 2026-01-28 12:19:31 +01:00
Thomas Camlong c16974b8db chore: address pr comments 2026-01-28 11:43:38 +01:00
Thomas Camlong b0c5de82f4 chore: address pr comments 2026-01-28 11:43:28 +01:00
Thomas Camlong d99b20d3ef feat(manage): consolidate user general settings form 2026-01-25 16:00:21 +01:00
Thomas Camlong d8eaef859a chore(gitignore): ignore Cursor project files 2026-01-25 16:00:10 +01:00
265 changed files with 5585 additions and 3014 deletions

No files matched your search

+5 -5
View File
@@ -42,14 +42,14 @@ jobs:
- name: Get changed files - name: Get changed files
id: changed id: changed
run: | run: |
FILES=$(git diff --name-only --diff-filter=ACMR origin/${{ github.base_ref }}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' | tr '\n' ' ') git diff --name-only --diff-filter=ACMR origin/${{ github.base_ref }}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files
echo "files=$FILES" >> $GITHUB_OUTPUT echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT
- name: Auto-fix lint + format - name: Auto-fix lint + format
if: steps.changed.outputs.files != '' if: steps.changed.outputs.count != '0'
run: | run: |
pnpm exec oxlint --fix ${{ steps.changed.outputs.files }} || true xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true
pnpm exec oxfmt --write ${{ steps.changed.outputs.files }} xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
- name: Commit fixes - name: Commit fixes
uses: stefanzweifel/git-auto-commit-action@v5 uses: stefanzweifel/git-auto-commit-action@v5
@@ -66,7 +66,7 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
if: env.SKIP_RELEASE == 'false' if: env.SKIP_RELEASE == 'false'
with: with:
node-version: 24.15.0 node-version: 24.16.0
package-manager-cache: false package-manager-cache: false
- run: npm i -g pnpm - run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false' if: env.SKIP_RELEASE == 'false'
+1 -1
View File
@@ -1 +1 @@
24.15.0 24.16.0
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.15.0-alpine AS base FROM node:24.16.0-alpine AS base
FROM base AS builder FROM base AS builder
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
+1 -1
View File
@@ -7,7 +7,7 @@
"build": "pnpm with-env next build", "build": "pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules", "clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev", "dev": "pnpm with-env next dev",
"format": "oxfmt --check .", "format": "oxfmt .",
"lint": "oxlint", "lint": "oxlint",
"start": "pnpm with-env next start", "start": "pnpm with-env next start",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
@@ -2,13 +2,14 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core"; import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
import { createTheme, DirectionProvider, MantineProvider } from "@mantine/core"; import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { parseCookies, setClientCookie } from "@homarr/common"; import { parseCookies, setClientCookie } from "@homarr/common";
import { colorSchemeCookieKey } from "@homarr/definitions"; import { colorSchemeCookieKey } from "@homarr/definitions";
import { theme } from "@homarr/ui";
export const CustomMantineProvider = ({ export const CustomMantineProvider = ({
children, children,
@@ -20,10 +21,8 @@ export const CustomMantineProvider = ({
<MantineProvider <MantineProvider
defaultColorScheme={defaultColorScheme} defaultColorScheme={defaultColorScheme}
colorSchemeManager={manager} colorSchemeManager={manager}
theme={createTheme({ theme={theme}
primaryColor: "red", cssVariablesResolver={v8CssVariablesResolver}
autoContrast: true,
})}
> >
{children} {children}
</MantineProvider> </MantineProvider>
@@ -59,7 +59,7 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
{t("subtitle")} {t("subtitle")}
</Text> </Text>
</Stack> </Stack>
<Card withBorder w={64 * 6} maw="90vw"> <Card w={64 * 6} maw="90vw">
<RegistrationForm invite={invite} /> <RegistrationForm invite={invite} />
</Card> </Card>
<Text size="xs" c="gray.5" ta="center"> <Text size="xs" c="gray.5" ta="center">
@@ -9,7 +9,7 @@ import { z } from "zod/v4";
import { signIn } from "@homarr/auth/client"; import { signIn } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import type { useForm } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
@@ -162,7 +162,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
interface SubmitButtonProps { interface SubmitButtonProps {
isPending: boolean; isPending: boolean;
form: ReturnType<typeof useForm<FormType, (values: FormType) => FormType>>; form: UseFormReturnType<FormType>;
provider: "credentials" | "ldap"; provider: "credentials" | "ldap";
} }
@@ -198,7 +198,7 @@ const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps)
{tForgotPassword("label")} {tForgotPassword("label")}
</Anchor> </Anchor>
<Collapse in={visible}> <Collapse expanded={visible}>
<Card> <Card>
<Stack gap="xs"> <Stack gap="xs">
<Text size="sm">{tForgotPassword("description")}</Text> <Text size="sm">{tForgotPassword("description")}</Text>
@@ -37,7 +37,7 @@ export default async function Login(props: LoginProps) {
{t("subtitle")} {t("subtitle")}
</Text> </Text>
</Stack> </Stack>
<Card withBorder w={64 * 6} maw="90vw"> <Card w={64 * 6} maw="90vw">
<LoginForm <LoginForm
providers={env.AUTH_PROVIDERS} providers={env.AUTH_PROVIDERS}
oidcClientName={env.AUTH_OIDC_CLIENT_NAME} oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
@@ -41,13 +41,11 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}), }),
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
page: async ({ params }: { params: Promise<TParams> }) => { page: async ({ params }: { params: Promise<TParams> }) => {
const session = await auth(); const resolvedParams = await params;
const integrations = await getIntegrationsWithPermissionsAsync(session);
const board = await getInitialBoard(await params);
const queryClient = getQueryClient(); const queryClient = getQueryClient();
// Prefetch item data const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]);
const itemsMap = board.items.reduce((acc, item) => { const itemsMap = board.items.reduce((acc, item) => {
const existing = acc.get(item.kind); const existing = acc.get(item.kind);
if (existing) { if (existing) {
@@ -58,17 +56,20 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
return acc; return acc;
}, new Map<WidgetKind, Item[]>()); }, new Map<WidgetKind, Item[]>());
for (const [kind, items] of itemsMap) { const [integrations] = await Promise.all([
await prefetchForKindAsync(kind, queryClient, items).catch((error) => { getIntegrationsWithPermissionsAsync(session),
logger.error( ...Array.from(itemsMap).map(([kind, items]) =>
new ErrorWithMetadata( prefetchForKindAsync(kind, queryClient, items).catch((error) => {
"Failed to prefetch widget", logger.error(
{ widgetKind: kind, itemCount: items.length }, new ErrorWithMetadata(
{ cause: error }, "Failed to prefetch widget",
), { widgetKind: kind, itemCount: items.length },
); { cause: error },
}); ),
} );
}),
),
]);
return ( return (
<HydrationBoundary state={dehydrate(queryClient)}> <HydrationBoundary state={dehydrate(queryClient)}>
@@ -109,7 +109,7 @@ const AddMenu = () => {
}, [openAppSelectModal, createItem]); }, [openAppSelectModal, createItem]);
return ( return (
<Menu position="bottom-end" withArrow> <Menu position="bottom-end">
<Menu.Target> <Menu.Target>
<HeaderButton w="auto" px={4}> <HeaderButton w="auto" px={4}>
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
@@ -183,7 +183,7 @@ const SelectBoardsMenu = () => {
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(); const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
return ( return (
<Menu position="bottom-end" withArrow> <Menu position="bottom-end">
<Menu.Target> <Menu.Target>
<HeaderButton w="auto" px={4}> <HeaderButton w="auto" px={4}>
<IconReplace stroke={1.5} /> <IconReplace stroke={1.5} />
@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import type { MantineColorsTuple } from "@mantine/core"; 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 { useRequiredBoard } from "@homarr/boards/context";
import type { ColorScheme } from "@homarr/definitions"; import type { ColorScheme } from "@homarr/definitions";
@@ -24,6 +24,7 @@ export const BoardMantineProvider = ({
}, },
primaryColor: "primaryColor", primaryColor: "primaryColor",
autoContrast: true, autoContrast: true,
defaultRadius: "sm",
fontSizes: { fontSizes: {
"2xl": rem(24), "2xl": rem(24),
"3xl": rem(28), "3xl": rem(28),
@@ -32,7 +33,12 @@ export const BoardMantineProvider = ({
}); });
return ( return (
<MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}> <MantineProvider
defaultColorScheme={defaultColorScheme}
theme={theme}
colorSchemeManager={colorSchemeManager}
cssVariablesResolver={v8CssVariablesResolver}
>
{children} {children}
</MantineProvider> </MantineProvider>
); );
@@ -83,7 +83,7 @@ export const ColorSettingsContent = ({ board }: Props) => {
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor> <Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<Collapse in={showPreview}> <Collapse expanded={showPreview}>
<Stack> <Stack>
<ColorsPreview previewColor={form.values.primaryColor} /> <ColorsPreview previewColor={form.values.primaryColor} />
<ColorsPreview previewColor={form.values.secondaryColor} /> <ColorsPreview previewColor={form.values.secondaryColor} />
@@ -15,7 +15,7 @@ export const InitFinish = async () => {
const tFinish = await getScopedI18n("init.step.finish"); const tFinish = await getScopedI18n("init.step.finish");
return ( return (
<Card w={64 * 6} maw="90vw" withBorder> <Card w={64 * 6} maw="90vw">
<Stack> <Stack>
<Text>{tFinish("description")}</Text> <Text>{tFinish("description")}</Text>
@@ -34,7 +34,7 @@ export const InitGroup = () => {
}; };
return ( return (
<Card w={64 * 6} maw="90vw" withBorder> <Card w={64 * 6} maw="90vw">
<form onSubmit={form.onSubmit(handleSubmitAsync)}> <form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack> <Stack>
<TextInput <TextInput
@@ -13,7 +13,7 @@ interface FileInfoCardProps {
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => { export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
const tFileInfo = useScopedI18n("init.step.import.fileInfo"); const tFileInfo = useScopedI18n("init.step.import.fileInfo");
return ( return (
<Card w={64 * 12 + 8} maw="90vw"> <Card w={64 * 12 + 8} maw="90vw" withBorder={false}>
<Group justify="space-between" align="center" wrap="nowrap"> <Group justify="space-between" align="center" wrap="nowrap">
<Group> <Group>
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}> <Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
@@ -20,7 +20,7 @@ export const InitImport = () => {
if (!file) { if (!file) {
return ( return (
<Card w={64 * 12 + 8} maw="90vw" withBorder> <Card w={64 * 12 + 8} maw="90vw">
<ImportDropZone <ImportDropZone
loading={isPending} loading={isPending}
updateFile={(file) => { updateFile={(file) => {
@@ -30,7 +30,7 @@ export const InitSettings = () => {
return ( return (
<form onSubmit={form.onSubmit(handleSubmitAsync)}> <form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack> <Stack>
<Card w={64 * 12 + 8} maw="90vw" withBorder> <Card w={64 * 12 + 8} maw="90vw">
<Stack gap="sm"> <Stack gap="sm">
<Text fw={500}>{tSection("analytics.title")}</Text> <Text fw={500}>{tSection("analytics.title")}</Text>
@@ -39,7 +39,7 @@ export const InitSettings = () => {
</Stack> </Stack>
</Stack> </Stack>
</Card> </Card>
<Card w={64 * 12 + 8} maw="90vw" withBorder> <Card w={64 * 12 + 8} maw="90vw">
<Stack gap="sm"> <Stack gap="sm">
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text> <Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
@@ -10,7 +10,7 @@ export const InitStart = async () => {
const tStart = await getScopedI18n("init.step.start"); const tStart = await getScopedI18n("init.step.start");
return ( return (
<Card w={64 * 6} maw="90vw" withBorder> <Card w={64 * 6} maw="90vw">
<Stack> <Stack>
<Text>{tStart("description")}</Text> <Text>{tStart("description")}</Text>
@@ -1,15 +1,15 @@
"use client"; "use client";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core"; import { Button, Stack, TextInput } from "@mantine/core";
import type { z } from "zod/v4"; import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { signIn } from "@homarr/auth/client"; import { signIn } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { UserCreatePasswordFields } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
import { userInitSchema } from "@homarr/validation/user"; import { userInitSchema } from "@homarr/validation/user";
export const InitUserForm = () => { export const InitUserForm = () => {
@@ -59,12 +59,10 @@ export const InitUserForm = () => {
> >
<Stack gap="lg"> <Stack gap="lg">
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} /> <TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
<CustomPasswordInput <UserCreatePasswordFields
withPasswordRequirements passwordInputProps={form.getInputProps("password")}
label={t("field.password.label")} confirmPasswordInputProps={form.getInputProps("confirmPassword")}
{...form.getInputProps("password")}
/> />
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
<Button type="submit" fullWidth loading={isPending}> <Button type="submit" fullWidth loading={isPending}>
{t("action.create")} {t("action.create")}
</Button> </Button>
@@ -4,7 +4,7 @@ import { InitUserForm } from "./init-user-form";
export const InitUser = () => { export const InitUser = () => {
return ( return (
<Card w={64 * 6} maw="90vw" withBorder> <Card w={64 * 6} maw="90vw">
<InitUserForm /> <InitUserForm />
</Card> </Card>
); );
+4 -4
View File
@@ -44,13 +44,13 @@ const fontSans = Inter({
export const generateMetadata = async (): Promise<Metadata> => ({ export const generateMetadata = async (): Promise<Metadata> => ({
title: "Homarr", title: "Homarr",
description: description:
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.", "A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
openGraph: { openGraph: {
title: "Homarr Dashboard", title: "Homarr Dashboard",
description: description:
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.", "A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
url: "https://homarr.dev", url: "https://homarr.dev",
siteName: "Homarr Documentation", siteName: "Homarr",
}, },
icons: { icons: {
icon: "/logo/logo.png", icon: "/logo/logo.png",
@@ -142,7 +142,7 @@ export default async function Layout(props: {
</head> </head>
<body className={["font-sans", fontSans.variable].join(" ")}> <body className={["font-sans", fontSans.variable].join(" ")}>
<StackedProvider> <StackedProvider>
<Notifications /> <Notifications pauseResetOnHover="notification" />
<ServiceWorkerRegistration /> <ServiceWorkerRegistration />
{props.children} {props.children}
</StackedProvider> </StackedProvider>
@@ -45,7 +45,6 @@ export const HeroBanner = async () => {
<Image <Image
key={`grid-column-${columnIndex}-scroll-1-${index}`} key={`grid-column-${columnIndex}-scroll-1-${index}`}
src={icon} src={icon}
radius="md"
fit={"contain"} fit={"contain"}
w={50} w={50}
h={50} h={50}
@@ -57,7 +56,6 @@ export const HeroBanner = async () => {
<Image <Image
key={`grid-column-${columnIndex}-scroll-2-${index}`} key={`grid-column-${columnIndex}-scroll-2-${index}`}
src={icon} src={icon}
radius="md"
fit={"contain"} fit={"contain"}
w={50} w={50}
h={50} h={50}
@@ -90,9 +90,7 @@ export default async function AboutPage() {
<Group py="lg"> <Group py="lg">
<Image src={homarrLogoPath} width={100} height={100} alt="" /> <Image src={homarrLogoPath} width={100} height={100} alt="" />
<Stack gap={0}> <Stack gap={0}>
<Title order={1} tt="uppercase"> <Title order={1}>Homarr</Title>
Homarr
</Title>
<Title order={2}>{t("version", { version })}</Title> <Title order={2}>{t("version", { version })}</Title>
</Stack> </Stack>
</Group> </Group>
@@ -195,9 +193,9 @@ export default async function AboutPage() {
href={`https://www.npmjs.com/package/${name}`} href={`https://www.npmjs.com/package/${name}`}
target="_blank" target="_blank"
> >
<Card withBorder radius="md" p="xs" className={classes.dependencyCard}> <Card radius="md" p="xs" className={classes.dependencyCard}>
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" size="lg" radius="md"> <ThemeIcon variant="light" size="lg">
<IconPackage size="1.5rem" stroke={1.5} /> <IconPackage size="1.5rem" stroke={1.5} />
</ThemeIcon> </ThemeIcon>
@@ -1,6 +1,6 @@
import { Fragment } from "react"; import { Fragment } from "react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core"; import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
import { IconBox, IconPencil } from "@tabler/icons-react"; import { IconBox, IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod/v4";
@@ -11,9 +11,8 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui"; import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results"; import { NoResults } from "~/components/no-results";
import { AppDeleteButton } from "./_app-delete-button"; import { AppDeleteButton } from "./_app-delete-button";
@@ -39,34 +38,31 @@ export default async function AppsPage(props: AppsPageProps) {
const { items: apps, totalCount } = await api.app.getPaginated(searchParams); const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
const t = await getScopedI18n("app"); const t = await getScopedI18n("app");
return ( const canCreate = session.user.permissions.includes("app-create");
<ManageContainer>
<DynamicBreadcrumb />
<Stack>
<Title>{t("page.list.title")}</Title>
<Group justify="space-between" align="center">
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />
{session.user.permissions.includes("app-create") && (
<MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")}
</MobileAffixButton>
)}
</Group>
{apps.length === 0 && <AppNoResults />}
{apps.length > 0 && (
<Stack gap="sm">
{apps.map((app) => (
<AppCard key={app.id} app={app} />
))}
</Stack>
)}
{/* Added margin to not hide pagination behind affix-button */} return (
<Group justify="end" mb={48}> <ManagePageLayout
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} /> title={t("page.list.title")}
</Group> primaryAction={
</Stack> canCreate ? (
</ManageContainer> <MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")}
</MobileAffixButton>
) : undefined
}
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />}
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
floatingPrimaryAction={canCreate}
>
{apps.length === 0 && <AppNoResults />}
{apps.length > 0 && (
<Stack gap="sm">
{apps.map((app) => (
<AppCard key={app.id} app={app} />
))}
</Stack>
)}
</ManagePageLayout>
); );
} }
@@ -79,7 +75,7 @@ const AppCard = async ({ app }: AppCardProps) => {
const session = await auth(); const session = await auth();
return ( return (
<Card withBorder> <Card>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}> <Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}>
<Avatar <Avatar
@@ -1,43 +1,38 @@
"use client"; "use client";
import { Affix, Button, Menu } from "@mantine/core"; import { Button, Menu } from "@mantine/core";
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react"; import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection"; import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
export const CreateBoardButton = () => { export const CreateBoardButton = () => {
const t = useI18n(); const t = useI18n();
const { openModal: openAddModal } = useModalAction(AddBoardModal); const { openModal: openAddModal } = useModalAction(AddBoardModal);
const { openModal: openImportModal } = useModalAction(ImportBoardModal); const { openModal: openImportModal } = useModalAction(ImportBoardModal);
const buttonGroupContent = (
<>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
{t("management.page.board.action.new.label")}
</Button>
<Menu position="bottom-end">
<Menu.Target>
<Button px="xs" ms={1}>
<IconChevronDown size="1rem" />
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
{t("board.action.oldImport.label")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
return ( return (
<> <ManageMobilePrimaryAction>
<Button.Group visibleFrom="md">{buttonGroupContent}</Button.Group> <Button.Group>
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}> <Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
<Button.Group>{buttonGroupContent}</Button.Group> {t("management.page.board.action.new.label")}
</Affix> </Button>
</> <Menu position="bottom-end">
<Menu.Target>
<Button px="xs" ms={1}>
<IconChevronDown size="1rem" />
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
{t("board.action.oldImport.label")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Button.Group>
</ManageMobilePrimaryAction>
); );
}; };
@@ -9,12 +9,17 @@ import {
Group, Group,
Menu, Menu,
MenuTarget, MenuTarget,
Stack,
Text, Text,
Title,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react"; import {
IconDeviceMobile,
IconDotsVertical,
IconHomeFilled,
IconLayoutDashboard,
IconLock,
IconWorld,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
@@ -23,8 +28,8 @@ import { getScopedI18n } from "@homarr/translation/server";
import { Link, UserAvatar } from "@homarr/ui"; import { Link, UserAvatar } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { NoResults } from "~/components/no-results";
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown"; import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
import { CreateBoardButton } from "./_components/create-board-button"; import { CreateBoardButton } from "./_components/create-board-button";
@@ -35,23 +40,22 @@ export default async function ManageBoardsPage() {
const canCreateBoards = session?.user.permissions.includes("board-create"); const canCreateBoards = session?.user.permissions.includes("board-create");
return ( return (
<ManageContainer> <ManagePageLayout
<DynamicBreadcrumb /> title={t("title")}
<Stack> primaryAction={canCreateBoards ? <CreateBoardButton /> : undefined}
<Group justify="space-between"> floatingPrimaryAction={canCreateBoards}
<Title mb="md">{t("title")}</Title> >
{canCreateBoards && <CreateBoardButton />} {boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />}
</Group> {boards.length > 0 && (
<Grid>
<Grid mb={{ base: "xl", md: 0 }}>
{boards.map((board) => ( {boards.map((board) => (
<GridCol span={{ base: 12, md: 6 }} key={board.id}> <GridCol span={{ base: 12, md: 6 }} key={board.id}>
<BoardCard board={board} /> <BoardCard board={board} />
</GridCol> </GridCol>
))} ))}
</Grid> </Grid>
</Stack> )}
</ManageContainer> </ManagePageLayout>
); );
} }
@@ -66,16 +70,14 @@ const BoardCard = async ({ board }: BoardCardProps) => {
const VisibilityIcon = board.isPublic ? IconWorld : IconLock; const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
return ( return (
<Card radius="lg" withBorder> <Card>
<CardSection p="sm" withBorder> <CardSection p="sm" withBorder>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Group gap="sm"> <Group gap="sm">
<Tooltip label={t(`visibility.${visibility}`)}> <Tooltip label={t(`visibility.${visibility}`)}>
<VisibilityIcon size={20} stroke={1.5} /> <VisibilityIcon size={20} stroke={1.5} />
</Tooltip> </Tooltip>
<Text fw="bolder" tt="uppercase"> <Text fw={700}>{board.name}</Text>
{board.name}
</Text>
</Group> </Group>
<Group> <Group>
@@ -123,6 +125,7 @@ const BoardCard = async ({ board }: BoardCardProps) => {
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }} style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
variant="default" variant="default"
size="lg" size="lg"
aria-label={t("action.settings.label")}
> >
<IconDotsVertical size={16} stroke={1.5} /> <IconDotsVertical size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
@@ -33,7 +33,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
const KindIcon = integrationSecretIcons[secret.kind]; const KindIcon = integrationSecretIcons[secret.kind];
return ( return (
<Card withBorder> <Card>
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Group> <Group>
@@ -58,7 +58,12 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
</Tooltip> </Tooltip>
)} )}
{isPublic ? ( {isPublic ? (
<ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}> <ActionIcon
color="gray"
variant="subtle"
onClick={togglePublicSecretDisplay}
aria-label={t(publicSecretDisplayOpened ? "common.action.hide" : "common.action.show")}
>
<DisplayIcon size={16} stroke={1.5} /> <DisplayIcon size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
) : null} ) : null}
@@ -79,7 +84,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
</Button> </Button>
</Group> </Group>
</Group> </Group>
<Collapse in={editMode}>{children}</Collapse> <Collapse expanded={editMode}>{children}</Collapse>
</Stack> </Stack>
</Card> </Card>
); );
@@ -12,7 +12,7 @@ interface FormType {
interface SecretKindsSegmentedControlProps<TFormType extends FormType> { interface SecretKindsSegmentedControlProps<TFormType extends FormType> {
defaultKinds?: IntegrationSecretKind[]; defaultKinds?: IntegrationSecretKind[];
secretKinds: IntegrationSecretKind[][]; secretKinds: IntegrationSecretKind[][];
form: UseFormReturnType<TFormType, (values: TFormType) => TFormType>; form: UseFormReturnType<TFormType, TFormType>;
} }
export const SecretKindsSegmentedControl = <TFormType extends FormType>({ export const SecretKindsSegmentedControl = <TFormType extends FormType>({
@@ -18,7 +18,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]); const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]);
return ( return (
<Card withBorder style={{ borderColor: getMantineColor("red", 8) }}> <Card style={{ borderColor: getMantineColor("red", 8) }}>
<Stack> <Stack>
<Stack gap="sm"> <Stack gap="sm">
<Text size="lg" fw={500} c="red.8"> <Text size="lg" fw={500} c="red.8">
@@ -216,7 +216,7 @@ export const CertificateDetailsCard = ({ certificate }: CertificateDetailsProps)
const tCertificateField = useScopedI18n("certificate.field"); const tCertificateField = useScopedI18n("certificate.field");
return ( return (
<Card withBorder> <Card>
<Text fw={500}>{tDetails("title")}</Text> <Text fw={500}>{tDetails("title")}</Text>
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
@@ -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 ( return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}> <form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
@@ -226,7 +228,7 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
description={t("integration.field.createApp.description")} description={t("integration.field.createApp.description")}
/> />
<Collapse in={form.values.hasApp}> <Collapse expanded={form.values.hasApp}>
<Fieldset legend={t("integration.field.app.sectionTitle")}> <Fieldset legend={t("integration.field.app.sectionTitle")}>
<Stack gap="sm"> <Stack gap="sm">
{canCreateApps && ( {canCreateApps && (
@@ -8,7 +8,7 @@
border: 1px solid transparent; border: 1px solid transparent;
position: relative; position: relative;
z-index: 0; z-index: 0;
transition: transform 150ms ease; transition: background-color 150ms ease;
overflow: hidden; overflow: hidden;
&[data-first="true"] { &[data-first="true"] {
@@ -20,11 +20,9 @@
} }
&[data-active] { &[data-active] {
transform: scale(1.01);
z-index: 1; z-index: 1;
background-color: var(--mantine-color-body); background-color: var(--mantine-color-body);
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
box-shadow: var(--mantine-shadow-md);
border-radius: var(--mantine-radius-lg); border-radius: var(--mantine-radius-lg);
} }
} }
@@ -7,9 +7,7 @@ import {
AccordionPanel, AccordionPanel,
ActionIcon, ActionIcon,
ActionIconGroup, ActionIconGroup,
Affix,
Anchor, Anchor,
Box,
Button, Button,
Divider, Divider,
Group, Group,
@@ -24,9 +22,8 @@ import {
TableThead, TableThead,
TableTr, TableTr,
Text, Text,
Title,
} from "@mantine/core"; } from "@mantine/core";
import { IconChevronDown, IconChevronUp, IconPencil, IconPlugX } from "@tabler/icons-react"; import { IconChevronDown, IconPencil, IconPlugX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
@@ -37,8 +34,8 @@ import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui"; import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { NoResults } from "~/components/no-results"; import { NoResults } from "~/components/no-results";
import { env } from "~/env"; import { env } from "~/env";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion"; import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
@@ -66,38 +63,23 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
const canCreateIntegrations = session.user.permissions.includes("integration-create"); const canCreateIntegrations = session.user.permissions.includes("integration-create");
return ( return (
<ManageContainer> <ManagePageLayout
<DynamicBreadcrumb /> title={t("page.list.title")}
<Stack> primaryAction={
<Group justify="space-between" align="center"> canCreateIntegrations ? (
<Title>{t("page.list.title")}</Title> <ManageMobilePrimaryAction>
<IntegrationSelectMenu>
{canCreateIntegrations && ( <MenuTarget>
<> <Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
<Box> </MenuTarget>
<IntegrationSelectMenu> </IntegrationSelectMenu>
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}> </ManageMobilePrimaryAction>
<MenuTarget> ) : undefined
<Button rightSection={<IconChevronUp size={16} stroke={1.5} />}>{t("action.create")}</Button> }
</MenuTarget> floatingPrimaryAction={canCreateIntegrations}
</Affix> >
</IntegrationSelectMenu> <IntegrationList integrations={integrations} activeTab={searchParams.tab} />
</Box> </ManagePageLayout>
<Box visibleFrom="md">
<IntegrationSelectMenu>
<MenuTarget>
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
</MenuTarget>
</IntegrationSelectMenu>
</Box>
</>
)}
</Group>
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
</Stack>
</ManageContainer>
); );
} }
@@ -4,17 +4,15 @@ import {
Anchor, Anchor,
Group, Group,
Image, Image,
Stack,
Table, Table,
TableTbody, TableTbody,
TableTd, TableTd,
TableTh, TableTh,
TableThead, TableThead,
TableTr, TableTr,
Title,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react"; import { IconExternalLink, IconPhoto } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -26,8 +24,9 @@ import { createLocalImageUrl } from "@homarr/icons/local";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination, UserAvatar } from "@homarr/ui"; import { Link, SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { NoResults } from "~/components/no-results";
import { CopyMedia } from "./_actions/copy-media"; import { CopyMedia } from "./_actions/copy-media";
import { DeleteMedia } from "./_actions/delete-media"; import { DeleteMedia } from "./_actions/delete-media";
import { IncludeFromAllUsersSwitch } from "./_actions/show-all"; import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
@@ -48,7 +47,7 @@ interface MediaListPageProps {
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>; searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
} }
export default async function GroupsListPage(props: MediaListPageProps) { export default async function MediaListPage(props: MediaListPageProps) {
const session = await auth(); const session = await auth();
if (!session) { if (!session) {
@@ -58,22 +57,31 @@ export default async function GroupsListPage(props: MediaListPageProps) {
const t = await getI18n(); const t = await getI18n();
const searchParams = searchParamsSchema.parse(await props.searchParams); const searchParams = searchParamsSchema.parse(await props.searchParams);
const { items: medias, totalCount } = await api.media.getPaginated(searchParams); const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
const canUpload = session.user.permissions.includes("media-upload");
return ( return (
<ManageContainer> <ManagePageLayout
<DynamicBreadcrumb /> title={t("media.plural")}
<Stack> primaryAction={
<Title>{t("media.plural")}</Title> canUpload ? (
<Group justify="space-between"> <ManageMobilePrimaryAction>
<Group> <UploadMediaButton />
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} /> </ManageMobilePrimaryAction>
{session.user.permissions.includes("media-view-all") && ( ) : undefined
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} /> }
)} toolbar={
</Group> <Group>
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
{session.user.permissions.includes("media-upload") && <UploadMediaButton />} {session.user.permissions.includes("media-view-all") && (
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
)}
</Group> </Group>
}
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
floatingPrimaryAction={canUpload}
>
{medias.length === 0 && <NoResults icon={IconPhoto} title={t("media.noResults.title")} />}
{medias.length > 0 && (
<Table striped highlightOnHover> <Table striped highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
@@ -90,13 +98,8 @@ export default async function GroupsListPage(props: MediaListPageProps) {
))} ))}
</TableTbody> </TableTbody>
</Table> </Table>
)}
{/* Added margin to not hide pagination behind affix-button */} </ManagePageLayout>
<Group justify="end" mb={48}>
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>
</ManageContainer>
); );
} }
@@ -7,9 +7,7 @@ export default async function NotFound() {
return ( return (
<Center h="100%"> <Center h="100%">
<Stack align="center"> <Stack align="center">
<Title order={1} tt="uppercase"> <Title order={1}>{t("title")}</Title>
{t("title")}
</Title>
<Text>{t("text")}</Text> <Text>{t("text")}</Text>
</Stack> </Stack>
</Center> </Center>
+5 -5
View File
@@ -29,20 +29,20 @@ export default async function ManagementPage() {
<Space h="md" /> <Space h="md" />
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}> <SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{statistics.map((statistic) => ( {statistics.map((statistic) => (
<Card component={Link} href={statistic.path} key={statistic.path} radius="lg"> <Card component={Link} href={statistic.path} key={statistic.path}>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group wrap="nowrap"> <Group wrap="nowrap">
<Text size="2.4rem" fw="bolder"> <Text size="xl" fw={700}>
{statistic.count} {statistic.count}
</Text> </Text>
<Stack gap={0}> <Stack gap={0}>
<Text c="red" size="xs"> <Text c="dimmed" size="xs">
{t(`statisticLabel.${statistic.subtitleKey}`)} {t(`statisticLabel.${statistic.subtitleKey}`)}
</Text> </Text>
<Text fw="bold">{t(`statistic.${statistic.titleKey}`)}</Text> <Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text>
</Stack> </Stack>
</Group> </Group>
<IconArrowRight /> <IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />
</Group> </Group>
</Card> </Card>
))} ))}
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core"; import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
import { IconPencil, IconSearch } from "@tabler/icons-react"; import { IconPencil, IconSearch } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod/v4";
@@ -10,9 +10,8 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui"; import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results"; import { NoResults } from "~/components/no-results";
import { SearchEngineDeleteButton } from "./_search-engine-delete-button"; import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
@@ -38,34 +37,31 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
const tEngine = await getScopedI18n("search.engine"); const tEngine = await getScopedI18n("search.engine");
return ( const canCreate = session.user.permissions.includes("search-engine-create");
<ManageContainer>
<DynamicBreadcrumb />
<Stack>
<Title>{tEngine("page.list.title")}</Title>
<Group justify="space-between" align="center">
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />
{session.user.permissions.includes("search-engine-create") && (
<MobileAffixButton component={Link} href="/manage/search-engines/new">
{tEngine("page.create.title")}
</MobileAffixButton>
)}
</Group>
{searchEngines.length === 0 && <SearchEngineNoResults />}
{searchEngines.length > 0 && (
<Stack gap="sm">
{searchEngines.map((searchEngine) => (
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
))}
</Stack>
)}
{/* Added margin to not hide pagination behind affix-button */} return (
<Group justify="end" mb={48}> <ManagePageLayout
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} /> title={tEngine("page.list.title")}
</Group> primaryAction={
</Stack> canCreate ? (
</ManageContainer> <MobileAffixButton component={Link} href="/manage/search-engines/new">
{tEngine("page.create.title")}
</MobileAffixButton>
) : undefined
}
toolbar={<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />}
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
floatingPrimaryAction={canCreate}
>
{searchEngines.length === 0 && <SearchEngineNoResults />}
{searchEngines.length > 0 && (
<Stack gap="sm">
{searchEngines.map((searchEngine) => (
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
))}
</Stack>
)}
</ManagePageLayout>
); );
} }
@@ -78,7 +74,7 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
const session = await auth(); const session = await auth();
return ( return (
<Card withBorder> <Card>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}> <Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
<Avatar <Avatar
@@ -41,8 +41,8 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
<> <>
<Title order={2}>{t("title")}</Title> <Title order={2}>{t("title")}</Title>
<Card pos="relative" withBorder> <Card pos="relative">
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} /> <LoadingOverlay visible={isPending} />
<Stack> <Stack>
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} /> <SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
</Stack> </Stack>
@@ -41,11 +41,11 @@ export const CrawlingAndIndexingSettings = ({ initialData }: CrawlingAndIndexing
<> <>
<Title order={2}>{t("title")}</Title> <Title order={2}>{t("title")}</Title>
<Card pos="relative" withBorder> <Card pos="relative">
<Text c={"dimmed"} mb={"lg"}> <Text c={"dimmed"} mb={"lg"}>
{t("warning")} {t("warning")}
</Text> </Text>
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} /> <LoadingOverlay visible={isPending} />
<Stack> <Stack>
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} /> <SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} /> <SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
@@ -15,7 +15,7 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
disabled, disabled,
}: { }: {
form: Modify< form: Modify<
UseFormReturnType<TFormValue, () => TFormValue>, UseFormReturnType<TFormValue, TFormValue>,
{ {
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void; setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
} }
@@ -79,7 +79,7 @@ export default async function CertificatesPage({ params }: CertificatesPageProps
<SimpleGrid cols={{ sm: 1, lg: 2, xl: 3 }} spacing="lg"> <SimpleGrid cols={{ sm: 1, lg: 2, xl: 3 }} spacing="lg">
{x509Certificates.map((cert) => ( {x509Certificates.map((cert) => (
<Card key={cert.fileName} withBorder> <Card key={cert.fileName}>
<Group wrap="nowrap"> <Group wrap="nowrap">
{cert.isError ? ( {cert.isError ? (
<IconAlertTriangle <IconAlertTriangle
@@ -107,6 +107,23 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
refetchOnReconnect: false, refetchOnReconnect: false,
}); });
const relativeTime = useTimeAgo(data.timestamp); 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({ const table = useTranslatedMantineReactTable({
data: data.containers, data: data.containers,
enableDensityToggle: false, enableDensityToggle: false,
@@ -125,35 +142,11 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
}, },
initialState: { density: "xs", showGlobalFilter: true }, initialState: { density: "xs", showGlobalFilter: true },
renderTopToolbarCustomActions: () => { renderTopToolbarCustomActions: () => (
const utils = clientApi.useUtils(); <Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({ {tDocker("action.refresh.label")}
async onSuccess() { </Button>
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 (
<Button
variant="default"
rightSection={<IconRefresh size="1rem" />}
onClick={() => mutate()}
loading={isPending}
>
{tDocker("action.refresh.label")}
</Button>
);
},
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => { renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original); const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
return ( return (
@@ -202,7 +195,7 @@ const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) =>
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} /> <ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} /> <ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} /> <ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light" radius="md"> <Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light">
{t("addToHomarr.label")} {t("addToHomarr.label")}
</Button> </Button>
</Group> </Group>
@@ -253,7 +246,6 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
onClick={handleClickAsync} onClick={handleClickAsync}
loading={isPending} loading={isPending}
variant="light" variant="light"
radius="md"
> >
{t(`${props.action}.label`)} {t(`${props.action}.label`)}
</Button> </Button>
@@ -4,28 +4,16 @@
overflow: hidden; overflow: hidden;
padding: var(--mantine-spacing-xs); padding: var(--mantine-spacing-xs);
padding-left: calc(var(--mantine-spacing-xs) * 2); padding-left: calc(var(--mantine-spacing-xs) * 2);
border-left: 1px solid var(--mantine-color-blue-6);
@mixin hover { background-color: light-dark(
box-shadow: var(--mantine-shadow-md); var(--mantine-color-blue-0),
} color-mix(in oklch, var(--mantine-color-blue-9), transparent 85%)
);
&::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 6px;
background-image: linear-gradient(0, var(--mantine-color-blue-4), var(--mantine-color-blue-9));
}
@mixin rtl { @mixin rtl {
padding-left: var(--mantine-spacing-xs); padding-left: var(--mantine-spacing-xs);
padding-right: calc(var(--mantine-spacing-xs) * 2); padding-right: calc(var(--mantine-spacing-xs) * 2);
border-left: none;
&::before { border-right: 1px solid var(--mantine-color-blue-6);
left: unset;
right: 0;
}
} }
} }
@@ -23,7 +23,6 @@ export function HeaderCard(props: HeaderCardProps) {
<Flex align="center" justify={isRTL ? "space-between" : "flex-start"} gap="md" direction={"row"}> <Flex align="center" justify={isRTL ? "space-between" : "flex-start"} gap="md" direction={"row"}>
<ThemeIcon <ThemeIcon
size="xl" size="xl"
radius="md"
variant="gradient" variant="gradient"
gradient={{ gradient={{
deg: 0, deg: 0,
@@ -8,6 +8,8 @@ import { useCurrentLocale, useI18n } from "@homarr/translation/client";
import { ResourceIcon } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-icon"; import { ResourceIcon } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-icon";
import classes from "./resource-gauge.module.css"; import classes from "./resource-gauge.module.css";
const KUBERNETES_BRAND_COLOR = "#326ce5";
interface KubernetesResourceGaugeProps { interface KubernetesResourceGaugeProps {
kubernetesCapacity: KubernetesCapacity; kubernetesCapacity: KubernetesCapacity;
} }
@@ -18,8 +20,8 @@ export function ResourceGauge(props: KubernetesResourceGaugeProps) {
const isRTL = Boolean(isLocaleRTL(currentLocale)); const isRTL = Boolean(isLocaleRTL(currentLocale));
return ( return (
<Paper radius="md" withBorder className={classes.paper} mt={20}> <Paper className={classes.paper} mt={20}>
<ThemeIcon className={classes.icon} size={60} radius={60} bg={"#326ce5"}> <ThemeIcon className={classes.icon} size={60} radius={60} bg={KUBERNETES_BRAND_COLOR}>
<ResourceIcon type={props.kubernetesCapacity.type} /> <ResourceIcon type={props.kubernetesCapacity.type} />
</ThemeIcon> </ThemeIcon>
@@ -17,12 +17,7 @@ interface ResourceTileProps {
export function ResourceTile(props: ResourceTileProps) { export function ResourceTile(props: ResourceTileProps) {
const t = useI18n(); const t = useI18n();
return ( return (
<Card <Card component={Link} href={`/manage/tools/kubernetes/${props.label}`} className={classes.cardContainer}>
withBorder
component={Link}
href={`/manage/tools/kubernetes/${props.label}`}
className={classes.cardContainer}
>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Image src={`/images/kubernetes/${props.label}.svg`} alt={props.label} width={64} height={64} /> <Image src={`/images/kubernetes/${props.label}.svg`} alt={props.label} width={64} height={64} />
<Group gap="xs"> <Group gap="xs">
@@ -2,12 +2,14 @@
import { Select } from "@mantine/core"; import { Select } from "@mantine/core";
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
import { logLevelConfiguration, logLevels } from "@homarr/core/infrastructure/logs/constants"; import { logLevelConfiguration, logLevels } from "@homarr/core/infrastructure/logs/constants";
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { useLogContext } from "./log-context"; import { useLogContext } from "./log-context";
const levelByValue = Object.fromEntries(logLevels.map((logLevel) => [logLevel, logLevel])) as Record<string, LogLevel>;
export const LogLevelSelection = () => { export const LogLevelSelection = () => {
const { level, setLevel } = useLogContext(); const { level, setLevel } = useLogContext();
const t = useI18n(); const t = useI18n();
@@ -19,7 +21,12 @@ export const LogLevelSelection = () => {
label: `${logLevelConfiguration[level].prefix} ${t(`log.level.option.${level}`)}`, label: `${logLevelConfiguration[level].prefix} ${t(`log.level.option.${level}`)}`,
}))} }))}
value={level} value={level}
onChange={(value) => setLevel(value as LogLevel)} onChange={(value) => {
const nextLevel = value && levelByValue[value];
if (nextLevel) {
setLevel(nextLevel);
}
}}
checkIconPosition="right" checkIconPosition="right"
/> />
); );
@@ -1,85 +0,0 @@
"use client";
import { Button, Group, Stack } from "@mantine/core";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { userChangeHomeBoardsSchema } from "@homarr/validation/user";
import type { Board } from "~/app/[locale]/boards/_types";
import { BoardSelect } from "~/components/board/board-select";
interface ChangeHomeBoardFormProps {
user: RouterOutputs["user"]["getById"];
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
}
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
const t = useI18n();
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"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changeHomeBoard.notification.error.message"),
});
},
});
const form = useZodForm(userChangeHomeBoardsSchema, {
initialValues: {
homeBoardId: user.homeBoardId,
mobileHomeBoardId: user.mobileHomeBoardId,
},
});
const handleSubmit = (values: FormType) => {
mutate({
userId: user.id,
...values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<BoardSelect
label={t("management.page.user.setting.general.item.board.type.general")}
clearable
boards={boardsData}
w="100%"
{...form.getInputProps("homeBoardId")}
/>
<BoardSelect
label={t("management.page.user.setting.general.item.board.type.mobile")}
clearable
boards={boardsData}
w="100%"
{...form.getInputProps("mobileHomeBoardId")}
/>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof userChangeHomeBoardsSchema>;
@@ -1,84 +0,0 @@
"use client";
import { Button, Group, Select, Stack, Switch } from "@mantine/core";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { userChangeSearchPreferencesSchema } from "@homarr/validation/user";
interface ChangeSearchPreferencesFormProps {
user: RouterOutputs["user"]["getById"];
searchEnginesData: { value: string; label: string }[];
}
export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeSearchPreferencesFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeSearchPreferences.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
defaultSearchEngineId: variables.defaultSearchEngineId,
openInNewTab: variables.openInNewTab,
ddgBangsEnabled: variables.ddgBangsEnabled,
});
showSuccessNotification({
message: t("user.action.changeSearchPreferences.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changeSearchPreferences.notification.error.message"),
});
},
});
const form = useZodForm(userChangeSearchPreferencesSchema, {
initialValues: {
defaultSearchEngineId: user.defaultSearchEngineId,
openInNewTab: user.openSearchInNewTab,
ddgBangsEnabled: user.ddgBangs,
},
});
const handleSubmit = (values: FormType) => {
mutate({
userId: user.id,
...values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select
label={t("user.field.defaultSearchEngine.label")}
w="100%"
data={searchEnginesData}
{...form.getInputProps("defaultSearchEngineId")}
/>
<Switch
label={t("user.field.openSearchInNewTab.label")}
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
/>
<Switch
label={t("user.field.ddgBangs.label")}
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
/>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof userChangeSearchPreferencesSchema>;
@@ -1,83 +0,0 @@
"use client";
import { Button, Group, Radio, Stack } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates";
import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { userFirstDayOfWeekSchema } from "@homarr/validation/user";
dayjs.extend(localeData);
interface FirstDayOfWeekProps {
user: RouterOutputs["user"]["getById"];
}
const weekDays = dayjs.weekdays(false);
export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeFirstDayOfWeek.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
firstDayOfWeek: variables.firstDayOfWeek as DayOfWeek,
});
showSuccessNotification({
message: t("user.action.changeFirstDayOfWeek.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changeFirstDayOfWeek.notification.error.message"),
});
},
});
const form = useZodForm(userFirstDayOfWeekSchema, {
initialValues: {
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
},
});
const handleSubmit = (values: FormType) => {
mutate({
id: user.id,
...values,
});
};
const inputProps = form.getInputProps("firstDayOfWeek");
const onChange = inputProps.onChange as (value: number) => void;
const value = (inputProps.value as number).toString();
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Radio.Group {...inputProps} value={value} onChange={(value) => onChange(parseInt(value))}>
<Group mt="xs">
<Radio value="1" label={weekDays[1]} />
<Radio value="6" label={weekDays[6]} />
<Radio value="0" label={weekDays[0]} />
</Group>
</Radio.Group>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof userFirstDayOfWeekSchema>;
@@ -0,0 +1,332 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import {
Button,
Card,
Divider,
Group,
Radio,
Select,
SimpleGrid,
Stack,
Switch,
Text,
TextInput,
Title,
} from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates";
import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData";
import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import {
userChangeHomeBoardsSchema,
userChangeSearchPreferencesSchema,
userEditProfileSchema,
userFirstDayOfWeekSchema,
userPingIconsEnabledSchema,
} from "@homarr/validation/user";
import type { Board } from "~/app/[locale]/boards/_types";
import { BoardSelect } from "~/components/board/board-select";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
dayjs.extend(localeData);
const userGeneralSettingsSchema = z.object({
name: userEditProfileSchema.shape.name,
email: userEditProfileSchema.shape.email,
homeBoardId: userChangeHomeBoardsSchema.shape.homeBoardId,
mobileHomeBoardId: userChangeHomeBoardsSchema.shape.mobileHomeBoardId,
defaultSearchEngineId: userChangeSearchPreferencesSchema.shape.defaultSearchEngineId,
openInNewTab: userChangeSearchPreferencesSchema.shape.openInNewTab,
ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled,
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
});
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
const FIRST_DAY_OPTIONS: { value: DayOfWeek; labelKey: number }[] = [
{ value: 1, labelKey: 1 },
{ value: 6, labelKey: 6 },
{ value: 0, labelKey: 0 },
];
interface UserGeneralSettingsFormProps {
user: RouterOutputs["user"]["getById"];
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
searchEnginesData: { value: string; label: string }[];
showLanguageSelector?: boolean;
}
const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues => ({
name: user.name ?? "",
email: user.email ?? "",
homeBoardId: user.homeBoardId,
mobileHomeBoardId: user.mobileHomeBoardId,
defaultSearchEngineId: user.defaultSearchEngineId,
openInNewTab: user.openSearchInNewTab,
ddgBangsEnabled: user.ddgBangs,
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
pingIconsEnabled: user.pingIconsEnabled,
});
export const UserGeneralSettingsForm = ({
user,
boardsData,
searchEnginesData,
showLanguageSelector = false,
}: UserGeneralSettingsFormProps) => {
const t = useI18n();
const tGeneral = useScopedI18n("management.page.user.setting.general");
const isCredentialsUser = user.provider === "credentials";
const editProfileMutation = clientApi.user.editProfile.useMutation();
const changeHomeBoardsMutation = clientApi.user.changeHomeBoards.useMutation();
const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation();
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
const initialValues = buildInitialValues(user);
const initialValuesRef = useRef(initialValues);
const form = useZodForm(userGeneralSettingsSchema, {
initialValues,
});
const isDirtyRef = useRef(false);
isDirtyRef.current = form.isDirty();
const weekDays = useMemo(() => dayjs.weekdays(false), []);
const mutations = [
editProfileMutation,
changeHomeBoardsMutation,
changeSearchPreferencesMutation,
changeFirstDayOfWeekMutation,
changePingIconsEnabledMutation,
];
const isPending = mutations.some((m) => m.isPending);
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (env.NODE_ENV === "development") return;
if (isDirtyRef.current) e.preventDefault();
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, []);
const handleSubmitAsync = async (values: FormValues) => {
const parsed = userGeneralSettingsSchema.safeParse(values);
if (!parsed.success) return;
const defaults = initialValuesRef.current;
const changed = <K extends keyof FormValues>(...fields: K[]) =>
fields.some((f) => (f === "email" ? (values[f] || null) !== (defaults[f] || null) : values[f] !== defaults[f]));
const saveActions: { when: boolean; action: () => Promise<unknown> }[] = [
{
when: isCredentialsUser && changed("name", "email"),
action: () =>
editProfileMutation.mutateAsync({ id: user.id, name: parsed.data.name, email: parsed.data.email ?? "" }),
},
{
when: changed("homeBoardId", "mobileHomeBoardId"),
action: () =>
changeHomeBoardsMutation.mutateAsync({
userId: user.id,
homeBoardId: parsed.data.homeBoardId,
mobileHomeBoardId: parsed.data.mobileHomeBoardId,
}),
},
{
when: changed("defaultSearchEngineId", "openInNewTab", "ddgBangsEnabled"),
action: () =>
changeSearchPreferencesMutation.mutateAsync({
userId: user.id,
defaultSearchEngineId: parsed.data.defaultSearchEngineId,
openInNewTab: parsed.data.openInNewTab,
ddgBangsEnabled: parsed.data.ddgBangsEnabled,
}),
},
{
when: changed("firstDayOfWeek"),
action: () =>
changeFirstDayOfWeekMutation.mutateAsync({ id: user.id, firstDayOfWeek: parsed.data.firstDayOfWeek }),
},
{
when: changed("pingIconsEnabled"),
action: () =>
changePingIconsEnabledMutation.mutateAsync({ id: user.id, pingIconsEnabled: parsed.data.pingIconsEnabled }),
},
];
const promises = saveActions.filter((s) => s.when).map((s) => s.action());
if (promises.length === 0) return;
try {
await Promise.all(promises);
const newValues = { ...parsed.data, email: parsed.data.email ?? "" };
initialValuesRef.current = newValues;
form.setInitialValues(newValues);
form.resetDirty();
await revalidatePathActionAsync(`/manage/users/${user.id}`);
showSuccessNotification({
title: t("common.notification.update.success"),
message: t("common.notification.update.success"),
});
} catch {
showErrorNotification({
title: t("common.notification.update.error"),
message: t("common.notification.update.error"),
});
}
};
const handleDiscard = () => {
form.setValues(initialValuesRef.current);
form.resetDirty();
};
const firstDayOfWeekInputProps = form.getInputProps("firstDayOfWeek");
const firstDayOfWeekOnChange = firstDayOfWeekInputProps.onChange as (value: number) => void;
const firstDayOfWeekValue = (firstDayOfWeekInputProps.value as number).toString();
return (
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack gap="lg">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" verticalSpacing="lg">
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{t("user.name")}</Title>
{!isCredentialsUser && (
<Text c="dimmed" size="sm">
{t("management.page.user.fieldsDisabledExternalProvider")}
</Text>
)}
</Stack>
<Divider />
<TextInput
disabled={!isCredentialsUser}
label={t("user.field.username.label")}
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
disabled={!isCredentialsUser}
label={t("user.field.email.label")}
{...form.getInputProps("email")}
/>
</Stack>
</Card>
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.board.title")}</Title>
</Stack>
<Divider />
<BoardSelect
label={tGeneral("item.board.type.general")}
clearable
boards={boardsData}
w="100%"
withinPortal
{...form.getInputProps("homeBoardId")}
/>
<BoardSelect
label={tGeneral("item.board.type.mobile")}
clearable
boards={boardsData}
w="100%"
withinPortal
{...form.getInputProps("mobileHomeBoardId")}
/>
</Stack>
</Card>
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.search")}</Title>
</Stack>
<Divider />
<Select
label={t("user.field.defaultSearchEngine.label")}
w="100%"
data={searchEnginesData}
comboboxProps={{ withinPortal: true }}
{...form.getInputProps("defaultSearchEngineId")}
/>
<Switch
label={t("user.field.openSearchInNewTab.label")}
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
/>
<Switch
label={t("user.field.ddgBangs.label")}
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
/>
</Stack>
</Card>
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.language")}</Title>
</Stack>
<Divider />
{showLanguageSelector && <CurrentLanguageCombobox withinPortal />}
<Title order={4}>{tGeneral("item.firstDayOfWeek")}</Title>
<Radio.Group
{...firstDayOfWeekInputProps}
value={firstDayOfWeekValue}
onChange={(nextValue: string) => firstDayOfWeekOnChange(parseInt(nextValue, 10))}
>
<Group mt="xs" wrap="wrap">
{FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => (
<Radio key={dayValue} value={dayValue.toString()} label={weekDays[labelKey]} />
))}
</Group>
</Radio.Group>
<Divider my="xs" />
<Title order={4}>{tGeneral("item.accessibility")}</Title>
<Switch
label={t("user.field.pingIconsEnabled.label")}
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
/>
</Stack>
</Card>
</SimpleGrid>
<div style={{ position: "sticky", bottom: 20 }}>
{form.isDirty() && (
<Card withBorder>
<Group justify="space-between" wrap="wrap">
<Text fw={500}>{t("common.unsavedChanges")}</Text>
<Group>
<Button disabled={isPending} variant="default" onClick={handleDiscard}>
{t("common.action.discard")}
</Button>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Group>
</Card>
)}
</div>
</Stack>
</form>
);
};
@@ -1,69 +0,0 @@
"use client";
import { Button, Group, Stack, Switch } from "@mantine/core";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { userPingIconsEnabledSchema } from "@homarr/validation/user";
interface PingIconsEnabledProps {
user: RouterOutputs["user"]["getById"];
}
export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changePingIconsEnabled.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
pingIconsEnabled: variables.pingIconsEnabled,
});
showSuccessNotification({
message: t("user.action.changePingIconsEnabled.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changePingIconsEnabled.notification.error.message"),
});
},
});
const form = useZodForm(userPingIconsEnabledSchema, {
initialValues: {
pingIconsEnabled: user.pingIconsEnabled,
},
});
const handleSubmit = (values: FormType) => {
mutate({
id: user.id,
...values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Switch
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
label={t("user.field.pingIconsEnabled.label")}
/>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof userPingIconsEnabledSchema>;
@@ -2,7 +2,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core"; import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure, useMediaQuery } from "@mantine/hooks";
import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react"; import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -25,6 +25,7 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
}, },
}); });
const [opened, { toggle }] = useDisclosure(false); const [opened, { toggle }] = useDisclosure(false);
const isMobile = useMediaQuery("(max-width: 48em)");
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const t = useI18n(); const t = useI18n();
const tManageAvatar = useScopedI18n("user.action.manageAvatar"); const tManageAvatar = useScopedI18n("user.action.manageAvatar");
@@ -94,11 +95,11 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
}, [mutate, user.id, openConfirmModal, tManageAvatar]); }, [mutate, user.id, openConfirmModal, tManageAvatar]);
return ( return (
<Box pos="relative"> <Box pos="relative" display="flex" style={{ justifyContent: isMobile ? "center" : undefined }}>
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow> <Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow withinPortal>
<Menu.Target> <Menu.Target>
<UnstyledButton onClick={toggle}> <UnstyledButton onClick={toggle}>
<UserAvatar user={user} size={200} /> <UserAvatar user={user} size={isMobile ? 120 : 200} />
<Button <Button
component="div" component="div"
pos="absolute" pos="absolute"
@@ -1,94 +0,0 @@
"use client";
import { useCallback } from "react";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { userEditProfileSchema } from "@homarr/validation/user";
interface UserProfileFormProps {
user: RouterOutputs["user"]["getById"];
}
export const UserProfileForm = ({ user }: UserProfileFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.editProfile.useMutation({
async onSettled() {
await revalidatePathActionAsync("/manage/users");
},
onSuccess(_, variables) {
// Reset form initial values to reset dirty state
form.setInitialValues({
name: variables.name,
email: variables.email ?? "",
});
showSuccessNotification({
title: t("common.notification.update.success"),
message: t("user.action.editProfile.notification.success.message"),
});
},
onError(error) {
const message =
error.data?.code === "CONFLICT"
? t("user.error.usernameTaken")
: t("user.action.editProfile.notification.error.message");
showErrorNotification({
title: t("common.notification.update.error"),
message,
});
},
});
const form = useZodForm(userEditProfileSchema.omit({ id: true }), {
initialValues: {
name: user.name ?? "",
email: user.email ?? "",
},
});
// Only credentials users can edit their profile
const isProviderCredentials = user.provider === "credentials";
const handleSubmit = useCallback(
(values: FormType) => {
if (!isProviderCredentials) return;
mutate({
...values,
id: user.id,
});
},
[isProviderCredentials, mutate, user.id],
);
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
disabled={!isProviderCredentials}
label={t("user.field.username.label")}
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
disabled={!isProviderCredentials}
label={t("user.field.email.label")}
{...form.getInputProps("email")}
/>
{isProviderCredentials && (
<Group justify="end">
<Button type="submit" disabled={!form.isDirty()} loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
)}
</Stack>
</form>
);
};
type FormType = Omit<RouterInputs["user"]["editProfile"], "id">;
@@ -6,18 +6,13 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"; import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error"; import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access"; import { canAccessUserEditPage } from "../access";
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
import { ChangeSearchPreferencesForm } from "./_components/_change-search-preferences";
import { DeleteUserButton } from "./_components/_delete-user-button"; import { DeleteUserButton } from "./_components/_delete-user-button";
import { FirstDayOfWeek } from "./_components/_first-day-of-week"; import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form"; import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form";
interface Props { interface Props {
params: Promise<{ params: Promise<{
@@ -63,7 +58,7 @@ export default async function EditUserPage(props: Props) {
const boards = await api.board.getAllBoards(); const boards = await api.board.getAllBoards();
const searchEngines = await api.searchEngine.getSelectable(); const searchEngines = await api.searchEngine.getSelectable();
const isSelf = session?.user.id === user.id;
const isCredentialsUser = user.provider === "credentials"; const isCredentialsUser = user.provider === "credentials";
return ( return (
@@ -73,51 +68,25 @@ export default async function EditUserPage(props: Props) {
{t("management.page.user.fieldsDisabledExternalProvider")} {t("management.page.user.fieldsDisabledExternalProvider")}
</Alert> </Alert>
)} )}
<Title>{tGeneral("title")}</Title> <Title>{tGeneral("title")}</Title>
<Group gap="xl"> <Group gap="xl" align="flex-start" wrap="wrap">
<Box flex={1}> <Box flex={1} miw={{ base: "100%", md: 540 }}>
<UserProfileForm user={user} /> <UserGeneralSettingsForm
user={user}
boardsData={boards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
}))}
searchEnginesData={searchEngines}
showLanguageSelector={isSelf}
/>
</Box> </Box>
<Box w={{ base: "100%", lg: 200 }}> <Box w={{ base: "100%", lg: 260 }}>
<UserProfileAvatarForm user={user} /> <UserProfileAvatarForm user={user} />
</Box> </Box>
</Group> </Group>
{session?.user.id === user.id && (
<Stack mb="lg">
<Title order={2}>{tGeneral("item.language")}</Title>
<CurrentLanguageCombobox />
</Stack>
)}
<Stack mb="lg">
<Title order={2}>{tGeneral("item.board.title")}</Title>
<ChangeHomeBoardForm
user={user}
boardsData={boards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
}))}
/>
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.search")}</Title>
<ChangeSearchPreferencesForm user={user} searchEnginesData={searchEngines} />
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
<FirstDayOfWeek user={user} />
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.accessibility")}</Title>
<PingIconsEnabled user={user} />
</Stack>
<DangerZoneRoot> <DangerZoneRoot>
<DangerZoneItem <DangerZoneItem
label={t("user.action.delete.label")} label={t("user.action.delete.label")}
@@ -1,24 +1,22 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo } from "react";
import { Anchor, Button, Group, Text, Title, Tooltip } from "@mantine/core"; import { Anchor, Group, Text, Tooltip } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react"; import { IconCheck } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table"; import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table"; import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui"; import { Link, UserAvatar } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
interface UserListComponentProps { interface UserListComponentProps {
initialUserList: RouterOutputs["user"]["getAll"]; initialUserList: RouterOutputs["user"]["getAll"];
credentialsProviderEnabled: boolean;
} }
export const UserListComponent = ({ initialUserList, credentialsProviderEnabled }: UserListComponentProps) => { export const UserListComponent = ({ initialUserList }: UserListComponentProps) => {
const tUserList = useScopedI18n("management.page.user.list");
const t = useI18n(); const t = useI18n();
const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, { const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, {
initialData: initialUserList, initialData: initialUserList,
@@ -69,21 +67,10 @@ export const UserListComponent = ({ initialUserList, credentialsProviderEnabled
enableFullScreenToggle: false, enableFullScreenToggle: false,
layoutMode: "grid-no-grow", layoutMode: "grid-no-grow",
getRowId: (row) => row.id, getRowId: (row) => row.id,
renderTopToolbarCustomActions: () =>
credentialsProviderEnabled ? (
<Button component={Link} href="/manage/users/create">
{t("management.page.user.create.title")}
</Button>
) : null,
state: { state: {
isLoading, isLoading,
}, },
}); });
return ( return <MantineReactTable table={table} />;
<>
<Title mb="md">{tUserList("title")}</Title>
<MantineReactTable table={table} />
</>
);
}; };
@@ -1,20 +1,7 @@
"use client"; "use client";
import { startTransition, useCallback, useState } from "react"; import { startTransition, useCallback, useState } from "react";
import { import { Badge, Button, Card, Group, Stack, Stepper, Table, Text, TextInput, Title, Tooltip } from "@mantine/core";
Badge,
Button,
Card,
Group,
PasswordInput,
Stack,
Stepper,
Table,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { useListState } from "@mantine/hooks"; import { useListState } from "@mantine/hooks";
import { IconPlus, IconUserCheck } from "@tabler/icons-react"; import { IconPlus, IconUserCheck } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod/v4";
@@ -24,10 +11,11 @@ import type { GroupPermissionKey } from "@homarr/definitions";
import { everyoneGroup, groupPermissions } from "@homarr/definitions"; import { everyoneGroup, groupPermissions } from "@homarr/definitions";
import type { IsValid } from "@homarr/form"; import type { IsValid } from "@homarr/form";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { UserCreatePasswordFields } from "@homarr/forms-collection";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications"; import { showErrorNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; 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 { createCustomErrorParams } from "@homarr/validation/form/i18n";
import { userPasswordSchema } from "@homarr/validation/user"; import { userPasswordSchema } from "@homarr/validation/user";
@@ -149,7 +137,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
color={!generalForm.isValid() ? "red" : undefined} color={!generalForm.isValid() ? "red" : undefined}
> >
<form> <form>
<Card p="xl" shadow="md" withBorder> <Card p="xl">
<Stack gap="md"> <Stack gap="md">
<TextInput <TextInput
label={tUserField("username.label")} label={tUserField("username.label")}
@@ -165,27 +153,19 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
</Stepper.Step> </Stepper.Step>
<Stepper.Step label={t("step.security.label")} allowStepSelect={false} allowStepClick={false}> <Stepper.Step label={t("step.security.label")} allowStepSelect={false} allowStepClick={false}>
<form> <form>
<Card p="xl" shadow="md" withBorder> <Card p="xl">
<Stack gap="md"> <Stack gap="md">
<CustomPasswordInput <UserCreatePasswordFields
withPasswordRequirements
label={tUserField("password.label")}
variant="filled" variant="filled"
withAsterisk passwordInputProps={securityForm.getInputProps("password")}
{...securityForm.getInputProps("password")} confirmPasswordInputProps={securityForm.getInputProps("confirmPassword")}
/>
<PasswordInput
label={tUserField("passwordConfirm.label")}
variant="filled"
withAsterisk
{...securityForm.getInputProps("confirmPassword")}
/> />
</Stack> </Stack>
</Card> </Card>
</form> </form>
</Stepper.Step> </Stepper.Step>
<Stepper.Step label={t("step.groups.label")} allowStepSelect={false} allowStepClick={false}> <Stepper.Step label={t("step.groups.label")} allowStepSelect={false} allowStepClick={false}>
<Card p="xl" shadow="md" withBorder> <Card p="xl">
<GroupsForm <GroupsForm
initialGroups={initialGroups} initialGroups={initialGroups}
addGroup={(groupId) => addGroup={(groupId) =>
@@ -198,20 +178,20 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
</Card> </Card>
</Stepper.Step> </Stepper.Step>
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}> <Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
<Card p="xl" shadow="md" withBorder> <Card p="xl">
<Stack maw={300} align="center" mx="auto"> <Stack maw={300} align="center" mx="auto">
<UserAvatar <UserAvatar
size="xl" size="xl"
user={{ name: generalForm.values.username, email: generalForm.values.email ?? null, image: null }} user={{ name: generalForm.values.username, email: generalForm.values.email ?? null, image: null }}
/> />
<Text tt="uppercase" fw="bolder" size="xl"> <Text fw={700} size="xl">
{generalForm.values.username} {generalForm.values.username}
</Text> </Text>
</Stack> </Stack>
</Card> </Card>
</Stepper.Step> </Stepper.Step>
<Stepper.Completed> <Stepper.Completed>
<Card p="xl" shadow="md" withBorder> <Card p="xl">
<Stack align="center" maw={300} mx="auto"> <Stack align="center" maw={300} mx="auto">
<IconUserCheck size="3rem" /> <IconUserCheck size="3rem" />
<Title order={2}>{t("step.completed.title")}</Title> <Title order={2}>{t("step.completed.title")}</Title>
@@ -25,7 +25,7 @@ export const StepperNavigationComponent = ({
}: StepperNavigationComponentProps) => { }: StepperNavigationComponentProps) => {
const t = useI18n(); const t = useI18n();
return ( return (
<Card shadow="md" withBorder> <Card>
{!isComplete ? ( {!isComplete ? (
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Button <Button
@@ -101,7 +101,7 @@ export const SaveAffix = ({ groupId }: SaveAffixProps) => {
<div style={{ position: "sticky", bottom: 20 }}> <div style={{ position: "sticky", bottom: 20 }}>
<Transition transition="slide-up" mounted={form.isDirty()}> <Transition transition="slide-up" mounted={form.isDirty()}>
{(transitionStyles) => ( {(transitionStyles) => (
<Card style={transitionStyles} withBorder> <Card style={transitionStyles}>
<Group justify="space-between"> <Group justify="space-between">
<Text fw={500}>{tForm("unsavedChanges")}</Text> <Text fw={500}>{tForm("unsavedChanges")}</Text>
<Group> <Group>
@@ -68,7 +68,6 @@ const PermissionCard = async ({ group, isDanger }: PermissionCardProps) => {
return ( return (
<Card <Card
p="md" p="md"
withBorder
style={{ style={{
borderColor: isDanger ? "var(--mantine-color-red-8)" : undefined, borderColor: isDanger ? "var(--mantine-color-red-8)" : undefined,
}} }}
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useCallback, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Group, TextInput } from "@mantine/core"; import { TextInput } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -33,29 +33,20 @@ export const GroupsList = ({ groups }: GroupsListProps) => {
return ( return (
<> <>
<Group justify="space-between"> <TextInput
<TextInput leftSection={<IconSearch size={20} stroke={1.5} />}
leftSection={<IconSearch size={20} stroke={1.5} />} value={search}
value={search} onChange={(event) => setSearch(event.currentTarget.value)}
onChange={(event) => setSearch(event.currentTarget.value)} placeholder={`${t("group.search")}...`}
placeholder={`${t("group.search")}...`} />
style={{ flex: 1 }}
/>
<AddGroup />
</Group>
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} /> <GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
</> </>
); );
}; };
const AddGroup = () => { export const AddGroupButton = () => {
const t = useI18n(); const t = useI18n();
const { openModal } = useModalAction(AddGroupModal); const { openModal } = useModalAction(AddGroupModal);
const handleAddGroup = useCallback(() => { return <MobileAffixButton onClick={() => openModal()}>{t("group.action.create.label")}</MobileAffixButton>;
openModal();
}, [openModal]);
return <MobileAffixButton onClick={handleAddGroup}>{t("group.action.create.label")}</MobileAffixButton>;
}; };
@@ -259,7 +259,7 @@ const SaveAffix = ({ visible, isPending, onDiscard, onSave }: SaveAffixProps) =>
<div style={{ position: "sticky", bottom: 20 }}> <div style={{ position: "sticky", bottom: 20 }}>
<Transition transition="slide-up" mounted={visible}> <Transition transition="slide-up" mounted={visible}>
{(transitionStyles) => ( {(transitionStyles) => (
<Card style={transitionStyles} withBorder> <Card style={transitionStyles}>
<Group justify="space-between"> <Group justify="space-between">
<Text fw={500}>{t("common.unsavedChanges")}</Text> <Text fw={500}>{t("common.unsavedChanges")}</Text>
<Group> <Group>
@@ -1,5 +1,5 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Card, Group, Stack, Text, ThemeIcon, Title, UnstyledButton } from "@mantine/core"; import { Card, Group, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
import { IconChevronRight, IconUsersGroup } from "@tabler/icons-react"; import { IconChevronRight, IconUsersGroup } from "@tabler/icons-react";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
@@ -8,9 +8,8 @@ import { everyoneGroup } from "@homarr/definitions";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { AddGroupButton, GroupsList } from "./_client";
import { GroupsList } from "./_client";
import classes from "./groups.module.css"; import classes from "./groups.module.css";
export default async function GroupsListPage() { export default async function GroupsListPage() {
@@ -26,11 +25,8 @@ export default async function GroupsListPage() {
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup); const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
return ( return (
<ManageContainer size="xl"> <ManagePageLayout title={t("group.title")} primaryAction={<AddGroupButton />} floatingPrimaryAction>
<DynamicBreadcrumb />
<Stack> <Stack>
<Title>{t("group.title")}</Title>
{dbEveryoneGroup && ( {dbEveryoneGroup && (
<UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}> <UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
<Card className={classes.everyoneGroup}> <Card className={classes.everyoneGroup}>
@@ -53,6 +49,6 @@ export default async function GroupsListPage() {
<GroupsList groups={groupsWithoutEveryone} /> <GroupsList groups={groupsWithoutEveryone} />
</Stack> </Stack>
</ManageContainer> </ManagePageLayout>
); );
} }
@@ -0,0 +1,20 @@
"use client";
import { Button } from "@mantine/core";
import { useModalAction } from "@homarr/modals";
import { InviteCreateModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { MANAGE_ACTION_BUTTON_MIN_WIDTH } from "~/components/manage/manage-page.constants";
export const InviteCreateButton = () => {
const t = useScopedI18n("management.page.user.invite");
const { openModal } = useModalAction(InviteCreateModal);
return (
<Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={() => openModal()}>
{t("action.new.title")}
</Button>
);
};
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { ActionIcon, Button, Title } from "@mantine/core"; import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react"; import { IconTrash } from "@tabler/icons-react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
@@ -10,8 +10,7 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { InviteCreateModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
@@ -65,7 +64,6 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps
enableFullScreenToggle: false, enableFullScreenToggle: false,
layoutMode: "grid-no-grow", layoutMode: "grid-no-grow",
getRowId: (row) => row.id, getRowId: (row) => row.id,
renderTopToolbarCustomActions: RenderTopToolbarCustomActions,
state: { state: {
isLoading, isLoading,
}, },
@@ -74,22 +72,7 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps
}, },
}); });
return ( return <MantineReactTable table={table} />;
<>
<Title mb="md">{t("title")}</Title>
<MantineReactTable table={table} />
</>
);
};
const RenderTopToolbarCustomActions = () => {
const t = useScopedI18n("management.page.user.invite");
const { openModal } = useModalAction(InviteCreateModal);
const handleNewInvite = useCallback(() => {
openModal();
}, [openModal]);
return <Button onClick={handleNewInvite}>{t("action.new.title")}</Button>;
}; };
const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => { const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => {
@@ -3,8 +3,11 @@ import { notFound } from "next/navigation";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server"; import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { InviteCreateButton } from "./_components/invite-create-button";
import { InviteListComponent } from "./_components/invite-list"; import { InviteListComponent } from "./_components/invite-list";
export default async function InvitesOverviewPage() { export default async function InvitesOverviewPage() {
@@ -17,11 +20,20 @@ export default async function InvitesOverviewPage() {
return notFound(); return notFound();
} }
const t = await getScopedI18n("management.page.user.invite");
const initialInvites = await api.invite.getAll(); const initialInvites = await api.invite.getAll();
return ( return (
<> <ManagePageLayout
<DynamicBreadcrumb /> title={t("title")}
primaryAction={
<ManageMobilePrimaryAction>
<InviteCreateButton />
</ManageMobilePrimaryAction>
}
floatingPrimaryAction
>
<InviteListComponent initialInvites={initialInvites} /> <InviteListComponent initialInvites={initialInvites} />
</> </ManagePageLayout>
); );
} }
@@ -4,8 +4,10 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server"; import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import { UserListComponent } from "./_components/user-list"; import { UserListComponent } from "./_components/user-list";
@@ -27,13 +29,23 @@ export default async function UsersPage() {
return notFound(); return notFound();
} }
const t = await getScopedI18n("management.page.user");
const userList = await api.user.getAll(); const userList = await api.user.getAll();
const credentialsProviderEnabled = isProviderEnabled("credentials"); const credentialsProviderEnabled = isProviderEnabled("credentials");
return ( return (
<> <ManagePageLayout
<DynamicBreadcrumb /> title={t("list.title")}
<UserListComponent initialUserList={userList} credentialsProviderEnabled={credentialsProviderEnabled} /> primaryAction={
</> credentialsProviderEnabled ? (
<MobileAffixButton component={Link} href="/manage/users/create">
{t("create.title")}
</MobileAffixButton>
) : undefined
}
floatingPrimaryAction={credentialsProviderEnabled}
>
<UserListComponent initialUserList={userList} />
</ManagePageLayout>
); );
} }
@@ -99,7 +99,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
return ( return (
<> <>
<Card withBorder w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}> <Card w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
<QueryErrorResetBoundary> <QueryErrorResetBoundary>
{({ reset }) => ( {({ reset }) => (
<ErrorBoundary <ErrorBoundary
@@ -123,17 +123,35 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
</QueryErrorResetBoundary> </QueryErrorResetBoundary>
</Card> </Card>
<Affix bottom={12} right={72}> <Affix bottom={12} right={72}>
<ActionIcon size={48} variant="default" radius="xl" onClick={handleOpenEditWidgetModal}> <ActionIcon
size={48}
variant="default"
radius="xl"
onClick={handleOpenEditWidgetModal}
aria-label={t("common.action.edit")}
>
<IconPencil size={24} /> <IconPencil size={24} />
</ActionIcon> </ActionIcon>
</Affix> </Affix>
<Affix bottom={12} right={72 + 60}> <Affix bottom={12} right={72 + 60}>
<ActionIcon size={48} variant="default" radius="xl" onClick={toggleEditMode}> <ActionIcon
size={48}
variant="default"
radius="xl"
onClick={toggleEditMode}
aria-label={editMode ? t("widgetPreview.toggle.disabled") : t("widgetPreview.toggle.enabled")}
>
{editMode ? <IconToggleLeft size={24} /> : <IconToggleRight size={24} />} {editMode ? <IconToggleLeft size={24} /> : <IconToggleRight size={24} />}
</ActionIcon> </ActionIcon>
</Affix> </Affix>
<Affix bottom={12} right={72 + 120}> <Affix bottom={12} right={72 + 120}>
<ActionIcon size={48} variant="default" radius="xl" onClick={openDimensionsModal}> <ActionIcon
size={48}
variant="default"
radius="xl"
onClick={openDimensionsModal}
aria-label={t("widgetPreview.dimensions.title")}
>
<IconDimensions size={24} /> <IconDimensions size={24} />
</ActionIcon> </ActionIcon>
</Affix> </Affix>
@@ -42,7 +42,6 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
item.advancedOptions.customCssClasses.join(" "), item.advancedOptions.customCssClasses.join(" "),
)} )}
radius={board.itemRadius} radius={board.itemRadius}
withBorder
styles={{ styles={{
root: { root: {
"--opacity": board.opacity / 100, "--opacity": board.opacity / 100,
@@ -85,6 +85,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
), ),
integrationSupport: "supportedIntegrations" in currentDefinition, integrationSupport: "supportedIntegrations" in currentDefinition,
settings, settings,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
}, },
{ {
title(t) { title(t) {
@@ -105,9 +106,17 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
}; };
return ( return (
<Menu withinPortal withArrow position="right-start" arrowPosition="center"> <Menu withinPortal position="right-start" arrowPosition="center">
<Menu.Target> <Menu.Target>
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={offset} right={offset} style={{ zIndex: 10 }}> <ActionIcon
variant="default"
radius={"xl"}
pos="absolute"
top={offset}
right={offset}
style={{ zIndex: 10 }}
aria-label={tItem("menu.label.settings")}
>
<IconDotsVertical size={"1rem"} /> <IconDotsVertical size={"1rem"} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
@@ -145,7 +145,7 @@ const WidgetItem = ({
</Text> </Text>
</Stack> </Stack>
<SupportedIntegrations mt="auto" integrations={item.supportedIntegrations} /> <SupportedIntegrations mt="auto" integrations={item.supportedIntegrations} />
<Button onClick={onSelect} variant="light" size="xs" radius="md" fullWidth> <Button onClick={onSelect} variant="light" size="xs" fullWidth>
{t(`item.create.addToBoard`)} {t(`item.create.addToBoard`)}
</Button> </Button>
</Stack> </Stack>
@@ -170,7 +170,7 @@ const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps)
return ( return (
<Center mt={mt}> <Center mt={mt}>
<Tooltip.Group openDelay={300} closeDelay={100}> <Tooltip.Group closeDelay={100}>
<Group gap={2}> <Group gap={2}>
{integrations.slice(0, countToShow).map((integration) => ( {integrations.slice(0, countToShow).map((integration) => (
<Tooltip key={integration} label={getIntegrationName(integration)} withArrow> <Tooltip key={integration} label={getIntegrationName(integration)} withArrow>
@@ -45,13 +45,7 @@ export const BoardCategorySection = ({ section }: Props) => {
}); });
return ( return (
<Card <Card style={{ "--opacity": board.opacity / 100 }} radius={board.itemRadius} p={0} className={classes.itemCard}>
style={{ "--opacity": board.opacity / 100 }}
radius={board.itemRadius}
withBorder
p={0}
className={classes.itemCard}
>
<Stack> <Stack>
<Group wrap="nowrap" gap="sm"> <Group wrap="nowrap" gap="sm">
<UnstyledButton w="100%" p="sm" onClick={toggle}> <UnstyledButton w="100%" p="sm" onClick={toggle}>
@@ -62,7 +56,7 @@ export const BoardCategorySection = ({ section }: Props) => {
</UnstyledButton> </UnstyledButton>
<CategoryMenu category={section} /> <CategoryMenu category={section} />
</Group> </Group>
<Collapse in={opened} p="sm" pt={0}> <Collapse expanded={opened} p="sm" pt={0}>
<GridStack section={section} /> <GridStack section={section} />
</Collapse> </Collapse>
</Stack> </Stack>
@@ -30,9 +30,9 @@ export const CategoryMenu = ({ category }: Props) => {
const t = useScopedI18n("section.category"); const t = useScopedI18n("section.category");
return ( return (
<Menu withArrow> <Menu>
<Menu.Target> <Menu.Target>
<ActionIcon mr="sm" variant="transparent"> <ActionIcon mr="sm" variant="transparent" aria-label={t("menu.label.settings")}>
<IconDotsVertical size={20} /> <IconDotsVertical size={20} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
@@ -28,7 +28,6 @@ export const BoardDynamicSection = ({ section }: Props) => {
className={combineClasses(classes.itemCard, section.options.customCssClasses.join(" "))} className={combineClasses(classes.itemCard, section.options.customCssClasses.join(" "))}
w="100%" w="100%"
h="100%" h="100%"
withBorder
styles={{ styles={{
root: { root: {
overflow: "visible", overflow: "visible",
@@ -43,9 +43,17 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
}; };
return ( return (
<Menu withinPortal withArrow position="right-start" arrowPosition="center"> <Menu withinPortal position="right-start" arrowPosition="center">
<Menu.Target> <Menu.Target>
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={4} right={4} style={{ zIndex: 10 }}> <ActionIcon
variant="default"
radius={"xl"}
pos="absolute"
top={4}
right={4}
style={{ zIndex: 10 }}
aria-label={tItem("menu.label.settings")}
>
<IconDotsVertical size={"1rem"} /> <IconDotsVertical size={"1rem"} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
@@ -1,6 +1,6 @@
import type { RefObject } from "react"; import type { Ref, RefObject } from "react";
import { createRef, useCallback, useEffect, useRef } from "react"; import { createRef, useCallback, useEffect, useRef } from "react";
import { useElementSize } from "@mantine/hooks"; import { useElementSize, useMergedRef } from "@mantine/hooks";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
@@ -13,7 +13,7 @@ import { useSectionActions } from "../section-actions";
import { initializeGridstack } from "./init-gridstack"; import { initializeGridstack } from "./init-gridstack";
export interface UseGridstackRefs { export interface UseGridstackRefs {
wrapper: RefObject<HTMLDivElement | null>; wrapper: Ref<HTMLDivElement | null>;
items: RefObject<Record<string, RefObject<GridItemHTMLElement | null>>>; items: RefObject<Record<string, RefObject<GridItemHTMLElement | null>>>;
gridstack: RefObject<GridStack | null>; gridstack: RefObject<GridStack | null>;
} }
@@ -59,8 +59,9 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
const { moveAndResizeItem, moveItemToSection } = useItemActions(); const { moveAndResizeItem, moveItemToSection } = useItemActions();
const { moveAndResizeInnerSection, moveInnerSectionToSection } = useSectionActions(); const { moveAndResizeInnerSection, moveInnerSectionToSection } = useSectionActions();
// define reference for wrapper - is used to calculate the width of the wrapper const wrapperElementRef = useRef<HTMLDivElement | null>(null);
const { ref: wrapperRef, width, height } = useElementSize<HTMLDivElement>(); const { ref: measureWrapperRef, width, height } = useElementSize<HTMLDivElement>();
const wrapperRef = useMergedRef(measureWrapperRef, wrapperElementRef);
// references to the diffrent items contained in the gridstack // references to the diffrent items contained in the gridstack
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement | null>>>({}); const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement | null>>>({});
// reference of the gridstack object for modifications after initialization // reference of the gridstack object for modifications after initialization
@@ -193,7 +194,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
itemIds, itemIds,
refs: { refs: {
items: itemRefs, items: itemRefs,
wrapper: wrapperRef, wrapper: wrapperElementRef,
gridstack: gridRef, gridstack: gridRef,
}, },
sectionColumnCount: columnCount, sectionColumnCount: columnCount,
@@ -276,7 +277,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
useCssVariableConfiguration({ useCssVariableConfiguration({
columnCount, columnCount,
gridRef, gridRef,
wrapperRef, wrapperRef: wrapperElementRef,
width, width,
height, height,
isDynamic: section.kind === "dynamic", isDynamic: section.kind === "dynamic",
@@ -293,7 +294,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
interface UseCssVariableConfiguration { interface UseCssVariableConfiguration {
gridRef: UseGridstackRefs["gridstack"]; gridRef: UseGridstackRefs["gridstack"];
wrapperRef: UseGridstackRefs["wrapper"]; wrapperRef: RefObject<HTMLDivElement | null>;
width: number; width: number;
height: number; height: number;
columnCount: number; columnCount: number;
@@ -6,11 +6,20 @@ import { LanguageCombobox } from "./language-combobox";
interface CurrentLanguageComboboxProps { interface CurrentLanguageComboboxProps {
width?: string; width?: string;
withinPortal?: boolean;
} }
export const CurrentLanguageCombobox = ({ width }: CurrentLanguageComboboxProps) => { export const CurrentLanguageCombobox = ({ width, withinPortal }: CurrentLanguageComboboxProps) => {
const currentLocale = useCurrentLocale(); const currentLocale = useCurrentLocale();
const { changeLocale, isPending } = useChangeLocale(); const { changeLocale, isPending } = useChangeLocale();
return <LanguageCombobox value={currentLocale} onChange={changeLocale} isPending={isPending} width={width} />; return (
<LanguageCombobox
value={currentLocale}
onChange={changeLocale}
isPending={isPending}
width={width}
withinPortal={withinPortal}
/>
);
}; };
@@ -16,9 +16,17 @@ interface LanguageComboboxProps {
onChange: (value: SupportedLanguage) => void; onChange: (value: SupportedLanguage) => void;
isPending?: boolean; isPending?: boolean;
width?: string; width?: string;
withinPortal?: boolean;
} }
export const LanguageCombobox = ({ label, value, onChange, isPending, width }: LanguageComboboxProps) => { export const LanguageCombobox = ({
label,
value,
onChange,
isPending,
width,
withinPortal = false,
}: LanguageComboboxProps) => {
const combobox = useCombobox({ const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(), onDropdownClose: () => combobox.resetSelectedOption(),
}); });
@@ -39,7 +47,7 @@ export const LanguageCombobox = ({ label, value, onChange, isPending, width }: L
}, [combobox]); }, [combobox]);
return ( return (
<Combobox store={combobox} onOptionSubmit={handleOnOptionSubmit}> <Combobox store={combobox} withinPortal={withinPortal} onOptionSubmit={handleOnOptionSubmit}>
<Combobox.Target> <Combobox.Target>
<InputBase <InputBase
component="button" component="button"
@@ -12,16 +12,7 @@ export const CommonNavLink = (props: ClientNavigationLink) =>
const NavLinkHref = (props: NavigationLinkHref) => { const NavLinkHref = (props: NavigationLinkHref) => {
const pathname = usePathname(); const pathname = usePathname();
return props.external ? ( return props.external ? (
<NavLink <NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
component="a"
label={props.label}
leftSection={props.icon}
href={props.href}
style={{
borderRadius: 5,
}}
target="_blank"
/>
) : ( ) : (
<NavLink <NavLink
component={Link} component={Link}
@@ -29,9 +20,6 @@ const NavLinkHref = (props: NavigationLinkHref) => {
leftSection={props.icon} leftSection={props.icon}
href={props.href} href={props.href}
active={pathname === props.href} active={pathname === props.href}
style={{
borderRadius: 5,
}}
/> />
); );
}; };
@@ -40,14 +28,7 @@ const NavLinkWithItems = (props: NavigationLinkWithItems) => {
const pathname = usePathname(); const pathname = usePathname();
const isActive = props.items.some((item) => item.href === pathname); const isActive = props.items.some((item) => item.href === pathname);
return ( return (
<NavLink <NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
style={{
borderRadius: 5,
}}
label={props.label}
leftSection={props.icon}
defaultOpened={isActive}
>
{props.items.map((item) => ( {props.items.map((item) => (
<NavLinkHref key={item.label} {...item} /> <NavLinkHref key={item.label} {...item} />
))} ))}
@@ -15,7 +15,7 @@ export const DangerZoneRoot = async ({ children }: DangerZoneRootProps) => {
<Title c="red.8" order={2}> <Title c="red.8" order={2}>
{t("common.dangerZone")} {t("common.dangerZone")}
</Title> </Title>
<Card withBorder style={{ borderColor: "var(--mantine-color-red-8)", borderWidth: 3 }}> <Card style={{ borderColor: "var(--mantine-color-red-8)", borderWidth: 3 }}>
<Stack gap="sm"> <Stack gap="sm">
{Array.isArray(children) {Array.isArray(children)
? children.map((child, index) => ( ? children.map((child, index) => (
@@ -0,0 +1,15 @@
import type { PropsWithChildren } from "react";
import { Affix, Box } from "@mantine/core";
import { MANAGE_ACTION_BUTTON_MIN_WIDTH, MANAGE_AFFIX_POSITION } from "./manage-page.constants";
export const ManageMobilePrimaryAction = ({ children }: PropsWithChildren) => (
<>
<Box visibleFrom="md" miw={MANAGE_ACTION_BUTTON_MIN_WIDTH}>
{children}
</Box>
<Affix hiddenFrom="md" position={MANAGE_AFFIX_POSITION}>
{children}
</Affix>
</>
);
@@ -0,0 +1,44 @@
import type { ReactNode } from "react";
import type { MantineSize } from "@mantine/core";
import { Group, Stack, Title } from "@mantine/core";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { ManageContainer } from "./manage-container";
import { MANAGE_FLOATING_ACTION_BOTTOM_OFFSET } from "./manage-page.constants";
interface ManagePageLayoutProps {
title: ReactNode;
primaryAction?: ReactNode;
toolbar?: ReactNode;
footer?: ReactNode;
floatingPrimaryAction?: boolean;
size?: MantineSize;
children: ReactNode;
}
export const ManagePageLayout = ({
title,
primaryAction,
toolbar,
footer,
floatingPrimaryAction,
size,
children,
}: ManagePageLayoutProps) => {
const titleNode = typeof title === "string" ? <Title>{title}</Title> : title;
return (
<ManageContainer size={size}>
<DynamicBreadcrumb />
<Stack pb={floatingPrimaryAction ? { base: MANAGE_FLOATING_ACTION_BOTTOM_OFFSET, md: 0 } : undefined}>
<Group justify="space-between" align="center">
{titleNode}
{primaryAction}
</Group>
{toolbar}
{children}
{footer && <Group justify="end">{footer}</Group>}
</Stack>
</ManageContainer>
);
};
@@ -0,0 +1,5 @@
export const MANAGE_AFFIX_POSITION = { bottom: 20, right: 20 } as const;
export const MANAGE_FLOATING_ACTION_BOTTOM_OFFSET = 48;
export const MANAGE_ACTION_BUTTON_MIN_WIDTH = 160;
@@ -2,13 +2,15 @@ import { forwardRef } from "react";
import type { ButtonProps } from "@mantine/core"; import type { ButtonProps } from "@mantine/core";
import { Affix, Button, createPolymorphicComponent } from "@mantine/core"; import { Affix, Button, createPolymorphicComponent } from "@mantine/core";
import { MANAGE_ACTION_BUTTON_MIN_WIDTH, MANAGE_AFFIX_POSITION } from "./manage-page.constants";
type MobileAffixButtonProps = Omit<ButtonProps, "visibleFrom" | "hiddenFrom">; type MobileAffixButtonProps = Omit<ButtonProps, "visibleFrom" | "hiddenFrom">;
export const MobileAffixButton = createPolymorphicComponent<"button", MobileAffixButtonProps>( export const MobileAffixButton = createPolymorphicComponent<"button", MobileAffixButtonProps>(
forwardRef<HTMLButtonElement, MobileAffixButtonProps>((props, ref) => ( forwardRef<HTMLButtonElement, MobileAffixButtonProps>((props, ref) => (
<> <>
<Button ref={ref} visibleFrom="md" {...props} /> <Button ref={ref} visibleFrom="md" miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} {...props} />
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}> <Affix hiddenFrom="md" position={MANAGE_AFFIX_POSITION}>
<Button ref={ref} {...props} /> <Button ref={ref} {...props} />
</Affix> </Affix>
</> </>
+2 -2
View File
@@ -14,13 +14,13 @@ interface NoResultsProps {
export const NoResults = ({ icon: Icon, title, action }: NoResultsProps) => { export const NoResults = ({ icon: Icon, title, action }: NoResultsProps) => {
return ( return (
<Card withBorder bg="transparent"> <Card bg="transparent" withBorder={false}>
<Stack align="center" gap="sm"> <Stack align="center" gap="sm">
<Icon size="2rem" /> <Icon size="2rem" />
<Text fw={500} size="lg"> <Text fw={500} size="lg">
{title} {title}
</Text> </Text>
{!action?.hidden && <Anchor href={action?.href}>{action?.label}</Anchor>} {action && !action.hidden && <Anchor href={action.href}>{action.label}</Anchor>}
</Stack> </Stack>
</Card> </Card>
); );
@@ -52,7 +52,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
return ( return (
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board // We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withArrow withinPortal keepMounted> <Menu width={300} withinPortal keepMounted>
<Menu.Dropdown> <Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} /> <AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}> <Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
+1 -1
View File
@@ -12,7 +12,7 @@
"scripts": { "scripts": {
"clean": "rm -rf .turbo node_modules", "clean": "rm -rf .turbo node_modules",
"dev": "pnpm with-env tsx ./src/main.ts", "dev": "pnpm with-env tsx ./src/main.ts",
"format": "oxfmt --check .", "format": "oxfmt .",
"lint": "oxlint", "lint": "oxlint",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --" "with-env": "dotenv -e ../../.env --"
+1 -1
View File
@@ -12,7 +12,7 @@
"scripts": { "scripts": {
"clean": "rm -rf .turbo node_modules", "clean": "rm -rf .turbo node_modules",
"dev": "pnpm with-env tsx ./src/main.ts", "dev": "pnpm with-env tsx ./src/main.ts",
"format": "oxfmt --check .", "format": "oxfmt .",
"lint": "oxlint", "lint": "oxlint",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --" "with-env": "dotenv -e ../../.env --"
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.15.0-trixie AS base FROM node:24.16.0-trixie AS base
WORKDIR /app WORKDIR /app
COPY package.json . COPY package.json .
COPY pnpm-lock.yaml . COPY pnpm-lock.yaml .
+3 -3
View File
@@ -5,10 +5,10 @@
"better-sqlite3": "^12.10.0" "better-sqlite3": "^12.10.0"
}, },
"engines": { "engines": {
"node": ">=24.15.0", "node": ">=24.16.0",
"pnpm": ">=10.33.4" "pnpm": ">=10.34.1"
}, },
"packageManager": "pnpm@10.33.4", "packageManager": "pnpm@10.34.1",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"better-sqlite3" "better-sqlite3"
+1 -1
View File
@@ -45,7 +45,7 @@ describe("LLDAP authorization", () => {
// Act // Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/auth/login`); await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/auth/login`);
await page.getByLabel("Username").fill(defaultCredentials.username); await page.getByLabel("Username").fill(defaultCredentials.username);
await page.getByLabel("Password").fill(defaultCredentials.password); await page.locator("#password").fill(defaultCredentials.password);
await page.locator("css=button[type='submit']").click(); await page.locator("css=button[type='submit']").click();
// Assert // Assert
+9 -9
View File
@@ -17,8 +17,8 @@
"db:studio": "pnpm -F db studio", "db:studio": "pnpm -F db studio",
"dev": "pnpm -F nextjs dev", "dev": "pnpm -F nextjs dev",
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up", "docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
"format": "turbo format --continue", "format": "turbo format --continue -- --check",
"format:fix": "turbo format --continue -- --write", "format:fix": "turbo format --continue",
"lint": "turbo lint --continue", "lint": "turbo lint --continue",
"lint:fix": "turbo lint --continue -- --fix", "lint:fix": "turbo lint --continue -- --fix",
"lint:ws": "pnpm dlx sherif@latest", "lint:ws": "pnpm dlx sherif@latest",
@@ -59,10 +59,10 @@
"vitest": "catalog:" "vitest": "catalog:"
}, },
"engines": { "engines": {
"node": ">=24.15.0", "node": ">=24.16.0",
"pnpm": ">=10.33.4" "pnpm": ">=10.34.1"
}, },
"packageManager": "pnpm@10.33.4", "packageManager": "pnpm@10.34.1",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@tree-sitter-grammars/tree-sitter-yaml", "@tree-sitter-grammars/tree-sitter-yaml",
@@ -76,14 +76,14 @@
"tree-sitter-json" "tree-sitter-json"
], ],
"overrides": { "overrides": {
"@babel/helpers@<7.26.10": ">=7.29.2", "@babel/helpers@<7.26.10": ">=7.29.7",
"@babel/runtime@<7.26.10": ">=7.29.2", "@babel/runtime@<7.26.10": ">=7.29.7",
"axios@>=1.0.0 <1.8.2": ">=1.16.1", "axios@>=1.0.0 <1.8.2": ">=1.16.1",
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6", "brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6", "brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
"esbuild@<=0.24.2": ">=0.28.0", "esbuild@<=0.24.2": ">=0.28.0",
"form-data@>=4.0.0 <4.0.4": ">=4.0.5", "form-data@>=4.0.0 <4.0.4": ">=4.0.5",
"hono@<4.6.5": ">=4.12.21", "hono@<4.6.5": ">=4.12.23",
"linkifyjs@<4.3.2": ">=4.3.3", "linkifyjs@<4.3.2": ">=4.3.3",
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11", "nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
"prismjs@<1.30.0": ">=1.30.0", "prismjs@<1.30.0": ">=1.30.0",
@@ -93,7 +93,7 @@
"sha.js@<=2.4.11": ">=2.4.12", "sha.js@<=2.4.11": ">=2.4.12",
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2", "tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
"tar-fs@>=2.0.0 <2.1.3": ">=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" "vite@>=5.0.0 <=5.4.18": ">=8.0.14"
}, },
"patchedDependencies": { "patchedDependencies": {
+1 -1
View File
@@ -16,7 +16,7 @@
}, },
"scripts": { "scripts": {
"clean": "rm -rf .turbo node_modules", "clean": "rm -rf .turbo node_modules",
"format": "oxfmt --check .", "format": "oxfmt .",
"lint": "oxlint", "lint": "oxlint",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
+1 -1
View File
@@ -16,7 +16,7 @@
}, },
"scripts": { "scripts": {
"clean": "rm -rf .turbo node_modules", "clean": "rm -rf .turbo node_modules",
"format": "oxfmt --check .", "format": "oxfmt .",
"lint": "oxlint", "lint": "oxlint",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
@@ -1,6 +1,8 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { createId, objectEntries } from "@homarr/common"; import { createId, objectEntries } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -14,11 +16,9 @@ import {
integrations, integrations,
integrationSecrets, integrationSecrets,
integrationUserPermissions, integrationUserPermissions,
searchEngines,
} from "@homarr/db/schema"; } from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationSecretKind } from "@homarr/definitions";
import { import {
getIconUrl,
getIntegrationKindsByCategory, getIntegrationKindsByCategory,
getPermissionsWithParents, getPermissionsWithParents,
integrationCategories, integrationCategories,
@@ -33,6 +33,7 @@ import {
integrationSavePermissionsSchema, integrationSavePermissionsSchema,
integrationUpdateSchema, integrationUpdateSchema,
} from "@homarr/validation/integration"; } from "@homarr/validation/integration";
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; 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"; import { mapTestConnectionError } from "./map-test-connection-error";
const logger = createLogger({ module: "integrationRouter" }); const logger = createLogger({ module: "integrationRouter" });
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
export const integrationRouter = createTRPCRouter({ export const integrationRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => { all: protectedProcedure.query(async ({ ctx }) => {
@@ -80,7 +82,7 @@ export const integrationRouter = createTRPCRouter({
}, },
}; };
}) })
.sort( .toSorted(
(integrationA, integrationB) => (integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
); );
@@ -128,7 +130,7 @@ export const integrationRouter = createTRPCRouter({
}, },
}; };
}) })
.sort( .toSorted(
(integrationA, integrationB) => (integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
); );
@@ -179,7 +181,7 @@ export const integrationRouter = createTRPCRouter({
}, },
}; };
}) })
.sort( .toSorted(
(integrationA, integrationB) => (integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
); );
@@ -314,20 +316,7 @@ export const integrationRouter = createTRPCRouter({
url: input.url, url: input.url,
}); });
if ( // Media request search is a first-class Spotlight action. Do not create a search engine for it.
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),
});
}
}), }),
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => { update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -495,7 +484,7 @@ export const integrationRouter = createTRPCRouter({
}); });
return { return {
inherited: dbGroupPermissions.sort((permissionA, permissionB) => { inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name); return permissionA.group.name.localeCompare(permissionB.group.name);
}), }),
users: userPermissions users: userPermissions
@@ -503,7 +492,7 @@ export const integrationRouter = createTRPCRouter({
user, user,
permission, permission,
})) }))
.sort((permissionA, permissionB) => { .toSorted((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? ""); return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
}), }),
groups: dbGroupIntegrationPermission groups: dbGroupIntegrationPermission
@@ -514,7 +503,7 @@ export const integrationRouter = createTRPCRouter({
}, },
permission, permission,
})) }))
.sort((permissionA, permissionB) => { .toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name); return permissionA.group.name.localeCompare(permissionB.group.name);
}), }),
}; };
@@ -618,8 +607,149 @@ export const integrationRouter = createTRPCRouter({
const integrationInstance = await createIntegrationAsync(ctx.integration); const integrationInstance = await createIntegrationAsync(ctx.integration);
return await integrationInstance.searchAsync(encodeURI(input.query)); 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<typeof hasQueryAccessToIntegrationsAsync>[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 { interface UpdateSecretInput {
integrationId: string; integrationId: string;
value: string; value: string;
@@ -640,35 +770,6 @@ interface AddSecretInput {
kind: IntegrationSecretKind; 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) => { const addSecretAsync = async (db: Database, input: AddSecretInput) => {
await db.insert(integrationSecrets).values({ await db.insert(integrationSecrets).values({
kind: input.kind, kind: input.kind,
@@ -6,12 +6,9 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { asc, eq, like } from "@homarr/db"; import { asc, eq, like } from "@homarr/db";
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries"; import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema"; import { searchEngines, users } from "@homarr/db/schema";
import { createIntegrationAsync } from "@homarr/integrations";
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common"; import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine"; 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"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "searchEngineRouter" }); const logger = createLogger({ module: "searchEngineRouter" });
@@ -148,20 +145,6 @@ export const searchEngineRouter = createTRPCRouter({
limit: input.limit, 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 create: permissionRequiredProcedure
.requiresPermission("search-engine-create") .requiresPermission("search-engine-create")
.input(searchEngineManageSchema) .input(searchEngineManageSchema)
@@ -4,7 +4,7 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth"; import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common"; import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server"; 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 { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions"; 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", () => { describe("byId should return an integration by id", () => {
test("with full access should return an integration by id", async () => { test("with full access should return an integration by id", async () => {
const db = createDb(); const db = createDb();
@@ -208,7 +329,7 @@ describe("create should create a new integration", () => {
expect(dbSecret!.updatedAt).toEqual(fakeNow); 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 db = createDb();
const caller = integrationRouter.createCaller({ const caller = integrationRouter.createCaller({
db, db,
@@ -243,12 +364,7 @@ describe("create should create a new integration", () => {
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/); expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow); expect(dbSecret!.updatedAt).toEqual(fakeNow);
expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id); expect(dbSearchEngine).toBeUndefined();
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",
);
}); });
test("with create integration access should create a new integration with new linked app", async () => { test("with create integration access should create a new integration with new linked app", async () => {
+50 -8
View File
@@ -81,13 +81,7 @@ describe("initUser should initialize the first user", () => {
await expect(actAsync()).rejects.toThrow("passwordsDoNotMatch"); await expect(actAsync()).rejects.toThrow("passwordsDoNotMatch");
}); });
it.each([ it.each([["aB2%"], ["short"]])("should reject passwords shorter than 8 characters for '%s'", async (password) => {
["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) => {
const db = createDb(); const db = createDb();
await createOnboardingStepAsync(db, "user"); await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({ const caller = userRouter.createCaller({
@@ -103,7 +97,55 @@ describe("initUser should initialize the first user", () => {
confirmPassword: password, 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();
}); });
}); });
+2 -2
View File
@@ -542,7 +542,7 @@ export const userRouter = createTRPCRouter({
.set({ .set({
pingIconsEnabled: input.pingIconsEnabled, pingIconsEnabled: input.pingIconsEnabled,
}) })
.where(eq(users.id, ctx.session.user.id)); .where(eq(users.id, input.id));
}), }),
changeDdgBangs: protectedProcedure changeDdgBangs: protectedProcedure
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema))) .input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
@@ -603,7 +603,7 @@ export const userRouter = createTRPCRouter({
.set({ .set({
firstDayOfWeek: input.firstDayOfWeek, firstDayOfWeek: input.firstDayOfWeek,
}) })
.where(eq(users.id, ctx.session.user.id)); .where(eq(users.id, input.id));
}), }),
}); });
Loaded 100 of 265 files, more files were not shown because too many files have changed in this diff. Show more