mirror of
https://github.com/ajnart/homarr.git
synced 2026-03-01 01:40:55 +01:00
chore(release): automatic release v0.1.0
This commit is contained in:
@@ -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"
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -30,6 +30,8 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
|
||||
onSuccess: async (values) => {
|
||||
await mutateAsync({
|
||||
name: values.name,
|
||||
columnCount: values.columnCount,
|
||||
isPublic: values.isPublic,
|
||||
});
|
||||
},
|
||||
boardNames,
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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>;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
10
package.json
10
package.json
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()) }))
|
||||
|
||||
@@ -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");
|
||||
|
||||
91
packages/api/src/router/test/docker/docker-router.spec.ts
Normal file
91
packages/api/src/router/test/docker/docker-router.spec.ts
Normal 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" }));
|
||||
});
|
||||
});
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
9
packages/cli/eslint.config.js
Normal file
9
packages/cli/eslint.config.js
Normal 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
1
packages/cli/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
39
packages/cli/package.json
Normal file
39
packages/cli/package.json
Normal 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"
|
||||
}
|
||||
46
packages/cli/src/commands/reset-password.ts
Normal file
46
packages/cli/src/commands/reset-password.ts
Normal 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
10
packages/cli/src/index.ts
Normal 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",
|
||||
});
|
||||
8
packages/cli/tsconfig.json
Normal file
8
packages/cli/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
23
packages/common/src/app-url/base.ts
Normal file
23
packages/common/src/app-url/base.ts
Normal 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;
|
||||
};
|
||||
5
packages/common/src/app-url/client.ts
Normal file
5
packages/common/src/app-url/client.ts
Normal 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);
|
||||
};
|
||||
8
packages/common/src/app-url/server.ts
Normal file
8
packages/common/src/app-url/server.ts
Normal 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()));
|
||||
};
|
||||
1
packages/common/src/client.ts
Normal file
1
packages/common/src/client.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./app-url/client";
|
||||
10
packages/common/src/security.ts
Normal file
10
packages/common/src/security.ts
Normal 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");
|
||||
};
|
||||
2
packages/common/src/server.ts
Normal file
2
packages/common/src/server.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./app-url/server";
|
||||
export * from "./security";
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6,6 +6,7 @@ export const widgetKinds = [
|
||||
"video",
|
||||
"notebook",
|
||||
"dnsHoleSummary",
|
||||
"dnsHoleControls",
|
||||
"smartHome-entityState",
|
||||
"smartHome-executeAutomation",
|
||||
"mediaServer",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
43
packages/integrations/src/adguard-home/adguard-home-types.ts
Normal file
43
packages/integrations/src/adguard-home/adguard-home-types.ts
Normal 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(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -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":
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface DnsHoleSummary {
|
||||
status: "enabled" | "disabled";
|
||||
domainsBeingBlocked: number;
|
||||
adsBlockedToday: number;
|
||||
adsBlockedTodayPercentage: number;
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
105
packages/widgets/src/dns-hole/controls/TimerModal.tsx
Normal file
105
packages/widgets/src/dns-hole/controls/TimerModal.tsx
Normal 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;
|
||||
160
packages/widgets/src/dns-hole/controls/component.tsx
Normal file
160
packages/widgets/src/dns-hole/controls/component.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
22
packages/widgets/src/dns-hole/controls/index.ts
Normal file
22
packages/widgets/src/dns-hole/controls/index.ts
Normal 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"));
|
||||
27
packages/widgets/src/dns-hole/controls/serverData.ts
Normal file
27
packages/widgets/src/dns-hole/controls/serverData.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -17,7 +17,7 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
|
||||
defaultValue: "grid",
|
||||
}),
|
||||
})),
|
||||
supportedIntegrations: ["piHole"],
|
||||
supportedIntegrations: ["piHole", "adGuardHome"],
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>>>();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
1211
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user