chore(release): automatic release v1.7.0

This commit is contained in:
homarr-releases[bot]
2025-02-21 19:12:41 +00:00
committed by GitHub
180 changed files with 15732 additions and 1574 deletions

View File

@@ -12,6 +12,8 @@ AUTH_SECRET="supersecret"
# or starting the project without any (which will show a randomly generated one). # or starting the project without any (which will show a randomly generated one).
SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
LOG_LEVEL='info'
# This is how you can use the sqlite driver: # This is how you can use the sqlite driver:
DB_DRIVER='better-sqlite3' DB_DRIVER='better-sqlite3'
DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE' DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'

View File

@@ -31,6 +31,7 @@ body:
label: Version label: Version
description: What version of Homarr are you running? description: What version of Homarr are you running?
options: options:
- 1.6.0
- 1.5.0 - 1.5.0
- 1.4.0 - 1.4.0
- 1.3.1 - 1.3.1

View File

@@ -6,7 +6,7 @@
**Thank you for your contribution. Please ensure that your pull request meets the following pull request:** **Thank you for your contribution. Please ensure that your pull request meets the following pull request:**
- [ ] Builds without warnings or errors (``pnpm buid``, autofix with ``pnpm format:fix``) - [ ] Builds without warnings or errors (``pnpm build``, autofix with ``pnpm format:fix``)
- [ ] Pull request targets ``dev`` branch - [ ] Pull request targets ``dev`` branch
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/) - [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
- [ ] No shorthand variable names are used (eg. ``x``, ``y``, ``i`` or any abbrevation) - [ ] No shorthand variable names are used (eg. ``x``, ``y``, ``i`` or any abbrevation)

View File

@@ -96,10 +96,11 @@ jobs:
run: | run: |
git config user.name "Releases Homarr" git config user.name "Releases Homarr"
git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com" git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com"
git remote set-url origin https://x-access-token:${{ steps.obtainToken.outputs.token }}@github.com/${{ github.repository }}.git
git fetch origin dev git fetch origin dev
git checkout dev git checkout dev
git pull origin dev git pull origin dev
git merge ${{ github.ref_name }} git rebase ${{ github.ref_name }}
git push origin dev git push origin dev
deploy: deploy:
name: Deploy docker image name: Deploy docker image

View File

@@ -2,14 +2,13 @@
import "@homarr/auth/env"; import "@homarr/auth/env";
import "@homarr/db/env"; import "@homarr/db/env";
import "@homarr/common/env"; import "@homarr/common/env";
import "@homarr/log/env";
import "@homarr/docker/env"; import "@homarr/docker/env";
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import MillionLint from "@million/lint"; import MillionLint from "@million/lint";
import createNextIntlPlugin from "next-intl/plugin"; import createNextIntlPlugin from "next-intl/plugin";
import "./src/env.ts";
// Package path does not work... so we need to use relative path // Package path does not work... so we need to use relative path
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts"); const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");

View File

@@ -15,6 +15,10 @@
}, },
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@homarr/analytics": "workspace:^0.1.0", "@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0", "@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0",
@@ -26,6 +30,7 @@
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0", "@homarr/docker": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0",
"@homarr/forms-collection": "workspace:^0.1.0",
"@homarr/gridstack": "^1.12.0", "@homarr/gridstack": "^1.12.0",
"@homarr/icons": "workspace:^0.1.0", "@homarr/icons": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0",
@@ -43,18 +48,17 @@
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.16.3", "@mantine/colors-generator": "^7.17.0",
"@mantine/core": "^7.16.3", "@mantine/core": "^7.17.0",
"@mantine/dropzone": "^7.16.3", "@mantine/dropzone": "^7.17.0",
"@mantine/hooks": "^7.16.3", "@mantine/hooks": "^7.17.0",
"@mantine/modals": "^7.16.3", "@mantine/modals": "^7.17.0",
"@mantine/tiptap": "^7.16.3", "@mantine/tiptap": "^7.17.0",
"@million/lint": "1.0.14", "@million/lint": "1.0.14",
"@t3-oss/env-nextjs": "^0.12.0",
"@tabler/icons-react": "^3.30.0", "@tabler/icons-react": "^3.30.0",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.9",
"@tanstack/react-query-devtools": "^5.66.0", "@tanstack/react-query-devtools": "^5.66.9",
"@tanstack/react-query-next-experimental": "^5.66.0", "@tanstack/react-query-next-experimental": "^5.66.9",
"@trpc/client": "next", "@trpc/client": "next",
"@trpc/next": "next", "@trpc/next": "next",
"@trpc/react-query": "next", "@trpc/react-query": "next",
@@ -68,8 +72,8 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"flag-icons": "^7.3.2", "flag-icons": "^7.3.2",
"glob": "^11.0.1", "glob": "^11.0.1",
"jotai": "^2.12.0", "jotai": "^2.12.1",
"mantine-react-table": "2.0.0-beta.8", "mantine-react-table": "2.0.0-beta.9",
"next": "15.1.7", "next": "15.1.7",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
@@ -79,7 +83,7 @@
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
"sass": "^1.85.0", "sass": "^1.85.0",
"superjson": "2.2.2", "superjson": "2.2.2",
"swagger-ui-react": "^5.18.3", "swagger-ui-react": "^5.19.0",
"use-deep-compare-effect": "^1.8.1", "use-deep-compare-effect": "^1.8.1",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
@@ -90,13 +94,13 @@
"@types/chroma-js": "3.1.1", "@types/chroma-js": "3.1.1",
"@types/node": "^22.13.4", "@types/node": "^22.13.4",
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.5",
"@types/react": "19.0.8", "@types/react": "19.0.10",
"@types/react-dom": "19.0.3", "@types/react-dom": "19.0.4",
"@types/swagger-ui-react": "^5.18.0", "@types/swagger-ui-react": "^5.18.0",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"eslint": "^9.20.1", "eslint": "^9.20.1",
"node-loader": "^2.1.0", "node-loader": "^2.1.0",
"prettier": "^3.4.2", "prettier": "^3.5.1",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }

View File

@@ -20,8 +20,7 @@ import type { SuperJSONResult } from "superjson";
import type { AppRouter } from "@homarr/api"; import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { env } from "~/env";
const getWebSocketProtocol = () => { const getWebSocketProtocol = () => {
// window is not defined on server side // window is not defined on server side
@@ -66,7 +65,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
links: [ links: [
loggerLink({ loggerLink({
enabled: (opts) => enabled: (opts) =>
process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error), env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
}), }),
splitLink({ splitLink({
condition: ({ type }) => type === "subscription", condition: ({ type }) => type === "subscription",

View File

@@ -23,6 +23,7 @@ import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
@@ -33,7 +34,6 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
import { env } from "~/env";
export const BoardContentHeaderActions = () => { export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();

View File

@@ -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 { createTheme, darken, lighten, MantineProvider } from "@mantine/core"; import { colorsTuple, createTheme, darken, lighten, MantineProvider } 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";
@@ -20,6 +20,7 @@ export const BoardMantineProvider = ({
colors: { colors: {
primaryColor: generateColors(board.primaryColor), primaryColor: generateColors(board.primaryColor),
secondaryColor: generateColors(board.secondaryColor), secondaryColor: generateColors(board.secondaryColor),
iconColor: board.iconColor ? generateColors(board.iconColor) : colorsTuple("#000000"),
}, },
primaryColor: "primaryColor", primaryColor: "primaryColor",
autoContrast: true, autoContrast: true,

View File

@@ -10,6 +10,7 @@ import {
Group, Group,
InputWrapper, InputWrapper,
isLightColor, isLightColor,
Select,
Slider, Slider,
Stack, Stack,
Text, Text,
@@ -39,6 +40,8 @@ export const ColorSettingsContent = ({ board }: Props) => {
primaryColor: board.primaryColor, primaryColor: board.primaryColor,
secondaryColor: board.secondaryColor, secondaryColor: board.secondaryColor,
opacity: board.opacity, opacity: board.opacity,
iconColor: board.iconColor ?? "",
itemRadius: board.itemRadius,
}, },
}); });
const [showPreview, { toggle }] = useDisclosure(false); const [showPreview, { toggle }] = useDisclosure(false);
@@ -98,6 +101,26 @@ export const ColorSettingsContent = ({ board }: Props) => {
/> />
</InputWrapper> </InputWrapper>
</Grid.Col> </Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<ColorInput
label={t("board.field.iconColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
{...form.getInputProps("iconColor")}
/>
<Select
label={t("board.field.itemRadius.label")}
description={t("board.field.itemRadius.description")}
data={[
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
{ label: t("board.field.itemRadius.option.md"), value: "md" },
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
]}
{...form.getInputProps("itemRadius")}
/>
</Grid.Col>
</Grid> </Grid>
<Group justify="end"> <Group justify="end">
<Button type="submit" loading={isPending} color="teal"> <Button type="submit" loading={isPending} color="teal">

View File

@@ -23,10 +23,10 @@ import type { TablerIcon } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion"; import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
import { ColorSettingsContent } from "./_appereance";
import { BackgroundSettingsContent } from "./_background"; import { BackgroundSettingsContent } from "./_background";
import { BehaviorSettingsContent } from "./_behavior"; import { BehaviorSettingsContent } from "./_behavior";
import { BoardAccessSettings } from "./_board-access"; import { BoardAccessSettings } from "./_board-access";
import { ColorSettingsContent } from "./_colors";
import { CustomCssSettingsContent } from "./_customCss"; import { CustomCssSettingsContent } from "./_customCss";
import { DangerZoneSettingsContent } from "./_danger"; import { DangerZoneSettingsContent } from "./_danger";
import { GeneralSettingsContent } from "./_general"; import { GeneralSettingsContent } from "./_general";
@@ -91,7 +91,7 @@ export default async function BoardSettingsPage(props: Props) {
<AccordionItemFor value="background" icon={IconPhoto}> <AccordionItemFor value="background" icon={IconPhoto}>
<BackgroundSettingsContent board={board} /> <BackgroundSettingsContent board={board} />
</AccordionItemFor> </AccordionItemFor>
<AccordionItemFor value="color" icon={IconBrush}> <AccordionItemFor value="appearance" icon={IconBrush}>
<ColorSettingsContent board={board} /> <ColorSettingsContent board={board} />
</AccordionItemFor> </AccordionItemFor>
<AccordionItemFor value="customCss" icon={IconFileTypeCss}> <AccordionItemFor value="customCss" icon={IconFileTypeCss}>

View File

@@ -1,8 +1,9 @@
import type { JSX, PropsWithChildren } from "react"; import type { JSX, PropsWithChildren } from "react";
import { notFound } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { AppShellMain } from "@mantine/core"; import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { auth } from "@homarr/auth/next";
import { BoardProvider } from "@homarr/boards/context"; import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode"; import { EditModeProvider } from "@homarr/boards/edit-mode";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
@@ -32,8 +33,14 @@ export const createBoardLayout = <TParams extends Params>({
}: PropsWithChildren<{ }: PropsWithChildren<{
params: Promise<TParams>; params: Promise<TParams>;
}>) => { }>) => {
const session = await auth();
const initialBoard = await getInitialBoard(await params).catch((error) => { const initialBoard = await getInitialBoard(await params).catch((error) => {
if (error instanceof TRPCError && error.code === "NOT_FOUND") { if (error instanceof TRPCError && error.code === "NOT_FOUND") {
if (!session) {
logger.debug("No home board found for anonymous user, redirecting to login");
redirect("/auth/login");
}
logger.warn(error); logger.warn(error);
notFound(); notFound();
} }

View File

@@ -7,12 +7,11 @@ import type { z } from "zod";
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 { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { AppForm } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { validation } from "@homarr/validation"; import type { validation } from "@homarr/validation";
import { AppForm } from "../../_form";
interface AppEditFormProps { interface AppEditFormProps {
app: RouterOutputs["app"]["byId"]; app: RouterOutputs["app"]["byId"];
} }
@@ -58,6 +57,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
initialValues={app} initialValues={app}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
isPending={isPending} isPending={isPending}
showBackToOverview
/> />
); );
}; };

View File

@@ -2,10 +2,10 @@ import { notFound } from "next/navigation";
import { Container, Stack, Title } from "@mantine/core"; import { Container, Stack, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { AppNewForm } from "@homarr/forms-collection";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AppNewForm } from "./_app-new-form";
export default async function AppNewPage() { export default async function AppNewPage() {
const session = await auth(); const session = await auth();
@@ -22,7 +22,7 @@ export default async function AppNewPage() {
<Container> <Container>
<Stack> <Stack>
<Title>{t("app.page.create.title")}</Title> <Title>{t("app.page.create.title")}</Title>
<AppNewForm /> <AppNewForm showBackToOverview showCreateAnother />
</Stack> </Stack>
</Container> </Container>
</> </>

View File

@@ -1,15 +1,11 @@
"use client"; "use client";
import type { JSX } from "react"; import { Button } from "@mantine/core";
import { Button, FileButton } from "@mantine/core";
import { IconUpload } from "@tabler/icons-react"; import { IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import type { MaybePromise } from "@homarr/common/types"; import { UploadMedia } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { supportedMediaUploadFormats } from "@homarr/validation";
export const UploadMediaButton = () => { export const UploadMediaButton = () => {
const t = useI18n(); const t = useI18n();
@@ -27,45 +23,3 @@ export const UploadMediaButton = () => {
</UploadMedia> </UploadMedia>
); );
}; };
interface UploadMediaProps {
children: (props: { onClick: () => void; loading: boolean }) => JSX.Element;
onSettled?: () => MaybePromise<void>;
onSuccess?: (media: { id: string; url: string }) => MaybePromise<void>;
}
export const UploadMedia = ({ children, onSettled, onSuccess }: UploadMediaProps) => {
const t = useI18n();
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
const handleFileUploadAsync = async (file: File | null) => {
if (!file) return;
const formData = new FormData();
formData.append("file", file);
await mutateAsync(formData, {
async onSuccess(mediaId) {
showSuccessNotification({
message: t("media.action.upload.notification.success.message"),
});
await onSuccess?.({
id: mediaId,
url: `/api/user-medias/${mediaId}`,
});
},
onError() {
showErrorNotification({
message: t("media.action.upload.notification.error.message"),
});
},
async onSettled() {
await onSettled?.();
},
});
};
return (
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
{({ onClick }) => children({ onClick, loading: isPending })}
</FileButton>
);
};

View File

@@ -1,10 +1,10 @@
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { import {
ActionIcon, ActionIcon,
Anchor, Anchor,
Group, Group,
Image,
Stack, Stack,
Table, Table,
TableTbody, TableTbody,
@@ -113,11 +113,12 @@ const Row = async ({ media }: RowProps) => {
<TableTr> <TableTr>
<TableTd w={64}> <TableTd w={64}>
<Image <Image
// Switched to mantine image because next/image doesn't support svgs
src={createLocalImageUrl(media.id)} src={createLocalImageUrl(media.id)}
alt={media.name} alt={media.name}
width={64} w={64}
height={64} h={64}
style={{ objectFit: "contain" }} fit="contain"
/> />
</TableTd> </TableTd>
<TableTd>{media.name}</TableTd> <TableTd>{media.name}</TableTd>

View File

@@ -9,12 +9,11 @@ import type { z } from "zod";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { searchEngineTypes } from "@homarr/definitions"; import { searchEngineTypes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import { IconPicker } from "~/components/icons/picker/icon-picker";
type FormType = z.infer<typeof validation.searchEngine.manage>; type FormType = z.infer<typeof validation.searchEngine.manage>;
interface SearchEngineFormProps { interface SearchEngineFormProps {

View File

@@ -1,13 +1,12 @@
"use client"; "use client";
import { Group, Switch, Text } from "@mantine/core"; import { Switch, Text } from "@mantine/core";
import { IconLayoutDashboard } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { ServerSettings } from "@homarr/server-settings"; import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithCustomItems } from "@homarr/ui";
import { BoardSelect } from "~/components/board/board-select";
import { CommonSettingsForm } from "./common-form"; import { CommonSettingsForm } from "./common-form";
export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => { export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
@@ -18,42 +17,19 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}> <CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
{(form) => ( {(form) => (
<> <>
<SelectWithCustomItems <BoardSelect
label={tBoard("homeBoard.label")} label={tBoard("homeBoard.label")}
description={tBoard("homeBoard.description")} description={tBoard("homeBoard.description")}
data={selectableBoards.map((board) => ({ clearable
value: board.id, boards={selectableBoards}
label: board.name,
image: board.logoImageUrl,
}))}
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
<Group>
{/* eslint-disable-next-line @next/next/no-img-element */}
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
)}
{...form.getInputProps("homeBoardId")} {...form.getInputProps("homeBoardId")}
/> />
<SelectWithCustomItems
<BoardSelect
label={tBoard("homeBoard.mobileLabel")} label={tBoard("homeBoard.mobileLabel")}
description={tBoard("homeBoard.description")} description={tBoard("homeBoard.description")}
data={selectableBoards.map((board) => ({ clearable
value: board.id, boards={selectableBoards}
label: board.name,
image: board.logoImageUrl,
}))}
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
<Group>
{/* eslint-disable-next-line @next/next/no-img-element */}
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
)}
{...form.getInputProps("mobileHomeBoardId")} {...form.getInputProps("mobileHomeBoardId")}
/> />

View File

@@ -1434,26 +1434,30 @@
} }
::-webkit-scrollbar-button:vertical:start:decrement { ::-webkit-scrollbar-button:vertical:start:decrement {
background: linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%), background:
linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
linear-gradient(230deg, #696969 40%, transparent 41%), linear-gradient(0deg, #696969 40%, transparent 31%); linear-gradient(230deg, #696969 40%, transparent 41%), linear-gradient(0deg, #696969 40%, transparent 31%);
background-color: #b6b6b6; background-color: #b6b6b6;
} }
::-webkit-scrollbar-button:vertical:end:increment { ::-webkit-scrollbar-button:vertical:end:increment {
background: linear-gradient(310deg, #696969 40%, transparent 41%), background:
linear-gradient(50deg, #696969 40%, transparent 41%), linear-gradient(180deg, #696969 40%, transparent 31%); linear-gradient(310deg, #696969 40%, transparent 41%), linear-gradient(50deg, #696969 40%, transparent 41%),
linear-gradient(180deg, #696969 40%, transparent 31%);
background-color: #b6b6b6; background-color: #b6b6b6;
} }
::-webkit-scrollbar-button:horizontal:end:increment { ::-webkit-scrollbar-button:horizontal:end:increment {
background: linear-gradient(210deg, #696969 40%, transparent 41%), background:
linear-gradient(330deg, #696969 40%, transparent 41%), linear-gradient(90deg, #696969 30%, transparent 31%); linear-gradient(210deg, #696969 40%, transparent 41%), linear-gradient(330deg, #696969 40%, transparent 41%),
linear-gradient(90deg, #696969 30%, transparent 31%);
background-color: #b6b6b6; background-color: #b6b6b6;
} }
::-webkit-scrollbar-button:horizontal:start:decrement { ::-webkit-scrollbar-button:horizontal:start:decrement {
background: linear-gradient(30deg, #696969 40%, transparent 41%), background:
linear-gradient(150deg, #696969 40%, transparent 41%), linear-gradient(270deg, #696969 30%, transparent 31%); linear-gradient(30deg, #696969 40%, transparent 41%), linear-gradient(150deg, #696969 40%, transparent 41%),
linear-gradient(270deg, #696969 30%, transparent 31%);
background-color: #b6b6b6; background-color: #b6b6b6;
} }
@@ -1681,28 +1685,32 @@
} }
::-webkit-scrollbar-button:vertical:start:decrement { ::-webkit-scrollbar-button:vertical:start:decrement {
background: linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%), background:
linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
linear-gradient(230deg, #696969 40%, rgba(0, 0, 0, 0) 41%), linear-gradient(230deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(0deg, #696969 40%, rgba(0, 0, 0, 0) 31%); linear-gradient(0deg, #696969 40%, rgba(0, 0, 0, 0) 31%);
background-color: #b6b6b6; background-color: #b6b6b6;
} }
::-webkit-scrollbar-button:vertical:end:increment { ::-webkit-scrollbar-button:vertical:end:increment {
background: linear-gradient(310deg, #696969 40%, rgba(0, 0, 0, 0) 41%), background:
linear-gradient(310deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(50deg, #696969 40%, rgba(0, 0, 0, 0) 41%), linear-gradient(50deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(180deg, #696969 40%, rgba(0, 0, 0, 0) 31%); linear-gradient(180deg, #696969 40%, rgba(0, 0, 0, 0) 31%);
background-color: #b6b6b6; background-color: #b6b6b6;
} }
::-webkit-scrollbar-button:horizontal:end:increment { ::-webkit-scrollbar-button:horizontal:end:increment {
background: linear-gradient(210deg, #696969 40%, rgba(0, 0, 0, 0) 41%), background:
linear-gradient(210deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(330deg, #696969 40%, rgba(0, 0, 0, 0) 41%), linear-gradient(330deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(90deg, #696969 30%, rgba(0, 0, 0, 0) 31%); linear-gradient(90deg, #696969 30%, rgba(0, 0, 0, 0) 31%);
background-color: #b6b6b6; background-color: #b6b6b6;
} }
::-webkit-scrollbar-button:horizontal:start:decrement { ::-webkit-scrollbar-button:horizontal:start:decrement {
background: linear-gradient(30deg, #696969 40%, rgba(0, 0, 0, 0) 41%), background:
linear-gradient(30deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(150deg, #696969 40%, rgba(0, 0, 0, 0) 41%), linear-gradient(150deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(270deg, #696969 30%, rgba(0, 0, 0, 0) 31%); linear-gradient(270deg, #696969 30%, rgba(0, 0, 0, 0) 31%);
background-color: #b6b6b6; background-color: #b6b6b6;

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { Button, Group, Select, Stack } from "@mantine/core"; import { Button, Group, Stack } from "@mantine/core";
import type { z } from "zod"; import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -11,9 +11,12 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import type { Board } from "~/app/[locale]/boards/_types";
import { BoardSelect } from "~/components/board/board-select";
interface ChangeHomeBoardFormProps { interface ChangeHomeBoardFormProps {
user: RouterOutputs["user"]["getById"]; user: RouterOutputs["user"]["getById"];
boardsData: { value: string; label: string }[]; boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
} }
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => { export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
@@ -54,16 +57,18 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
return ( return (
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md"> <Stack gap="md">
<Select <BoardSelect
label={t("management.page.user.setting.general.item.board.type.general")} label={t("management.page.user.setting.general.item.board.type.general")}
clearable
boards={boardsData}
w="100%" w="100%"
data={boardsData}
{...form.getInputProps("homeBoardId")} {...form.getInputProps("homeBoardId")}
/> />
<Select <BoardSelect
label={t("management.page.user.setting.general.item.board.type.mobile")} label={t("management.page.user.setting.general.item.board.type.mobile")}
clearable
boards={boardsData}
w="100%" w="100%"
data={boardsData}
{...form.getInputProps("mobileHomeBoardId")} {...form.getInputProps("mobileHomeBoardId")}
/> />

View File

@@ -95,8 +95,9 @@ export default async function EditUserPage(props: Props) {
<ChangeHomeBoardForm <ChangeHomeBoardForm
user={user} user={user}
boardsData={boards.map((board) => ({ boardsData={boards.map((board) => ({
value: board.id, id: board.id,
label: board.name, name: board.name,
logoImageUrl: board.logoImageUrl,
}))} }))}
/> />
</Stack> </Stack>

View File

@@ -1,7 +1,7 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import Link from "next/link"; import Link from "next/link";
import { Button, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core"; import { Button, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
import { IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react"; import { IconId, IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
@@ -42,6 +42,11 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
<NavigationLink <NavigationLink
href={`/manage/users/groups/${params.id}`} href={`/manage/users/groups/${params.id}`}
label={tGroup("setting.general.title")} label={tGroup("setting.general.title")}
icon={<IconId size="1rem" stroke={1.5} />}
/>
<NavigationLink
href={`/manage/users/groups/${params.id}/settings`}
label={tGroup("setting.setting.title")}
icon={<IconSettings size="1rem" stroke={1.5} />} icon={<IconSettings size="1rem" stroke={1.5} />}
/> />
<NavigationLink <NavigationLink

View File

@@ -0,0 +1,81 @@
"use client";
import { Button, Group, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { BoardSelect } from "~/components/board/board-select";
interface GroupHomeBoardsProps {
homeBoardId: string | null;
mobileHomeBoardId: string | null;
groupId: string;
}
export const GroupHomeBoards = ({ homeBoardId, mobileHomeBoardId, groupId }: GroupHomeBoardsProps) => {
const t = useI18n();
const [availableBoards] = clientApi.board.getBoardsForGroup.useSuspenseQuery({ groupId });
const form = useZodForm(validation.group.settings.pick({ homeBoardId: true, mobileHomeBoardId: true }), {
initialValues: {
homeBoardId,
mobileHomeBoardId,
},
});
const { mutateAsync, isPending } = clientApi.group.savePartialSettings.useMutation();
const handleSubmit = form.onSubmit(async (values) => {
await mutateAsync(
{
id: groupId,
settings: values,
},
{
onSuccess() {
form.setInitialValues(values);
showSuccessNotification({
title: t("group.action.settings.board.notification.success.title"),
message: t("group.action.settings.board.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("group.action.settings.board.notification.error.title"),
message: t("group.action.settings.board.notification.error.message"),
});
},
},
);
});
return (
<form onSubmit={handleSubmit}>
<Stack gap="md">
<BoardSelect
label={t("group.field.homeBoard.label")}
description={t("group.field.homeBoard.description")}
clearable
boards={availableBoards}
{...form.getInputProps("homeBoardId")}
/>
<BoardSelect
label={t("group.field.mobileBoard.label")}
description={t("group.field.mobileBoard.description")}
clearable
boards={availableBoards}
{...form.getInputProps("mobileHomeBoardId")}
/>
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};

View File

@@ -0,0 +1,41 @@
import { notFound } from "next/navigation";
import { Alert, Stack, Title } from "@mantine/core";
import { IconExclamationCircle } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { GroupHomeBoards } from "./_group-home-boards";
interface GroupSettingsPageProps {
params: Promise<{
id: string;
}>;
}
export default async function GroupPermissionsPage(props: GroupSettingsPageProps) {
const params = await props.params;
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const group = await api.group.getById({ id: params.id });
const t = await getI18n();
return (
<Stack>
<Title>{t("management.page.group.setting.setting.title")}</Title>
<Alert color="cyan" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
{t("management.page.group.setting.setting.alert")}
</Alert>
<Title order={3}>{t("management.page.group.setting.setting.board.title")}</Title>
<GroupHomeBoards homeBoardId={group.homeBoardId} mobileHomeBoardId={group.mobileHomeBoardId} groupId={group.id} />
</Stack>
);
}

View File

@@ -1,24 +0,0 @@
"use client";
import { useCallback } from "react";
import { useModalAction } from "@homarr/modals";
import { AddGroupModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
export const AddGroup = () => {
const t = useI18n();
const { openModal } = useModalAction(AddGroupModal);
const handleAddGroup = useCallback(() => {
openModal();
}, [openModal]);
return (
<MobileAffixButton onClick={handleAddGroup} color="teal">
{t("group.action.create.label")}
</MobileAffixButton>
);
};

View File

@@ -0,0 +1,65 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { Group, TextInput } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { useModalAction } from "@homarr/modals";
import { AddGroupModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { GroupsTable } from "./_groups-table";
interface GroupsListProps {
groups: RouterOutputs["group"]["getAll"];
}
export const GroupsList = ({ groups }: GroupsListProps) => {
const [search, setSearch] = useState("");
const initialGroupIds = useMemo(
() => groups.sort((groupA, groupB) => groupA.position - groupB.position).map((group) => group.id),
[groups],
);
const filteredGroups = useMemo(
() =>
groups
.filter((group) => group.name.toLowerCase().includes(search.toLowerCase()))
.sort((groupA, groupB) => groupA.position - groupB.position),
[groups, search],
);
const t = useI18n();
return (
<>
<Group justify="space-between">
<TextInput
leftSection={<IconSearch size={20} stroke={1.5} />}
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
placeholder={`${t("group.search")}...`}
style={{ flex: 1 }}
/>
<AddGroup />
</Group>
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
</>
);
};
const AddGroup = () => {
const t = useI18n();
const { openModal } = useModalAction(AddGroupModal);
const handleAddGroup = useCallback(() => {
openModal();
}, [openModal]);
return (
<MobileAffixButton onClick={handleAddGroup} color="teal">
{t("group.action.create.label")}
</MobileAffixButton>
);
};

View File

@@ -0,0 +1,277 @@
"use client";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import Link from "next/link";
import type { DragEndEvent, DraggableAttributes, DragStartEvent } from "@dnd-kit/core";
import {
closestCenter,
DndContext,
DragOverlay,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Anchor,
Box,
Button,
Card,
Flex,
Group,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Transition,
} from "@mantine/core";
import { IconGripVertical } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { UserAvatarGroup } from "@homarr/ui";
interface GroupsTableProps {
initialGroupIds: string[];
groups: RouterOutputs["group"]["getAll"];
hasFilter: boolean;
}
export const GroupsTable = ({ groups, initialGroupIds, hasFilter }: GroupsTableProps) => {
const t = useI18n();
const [activeId, setActiveId] = useState<string | null>(null);
const [groupIds, setGroupIds] = useState(groups.map((group) => group.id));
const isDirty = useMemo(
() => initialGroupIds.some((groupId, index) => groupIds.indexOf(groupId) !== index),
[groupIds, initialGroupIds],
);
const { mutateAsync, isPending } = clientApi.group.savePositions.useMutation();
const handleSavePositionsAsync = async () => {
await mutateAsync(
{ positions: groupIds },
{
async onSuccess() {
showSuccessNotification({
message: t("group.action.changePosition.notification.success.message"),
});
await revalidatePathActionAsync("/manage/users/groups");
},
onError() {
showSuccessNotification({
message: t("group.action.changePosition.notification.error.message"),
});
},
},
);
};
const sensors = useSensors(useSensor(MouseSensor, {}), useSensor(TouchSensor, {}), useSensor(KeyboardSensor, {}));
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) {
setActiveId(null);
return;
}
setGroupIds((groupIds) => {
const oldIndex = groupIds.indexOf(active.id as string);
const newIndex = groupIds.indexOf(over.id as string);
return arrayMove(groupIds, oldIndex, newIndex);
});
}
function handleDragCancel() {
setActiveId(null);
}
const selectedRow = useMemo(() => {
if (!activeId) return null;
const current = groups.find((group) => group.id === activeId);
if (!current) return null;
return <Row group={current} handle={<DragHandle attributes={undefined} listeners={undefined} active />} />;
}, [activeId, groups]);
return (
<>
<DndContext
sensors={sensors}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
onDragCancel={handleDragCancel}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
id="groups-table"
>
<Table striped highlightOnHover>
<TableThead>
<TableTr>
<TableTh>{t("group.field.name")}</TableTh>
<TableTh>{t("group.field.members")}</TableTh>
</TableTr>
</TableThead>
<TableTbody>
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
{groupIds.map((groupId) => {
const group = groups.find(({ id }) => id === groupId);
if (!group) return null;
return <DraggableRow key={group.id} group={group} disabled={hasFilter} />;
})}
</SortableContext>
</TableTbody>
</Table>
<DragOverlay>
{activeId && (
<Table w="100%">
<TableTbody>{selectedRow}</TableTbody>
</Table>
)}
</DragOverlay>
</DndContext>
<SaveAffix
visible={isDirty}
onDiscard={() => setGroupIds(initialGroupIds)}
isPending={isPending}
onSave={handleSavePositionsAsync}
/>
</>
);
};
interface DraggableRowProps {
group: RouterOutputs["group"]["getAll"][number];
disabled?: boolean;
}
const DraggableRow = ({ group, disabled }: DraggableRowProps) => {
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
id: group.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
if (isDragging) {
return (
<TableTr ref={setNodeRef} style={style}>
<TableTd colSpan={2}>&nbsp;</TableTd>
</TableTr>
);
}
return (
<Row
group={group}
setNodeRef={setNodeRef}
style={style}
handle={<DragHandle attributes={attributes} listeners={listeners} active={false} disabled={disabled} />}
/>
);
};
interface RowProps {
group: RouterOutputs["group"]["getAll"][number];
handle?: ReactNode;
setNodeRef?: (node: HTMLElement | null) => void;
style?: React.CSSProperties;
}
const Row = ({ group, handle, setNodeRef, style }: RowProps) => {
return (
<TableTr ref={setNodeRef} style={style}>
<TableTd>
<Group>
{handle}
<Anchor component={Link} href={`/manage/users/groups/${group.id}`}>
{group.name}
</Anchor>
</Group>
</TableTd>
<TableTd>
<UserAvatarGroup users={group.members} size="sm" limit={5} />
</TableTd>
</TableTr>
);
};
interface DragHandleProps {
attributes: DraggableAttributes | undefined;
listeners: SyntheticListenerMap | undefined;
active: boolean;
disabled?: boolean;
}
const DragHandle = ({ attributes, listeners, active, disabled }: DragHandleProps) => {
if (disabled) {
return <Box w={40} h="100%" />;
}
return (
<Flex
align="center"
justify="center"
h="100%"
w={40}
style={{ cursor: active ? "grabbing" : "grab" }}
{...attributes}
{...listeners}
>
<IconGripVertical size={18} stroke={1.5} />
</Flex>
);
};
interface SaveAffixProps {
visible: boolean;
isPending: boolean;
onDiscard: () => void;
onSave: () => void;
}
const SaveAffix = ({ visible, isPending, onDiscard, onSave }: SaveAffixProps) => {
const t = useI18n();
return (
<div style={{ position: "sticky", bottom: 20 }}>
<Transition transition="slide-up" mounted={visible}>
{(transitionStyles) => (
<Card style={transitionStyles} withBorder>
<Group justify="space-between">
<Text fw={500}>{t("common.unsavedChanges")}</Text>
<Group>
<Button disabled={isPending} onClick={onDiscard}>
{t("common.action.discard")}
</Button>
<Button color="teal" loading={isPending} onClick={onSave}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Group>
</Card>
)}
</Transition>
</div>
);
};

View File

@@ -0,0 +1,7 @@
.everyoneGroup {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
}
.everyoneGroup:hover {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}

View File

@@ -1,30 +1,19 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core"; import { Card, Group, Stack, Text, ThemeIcon, Title, UnstyledButton } from "@mantine/core";
import { z } from "zod"; import { IconChevronRight, IconUsersGroup } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
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 type { inferSearchParamsFromSchema } from "@homarr/common/types"; import { everyoneGroup } from "@homarr/definitions";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AddGroup } from "./_add-group"; import { GroupsList } from "./_client";
import classes from "./groups.module.css";
const searchParamsSchema = z.object({ export default async function GroupsListPage() {
search: z.string().optional(),
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
page: z.string().regex(/\d+/).transform(Number).catch(1),
});
interface GroupsListPageProps {
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
}
export default async function GroupsListPage(props: GroupsListPageProps) {
const session = await auth(); const session = await auth();
if (!session?.user.permissions.includes("admin")) { if (!session?.user.permissions.includes("admin")) {
@@ -32,55 +21,38 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
} }
const t = await getI18n(); const t = await getI18n();
const searchParams = searchParamsSchema.parse(await props.searchParams); const groups = await api.group.getAll();
const { items: groups, totalCount } = await api.group.getPaginated(searchParams); const dbEveryoneGroup = groups.find((group) => group.name === everyoneGroup);
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
return ( return (
<ManageContainer size="xl"> <ManageContainer size="xl">
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<Stack> <Stack>
<Title>{t("group.title")}</Title> <Title>{t("group.title")}</Title>
<Group justify="space-between">
<SearchInput placeholder={`${t("group.search")}...`} defaultValue={searchParams.search} />
<AddGroup />
</Group>
<Table striped highlightOnHover>
<TableThead>
<TableTr>
<TableTh>{t("group.field.name")}</TableTh>
<TableTh>{t("group.field.members")}</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{groups.map((group) => (
<Row key={group.id} group={group} />
))}
</TableTbody>
</Table>
<Group justify="end"> {dbEveryoneGroup && (
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} /> <UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
</Group> <Card className={classes.everyoneGroup}>
<Group align="center">
<ThemeIcon radius="xl" variant="light">
<IconUsersGroup size={16} />
</ThemeIcon>
<Stack gap={0} flex={1}>
<Text fw={500}>{t("group.defaultGroup.name")}</Text>
<Text size="sm" c="gray.6">
{t("group.defaultGroup.description", { name: everyoneGroup })}
</Text>
</Stack>
<IconChevronRight size={20} />
</Group>
</Card>
</UnstyledButton>
)}
<GroupsList groups={groupsWithoutEveryone} />
</Stack> </Stack>
</ManageContainer> </ManageContainer>
); );
} }
interface RowProps {
group: RouterOutputs["group"]["getPaginated"]["items"][number];
}
const Row = ({ group }: RowProps) => {
return (
<TableTr>
<TableTd>
<Anchor component={Link} href={`/manage/users/groups/${group.id}`}>
{group.name}
</Anchor>
</TableTd>
<TableTd>
<UserAvatarGroup users={group.members} size="sm" limit={5} />
</TableTd>
</TableTr>
);
};

View File

@@ -1,11 +1,11 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Center } from "@mantine/core"; import { Center } from "@mantine/core";
import { env } from "@homarr/common/env";
import { db } from "@homarr/db"; import { db } from "@homarr/db";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { widgetImports } from "@homarr/widgets"; import { widgetImports } from "@homarr/widgets";
import { env } from "~/env";
import { WidgetPreviewPageContent } from "./_content"; import { WidgetPreviewPageContent } from "./_content";
interface Props { interface Props {

View File

@@ -0,0 +1,33 @@
import { Group, Text } from "@mantine/core";
import { IconLayoutDashboard } from "@tabler/icons-react";
import type { SelectWithCustomItemsProps } from "@homarr/ui";
import { SelectWithCustomItems } from "@homarr/ui";
import type { Board } from "~/app/[locale]/boards/_types";
interface BoardSelectProps extends Omit<SelectWithCustomItemsProps<{ value: string; label: string }>, "data"> {
boards: Pick<Board, "id" | "name" | "logoImageUrl">[];
}
export const BoardSelect = ({ boards, ...props }: BoardSelectProps) => {
return (
<SelectWithCustomItems
{...props}
data={boards.map((board) => ({
value: board.id,
label: board.name,
image: board.logoImageUrl,
}))}
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
<Group>
{/* eslint-disable-next-line @next/next/no-img-element */}
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
)}
/>
);
};

View File

@@ -33,6 +33,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
"grid-stack-item-content", "grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "), item.advancedOptions.customCssClasses.join(" "),
)} )}
radius={board.itemRadius}
withBorder withBorder
styles={{ styles={{
root: { root: {

View File

@@ -27,7 +27,13 @@ export const BoardCategorySection = ({ section }: Props) => {
}); });
return ( return (
<Card style={{ "--opacity": board.opacity / 100 }} withBorder p={0} className={classes.itemCard}> <Card
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}>

View File

@@ -26,6 +26,7 @@ export const BoardDynamicSection = ({ section }: Props) => {
overflow: "hidden", overflow: "hidden",
}, },
}} }}
radius={board.itemRadius}
p={0} p={0}
> >
<GridStack section={section} className="min-row" /> <GridStack section={section} className="min-row" />

View File

@@ -1,33 +0,0 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
import { shouldSkipEnvValidation } from "@homarr/common/env-validation";
export const env = createEnv({
shared: {
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
},
/**
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
* built with invalid env vars.
*/
server: {},
/**
* Specify your client-side environment variables schema here.
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
/**
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
*/
runtimeEnv: {
PORT: process.env.PORT,
NODE_ENV: process.env.NODE_ENV,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
skipValidation: shouldSkipEnvValidation(),
emptyStringAsUndefined: true,
});

View File

@@ -47,8 +47,8 @@
"@types/node": "^22.13.4", "@types/node": "^22.13.4",
"dotenv-cli": "^8.0.0", "dotenv-cli": "^8.0.0",
"eslint": "^9.20.1", "eslint": "^9.20.1",
"prettier": "^3.4.2", "prettier": "^3.5.1",
"tsx": "4.19.2", "tsx": "4.19.3",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }

View File

@@ -26,8 +26,8 @@
"@homarr/redis": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"tsx": "4.19.2", "tsx": "4.19.3",
"ws": "^8.18.0" "ws": "^8.18.1"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
@@ -35,7 +35,7 @@
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.5.14", "@types/ws": "^8.5.14",
"eslint": "^9.20.1", "eslint": "^9.20.1",
"prettier": "^3.4.2", "prettier": "^3.5.1",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }

View File

@@ -1,5 +1,5 @@
<!-- Project Title --> <!-- Project Title -->
![Banner](./banner.png) [![Banner](./banner.png)](https://homarr.dev/)
<!-- Badges --> <!-- Badges -->
<p align="center"> <p align="center">
@@ -36,7 +36,7 @@
</p> </p>
![Features Section](./section-features.png) [![Features Section](./section-features.png)](https://homarr.dev/)
- 🖌️ Highly customizable with an extensive drag and drop grid system - 🖌️ Highly customizable with an extensive drag and drop grid system
- ✨ Integrates seamlessly with your favorite self-hosted applications - ✨ Integrates seamlessly with your favorite self-hosted applications
@@ -53,7 +53,7 @@
<br/> <br/>
<br/> <br/>
![Widgets & Integrations Section](./section-widgets-and-integrations.png) [![Widgets & Integrations Section](./section-widgets-and-integrations.png)](https://homarr.dev/docs/category/widgets)
Homarr has a [built-in collection of widgets and integrations](https://homarr.dev/docs/category/integrations), that connect to your applications and enable you to control them directly from the dashboard. Homarr has a [built-in collection of widgets and integrations](https://homarr.dev/docs/category/integrations), that connect to your applications and enable you to control them directly from the dashboard.
@@ -88,7 +88,7 @@ Homarr has a [built-in collection of widgets and integrations](https://homarr.de
<br/> <br/>
<br/> <br/>
![Installation Section](./section-installation.png) [![Installation Section](./section-installation.png)](https://homarr.dev/docs/category/installation-1)
Since we are updating Homarr very frequently, we recommend reading our official installation guides: Since we are updating Homarr very frequently, we recommend reading our official installation guides:
@@ -101,7 +101,7 @@ Since we are updating Homarr very frequently, we recommend reading our official
<br/> <br/>
<br/> <br/>
![Contribute Section](./section-contribute.png) [![Contribute Section](./section-contribute.png)](https://opencollective.com/homarr)
<br/> <br/>

View File

@@ -22,6 +22,7 @@ export class OnboardingActions {
await this.db.insert(sqliteSchema.groups).values({ await this.db.insert(sqliteSchema.groups).values({
id: createId(), id: createId(),
name: input.group, name: input.group,
position: 1,
}); });
} }
} }

View File

@@ -40,20 +40,20 @@
"@semantic-release/release-notes-generator": "^14.0.3", "@semantic-release/release-notes-generator": "^14.0.3",
"@turbo/gen": "^2.4.2", "@turbo/gen": "^2.4.2",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.0.5", "@vitest/coverage-v8": "^3.0.6",
"@vitest/ui": "^3.0.5", "@vitest/ui": "^3.0.6",
"conventional-changelog-conventionalcommits": "^8.0.0", "conventional-changelog-conventionalcommits": "^8.0.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"prettier": "^3.4.2", "prettier": "^3.5.1",
"semantic-release": "^24.2.2", "semantic-release": "^24.2.3",
"testcontainers": "^10.18.0", "testcontainers": "^10.18.0",
"turbo": "^2.4.2", "turbo": "^2.4.2",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.5" "vitest": "^3.0.6"
}, },
"packageManager": "pnpm@10.4.0", "packageManager": "pnpm@10.4.1",
"engines": { "engines": {
"node": ">=22.14.0" "node": ">=22.14.0"
}, },

View File

@@ -57,7 +57,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.20.1", "eslint": "^9.20.1",
"prettier": "^3.4.2", "prettier": "^3.5.1",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }

View File

@@ -111,16 +111,19 @@ export const appRouter = createTRPCRouter({
create: permissionRequiredProcedure create: permissionRequiredProcedure
.requiresPermission("app-create") .requiresPermission("app-create")
.input(validation.app.manage) .input(validation.app.manage)
.output(z.void()) .output(z.object({ appId: z.string() }))
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } }) .meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const id = createId();
await ctx.db.insert(apps).values({ await ctx.db.insert(apps).values({
id: createId(), id,
name: input.name, name: input.name,
description: input.description, description: input.description,
iconUrl: input.iconUrl, iconUrl: input.iconUrl,
href: input.href, href: input.href,
}); });
return { appId: id };
}), }),
createMany: permissionRequiredProcedure createMany: permissionRequiredProcedure
.requiresPermission("app-create") .requiresPermission("app-create")

View File

@@ -5,7 +5,7 @@ import { z } from "zod";
import { constructBoardPermissions } from "@homarr/auth/shared"; import { constructBoardPermissions } from "@homarr/auth/shared";
import type { DeviceType } from "@homarr/common/server"; import type { DeviceType } from "@homarr/common/server";
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db"; import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
import { and, createId, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db"; import { and, asc, createId, eq, handleTransactionsAsync, inArray, isNull, like, not, or } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries"; import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { import {
boardGroupPermissions, boardGroupPermissions,
@@ -13,6 +13,7 @@ import {
boardUserPermissions, boardUserPermissions,
groupMembers, groupMembers,
groupPermissions, groupPermissions,
groups,
integrationGroupPermissions, integrationGroupPermissions,
integrationItems, integrationItems,
integrationUserPermissions, integrationUserPermissions,
@@ -22,7 +23,7 @@ import {
users, users,
} from "@homarr/db/schema"; } from "@homarr/db/schema";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { getPermissionsWithParents, widgetKinds } from "@homarr/definitions"; import { everyoneGroup, getPermissionsWithChildren, getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
import { importOldmarrAsync } from "@homarr/old-import"; import { importOldmarrAsync } from "@homarr/old-import";
import { importJsonFileSchema } from "@homarr/old-import/shared"; import { importJsonFileSchema } from "@homarr/old-import/shared";
import { oldmarrConfigSchema } from "@homarr/old-schema"; import { oldmarrConfigSchema } from "@homarr/old-schema";
@@ -57,6 +58,37 @@ export const boardRouter = createTRPCRouter({
where: eq(boards.isPublic, true), where: eq(boards.isPublic, true),
}); });
}), }),
getBoardsForGroup: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ groupId: z.string() }))
.query(async ({ ctx, input }) => {
const dbEveryoneAndCurrentGroup = await ctx.db.query.groups.findMany({
where: or(eq(groups.name, everyoneGroup), eq(groups.id, input.groupId)),
with: {
boardPermissions: true,
permissions: true,
},
});
const distinctPermissions = new Set(
dbEveryoneAndCurrentGroup.flatMap((group) => group.permissions.map(({ permission }) => permission)),
);
const canViewAllBoards = getPermissionsWithChildren([...distinctPermissions]).includes("board-view-all");
const boardIds = dbEveryoneAndCurrentGroup.flatMap((group) =>
group.boardPermissions.map(({ boardId }) => boardId),
);
const boardWhere = canViewAllBoards ? undefined : or(eq(boards.isPublic, true), inArray(boards.id, boardIds));
return await ctx.db.query.boards.findMany({
columns: {
id: true,
name: true,
logoImageUrl: true,
},
where: boardWhere,
});
}),
getAllBoards: publicProcedure.query(async ({ ctx }) => { getAllBoards: publicProcedure.query(async ({ ctx }) => {
const userId = ctx.session?.user.id; const userId = ctx.session?.user.id;
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({ const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
@@ -89,6 +121,7 @@ export const boardRouter = createTRPCRouter({
columns: { columns: {
id: true, id: true,
name: true, name: true,
logoImageUrl: true,
isPublic: true, isPublic: true,
}, },
with: { with: {
@@ -478,12 +511,14 @@ export const boardRouter = createTRPCRouter({
primaryColor: input.primaryColor, primaryColor: input.primaryColor,
secondaryColor: input.secondaryColor, secondaryColor: input.secondaryColor,
opacity: input.opacity, opacity: input.opacity,
iconColor: input.iconColor,
// custom css // custom css
customCss: input.customCss, customCss: input.customCss,
// layout settings // layout settings
columnCount: input.columnCount, columnCount: input.columnCount,
itemRadius: input.itemRadius,
// Behavior settings // Behavior settings
disableStatus: input.disableStatus, disableStatus: input.disableStatus,
@@ -975,9 +1010,13 @@ export const boardRouter = createTRPCRouter({
* For an example of a user with deviceType = 'mobile' it would go through the following order: * For an example of a user with deviceType = 'mobile' it would go through the following order:
* 1. user.mobileHomeBoardId * 1. user.mobileHomeBoardId
* 2. user.homeBoardId * 2. user.homeBoardId
* 3. serverSettings.mobileHomeBoardId * 3. group.mobileHomeBoardId of the lowest positions group
* 4. serverSettings.homeBoardId * 4. group.homeBoardId of the lowest positions group
* 5. show NOT_FOUND error * 5. everyoneGroup.mobileHomeBoardId
* 6. everyoneGroup.homeBoardId
* 7. serverSettings.mobileHomeBoardId
* 8. serverSettings.homeBoardId
* 9. show NOT_FOUND error
*/ */
const getHomeIdBoardAsync = async ( const getHomeIdBoardAsync = async (
db: Database, db: Database,
@@ -985,12 +1024,46 @@ const getHomeIdBoardAsync = async (
deviceType: DeviceType, deviceType: DeviceType,
) => { ) => {
const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId"; const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
if (user?.[settingKey] || user?.homeBoardId) {
return user[settingKey] ?? user.homeBoardId; if (!user) {
} else {
const boardSettings = await getServerSettingByKeyAsync(db, "board"); const boardSettings = await getServerSettingByKeyAsync(db, "board");
return boardSettings[settingKey] ?? boardSettings.homeBoardId; return boardSettings[settingKey] ?? boardSettings.homeBoardId;
} }
if (user[settingKey]) return user[settingKey];
if (user.homeBoardId) return user.homeBoardId;
const lowestGroupExceptEveryone = await db
.select({
homeBoardId: groups.homeBoardId,
mobileHomeBoardId: groups.mobileHomeBoardId,
})
.from(groups)
.leftJoin(groupMembers, eq(groups.id, groupMembers.groupId))
.where(
and(
eq(groupMembers.userId, user.id),
not(eq(groups.name, everyoneGroup)),
not(isNull(groups[settingKey])),
not(isNull(groups.homeBoardId)),
),
)
.orderBy(asc(groups.position))
.limit(1)
.then((result) => result[0]);
if (lowestGroupExceptEveryone?.[settingKey]) return lowestGroupExceptEveryone[settingKey];
if (lowestGroupExceptEveryone?.homeBoardId) return lowestGroupExceptEveryone.homeBoardId;
const dbEveryoneGroup = await db.query.groups.findFirst({
where: eq(groups.name, everyoneGroup),
});
if (dbEveryoneGroup?.[settingKey]) return dbEveryoneGroup[settingKey];
if (dbEveryoneGroup?.homeBoardId) return dbEveryoneGroup.homeBoardId;
const boardSettings = await getServerSettingByKeyAsync(db, "board");
return boardSettings[settingKey] ?? boardSettings.homeBoardId;
}; };
const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => { const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => {

View File

@@ -2,7 +2,8 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import type { Database } from "@homarr/db"; import type { Database } from "@homarr/db";
import { and, createId, eq, like, not, sql } from "@homarr/db"; import { and, createId, eq, handleTransactionsAsync, like, not, sql } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema"; import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
import { everyoneGroup } from "@homarr/definitions"; import { everyoneGroup } from "@homarr/definitions";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
@@ -12,6 +13,30 @@ import { throwIfCredentialsDisabled } from "./invite/checks";
import { nextOnboardingStepAsync } from "./onboard/onboard-queries"; import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
export const groupRouter = createTRPCRouter({ export const groupRouter = createTRPCRouter({
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
const dbGroups = await ctx.db.query.groups.findMany({
with: {
members: {
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
},
},
},
},
},
});
return dbGroups.map((group) => ({
...group,
members: group.members.map((member) => member.user),
}));
}),
getPaginated: permissionRequiredProcedure getPaginated: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
.input(validation.common.paginated) .input(validation.common.paginated)
@@ -153,10 +178,13 @@ export const groupRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkSimilarNameAndThrowAsync(ctx.db, input.name); await checkSimilarNameAndThrowAsync(ctx.db, input.name);
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
const groupId = createId(); const groupId = createId();
await ctx.db.insert(groups).values({ await ctx.db.insert(groups).values({
id: groupId, id: groupId,
name: input.name, name: input.name,
position: maxPosition + 1,
}); });
await ctx.db.insert(groupPermissions).values({ await ctx.db.insert(groupPermissions).values({
@@ -172,10 +200,13 @@ export const groupRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkSimilarNameAndThrowAsync(ctx.db, input.name); await checkSimilarNameAndThrowAsync(ctx.db, input.name);
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
const id = createId(); const id = createId();
await ctx.db.insert(groups).values({ await ctx.db.insert(groups).values({
id, id,
name: input.name, name: input.name,
position: maxPosition + 1,
ownerId: ctx.session.user.id, ownerId: ctx.session.user.id,
}); });
@@ -197,6 +228,43 @@ export const groupRouter = createTRPCRouter({
}) })
.where(eq(groups.id, input.id)); .where(eq(groups.id, input.id));
}), }),
savePartialSettings: permissionRequiredProcedure
.requiresPermission("admin")
.input(validation.group.savePartialSettings)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
await ctx.db
.update(groups)
.set({
homeBoardId: input.settings.homeBoardId,
mobileHomeBoardId: input.settings.mobileHomeBoardId,
})
.where(eq(groups.id, input.id));
}),
savePositions: permissionRequiredProcedure
.requiresPermission("admin")
.input(validation.group.savePositions)
.mutation(async ({ input, ctx }) => {
const positions = input.positions.map((id, index) => ({ id, position: index + 1 }));
await handleTransactionsAsync(ctx.db, {
handleAsync: async (db, schema) => {
await db.transaction(async (trx) => {
for (const { id, position } of positions) {
await trx.update(schema.groups).set({ position }).where(eq(groups.id, id));
}
});
},
handleSync: (db) => {
db.transaction((trx) => {
for (const { id, position } of positions) {
trx.update(groups).set({ position }).where(eq(groups.id, id)).run();
}
});
},
});
}),
savePermissions: permissionRequiredProcedure savePermissions: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
.input(validation.group.savePermissions) .input(validation.group.savePermissions)

View File

@@ -205,6 +205,7 @@ describe("getAllBoards should return all boards accessable to the current user",
await db.insert(groups).values({ await db.insert(groups).values({
id: groupId, id: groupId,
name: "group1", name: "group1",
position: 1,
}); });
await db.insert(groupMembers).values({ await db.insert(groupMembers).values({
@@ -1166,6 +1167,7 @@ describe("getBoardPermissions should return board permissions", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: groupId, id: groupId,
name: "group1", name: "group1",
position: 1,
}); });
await db.insert(boardGroupPermissions).values({ await db.insert(boardGroupPermissions).values({
@@ -1260,6 +1262,7 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
await db.insert(groups).values({ await db.insert(groups).values({
id: groupId, id: groupId,
name: "group1", name: "group1",
position: 1,
}); });
const boardId = createId(); const boardId = createId();

View File

@@ -43,6 +43,7 @@ describe("paginated should return a list of groups with pagination", () => {
[1, 2, 3, 4, 5].map((number) => ({ [1, 2, 3, 4, 5].map((number) => ({
id: number.toString(), id: number.toString(),
name: `Group ${number}`, name: `Group ${number}`,
position: number,
})), })),
); );
@@ -66,6 +67,7 @@ describe("paginated should return a list of groups with pagination", () => {
[1, 2, 3, 4, 5].map((number) => ({ [1, 2, 3, 4, 5].map((number) => ({
id: number.toString(), id: number.toString(),
name: `Group ${number}`, name: `Group ${number}`,
position: number,
})), })),
); );
@@ -89,6 +91,7 @@ describe("paginated should return a list of groups with pagination", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: groupId, id: groupId,
name: "Group", name: "Group",
position: 1,
}); });
await db.insert(groupMembers).values({ await db.insert(groupMembers).values({
groupId, groupId,
@@ -123,6 +126,7 @@ describe("paginated should return a list of groups with pagination", () => {
["first", "second", "third", "forth", "fifth"].map((key, index) => ({ ["first", "second", "third", "forth", "fifth"].map((key, index) => ({
id: index.toString(), id: index.toString(),
name: key, name: key,
position: index + 1,
})), })),
); );
@@ -163,10 +167,12 @@ describe("byId should return group by id including members and permissions", ()
{ {
id: groupId, id: groupId,
name: "Group", name: "Group",
position: 1,
}, },
{ {
id: createId(), id: createId(),
name: "Another group", name: "Another group",
position: 2,
}, },
]); ]);
await db.insert(groupMembers).values({ await db.insert(groupMembers).values({
@@ -202,6 +208,7 @@ describe("byId should return group by id including members and permissions", ()
await db.insert(groups).values({ await db.insert(groups).values({
id: "2", id: "2",
name: "Group", name: "Group",
position: 1,
}); });
// Act // Act
@@ -278,6 +285,7 @@ describe("create should create group in database", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: createId(), id: createId(),
name: similarName, name: similarName,
position: 1,
}); });
// Act // Act
@@ -314,10 +322,12 @@ describe("update should update name with value that is no duplicate", () => {
{ {
id: groupId, id: groupId,
name: initialValue, name: initialValue,
position: 1,
}, },
{ {
id: createId(), id: createId(),
name: "Third", name: "Third",
position: 2,
}, },
]); ]);
@@ -347,10 +357,12 @@ describe("update should update name with value that is no duplicate", () => {
{ {
id: groupId, id: groupId,
name: "Something", name: "Something",
position: 1,
}, },
{ {
id: createId(), id: createId(),
name: initialDuplicate, name: initialDuplicate,
position: 2,
}, },
]); ]);
@@ -373,6 +385,7 @@ describe("update should update name with value that is no duplicate", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: createId(), id: createId(),
name: "something", name: "something",
position: 1,
}); });
// Act // Act
@@ -413,6 +426,7 @@ describe("savePermissions should save permissions for group", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: groupId, id: groupId,
name: "Group", name: "Group",
position: 1,
}); });
await db.insert(groupPermissions).values({ await db.insert(groupPermissions).values({
groupId, groupId,
@@ -442,6 +456,7 @@ describe("savePermissions should save permissions for group", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: createId(), id: createId(),
name: "Group", name: "Group",
position: 1,
}); });
// Act // Act
@@ -494,6 +509,7 @@ describe("transferOwnership should transfer ownership of group", () => {
id: groupId, id: groupId,
name: "Group", name: "Group",
ownerId: defaultOwnerId, ownerId: defaultOwnerId,
position: 1,
}); });
// Act // Act
@@ -518,6 +534,7 @@ describe("transferOwnership should transfer ownership of group", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: createId(), id: createId(),
name: "Group", name: "Group",
position: 1,
}); });
// Act // Act
@@ -559,10 +576,12 @@ describe("deleteGroup should delete group", () => {
{ {
id: groupId, id: groupId,
name: "Group", name: "Group",
position: 1,
}, },
{ {
id: createId(), id: createId(),
name: "Another group", name: "Another group",
position: 2,
}, },
]); ]);
@@ -586,6 +605,7 @@ describe("deleteGroup should delete group", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: createId(), id: createId(),
name: "Group", name: "Group",
position: 1,
}); });
// Act // Act
@@ -638,6 +658,7 @@ describe("addMember should add member to group", () => {
id: groupId, id: groupId,
name: "Group", name: "Group",
ownerId: defaultOwnerId, ownerId: defaultOwnerId,
position: 1,
}); });
// Act // Act
@@ -715,6 +736,7 @@ describe("addMember should add member to group", () => {
id: groupId, id: groupId,
name: "Group", name: "Group",
ownerId: defaultOwnerId, ownerId: defaultOwnerId,
position: 1,
}); });
// Act // Act
@@ -753,6 +775,7 @@ describe("removeMember should remove member from group", () => {
id: groupId, id: groupId,
name: "Group", name: "Group",
ownerId: defaultOwnerId, ownerId: defaultOwnerId,
position: 1,
}); });
await db.insert(groupMembers).values({ await db.insert(groupMembers).values({
groupId, groupId,
@@ -833,6 +856,7 @@ describe("removeMember should remove member from group", () => {
id: groupId, id: groupId,
name: "Group", name: "Group",
ownerId: defaultOwnerId, ownerId: defaultOwnerId,
position: 1,
}); });
await db.insert(groupMembers).values({ await db.insert(groupMembers).values({
groupId, groupId,

View File

@@ -4,6 +4,7 @@ import { z } from "zod";
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth"; import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
import type { Database } from "@homarr/db"; import type { Database } from "@homarr/db";
import { and, createId, eq, like } from "@homarr/db"; import { and, createId, eq, like } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema"; import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
import { selectUserSchema } from "@homarr/db/validationSchemas"; import { selectUserSchema } from "@homarr/db/validationSchemas";
import { credentialsAdminGroup } from "@homarr/definitions"; import { credentialsAdminGroup } from "@homarr/definitions";
@@ -31,12 +32,14 @@ export const userRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled(); throwIfCredentialsDisabled();
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
const userId = await createUserAsync(ctx.db, input); const userId = await createUserAsync(ctx.db, input);
const groupId = createId(); const groupId = createId();
await ctx.db.insert(groups).values({ await ctx.db.insert(groups).values({
id: groupId, id: groupId,
name: credentialsAdminGroup, name: credentialsAdminGroup,
ownerId: userId, ownerId: userId,
position: maxPosition + 1,
}); });
await ctx.db.insert(groupPermissions).values({ await ctx.db.insert(groupPermissions).values({
groupId, groupId,

View File

@@ -1,8 +1,8 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod"; import { z } from "zod";
import { createBooleanSchema, createDurationSchema, shouldSkipEnvValidation } from "@homarr/common/env-validation";
import { supportedAuthProviders } from "@homarr/definitions"; import { supportedAuthProviders } from "@homarr/definitions";
import { createEnv } from "@homarr/env";
import { createBooleanSchema, createDurationSchema } from "@homarr/env/schemas";
const authProvidersSchema = z const authProvidersSchema = z
.string() .string()
@@ -22,8 +22,7 @@ const authProvidersSchema = z
) )
.default("credentials"); .default("credentials");
const skipValidation = shouldSkipEnvValidation(); const authProviders = authProvidersSchema.safeParse(process.env.AUTH_PROVIDERS).data ?? [];
const authProviders = skipValidation ? [] : authProvidersSchema.parse(process.env.AUTH_PROVIDERS);
export const env = createEnv({ export const env = createEnv({
server: { server: {
@@ -59,32 +58,5 @@ export const env = createEnv({
} }
: {}), : {}),
}, },
client: {}, experimental__runtimeEnv: process.env,
runtimeEnv: {
AUTH_LOGOUT_REDIRECT_URL: process.env.AUTH_LOGOUT_REDIRECT_URL,
AUTH_SESSION_EXPIRY_TIME: process.env.AUTH_SESSION_EXPIRY_TIME,
AUTH_PROVIDERS: process.env.AUTH_PROVIDERS,
AUTH_LDAP_BASE: process.env.AUTH_LDAP_BASE,
AUTH_LDAP_BIND_DN: process.env.AUTH_LDAP_BIND_DN,
AUTH_LDAP_BIND_PASSWORD: process.env.AUTH_LDAP_BIND_PASSWORD,
AUTH_LDAP_GROUP_CLASS: process.env.AUTH_LDAP_GROUP_CLASS,
AUTH_LDAP_GROUP_FILTER_EXTRA_ARG: process.env.AUTH_LDAP_GROUP_FILTER_EXTRA_ARG,
AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE: process.env.AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE,
AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE: process.env.AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE,
AUTH_LDAP_SEARCH_SCOPE: process.env.AUTH_LDAP_SEARCH_SCOPE,
AUTH_LDAP_URI: process.env.AUTH_LDAP_URI,
AUTH_OIDC_CLIENT_ID: process.env.AUTH_OIDC_CLIENT_ID,
AUTH_OIDC_CLIENT_NAME: process.env.AUTH_OIDC_CLIENT_NAME,
AUTH_OIDC_CLIENT_SECRET: process.env.AUTH_OIDC_CLIENT_SECRET,
AUTH_OIDC_ISSUER: process.env.AUTH_OIDC_ISSUER,
AUTH_OIDC_SCOPE_OVERWRITE: process.env.AUTH_OIDC_SCOPE_OVERWRITE,
AUTH_OIDC_GROUPS_ATTRIBUTE: process.env.AUTH_OIDC_GROUPS_ATTRIBUTE,
AUTH_LDAP_USERNAME_ATTRIBUTE: process.env.AUTH_LDAP_USERNAME_ATTRIBUTE,
AUTH_LDAP_USER_MAIL_ATTRIBUTE: process.env.AUTH_LDAP_USER_MAIL_ATTRIBUTE,
AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG: process.env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG,
AUTH_OIDC_AUTO_LOGIN: process.env.AUTH_OIDC_AUTO_LOGIN,
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: process.env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE,
},
skipValidation,
emptyStringAsUndefined: true,
}); });

View File

@@ -28,9 +28,9 @@
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@t3-oss/env-nextjs": "^0.12.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cookies": "^0.9.1", "cookies": "^0.9.1",
"ldapts": "7.3.1", "ldapts": "7.3.1",
@@ -48,7 +48,7 @@
"@types/bcrypt": "5.0.2", "@types/bcrypt": "5.0.2",
"@types/cookies": "0.9.0", "@types/cookies": "0.9.0",
"eslint": "^9.20.1", "eslint": "^9.20.1",
"prettier": "^3.4.2", "prettier": "^3.5.1",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }

View File

@@ -272,7 +272,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
}, },
]; ];
await db.insert(boards).values(createMockBoard({ id: "1" })); await db.insert(boards).values(createMockBoard({ id: "1" }));
await db.insert(groups).values({ id: "1", name: "" }); await db.insert(groups).values({ id: "1", name: "", position: 1 });
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" }); await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" }); await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" });
@@ -325,7 +325,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
}, },
]; ];
await db.insert(boards).values(createMockBoard({ id: "1" })); await db.insert(boards).values(createMockBoard({ id: "1" }));
await db.insert(groups).values({ id: "1", name: "" }); await db.insert(groups).values({ id: "1", name: "", position: 1 });
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" }); await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" }); await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" });
@@ -379,7 +379,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
]; ];
await db.insert(boards).values(createMockBoard({ id: "1" })); await db.insert(boards).values(createMockBoard({ id: "1" }));
await db.insert(boards).values(createMockBoard({ id: "2" })); await db.insert(boards).values(createMockBoard({ id: "2" }));
await db.insert(groups).values({ id: "1", name: "" }); await db.insert(groups).values({ id: "1", name: "", position: 1 });
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" }); await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "2", permission: "view" }); await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "2", permission: "view" });
await db.insert(boardUserPermissions).values({ userId: session.user.id, boardId: "1", permission: "view" }); await db.insert(boardUserPermissions).values({ userId: session.user.id, boardId: "1", permission: "view" });

View File

@@ -301,6 +301,7 @@ describe("authorizeWithLdapCredentials", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: groupId, id: groupId,
name: "homarr_example", name: "homarr_example",
position: 1,
}); });
// Act // Act

View File

@@ -25,6 +25,7 @@ describe("getCurrentUserPermissions", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: "2", id: "2",
name: "test", name: "test",
position: 1,
}); });
await db.insert(groupPermissions).values({ await db.insert(groupPermissions).values({
groupId: "2", groupId: "2",
@@ -51,6 +52,7 @@ describe("getCurrentUserPermissions", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: "2", id: "2",
name: "test", name: "test",
position: 1,
}); });
await db.insert(groupPermissions).values({ await db.insert(groupPermissions).values({
groupId: "2", groupId: "2",
@@ -81,6 +83,7 @@ describe("getCurrentUserPermissions", () => {
await db.insert(groups).values({ await db.insert(groups).values({
id: mockId, id: mockId,
name: "test", name: "test",
position: 1,
}); });
await db.insert(groupMembers).values({ await db.insert(groupMembers).values({
userId: mockId, userId: mockId,

View File

@@ -259,4 +259,5 @@ const createGroupAsync = async (db: Database, name = "test") =>
await db.insert(groups).values({ await db.insert(groups).values({
id: "1", id: "1",
name, name,
position: 1,
}); });

View File

@@ -6,10 +6,11 @@ import { rootCertificates } from "node:tls";
import axios from "axios"; import axios from "axios";
import { fetch } from "undici"; import { fetch } from "undici";
import { env } from "@homarr/common/env";
import { LoggingAgent } from "@homarr/common/server"; import { LoggingAgent } from "@homarr/common/server";
const getCertificateFolder = () => { const getCertificateFolder = () => {
return process.env.NODE_ENV === "production" return env.NODE_ENV === "production"
? path.join("/appdata", "trusted-certificates") ? path.join("/appdata", "trusted-certificates")
: process.env.LOCAL_CERTIFICATE_PATH; : process.env.LOCAL_CERTIFICATE_PATH;
}; };

View File

@@ -0,0 +1,36 @@
import { command } from "@drizzle-team/brocli";
import { db, eq } from "@homarr/db";
import { users } from "@homarr/db/schema";
export const fixUsernames = command({
name: "fix-usernames",
desc: "Changes all credentials usernames to lowercase",
// eslint-disable-next-line no-restricted-syntax
handler: async () => {
if (!process.env.AUTH_PROVIDERS?.toLowerCase().includes("credentials")) {
console.error("Credentials provider is not enabled");
return;
}
const credentialUsers = await db.query.users.findMany({
where: eq(users.provider, "credentials"),
});
for (const user of credentialUsers) {
if (!user.name) continue;
if (user.name !== user.name.toLowerCase()) continue;
await db
.update(users)
.set({
name: user.name.toLowerCase(),
})
.where(eq(users.id, user.id));
console.log(`Changed username from ${user.name} to ${user.name.toLowerCase()}`);
}
console.log("All usernames have been fixed");
},
});

View File

@@ -1,8 +1,9 @@
import { run } from "@drizzle-team/brocli"; import { run } from "@drizzle-team/brocli";
import { fixUsernames } from "./commands/fix-usernames";
import { resetPassword } from "./commands/reset-password"; import { resetPassword } from "./commands/reset-password";
const commands = [resetPassword]; const commands = [resetPassword, fixUsernames];
void run(commands, { void run(commands, {
name: "homarr-cli", name: "homarr-cli",

View File

@@ -1,12 +1,14 @@
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod"; import { z } from "zod";
import { shouldSkipEnvValidation } from "./src/env-validation"; import { createEnv } from "@homarr/env";
const errorSuffix = `, please generate a 64 character secret in hex format or use the following: "${randomBytes(32).toString("hex")}"`; const errorSuffix = `, please generate a 64 character secret in hex format or use the following: "${randomBytes(32).toString("hex")}"`;
export const env = createEnv({ export const env = createEnv({
shared: {
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
},
server: { server: {
SECRET_ENCRYPTION_KEY: z SECRET_ENCRYPTION_KEY: z
.string({ .string({
@@ -24,7 +26,6 @@ export const env = createEnv({
}, },
runtimeEnv: { runtimeEnv: {
SECRET_ENCRYPTION_KEY: process.env.SECRET_ENCRYPTION_KEY, SECRET_ENCRYPTION_KEY: process.env.SECRET_ENCRYPTION_KEY,
NODE_ENV: process.env.NODE_ENV,
}, },
skipValidation: shouldSkipEnvValidation(),
emptyStringAsUndefined: true,
}); });

View File

@@ -27,6 +27,7 @@
}, },
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"next": "15.1.7", "next": "15.1.7",

View File

@@ -1,9 +1,12 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import updateLocale from "dayjs/plugin/updateLocale"; import updateLocale from "dayjs/plugin/updateLocale";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
dayjs.extend(updateLocale); dayjs.extend(updateLocale);
dayjs.extend(duration);
dayjs.updateLocale("en", { dayjs.updateLocale("en", {
relativeTime: { relativeTime: {
future: "in %s", future: "in %s",
@@ -38,6 +41,10 @@ export class Stopwatch {
return dayjs().millisecond(this.startTime).fromNow(true); return dayjs().millisecond(this.startTime).fromNow(true);
} }
getElapsedInMilliseconds() {
return performance.now() - this.startTime;
}
reset() { reset() {
this.startTime = performance.now(); this.startTime = performance.now();
} }

View File

@@ -16,6 +16,7 @@ export interface CreateCronJobCreatorOptions<TAllowedNames extends string> {
interface CreateCronJobOptions { interface CreateCronJobOptions {
runOnStart?: boolean; runOnStart?: boolean;
expectedMaximumDurationInMillis?: number;
beforeStart?: () => MaybePromise<void>; beforeStart?: () => MaybePromise<void>;
} }
@@ -25,6 +26,7 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
options: CreateCronJobOptions, options: CreateCronJobOptions,
creatorOptions: CreateCronJobCreatorOptions<TAllowedNames>, creatorOptions: CreateCronJobCreatorOptions<TAllowedNames>,
) => { ) => {
const expectedMaximumDurationInMillis = options.expectedMaximumDurationInMillis ?? 1000;
return (callback: () => MaybePromise<void>) => { return (callback: () => MaybePromise<void>) => {
const catchingCallbackAsync = async () => { const catchingCallbackAsync = async () => {
try { try {
@@ -34,9 +36,16 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
const beforeCallbackTook = stopwatch.getElapsedInHumanWords(); const beforeCallbackTook = stopwatch.getElapsedInHumanWords();
await callback(); await callback();
const callbackTook = stopwatch.getElapsedInHumanWords(); const callbackTook = stopwatch.getElapsedInHumanWords();
creatorOptions.logger.logInfo( creatorOptions.logger.logDebug(
`The callback of '${name}' cron job succeeded (before callback took ${beforeCallbackTook}, callback took ${callbackTook})`, `The callback of '${name}' cron job succeeded (before callback took ${beforeCallbackTook}, callback took ${callbackTook})`,
); );
const durationInMillis = stopwatch.getElapsedInMilliseconds();
if (durationInMillis > expectedMaximumDurationInMillis) {
creatorOptions.logger.logWarning(
`The callback of '${name}' succeeded but took ${(durationInMillis - expectedMaximumDurationInMillis).toFixed(2)}ms longer than expected (${expectedMaximumDurationInMillis}ms). This may indicate that your network performance, host performance or something else is too slow. If this happens too often, it should be looked into.`,
);
}
await creatorOptions.onCallbackSuccess?.(name); await creatorOptions.onCallbackSuccess?.(name);
} catch (error) { } catch (error) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions // eslint-disable-next-line @typescript-eslint/restrict-template-expressions

View File

@@ -2,6 +2,7 @@ export interface Logger {
logDebug(message: string): void; logDebug(message: string): void;
logInfo(message: string): void; logInfo(message: string): void;
logError(error: unknown): void; logError(error: unknown): void;
logWarning(message: string): void;
} }
export class ConsoleLogger implements Logger { export class ConsoleLogger implements Logger {
@@ -16,4 +17,8 @@ export class ConsoleLogger implements Logger {
public logError(error: unknown) { public logError(error: unknown) {
console.error(error); console.error(error);
} }
public logWarning(message: string) {
console.warn(message);
}
} }

View File

@@ -11,6 +11,7 @@ import { createCronJob } from "../lib";
export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, { export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
runOnStart: true, runOnStart: true,
expectedMaximumDurationInMillis: 10 * 1000,
}).withCallback(async () => { }).withCallback(async () => {
logger.info("Updating icon repository cache..."); logger.info("Updating icon repository cache...");
const stopWatch = new Stopwatch(); const stopWatch = new Stopwatch();

View File

@@ -16,6 +16,10 @@ class WinstonCronJobLogger implements Logger {
logError(error: unknown) { logError(error: unknown) {
logger.error(error); logger.error(error);
} }
logWarning(message: string) {
logger.warn(message);
}
} }
export const { createCronJob, createCronJobGroup } = createCronJobFunctions< export const { createCronJob, createCronJobGroup } = createCronJobFunctions<

View File

@@ -1,7 +1,7 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod"; import { z } from "zod";
import { shouldSkipEnvValidation } from "@homarr/common/env-validation"; import { env as commonEnv } from "@homarr/common/env";
import { createEnv } from "@homarr/env";
const drivers = { const drivers = {
betterSqlite3: "better-sqlite3", betterSqlite3: "better-sqlite3",
@@ -29,7 +29,7 @@ export const env = createEnv({
? { ? {
DB_URL: DB_URL:
// Fallback to the default sqlite file path in production // Fallback to the default sqlite file path in production
process.env.NODE_ENV === "production" && isDriver("better-sqlite3") commonEnv.NODE_ENV === "production" && isDriver("better-sqlite3")
? z.string().default("/appdata/db/db.sqlite") ? z.string().default("/appdata/db/db.sqlite")
: z.string().nonempty(), : z.string().nonempty(),
} }
@@ -49,18 +49,5 @@ export const env = createEnv({
} }
: {}), : {}),
}, },
/** experimental__runtimeEnv: process.env,
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
*/
runtimeEnv: {
DB_DRIVER: process.env.DB_DRIVER,
DB_URL: process.env.DB_URL,
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
DB_PORT: process.env.DB_PORT,
},
skipValidation: shouldSkipEnvValidation(),
emptyStringAsUndefined: true,
}); });

View File

@@ -0,0 +1,25 @@
ALTER TABLE `group` ADD `home_board_id` varchar(64);
--> statement-breakpoint
ALTER TABLE `group` ADD `mobile_home_board_id` varchar(64);
--> statement-breakpoint
ALTER TABLE `group` ADD `position` smallint;
--> statement-breakpoint
CREATE TABLE `temp_group` (
`id` varchar(64) NOT NULL,
`name` varchar(255) NOT NULL,
`position` smallint NOT NULL
);
--> statement-breakpoint
INSERT INTO `temp_group`(`id`, `name`, `position`) SELECT `id`, `name`, ROW_NUMBER() OVER(ORDER BY `name`) FROM `group` WHERE `name` != 'everyone';
--> statement-breakpoint
UPDATE `group` SET `position`=(SELECT `position` FROM `temp_group` WHERE `temp_group`.`id`=`group`.`id`);
--> statement-breakpoint
DROP TABLE `temp_group`;
--> statement-breakpoint
UPDATE `group` SET `position` = -1 WHERE `name` = 'everyone';
--> statement-breakpoint
ALTER TABLE `group` MODIFY `position` smallint NOT NULL;
--> statement-breakpoint
ALTER TABLE `group` ADD CONSTRAINT `group_home_board_id_board_id_fk` FOREIGN KEY (`home_board_id`) REFERENCES `board`(`id`) ON DELETE set null ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE `group` ADD CONSTRAINT `group_mobile_home_board_id_board_id_fk` FOREIGN KEY (`mobile_home_board_id`) REFERENCES `board`(`id`) ON DELETE set null ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE `board` ADD `item_radius` text DEFAULT ('lg') NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE `board` ADD `icon_color` text;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -176,6 +176,27 @@
"when": 1738961147412, "when": 1738961147412,
"tag": "0024_mean_vin_gonzales", "tag": "0024_mean_vin_gonzales",
"breakpoints": true "breakpoints": true
},
{
"idx": 25,
"version": "5",
"when": 1739469710187,
"tag": "0025_add-group-home-board-settings",
"breakpoints": true
},
{
"idx": 26,
"version": "5",
"when": 1739907771355,
"tag": "0026_add-border-radius",
"breakpoints": true
},
{
"idx": 27,
"version": "5",
"when": 1739915526818,
"tag": "0027_acoustic_karma",
"breakpoints": true
} }
] ]
} }

View File

@@ -28,6 +28,7 @@ const seedEveryoneGroupAsync = async (db: Database) => {
await db.insert(groups).values({ await db.insert(groups).values({
id: createId(), id: createId(),
name: everyoneGroup, name: everyoneGroup,
position: -1,
}); });
console.log("Created group 'everyone' through seed"); console.log("Created group 'everyone' through seed");
}; };

View File

@@ -0,0 +1,33 @@
COMMIT TRANSACTION;
--> statement-breakpoint
PRAGMA foreign_keys = OFF;
--> statement-breakpoint
BEGIN TRANSACTION;
--> statement-breakpoint
CREATE TABLE `__new_group` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`owner_id` text,
`home_board_id` text,
`mobile_home_board_id` text,
`position` integer NOT NULL,
FOREIGN KEY (`owner_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`home_board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`mobile_home_board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
INSERT INTO `__new_group`("id", "name", "owner_id", "position") SELECT "id", "name", "owner_id", -1 FROM `group` WHERE "name" = 'everyone';
--> statement-breakpoint
INSERT INTO `__new_group`("id", "name", "owner_id", "position") SELECT "id", "name", "owner_id", ROW_NUMBER() OVER(ORDER BY "name") FROM `group` WHERE "name" != 'everyone';
--> statement-breakpoint
DROP TABLE `group`;
--> statement-breakpoint
ALTER TABLE `__new_group` RENAME TO `group`;
--> statement-breakpoint
CREATE UNIQUE INDEX `group_name_unique` ON `group` (`name`);
--> statement-breakpoint
COMMIT TRANSACTION;
--> statement-breakpoint
PRAGMA foreign_keys = ON;
--> statement-breakpoint
BEGIN TRANSACTION;

View File

@@ -0,0 +1 @@
ALTER TABLE `board` ADD `item_radius` text DEFAULT 'lg' NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE `board` ADD `icon_color` text;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -176,6 +176,27 @@
"when": 1738961178990, "when": 1738961178990,
"tag": "0024_bitter_scrambler", "tag": "0024_bitter_scrambler",
"breakpoints": true "breakpoints": true
},
{
"idx": 25,
"version": "6",
"when": 1739468826756,
"tag": "0025_add-group-home-board-settings",
"breakpoints": true
},
{
"idx": 26,
"version": "6",
"when": 1739907755789,
"tag": "0026_add-border-radius",
"breakpoints": true
},
{
"idx": 27,
"version": "6",
"when": 1739915486467,
"tag": "0027_wooden_blizzard",
"breakpoints": true
} }
] ]
} }

View File

@@ -40,10 +40,11 @@
"@auth/core": "^0.37.4", "@auth/core": "^0.37.4",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@mantine/core": "^7.17.0",
"@paralleldrive/cuid2": "^2.2.2", "@paralleldrive/cuid2": "^2.2.2",
"@t3-oss/env-nextjs": "^0.12.0",
"@testcontainers/mysql": "^10.18.0", "@testcontainers/mysql": "^10.18.0",
"better-sqlite3": "^11.8.1", "better-sqlite3": "^11.8.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
@@ -59,8 +60,8 @@
"@types/better-sqlite3": "7.6.12", "@types/better-sqlite3": "7.6.12",
"dotenv-cli": "^8.0.0", "dotenv-cli": "^8.0.0",
"eslint": "^9.20.1", "eslint": "^9.20.1",
"prettier": "^3.4.2", "prettier": "^3.5.1",
"tsx": "4.19.2", "tsx": "4.19.3",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }

View File

@@ -0,0 +1,11 @@
import { max } from "drizzle-orm";
import type { HomarrDatabase } from "../driver";
import { groups } from "../schema";
export const getMaxGroupPositionAsync = async (db: HomarrDatabase) => {
return await db
.select({ value: max(groups.position) })
.from(groups)
.then((result) => result[0]?.value ?? 1);
};

View File

@@ -1,2 +1,3 @@
export * from "./item"; export * from "./item";
export * from "./server-setting"; export * from "./server-setting";
export * from "./group";

View File

@@ -1,4 +1,5 @@
import type { AdapterAccount } from "@auth/core/adapters"; import type { AdapterAccount } from "@auth/core/adapters";
import type { MantineSize } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates"; import type { DayOfWeek } from "@mantine/dates";
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core"; import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
@@ -9,6 +10,7 @@ import {
int, int,
mysqlTable, mysqlTable,
primaryKey, primaryKey,
smallint,
text, text,
timestamp, timestamp,
tinyint, tinyint,
@@ -150,6 +152,13 @@ export const groups = mysqlTable("group", {
ownerId: varchar({ length: 64 }).references(() => users.id, { ownerId: varchar({ length: 64 }).references(() => users.id, {
onDelete: "set null", onDelete: "set null",
}), }),
homeBoardId: varchar({ length: 64 }).references(() => boards.id, {
onDelete: "set null",
}),
mobileHomeBoardId: varchar({ length: 64 }).references(() => boards.id, {
onDelete: "set null",
}),
position: smallint().notNull(),
}); });
export const groupPermissions = mysqlTable("groupPermission", { export const groupPermissions = mysqlTable("groupPermission", {
@@ -272,6 +281,8 @@ export const boards = mysqlTable("board", {
opacity: int().default(100).notNull(), opacity: int().default(100).notNull(),
customCss: text(), customCss: text(),
columnCount: int().default(10).notNull(), columnCount: int().default(10).notNull(),
iconColor: text(),
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
disableStatus: boolean().default(false).notNull(), disableStatus: boolean().default(false).notNull(),
}); });
@@ -499,6 +510,16 @@ export const groupRelations = relations(groups, ({ one, many }) => ({
fields: [groups.ownerId], fields: [groups.ownerId],
references: [users.id], references: [users.id],
}), }),
homeBoard: one(boards, {
fields: [groups.homeBoardId],
references: [boards.id],
relationName: "groupRelations__board__homeBoardId",
}),
mobileHomeBoard: one(boards, {
fields: [groups.mobileHomeBoardId],
references: [boards.id],
relationName: "groupRelations__board__mobileHomeBoardId",
}),
})); }));
export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({ export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({
@@ -574,6 +595,12 @@ export const boardRelations = relations(boards, ({ many, one }) => ({
}), }),
userPermissions: many(boardUserPermissions), userPermissions: many(boardUserPermissions),
groupPermissions: many(boardGroupPermissions), groupPermissions: many(boardGroupPermissions),
groupHomes: many(groups, {
relationName: "groupRelations__board__homeBoardId",
}),
mobileHomeBoard: many(groups, {
relationName: "groupRelations__board__mobileHomeBoardId",
}),
})); }));
export const sectionRelations = relations(sections, ({ many, one }) => ({ export const sectionRelations = relations(sections, ({ many, one }) => ({

View File

@@ -1,4 +1,5 @@
import type { AdapterAccount } from "@auth/core/adapters"; import type { AdapterAccount } from "@auth/core/adapters";
import type { MantineSize } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates"; import type { DayOfWeek } from "@mantine/dates";
import { relations, sql } from "drizzle-orm"; import { relations, sql } from "drizzle-orm";
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core"; import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
@@ -133,6 +134,13 @@ export const groups = sqliteTable("group", {
ownerId: text().references(() => users.id, { ownerId: text().references(() => users.id, {
onDelete: "set null", onDelete: "set null",
}), }),
homeBoardId: text().references(() => boards.id, {
onDelete: "set null",
}),
mobileHomeBoardId: text().references(() => boards.id, {
onDelete: "set null",
}),
position: int().notNull(),
}); });
export const groupPermissions = sqliteTable("groupPermission", { export const groupPermissions = sqliteTable("groupPermission", {
@@ -258,6 +266,8 @@ export const boards = sqliteTable("board", {
opacity: int().default(100).notNull(), opacity: int().default(100).notNull(),
customCss: text(), customCss: text(),
columnCount: int().default(10).notNull(), columnCount: int().default(10).notNull(),
iconColor: text(),
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
disableStatus: int({ mode: "boolean" }).default(false).notNull(), disableStatus: int({ mode: "boolean" }).default(false).notNull(),
}); });
@@ -486,6 +496,16 @@ export const groupRelations = relations(groups, ({ one, many }) => ({
fields: [groups.ownerId], fields: [groups.ownerId],
references: [users.id], references: [users.id],
}), }),
homeBoard: one(boards, {
fields: [groups.homeBoardId],
references: [boards.id],
relationName: "groupRelations__board__homeBoardId",
}),
mobileHomeBoard: one(boards, {
fields: [groups.mobileHomeBoardId],
references: [boards.id],
relationName: "groupRelations__board__mobileHomeBoardId",
}),
})); }));
export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({ export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({
@@ -561,6 +581,12 @@ export const boardRelations = relations(boards, ({ many, one }) => ({
}), }),
userPermissions: many(boardUserPermissions), userPermissions: many(boardUserPermissions),
groupPermissions: many(boardGroupPermissions), groupPermissions: many(boardGroupPermissions),
groupHomes: many(groups, {
relationName: "groupRelations__board__homeBoardId",
}),
mobileHomeBoard: many(groups, {
relationName: "groupRelations__board__mobileHomeBoardId",
}),
})); }));
export const sectionRelations = relations(sections, ({ many, one }) => ({ export const sectionRelations = relations(sections, ({ many, one }) => ({

View File

@@ -24,7 +24,7 @@
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@t3-oss/env-nextjs": "^0.12.0", "@homarr/env": "workspace:^0.1.0",
"dockerode": "^4.0.4" "dockerode": "^4.0.4"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,7 +1,6 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod"; import { z } from "zod";
import { shouldSkipEnvValidation } from "@homarr/common/env-validation"; import { createEnv } from "@homarr/env";
export const env = createEnv({ export const env = createEnv({
server: { server: {
@@ -9,10 +8,5 @@ export const env = createEnv({
DOCKER_HOSTNAMES: z.string().optional(), DOCKER_HOSTNAMES: z.string().optional(),
DOCKER_PORTS: z.string().optional(), DOCKER_PORTS: z.string().optional(),
}, },
runtimeEnv: { experimental__runtimeEnv: process.env,
DOCKER_HOSTNAMES: process.env.DOCKER_HOSTNAMES,
DOCKER_PORTS: process.env.DOCKER_PORTS,
},
skipValidation: shouldSkipEnvValidation(),
emptyStringAsUndefined: true,
}); });

9
packages/env/eslint.config.js vendored Normal file
View File

@@ -0,0 +1,9 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [],
},
...baseConfig,
];

1
packages/env/index.ts vendored Normal file
View File

@@ -0,0 +1 @@
export * from "./src";

36
packages/env/package.json vendored Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "@homarr/env",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"exports": {
".": "./index.ts",
"./schemas": "./src/schemas.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@t3-oss/env-nextjs": "^0.12.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.20.1",
"typescript": "^5.7.3"
}
}

9
packages/env/src/index.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import { createEnv as createEnvT3 } from "@t3-oss/env-nextjs";
export const defaultEnvOptions = {
emptyStringAsUndefined: true,
skipValidation:
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint",
} satisfies Partial<Parameters<typeof createEnvT3>[0]>;
export const createEnv: typeof createEnvT3 = (options) => createEnvT3({ ...defaultEnvOptions, ...options });

39
packages/env/src/schemas.ts vendored Normal file
View File

@@ -0,0 +1,39 @@
import { z } from "zod";
const trueStrings = ["1", "yes", "t", "true"];
const falseStrings = ["0", "no", "f", "false"];
export const createBooleanSchema = (defaultValue: boolean) =>
z
.string()
.default(defaultValue.toString())
.transform((value, ctx) => {
const normalized = value.trim().toLowerCase();
if (trueStrings.includes(normalized)) return true;
if (falseStrings.includes(normalized)) return false;
throw new Error(`Invalid boolean value for ${ctx.path.join(".")}`);
});
export const createDurationSchema = (defaultValue: `${number}${"s" | "m" | "h" | "d"}`) =>
z
.string()
.regex(/^\d+[smhd]?$/)
.default(defaultValue)
.transform((duration) => {
const lastChar = duration[duration.length - 1] as "s" | "m" | "h" | "d";
if (!isNaN(Number(lastChar))) {
return Number(defaultValue);
}
const multipliers = {
s: 1,
m: 60,
h: 60 * 60,
d: 60 * 60 * 24,
};
const numberDuration = Number(duration.slice(0, -1));
const multiplier = multipliers[lastChar];
return numberDuration * multiplier;
});

8
packages/env/tsconfig.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View File

@@ -26,7 +26,7 @@
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^7.16.3", "@mantine/form": "^7.17.0",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,4 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [...baseConfig];

View File

@@ -0,0 +1 @@
export * from "./src";

View File

@@ -0,0 +1,43 @@
{
"name": "@homarr/forms-collection",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"exports": {
".": "./index.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.17.0",
"react": "19.0.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.20.1",
"typescript": "^5.7.3"
}
}

View File

@@ -24,7 +24,7 @@ import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { UploadMedia } from "~/app/[locale]/manage/medias/_actions/upload-media"; import { UploadMedia } from "../upload-media/upload-media";
import classes from "./icon-picker.module.css"; import classes from "./icon-picker.module.css";
interface IconPickerProps { interface IconPickerProps {
@@ -124,12 +124,7 @@ export const IconPicker = ({ value: propsValue, onChange, error, onFocus, onBlur
<InputBase <InputBase
flex={1} flex={1}
rightSection={<Combobox.Chevron />} rightSection={<Combobox.Chevron />}
leftSection={ leftSection={previewUrl ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null}
previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={previewUrl} alt="" style={{ width: 20, height: 20 }} />
) : null
}
value={search} value={search}
onChange={(event) => { onChange={(event) => {
combobox.openDropdown(); combobox.openDropdown();

View File

@@ -0,0 +1,6 @@
export * from "./new-app/_app-new-form";
export * from "./new-app/_form";
export * from "./icon-picker/icon-picker";
export * from "./upload-media/upload-media";

View File

@@ -10,9 +10,15 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { validation } from "@homarr/validation"; import type { validation } from "@homarr/validation";
import { AppForm } from "../_form"; import { AppForm } from "./_form";
export const AppNewForm = () => { export const AppNewForm = ({
showCreateAnother,
showBackToOverview,
}: {
showCreateAnother: boolean;
showBackToOverview: boolean;
}) => {
const tScoped = useScopedI18n("app.page.create.notification"); const tScoped = useScopedI18n("app.page.create.notification");
const t = useI18n(); const t = useI18n();
const router = useRouter(); const router = useRouter();
@@ -52,8 +58,9 @@ export const AppNewForm = () => {
<AppForm <AppForm
buttonLabels={{ buttonLabels={{
submit: t("common.action.create"), submit: t("common.action.create"),
submitAndCreateAnother: t("common.action.createAnother"), submitAndCreateAnother: showCreateAnother ? t("common.action.createAnother") : undefined,
}} }}
showBackToOverview={showBackToOverview}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
isPending={isPending} isPending={isPending}
/> />

Some files were not shown because too many files have changed in this diff Show More