chore(release): automatic release v0.1.0

This commit is contained in:
homarr-releases[bot]
2024-08-09 19:12:44 +00:00
committed by GitHub
87 changed files with 2124 additions and 765 deletions

View File

@@ -6,6 +6,7 @@ on:
jobs:
approve-renovate-prs:
runs-on: ubuntu-latest
if: github.actor_id == 158783068 # Id of renovate bot see https://api.github.com/users/homarr-renovate%5Bbot%5D
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -21,6 +22,4 @@ jobs:
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
for pr in $(gh pr list --author homarr-renovate[bot] --json number --jq .[].number); do
gh pr review $pr --approve --body "Automatically approved by GitHub Action"
done
gh pr review ${{github.event.pull_request.number}} --approve --body "Automatically approved by GitHub Action"

View File

@@ -12,6 +12,7 @@ RUN turbo prune @homarr/nextjs --docker --out-dir ./next-out
RUN turbo prune @homarr/tasks --docker --out-dir ./tasks-out
RUN turbo prune @homarr/websocket --docker --out-dir ./websocket-out
RUN turbo prune @homarr/db --docker --out-dir ./migration-out
RUN turbo prune @homarr/cli --docker --out-dir ./cli-out
# Add lockfile and package.json's of isolated subworkspace
FROM base AS installer
@@ -34,6 +35,10 @@ COPY --from=builder /app/migration-out/json/ .
COPY --from=builder /app/migration-out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm && pnpm install
COPY --from=builder /app/cli-out/json/ .
COPY --from=builder /app/cli-out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm && pnpm install
COPY --from=builder /app/next-out/json/ .
COPY --from=builder /app/next-out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm && pnpm install
@@ -45,6 +50,7 @@ COPY --from=builder /app/tasks-out/full/ .
COPY --from=builder /app/websocket-out/full/ .
COPY --from=builder /app/next-out/full/ .
COPY --from=builder /app/migration-out/full/ .
COPY --from=builder /app/cli-out/full/ .
# Copy static data as it is not part of the build
COPY static-data ./static-data
@@ -55,15 +61,23 @@ RUN corepack enable pnpm && pnpm build
FROM base AS runner
WORKDIR /app
RUN apk add --no-cache redis
RUN apk add --no-cache redis bash
RUN mkdir /appdata
RUN mkdir /appdata/db
RUN mkdir /appdata/redis
VOLUME /appdata
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Enable homarr cli
COPY --from=installer --chown=nextjs:nodejs /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs
RUN echo $'#!/bin/bash\ncd /app/apps/cli && node ./cli.cjs "$@"' > /usr/bin/homarr
RUN chmod +x /usr/bin/homarr
# Don't run production as root
RUN chown -R nextjs:nodejs /appdata
USER nextjs

View File

@@ -32,17 +32,17 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.11.2",
"@mantine/core": "^7.11.2",
"@mantine/hooks": "^7.11.2",
"@mantine/modals": "^7.11.2",
"@mantine/tiptap": "^7.11.2",
"@mantine/colors-generator": "^7.12.0",
"@mantine/core": "^7.12.0",
"@mantine/hooks": "^7.12.0",
"@mantine/modals": "^7.12.0",
"@mantine/tiptap": "^7.12.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@t3-oss/env-nextjs": "^0.11.0",
"@tanstack/react-query": "^5.51.21",
"@tanstack/react-query-devtools": "^5.51.21",
"@tanstack/react-query-next-experimental": "5.51.21",
"@tabler/icons-react": "^3.11.0",
"@tanstack/react-query": "^5.51.23",
"@tanstack/react-query-devtools": "^5.51.23",
"@tanstack/react-query-next-experimental": "5.51.23",
"@tabler/icons-react": "^3.12.0",
"@trpc/client": "next",
"@trpc/next": "next",
"@trpc/react-query": "next",
@@ -56,7 +56,7 @@
"dotenv": "^16.4.5",
"flag-icons": "^7.2.3",
"glob": "^11.0.0",
"jotai": "^2.9.1",
"jotai": "^2.9.2",
"mantine-react-table": "2.0.0-beta.6",
"next": "^14.2.5",
"postcss-preset-mantine": "^1.17.0",
@@ -74,7 +74,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "2.4.4",
"@types/node": "^20.14.14",
"@types/node": "^20.14.15",
"@types/prismjs": "^1.26.4",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",

View File

@@ -1,14 +1,46 @@
"use client";
import { createContext, useContext, useEffect } from "react";
import type { PropsWithChildren } from "react";
import dayjs from "dayjs";
import type { Session } from "@homarr/auth";
import { SessionProvider } from "@homarr/auth/client";
import { SessionProvider, signIn } from "@homarr/auth/client";
interface AuthProviderProps {
interface AuthProviderProps extends AuthContextProps {
session: Session | null;
}
export const AuthProvider = ({ children, session }: PropsWithChildren<AuthProviderProps>) => {
return <SessionProvider session={session}>{children}</SessionProvider>;
export const AuthProvider = ({ children, session, logoutUrl }: PropsWithChildren<AuthProviderProps>) => {
useLoginRedirectOnSessionExpiry(session);
return (
<SessionProvider session={session}>
<AuthContext.Provider value={{ logoutUrl }}>{children}</AuthContext.Provider>
</SessionProvider>
);
};
interface AuthContextProps {
logoutUrl: string | undefined;
}
const AuthContext = createContext<AuthContextProps | null>(null);
export const useAuthContext = () => {
const context = useContext(AuthContext);
if (!context) throw new Error("useAuthContext must be used within an AuthProvider");
return context;
};
const useLoginRedirectOnSessionExpiry = (session: Session | null) => {
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
if (!session) return () => {};
//setTimeout doesn't allow for a number higher than 2147483647 (2³¹-1 , or roughly 24 days)
const timeout = setTimeout(() => void signIn(), Math.min(dayjs(session.expires).diff(), 2147483647));
return () => clearTimeout(timeout);
}, [session]);
};

View File

@@ -10,7 +10,7 @@ import { LoginForm } from "./_login-form";
interface LoginProps {
searchParams: {
redirectAfterLogin?: string;
callbackUrl?: string;
};
}
@@ -18,7 +18,7 @@ export default async function Login({ searchParams }: LoginProps) {
const session = await auth();
if (session) {
redirect(searchParams.redirectAfterLogin ?? "/");
redirect(searchParams.callbackUrl ?? "/");
}
const t = await getScopedI18n("user.page.login");
@@ -40,7 +40,7 @@ export default async function Login({ searchParams }: LoginProps) {
providers={env.AUTH_PROVIDERS}
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
isOidcAutoLoginEnabled={env.AUTH_OIDC_AUTO_LOGIN}
callbackUrl={searchParams.redirectAfterLogin ?? "/"}
callbackUrl={searchParams.callbackUrl ?? "/"}
/>
</Card>
</Stack>

View File

@@ -8,6 +8,7 @@ import "~/styles/scroll-area.scss";
import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core";
import { env } from "@homarr/auth/env.mjs";
import { auth } from "@homarr/auth/next";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";
@@ -56,7 +57,7 @@ export default function Layout(props: { children: React.ReactNode; params: { loc
const StackedProvider = composeWrappers([
async (innerProps) => {
const session = await auth();
return <AuthProvider session={session} {...innerProps} />;
return <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
},
(innerProps) => <JotaiProvider {...innerProps} />,
(innerProps) => <TRPCReactProvider {...innerProps} />,

View File

@@ -4,6 +4,7 @@ import { IconApps, IconPencil } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { ManageContainer } from "~/components/manage/manage-container";
@@ -69,8 +70,8 @@ const AppCard = async ({ app }: AppCardProps) => {
</Text>
)}
{app.href && (
<Anchor href={app.href} lineClamp={1} size="sm" w="min-content">
{app.href}
<Anchor href={parseAppHrefWithVariablesServer(app.href)} lineClamp={1} size="sm" w="min-content">
{parseAppHrefWithVariablesServer(app.href)}
</Anchor>
)}
</Stack>

View File

@@ -30,6 +30,8 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
onSuccess: async (values) => {
await mutateAsync({
name: values.name,
columnCount: values.columnCount,
isPublic: values.isPublic,
});
},
boardNames,

View File

@@ -2,7 +2,7 @@
import type { MantineColor } from "@mantine/core";
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
import { IconPlayerPlay, IconPlayerStop, IconRotateClockwise, IconTrash } from "@tabler/icons-react";
import { IconPlayerPlay, IconPlayerStop, IconRefresh, IconRotateClockwise, IconTrash } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
@@ -46,10 +46,12 @@ const createColumns = (
accessorKey: "image",
header: t("docker.field.containerImage.label"),
maxSize: 200,
Cell({ renderedCellValue }) {
Cell({ renderedCellValue, cell }) {
return (
<Box maw={200}>
<Text truncate="end">{renderedCellValue}</Text>
<Text truncate="end" title={cell.row.original.image}>
{renderedCellValue}
</Text>
</Box>
);
},
@@ -93,6 +95,35 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
},
initialState: { density: "xs", showGlobalFilter: true },
renderTopToolbarCustomActions: () => {
const utils = clientApi.useUtils();
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
async onSuccess() {
await utils.docker.getContainers.invalidate();
showSuccessNotification({
title: tDocker("action.refresh.notification.success.title"),
message: tDocker("action.refresh.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: tDocker("action.refresh.notification.error.title"),
message: tDocker("action.refresh.notification.error.message"),
});
},
});
return (
<Button
variant="default"
rightSection={<IconRefresh size="1rem" />}
onClick={() => mutate()}
loading={isPending}
>
{tDocker("action.refresh.label")}
</Button>
);
},
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
return (
<Group gap={"sm"}>

View File

@@ -1,12 +1,19 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { DockerTable } from "./docker-table";
export default async function DockerPage() {
const session = await auth();
if (!session?.user || !session.user.permissions.includes("admin")) {
notFound();
}
const { containers, timestamp } = await api.docker.getContainers();
const tDocker = await getScopedI18n("docker");

View File

@@ -0,0 +1,68 @@
"use client";
import { Button, Group, Select, Stack } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface ChangeHomeBoardFormProps {
user: RouterOutputs["user"]["getById"];
boardsData: { value: string; label: string }[];
}
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeHomeBoardId.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
homeBoardId: variables.homeBoardId,
});
showSuccessNotification({
message: t("user.action.changeHomeBoard.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changeHomeBoard.notification.error.message"),
});
},
});
const form = useZodForm(validation.user.changeHomeBoard, {
initialValues: {
homeBoardId: user.homeBoardId ?? "",
},
});
const handleSubmit = (values: FormType) => {
mutate({
userId: user.id,
...values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select w="100%" data={boardsData} {...form.getInputProps("homeBoardId")} />
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof validation.user.changeHomeBoard>;

View File

@@ -1,12 +0,0 @@
import { Stack, Title } from "@mantine/core";
import { LanguageCombobox } from "~/components/language/language-combobox";
export const ProfileLanguageChange = () => {
return (
<Stack mb="lg">
<Title order={2}>Language & Region</Title>
<LanguageCombobox />
</Stack>
);
};

View File

@@ -6,14 +6,15 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { LanguageCombobox } from "~/components/language/language-combobox";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access";
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form";
import { ProfileLanguageChange } from "./_components/_profile-language-change";
interface Props {
params: {
@@ -54,13 +55,17 @@ export default async function EditUserPage({ params }: Props) {
notFound();
}
const boards = await api.board.getAllBoards();
const isCredentialsUser = user.provider === "credentials";
return (
<Stack>
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
{t("management.page.user.fieldsDisabledExternalProvider")}
</Alert>
{!isCredentialsUser && (
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
{t("management.page.user.fieldsDisabledExternalProvider")}
</Alert>
)}
<Title>{tGeneral("title")}</Title>
<Group gap="xl">
@@ -72,7 +77,21 @@ export default async function EditUserPage({ params }: Props) {
</Box>
</Group>
<ProfileLanguageChange />
<Stack mb="lg">
<Title order={2}>{tGeneral("item.language")}</Title>
<LanguageCombobox />
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.board")}</Title>
<ChangeHomeBoardForm
user={user}
boardsData={boards.map((board) => ({
value: board.id,
label: board.name,
}))}
/>
</Stack>
{isCredentialsUser && (
<DangerZoneRoot>

View File

@@ -45,6 +45,10 @@ interface CreateItem {
kind: WidgetKind;
}
interface DuplicateItem {
itemId: string;
}
export const useItemActions = () => {
const { updateBoard } = useUpdateBoard();
@@ -87,6 +91,38 @@ export const useItemActions = () => {
[updateBoard],
);
const duplicateItem = useCallback(
({ itemId }: DuplicateItem) => {
updateBoard((previous) => {
const itemToDuplicate = previous.sections
.flatMap((section) => section.items)
.find((item) => item.id === itemId);
if (!itemToDuplicate) return previous;
const newItem = {
...itemToDuplicate,
id: createId(),
yOffset: undefined,
xOffset: undefined,
} satisfies Omit<Item, "yOffset" | "xOffset"> & { yOffset?: number; xOffset?: number };
return {
...previous,
sections: previous.sections.map((section) => {
// Return same section if item is not in it
if (!section.items.some((item) => item.id === itemId)) return section;
return {
...section,
items: section.items.concat(newItem as unknown as Item),
};
}),
};
});
},
[updateBoard],
);
const updateItemOptions = useCallback(
({ itemId, newOptions }: UpdateItemOptions) => {
updateBoard((previous) => {
@@ -258,6 +294,7 @@ export const useItemActions = () => {
updateItemOptions,
updateItemAdvancedOptions,
updateItemIntegrations,
duplicateItem,
createItem,
};
};

View File

@@ -2,7 +2,7 @@ import type { RefObject } from "react";
import { useEffect, useMemo, useRef } from "react";
import { ActionIcon, Card, Menu } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import combineClasses from "clsx";
import { ErrorBoundary } from "react-error-boundary";
@@ -147,7 +147,8 @@ const ItemMenu = ({
const { openModal } = useModalAction(WidgetEditModal);
const { openConfirmModal } = useConfirmModal();
const [isEditMode] = useEditMode();
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, removeItem } = useItemActions();
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
@@ -216,6 +217,9 @@ const ItemMenu = ({
{tItem("action.edit")}
</Menu.Item>
<Menu.Item leftSection={<IconLayoutKanban size={16} />}>{tItem("action.move")}</Menu.Item>
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
{tItem("action.duplicate")}
</Menu.Item>
<Menu.Divider />
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>

View File

@@ -16,6 +16,7 @@ interface IconPickerProps {
export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: IconPickerProps) => {
const [value, setValue] = useState<string>(initialValue ?? "");
const [search, setSearch] = useState(initialValue ?? "");
const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null);
const t = useScopedI18n("common");
@@ -52,6 +53,7 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
<Combobox
onOptionSubmit={(value) => {
setValue(value);
setPreviewUrl(value);
setSearch(value);
onChange(value);
combobox.closeDropdown();
@@ -62,12 +64,15 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
<Combobox.Target>
<InputBase
rightSection={<Combobox.Chevron />}
// eslint-disable-next-line @next/next/no-img-element
leftSection={previewUrl ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null}
value={search}
onChange={(event) => {
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
setValue(event.currentTarget.value);
setPreviewUrl(null);
onChange(event.currentTarget.value);
}}
onClick={() => combobox.openDropdown()}
@@ -78,6 +83,7 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
onBlur={(event) => {
onBlur?.(event);
combobox.closeDropdown();
setPreviewUrl(value);
setSearch(value || "");
}}
rightSectionPointerEvents="none"

View File

@@ -1,31 +1,36 @@
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { Button, Group, InputWrapper, Slider, Stack, Switch, TextInput } from "@mantine/core";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { validation, z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { createCustomErrorParams } from "@homarr/validation/form";
interface InnerProps {
boardNames: string[];
onSuccess: ({ name }: { name: string }) => Promise<void>;
onSuccess: (props: { name: string; columnCount: number; isPublic: boolean }) => Promise<void>;
}
export const AddBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const form = useZodForm(
z.object({
name: validation.board.byName.shape.name.refine((value) => !innerProps.boardNames.includes(value), {
params: createCustomErrorParams("boardAlreadyExists"),
}),
validation.board.create.refine((value) => !innerProps.boardNames.includes(value.name), {
params: createCustomErrorParams("boardAlreadyExists"),
path: ["name"],
}),
{
initialValues: {
name: "",
columnCount: 10,
isPublic: false,
},
},
);
const columnCountChecks = validation.board.create.shape.columnCount._def.checks;
const minColumnCount = columnCountChecks.find((check) => check.kind === "min")?.value;
const maxColumnCount = columnCountChecks.find((check) => check.kind === "max")?.value;
return (
<form
onSubmit={form.onSubmit((values) => {
@@ -35,11 +40,21 @@ export const AddBoardModal = createModal<InnerProps>(({ actions, innerProps }) =
>
<Stack>
<TextInput label={t("board.field.name.label")} data-autofocus {...form.getInputProps("name")} />
<InputWrapper label={t("board.field.columnCount.label")} {...form.getInputProps("columnCount")}>
<Slider min={minColumnCount} max={maxColumnCount} step={1} {...form.getInputProps("columnCount")} />
</InputWrapper>
<Switch
label={t("board.field.isPublic.label")}
description={t("board.field.isPublic.description")}
{...form.getInputProps("isPublic")}
/>
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button disabled={!form.isValid()} type="submit" color="teal">
<Button type="submit" color="teal">
{t("common.action.create")}
</Button>
</Group>

View File

@@ -23,6 +23,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import "flag-icons/css/flag-icons.min.css";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { LanguageCombobox } from "./language/language-combobox";
interface UserAvatarMenuProps {
@@ -40,6 +41,7 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
const session = useSession();
const router = useRouter();
const { logoutUrl } = useAuthContext();
const { openModal } = useModalAction(LogoutModal);
const handleSignout = useCallback(async () => {
@@ -48,6 +50,10 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
});
openModal({
onTimeout: () => {
if (logoutUrl) {
window.location.assign(logoutUrl);
return;
}
router.refresh();
},
});

View File

@@ -37,13 +37,13 @@
"@homarr/cron-job-runner": "workspace:^0.1.0",
"dotenv": "^16.4.5",
"superjson": "2.2.1",
"undici": "6.19.5"
"undici": "6.19.7"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^20.14.14",
"@types/node": "^20.14.15",
"dotenv-cli": "^7.4.2",
"eslint": "^9.8.0",
"prettier": "^3.3.3",

View File

@@ -4,7 +4,7 @@
"engines": {
"node": ">=20.16.0"
},
"packageManager": "pnpm@9.6.0",
"packageManager": "pnpm@9.7.0",
"scripts": {
"build": "turbo build",
"clean": "git clean -xdf node_modules",
@@ -15,6 +15,8 @@
"db:migration:mysql:generate": "pnpm -F db migration:mysql:generate",
"db:migration:sqlite:run": "pnpm -F db migration:sqlite:run",
"db:migration:mysql:run": "pnpm -F db migration:mysql:run",
"cli": "pnpm with-env tsx packages/cli/index.ts",
"with-env": "dotenv -e .env --",
"dev": "turbo dev --parallel",
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
"format": "turbo format --continue -- --cache --cache-location node_modules/.cache/.prettiercache",
@@ -28,7 +30,7 @@
},
"devDependencies": {
"@homarr/prettier-config": "workspace:^0.1.0",
"@turbo/gen": "^2.0.11",
"@turbo/gen": "^2.0.12",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^2.0.5",
"@vitest/ui": "^2.0.5",
@@ -36,9 +38,9 @@
"jsdom": "^24.1.1",
"prettier": "^3.3.3",
"testcontainers": "^10.11.0",
"turbo": "^2.0.11",
"turbo": "^2.0.12",
"typescript": "^5.5.4",
"vite-tsconfig-paths": "^4.3.2",
"vite-tsconfig-paths": "^5.0.1",
"vitest": "^2.0.5"
},
"prettier": "@homarr/prettier-config"

View File

@@ -102,6 +102,8 @@ export const boardRouter = createTRPCRouter({
await transaction.insert(boards).values({
id: boardId,
name: input.name,
isPublic: input.isPublic,
columnCount: input.columnCount,
creatorId: ctx.session.user.id,
});
await transaction.insert(sections).values({

View File

@@ -8,7 +8,7 @@ import type { DockerContainerState } from "@homarr/definitions";
import { createCacheChannel } from "@homarr/redis";
import { z } from "@homarr/validation";
import { createTRPCRouter, permissionRequiredProcedure, publicProcedure } from "../../trpc";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
import { DockerSingleton } from "./docker-singleton";
const dockerCache = createCacheChannel<{
@@ -16,7 +16,7 @@ const dockerCache = createCacheChannel<{
}>("docker-containers", 5 * 60 * 1000);
export const dockerRouter = createTRPCRouter({
getContainers: publicProcedure.query(async () => {
getContainers: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
const { timestamp, data } = await dockerCache.consumeAsync(async () => {
const dockerInstances = DockerSingleton.getInstance();
const containers = await Promise.all(
@@ -59,6 +59,10 @@ export const dockerRouter = createTRPCRouter({
timestamp,
};
}),
invalidate: permissionRequiredProcedure.requiresPermission("admin").mutation(async () => {
await dockerCache.invalidateAsync();
return;
}),
startAll: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ ids: z.array(z.string()) }))

View File

@@ -294,12 +294,14 @@ describe("createBoard should create a new board", () => {
});
// Act
await caller.createBoard({ name: "newBoard" });
await caller.createBoard({ name: "newBoard", columnCount: 24, isPublic: true });
// Assert
const dbBoard = await db.query.boards.findFirst();
expect(dbBoard).toBeDefined();
expect(dbBoard?.name).toBe("newBoard");
expect(dbBoard?.columnCount).toBe(24);
expect(dbBoard?.isPublic).toBe(true);
expect(dbBoard?.creatorId).toBe(defaultCreatorId);
const dbSection = await db.query.sections.findFirst();
@@ -314,7 +316,7 @@ describe("createBoard should create a new board", () => {
const caller = boardRouter.createCaller({ db, session: defaultSession });
// Act
const actAsync = async () => await caller.createBoard({ name: "newBoard" });
const actAsync = async () => await caller.createBoard({ name: "newBoard", columnCount: 12, isPublic: true });
// Assert
await expect(actAsync()).rejects.toThrowError("Permission denied");

View File

@@ -0,0 +1,91 @@
import { TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { objectKeys } from "@homarr/common";
import type { Database } from "@homarr/db";
import { getPermissionsWithChildren } from "@homarr/definitions";
import type { GroupPermissionKey } from "@homarr/definitions";
import type { RouterInputs } from "../../..";
import { dockerRouter } from "../../docker/docker-router";
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
vi.mock("@homarr/redis", () => ({
createCacheChannel: () => ({
// eslint-disable-next-line @typescript-eslint/require-await
consumeAsync: async () => ({
timestamp: new Date().toISOString(),
data: { containers: [] },
}),
// eslint-disable-next-line @typescript-eslint/no-empty-function
invalidateAsync: async () => {},
}),
}));
const createSessionWithPermissions = (...permissions: GroupPermissionKey[]) =>
({
user: {
id: "1",
permissions,
},
expires: new Date().toISOString(),
}) satisfies Session;
const procedureKeys = objectKeys(dockerRouter._def.procedures);
const validInputs: {
[key in (typeof procedureKeys)[number]]: RouterInputs["docker"][key];
} = {
getContainers: undefined,
startAll: { ids: ["1"] },
stopAll: { ids: ["1"] },
restartAll: { ids: ["1"] },
removeAll: { ids: ["1"] },
invalidate: undefined,
};
describe("All procedures should only be accessible for users with admin permission", () => {
test.each(procedureKeys)("Procedure %s should be accessible for users with admin permission", async (procedure) => {
// Arrange
const caller = dockerRouter.createCaller({
db: null as unknown as Database,
session: createSessionWithPermissions("admin"),
});
// Act
const act = () => caller[procedure](validInputs[procedure] as never);
await expect(act()).resolves.not.toThrow();
});
test.each(procedureKeys)("Procedure %s should not be accessible with other permissions", async (procedure) => {
// Arrange
const groupPermissionsWithoutAdmin = getPermissionsWithChildren(["admin"]).filter(
(permission) => permission !== "admin",
);
const caller = dockerRouter.createCaller({
db: null as unknown as Database,
session: createSessionWithPermissions(...groupPermissionsWithoutAdmin),
});
// Act
const act = () => caller[procedure](validInputs[procedure] as never);
await expect(act()).rejects.toThrow(new TRPCError({ code: "FORBIDDEN", message: "Permission denied" }));
});
test.each(procedureKeys)("Procedure %s should not be accessible without session", async (procedure) => {
// Arrange
const caller = dockerRouter.createCaller({
db: null as unknown as Database,
session: null,
});
// Act
const act = () => caller[procedure](validInputs[procedure] as never);
await expect(act()).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED" }));
});
});

View File

@@ -156,6 +156,7 @@ export const userRouter = createTRPCRouter({
emailVerified: true,
image: true,
provider: true,
homeBoardId: true,
},
where: eq(users.id, input.userId),
});
@@ -266,6 +267,39 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, input.userId));
}),
changeHomeBoardId: protectedProcedure
.input(validation.user.changeHomeBoard.and(z.object({ userId: z.string() })))
.mutation(async ({ input, ctx }) => {
const user = ctx.session.user;
// Only admins can change other users' passwords
if (!user.permissions.includes("admin") && user.id !== input.userId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const dbUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
},
where: eq(users.id, input.userId),
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
homeBoardId: input.homeBoardId,
})
.where(eq(users.id, input.userId));
}),
});
const createUserAsync = async (db: Database, input: z.infer<typeof validation.user.create>) => {

View File

@@ -1,32 +1,79 @@
import { TRPCError } from "@trpc/server";
import { PiHoleIntegration } from "@homarr/integrations";
import { AdGuardHomeIntegration, PiHoleIntegration } from "@homarr/integrations";
import type { DnsHoleSummary } from "@homarr/integrations/types";
import { logger } from "@homarr/log";
import { createCacheChannel } from "@homarr/redis";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { controlsInputSchema } from "../../../../integrations/src/pi-hole/pi-hole-types";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const dnsHoleRouter = createTRPCRouter({
summary: publicProcedure.unstable_concat(createOneIntegrationMiddleware("query", "piHole")).query(async ({ ctx }) => {
const cache = createCacheChannel<DnsHoleSummary>(`dns-hole-summary:${ctx.integration.id}`);
summary: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", "piHole", "adGuardHome"))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const cache = createCacheChannel<DnsHoleSummary>(`dns-hole-summary:${integration.id}`);
const { data } = await cache.consumeAsync(async () => {
let client;
switch (integration.kind) {
case "piHole":
client = new PiHoleIntegration(integration);
break;
case "adGuardHome":
client = new AdGuardHomeIntegration(integration);
break;
}
const { data } = await cache.consumeAsync(async () => {
const client = new PiHoleIntegration(ctx.integration);
return await client.getSummaryAsync().catch((err) => {
logger.error("dns-hole router - ", err);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Failed to fetch DNS Hole summary for ${integration.name} (${integration.id})`,
});
});
});
return await client.getSummaryAsync().catch((err) => {
logger.error("dns-hole router - ", err);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Failed to fetch DNS Hole summary for ${ctx.integration.name} (${ctx.integration.id})`,
});
});
});
return {
integrationId: integration.id,
integrationKind: integration.kind,
summary: data,
};
}),
);
return results;
}),
return {
...data,
integrationId: ctx.integration.id,
};
}),
enable: publicProcedure
.unstable_concat(createOneIntegrationMiddleware("interact", "piHole", "adGuardHome"))
.mutation(async ({ ctx }) => {
let client;
switch (ctx.integration.kind) {
case "piHole":
client = new PiHoleIntegration(ctx.integration);
break;
case "adGuardHome":
client = new AdGuardHomeIntegration(ctx.integration);
break;
}
await client.enableAsync();
}),
disable: publicProcedure
.input(controlsInputSchema)
.unstable_concat(createOneIntegrationMiddleware("interact", "piHole", "adGuardHome"))
.mutation(async ({ ctx, input }) => {
let client;
switch (ctx.integration.kind) {
case "piHole":
client = new PiHoleIntegration(ctx.integration);
break;
case "adGuardHome":
client = new AdGuardHomeIntegration(ctx.integration);
break;
}
await client.disableAsync(input.duration);
}),
});

View File

@@ -7,7 +7,8 @@ import { eq, inArray } from "@homarr/db";
import { groupMembers, groupPermissions } from "@homarr/db/schema/sqlite";
import { getPermissionsWithChildren } from "@homarr/definitions";
import { expireDateAfter, generateSessionToken, sessionMaxAgeInSeconds, sessionTokenCookieName } from "./session";
import { env } from "./env.mjs";
import { expireDateAfter, generateSessionToken, sessionTokenCookieName } from "./session";
export const getCurrentUserPermissionsAsync = async (db: Database, userId: string) => {
const dbGroupMembers = await db.query.groupMembers.findMany({
@@ -53,18 +54,18 @@ export const createSignInCallback =
}
const sessionToken = generateSessionToken();
const sessionExpiry = expireDateAfter(sessionMaxAgeInSeconds);
const sessionExpires = expireDateAfter(env.AUTH_SESSION_EXPIRY_TIME);
await adapter.createSession({
sessionToken,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
userId: user.id!,
expires: sessionExpiry,
expires: sessionExpires,
});
cookies().set(sessionTokenCookieName, sessionToken, {
path: "/",
expires: sessionExpiry,
expires: sessionExpires,
httpOnly: true,
sameSite: "lax",
secure: true,

View File

@@ -7,12 +7,13 @@ import { db } from "@homarr/db";
import { adapter } from "./adapter";
import { createSessionCallback, createSignInCallback } from "./callbacks";
import { env } from "./env.mjs";
import { createCredentialsConfiguration } from "./providers/credentials/credentials-provider";
import { EmptyNextAuthProvider } from "./providers/empty/empty-provider";
import { filterProviders } from "./providers/filter-providers";
import { OidcProvider } from "./providers/oidc/oidc-provider";
import { createRedirectUri } from "./redirect";
import { sessionMaxAgeInSeconds, sessionTokenCookieName } from "./session";
import { sessionTokenCookieName } from "./session";
export const createConfiguration = (isCredentialsRequest: boolean, headers: ReadonlyHeaders | null) =>
NextAuth({
@@ -43,7 +44,7 @@ export const createConfiguration = (isCredentialsRequest: boolean, headers: Read
secret: "secret-is-not-defined-yet", // TODO: This should be added later
session: {
strategy: "database",
maxAge: sessionMaxAgeInSeconds,
maxAge: env.AUTH_SESSION_EXPIRY_TIME,
},
pages: {
signIn: "/auth/login",

View File

@@ -23,6 +23,29 @@ const authProvidersSchema = z
)
.default("credentials");
const createDurationSchema = (defaultValue) =>
z
.string()
.regex(/^\d+[smhd]?$/)
.default(defaultValue)
.transform((duration) => {
const lastChar = duration[duration.length - 1];
if (!isNaN(Number(lastChar))) {
return Number(defaultValue);
}
const multipliers = {
s: 1,
m: 60,
h: 60 * 60,
d: 60 * 60 * 24,
};
const numberDuration = Number(duration.slice(0, -1));
const multiplier = multipliers[lastChar];
return numberDuration * multiplier;
});
const booleanSchema = z
.string()
.default("false")
@@ -39,6 +62,8 @@ const authProviders = skipValidation ? [] : authProvidersSchema.parse(process.en
export const env = createEnv({
server: {
AUTH_LOGOUT_REDIRECT_URL: z.string().url().optional(),
AUTH_SESSION_EXPIRY_TIME: createDurationSchema("30d"),
AUTH_SECRET: process.env.NODE_ENV === "production" ? z.string().min(1) : z.string().min(1).optional(),
AUTH_PROVIDERS: authProvidersSchema,
...(authProviders.includes("oidc")
@@ -70,6 +95,8 @@ export const env = createEnv({
},
client: {},
runtimeEnv: {
AUTH_LOGOUT_REDIRECT_URL: process.env.AUTH_LOGOUT_REDIRECT_URL,
AUTH_SESSION_EXPIRY_TIME: process.env.AUTH_SESSION_EXPIRY_TIME,
AUTH_SECRET: process.env.AUTH_SECRET,
AUTH_PROVIDERS: process.env.AUTH_PROVIDERS,
AUTH_LDAP_BASE: process.env.AUTH_LDAP_BASE,

View File

@@ -1,5 +1,7 @@
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
import { extractBaseUrlFromHeaders } from "@homarr/common";
/**
* The redirect_uri is constructed to work behind a reverse proxy. It is constructed from the headers x-forwarded-proto and x-forwarded-host.
* @param headers
@@ -11,16 +13,9 @@ export const createRedirectUri = (headers: ReadonlyHeaders | null, pathname: str
return pathname;
}
let protocol = headers.get("x-forwarded-proto") ?? "http";
// @see https://support.glitch.com/t/x-forwarded-proto-contains-multiple-protocols/17219
if (protocol.includes(",")) {
protocol = protocol.includes("https") ? "https" : "http";
}
const baseUrl = extractBaseUrlFromHeaders(headers);
const path = pathname.startsWith("/") ? pathname : `/${pathname}`;
const host = headers.get("x-forwarded-host") ?? headers.get("host");
return `${protocol}://${host}${path}`;
return `${baseUrl}${path}`;
};

View File

@@ -1,11 +1,10 @@
import { randomUUID } from "crypto";
import type { Session } from "next-auth";
import { generateSecureRandomToken } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { getCurrentUserPermissionsAsync } from "./callbacks";
export const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days
export const sessionTokenCookieName = "next-auth.session-token";
export const expireDateAfter = (seconds: number) => {
@@ -13,7 +12,7 @@ export const expireDateAfter = (seconds: number) => {
};
export const generateSessionToken = () => {
return randomUUID();
return generateSecureRandomToken(48);
};
export const getSessionFromTokenAsync = async (db: Database, token: string | undefined): Promise<Session | null> => {

View File

@@ -132,6 +132,13 @@ const createAdapter = () => {
type SessionExport = typeof import("../session");
const mockSessionToken = "e9ef3010-6981-4a81-b9d6-8495d09cf3b5";
const mockSessionExpiry = new Date("2023-07-01");
vi.mock("../env.mjs", () => {
return {
env: {
AUTH_SESSION_EXPIRY_TIME: 60 * 60 * 24 * 7,
},
};
});
vi.mock("../session", async (importOriginal) => {
const mod = await importOriginal<SessionExport>();
@@ -185,7 +192,7 @@ describe("createSignInCallback", () => {
expect(result).toBe(false);
});
it("should call adapter.createSession with correct input", async () => {
test("should call adapter.createSession with correct input", async () => {
const adapter = createAdapter();
const isCredentialsRequest = true;
const signInCallback = createSignInCallback(adapter, isCredentialsRequest);

View File

@@ -30,7 +30,12 @@ describe("expireDateAfter should calculate date after specified seconds", () =>
describe("generateSessionToken should return a random UUID", () => {
it("should return a random UUID", () => {
const result = generateSessionToken();
expect(z.string().uuid().safeParse(result).success).toBe(true);
expect(
z
.string()
.regex(/^[a-f0-9]+$/)
.safeParse(result).success,
).toBe(true);
});
it("should return a different token each time", () => {
const result1 = generateSessionToken();

View File

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

1
packages/cli/index.ts Normal file
View File

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

39
packages/cli/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "@homarr/cli",
"private": true,
"version": "0.1.0",
"type": "module",
"exports": {
".": "./index.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"license": "MIT",
"scripts": {
"clean": "rm -rf .turbo node_modules",
"lint": "eslint",
"build": "esbuild src/index.ts --bundle --platform=node --outfile=cli.cjs --external:bcrypt --external:cpu-features --loader:.html=text --loader:.node=text",
"format": "prettier --check . --ignore-path ../../.gitignore",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@drizzle-team/brocli": "^0.10.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"dotenv": "^16.4.5"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.8.0",
"typescript": "^5.5.4"
},
"prettier": "@homarr/prettier-config"
}

View File

@@ -0,0 +1,46 @@
import { command, string } from "@drizzle-team/brocli";
import { hashPasswordAsync } from "@homarr/auth";
import { generateSecureRandomToken } from "@homarr/common/server";
import { and, db, eq } from "@homarr/db";
import { sessions, users } from "@homarr/db/schema/sqlite";
export const resetPassword = command({
name: "reset-password",
desc: "Reset password for a user",
options: {
username: string("username").required().alias("u").desc("Name of the user"),
},
// eslint-disable-next-line no-restricted-syntax
handler: async (options) => {
if (!process.env.AUTH_PROVIDERS?.toLowerCase().includes("credentials")) {
console.error("Credentials provider is not enabled");
return;
}
const user = await db.query.users.findFirst({
where: and(eq(users.name, options.username), eq(users.provider, "credentials")),
});
if (!user?.salt) {
console.error(`User ${options.username} not found`);
return;
}
// Generates a new password with 48 characters
const newPassword = generateSecureRandomToken(24);
await db
.update(users)
.set({
password: await hashPasswordAsync(newPassword, user.salt),
})
.where(eq(users.id, user.id));
await db.delete(sessions).where(eq(sessions.userId, user.id));
console.log(`All sessions for user ${options.username} have been deleted`);
console.log("You can now login with the new password");
console.log(`New password for user ${options.username}: ${newPassword}`);
},
});

10
packages/cli/src/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import { run } from "@drizzle-team/brocli";
import { resetPassword } from "./commands/reset-password";
const commands = [resetPassword];
void run(commands, {
name: "homarr-cli",
version: "1.0.0",
});

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

@@ -5,7 +5,9 @@
"type": "module",
"exports": {
".": "./index.ts",
"./types": "./src/types.ts"
"./types": "./src/types.ts",
"./server": "./src/server.ts",
"./client": "./src/client.ts"
},
"typesVersions": {
"*": {
@@ -23,8 +25,9 @@
},
"dependencies": {
"dayjs": "^1.11.12",
"next": "^14.2.5",
"react": "^18.3.1",
"next": "^14.2.5"
"tldts": "^6.1.38"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -0,0 +1,23 @@
import * as tldts from "tldts";
const safeParseTldts = (url: string) => {
try {
return tldts.parse(url);
} catch {
return null;
}
};
export const parseAppHrefWithVariables = <TInput extends string | null>(url: TInput, currentHref: string): TInput => {
if (!url || url.length === 0) return url;
const tldtsResult = safeParseTldts(currentHref);
const urlObject = new URL(currentHref);
return url
.replaceAll("[homarr_base]", `${urlObject.protocol}//${urlObject.hostname}`)
.replaceAll("[homarr_hostname]", tldtsResult?.hostname ?? "")
.replaceAll("[homarr_domain]", tldtsResult?.domain ?? "")
.replaceAll("[homarr_protocol]", urlObject.protocol.replace(":", "")) as TInput;
};

View File

@@ -0,0 +1,5 @@
import { parseAppHrefWithVariables } from "./base";
export const parseAppHrefWithVariablesClient = <TInput extends string | null>(url: TInput): TInput => {
return parseAppHrefWithVariables(url, window.location.href);
};

View File

@@ -0,0 +1,8 @@
import { headers } from "next/headers";
import { extractBaseUrlFromHeaders } from "../url";
import { parseAppHrefWithVariables } from "./base";
export const parseAppHrefWithVariablesServer = <TInput extends string | null>(url: TInput): TInput => {
return parseAppHrefWithVariables(url, extractBaseUrlFromHeaders(headers()));
};

View File

@@ -0,0 +1 @@
export * from "./app-url/client";

View File

@@ -0,0 +1,10 @@
import { randomBytes } from "crypto";
/**
* Generates a random hex token twice the size of the given size
* @param size amount of bytes to generate
* @returns a random hex token twice the length of the given size
*/
export const generateSecureRandomToken = (size: number) => {
return randomBytes(size).toString("hex");
};

View File

@@ -0,0 +1,2 @@
export * from "./app-url/server";
export * from "./security";

View File

@@ -1,3 +1,5 @@
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
export const appendPath = (url: URL | string, path: string) => {
const newUrl = new URL(url);
newUrl.pathname = removeTrailingSlash(newUrl.pathname) + path;
@@ -7,3 +9,16 @@ export const appendPath = (url: URL | string, path: string) => {
const removeTrailingSlash = (path: string) => {
return path.at(-1) === "/" ? path.substring(0, path.length - 1) : path;
};
export const extractBaseUrlFromHeaders = (headers: ReadonlyHeaders): `${string}://${string}` => {
let protocol = headers.get("x-forwarded-proto") ?? "http";
// @see https://support.glitch.com/t/x-forwarded-proto-contains-multiple-protocols/17219
if (protocol.includes(",")) {
protocol = protocol.includes("https") ? "https" : "http";
}
const host = headers.get("x-forwarded-host") ?? headers.get("host");
return `${protocol}://${host}`;
};

View File

@@ -16,6 +16,7 @@ export interface CreateCronJobCreatorOptions<TAllowedNames extends string> {
interface CreateCronJobOptions {
runOnStart?: boolean;
beforeStart?: () => MaybePromise<void>;
}
const createCallback = <TAllowedNames extends string, TName extends TAllowedNames>(
@@ -62,6 +63,11 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
cronExpression,
scheduledTask,
async onStartAsync() {
if (options.beforeStart) {
creatorOptions.logger.logDebug(`Running beforeStart for job: ${name}`);
await options.beforeStart();
}
if (!options.runOnStart) return;
creatorOptions.logger.logDebug(`The cron job '${name}' is running because runOnStart is set to true`);

View File

@@ -5,7 +5,14 @@ import { pingChannel, pingUrlChannel } from "@homarr/redis";
import { createCronJob } from "../lib";
export const pingJob = createCronJob("ping", EVERY_MINUTE).withCallback(async () => {
const resetPreviousUrlsAsync = async () => {
await pingUrlChannel.clearAsync();
logger.info("Cleared previous ping urls");
};
export const pingJob = createCronJob("ping", EVERY_MINUTE, {
beforeStart: resetPreviousUrlsAsync,
}).withCallback(async () => {
const urls = await pingUrlChannel.getAllAsync();
for (const url of new Set(urls)) {

View File

@@ -35,10 +35,10 @@
"@paralleldrive/cuid2": "^2.2.2",
"@auth/core": "^0.34.2",
"better-sqlite3": "^11.1.2",
"drizzle-orm": "^0.32.1",
"drizzle-orm": "^0.33.0",
"dotenv": "^16.4.5",
"mysql2": "3.11.0",
"drizzle-kit": "^0.23.1"
"drizzle-kit": "^0.24.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -6,6 +6,7 @@ export const widgetKinds = [
"video",
"notebook",
"dnsHoleSummary",
"dnsHoleControls",
"smartHome-entityState",
"smartHome-executeAutomation",
"mediaServer",

View File

@@ -21,7 +21,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@mantine/form": "^7.11.2",
"@mantine/form": "^7.12.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0"
},

View File

@@ -0,0 +1,150 @@
import { Integration } from "../base/integration";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import type { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration";
import type { DnsHoleSummary } from "../interfaces/dns-hole-summary/dns-hole-summary-types";
import { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from "./adguard-home-types";
export class AdGuardHomeIntegration extends Integration implements DnsHoleSummaryIntegration {
public async getSummaryAsync(): Promise<DnsHoleSummary> {
const statsResponse = await fetch(`${this.integration.url}/control/stats`, {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
});
if (!statsResponse.ok) {
throw new Error(
`Failed to fetch stats for ${this.integration.name} (${this.integration.id}): ${statsResponse.statusText}`,
);
}
const statusResponse = await fetch(`${this.integration.url}/control/status`, {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
});
if (!statusResponse.ok) {
throw new Error(
`Failed to fetch status for ${this.integration.name} (${this.integration.id}): ${statusResponse.statusText}`,
);
}
const filteringStatusResponse = await fetch(`${this.integration.url}/control/filtering/status`, {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
});
if (!filteringStatusResponse.ok) {
throw new Error(
`Failed to fetch filtering status for ${this.integration.name} (${this.integration.id}): ${filteringStatusResponse.statusText}`,
);
}
const stats = statsResponseSchema.safeParse(await statsResponse.json());
const status = statusResponseSchema.safeParse(await statusResponse.json());
const filteringStatus = filteringStatusSchema.safeParse(await filteringStatusResponse.json());
const errorMessages: string[] = [];
if (!stats.success) {
errorMessages.push(`Stats parsing error: ${stats.error.message}`);
}
if (!status.success) {
errorMessages.push(`Status parsing error: ${status.error.message}`);
}
if (!filteringStatus.success) {
errorMessages.push(`Filtering status parsing error: ${filteringStatus.error.message}`);
}
if (!stats.success || !status.success || !filteringStatus.success) {
throw new Error(
`Failed to parse summary for ${this.integration.name} (${this.integration.id}):\n${errorMessages.join("\n")}`,
);
}
const blockedQueriesToday =
stats.data.time_units === "days"
? (stats.data.blocked_filtering[stats.data.blocked_filtering.length - 1] ?? 0)
: stats.data.blocked_filtering.reduce((prev, sum) => prev + sum, 0);
const queriesToday =
stats.data.time_units === "days"
? (stats.data.dns_queries[stats.data.dns_queries.length - 1] ?? 0)
: stats.data.dns_queries.reduce((prev, sum) => prev + sum, 0);
const countFilteredDomains = filteringStatus.data.filters
.filter((filter) => filter.enabled)
.reduce((sum, filter) => filter.rules_count + sum, 0);
return {
status: status.data.protection_enabled ? ("enabled" as const) : ("disabled" as const),
adsBlockedToday: blockedQueriesToday,
adsBlockedTodayPercentage: (queriesToday / blockedQueriesToday) * 100,
domainsBeingBlocked: countFilteredDomains,
dnsQueriesToday: queriesToday,
};
}
public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetch(`${this.integration.url}/control/status`, {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
});
},
handleResponseAsync: async (response) => {
try {
const result = (await response.json()) as unknown;
if (typeof result === "object" && result !== null) return;
} catch {
throw new IntegrationTestConnectionError("invalidJson");
}
throw new IntegrationTestConnectionError("invalidCredentials");
},
});
}
public async enableAsync(): Promise<void> {
const response = await fetch(`${this.integration.url}/control/protection`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
body: JSON.stringify({
enabled: true,
}),
});
if (!response.ok) {
throw new Error(
`Failed to enable AdGuard Home for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
);
}
}
public async disableAsync(duration?: number): Promise<void> {
const response = await fetch(`${this.integration.url}/control/protection`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
body: JSON.stringify({
enabled: false,
duration: duration,
}),
});
if (!response.ok) {
throw new Error(
`Failed to disable AdGuard Home for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
);
}
}
private getAuthorizationHeaderValue() {
const username = super.getSecretValue("username");
const password = super.getSecretValue("password");
return Buffer.from(`${username}:${password}`).toString("base64");
}
}

View File

@@ -0,0 +1,43 @@
import { z } from "@homarr/validation";
export const statsResponseSchema = z.object({
time_units: z.enum(["hours", "days"]),
top_queried_domains: z.array(z.record(z.string(), z.number())),
top_clients: z.array(z.record(z.string(), z.number())),
top_blocked_domains: z.array(z.record(z.string(), z.number())),
dns_queries: z.array(z.number()),
blocked_filtering: z.array(z.number()),
replaced_safebrowsing: z.array(z.number()),
replaced_parental: z.array(z.number()),
num_dns_queries: z.number().min(0),
num_blocked_filtering: z.number().min(0),
num_replaced_safebrowsing: z.number().min(0),
num_replaced_safesearch: z.number().min(0),
num_replaced_parental: z.number().min(0),
avg_processing_time: z.number().min(0),
});
export const statusResponseSchema = z.object({
version: z.string(),
language: z.string(),
dns_addresses: z.array(z.string()),
dns_port: z.number().positive(),
http_port: z.number().positive(),
protection_disabled_duration: z.number(),
protection_enabled: z.boolean(),
dhcp_available: z.boolean(),
running: z.boolean(),
});
export const filteringStatusSchema = z.object({
filters: z.array(
z.object({
url: z.string(),
name: z.string(),
last_updated: z.string().optional(),
id: z.number().nonnegative(),
rules_count: z.number().nonnegative(),
enabled: z.boolean(),
}),
),
});

View File

@@ -1,5 +1,6 @@
import type { IntegrationKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
@@ -10,6 +11,8 @@ export const integrationCreatorByKind = (kind: IntegrationKind, integration: Int
switch (kind) {
case "piHole":
return new PiHoleIntegration(integration);
case "adGuardHome":
return new AdGuardHomeIntegration(integration);
case "homeAssistant":
return new HomeAssistantIntegration(integration);
case "jellyfin":

View File

@@ -1,12 +1,13 @@
// General integrations
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
// Types
export type { StreamSession } from "./interfaces/media-server/session";
// Helpers
export { IntegrationTestConnectionError } from "./base/test-connection-error";
export { integrationCreatorByKind } from "./base/creator";
export { IntegrationTestConnectionError } from "./base/test-connection-error";

View File

@@ -1,4 +1,5 @@
export interface DnsHoleSummary {
status: "enabled" | "disabled";
domainsBeingBlocked: number;
adsBlockedToday: number;
adsBlockedTodayPercentage: number;

View File

@@ -23,6 +23,7 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
}
return {
status: result.data.status,
adsBlockedToday: result.data.ads_blocked_today,
adsBlockedTodayPercentage: result.data.ads_percentage_today,
domainsBeingBlocked: result.data.domains_being_blocked,
@@ -49,4 +50,25 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
},
});
}
public async enableAsync(): Promise<void> {
const apiKey = super.getSecretValue("apiKey");
const response = await fetch(`${this.integration.url}/admin/api.php?enable&auth=${apiKey}`);
if (!response.ok) {
throw new Error(
`Failed to enable PiHole for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
);
}
}
public async disableAsync(duration?: number): Promise<void> {
const apiKey = super.getSecretValue("apiKey");
const url = `${this.integration.url}/admin/api.php?disable${duration ? `=${duration}` : ""}&auth=${apiKey}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to disable PiHole for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
);
}
}
}

View File

@@ -7,3 +7,7 @@ export const summaryResponseSchema = z.object({
dns_queries_today: z.number(),
ads_percentage_today: z.number(),
});
export const controlsInputSchema = z.object({
duration: z.number().optional(),
});

View File

@@ -27,7 +27,7 @@
"dependencies": {
"ioredis": "5.4.1",
"superjson": "2.2.1",
"winston": "3.13.1"
"winston": "3.14.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -24,8 +24,8 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"react": "^18.3.1",
"@mantine/core": "^7.11.2",
"@mantine/hooks": "^7.11.2"
"@mantine/core": "^7.12.0",
"@mantine/hooks": "^7.12.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -22,9 +22,9 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@mantine/notifications": "^7.11.2",
"@mantine/notifications": "^7.12.0",
"@homarr/ui": "workspace:^0.1.0",
"@tabler/icons-react": "^3.11.0"
"@tabler/icons-react": "^3.12.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -77,6 +77,12 @@ export const createListChannel = <TItem>(name: string) => {
removeAsync: async (item: TItem) => {
await getSetClient.lrem(listChannelName, 0, superjson.stringify(item));
},
/**
* Clear all items from the channels list
*/
clearAsync: async () => {
await getSetClient.del(listChannelName);
},
/**
* Add an item to the channels list
* @param item item to add

View File

@@ -24,11 +24,11 @@
"dependencies": {
"@homarr/ui": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@mantine/core": "^7.11.2",
"@mantine/hooks": "^7.11.2",
"@mantine/spotlight": "^7.11.2",
"@tabler/icons-react": "^3.11.0",
"jotai": "^2.9.1",
"@mantine/core": "^7.12.0",
"@mantine/hooks": "^7.12.0",
"@mantine/spotlight": "^7.12.0",
"@tabler/icons-react": "^3.12.0",
"jotai": "^2.9.2",
"next": "^14.2.5",
"react": "^18.3.1",
"use-deep-compare-effect": "^1.8.1"

View File

@@ -37,6 +37,9 @@ export default {
previousPassword: {
label: "Previous password",
},
homeBoard: {
label: "Home board",
},
},
error: {
usernameTaken: "Username already taken",
@@ -81,6 +84,16 @@ export default {
},
},
},
changeHomeBoard: {
notification: {
success: {
message: "Home board changed successfully",
},
error: {
message: "Unable to change home board",
},
},
},
manageAvatar: {
changeImage: {
label: "Change image",
@@ -558,7 +571,7 @@ export default {
},
multiText: {
placeholder: "Add more values",
addLabel: `Add {value}`,
addLabel: "Add {value}",
},
select: {
placeholder: "Pick value",
@@ -662,6 +675,7 @@ export default {
import: "Import item",
edit: "Edit item",
move: "Move item",
duplicate: "Duplicate item",
remove: "Remove item",
},
menu: {
@@ -763,6 +777,43 @@ export default {
domainsBeingBlocked: "Domains on blocklist",
},
},
dnsHoleControls: {
name: "DNS Hole Controls",
description: "Control PiHole or AdGuard from your dashboard",
option: {
layout: {
label: "Layout",
option: {
row: {
label: "Horizontal",
},
column: {
label: "Vertical",
},
grid: {
label: "Grid",
},
},
},
showToggleAllButtons: {
label: "Show Toggle All Buttons",
},
},
error: {
internalServerError: "Failed to control DNS Hole",
},
controls: {
enableAll: "Enable All",
disableAll: "Disable All",
setTimer: "Set Timer",
set: "Set",
enabled: "Enabled",
disabled: "Disabled",
hours: "Hours",
minutes: "Minutes",
unlimited: "Leave blank to unlimited",
},
},
clock: {
name: "Date and time",
description: "Displays the current date and time.",
@@ -1163,6 +1214,10 @@ export default {
name: {
label: "Name",
},
isPublic: {
label: "Public",
description: "Public boards are accessible by everyone, even without an account.",
},
},
content: {
metaTitle: "{boardName} board",
@@ -1362,10 +1417,17 @@ export default {
setting: {
general: {
title: "General",
item: {
language: "Language & Region",
board: "Home board",
},
},
security: {
title: "Security",
},
board: {
title: "Boards",
},
},
list: {
metaTitle: "Manage users",
@@ -1619,6 +1681,19 @@ export default {
},
},
},
refresh: {
label: "Refresh",
notification: {
success: {
title: "Containers refreshed",
message: "You are now viewing the most recent data",
},
error: {
title: "Containers not refreshed",
message: "Something went wrong while refreshing the containers",
},
},
},
},
},
permission: {
@@ -1681,6 +1756,7 @@ export default {
},
general: "General",
security: "Security",
board: "Boards",
groups: {
label: "Groups",
},

View File

@@ -26,10 +26,10 @@
"@homarr/log": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@mantine/core": "^7.11.2",
"@mantine/dates": "^7.11.2",
"@mantine/hooks": "^7.11.2",
"@tabler/icons-react": "^3.11.0",
"@mantine/core": "^7.12.0",
"@mantine/dates": "^7.12.0",
"@mantine/hooks": "^7.12.0",
"@tabler/icons-react": "^3.12.0",
"mantine-react-table": "2.0.0-beta.6",
"next": "^14.2.5",
"react": "^18.3.1"

View File

@@ -61,7 +61,7 @@ const saveSchema = z.object({
sections: z.array(createSectionSchema(commonItemSchema)),
});
const createSchema = z.object({ name: boardNameSchema });
const createSchema = z.object({ name: boardNameSchema, columnCount: z.number().min(1).max(24), isPublic: z.boolean() });
const permissionsSchema = z.object({
id: z.string(),

View File

@@ -68,6 +68,10 @@ const changePasswordSchema = z
const changePasswordApiSchema = changePasswordSchema.and(z.object({ userId: z.string() }));
const changeHomeBoardSchema = z.object({
homeBoardId: z.string().min(1),
});
export const userSchemas = {
signIn: signInSchema,
registration: registrationSchema,
@@ -77,5 +81,6 @@ export const userSchemas = {
password: passwordSchema,
editProfile: editProfileSchema,
changePassword: changePasswordSchema,
changeHomeBoard: changeHomeBoardSchema,
changePasswordApi: changePasswordApiSchema,
};

View File

@@ -35,30 +35,30 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/hooks": "^7.11.2",
"@mantine/core": "^7.11.2",
"@tabler/icons-react": "^3.11.0",
"@tiptap/extension-color": "2.5.8",
"@tiptap/extension-highlight": "2.5.8",
"@tiptap/extension-image": "2.5.8",
"@tiptap/extension-link": "^2.5.8",
"@tiptap/extension-table": "2.5.8",
"@tiptap/extension-table-cell": "2.5.8",
"@tiptap/extension-table-header": "2.5.8",
"@tiptap/extension-table-row": "2.5.8",
"@tiptap/extension-task-item": "2.5.8",
"@tiptap/extension-task-list": "2.5.8",
"@tiptap/extension-text-align": "2.5.8",
"@tiptap/extension-text-style": "2.5.8",
"@tiptap/extension-underline": "2.5.8",
"@tiptap/react": "^2.5.8",
"@tiptap/starter-kit": "^2.5.8",
"@mantine/hooks": "^7.12.0",
"@mantine/core": "^7.12.0",
"@tabler/icons-react": "^3.12.0",
"@tiptap/extension-color": "2.5.9",
"@tiptap/extension-highlight": "2.5.9",
"@tiptap/extension-image": "2.5.9",
"@tiptap/extension-link": "^2.5.9",
"@tiptap/extension-table": "2.5.9",
"@tiptap/extension-table-cell": "2.5.9",
"@tiptap/extension-table-header": "2.5.9",
"@tiptap/extension-table-row": "2.5.9",
"@tiptap/extension-task-item": "2.5.9",
"@tiptap/extension-task-list": "2.5.9",
"@tiptap/extension-text-align": "2.5.9",
"@tiptap/extension-text-style": "2.5.9",
"@tiptap/extension-underline": "2.5.9",
"@tiptap/react": "^2.5.9",
"@tiptap/starter-kit": "^2.5.9",
"clsx": "^2.1.1",
"dayjs": "^1.11.12",
"next": "^14.2.5",
"mantine-react-table": "2.0.0-beta.6",
"react": "^18.3.1",
"video.js": "^8.17.1"
"video.js": "^8.17.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -8,6 +8,7 @@ import combineClasses from "clsx";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { parseAppHrefWithVariablesClient } from "@homarr/common/client";
import { useRegisterSpotlightActions } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
@@ -40,7 +41,7 @@ export default function AppWidget({ options, serverData, isEditMode, width }: Wi
const shouldRunPing = Boolean(app?.href) && options.pingEnabled;
clientApi.widget.app.updatedPing.useSubscription(
{ url: app?.href ?? "" },
{ url: parseAppHrefWithVariablesClient(app?.href ?? "") },
{
enabled: shouldRunPing,
onData(data) {
@@ -60,7 +61,7 @@ export default function AppWidget({ options, serverData, isEditMode, width }: Wi
icon: app.iconUrl,
group: "app",
type: "link",
href: app.href,
href: parseAppHrefWithVariablesClient(app.href),
openInNewTab: options.openInNewTab,
},
]
@@ -92,7 +93,11 @@ export default function AppWidget({ options, serverData, isEditMode, width }: Wi
}
return (
<AppLink href={app?.href ?? ""} openInNewTab={options.openInNewTab} enabled={Boolean(app?.href) && !isEditMode}>
<AppLink
href={parseAppHrefWithVariablesClient(app?.href ?? "")}
openInNewTab={options.openInNewTab}
enabled={Boolean(app?.href) && !isEditMode}
>
<Tooltip.Floating
label={app?.description}
position="right-start"

View File

@@ -2,6 +2,7 @@
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
import type { WidgetProps } from "../definition";
@@ -15,7 +16,9 @@ export default async function getServerDataAsync({ options }: WidgetProps<"app">
let pingResult: RouterOutputs["widget"]["app"]["ping"] | null = null;
if (app.href && options.pingEnabled) {
pingResult = await api.widget.app.ping({ url: app.href });
pingResult = await api.widget.app.ping({
url: parseAppHrefWithVariablesServer(app.href),
});
}
return { app, pingResult };

View File

@@ -30,6 +30,9 @@ vi.mock("@homarr/api/server", () => ({
},
},
}));
vi.mock("@homarr/common/server", () => ({
parseAppHrefWithVariablesServer: () => "http://localhost",
}));
describe("getServerDataAsync should load app and ping result", () => {
test("when appId is empty it should return null for app and pingResult", async () => {

View File

@@ -0,0 +1,105 @@
import { useRef, useState } from "react";
import type { NumberInputHandlers } from "@mantine/core";
import { ActionIcon, Button, Flex, Group, Modal, NumberInput, rem, Stack, Text } from "@mantine/core";
import { IconClockPause } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface TimerModalProps {
opened: boolean;
close: () => void;
integrationIds: string[];
disableDns: (data: { duration: number; integrationId: string }) => void;
}
const TimerModal = ({ opened, close, integrationIds, disableDns }: TimerModalProps) => {
const t = useI18n();
const [hours, setHours] = useState(0);
const [minutes, setMinutes] = useState(0);
const hoursHandlers = useRef<NumberInputHandlers>();
const minutesHandlers = useRef<NumberInputHandlers>();
const handleSetTimer = () => {
const duration = hours * 3600 + minutes * 60;
integrationIds.forEach((integrationId) => {
disableDns({ duration, integrationId });
});
setHours(0);
setMinutes(0);
close();
};
return (
<Modal
withinPortal
radius="lg"
shadow="sm"
size="sm"
opened={opened}
onClose={() => {
close();
setHours(0);
setMinutes(0);
}}
title={t("widget.dnsHoleControls.controls.setTimer")}
>
<Flex direction="column" align="center" justify="center">
<Stack align="flex-end">
<Group>
<Text>{t("widget.dnsHoleControls.controls.hours")}</Text>
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.decrement()}>
</ActionIcon>
<NumberInput
hideControls
value={hours}
onChange={(val) => setHours(Number(val))}
handlersRef={hoursHandlers}
max={999}
min={0}
step={1}
styles={{ input: { width: rem(54), textAlign: "center" } }}
/>
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.increment()}>
+
</ActionIcon>
</Group>
<Group>
<Text>{t("widget.dnsHoleControls.controls.minutes")}</Text>
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.decrement()}>
</ActionIcon>
<NumberInput
hideControls
value={minutes}
onChange={(val) => setMinutes(Number(val))}
handlersRef={minutesHandlers}
max={59}
min={0}
step={1}
styles={{ input: { width: rem(54), textAlign: "center" } }}
/>
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.increment()}>
+
</ActionIcon>
</Group>
</Stack>
<Text ta="center" c="dimmed" my={5}>
{t("widget.dnsHoleControls.controls.unlimited")}
</Text>
<Button
variant="light"
color="red"
leftSection={<IconClockPause size={20} />}
h="2rem"
w="12rem"
onClick={handleSetTimer}
>
{t("widget.dnsHoleControls.controls.set")}
</Button>
</Flex>
</Modal>
);
};
export default TimerModal;

View File

@@ -0,0 +1,160 @@
"use client";
import { useEffect, useState } from "react";
import { ActionIcon, Badge, Box, Button, Card, Flex, Image, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconClockPause, IconPlayerPlay, IconPlayerStop } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { integrationDefs } from "@homarr/definitions";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationSelectedError } from "../../errors";
import TimerModal from "./TimerModal";
const dnsLightStatus = (enabled: boolean): "green" | "red" => (enabled ? "green" : "red");
export default function DnsHoleControlsWidget({ options, integrationIds }: WidgetComponentProps<"dnsHoleControls">) {
if (integrationIds.length === 0) {
throw new NoIntegrationSelectedError();
}
const t = useI18n();
const [status, setStatus] = useState<{ integrationId: string; enabled: boolean }[]>(
integrationIds.map((id) => ({ integrationId: id, enabled: false })),
);
const [opened, { close, open }] = useDisclosure(false);
const [data] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
retry: false,
},
);
useEffect(() => {
const newStatus = data.map((integrationData) => ({
integrationId: integrationData.integrationId,
enabled: integrationData.summary.status === "enabled",
}));
setStatus(newStatus);
}, [data]);
const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({
onSuccess: (_, variables) => {
setStatus((prevStatus) =>
prevStatus.map((item) => (item.integrationId === variables.integrationId ? { ...item, enabled: true } : item)),
);
},
});
const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({
onSuccess: (_, variables) => {
setStatus((prevStatus) =>
prevStatus.map((item) => (item.integrationId === variables.integrationId ? { ...item, enabled: false } : item)),
);
},
});
const toggleDns = (integrationId: string) => {
const integrationStatus = status.find((item) => item.integrationId === integrationId);
if (integrationStatus?.enabled) {
disableDns({ integrationId, duration: 0 });
} else {
enableDns({ integrationId });
}
};
const allEnabled = status.every((item) => item.enabled);
const allDisabled = status.every((item) => !item.enabled);
return (
<Flex h="100%" direction="column" gap={0} p="2.5cqmin">
{options.showToggleAllButtons && (
<Flex gap="2.5cqmin">
<Tooltip label={t("widget.dnsHoleControls.controls.enableAll")}>
<Button
onClick={() => {
integrationIds.forEach((integrationId) => enableDns({ integrationId }));
}}
disabled={allEnabled}
variant="light"
color="green"
fullWidth
h="2rem"
>
<IconPlayerPlay size={20} />
</Button>
</Tooltip>
<Tooltip label={t("widget.dnsHoleControls.controls.setTimer")}>
<Button onClick={open} disabled={allDisabled} variant="light" color="yellow" fullWidth h="2rem">
<IconClockPause size={20} />
</Button>
</Tooltip>
<Tooltip label={t("widget.dnsHoleControls.controls.disableAll")}>
<Button
onClick={() => {
integrationIds.forEach((integrationId) => disableDns({ integrationId, duration: 0 }));
}}
disabled={allDisabled}
variant="light"
color="red"
fullWidth
h="2rem"
>
<IconPlayerStop size={20} />
</Button>
</Tooltip>
</Flex>
)}
<Stack gap="2.5cqmin" flex={1} justify={options.showToggleAllButtons ? "flex-end" : "space-evenly"}>
{data.map((integrationData) =>
ControlsCard(integrationData.integrationId, integrationData.integrationKind, toggleDns, status, open, t),
)}
</Stack>
<TimerModal opened={opened} close={close} integrationIds={integrationIds} disableDns={disableDns} />
</Flex>
);
}
const ControlsCard = (
integrationId: string,
integrationKind: string,
toggleDns: (integrationId: string) => void,
status: { integrationId: string; enabled: boolean }[],
open: () => void,
t: TranslationFunction,
) => {
const integrationStatus = status.find((item) => item.integrationId === integrationId);
const isEnabled = integrationStatus?.enabled ?? false;
const integrationDef = integrationKind === "piHole" ? integrationDefs.piHole : integrationDefs.adGuardHome;
return (
<Card key={integrationId} withBorder p="2.5cqmin" radius="2.5cqmin">
<Flex>
<Box m="1.5cqmin" p="1.5cqmin">
<Image src={integrationDef.iconUrl} width="50cqmin" height="50cqmin" fit="contain" />
</Box>
<Flex direction="column" m="1.5cqmin" p="1.5cqmin" gap="1cqmin">
<Text>{integrationDef.name}</Text>
<Flex direction="row" gap="2cqmin">
<UnstyledButton onClick={() => toggleDns(integrationId)}>
<Badge variant="dot" color={dnsLightStatus(isEnabled)}>
{t(`widget.dnsHoleControls.controls.${isEnabled ? "enabled" : "disabled"}`)}
</Badge>
</UnstyledButton>
<ActionIcon disabled={!isEnabled} size={20} radius="xl" top="2.67px" variant="default" onClick={open}>
<IconClockPause size={20} color="red" />
</ActionIcon>
</Flex>
</Flex>
</Flex>
</Card>
);
};

View File

@@ -0,0 +1,22 @@
import { IconDeviceGamepad, IconServerOff } from "@tabler/icons-react";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("dnsHoleControls", {
icon: IconDeviceGamepad,
options: optionsBuilder.from((factory) => ({
showToggleAllButtons: factory.switch({
defaultValue: true,
}),
})),
supportedIntegrations: ["piHole", "adGuardHome"],
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.dnsHoleControls.error.internalServerError"),
},
},
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,27 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../../definition";
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"dnsHoleControls">) {
if (integrationIds.length === 0) {
return {
initialData: [],
};
}
try {
const currentDns = await api.widget.dnsHole.summary({
integrationIds,
});
return {
initialData: currentDns,
};
} catch {
return {
initialData: [],
};
}
}

View File

@@ -1,12 +1,12 @@
"use client";
import { useMemo } from "react";
import type { BoxProps } from "@mantine/core";
import { Box, Card, Flex, Text } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { formatNumber } from "@homarr/common";
import type { stringOrTranslation, TranslationFunction } from "@homarr/translation";
import { translateIfNecessary } from "@homarr/translation";
@@ -16,22 +16,18 @@ import type { TablerIcon } from "@homarr/ui";
import type { WidgetComponentProps, WidgetProps } from "../../definition";
import { NoIntegrationSelectedError } from "../../errors";
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<"dnsHoleSummary">) {
export default function DnsHoleSummaryWidget({
options,
integrationIds,
serverData,
}: WidgetComponentProps<"dnsHoleSummary">) {
const integrationId = integrationIds.at(0);
if (!integrationId) {
throw new NoIntegrationSelectedError();
}
const [data] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
{
integrationId,
},
{
refetchOnMount: false,
retry: false,
},
);
const data = useMemo(() => (serverData?.initialData ?? []).flatMap((summary) => summary.summary), [serverData]);
return (
<Box h="100%" {...boxPropsByLayout(options.layout)}>
@@ -45,15 +41,22 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget
const stats = [
{
icon: IconBarrierBlock,
value: ({ adsBlockedToday }) => formatNumber(adsBlockedToday, 2),
value: (data) =>
formatNumber(
data.reduce((count, { adsBlockedToday }) => count + adsBlockedToday, 0),
2,
),
label: (t) => t("widget.dnsHoleSummary.data.adsBlockedToday"),
color: "rgba(240, 82, 60, 0.4)", // RED
},
{
icon: IconPercentage,
value: ({ adsBlockedTodayPercentage }, t) =>
value: (data, t) =>
t("common.rtl", {
value: formatNumber(adsBlockedTodayPercentage, 2),
value: formatNumber(
data.reduce((count, { adsBlockedTodayPercentage }) => count + adsBlockedTodayPercentage, 0),
2,
),
symbol: "%",
}),
label: (t) => t("widget.dnsHoleSummary.data.adsBlockedTodayPercentage"),
@@ -61,13 +64,21 @@ const stats = [
},
{
icon: IconSearch,
value: ({ dnsQueriesToday }) => formatNumber(dnsQueriesToday, 2),
value: (data) =>
formatNumber(
data.reduce((count, { dnsQueriesToday }) => count + dnsQueriesToday, 0),
2,
),
label: (t) => t("widget.dnsHoleSummary.data.dnsQueriesToday"),
color: "rgba(0, 175, 218, 0.4)", // BLUE
},
{
icon: IconWorldWww,
value: ({ domainsBeingBlocked }) => formatNumber(domainsBeingBlocked, 2),
value: (data) =>
formatNumber(
data.reduce((count, { domainsBeingBlocked }) => count + domainsBeingBlocked, 0),
2,
),
label: (t) => t("widget.dnsHoleSummary.data.domainsBeingBlocked"),
color: "rgba(0, 176, 96, 0.4)", // GREEN
},
@@ -75,14 +86,14 @@ const stats = [
interface StatItem {
icon: TablerIcon;
value: (x: RouterOutputs["widget"]["dnsHole"]["summary"], t: TranslationFunction) => string;
value: (x: RouterOutputs["widget"]["dnsHole"]["summary"][number]["summary"][], t: TranslationFunction) => string;
label: stringOrTranslation;
color: string;
}
interface StatCardProps {
item: StatItem;
data: RouterOutputs["widget"]["dnsHole"]["summary"];
data: RouterOutputs["widget"]["dnsHole"]["summary"][number]["summary"][];
usePiHoleColors: boolean;
}
const StatCard = ({ item, data, usePiHoleColors }: StatCardProps) => {

View File

@@ -17,7 +17,7 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
defaultValue: "grid",
}),
})),
supportedIntegrations: ["piHole"],
supportedIntegrations: ["piHole", "adGuardHome"],
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,

View File

@@ -5,20 +5,23 @@ import { api } from "@homarr/api/server";
import type { WidgetProps } from "../../definition";
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"dnsHoleSummary">) {
const integrationId = integrationIds.at(0);
if (!integrationId) return { initialData: undefined };
if (integrationIds.length === 0) {
return {
initialData: [],
};
}
try {
const data = await api.widget.dnsHole.summary({
integrationId,
const currentDns = await api.widget.dnsHole.summary({
integrationIds,
});
return {
initialData: data,
initialData: currentDns,
};
} catch {
return {
initialData: undefined,
initialData: [],
};
}
}

View File

@@ -9,6 +9,7 @@ import * as app from "./app";
import * as calendar from "./calendar";
import * as clock from "./clock";
import type { WidgetComponentProps } from "./definition";
import * as dnsHoleControls from "./dns-hole/controls";
import * as dnsHoleSummary from "./dns-hole/summary";
import * as iframe from "./iframe";
import type { WidgetImportRecord } from "./import";
@@ -22,9 +23,11 @@ import * as weather from "./weather";
export { reduceWidgetOptionsWithDefaultValues } from "./options";
export type { WidgetDefinition } from "./definition";
export { WidgetEditModal } from "./modals/widget-edit-modal";
export { useServerDataFor } from "./server/provider";
export { GlobalItemServerDataRunner } from "./server/runner";
export type { WidgetComponentProps };
export const widgetImports = {
clock,
@@ -34,6 +37,7 @@ export const widgetImports = {
iframe,
video,
dnsHoleSummary,
dnsHoleControls,
"smartHome-entityState": smartHomeEntityState,
"smartHome-executeAutomation": smartHomeExecuteAutomation,
mediaServer,
@@ -43,8 +47,6 @@ export const widgetImports = {
export type WidgetImports = typeof widgetImports;
export type WidgetImportKey = keyof WidgetImports;
export type { WidgetComponentProps };
export type { WidgetDefinition } from "./definition";
const loadedComponents = new Map<WidgetKind, ComponentType<WidgetComponentProps<WidgetKind>>>();

View File

@@ -92,8 +92,17 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
{Object.entries(definition.options).map(([key, value]: [string, OptionsBuilderResult[string]]) => {
const Input = getInputForType(value.type);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!Input || value.shouldHide?.(form.values.options as never)) {
if (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
!Input ||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
value.shouldHide?.(
form.values.options as never,
innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind),
)
) {
return null;
}

View File

@@ -1,5 +1,5 @@
import { objectEntries } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { ZodType } from "@homarr/validation";
import { z } from "@homarr/validation";
@@ -123,7 +123,7 @@ export type inferOptionsFromDefinition<TOptions extends WidgetOptionsRecord> = {
};
interface FieldConfiguration<TOptions extends WidgetOptionsRecord> {
shouldHide: (options: inferOptionsFromDefinition<TOptions>) => boolean;
shouldHide: (options: inferOptionsFromDefinition<TOptions>, integrationKinds: IntegrationKind[]) => boolean;
}
type ConfigurationInput<TOptions extends WidgetOptionsRecord> = Partial<

1211
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,12 +18,12 @@
"dependencies": {
"@next/eslint-plugin-next": "^14.2.5",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "^2.0.11",
"eslint-config-turbo": "^2.0.12",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.9.0",
"eslint-plugin-react": "^7.35.0",
"eslint-plugin-react-hooks": "^4.6.2",
"typescript-eslint": "^8.0.0"
"typescript-eslint": "^8.0.1"
},
"devDependencies": {
"@homarr/prettier-config": "workspace:^0.1.0",

View File

@@ -22,8 +22,10 @@
"AUTH_LDAP_USER_MAIL_ATTRIBUTE",
"AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG",
"AUTH_OIDC_AUTO_LOGIN",
"AUTH_LOGOUT_REDIRECT_URL",
"AUTH_PROVIDERS",
"AUTH_SECRET",
"AUTH_SESSION_EXPIRY_TIME",
"CI",
"DISABLE_REDIS_LOGS",
"DB_URL",

View File

@@ -24,8 +24,8 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.5.0",
"typescript": "^5.5.2"
"eslint": "^9.8.0",
"typescript": "^5.5.4"
},
"prettier": "@homarr/prettier-config"
}