chore(release): automatic release v0.1.0
This commit is contained in:
@@ -6,6 +6,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
approve-renovate-prs:
|
approve-renovate-prs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: github.actor_id == 158783068 # Id of renovate bot see https://api.github.com/users/homarr-renovate%5Bbot%5D
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -21,6 +22,4 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
run: |
|
run: |
|
||||||
for pr in $(gh pr list --author homarr-renovate[bot] --json number --jq .[].number); do
|
gh pr review ${{github.event.pull_request.number}} --approve --body "Automatically approved by GitHub Action"
|
||||||
gh pr review $pr --approve --body "Automatically approved by GitHub Action"
|
|
||||||
done
|
|
||||||
|
|||||||
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/tasks --docker --out-dir ./tasks-out
|
||||||
RUN turbo prune @homarr/websocket --docker --out-dir ./websocket-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/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
|
# Add lockfile and package.json's of isolated subworkspace
|
||||||
FROM base AS installer
|
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
|
COPY --from=builder /app/migration-out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
RUN corepack enable pnpm && pnpm install
|
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/json/ .
|
||||||
COPY --from=builder /app/next-out/pnpm-lock.yaml ./pnpm-lock.yaml
|
COPY --from=builder /app/next-out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
RUN corepack enable pnpm && pnpm install
|
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/websocket-out/full/ .
|
||||||
COPY --from=builder /app/next-out/full/ .
|
COPY --from=builder /app/next-out/full/ .
|
||||||
COPY --from=builder /app/migration-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 as it is not part of the build
|
||||||
COPY static-data ./static-data
|
COPY static-data ./static-data
|
||||||
@@ -55,15 +61,23 @@ RUN corepack enable pnpm && pnpm build
|
|||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache redis
|
RUN apk add --no-cache redis bash
|
||||||
RUN mkdir /appdata
|
RUN mkdir /appdata
|
||||||
RUN mkdir /appdata/db
|
RUN mkdir /appdata/db
|
||||||
RUN mkdir /appdata/redis
|
RUN mkdir /appdata/redis
|
||||||
VOLUME /appdata
|
VOLUME /appdata
|
||||||
|
|
||||||
# Don't run production as root
|
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
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
|
RUN chown -R nextjs:nodejs /appdata
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
|
|||||||
@@ -32,17 +32,17 @@
|
|||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@homarr/widgets": "workspace:^0.1.0",
|
"@homarr/widgets": "workspace:^0.1.0",
|
||||||
"@mantine/colors-generator": "^7.11.2",
|
"@mantine/colors-generator": "^7.12.0",
|
||||||
"@mantine/core": "^7.11.2",
|
"@mantine/core": "^7.12.0",
|
||||||
"@mantine/hooks": "^7.11.2",
|
"@mantine/hooks": "^7.12.0",
|
||||||
"@mantine/modals": "^7.11.2",
|
"@mantine/modals": "^7.12.0",
|
||||||
"@mantine/tiptap": "^7.11.2",
|
"@mantine/tiptap": "^7.12.0",
|
||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
"@t3-oss/env-nextjs": "^0.11.0",
|
"@t3-oss/env-nextjs": "^0.11.0",
|
||||||
"@tanstack/react-query": "^5.51.21",
|
"@tanstack/react-query": "^5.51.23",
|
||||||
"@tanstack/react-query-devtools": "^5.51.21",
|
"@tanstack/react-query-devtools": "^5.51.23",
|
||||||
"@tanstack/react-query-next-experimental": "5.51.21",
|
"@tanstack/react-query-next-experimental": "5.51.23",
|
||||||
"@tabler/icons-react": "^3.11.0",
|
"@tabler/icons-react": "^3.12.0",
|
||||||
"@trpc/client": "next",
|
"@trpc/client": "next",
|
||||||
"@trpc/next": "next",
|
"@trpc/next": "next",
|
||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"flag-icons": "^7.2.3",
|
"flag-icons": "^7.2.3",
|
||||||
"glob": "^11.0.0",
|
"glob": "^11.0.0",
|
||||||
"jotai": "^2.9.1",
|
"jotai": "^2.9.2",
|
||||||
"mantine-react-table": "2.0.0-beta.6",
|
"mantine-react-table": "2.0.0-beta.6",
|
||||||
"next": "^14.2.5",
|
"next": "^14.2.5",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/chroma-js": "2.4.4",
|
"@types/chroma-js": "2.4.4",
|
||||||
"@types/node": "^20.14.14",
|
"@types/node": "^20.14.15",
|
||||||
"@types/prismjs": "^1.26.4",
|
"@types/prismjs": "^1.26.4",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
|||||||
@@ -1,14 +1,46 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect } from "react";
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import type { Session } from "@homarr/auth";
|
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;
|
session: Session | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthProvider = ({ children, session }: PropsWithChildren<AuthProviderProps>) => {
|
export const AuthProvider = ({ children, session, logoutUrl }: PropsWithChildren<AuthProviderProps>) => {
|
||||||
return <SessionProvider session={session}>{children}</SessionProvider>;
|
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 {
|
interface LoginProps {
|
||||||
searchParams: {
|
searchParams: {
|
||||||
redirectAfterLogin?: string;
|
callbackUrl?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ export default async function Login({ searchParams }: LoginProps) {
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
redirect(searchParams.redirectAfterLogin ?? "/");
|
redirect(searchParams.callbackUrl ?? "/");
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = await getScopedI18n("user.page.login");
|
const t = await getScopedI18n("user.page.login");
|
||||||
@@ -40,7 +40,7 @@ export default async function Login({ searchParams }: LoginProps) {
|
|||||||
providers={env.AUTH_PROVIDERS}
|
providers={env.AUTH_PROVIDERS}
|
||||||
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
|
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
|
||||||
isOidcAutoLoginEnabled={env.AUTH_OIDC_AUTO_LOGIN}
|
isOidcAutoLoginEnabled={env.AUTH_OIDC_AUTO_LOGIN}
|
||||||
callbackUrl={searchParams.redirectAfterLogin ?? "/"}
|
callbackUrl={searchParams.callbackUrl ?? "/"}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import "~/styles/scroll-area.scss";
|
|||||||
|
|
||||||
import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core";
|
import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core";
|
||||||
|
|
||||||
|
import { env } from "@homarr/auth/env.mjs";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { ModalProvider } from "@homarr/modals";
|
import { ModalProvider } from "@homarr/modals";
|
||||||
import { Notifications } from "@homarr/notifications";
|
import { Notifications } from "@homarr/notifications";
|
||||||
@@ -56,7 +57,7 @@ export default function Layout(props: { children: React.ReactNode; params: { loc
|
|||||||
const StackedProvider = composeWrappers([
|
const StackedProvider = composeWrappers([
|
||||||
async (innerProps) => {
|
async (innerProps) => {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
return <AuthProvider session={session} {...innerProps} />;
|
return <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
|
||||||
},
|
},
|
||||||
(innerProps) => <JotaiProvider {...innerProps} />,
|
(innerProps) => <JotaiProvider {...innerProps} />,
|
||||||
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { IconApps, IconPencil } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
@@ -69,8 +70,8 @@ const AppCard = async ({ app }: AppCardProps) => {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{app.href && (
|
{app.href && (
|
||||||
<Anchor href={app.href} lineClamp={1} size="sm" w="min-content">
|
<Anchor href={parseAppHrefWithVariablesServer(app.href)} lineClamp={1} size="sm" w="min-content">
|
||||||
{app.href}
|
{parseAppHrefWithVariablesServer(app.href)}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
|
|||||||
onSuccess: async (values) => {
|
onSuccess: async (values) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
|
columnCount: values.columnCount,
|
||||||
|
isPublic: values.isPublic,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
boardNames,
|
boardNames,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { MantineColor } from "@mantine/core";
|
import type { MantineColor } from "@mantine/core";
|
||||||
import { Avatar, Badge, Box, Button, Group, Text } 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 type { MRT_ColumnDef } from "mantine-react-table";
|
||||||
import { MantineReactTable } from "mantine-react-table";
|
import { MantineReactTable } from "mantine-react-table";
|
||||||
|
|
||||||
@@ -46,10 +46,12 @@ const createColumns = (
|
|||||||
accessorKey: "image",
|
accessorKey: "image",
|
||||||
header: t("docker.field.containerImage.label"),
|
header: t("docker.field.containerImage.label"),
|
||||||
maxSize: 200,
|
maxSize: 200,
|
||||||
Cell({ renderedCellValue }) {
|
Cell({ renderedCellValue, cell }) {
|
||||||
return (
|
return (
|
||||||
<Box maw={200}>
|
<Box maw={200}>
|
||||||
<Text truncate="end">{renderedCellValue}</Text>
|
<Text truncate="end" title={cell.row.original.image}>
|
||||||
|
{renderedCellValue}
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -93,6 +95,35 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
|||||||
},
|
},
|
||||||
|
|
||||||
initialState: { density: "xs", showGlobalFilter: true },
|
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 }) => {
|
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
|
||||||
return (
|
return (
|
||||||
<Group gap={"sm"}>
|
<Group gap={"sm"}>
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
import { Stack, Title } from "@mantine/core";
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { DockerTable } from "./docker-table";
|
import { DockerTable } from "./docker-table";
|
||||||
|
|
||||||
export default async function DockerPage() {
|
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 { containers, timestamp } = await api.docker.getContainers();
|
||||||
const tDocker = await getScopedI18n("docker");
|
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 { auth } from "@homarr/auth/next";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { LanguageCombobox } from "~/components/language/language-combobox";
|
||||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||||
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { canAccessUserEditPage } from "../access";
|
import { canAccessUserEditPage } from "../access";
|
||||||
|
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
|
||||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||||
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
||||||
import { UserProfileForm } from "./_components/_profile-form";
|
import { UserProfileForm } from "./_components/_profile-form";
|
||||||
import { ProfileLanguageChange } from "./_components/_profile-language-change";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: {
|
params: {
|
||||||
@@ -54,13 +55,17 @@ export default async function EditUserPage({ params }: Props) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const boards = await api.board.getAllBoards();
|
||||||
|
|
||||||
const isCredentialsUser = user.provider === "credentials";
|
const isCredentialsUser = user.provider === "credentials";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
|
{!isCredentialsUser && (
|
||||||
{t("management.page.user.fieldsDisabledExternalProvider")}
|
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
|
||||||
</Alert>
|
{t("management.page.user.fieldsDisabledExternalProvider")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Title>{tGeneral("title")}</Title>
|
<Title>{tGeneral("title")}</Title>
|
||||||
<Group gap="xl">
|
<Group gap="xl">
|
||||||
@@ -72,7 +77,21 @@ export default async function EditUserPage({ params }: Props) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</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 && (
|
{isCredentialsUser && (
|
||||||
<DangerZoneRoot>
|
<DangerZoneRoot>
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ interface CreateItem {
|
|||||||
kind: WidgetKind;
|
kind: WidgetKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DuplicateItem {
|
||||||
|
itemId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const useItemActions = () => {
|
export const useItemActions = () => {
|
||||||
const { updateBoard } = useUpdateBoard();
|
const { updateBoard } = useUpdateBoard();
|
||||||
|
|
||||||
@@ -87,6 +91,38 @@ export const useItemActions = () => {
|
|||||||
[updateBoard],
|
[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(
|
const updateItemOptions = useCallback(
|
||||||
({ itemId, newOptions }: UpdateItemOptions) => {
|
({ itemId, newOptions }: UpdateItemOptions) => {
|
||||||
updateBoard((previous) => {
|
updateBoard((previous) => {
|
||||||
@@ -258,6 +294,7 @@ export const useItemActions = () => {
|
|||||||
updateItemOptions,
|
updateItemOptions,
|
||||||
updateItemAdvancedOptions,
|
updateItemAdvancedOptions,
|
||||||
updateItemIntegrations,
|
updateItemIntegrations,
|
||||||
|
duplicateItem,
|
||||||
createItem,
|
createItem,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { RefObject } from "react";
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { ActionIcon, Card, Menu } from "@mantine/core";
|
import { ActionIcon, Card, Menu } from "@mantine/core";
|
||||||
import { useElementSize } from "@mantine/hooks";
|
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 { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||||
import combineClasses from "clsx";
|
import combineClasses from "clsx";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
@@ -147,7 +147,8 @@ const ItemMenu = ({
|
|||||||
const { openModal } = useModalAction(WidgetEditModal);
|
const { openModal } = useModalAction(WidgetEditModal);
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
const [isEditMode] = useEditMode();
|
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 { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||||
|
|
||||||
@@ -216,6 +217,9 @@ const ItemMenu = ({
|
|||||||
{tItem("action.edit")}
|
{tItem("action.edit")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item leftSection={<IconLayoutKanban size={16} />}>{tItem("action.move")}</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.Divider />
|
||||||
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||||
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
<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) => {
|
export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: IconPickerProps) => {
|
||||||
const [value, setValue] = useState<string>(initialValue ?? "");
|
const [value, setValue] = useState<string>(initialValue ?? "");
|
||||||
const [search, setSearch] = useState(initialValue ?? "");
|
const [search, setSearch] = useState(initialValue ?? "");
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null);
|
||||||
|
|
||||||
const t = useScopedI18n("common");
|
const t = useScopedI18n("common");
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
|||||||
<Combobox
|
<Combobox
|
||||||
onOptionSubmit={(value) => {
|
onOptionSubmit={(value) => {
|
||||||
setValue(value);
|
setValue(value);
|
||||||
|
setPreviewUrl(value);
|
||||||
setSearch(value);
|
setSearch(value);
|
||||||
onChange(value);
|
onChange(value);
|
||||||
combobox.closeDropdown();
|
combobox.closeDropdown();
|
||||||
@@ -62,12 +64,15 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
|||||||
<Combobox.Target>
|
<Combobox.Target>
|
||||||
<InputBase
|
<InputBase
|
||||||
rightSection={<Combobox.Chevron />}
|
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}
|
value={search}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
combobox.openDropdown();
|
combobox.openDropdown();
|
||||||
combobox.updateSelectedOptionIndex();
|
combobox.updateSelectedOptionIndex();
|
||||||
setSearch(event.currentTarget.value);
|
setSearch(event.currentTarget.value);
|
||||||
setValue(event.currentTarget.value);
|
setValue(event.currentTarget.value);
|
||||||
|
setPreviewUrl(null);
|
||||||
onChange(event.currentTarget.value);
|
onChange(event.currentTarget.value);
|
||||||
}}
|
}}
|
||||||
onClick={() => combobox.openDropdown()}
|
onClick={() => combobox.openDropdown()}
|
||||||
@@ -78,6 +83,7 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
|||||||
onBlur={(event) => {
|
onBlur={(event) => {
|
||||||
onBlur?.(event);
|
onBlur?.(event);
|
||||||
combobox.closeDropdown();
|
combobox.closeDropdown();
|
||||||
|
setPreviewUrl(value);
|
||||||
setSearch(value || "");
|
setSearch(value || "");
|
||||||
}}
|
}}
|
||||||
rightSectionPointerEvents="none"
|
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 { useZodForm } from "@homarr/form";
|
||||||
import { createModal } from "@homarr/modals";
|
import { createModal } from "@homarr/modals";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { validation, z } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
import { createCustomErrorParams } from "@homarr/validation/form";
|
import { createCustomErrorParams } from "@homarr/validation/form";
|
||||||
|
|
||||||
interface InnerProps {
|
interface InnerProps {
|
||||||
boardNames: string[];
|
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 }) => {
|
export const AddBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const form = useZodForm(
|
const form = useZodForm(
|
||||||
z.object({
|
validation.board.create.refine((value) => !innerProps.boardNames.includes(value.name), {
|
||||||
name: validation.board.byName.shape.name.refine((value) => !innerProps.boardNames.includes(value), {
|
params: createCustomErrorParams("boardAlreadyExists"),
|
||||||
params: createCustomErrorParams("boardAlreadyExists"),
|
path: ["name"],
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: "",
|
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 (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={form.onSubmit((values) => {
|
onSubmit={form.onSubmit((values) => {
|
||||||
@@ -35,11 +40,21 @@ export const AddBoardModal = createModal<InnerProps>(({ actions, innerProps }) =
|
|||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label={t("board.field.name.label")} data-autofocus {...form.getInputProps("name")} />
|
<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">
|
<Group justify="right">
|
||||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||||
{t("common.action.cancel")}
|
{t("common.action.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={!form.isValid()} type="submit" color="teal">
|
<Button type="submit" color="teal">
|
||||||
{t("common.action.create")}
|
{t("common.action.create")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import "flag-icons/css/flag-icons.min.css";
|
import "flag-icons/css/flag-icons.min.css";
|
||||||
|
|
||||||
|
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||||
import { LanguageCombobox } from "./language/language-combobox";
|
import { LanguageCombobox } from "./language/language-combobox";
|
||||||
|
|
||||||
interface UserAvatarMenuProps {
|
interface UserAvatarMenuProps {
|
||||||
@@ -40,6 +41,7 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
|
|||||||
const session = useSession();
|
const session = useSession();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { logoutUrl } = useAuthContext();
|
||||||
const { openModal } = useModalAction(LogoutModal);
|
const { openModal } = useModalAction(LogoutModal);
|
||||||
|
|
||||||
const handleSignout = useCallback(async () => {
|
const handleSignout = useCallback(async () => {
|
||||||
@@ -48,6 +50,10 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
|
|||||||
});
|
});
|
||||||
openModal({
|
openModal({
|
||||||
onTimeout: () => {
|
onTimeout: () => {
|
||||||
|
if (logoutUrl) {
|
||||||
|
window.location.assign(logoutUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
router.refresh();
|
router.refresh();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,13 +37,13 @@
|
|||||||
"@homarr/cron-job-runner": "workspace:^0.1.0",
|
"@homarr/cron-job-runner": "workspace:^0.1.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
"undici": "6.19.5"
|
"undici": "6.19.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/node": "^20.14.14",
|
"@types/node": "^20.14.15",
|
||||||
"dotenv-cli": "^7.4.2",
|
"dotenv-cli": "^7.4.2",
|
||||||
"eslint": "^9.8.0",
|
"eslint": "^9.8.0",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -4,7 +4,7 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.16.0"
|
"node": ">=20.16.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.6.0",
|
"packageManager": "pnpm@9.7.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
"clean": "git clean -xdf node_modules",
|
"clean": "git clean -xdf node_modules",
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
"db:migration:mysql:generate": "pnpm -F db migration:mysql:generate",
|
"db:migration:mysql:generate": "pnpm -F db migration:mysql:generate",
|
||||||
"db:migration:sqlite:run": "pnpm -F db migration:sqlite:run",
|
"db:migration:sqlite:run": "pnpm -F db migration:sqlite:run",
|
||||||
"db:migration:mysql:run": "pnpm -F db migration:mysql: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",
|
"dev": "turbo dev --parallel",
|
||||||
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
|
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
|
||||||
"format": "turbo format --continue -- --cache --cache-location node_modules/.cache/.prettiercache",
|
"format": "turbo format --continue -- --cache --cache-location node_modules/.cache/.prettiercache",
|
||||||
@@ -28,7 +30,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@turbo/gen": "^2.0.11",
|
"@turbo/gen": "^2.0.12",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"@vitest/coverage-v8": "^2.0.5",
|
"@vitest/coverage-v8": "^2.0.5",
|
||||||
"@vitest/ui": "^2.0.5",
|
"@vitest/ui": "^2.0.5",
|
||||||
@@ -36,9 +38,9 @@
|
|||||||
"jsdom": "^24.1.1",
|
"jsdom": "^24.1.1",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"testcontainers": "^10.11.0",
|
"testcontainers": "^10.11.0",
|
||||||
"turbo": "^2.0.11",
|
"turbo": "^2.0.12",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"vite-tsconfig-paths": "^4.3.2",
|
"vite-tsconfig-paths": "^5.0.1",
|
||||||
"vitest": "^2.0.5"
|
"vitest": "^2.0.5"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ export const boardRouter = createTRPCRouter({
|
|||||||
await transaction.insert(boards).values({
|
await transaction.insert(boards).values({
|
||||||
id: boardId,
|
id: boardId,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
|
isPublic: input.isPublic,
|
||||||
|
columnCount: input.columnCount,
|
||||||
creatorId: ctx.session.user.id,
|
creatorId: ctx.session.user.id,
|
||||||
});
|
});
|
||||||
await transaction.insert(sections).values({
|
await transaction.insert(sections).values({
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { DockerContainerState } from "@homarr/definitions";
|
|||||||
import { createCacheChannel } from "@homarr/redis";
|
import { createCacheChannel } from "@homarr/redis";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
import { createTRPCRouter, permissionRequiredProcedure, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||||
import { DockerSingleton } from "./docker-singleton";
|
import { DockerSingleton } from "./docker-singleton";
|
||||||
|
|
||||||
const dockerCache = createCacheChannel<{
|
const dockerCache = createCacheChannel<{
|
||||||
@@ -16,7 +16,7 @@ const dockerCache = createCacheChannel<{
|
|||||||
}>("docker-containers", 5 * 60 * 1000);
|
}>("docker-containers", 5 * 60 * 1000);
|
||||||
|
|
||||||
export const dockerRouter = createTRPCRouter({
|
export const dockerRouter = createTRPCRouter({
|
||||||
getContainers: publicProcedure.query(async () => {
|
getContainers: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
|
||||||
const { timestamp, data } = await dockerCache.consumeAsync(async () => {
|
const { timestamp, data } = await dockerCache.consumeAsync(async () => {
|
||||||
const dockerInstances = DockerSingleton.getInstance();
|
const dockerInstances = DockerSingleton.getInstance();
|
||||||
const containers = await Promise.all(
|
const containers = await Promise.all(
|
||||||
@@ -59,6 +59,10 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
invalidate: permissionRequiredProcedure.requiresPermission("admin").mutation(async () => {
|
||||||
|
await dockerCache.invalidateAsync();
|
||||||
|
return;
|
||||||
|
}),
|
||||||
startAll: permissionRequiredProcedure
|
startAll: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
.input(z.object({ ids: z.array(z.string()) }))
|
.input(z.object({ ids: z.array(z.string()) }))
|
||||||
|
|||||||
@@ -294,12 +294,14 @@ describe("createBoard should create a new board", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await caller.createBoard({ name: "newBoard" });
|
await caller.createBoard({ name: "newBoard", columnCount: 24, isPublic: true });
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
const dbBoard = await db.query.boards.findFirst();
|
const dbBoard = await db.query.boards.findFirst();
|
||||||
expect(dbBoard).toBeDefined();
|
expect(dbBoard).toBeDefined();
|
||||||
expect(dbBoard?.name).toBe("newBoard");
|
expect(dbBoard?.name).toBe("newBoard");
|
||||||
|
expect(dbBoard?.columnCount).toBe(24);
|
||||||
|
expect(dbBoard?.isPublic).toBe(true);
|
||||||
expect(dbBoard?.creatorId).toBe(defaultCreatorId);
|
expect(dbBoard?.creatorId).toBe(defaultCreatorId);
|
||||||
|
|
||||||
const dbSection = await db.query.sections.findFirst();
|
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 });
|
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const actAsync = async () => await caller.createBoard({ name: "newBoard" });
|
const actAsync = async () => await caller.createBoard({ name: "newBoard", columnCount: 12, isPublic: true });
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await expect(actAsync()).rejects.toThrowError("Permission denied");
|
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,
|
emailVerified: true,
|
||||||
image: true,
|
image: true,
|
||||||
provider: true,
|
provider: true,
|
||||||
|
homeBoardId: true,
|
||||||
},
|
},
|
||||||
where: eq(users.id, input.userId),
|
where: eq(users.id, input.userId),
|
||||||
});
|
});
|
||||||
@@ -266,6 +267,39 @@ export const userRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.where(eq(users.id, input.userId));
|
.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>) => {
|
const createUserAsync = async (db: Database, input: z.infer<typeof validation.user.create>) => {
|
||||||
|
|||||||
@@ -1,32 +1,79 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
import { PiHoleIntegration } from "@homarr/integrations";
|
import { AdGuardHomeIntegration, PiHoleIntegration } from "@homarr/integrations";
|
||||||
import type { DnsHoleSummary } from "@homarr/integrations/types";
|
import type { DnsHoleSummary } from "@homarr/integrations/types";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
import { createCacheChannel } from "@homarr/redis";
|
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";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const dnsHoleRouter = createTRPCRouter({
|
export const dnsHoleRouter = createTRPCRouter({
|
||||||
summary: publicProcedure.unstable_concat(createOneIntegrationMiddleware("query", "piHole")).query(async ({ ctx }) => {
|
summary: publicProcedure
|
||||||
const cache = createCacheChannel<DnsHoleSummary>(`dns-hole-summary:${ctx.integration.id}`);
|
.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 () => {
|
return await client.getSummaryAsync().catch((err) => {
|
||||||
const client = new PiHoleIntegration(ctx.integration);
|
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) => {
|
return {
|
||||||
logger.error("dns-hole router - ", err);
|
integrationId: integration.id,
|
||||||
throw new TRPCError({
|
integrationKind: integration.kind,
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
summary: data,
|
||||||
message: `Failed to fetch DNS Hole summary for ${ctx.integration.name} (${ctx.integration.id})`,
|
};
|
||||||
});
|
}),
|
||||||
});
|
);
|
||||||
});
|
return results;
|
||||||
|
}),
|
||||||
|
|
||||||
return {
|
enable: publicProcedure
|
||||||
...data,
|
.unstable_concat(createOneIntegrationMiddleware("interact", "piHole", "adGuardHome"))
|
||||||
integrationId: ctx.integration.id,
|
.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 { groupMembers, groupPermissions } from "@homarr/db/schema/sqlite";
|
||||||
import { getPermissionsWithChildren } from "@homarr/definitions";
|
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) => {
|
export const getCurrentUserPermissionsAsync = async (db: Database, userId: string) => {
|
||||||
const dbGroupMembers = await db.query.groupMembers.findMany({
|
const dbGroupMembers = await db.query.groupMembers.findMany({
|
||||||
@@ -53,18 +54,18 @@ export const createSignInCallback =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionToken = generateSessionToken();
|
const sessionToken = generateSessionToken();
|
||||||
const sessionExpiry = expireDateAfter(sessionMaxAgeInSeconds);
|
const sessionExpires = expireDateAfter(env.AUTH_SESSION_EXPIRY_TIME);
|
||||||
|
|
||||||
await adapter.createSession({
|
await adapter.createSession({
|
||||||
sessionToken,
|
sessionToken,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
userId: user.id!,
|
userId: user.id!,
|
||||||
expires: sessionExpiry,
|
expires: sessionExpires,
|
||||||
});
|
});
|
||||||
|
|
||||||
cookies().set(sessionTokenCookieName, sessionToken, {
|
cookies().set(sessionTokenCookieName, sessionToken, {
|
||||||
path: "/",
|
path: "/",
|
||||||
expires: sessionExpiry,
|
expires: sessionExpires,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
secure: true,
|
secure: true,
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import { db } from "@homarr/db";
|
|||||||
|
|
||||||
import { adapter } from "./adapter";
|
import { adapter } from "./adapter";
|
||||||
import { createSessionCallback, createSignInCallback } from "./callbacks";
|
import { createSessionCallback, createSignInCallback } from "./callbacks";
|
||||||
|
import { env } from "./env.mjs";
|
||||||
import { createCredentialsConfiguration } from "./providers/credentials/credentials-provider";
|
import { createCredentialsConfiguration } from "./providers/credentials/credentials-provider";
|
||||||
import { EmptyNextAuthProvider } from "./providers/empty/empty-provider";
|
import { EmptyNextAuthProvider } from "./providers/empty/empty-provider";
|
||||||
import { filterProviders } from "./providers/filter-providers";
|
import { filterProviders } from "./providers/filter-providers";
|
||||||
import { OidcProvider } from "./providers/oidc/oidc-provider";
|
import { OidcProvider } from "./providers/oidc/oidc-provider";
|
||||||
import { createRedirectUri } from "./redirect";
|
import { createRedirectUri } from "./redirect";
|
||||||
import { sessionMaxAgeInSeconds, sessionTokenCookieName } from "./session";
|
import { sessionTokenCookieName } from "./session";
|
||||||
|
|
||||||
export const createConfiguration = (isCredentialsRequest: boolean, headers: ReadonlyHeaders | null) =>
|
export const createConfiguration = (isCredentialsRequest: boolean, headers: ReadonlyHeaders | null) =>
|
||||||
NextAuth({
|
NextAuth({
|
||||||
@@ -43,7 +44,7 @@ export const createConfiguration = (isCredentialsRequest: boolean, headers: Read
|
|||||||
secret: "secret-is-not-defined-yet", // TODO: This should be added later
|
secret: "secret-is-not-defined-yet", // TODO: This should be added later
|
||||||
session: {
|
session: {
|
||||||
strategy: "database",
|
strategy: "database",
|
||||||
maxAge: sessionMaxAgeInSeconds,
|
maxAge: env.AUTH_SESSION_EXPIRY_TIME,
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
signIn: "/auth/login",
|
signIn: "/auth/login",
|
||||||
|
|||||||
@@ -23,6 +23,29 @@ const authProvidersSchema = z
|
|||||||
)
|
)
|
||||||
.default("credentials");
|
.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
|
const booleanSchema = z
|
||||||
.string()
|
.string()
|
||||||
.default("false")
|
.default("false")
|
||||||
@@ -39,6 +62,8 @@ const authProviders = skipValidation ? [] : authProvidersSchema.parse(process.en
|
|||||||
|
|
||||||
export const env = createEnv({
|
export const env = createEnv({
|
||||||
server: {
|
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_SECRET: process.env.NODE_ENV === "production" ? z.string().min(1) : z.string().min(1).optional(),
|
||||||
AUTH_PROVIDERS: authProvidersSchema,
|
AUTH_PROVIDERS: authProvidersSchema,
|
||||||
...(authProviders.includes("oidc")
|
...(authProviders.includes("oidc")
|
||||||
@@ -70,6 +95,8 @@ export const env = createEnv({
|
|||||||
},
|
},
|
||||||
client: {},
|
client: {},
|
||||||
runtimeEnv: {
|
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_SECRET: process.env.AUTH_SECRET,
|
||||||
AUTH_PROVIDERS: process.env.AUTH_PROVIDERS,
|
AUTH_PROVIDERS: process.env.AUTH_PROVIDERS,
|
||||||
AUTH_LDAP_BASE: process.env.AUTH_LDAP_BASE,
|
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 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.
|
* 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
|
* @param headers
|
||||||
@@ -11,16 +13,9 @@ export const createRedirectUri = (headers: ReadonlyHeaders | null, pathname: str
|
|||||||
return pathname;
|
return pathname;
|
||||||
}
|
}
|
||||||
|
|
||||||
let protocol = headers.get("x-forwarded-proto") ?? "http";
|
const baseUrl = extractBaseUrlFromHeaders(headers);
|
||||||
|
|
||||||
// @see https://support.glitch.com/t/x-forwarded-proto-contains-multiple-protocols/17219
|
|
||||||
if (protocol.includes(",")) {
|
|
||||||
protocol = protocol.includes("https") ? "https" : "http";
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
const path = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
||||||
|
|
||||||
const host = headers.get("x-forwarded-host") ?? headers.get("host");
|
return `${baseUrl}${path}`;
|
||||||
|
|
||||||
return `${protocol}://${host}${path}`;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { randomUUID } from "crypto";
|
|
||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
|
|
||||||
|
import { generateSecureRandomToken } from "@homarr/common/server";
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
|
|
||||||
import { getCurrentUserPermissionsAsync } from "./callbacks";
|
import { getCurrentUserPermissionsAsync } from "./callbacks";
|
||||||
|
|
||||||
export const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days
|
|
||||||
export const sessionTokenCookieName = "next-auth.session-token";
|
export const sessionTokenCookieName = "next-auth.session-token";
|
||||||
|
|
||||||
export const expireDateAfter = (seconds: number) => {
|
export const expireDateAfter = (seconds: number) => {
|
||||||
@@ -13,7 +12,7 @@ export const expireDateAfter = (seconds: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const generateSessionToken = () => {
|
export const generateSessionToken = () => {
|
||||||
return randomUUID();
|
return generateSecureRandomToken(48);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSessionFromTokenAsync = async (db: Database, token: string | undefined): Promise<Session | null> => {
|
export const getSessionFromTokenAsync = async (db: Database, token: string | undefined): Promise<Session | null> => {
|
||||||
|
|||||||
@@ -132,6 +132,13 @@ const createAdapter = () => {
|
|||||||
type SessionExport = typeof import("../session");
|
type SessionExport = typeof import("../session");
|
||||||
const mockSessionToken = "e9ef3010-6981-4a81-b9d6-8495d09cf3b5";
|
const mockSessionToken = "e9ef3010-6981-4a81-b9d6-8495d09cf3b5";
|
||||||
const mockSessionExpiry = new Date("2023-07-01");
|
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) => {
|
vi.mock("../session", async (importOriginal) => {
|
||||||
const mod = await importOriginal<SessionExport>();
|
const mod = await importOriginal<SessionExport>();
|
||||||
|
|
||||||
@@ -185,7 +192,7 @@ describe("createSignInCallback", () => {
|
|||||||
expect(result).toBe(false);
|
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 adapter = createAdapter();
|
||||||
const isCredentialsRequest = true;
|
const isCredentialsRequest = true;
|
||||||
const signInCallback = createSignInCallback(adapter, isCredentialsRequest);
|
const signInCallback = createSignInCallback(adapter, isCredentialsRequest);
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ describe("expireDateAfter should calculate date after specified seconds", () =>
|
|||||||
describe("generateSessionToken should return a random UUID", () => {
|
describe("generateSessionToken should return a random UUID", () => {
|
||||||
it("should return a random UUID", () => {
|
it("should return a random UUID", () => {
|
||||||
const result = generateSessionToken();
|
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", () => {
|
it("should return a different token each time", () => {
|
||||||
const result1 = generateSessionToken();
|
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",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
"./types": "./src/types.ts"
|
"./types": "./src/types.ts",
|
||||||
|
"./server": "./src/server.ts",
|
||||||
|
"./client": "./src/client.ts"
|
||||||
},
|
},
|
||||||
"typesVersions": {
|
"typesVersions": {
|
||||||
"*": {
|
"*": {
|
||||||
@@ -23,8 +25,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dayjs": "^1.11.12",
|
"dayjs": "^1.11.12",
|
||||||
|
"next": "^14.2.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"next": "^14.2.5"
|
"tldts": "^6.1.38"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@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) => {
|
export const appendPath = (url: URL | string, path: string) => {
|
||||||
const newUrl = new URL(url);
|
const newUrl = new URL(url);
|
||||||
newUrl.pathname = removeTrailingSlash(newUrl.pathname) + path;
|
newUrl.pathname = removeTrailingSlash(newUrl.pathname) + path;
|
||||||
@@ -7,3 +9,16 @@ export const appendPath = (url: URL | string, path: string) => {
|
|||||||
const removeTrailingSlash = (path: string) => {
|
const removeTrailingSlash = (path: string) => {
|
||||||
return path.at(-1) === "/" ? path.substring(0, path.length - 1) : path;
|
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 {
|
interface CreateCronJobOptions {
|
||||||
runOnStart?: boolean;
|
runOnStart?: boolean;
|
||||||
|
beforeStart?: () => MaybePromise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createCallback = <TAllowedNames extends string, TName extends TAllowedNames>(
|
const createCallback = <TAllowedNames extends string, TName extends TAllowedNames>(
|
||||||
@@ -62,6 +63,11 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
|
|||||||
cronExpression,
|
cronExpression,
|
||||||
scheduledTask,
|
scheduledTask,
|
||||||
async onStartAsync() {
|
async onStartAsync() {
|
||||||
|
if (options.beforeStart) {
|
||||||
|
creatorOptions.logger.logDebug(`Running beforeStart for job: ${name}`);
|
||||||
|
await options.beforeStart();
|
||||||
|
}
|
||||||
|
|
||||||
if (!options.runOnStart) return;
|
if (!options.runOnStart) return;
|
||||||
|
|
||||||
creatorOptions.logger.logDebug(`The cron job '${name}' is running because runOnStart is set to true`);
|
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";
|
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();
|
const urls = await pingUrlChannel.getAllAsync();
|
||||||
|
|
||||||
for (const url of new Set(urls)) {
|
for (const url of new Set(urls)) {
|
||||||
|
|||||||
@@ -35,10 +35,10 @@
|
|||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"@auth/core": "^0.34.2",
|
"@auth/core": "^0.34.2",
|
||||||
"better-sqlite3": "^11.1.2",
|
"better-sqlite3": "^11.1.2",
|
||||||
"drizzle-orm": "^0.32.1",
|
"drizzle-orm": "^0.33.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"mysql2": "3.11.0",
|
"mysql2": "3.11.0",
|
||||||
"drizzle-kit": "^0.23.1"
|
"drizzle-kit": "^0.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const widgetKinds = [
|
|||||||
"video",
|
"video",
|
||||||
"notebook",
|
"notebook",
|
||||||
"dnsHoleSummary",
|
"dnsHoleSummary",
|
||||||
|
"dnsHoleControls",
|
||||||
"smartHome-entityState",
|
"smartHome-entityState",
|
||||||
"smartHome-executeAutomation",
|
"smartHome-executeAutomation",
|
||||||
"mediaServer",
|
"mediaServer",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/form": "^7.11.2",
|
"@mantine/form": "^7.12.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "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 type { IntegrationKind } from "@homarr/definitions";
|
||||||
|
|
||||||
|
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
||||||
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
||||||
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
|
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
|
||||||
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
||||||
@@ -10,6 +11,8 @@ export const integrationCreatorByKind = (kind: IntegrationKind, integration: Int
|
|||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "piHole":
|
case "piHole":
|
||||||
return new PiHoleIntegration(integration);
|
return new PiHoleIntegration(integration);
|
||||||
|
case "adGuardHome":
|
||||||
|
return new AdGuardHomeIntegration(integration);
|
||||||
case "homeAssistant":
|
case "homeAssistant":
|
||||||
return new HomeAssistantIntegration(integration);
|
return new HomeAssistantIntegration(integration);
|
||||||
case "jellyfin":
|
case "jellyfin":
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
// General integrations
|
// 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 { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
||||||
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
|
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
|
||||||
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
||||||
|
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type { StreamSession } from "./interfaces/media-server/session";
|
export type { StreamSession } from "./interfaces/media-server/session";
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
export { IntegrationTestConnectionError } from "./base/test-connection-error";
|
|
||||||
export { integrationCreatorByKind } from "./base/creator";
|
export { integrationCreatorByKind } from "./base/creator";
|
||||||
|
export { IntegrationTestConnectionError } from "./base/test-connection-error";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export interface DnsHoleSummary {
|
export interface DnsHoleSummary {
|
||||||
|
status: "enabled" | "disabled";
|
||||||
domainsBeingBlocked: number;
|
domainsBeingBlocked: number;
|
||||||
adsBlockedToday: number;
|
adsBlockedToday: number;
|
||||||
adsBlockedTodayPercentage: number;
|
adsBlockedTodayPercentage: number;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
status: result.data.status,
|
||||||
adsBlockedToday: result.data.ads_blocked_today,
|
adsBlockedToday: result.data.ads_blocked_today,
|
||||||
adsBlockedTodayPercentage: result.data.ads_percentage_today,
|
adsBlockedTodayPercentage: result.data.ads_percentage_today,
|
||||||
domainsBeingBlocked: result.data.domains_being_blocked,
|
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(),
|
dns_queries_today: z.number(),
|
||||||
ads_percentage_today: z.number(),
|
ads_percentage_today: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const controlsInputSchema = z.object({
|
||||||
|
duration: z.number().optional(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ioredis": "5.4.1",
|
"ioredis": "5.4.1",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
"winston": "3.13.1"
|
"winston": "3.14.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"@mantine/core": "^7.11.2",
|
"@mantine/core": "^7.12.0",
|
||||||
"@mantine/hooks": "^7.11.2"
|
"@mantine/hooks": "^7.12.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
@@ -22,9 +22,9 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/notifications": "^7.11.2",
|
"@mantine/notifications": "^7.12.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@tabler/icons-react": "^3.11.0"
|
"@tabler/icons-react": "^3.12.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ export const createListChannel = <TItem>(name: string) => {
|
|||||||
removeAsync: async (item: TItem) => {
|
removeAsync: async (item: TItem) => {
|
||||||
await getSetClient.lrem(listChannelName, 0, superjson.stringify(item));
|
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
|
* Add an item to the channels list
|
||||||
* @param item item to add
|
* @param item item to add
|
||||||
|
|||||||
@@ -24,11 +24,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.11.2",
|
"@mantine/core": "^7.12.0",
|
||||||
"@mantine/hooks": "^7.11.2",
|
"@mantine/hooks": "^7.12.0",
|
||||||
"@mantine/spotlight": "^7.11.2",
|
"@mantine/spotlight": "^7.12.0",
|
||||||
"@tabler/icons-react": "^3.11.0",
|
"@tabler/icons-react": "^3.12.0",
|
||||||
"jotai": "^2.9.1",
|
"jotai": "^2.9.2",
|
||||||
"next": "^14.2.5",
|
"next": "^14.2.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"use-deep-compare-effect": "^1.8.1"
|
"use-deep-compare-effect": "^1.8.1"
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export default {
|
|||||||
previousPassword: {
|
previousPassword: {
|
||||||
label: "Previous password",
|
label: "Previous password",
|
||||||
},
|
},
|
||||||
|
homeBoard: {
|
||||||
|
label: "Home board",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
usernameTaken: "Username already taken",
|
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: {
|
manageAvatar: {
|
||||||
changeImage: {
|
changeImage: {
|
||||||
label: "Change image",
|
label: "Change image",
|
||||||
@@ -558,7 +571,7 @@ export default {
|
|||||||
},
|
},
|
||||||
multiText: {
|
multiText: {
|
||||||
placeholder: "Add more values",
|
placeholder: "Add more values",
|
||||||
addLabel: `Add {value}`,
|
addLabel: "Add {value}",
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
placeholder: "Pick value",
|
placeholder: "Pick value",
|
||||||
@@ -662,6 +675,7 @@ export default {
|
|||||||
import: "Import item",
|
import: "Import item",
|
||||||
edit: "Edit item",
|
edit: "Edit item",
|
||||||
move: "Move item",
|
move: "Move item",
|
||||||
|
duplicate: "Duplicate item",
|
||||||
remove: "Remove item",
|
remove: "Remove item",
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
@@ -763,6 +777,43 @@ export default {
|
|||||||
domainsBeingBlocked: "Domains on blocklist",
|
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: {
|
clock: {
|
||||||
name: "Date and time",
|
name: "Date and time",
|
||||||
description: "Displays the current date and time.",
|
description: "Displays the current date and time.",
|
||||||
@@ -1163,6 +1214,10 @@ export default {
|
|||||||
name: {
|
name: {
|
||||||
label: "Name",
|
label: "Name",
|
||||||
},
|
},
|
||||||
|
isPublic: {
|
||||||
|
label: "Public",
|
||||||
|
description: "Public boards are accessible by everyone, even without an account.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
metaTitle: "{boardName} board",
|
metaTitle: "{boardName} board",
|
||||||
@@ -1362,10 +1417,17 @@ export default {
|
|||||||
setting: {
|
setting: {
|
||||||
general: {
|
general: {
|
||||||
title: "General",
|
title: "General",
|
||||||
|
item: {
|
||||||
|
language: "Language & Region",
|
||||||
|
board: "Home board",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
title: "Security",
|
title: "Security",
|
||||||
},
|
},
|
||||||
|
board: {
|
||||||
|
title: "Boards",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
metaTitle: "Manage users",
|
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: {
|
permission: {
|
||||||
@@ -1681,6 +1756,7 @@ export default {
|
|||||||
},
|
},
|
||||||
general: "General",
|
general: "General",
|
||||||
security: "Security",
|
security: "Security",
|
||||||
|
board: "Boards",
|
||||||
groups: {
|
groups: {
|
||||||
label: "Groups",
|
label: "Groups",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,10 +26,10 @@
|
|||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.11.2",
|
"@mantine/core": "^7.12.0",
|
||||||
"@mantine/dates": "^7.11.2",
|
"@mantine/dates": "^7.12.0",
|
||||||
"@mantine/hooks": "^7.11.2",
|
"@mantine/hooks": "^7.12.0",
|
||||||
"@tabler/icons-react": "^3.11.0",
|
"@tabler/icons-react": "^3.12.0",
|
||||||
"mantine-react-table": "2.0.0-beta.6",
|
"mantine-react-table": "2.0.0-beta.6",
|
||||||
"next": "^14.2.5",
|
"next": "^14.2.5",
|
||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const saveSchema = z.object({
|
|||||||
sections: z.array(createSectionSchema(commonItemSchema)),
|
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({
|
const permissionsSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ const changePasswordSchema = z
|
|||||||
|
|
||||||
const changePasswordApiSchema = changePasswordSchema.and(z.object({ userId: z.string() }));
|
const changePasswordApiSchema = changePasswordSchema.and(z.object({ userId: z.string() }));
|
||||||
|
|
||||||
|
const changeHomeBoardSchema = z.object({
|
||||||
|
homeBoardId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
export const userSchemas = {
|
export const userSchemas = {
|
||||||
signIn: signInSchema,
|
signIn: signInSchema,
|
||||||
registration: registrationSchema,
|
registration: registrationSchema,
|
||||||
@@ -77,5 +81,6 @@ export const userSchemas = {
|
|||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
editProfile: editProfileSchema,
|
editProfile: editProfileSchema,
|
||||||
changePassword: changePasswordSchema,
|
changePassword: changePasswordSchema,
|
||||||
|
changeHomeBoard: changeHomeBoardSchema,
|
||||||
changePasswordApi: changePasswordApiSchema,
|
changePasswordApi: changePasswordApiSchema,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,30 +35,30 @@
|
|||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@mantine/hooks": "^7.11.2",
|
"@mantine/hooks": "^7.12.0",
|
||||||
"@mantine/core": "^7.11.2",
|
"@mantine/core": "^7.12.0",
|
||||||
"@tabler/icons-react": "^3.11.0",
|
"@tabler/icons-react": "^3.12.0",
|
||||||
"@tiptap/extension-color": "2.5.8",
|
"@tiptap/extension-color": "2.5.9",
|
||||||
"@tiptap/extension-highlight": "2.5.8",
|
"@tiptap/extension-highlight": "2.5.9",
|
||||||
"@tiptap/extension-image": "2.5.8",
|
"@tiptap/extension-image": "2.5.9",
|
||||||
"@tiptap/extension-link": "^2.5.8",
|
"@tiptap/extension-link": "^2.5.9",
|
||||||
"@tiptap/extension-table": "2.5.8",
|
"@tiptap/extension-table": "2.5.9",
|
||||||
"@tiptap/extension-table-cell": "2.5.8",
|
"@tiptap/extension-table-cell": "2.5.9",
|
||||||
"@tiptap/extension-table-header": "2.5.8",
|
"@tiptap/extension-table-header": "2.5.9",
|
||||||
"@tiptap/extension-table-row": "2.5.8",
|
"@tiptap/extension-table-row": "2.5.9",
|
||||||
"@tiptap/extension-task-item": "2.5.8",
|
"@tiptap/extension-task-item": "2.5.9",
|
||||||
"@tiptap/extension-task-list": "2.5.8",
|
"@tiptap/extension-task-list": "2.5.9",
|
||||||
"@tiptap/extension-text-align": "2.5.8",
|
"@tiptap/extension-text-align": "2.5.9",
|
||||||
"@tiptap/extension-text-style": "2.5.8",
|
"@tiptap/extension-text-style": "2.5.9",
|
||||||
"@tiptap/extension-underline": "2.5.8",
|
"@tiptap/extension-underline": "2.5.9",
|
||||||
"@tiptap/react": "^2.5.8",
|
"@tiptap/react": "^2.5.9",
|
||||||
"@tiptap/starter-kit": "^2.5.8",
|
"@tiptap/starter-kit": "^2.5.9",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.12",
|
"dayjs": "^1.11.12",
|
||||||
"next": "^14.2.5",
|
"next": "^14.2.5",
|
||||||
"mantine-react-table": "2.0.0-beta.6",
|
"mantine-react-table": "2.0.0-beta.6",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"video.js": "^8.17.1"
|
"video.js": "^8.17.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import combineClasses from "clsx";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { parseAppHrefWithVariablesClient } from "@homarr/common/client";
|
||||||
import { useRegisterSpotlightActions } from "@homarr/spotlight";
|
import { useRegisterSpotlightActions } from "@homarr/spotlight";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
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;
|
const shouldRunPing = Boolean(app?.href) && options.pingEnabled;
|
||||||
clientApi.widget.app.updatedPing.useSubscription(
|
clientApi.widget.app.updatedPing.useSubscription(
|
||||||
{ url: app?.href ?? "" },
|
{ url: parseAppHrefWithVariablesClient(app?.href ?? "") },
|
||||||
{
|
{
|
||||||
enabled: shouldRunPing,
|
enabled: shouldRunPing,
|
||||||
onData(data) {
|
onData(data) {
|
||||||
@@ -60,7 +61,7 @@ export default function AppWidget({ options, serverData, isEditMode, width }: Wi
|
|||||||
icon: app.iconUrl,
|
icon: app.iconUrl,
|
||||||
group: "app",
|
group: "app",
|
||||||
type: "link",
|
type: "link",
|
||||||
href: app.href,
|
href: parseAppHrefWithVariablesClient(app.href),
|
||||||
openInNewTab: options.openInNewTab,
|
openInNewTab: options.openInNewTab,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -92,7 +93,11 @@ export default function AppWidget({ options, serverData, isEditMode, width }: Wi
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<Tooltip.Floating
|
||||||
label={app?.description}
|
label={app?.description}
|
||||||
position="right-start"
|
position="right-start"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
|
||||||
|
|
||||||
import type { WidgetProps } from "../definition";
|
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;
|
let pingResult: RouterOutputs["widget"]["app"]["ping"] | null = null;
|
||||||
|
|
||||||
if (app.href && options.pingEnabled) {
|
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 };
|
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", () => {
|
describe("getServerDataAsync should load app and ping result", () => {
|
||||||
test("when appId is empty it should return null for app and pingResult", async () => {
|
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";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
import type { BoxProps } from "@mantine/core";
|
import type { BoxProps } from "@mantine/core";
|
||||||
import { Box, Card, Flex, Text } from "@mantine/core";
|
import { Box, Card, Flex, Text } from "@mantine/core";
|
||||||
import { useElementSize } from "@mantine/hooks";
|
import { useElementSize } from "@mantine/hooks";
|
||||||
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
|
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { formatNumber } from "@homarr/common";
|
import { formatNumber } from "@homarr/common";
|
||||||
import type { stringOrTranslation, TranslationFunction } from "@homarr/translation";
|
import type { stringOrTranslation, TranslationFunction } from "@homarr/translation";
|
||||||
import { translateIfNecessary } 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 type { WidgetComponentProps, WidgetProps } from "../../definition";
|
||||||
import { NoIntegrationSelectedError } from "../../errors";
|
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);
|
const integrationId = integrationIds.at(0);
|
||||||
|
|
||||||
if (!integrationId) {
|
if (!integrationId) {
|
||||||
throw new NoIntegrationSelectedError();
|
throw new NoIntegrationSelectedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const [data] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
|
const data = useMemo(() => (serverData?.initialData ?? []).flatMap((summary) => summary.summary), [serverData]);
|
||||||
{
|
|
||||||
integrationId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
refetchOnMount: false,
|
|
||||||
retry: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="100%" {...boxPropsByLayout(options.layout)}>
|
<Box h="100%" {...boxPropsByLayout(options.layout)}>
|
||||||
@@ -45,15 +41,22 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget
|
|||||||
const stats = [
|
const stats = [
|
||||||
{
|
{
|
||||||
icon: IconBarrierBlock,
|
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"),
|
label: (t) => t("widget.dnsHoleSummary.data.adsBlockedToday"),
|
||||||
color: "rgba(240, 82, 60, 0.4)", // RED
|
color: "rgba(240, 82, 60, 0.4)", // RED
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconPercentage,
|
icon: IconPercentage,
|
||||||
value: ({ adsBlockedTodayPercentage }, t) =>
|
value: (data, t) =>
|
||||||
t("common.rtl", {
|
t("common.rtl", {
|
||||||
value: formatNumber(adsBlockedTodayPercentage, 2),
|
value: formatNumber(
|
||||||
|
data.reduce((count, { adsBlockedTodayPercentage }) => count + adsBlockedTodayPercentage, 0),
|
||||||
|
2,
|
||||||
|
),
|
||||||
symbol: "%",
|
symbol: "%",
|
||||||
}),
|
}),
|
||||||
label: (t) => t("widget.dnsHoleSummary.data.adsBlockedTodayPercentage"),
|
label: (t) => t("widget.dnsHoleSummary.data.adsBlockedTodayPercentage"),
|
||||||
@@ -61,13 +64,21 @@ const stats = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconSearch,
|
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"),
|
label: (t) => t("widget.dnsHoleSummary.data.dnsQueriesToday"),
|
||||||
color: "rgba(0, 175, 218, 0.4)", // BLUE
|
color: "rgba(0, 175, 218, 0.4)", // BLUE
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconWorldWww,
|
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"),
|
label: (t) => t("widget.dnsHoleSummary.data.domainsBeingBlocked"),
|
||||||
color: "rgba(0, 176, 96, 0.4)", // GREEN
|
color: "rgba(0, 176, 96, 0.4)", // GREEN
|
||||||
},
|
},
|
||||||
@@ -75,14 +86,14 @@ const stats = [
|
|||||||
|
|
||||||
interface StatItem {
|
interface StatItem {
|
||||||
icon: TablerIcon;
|
icon: TablerIcon;
|
||||||
value: (x: RouterOutputs["widget"]["dnsHole"]["summary"], t: TranslationFunction) => string;
|
value: (x: RouterOutputs["widget"]["dnsHole"]["summary"][number]["summary"][], t: TranslationFunction) => string;
|
||||||
label: stringOrTranslation;
|
label: stringOrTranslation;
|
||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
item: StatItem;
|
item: StatItem;
|
||||||
data: RouterOutputs["widget"]["dnsHole"]["summary"];
|
data: RouterOutputs["widget"]["dnsHole"]["summary"][number]["summary"][];
|
||||||
usePiHoleColors: boolean;
|
usePiHoleColors: boolean;
|
||||||
}
|
}
|
||||||
const StatCard = ({ item, data, usePiHoleColors }: StatCardProps) => {
|
const StatCard = ({ item, data, usePiHoleColors }: StatCardProps) => {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
|
|||||||
defaultValue: "grid",
|
defaultValue: "grid",
|
||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
supportedIntegrations: ["piHole"],
|
supportedIntegrations: ["piHole", "adGuardHome"],
|
||||||
errors: {
|
errors: {
|
||||||
INTERNAL_SERVER_ERROR: {
|
INTERNAL_SERVER_ERROR: {
|
||||||
icon: IconServerOff,
|
icon: IconServerOff,
|
||||||
|
|||||||
@@ -5,20 +5,23 @@ import { api } from "@homarr/api/server";
|
|||||||
import type { WidgetProps } from "../../definition";
|
import type { WidgetProps } from "../../definition";
|
||||||
|
|
||||||
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"dnsHoleSummary">) {
|
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"dnsHoleSummary">) {
|
||||||
const integrationId = integrationIds.at(0);
|
if (integrationIds.length === 0) {
|
||||||
if (!integrationId) return { initialData: undefined };
|
return {
|
||||||
|
initialData: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.widget.dnsHole.summary({
|
const currentDns = await api.widget.dnsHole.summary({
|
||||||
integrationId,
|
integrationIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initialData: data,
|
initialData: currentDns,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
initialData: undefined,
|
initialData: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import * as app from "./app";
|
|||||||
import * as calendar from "./calendar";
|
import * as calendar from "./calendar";
|
||||||
import * as clock from "./clock";
|
import * as clock from "./clock";
|
||||||
import type { WidgetComponentProps } from "./definition";
|
import type { WidgetComponentProps } from "./definition";
|
||||||
|
import * as dnsHoleControls from "./dns-hole/controls";
|
||||||
import * as dnsHoleSummary from "./dns-hole/summary";
|
import * as dnsHoleSummary from "./dns-hole/summary";
|
||||||
import * as iframe from "./iframe";
|
import * as iframe from "./iframe";
|
||||||
import type { WidgetImportRecord } from "./import";
|
import type { WidgetImportRecord } from "./import";
|
||||||
@@ -22,9 +23,11 @@ import * as weather from "./weather";
|
|||||||
|
|
||||||
export { reduceWidgetOptionsWithDefaultValues } from "./options";
|
export { reduceWidgetOptionsWithDefaultValues } from "./options";
|
||||||
|
|
||||||
|
export type { WidgetDefinition } from "./definition";
|
||||||
export { WidgetEditModal } from "./modals/widget-edit-modal";
|
export { WidgetEditModal } from "./modals/widget-edit-modal";
|
||||||
export { useServerDataFor } from "./server/provider";
|
export { useServerDataFor } from "./server/provider";
|
||||||
export { GlobalItemServerDataRunner } from "./server/runner";
|
export { GlobalItemServerDataRunner } from "./server/runner";
|
||||||
|
export type { WidgetComponentProps };
|
||||||
|
|
||||||
export const widgetImports = {
|
export const widgetImports = {
|
||||||
clock,
|
clock,
|
||||||
@@ -34,6 +37,7 @@ export const widgetImports = {
|
|||||||
iframe,
|
iframe,
|
||||||
video,
|
video,
|
||||||
dnsHoleSummary,
|
dnsHoleSummary,
|
||||||
|
dnsHoleControls,
|
||||||
"smartHome-entityState": smartHomeEntityState,
|
"smartHome-entityState": smartHomeEntityState,
|
||||||
"smartHome-executeAutomation": smartHomeExecuteAutomation,
|
"smartHome-executeAutomation": smartHomeExecuteAutomation,
|
||||||
mediaServer,
|
mediaServer,
|
||||||
@@ -43,8 +47,6 @@ export const widgetImports = {
|
|||||||
|
|
||||||
export type WidgetImports = typeof widgetImports;
|
export type WidgetImports = typeof widgetImports;
|
||||||
export type WidgetImportKey = keyof WidgetImports;
|
export type WidgetImportKey = keyof WidgetImports;
|
||||||
export type { WidgetComponentProps };
|
|
||||||
export type { WidgetDefinition } from "./definition";
|
|
||||||
|
|
||||||
const loadedComponents = new Map<WidgetKind, ComponentType<WidgetComponentProps<WidgetKind>>>();
|
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]]) => {
|
{Object.entries(definition.options).map(([key, value]: [string, OptionsBuilderResult[string]]) => {
|
||||||
const Input = getInputForType(value.type);
|
const Input = getInputForType(value.type);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
if (
|
||||||
if (!Input || value.shouldHide?.(form.values.options as never)) {
|
// 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { objectEntries } from "@homarr/common";
|
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 type { ZodType } from "@homarr/validation";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ export type inferOptionsFromDefinition<TOptions extends WidgetOptionsRecord> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface FieldConfiguration<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<
|
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": {
|
"dependencies": {
|
||||||
"@next/eslint-plugin-next": "^14.2.5",
|
"@next/eslint-plugin-next": "^14.2.5",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"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-import": "^2.29.1",
|
||||||
"eslint-plugin-jsx-a11y": "^6.9.0",
|
"eslint-plugin-jsx-a11y": "^6.9.0",
|
||||||
"eslint-plugin-react": "^7.35.0",
|
"eslint-plugin-react": "^7.35.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.2",
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
"typescript-eslint": "^8.0.0"
|
"typescript-eslint": "^8.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
|
|||||||
@@ -22,8 +22,10 @@
|
|||||||
"AUTH_LDAP_USER_MAIL_ATTRIBUTE",
|
"AUTH_LDAP_USER_MAIL_ATTRIBUTE",
|
||||||
"AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG",
|
"AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG",
|
||||||
"AUTH_OIDC_AUTO_LOGIN",
|
"AUTH_OIDC_AUTO_LOGIN",
|
||||||
|
"AUTH_LOGOUT_REDIRECT_URL",
|
||||||
"AUTH_PROVIDERS",
|
"AUTH_PROVIDERS",
|
||||||
"AUTH_SECRET",
|
"AUTH_SECRET",
|
||||||
|
"AUTH_SESSION_EXPIRY_TIME",
|
||||||
"CI",
|
"CI",
|
||||||
"DISABLE_REDIS_LOGS",
|
"DISABLE_REDIS_LOGS",
|
||||||
"DB_URL",
|
"DB_URL",
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.5.0",
|
"eslint": "^9.8.0",
|
||||||
"typescript": "^5.5.2"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user