Compare commits

...
Author SHA1 Message Date
Thomas dc45d1e0de Merge branch 'dev' into fix/credentials-login-session 2026-06-03 10:32:44 +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
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 5c0fd755cc Merge branch 'dev' into fix/credentials-login-session 2026-05-29 21:28:41 +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
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
Thomas 9bf6d9851c Merge branch 'dev' into fix/credentials-login-session 2026-05-29 11:17:53 +02:00
Thomas Camlong f1ed293b79 fix: address PR review feedback on password suggestions
Remove redundant tests, rename requirement translations to suggestion for clearer i18n, simplify password input state and requirement display.
2026-05-29 11:16:07 +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
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 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 0df64f05e5 fix: remove auth session and env schema changes, keep only password requirement relaxation 2026-05-25 11:30:32 +02:00
Thomas Camlong d2ce64252c fix: revert unrelated changes (Dockerfile, Redis null-safety) 2026-05-25 10:09:55 +02:00
Thomas Camlong 63d50ed5fb fix(ci): resolve format, lint and typecheck failures 2026-05-21 16:50:28 +02:00
Thomas Camlong 27325326da fix(auth): resolve credentials login SQLITE_CONSTRAINT_NOTNULL and relax password rules
Session creation failed with NOT NULL constraint on expires column due to
invalid date propagation from duration parsing. Override adapter.createSession
with validated insert, harden signIn callback, fix createDurationSchema for
numeric-only values, and guard events.ts group insert.

Also relaxes password complexity to suggestions-only (min 8 chars enforced),
extracts shared UserCreatePasswordFields component, and fixes login form
passing extra fields to signIn.
2026-05-21 16:30:07 +02:00
229 changed files with 2961 additions and 2254 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">
@@ -226,7 +226,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,
@@ -105,9 +105,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"
}, },
+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();
}); });
}); });
+3
View File
@@ -40,6 +40,9 @@ export const env = createEnv({
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(), AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false), AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false),
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false), AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false),
AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD: z
.enum(["client_secret_basic", "client_secret_post", "client_secret_jwt", "none"])
.default("client_secret_basic"),
} }
: {}), : {}),
...(authProviders.includes("ldap") ...(authProviders.includes("ldap")
+1 -1
View File
@@ -18,7 +18,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"
}, },
@@ -14,6 +14,9 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
type: "oidc", type: "oidc",
clientId: env.AUTH_OIDC_CLIENT_ID, clientId: env.AUTH_OIDC_CLIENT_ID,
clientSecret: env.AUTH_OIDC_CLIENT_SECRET, clientSecret: env.AUTH_OIDC_CLIENT_SECRET,
client: {
token_endpoint_auth_method: env.AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD,
},
issuer: env.AUTH_OIDC_ISSUER, issuer: env.AUTH_OIDC_ISSUER,
allowDangerousEmailAccountLinking: env.AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING, allowDangerousEmailAccountLinking: env.AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING,
authorization: { authorization: {
+1 -1
View File
@@ -18,7 +18,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"
}, },
Loaded 100 of 229 files, more files were not shown because too many files have changed in this diff. Show more