Merge branch 'dev' into fix-stacked-dynamic-sections-menu

This commit is contained in:
Meier Lukas committed 2024-10-26 18:06:22 +02:00
commit c2ddb676fe
182 files changed
+12295 -2404

No files matched your search

-2
View File
@@ -20,8 +20,6 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
# DB_PASSWORD='password'
# DB_NAME='name-of-database'
# @see https://next-auth.js.org/configuration/options#nextauth_url
AUTH_URL='http://localhost:3000'
# You can generate the secret via 'openssl rand -base64 32' on Unix
# @see https://next-auth.js.org/configuration/options#secret
+14 -26
View File
@@ -1,31 +1,19 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"packageRules": [
$schema: "https://docs.renovatebot.com/renovate-schema.json",
extends: ["config:recommended"],
packageRules: [
{
"matchPackagePatterns": [
"^@homarr/",
"tsx" // Disabled for now as version 0.14.4 did not work with the current version of homarr. It resulted in a ERR_MODULE_NOT_FOUND error
],
"enabled": false
matchPackagePatterns: ["^@homarr/"],
enabled: false,
},
{
"matchUpdateTypes": [
"minor",
"patch",
"pin",
"digest"
],
"automerge": true
}
matchUpdateTypes: ["minor", "patch", "pin", "digest"],
automerge: true,
},
],
"updateInternalDeps": true,
"rangeStrategy": "bump",
"automerge": false,
"baseBranches": [
"dev"
],
"dependencyDashboard": false
}
updateInternalDeps: true,
rangeStrategy: "bump",
automerge: false,
baseBranches: ["dev"],
dependencyDashboard: false,
}
+3
View File
@@ -30,6 +30,9 @@ COPY --from=builder /app/cli-out/json/ .
COPY --from=builder /app/next-out/json/ .
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
# Is used for postinstall of docs definitions
COPY --from=builder /app/packages/definitions/src/docs ./packages/definitions/src/docs
# Uses the lockfile to install the dependencies
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
+20 -20
View File
@@ -37,17 +37,17 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.13.2",
"@mantine/core": "^7.13.2",
"@mantine/hooks": "^7.13.2",
"@mantine/modals": "^7.13.2",
"@mantine/tiptap": "^7.13.2",
"@million/lint": "1.0.9",
"@mantine/colors-generator": "^7.13.4",
"@mantine/core": "^7.13.4",
"@mantine/hooks": "^7.13.4",
"@mantine/modals": "^7.13.4",
"@mantine/tiptap": "^7.13.4",
"@million/lint": "1.0.11",
"@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.19.0",
"@tanstack/react-query": "^5.59.9",
"@tanstack/react-query-devtools": "^5.59.9",
"@tanstack/react-query-next-experimental": "5.59.9",
"@tabler/icons-react": "^3.20.0",
"@tanstack/react-query": "^5.59.16",
"@tanstack/react-query-devtools": "^5.59.16",
"@tanstack/react-query-next-experimental": "5.59.16",
"@trpc/client": "next",
"@trpc/next": "next",
"@trpc/react-query": "next",
@@ -55,22 +55,22 @@
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "^5.5.0",
"chroma-js": "^3.1.1",
"chroma-js": "^3.1.2",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"dotenv": "^16.4.5",
"flag-icons": "^7.2.3",
"glob": "^11.0.0",
"jotai": "^2.10.0",
"mantine-react-table": "2.0.0-beta.6",
"next": "^14.2.15",
"jotai": "^2.10.1",
"mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.16",
"postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.29.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13",
"react-error-boundary": "^4.1.2",
"react-simple-code-editor": "^0.14.1",
"sass": "^1.79.5",
"sass": "^1.80.4",
"superjson": "2.2.1",
"swagger-ui-react": "^5.17.14",
"use-deep-compare-effect": "^1.8.1"
@@ -80,13 +80,13 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "2.4.4",
"@types/node": "^20.16.11",
"@types/prismjs": "^1.26.4",
"@types/react": "^18.3.11",
"@types/node": "^20.17.1",
"@types/prismjs": "^1.26.5",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/swagger-ui-react": "^4.18.3",
"concurrently": "^9.0.1",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"node-loader": "^2.0.0",
"prettier": "^3.3.3",
"typescript": "^5.6.3"
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -1,9 +1,8 @@
"use client";
import type { PropsWithChildren } from "react";
import { useState } from "react";
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
import { createTheme, DirectionProvider, isMantineColorScheme, MantineProvider } from "@mantine/core";
import { createTheme, DirectionProvider, MantineProvider } from "@mantine/core";
import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
@@ -16,7 +15,7 @@ export const CustomMantineProvider = ({ children }: PropsWithChildren) => {
return (
<DirectionProvider>
<MantineProvider
defaultColorScheme="auto"
defaultColorScheme="dark"
colorSchemeManager={manager}
theme={createTheme({
primaryColor: "red",
@@ -32,27 +31,23 @@ export const CustomMantineProvider = ({ children }: PropsWithChildren) => {
function useColorSchemeManager(): MantineColorSchemeManager {
const key = "homarr-color-scheme";
const { data: session } = useSession();
const [sessionColorScheme, setSessionColorScheme] = useState<MantineColorScheme | undefined>(
session?.user.colorScheme,
);
const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => {
setClientCookie(key, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
};
const { mutate: mutateColorScheme } = clientApi.user.changeColorScheme.useMutation({
onSuccess: (_, variables) => {
setSessionColorScheme(variables.colorScheme);
updateCookieValue(variables.colorScheme);
},
});
let handleStorageEvent: (event: StorageEvent) => void;
return {
get: (defaultValue) => {
if (typeof window === "undefined") {
return defaultValue;
}
if (sessionColorScheme) {
return sessionColorScheme;
}
try {
const cookies = parseCookies(document.cookie);
return (cookies[key] as MantineColorScheme | undefined) ?? defaultValue;
@@ -62,34 +57,18 @@ function useColorSchemeManager(): MantineColorSchemeManager {
},
set: (value) => {
if (value === "auto") return;
try {
if (session) {
mutateColorScheme({ colorScheme: value });
}
setClientCookie(key, value, { expires: dayjs().add(1, "year").toDate() });
window.localStorage.setItem(key, value);
updateCookieValue(value);
} catch (error) {
console.warn("[@mantine/core] Local storage color scheme manager was unable to save color scheme.", error);
console.warn("[@mantine/core] Color scheme manager was unable to save color scheme.", error);
}
},
subscribe: (onUpdate) => {
handleStorageEvent = (event) => {
if (session) return; // Ignore updates when session is available as we are using session color scheme
if (event.storageArea === window.localStorage && event.key === key && isMantineColorScheme(event.newValue)) {
onUpdate(event.newValue);
}
};
window.addEventListener("storage", handleStorageEvent);
},
unsubscribe: () => {
window.removeEventListener("storage", handleStorageEvent);
},
clear: () => {
window.localStorage.removeItem(key);
},
subscribe: () => undefined,
unsubscribe: () => undefined,
clear: () => undefined,
};
}
@@ -19,11 +19,23 @@ import superjson from "superjson";
import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { env } from "~/env.mjs";
const constructWebsocketUrl = () => {
const fallback = "ws://localhost:3001/websockets";
if (typeof window === "undefined") {
return fallback;
}
if (env.NODE_ENV === "development") {
return fallback;
}
return `ws://${window.location.hostname}:${window.location.port}/websockets`;
};
const wsClient = createWSClient({
url:
typeof window === "undefined"
? "ws://localhost:3001/websockets"
: `ws://${window.location.hostname}:${window.location.port}/websockets`,
url: constructWebsocketUrl(),
});
export function TRPCReactProvider(props: PropsWithChildren) {
@@ -49,6 +49,7 @@ export const BoardProvider = ({
useEffect(() => {
setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.sections.length, setReadySections]);
const markAsReady = useCallback((id: string) => {
@@ -28,7 +28,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />,
getInitialBoardAsync: getInitialBoard,
isBoardContentPage: true,
}),
// eslint-disable-next-line no-restricted-syntax
page: async () => {
@@ -50,6 +49,10 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
icons: {
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
apple: board.faviconImageUrl ? board.faviconImageUrl : undefined,
},
appleWebApp: {
startupImage: { url: board.faviconImageUrl ? board.faviconImageUrl : "/logo/logo.png" },
},
};
} catch (error) {
@@ -8,5 +8,4 @@ export default createBoardLayout<{ locale: string; name: string }>({
async getInitialBoardAsync({ name }) {
return await api.board.getBoardByName({ name });
},
isBoardContentPage: false,
});
@@ -4,7 +4,6 @@ import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server";
import { logger } from "@homarr/log";
import { GlobalItemServerDataRunner } from "@homarr/widgets";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
@@ -18,13 +17,11 @@ import { BoardMantineProvider } from "./(content)/_theme";
interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>;
isBoardContentPage: boolean;
}
export const createBoardLayout = <TParams extends Params>({
headerActions,
getInitialBoardAsync: getInitialBoard,
isBoardContentPage,
}: CreateBoardLayoutProps<TParams>) => {
const Layout = async ({
params,
@@ -42,21 +39,19 @@ export const createBoardLayout = <TParams extends Params>({
});
return (
<GlobalItemServerDataRunner board={initialBoard} shouldRun={isBoardContentPage}>
<BoardProvider initialBoard={initialBoard}>
<BoardMantineProvider>
<CustomCss />
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardMantineProvider>
</BoardProvider>
</GlobalItemServerDataRunner>
<BoardProvider initialBoard={initialBoard}>
<BoardMantineProvider>
<CustomCss />
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardMantineProvider>
</BoardProvider>
);
};
+25 -9
View File
@@ -28,8 +28,7 @@ const fontSans = Inter({
variable: "--font-sans",
});
export const metadata: Metadata = {
metadataBase: new URL("http://localhost:3000"),
export const generateMetadata = (): Metadata => ({
title: "Homarr",
description:
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
@@ -40,12 +39,17 @@ export const metadata: Metadata = {
url: "https://homarr.dev",
siteName: "Homarr Documentation",
},
twitter: {
card: "summary_large_image",
site: "@jullerino",
creator: "@jullerino",
icons: {
icon: "/logo/logo.png",
apple: "/logo/logo.png",
},
};
appleWebApp: {
title: "Homarr",
capable: true,
startupImage: { url: "/logo/logo.png" },
statusBarStyle: getColorScheme() === "dark" ? "black-translucent" : "default",
},
});
export const viewport: Viewport = {
themeColor: [
@@ -56,7 +60,7 @@ export const viewport: Viewport = {
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
const session = await auth();
const colorScheme = cookies().get("homarr-color-scheme")?.value ?? "light";
const colorScheme = getColorScheme();
const tCommon = await getScopedI18n("common");
const direction = tCommon("direction");
@@ -73,7 +77,15 @@ export default async function Layout(props: { children: React.ReactNode; params:
return (
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
<html lang="en" dir={direction} data-mantine-color-scheme={colorScheme} suppressHydrationWarning>
<html
lang="en"
dir={direction}
data-mantine-color-scheme={colorScheme}
style={{
backgroundColor: colorScheme === "dark" ? "#242424" : "#fff",
}}
suppressHydrationWarning
>
<head>
<Analytics />
<SearchEngineOptimization />
@@ -87,3 +99,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
</html>
);
}
const getColorScheme = () => {
return cookies().get("homarr-color-scheme")?.value ?? "dark";
};
@@ -9,7 +9,7 @@ import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions } from "@homarr/definitions";
import { getAllSecretKindOptions, getIntegrationName } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
@@ -32,7 +32,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
const router = useRouter();
const form = useZodForm(validation.integration.create.omit({ kind: true }), {
initialValues: {
name: searchParams.name ?? "",
name: searchParams.name ?? getIntegrationName(searchParams.kind),
url: searchParams.url ?? "",
secrets: secretKinds[0].map((kind) => ({
kind,
@@ -81,7 +81,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
<Stack>
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
<TextInput withAsterisk label={t("integration.field.name.label")} autoFocus {...form.getInputProps("name")} />
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
@@ -161,21 +161,20 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
</TableTd>
<TableTd>
<Group justify="end">
{hasFullAccess ||
(integration.permissions.hasFullAccess && (
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
</ActionIconGroup>
))}
{(hasFullAccess || integration.permissions.hasFullAccess) && (
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
</ActionIconGroup>
)}
</Group>
</TableTd>
</TableTr>
@@ -25,6 +25,7 @@ import {
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { createDocumentationLink } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { MainHeader } from "~/components/layout/header";
@@ -124,7 +125,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{
label: t("items.help.items.documentation"),
icon: IconBook2,
href: "https://homarr.dev/docs/getting-started/",
href: createDocumentationLink("/docs/getting-started"),
external: true,
},
{
@@ -47,7 +47,7 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
),
},
],
[],
[t],
);
const table = useMantineReactTable({
@@ -0,0 +1,66 @@
"use client";
import { Button, Group, Stack, Switch } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
interface PingIconsEnabledProps {
user: RouterOutputs["user"]["getById"];
}
export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changePingIconsEnabled.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
pingIconsEnabled: variables.pingIconsEnabled,
});
showSuccessNotification({
message: t("user.action.changePingIconsEnabled.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changePingIconsEnabled.notification.error.message"),
});
},
});
const form = useZodForm(validation.user.pingIconsEnabled, {
initialValues: {
pingIconsEnabled: user.pingIconsEnabled,
},
});
const handleSubmit = (values: FormType) => {
mutate({
id: user.id,
...values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Switch {...form.getInputProps("pingIconsEnabled")} label={t("user.field.pingIconsEnabled.label")} />
<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.pingIconsEnabled>;
@@ -61,7 +61,7 @@ export const UserProfileForm = ({ user }: UserProfileFormProps) => {
id: user.id,
});
},
[user.id, mutate],
[isProviderCredentials, mutate, user.id],
);
return (
@@ -14,6 +14,7 @@ import { canAccessUserEditPage } from "../access";
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form";
@@ -99,6 +100,11 @@ export default async function EditUserPage({ params }: Props) {
<FirstDayOfWeek user={user} />
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.accessibility")}</Title>
<PingIconsEnabled user={user} />
</Stack>
{isCredentialsUser && (
<DangerZoneRoot>
<DangerZoneItem
@@ -15,9 +15,10 @@ interface RenameGroupFormProps {
id: string;
name: string;
};
disabled?: boolean;
}
export const RenameGroupForm = ({ group }: RenameGroupFormProps) => {
export const RenameGroupForm = ({ group, disabled }: RenameGroupFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.group.updateGroup.useMutation();
const form = useZodForm(validation.group.update.pick({ name: true }), {
@@ -28,6 +29,9 @@ export const RenameGroupForm = ({ group }: RenameGroupFormProps) => {
const handleSubmit = useCallback(
(values: FormType) => {
if (disabled) {
return;
}
mutate(
{
...values,
@@ -60,13 +64,15 @@ export const RenameGroupForm = ({ group }: RenameGroupFormProps) => {
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput label={t("group.field.name")} {...form.getInputProps("name")} />
<TextInput label={t("group.field.name")} {...form.getInputProps("name")} disabled={disabled} />
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
{!disabled && (
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
)}
</Stack>
</form>
);
@@ -0,0 +1,27 @@
import Link from "next/link";
import { Alert, Anchor } from "@mantine/core";
import { IconExclamationCircle } from "@tabler/icons-react";
import { createDocumentationLink } from "@homarr/definitions";
import { getI18n } from "@homarr/translation/server";
export const ReservedGroupAlert = async () => {
const t = await getI18n();
return (
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
{t("group.reservedNotice.message", {
checkoutDocs: (
<Anchor
size="sm"
component={Link}
href={createDocumentationLink("/docs/management/users", "#special-groups")}
target="_blank"
>
{t("common.action.checkoutDocs")}
</Anchor>
),
})}
</Alert>
);
};
@@ -6,9 +6,11 @@ import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { env } from "@homarr/auth/env.mjs";
import { isProviderEnabled } from "@homarr/auth/server";
import { everyoneGroup } from "@homarr/definitions";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { SearchInput, UserAvatar } from "@homarr/ui";
import { ReservedGroupAlert } from "../_reserved-group-alert";
import { AddGroupMember } from "./_add-group-member";
import { RemoveGroupMember } from "./_remove-group-member";
@@ -25,6 +27,7 @@ export default async function GroupsDetailPage({ params, searchParams }: GroupsD
const t = await getI18n();
const tMembers = await getScopedI18n("management.page.group.setting.members");
const group = await api.group.getById({ id: params.id });
const isReserved = group.name === everyoneGroup;
const filteredMembers = searchParams.search
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -41,15 +44,19 @@ export default async function GroupsDetailPage({ params, searchParams }: GroupsD
<Stack>
<Title>{tMembers("title")}</Title>
{providerTypes !== "credentials" && (
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
{t(`group.memberNotice.${providerTypes}`)}
</Alert>
{isReserved ? (
<ReservedGroupAlert />
) : (
providerTypes !== "credentials" && (
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
{t(`group.memberNotice.${providerTypes}`)}
</Alert>
)
)}
<Group justify="space-between">
<SearchInput placeholder={`${tMembers("search")}...`} defaultValue={searchParams.search} />
{isProviderEnabled("credentials") && (
{isProviderEnabled("credentials") && !isReserved && (
<AddGroupMember groupId={group.id} presentUserIds={group.members.map((member) => member.id)} />
)}
</Group>
@@ -63,7 +70,7 @@ export default async function GroupsDetailPage({ params, searchParams }: GroupsD
<Table striped highlightOnHover>
<TableTbody>
{filteredMembers.map((member) => (
<Row key={group.id} member={member} groupId={group.id} />
<Row key={group.id} member={member} groupId={group.id} disabled={isReserved} />
))}
</TableTbody>
</Table>
@@ -74,9 +81,10 @@ export default async function GroupsDetailPage({ params, searchParams }: GroupsD
interface RowProps {
member: RouterOutputs["group"]["getById"]["members"][number];
groupId: string;
disabled?: boolean;
}
const Row = ({ member, groupId }: RowProps) => {
const Row = ({ member, groupId, disabled }: RowProps) => {
return (
<TableTr>
<TableTd>
@@ -88,7 +96,7 @@ const Row = ({ member, groupId }: RowProps) => {
</Group>
</TableTd>
<TableTd w={100}>
{member.provider === "credentials" && <RemoveGroupMember user={member} groupId={groupId} />}
{member.provider === "credentials" && !disabled && <RemoveGroupMember user={member} groupId={groupId} />}
</TableTd>
</TableTr>
);
@@ -1,11 +1,13 @@
import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { everyoneGroup } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
import { DeleteGroup } from "./_delete-group";
import { RenameGroupForm } from "./_rename-group-form";
import { ReservedGroupAlert } from "./_reserved-group-alert";
import { TransferGroupOwnership } from "./_transfer-group-ownership";
interface GroupsDetailPageProps {
@@ -18,26 +20,31 @@ export default async function GroupsDetailPage({ params }: GroupsDetailPageProps
const group = await api.group.getById({ id: params.id });
const tGeneral = await getScopedI18n("management.page.group.setting.general");
const tGroupAction = await getScopedI18n("group.action");
const isReserved = group.name === everyoneGroup;
return (
<Stack>
<Title>{tGeneral("title")}</Title>
<RenameGroupForm group={group} />
{isReserved && <ReservedGroupAlert />}
<DangerZoneRoot>
<DangerZoneItem
label={tGroupAction("transfer.label")}
description={tGroupAction("transfer.description")}
action={<TransferGroupOwnership group={group} />}
/>
<RenameGroupForm group={group} disabled={isReserved} />
<DangerZoneItem
label={tGroupAction("delete.label")}
description={tGroupAction("delete.description")}
action={<DeleteGroup group={group} />}
/>
</DangerZoneRoot>
{!isReserved && (
<DangerZoneRoot>
<DangerZoneItem
label={tGroupAction("transfer.label")}
description={tGroupAction("transfer.description")}
action={<TransferGroupOwnership group={group} />}
/>
<DangerZoneItem
label={tGroupAction("delete.label")}
description={tGroupAction("delete.description")}
action={<DeleteGroup group={group} />}
/>
</DangerZoneRoot>
)}
</Stack>
);
}
+39
View File
@@ -0,0 +1,39 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Homarr",
short_name: "Homarr",
description: "Your dashboard for managing your server.",
start_url: "/",
display: "standalone",
background_color: "#fff",
theme_color: "#fff",
icons: [
{
src: "/images/pwa/192.maskable.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/images/pwa/192.maskable.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/images/pwa/512.maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/images/pwa/512.maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}
@@ -4,7 +4,7 @@ import { QueryErrorResetBoundary } from "@tanstack/react-query";
import combineClasses from "clsx";
import { ErrorBoundary } from "react-error-boundary";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, useServerDataFor } from "@homarr/widgets";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors";
import type { Item } from "~/app/[locale]/boards/_types";
@@ -53,7 +53,6 @@ interface InnerContentProps {
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const serverData = useServerDataFor(item.id);
const Comp = loadWidgetDynamic(item.kind);
const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options);
const newItem = { ...item, options };
@@ -61,8 +60,6 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
updateItemOptions({ itemId: item.id, newOptions });
if (!serverData?.isReady) return null;
return (
<QueryErrorResetBoundary>
{({ reset }) => (
@@ -79,8 +76,6 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
<Comp
options={options as never}
integrationIds={item.integrationIds}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
serverData={serverData?.data as never}
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}
@@ -8,7 +8,6 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { z } from "@homarr/validation";
import type { Item } from "~/app/[locale]/boards/_types";
import { useItemActions } from "./item-actions";
interface InnerProps {
gridStack: GridStack;
@@ -21,7 +20,6 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
const t = useI18n();
// Keep track of the maximum width based on the x offset
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
const { moveAndResizeItem } = useItemActions();
const form = useZodForm(
z.object({
xOffset: z
@@ -62,7 +60,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
});
actions.closeModal();
},
[moveAndResizeItem],
[actions, innerProps.gridStack, innerProps.item.id],
);
return (
@@ -39,7 +39,7 @@ export const GridStackItem = ({
if (type !== "section") return;
innerRef.current.gridstackNode.minW = minWidth;
innerRef.current.gridstackNode.minH = minHeight;
}, [minWidth, minHeight, innerRef]);
}, [minWidth, minHeight, innerRef, type]);
return (
<Box
@@ -215,6 +215,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
}
// Only run this effect when the section items change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemIds.length, columnCount]);
/**
@@ -58,7 +58,7 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
router.refresh();
},
});
}, [openModal, router]);
}, [logoutUrl, openModal, router]);
return (
<Menu width={300} withArrow withinPortal>
+4 -4
View File
@@ -38,17 +38,17 @@
"dayjs": "^1.11.13",
"dotenv": "^16.4.5",
"superjson": "2.2.1",
"undici": "6.20.0"
"undici": "6.20.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^20.16.11",
"@types/node": "^20.17.1",
"dotenv-cli": "^7.4.2",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"prettier": "^3.3.3",
"tsx": "4.13.3",
"tsx": "4.19.1",
"typescript": "^5.6.3"
}
}
-3
View File
@@ -4,10 +4,7 @@ import "./undici-log-agent-override";
import { registerCronJobRunner } from "@homarr/cron-job-runner";
import { jobGroup } from "@homarr/cron-jobs";
import { seedServerSettingsAsync } from "./seed-server-settings";
void (async () => {
registerCronJobRunner();
await jobGroup.startAllAsync();
await seedServerSettingsAsync();
})();
-28
View File
@@ -1,28 +0,0 @@
import SuperJSON from "superjson";
import { db } from "@homarr/db";
import { serverSettings } from "@homarr/db/schema/sqlite";
import { logger } from "@homarr/log";
import { defaultServerSettings, defaultServerSettingsKeys } from "../../../packages/server-settings";
export const seedServerSettingsAsync = async () => {
const serverSettingsData = await db.query.serverSettings.findMany();
let insertedSettingsCount = 0;
for (const settingsKey of defaultServerSettingsKeys) {
if (serverSettingsData.some((setting) => setting.settingKey === settingsKey)) {
return;
}
await db.insert(serverSettings).values({
settingKey: settingsKey,
value: SuperJSON.stringify(defaultServerSettings[settingsKey]),
});
insertedSettingsCount++;
}
if (insertedSettingsCount > 0) {
logger.info(`Inserted ${insertedSettingsCount} missing settings`);
}
};
+2 -2
View File
@@ -26,7 +26,7 @@
"@homarr/redis": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"dotenv": "^16.4.5",
"tsx": "4.13.3",
"tsx": "4.19.1",
"ws": "^8.18.0"
},
"devDependencies": {
@@ -34,7 +34,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.5.12",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"prettier": "^3.3.3",
"typescript": "^5.6.3"
}
+8 -8
View File
@@ -2,7 +2,7 @@
"name": "homarr",
"private": true,
"scripts": {
"build": "turbo build",
"build": "cross-env CI=true turbo build",
"clean": "git clean -xdf node_modules",
"clean:workspaces": "turbo clean",
"cli": "pnpm with-env tsx packages/cli/index.ts",
@@ -27,20 +27,20 @@
"prettier": "@homarr/prettier-config",
"devDependencies": {
"@homarr/prettier-config": "workspace:^0.1.0",
"@turbo/gen": "^2.1.3",
"@vitejs/plugin-react": "^4.3.2",
"@vitest/coverage-v8": "^2.1.2",
"@vitest/ui": "^2.1.2",
"@turbo/gen": "^2.2.3",
"@vitejs/plugin-react": "^4.3.3",
"@vitest/coverage-v8": "^2.1.3",
"@vitest/ui": "^2.1.3",
"cross-env": "^7.0.3",
"jsdom": "^25.0.1",
"prettier": "^3.3.3",
"testcontainers": "^10.13.2",
"turbo": "^2.1.3",
"turbo": "^2.2.3",
"typescript": "^5.6.3",
"vite-tsconfig-paths": "^5.0.1",
"vitest": "^2.1.2"
"vitest": "^2.1.3"
},
"packageManager": "pnpm@9.12.1",
"packageManager": "pnpm@9.12.2",
"engines": {
"node": ">=20.18.0"
},
+1 -1
View File
@@ -32,7 +32,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"typescript": "^5.6.3"
}
}
+2 -2
View File
@@ -39,7 +39,7 @@
"@trpc/react-query": "next",
"@trpc/server": "next",
"dockerode": "^4.0.2",
"next": "^14.2.15",
"next": "^14.2.16",
"react": "^18.3.1",
"superjson": "2.2.1",
"trpc-swagger": "^1.2.6"
@@ -49,7 +49,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.31",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"prettier": "^3.3.3",
"typescript": "^5.6.3"
}
+21 -8
View File
@@ -3,6 +3,7 @@ import { TRPCError } from "@trpc/server";
import type { Database } from "@homarr/db";
import { and, createId, eq, like, not, sql } from "@homarr/db";
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema/sqlite";
import { everyoneGroup } from "@homarr/definitions";
import { validation, z } from "@homarr/validation";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
@@ -121,13 +122,12 @@ export const groupRouter = createTRPCRouter({
.requiresPermission("admin")
.input(validation.group.create)
.mutation(async ({ input, ctx }) => {
const normalizedName = normalizeName(input.name);
await checkSimilarNameAndThrowAsync(ctx.db, normalizedName);
await checkSimilarNameAndThrowAsync(ctx.db, input.name);
const id = createId();
await ctx.db.insert(groups).values({
id,
name: normalizedName,
name: input.name,
ownerId: ctx.session.user.id,
});
@@ -138,14 +138,14 @@ export const groupRouter = createTRPCRouter({
.input(validation.group.update)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
await throwIfGroupNameIsReservedAsync(ctx.db, input.id);
const normalizedName = normalizeName(input.name);
await checkSimilarNameAndThrowAsync(ctx.db, normalizedName, input.id);
await checkSimilarNameAndThrowAsync(ctx.db, input.name, input.id);
await ctx.db
.update(groups)
.set({
name: normalizedName,
name: input.name,
})
.where(eq(groups.id, input.id));
}),
@@ -169,6 +169,7 @@ export const groupRouter = createTRPCRouter({
.input(validation.group.groupUser)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
await ctx.db
.update(groups)
@@ -182,6 +183,7 @@ export const groupRouter = createTRPCRouter({
.input(validation.common.byId)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
await throwIfGroupNameIsReservedAsync(ctx.db, input.id);
await ctx.db.delete(groups).where(eq(groups.id, input.id));
}),
@@ -190,6 +192,7 @@ export const groupRouter = createTRPCRouter({
.input(validation.group.groupUser)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
throwIfCredentialsDisabled();
const user = await ctx.db.query.users.findFirst({
@@ -213,6 +216,7 @@ export const groupRouter = createTRPCRouter({
.input(validation.group.groupUser)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
throwIfCredentialsDisabled();
await ctx.db
@@ -221,8 +225,6 @@ export const groupRouter = createTRPCRouter({
}),
});
const normalizeName = (name: string) => name.trim();
const checkSimilarNameAndThrowAsync = async (db: Database, name: string, ignoreId?: string) => {
const similar = await db.query.groups.findFirst({
where: and(like(groups.name, `${name}`), not(eq(groups.id, ignoreId ?? ""))),
@@ -236,6 +238,17 @@ const checkSimilarNameAndThrowAsync = async (db: Database, name: string, ignoreI
}
};
const throwIfGroupNameIsReservedAsync = async (db: Database, id: string) => {
const count = await db.$count(groups, and(eq(groups.id, id), eq(groups.name, everyoneGroup)));
if (count > 0) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Action is forbidden for reserved group names",
});
}
};
const throwIfGroupNotFoundAsync = async (db: Database, id: string) => {
const group = await db.query.groups.findFirst({
where: eq(groups.id, id),
+5 -43
View File
@@ -248,18 +248,11 @@ describe("editProfile shoud update user", () => {
const user = await db.select().from(schema.users).where(eq(schema.users.id, defaultOwnerId));
expect(user).toHaveLength(1);
expect(user[0]).toStrictEqual({
expect(user[0]).containSubset({
id: defaultOwnerId,
name: "ABC",
email: "abc@gmail.com",
emailVerified,
salt: null,
password: null,
image: null,
homeBoardId: null,
provider: "credentials",
colorScheme: "auto",
firstDayOfWeek: 1,
});
});
@@ -289,18 +282,11 @@ describe("editProfile shoud update user", () => {
const user = await db.select().from(schema.users).where(eq(schema.users.id, defaultOwnerId));
expect(user).toHaveLength(1);
expect(user[0]).toStrictEqual({
expect(user[0]).containSubset({
id: defaultOwnerId,
name: "ABC",
email: "myNewEmail@gmail.com",
emailVerified: null,
salt: null,
password: null,
image: null,
homeBoardId: null,
provider: "credentials",
colorScheme: "auto",
firstDayOfWeek: 1,
});
});
});
@@ -317,40 +303,14 @@ describe("delete should delete user", () => {
{
id: createId(),
name: "User 1",
email: null,
emailVerified: null,
image: null,
password: null,
salt: null,
homeBoardId: null,
provider: "ldap" as const,
colorScheme: "auto" as const,
firstDayOfWeek: 1 as const,
},
{
id: defaultOwnerId,
name: "User 2",
email: null,
emailVerified: null,
image: null,
password: null,
salt: null,
homeBoardId: null,
colorScheme: "auto" as const,
firstDayOfWeek: 1 as const,
},
{
id: createId(),
name: "User 3",
email: null,
emailVerified: null,
image: null,
password: null,
salt: null,
homeBoardId: null,
provider: "oidc" as const,
colorScheme: "auto" as const,
firstDayOfWeek: 1 as const,
},
];
@@ -359,6 +319,8 @@ describe("delete should delete user", () => {
await caller.delete(defaultOwnerId);
const usersInDb = await db.select().from(schema.users);
expect(usersInDb).toStrictEqual([initialUsers[0], initialUsers[2]]);
expect(usersInDb).toHaveLength(2);
expect(usersInDb[0]).containSubset(initialUsers[0]);
expect(usersInDb[1]).containSubset(initialUsers[2]);
});
});
+35 -1
View File
@@ -209,6 +209,7 @@ export const userRouter = createTRPCRouter({
provider: true,
homeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
},
where: eq(users.id, input.userId),
});
@@ -376,6 +377,39 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, ctx.session.user.id));
}),
getPingIconsEnabledOrDefault: publicProcedure.query(async ({ ctx }) => {
if (!ctx.session?.user) {
return false;
}
const user = await ctx.db.query.users.findFirst({
columns: {
id: true,
pingIconsEnabled: true,
},
where: eq(users.id, ctx.session.user.id),
});
return user?.pingIconsEnabled ?? false;
}),
changePingIconsEnabled: protectedProcedure
.input(validation.user.pingIconsEnabled.and(validation.common.byId))
.mutation(async ({ input, ctx }) => {
// Only admins can change other users ping icons enabled
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
pingIconsEnabled: input.pingIconsEnabled,
})
.where(eq(users.id, ctx.session.user.id));
}),
getFirstDayOfWeekForUserOrDefault: publicProcedure.query(async ({ ctx }) => {
if (!ctx.session?.user) {
return 1 as const;
@@ -394,7 +428,7 @@ export const userRouter = createTRPCRouter({
changeFirstDayOfWeek: protectedProcedure
.input(validation.user.firstDayOfWeek.and(validation.common.byId))
.mutation(async ({ input, ctx }) => {
// Only admins can change other users' passwords
// Only admins can change other users first day of week
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
+2 -1
View File
@@ -9,11 +9,12 @@ export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure
.unstable_concat(createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.query(async ({ ctx }) => {
return await Promise.all(
const result = await Promise.all(
ctx.integrations.flatMap(async (integration) => {
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id);
return await cache.getAsync();
}),
);
return result.filter((item) => item !== null).flatMap((item) => item.data);
}),
});
@@ -13,15 +13,13 @@ export const healthMonitoringRouter = createTRPCRouter({
return await Promise.all(
ctx.integrations.map(async (integration) => {
const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id);
const data = await channel.getAsync();
if (!data) {
return null;
}
const { data: healthInfo, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) };
return {
integrationId: integration.id,
integrationName: integration.name,
healthInfo: data.data,
healthInfo,
timestamp,
};
}),
);
@@ -30,7 +28,7 @@ export const healthMonitoringRouter = createTRPCRouter({
subscribeHealthStatus: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault"))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; healthInfo: HealthMonitoring }>((emit) => {
return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integration of ctx.integrations) {
const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id);
@@ -38,6 +36,7 @@ export const healthMonitoringRouter = createTRPCRouter({
emit.next({
integrationId: integration.id,
healthInfo,
timestamp: new Date(),
});
});
unsubscribes.push(unsubscribe);
+1 -1
View File
@@ -36,7 +36,7 @@ export const createSessionAsync = async (
...user,
email: user.email ?? "",
permissions: await getCurrentUserPermissionsAsync(db, user.id),
colorScheme: "auto",
colorScheme: "dark",
},
} as Session;
};
+49 -4
View File
@@ -15,11 +15,12 @@ import { EmptyNextAuthProvider } from "./providers/empty/empty-provider";
import { filterProviders } from "./providers/filter-providers";
import { OidcProvider } from "./providers/oidc/oidc-provider";
import { createRedirectUri } from "./redirect";
import { generateSessionToken, sessionTokenCookieName } from "./session";
import { expireDateAfter, generateSessionToken, sessionTokenCookieName } from "./session";
// See why it's unknown in the [...nextauth]/route.ts file
export const createConfiguration = (provider: SupportedAuthProvider | "unknown", headers: ReadonlyHeaders | null) =>
NextAuth({
export const createConfiguration = (provider: SupportedAuthProvider | "unknown", headers: ReadonlyHeaders | null) => {
const adapter = createAdapter(db, provider);
return NextAuth({
logger: {
error: (code, ...message) => {
// Remove the big error message for failed login attempts
@@ -33,7 +34,18 @@ export const createConfiguration = (provider: SupportedAuthProvider | "unknown",
},
},
trustHost: true,
adapter: createAdapter(db, provider),
cookies: {
sessionToken: {
name: sessionTokenCookieName,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: true,
},
},
},
adapter,
providers: filterProviders([
Credentials(createCredentialsConfiguration(db)),
Credentials(createLdapConfiguration(db)),
@@ -42,6 +54,38 @@ export const createConfiguration = (provider: SupportedAuthProvider | "unknown",
]),
callbacks: {
session: createSessionCallback(db),
// eslint-disable-next-line no-restricted-syntax
signIn: async ({ user }) => {
/**
* For credentials provider only jwt is supported by default
* so we have to create the session and set the cookie manually.
*/
if (provider !== "credentials" && provider !== "ldap") {
return true;
}
if (!adapter.createSession || !user.id) {
return false;
}
const expires = expireDateAfter(env.AUTH_SESSION_EXPIRY_TIME);
const sessionToken = generateSessionToken();
await adapter.createSession({
sessionToken,
expires,
userId: user.id,
});
cookies().set(sessionTokenCookieName, sessionToken, {
path: "/",
expires: expires,
httpOnly: true,
sameSite: "lax",
secure: true,
});
return true;
},
},
events: {
signIn: createSignInEventHandler(db),
@@ -68,3 +112,4 @@ export const createConfiguration = (provider: SupportedAuthProvider | "unknown",
},
},
});
};
+24 -2
View File
@@ -5,6 +5,7 @@ import type { NextAuthConfig } from "next-auth";
import { and, eq, inArray } from "@homarr/db";
import type { Database } from "@homarr/db";
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
import { everyoneGroup } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { env } from "./env.mjs";
@@ -33,6 +34,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
if ("groups" in user && Array.isArray(user.groups)) {
await synchronizeGroupsWithExternalForUserAsync(db, user.id, user.groups as string[]);
}
await addUserToEveryoneGroupIfNotMemberAsync(db, user.id);
if (dbUser.name !== user.name) {
await db.update(users).set({ name: user.name }).where(eq(users.id, user.id));
@@ -57,7 +59,27 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
};
};
const addUserToEveryoneGroupIfNotMemberAsync = async (db: Database, userId: string) => {
const dbEveryoneGroup = await db.query.groups.findFirst({
where: eq(groups.name, everyoneGroup),
with: {
members: {
where: eq(groupMembers.userId, userId),
},
},
});
if (dbEveryoneGroup?.members.length === 0) {
await db.insert(groupMembers).values({
userId,
groupId: dbEveryoneGroup.id,
});
logger.info(`Added user to everyone group. user=${userId}`);
}
};
const synchronizeGroupsWithExternalForUserAsync = async (db: Database, userId: string, externalGroups: string[]) => {
const ignoredGroups = [everyoneGroup];
const dbGroupMembers = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId),
with: {
@@ -102,11 +124,11 @@ const synchronizeGroupsWithExternalForUserAsync = async (db: Database, userId: s
}
/**
* The below groups are those groups the user is part of in Homarr, but not in the external system.
* The below groups are those groups the user is part of in Homarr, but not in the external system and not ignored.
* So he has to be removed from those groups.
*/
const groupsUserIsNoLongerMemberOfExternally = dbGroupMembers.filter(
({ group }) => !externalGroups.includes(group.name),
({ group }) => !externalGroups.concat(ignoredGroups).includes(group.name),
);
if (groupsUserIsNoLongerMemberOfExternally.length > 0) {
+5 -5
View File
@@ -23,8 +23,8 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@auth/core": "^0.37.0",
"@auth/drizzle-adapter": "^1.7.0",
"@auth/core": "^0.37.2",
"@auth/drizzle-adapter": "^1.7.2",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
@@ -34,8 +34,8 @@
"bcrypt": "^5.1.1",
"cookies": "^0.9.1",
"ldapts": "7.2.1",
"next": "^14.2.15",
"next-auth": "5.0.0-beta.22",
"next": "^14.2.16",
"next-auth": "5.0.0-beta.25",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
@@ -45,7 +45,7 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "5.0.2",
"@types/cookies": "0.9.0",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"prettier": "^3.3.3",
"typescript": "^5.6.3"
}
+74 -2
View File
@@ -7,6 +7,7 @@ import { eq } from "@homarr/db";
import type { Database } from "@homarr/db";
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import { everyoneGroup } from "@homarr/definitions";
import { createSignInEventHandler } from "../events";
@@ -34,6 +35,29 @@ vi.mock("next/headers", async (importOriginal) => {
});
describe("createSignInEventHandler should create signInEventHandler", () => {
describe("signInEventHandler should add users to everyone group", () => {
test("should add user to everyone group if he isn't already", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db, everyoneGroup);
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test" },
profile: undefined,
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers?.groupId).toBe("1");
});
});
describe("signInEventHandler should synchronize ldap groups", () => {
test("should add missing group membership", async () => {
// Arrange
@@ -79,6 +103,30 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
});
expect(dbGroupMembers).toBeUndefined();
});
test("should not remove group membership for everyone group", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db, everyoneGroup);
await db.insert(groupMembers).values({
userId: "1",
groupId: "1",
});
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test", groups: [] } as never,
profile: undefined,
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers?.groupId).toBe("1");
});
});
describe("signInEventHandler should synchronize oidc groups", () => {
test("should add missing group membership", async () => {
@@ -125,6 +173,30 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
});
expect(dbGroupMembers).toBeUndefined();
});
test("should not remove group membership for everyone group", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db, everyoneGroup);
await db.insert(groupMembers).values({
userId: "1",
groupId: "1",
});
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test" },
profile: { preferred_username: "test", someRandomGroupsKey: [] },
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers?.groupId).toBe("1");
});
});
test.each([
["ldap" as const, { name: "test-new" }, undefined],
@@ -183,8 +255,8 @@ const createUserAsync = async (db: Database) =>
colorScheme: "dark",
});
const createGroupAsync = async (db: Database) =>
const createGroupAsync = async (db: Database, name = "test") =>
await db.insert(groups).values({
id: "1",
name: "test",
name,
});
+1 -1
View File
@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"typescript": "^5.6.3"
}
}
+3 -3
View File
@@ -27,15 +27,15 @@
"dependencies": {
"@homarr/log": "workspace:^0.1.0",
"dayjs": "^1.11.13",
"next": "^14.2.15",
"next": "^14.2.16",
"react": "^18.3.1",
"tldts": "^6.1.50"
"tldts": "^6.1.56"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"typescript": "^5.6.3"
}
}
+1 -1
View File
@@ -30,7 +30,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"typescript": "^5.6.3"
}
}
+1 -1
View File
@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"typescript": "^5.6.3"
}
}
+1 -1
View File
@@ -32,7 +32,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"typescript": "^5.6.3"
}
}
+1 -1
View File
@@ -41,7 +41,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"typescript": "^5.6.3"
}
}
@@ -0,0 +1 @@
ALTER TABLE `user` ADD `pingIconsEnabled` boolean DEFAULT false NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE `user` MODIFY COLUMN `colorScheme` varchar(5) NOT NULL DEFAULT 'dark';
@@ -0,0 +1 @@
ALTER TABLE `group` ADD CONSTRAINT `group_name_unique` UNIQUE(`name`);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -78,6 +78,27 @@
"when": 1728142597094,
"tag": "0010_melted_pestilence",
"breakpoints": true
},
{
"idx": 11,
"version": "5",
"when": 1728490046896,
"tag": "0011_freezing_banshee",
"breakpoints": true
},
{
"idx": 12,
"version": "5",
"when": 1729348221072,
"tag": "0012_abnormal_wendell_vaughn",
"breakpoints": true
},
{
"idx": 13,
"version": "5",
"when": 1729369383739,
"tag": "0013_youthful_vulture",
"breakpoints": true
}
]
}
+25 -15
View File
@@ -3,25 +3,35 @@ import { drizzle } from "drizzle-orm/mysql2";
import { migrate } from "drizzle-orm/mysql2/migrator";
import mysql from "mysql2";
import type { Database } from "../..";
import * as mysqlSchema from "../../schema/mysql";
import { seedDataAsync } from "../seed";
const migrationsFolder = process.argv[2] ?? ".";
const mysql2 = mysql.createConnection(
process.env.DB_HOST
? {
host: process.env.DB_HOST,
database: process.env.DB_NAME!,
port: Number(process.env.DB_PORT),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
}
: { uri: process.env.DB_URL },
);
const migrateAsync = async () => {
const mysql2 = mysql.createConnection(
process.env.DB_HOST
? {
host: process.env.DB_HOST,
database: process.env.DB_NAME!,
port: Number(process.env.DB_PORT),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
}
: { uri: process.env.DB_URL },
);
const db = drizzle(mysql2, {
mode: "default",
});
const db = drizzle(mysql2, {
mode: "default",
schema: mysqlSchema,
});
migrate(db, { migrationsFolder })
await migrate(db, { migrationsFolder });
await seedDataAsync(db as unknown as Database);
};
migrateAsync()
.then(() => {
console.log("Migration complete");
process.exit(0);
+12
View File
@@ -0,0 +1,12 @@
import { database } from "../driver";
import { seedDataAsync } from "./seed";
seedDataAsync(database)
.then(() => {
console.log("Seed complete");
process.exit(0);
})
.catch((err) => {
console.log("Seed failed\n\t", err);
process.exit(1);
});
+52
View File
@@ -0,0 +1,52 @@
import SuperJSON from "superjson";
import { everyoneGroup } from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import { createId, eq } from "..";
import type { Database } from "..";
import { groups } from "../schema/mysql";
import { serverSettings } from "../schema/sqlite";
export const seedDataAsync = async (db: Database) => {
await seedEveryoneGroupAsync(db);
await seedServerSettingsAsync(db);
};
const seedEveryoneGroupAsync = async (db: Database) => {
const group = await db.query.groups.findFirst({
where: eq(groups.name, everyoneGroup),
});
if (group) {
console.log("Skipping seeding of group 'everyone' as it already exists");
return;
}
await db.insert(groups).values({
id: createId(),
name: everyoneGroup,
});
console.log("Created group 'everyone' through seed");
};
const seedServerSettingsAsync = async (db: Database) => {
const serverSettingsData = await db.query.serverSettings.findMany();
let insertedSettingsCount = 0;
for (const settingsKey of defaultServerSettingsKeys) {
if (serverSettingsData.some((setting) => setting.settingKey === settingsKey)) {
return;
}
await db.insert(serverSettings).values({
settingKey: settingsKey,
value: SuperJSON.stringify(defaultServerSettings[settingsKey]),
});
insertedSettingsCount++;
}
if (insertedSettingsCount > 0) {
console.info(`Inserted ${insertedSettingsCount} missing settings`);
}
};
@@ -0,0 +1 @@
ALTER TABLE `user` ADD `pingIconsEnabled` integer DEFAULT false NOT NULL;
@@ -0,0 +1,21 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_user` (
`id` text PRIMARY KEY NOT NULL,
`name` text,
`email` text,
`emailVerified` integer,
`image` text,
`password` text,
`salt` text,
`provider` text DEFAULT 'credentials' NOT NULL,
`homeBoardId` text,
`colorScheme` text DEFAULT 'dark' NOT NULL,
`firstDayOfWeek` integer DEFAULT 1 NOT NULL,
`pingIconsEnabled` integer DEFAULT false NOT NULL,
FOREIGN KEY (`homeBoardId`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
INSERT INTO `__new_user`("id", "name", "email", "emailVerified", "image", "password", "salt", "provider", "homeBoardId", "colorScheme", "firstDayOfWeek", "pingIconsEnabled") SELECT "id", "name", "email", "emailVerified", "image", "password", "salt", "provider", "homeBoardId", "colorScheme", "firstDayOfWeek", "pingIconsEnabled" FROM `user`;--> statement-breakpoint
DROP TABLE `user`;--> statement-breakpoint
ALTER TABLE `__new_user` RENAME TO `user`;--> statement-breakpoint
PRAGMA foreign_keys=ON;
@@ -0,0 +1 @@
CREATE UNIQUE INDEX `group_name_unique` ON `group` (`name`);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -78,6 +78,27 @@
"when": 1728142590232,
"tag": "0010_gorgeous_stingray",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1728490026154,
"tag": "0011_classy_angel",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1729348200091,
"tag": "0012_ambiguous_black_panther",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1729369389386,
"tag": "0013_faithful_hex",
"breakpoints": true
}
]
}
+20 -3
View File
@@ -2,10 +2,27 @@ import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { schema } from "../..";
import { seedDataAsync } from "../seed";
const migrationsFolder = process.argv[2] ?? ".";
const sqlite = new Database(process.env.DB_URL?.replace("file:", ""));
const migrateAsync = async () => {
const sqlite = new Database(process.env.DB_URL?.replace("file:", ""));
const db = drizzle(sqlite);
const db = drizzle(sqlite, { schema });
migrate(db, { migrationsFolder });
migrate(db, { migrationsFolder });
await seedDataAsync(db);
};
migrateAsync()
.then(() => {
console.log("Migration complete");
process.exit(0);
})
.catch((err) => {
console.log("Migration failed", err);
process.exit(1);
});
+14 -8
View File
@@ -21,26 +21,31 @@
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"migration:mysql:generate": "drizzle-kit generate --config ./configs/mysql.config.ts",
"migration:mysql:run": "drizzle-kit migrate --config ./configs/mysql.config.ts",
"migration:mysql:run": "drizzle-kit migrate --config ./configs/mysql.config.ts && pnpm run seed",
"migration:mysql:drop": "drizzle-kit drop --config ./configs/mysql.config.ts",
"migration:sqlite:generate": "drizzle-kit generate --config ./configs/sqlite.config.ts",
"migration:sqlite:run": "drizzle-kit migrate --config ./configs/sqlite.config.ts",
"migration:sqlite:run": "drizzle-kit migrate --config ./configs/sqlite.config.ts && pnpm run seed",
"migration:sqlite:drop": "drizzle-kit drop --config ./configs/sqlite.config.ts",
"push:mysql": "drizzle-kit push --config ./configs/mysql.config.ts",
"push:sqlite": "drizzle-kit push --config ./configs/sqlite.config.ts",
"seed": "pnpm with-env tsx ./migrations/run-seed.ts",
"studio": "drizzle-kit studio --config ./configs/sqlite.config.ts",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@auth/core": "^0.37.0",
"@auth/core": "^0.37.2",
"@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@paralleldrive/cuid2": "^2.2.2",
"@testcontainers/mysql": "^10.13.2",
"better-sqlite3": "^11.3.0",
"better-sqlite3": "^11.5.0",
"dotenv": "^16.4.5",
"drizzle-kit": "^0.25.0",
"drizzle-orm": "^0.34.1",
"drizzle-kit": "^0.26.2",
"drizzle-orm": "^0.35.3",
"mysql2": "3.11.3"
},
"devDependencies": {
@@ -49,8 +54,9 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "7.6.11",
"dotenv-cli": "^7.4.2",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"prettier": "^3.3.3",
"tsx": "4.19.1",
"typescript": "^5.6.3"
}
}
+3 -2
View File
@@ -43,8 +43,9 @@ export const users = mysqlTable("user", {
homeBoardId: varchar("homeBoardId", { length: 64 }).references((): AnyMySqlColumn => boards.id, {
onDelete: "set null",
}),
colorScheme: varchar("colorScheme", { length: 5 }).$type<ColorScheme>().default("auto").notNull(),
colorScheme: varchar("colorScheme", { length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: tinyint("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean("pingIconsEnabled").default(false).notNull(),
});
export const accounts = mysqlTable(
@@ -119,7 +120,7 @@ export const groupMembers = mysqlTable(
export const groups = mysqlTable("group", {
id: varchar("id", { length: 64 }).notNull().primaryKey(),
name: varchar("name", { length: 64 }).notNull(),
name: varchar("name", { length: 64 }).unique().notNull(),
ownerId: varchar("owner_id", { length: 64 }).references(() => users.id, {
onDelete: "set null",
}),
+3 -2
View File
@@ -44,8 +44,9 @@ export const users = sqliteTable("user", {
homeBoardId: text("homeBoardId").references((): AnySQLiteColumn => boards.id, {
onDelete: "set null",
}),
colorScheme: text("colorScheme").$type<ColorScheme>().default("auto").notNull(),
colorScheme: text("colorScheme").$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: int("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: int("pingIconsEnabled", { mode: "boolean" }).default(false).notNull(),
});
export const accounts = sqliteTable(
@@ -120,7 +121,7 @@ export const groupMembers = sqliteTable(
export const groups = sqliteTable("group", {
id: text("id").notNull().primaryKey(),
name: text("name").notNull(),
name: text("name").unique().notNull(),
ownerId: text("owner_id").references(() => users.id, {
onDelete: "set null",
}),
+3 -2
View File
@@ -18,7 +18,8 @@
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"postinstall": "tsx ./src/docs/codegen.ts"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
@@ -28,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"typescript": "^5.6.3"
}
}
+75
View File
@@ -0,0 +1,75 @@
import fs from "fs/promises";
import path, { dirname } from "path";
import { fileURLToPath } from "url";
import { XMLParser } from "fast-xml-parser";
import { z } from "zod";
import { createDocumentationLink } from "./index";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const removeCommonUrl = (url: string) => {
return url.replace("https://homarr.dev", "");
};
const sitemapSchema = z.object({
urlset: z.object({
url: z.array(
z.object({
loc: z.string(),
}),
),
}),
});
const fetchSitemapAsync = async () => {
const response = await fetch(createDocumentationLink("/sitemap.xml"));
return await response.text();
};
const parseXml = (sitemapXml: string) => {
const parser = new XMLParser();
const data: unknown = parser.parse(sitemapXml);
const result = sitemapSchema.safeParse(data);
if (!result.success) {
throw new Error("Invalid sitemap schema");
}
return result.data;
};
const mapSitemapXmlToPaths = (sitemapData: z.infer<typeof sitemapSchema>) => {
return sitemapData.urlset.url.map((url) => removeCommonUrl(url.loc));
};
const createSitemapPathType = (paths: string[]) => {
return "export type HomarrDocumentationPath =\n" + paths.map((path) => ` | "${path.replace(/\/$/, "")}"`).join("\n");
};
const updateSitemapTypeFileAsync = async (sitemapPathType: string) => {
const content =
"// This file is auto-generated by the codegen script\n" +
"// it uses the sitemap.xml to generate the HomarrDocumentationPath type\n" +
sitemapPathType +
";\n";
await fs.writeFile(path.join(__dirname, "homarr-docs-sitemap.ts"), content);
};
/**
* This script fetches the sitemap.xml and generates the HomarrDocumentationPath type
* which is used for typesafe documentation links
*/
// eslint-disable-next-line no-restricted-syntax
const main = async () => {
const sitemapXml = await fetchSitemapAsync();
const sitemapData = parseXml(sitemapXml);
const paths = mapSitemapXmlToPaths(sitemapData);
// Adding sitemap as it's not in the sitemap.xml and we need it for this file
paths.push("/sitemap.xml");
const sitemapPathType = createSitemapPathType(paths);
await updateSitemapTypeFileAsync(sitemapPathType);
};
void main();
@@ -0,0 +1,191 @@
// This file is auto-generated by the codegen script
// it uses the sitemap.xml to generate the HomarrDocumentationPath type
export type HomarrDocumentationPath =
| "/about-us"
| "/blog"
| "/blog/2023/01/11/version0.11"
| "/blog/2023/04/16/version0.12-more-widgets"
| "/blog/2023/11/10/authentication"
| "/blog/2023/12/22/updated-documentation"
| "/blog/2024/09/23/version-1.0"
| "/blog/archive"
| "/blog/authors"
| "/blog/authors/ajnart"
| "/blog/authors/manuel-rw"
| "/blog/authors/meierschlumpf"
| "/blog/authors/tagashi"
| "/blog/authors/walkx"
| "/blog/documentation-migration"
| "/blog/tags"
| "/blog/tags/authentication"
| "/blog/tags/breaking-changes"
| "/blog/tags/contributions"
| "/blog/tags/design"
| "/blog/tags/dnd"
| "/blog/tags/docs"
| "/blog/tags/documentation"
| "/blog/tags/gridstack"
| "/blog/tags/homarr"
| "/blog/tags/migration"
| "/blog/tags/notepad"
| "/blog/tags/security"
| "/blog/tags/translations"
| "/blog/tags/update"
| "/blog/tags/version"
| "/blog/translations"
| "/docs/tags"
| "/docs/tags/active-directory"
| "/docs/tags/ad-guard"
| "/docs/tags/ad-guard-home"
| "/docs/tags/administration"
| "/docs/tags/advanced"
| "/docs/tags/analytics"
| "/docs/tags/api"
| "/docs/tags/banner"
| "/docs/tags/blocking"
| "/docs/tags/board"
| "/docs/tags/boards"
| "/docs/tags/bookmark"
| "/docs/tags/caddy"
| "/docs/tags/checklist"
| "/docs/tags/code"
| "/docs/tags/community"
| "/docs/tags/configuration"
| "/docs/tags/connections"
| "/docs/tags/customization"
| "/docs/tags/data-sources"
| "/docs/tags/developer"
| "/docs/tags/development"
| "/docs/tags/dns"
| "/docs/tags/docker"
| "/docs/tags/edit-mode"
| "/docs/tags/env"
| "/docs/tags/environment-variables"
| "/docs/tags/feeds"
| "/docs/tags/getting-started"
| "/docs/tags/google"
| "/docs/tags/grafana"
| "/docs/tags/groups"
| "/docs/tags/hardware"
| "/docs/tags/health"
| "/docs/tags/help"
| "/docs/tags/icons"
| "/docs/tags/iframe"
| "/docs/tags/images"
| "/docs/tags/installation"
| "/docs/tags/integrade"
| "/docs/tags/integration"
| "/docs/tags/integrations"
| "/docs/tags/interface"
| "/docs/tags/jellyserr"
| "/docs/tags/ldap"
| "/docs/tags/links"
| "/docs/tags/lists"
| "/docs/tags/management"
| "/docs/tags/monitoring"
| "/docs/tags/news"
| "/docs/tags/notebook"
| "/docs/tags/notes"
| "/docs/tags/oidc"
| "/docs/tags/open-media-vault"
| "/docs/tags/overseerr"
| "/docs/tags/permissions"
| "/docs/tags/pi-hole"
| "/docs/tags/preferences"
| "/docs/tags/programming"
| "/docs/tags/proxmox"
| "/docs/tags/proxy"
| "/docs/tags/roles"
| "/docs/tags/rss"
| "/docs/tags/search"
| "/docs/tags/search-engines"
| "/docs/tags/security"
| "/docs/tags/seo"
| "/docs/tags/server"
| "/docs/tags/settings"
| "/docs/tags/sinkhole"
| "/docs/tags/sso"
| "/docs/tags/system"
| "/docs/tags/table"
| "/docs/tags/technical-documentation"
| "/docs/tags/text"
| "/docs/tags/theming"
| "/docs/tags/traefik"
| "/docs/tags/translations"
| "/docs/tags/unraid"
| "/docs/tags/user"
| "/docs/tags/users"
| "/docs/tags/variables"
| "/docs/tags/widgets"
| "/docs/advanced/command-line"
| "/docs/advanced/command-line/password-recovery"
| "/docs/advanced/configuration/environment-variables"
| "/docs/advanced/configuration/keyboard-shortcuts"
| "/docs/advanced/configuration/proxies-and-certificates"
| "/docs/advanced/customizations/board-customization"
| "/docs/advanced/customizations/dark-mode"
| "/docs/advanced/customizations/icons"
| "/docs/advanced/customizations/user-preferences"
| "/docs/advanced/sso"
| "/docs/category/advanced"
| "/docs/category/getting-started"
| "/docs/category/installation"
| "/docs/category/installation-1"
| "/docs/category/integrations"
| "/docs/category/management"
| "/docs/category/more"
| "/docs/category/widgets"
| "/docs/community/developer-guides"
| "/docs/community/donate"
| "/docs/community/faq"
| "/docs/community/get-in-touch"
| "/docs/community/license"
| "/docs/community/translations"
| "/docs/getting-started"
| "/docs/getting-started/after-the-installation"
| "/docs/getting-started/glossary"
| "/docs/getting-started/installation/docker"
| "/docs/getting-started/installation/easy-panel"
| "/docs/getting-started/installation/home-assistant"
| "/docs/getting-started/installation/kubernetes"
| "/docs/getting-started/installation/portainer"
| "/docs/getting-started/installation/qnap"
| "/docs/getting-started/installation/saltbox"
| "/docs/getting-started/installation/source"
| "/docs/getting-started/installation/synology"
| "/docs/getting-started/installation/truenas"
| "/docs/getting-started/installation/unraid"
| "/docs/integrations/containers"
| "/docs/integrations/dns"
| "/docs/integrations/hardware"
| "/docs/integrations/media-requester"
| "/docs/integrations/media-server"
| "/docs/integrations/servarr"
| "/docs/integrations/torrent"
| "/docs/integrations/usenet"
| "/docs/management/api"
| "/docs/management/boards"
| "/docs/management/integrations"
| "/docs/management/search-engines"
| "/docs/management/settings"
| "/docs/management/users"
| "/docs/widgets/bookmarks"
| "/docs/widgets/calendar-widget"
| "/docs/widgets/clock-widget"
| "/docs/widgets/dashdot-widget"
| "/docs/widgets/dns-hole"
| "/docs/widgets/download-speed-widget"
| "/docs/widgets/health-monitoring"
| "/docs/widgets/home-assistant"
| "/docs/widgets/iframe"
| "/docs/widgets/indexer-manager"
| "/docs/widgets/media-requests"
| "/docs/widgets/media-server"
| "/docs/widgets/notebook"
| "/docs/widgets/rss-widget"
| "/docs/widgets/torrent-widget"
| "/docs/widgets/usenet-widget"
| "/docs/widgets/video"
| "/docs/widgets/weather-widget"
| ""
| "/sitemap.xml";
+7
View File
@@ -0,0 +1,7 @@
import type { HomarrDocumentationPath } from "./homarr-docs-sitemap";
const documentationBaseUrl = "https://deploy-preview-113--homarr-docs.netlify.app";
// Please use the method so the path can be checked!
export const createDocumentationLink = (path: HomarrDocumentationPath, hashTag?: `#${string}`) =>
`${documentationBaseUrl}${path}${hashTag ?? ""}`;
+1
View File
@@ -0,0 +1 @@
export const everyoneGroup = "everyone";
+2
View File
@@ -6,3 +6,5 @@ export * from "./permissions";
export * from "./docker";
export * from "./auth";
export * from "./user";
export * from "./group";
export * from "./docs";
+1 -1
View File
@@ -1,2 +1,2 @@
export const colorSchemes = ["light", "dark", "auto"] as const;
export const colorSchemes = ["light", "dark"] as const;
export type ColorScheme = (typeof colorSchemes)[number];
+2 -2
View File
@@ -24,13 +24,13 @@
"dependencies": {
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^7.13.2"
"@mantine/form": "^7.13.4"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"typescript": "^5.6.3"
}
}
+1 -1
View File
@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"typescript": "^5.6.3"
}
}
+4 -2
View File
@@ -33,13 +33,15 @@
"@homarr/log": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@jellyfin/sdk": "^0.10.0"
"@jellyfin/sdk": "^0.10.0",
"xml2js": "^0.6.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.12.0",
"@types/xml2js": "^0.4.14",
"eslint": "^9.13.0",
"typescript": "^5.6.3"
}
}
@@ -17,6 +17,7 @@ import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration"
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
import { PlexIntegration } from "../plex/plex-integration";
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
import type { Integration, IntegrationInput } from "./integration";
@@ -51,6 +52,7 @@ export const integrationCreators = {
adGuardHome: AdGuardHomeIntegration,
homeAssistant: HomeAssistantIntegration,
jellyfin: JellyfinIntegration,
plex: PlexIntegration,
sonarr: SonarrIntegration,
radarr: RadarrIntegration,
sabNzbd: SabnzbdIntegration,
@@ -1,7 +1,11 @@
export const radarrReleaseTypes = ["inCinemas", "digitalRelease", "physicalRelease"] as const;
type ReleaseType = (typeof radarrReleaseTypes)[number];
export interface CalendarEvent {
name: string;
subName: string;
date: Date;
dates?: { type: ReleaseType; date: Date }[];
description?: string;
thumbnail?: string;
mediaInformation?: {
+11 -10
View File
@@ -1,30 +1,31 @@
// General integrations
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
export { DelugeIntegration } from "./download-client/deluge/deluge-integration";
export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration";
export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration";
export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration";
export { TransmissionIntegration } from "./download-client/transmission/transmission-integration";
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
export { DownloadClientIntegration } from "./interfaces/downloads/download-client-integration";
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration";
export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration";
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration";
export { OverseerrIntegration } from "./overseerr/overseerr-integration";
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
export { PlexIntegration } from "./plex/plex-integration";
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration";
export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration";
export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration";
export { DelugeIntegration } from "./download-client/deluge/deluge-integration";
export { TransmissionIntegration } from "./download-client/transmission/transmission-integration";
// Types
export type { IntegrationInput } from "./base/integration";
export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data";
export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items";
export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status";
export type { HealthMonitoring } from "./interfaces/health-monitoring/healt-monitoring";
export { MediaRequestStatus } from "./interfaces/media-requests/media-request";
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request";
export type { StreamSession } from "./interfaces/media-server/session";
export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status";
export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items";
export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data";
export type { IntegrationInput } from "./base/integration";
// Schemas
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
@@ -1,8 +1,10 @@
import type { AtLeastOneOf } from "@homarr/common/types";
import { logger } from "@homarr/log";
import { z } from "@homarr/validation";
import { Integration } from "../../base/integration";
import type { CalendarEvent } from "../../calendar-types";
import { radarrReleaseTypes } from "../../calendar-types";
export class RadarrIntegration extends Integration {
/**
@@ -37,19 +39,23 @@ export class RadarrIntegration extends Integration {
});
const radarrCalendarEvents = await z.array(radarrCalendarEventSchema).parseAsync(await response.json());
return radarrCalendarEvents.map(
(radarrCalendarEvent): CalendarEvent => ({
return radarrCalendarEvents.map((radarrCalendarEvent): CalendarEvent => {
const dates = radarrReleaseTypes
.map((type) => (radarrCalendarEvent[type] ? { type, date: radarrCalendarEvent[type] } : undefined))
.filter((date) => date) as AtLeastOneOf<Exclude<CalendarEvent["dates"], undefined>[number]>;
return {
name: radarrCalendarEvent.title,
subName: radarrCalendarEvent.originalTitle,
description: radarrCalendarEvent.overview,
thumbnail: this.chooseBestImageAsURL(radarrCalendarEvent),
date: radarrCalendarEvent.inCinemas,
date: dates[0].date,
dates,
mediaInformation: {
type: "movie",
},
links: this.getLinksForRadarrCalendarEvent(radarrCalendarEvent),
}),
);
};
});
}
private getLinksForRadarrCalendarEvent = (event: z.infer<typeof radarrCalendarEventSchema>) => {
@@ -118,7 +124,18 @@ const radarrCalendarEventImageSchema = z.array(
const radarrCalendarEventSchema = z.object({
title: z.string(),
originalTitle: z.string(),
inCinemas: z.string().transform((value) => new Date(value)),
inCinemas: z
.string()
.transform((value) => new Date(value))
.optional(),
physicalRelease: z
.string()
.transform((value) => new Date(value))
.optional(),
digitalRelease: z
.string()
.transform((value) => new Date(value))
.optional(),
overview: z.string().optional(),
titleSlug: z.string(),
images: radarrCalendarEventImageSchema,
@@ -0,0 +1,37 @@
interface MediaContainer {
Video?: Session[];
Track?: Session[];
}
interface Session {
User?: {
$: {
id: string;
title: string;
thumb?: string;
};
}[];
Player?: {
$: {
product: string;
title: string;
};
}[];
Session?: {
$: {
id: string;
};
}[];
$: {
grandparentTitle?: string;
parentTitle?: string;
title?: string;
index?: number;
type: string;
live?: string;
};
}
export interface PlexResponse {
MediaContainer: MediaContainer;
}
@@ -0,0 +1,103 @@
import { parseStringPromise } from "xml2js";
import { logger } from "@homarr/log";
import { Integration } from "../base/integration";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import type { StreamSession } from "../interfaces/media-server/session";
import type { PlexResponse } from "./interface";
export class PlexIntegration extends Integration {
public async getCurrentSessionsAsync(): Promise<StreamSession[]> {
const token = super.getSecretValue("apiKey");
const response = await fetch(`${this.integration.url}/status/sessions`, {
headers: {
"X-Plex-Token": token,
},
});
const body = await response.text();
// convert xml response to objects, as there is no JSON api
const data = await PlexIntegration.parseXml<PlexResponse>(body);
const mediaContainer = data.MediaContainer;
const mediaElements = [mediaContainer.Video ?? [], mediaContainer.Track ?? []].flat();
// no sessions are open or available
if (mediaElements.length === 0) {
logger.info("No active video sessions found in MediaContainer");
return [];
}
const medias = mediaElements
.map((mediaElement): StreamSession | undefined => {
const userElement = mediaElement.User ? mediaElement.User[0] : undefined;
const playerElement = mediaElement.Player ? mediaElement.Player[0] : undefined;
const sessionElement = mediaElement.Session ? mediaElement.Session[0] : undefined;
if (!playerElement) {
return undefined;
}
return {
sessionId: sessionElement?.$.id ?? "unknown",
sessionName: `${playerElement.$.product} (${playerElement.$.title})`,
user: {
userId: userElement?.$.id ?? "Anonymous",
username: userElement?.$.title ?? "Anonymous",
profilePictureUrl: userElement?.$.thumb ?? null,
},
currentlyPlaying: {
type: mediaElement.$.live === "1" ? "tv" : PlexIntegration.getCurrentlyPlayingType(mediaElement.$.type),
name: mediaElement.$.grandparentTitle ?? mediaElement.$.title ?? "Unknown",
seasonName: mediaElement.$.parentTitle,
episodeName: mediaElement.$.title ?? null,
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
episodeCount: mediaElement.$.index ?? null,
},
};
})
.filter((session): session is StreamSession => session !== undefined);
return medias;
}
public async testConnectionAsync(): Promise<void> {
const token = super.getSecretValue("apiKey");
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetch(this.integration.url, {
headers: {
"X-Plex-Token": token,
},
});
},
handleResponseAsync: async (response) => {
try {
const result = await response.text();
await PlexIntegration.parseXml<PlexResponse>(result);
return;
} catch {
throw new IntegrationTestConnectionError("invalidCredentials");
}
},
});
}
static parseXml<T>(xml: string): Promise<T> {
return parseStringPromise(xml) as Promise<T>;
}
static getCurrentlyPlayingType(type: string): NonNullable<StreamSession["currentlyPlaying"]>["type"] {
switch (type) {
case "movie":
return "movie";
case "episode":
return "video";
case "track":
return "audio";
default:
return "video";
}
}
}
+1 -1
View File
@@ -34,7 +34,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"typescript": "^5.6.3"
}
}
+4 -4
View File
@@ -30,17 +30,17 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.13.2",
"@tabler/icons-react": "^3.19.0",
"@mantine/core": "^7.13.4",
"@tabler/icons-react": "^3.20.0",
"dayjs": "^1.11.13",
"next": "^14.2.15",
"next": "^14.2.16",
"react": "^18.3.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"typescript": "^5.6.3"
},
"prettier": "@homarr/prettier-config"
+3 -3
View File
@@ -24,15 +24,15 @@
"dependencies": {
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.13.2",
"@mantine/hooks": "^7.13.2",
"@mantine/core": "^7.13.4",
"@mantine/hooks": "^7.13.4",
"react": "^18.3.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"typescript": "^5.6.3"
}
}
+2 -2
View File
@@ -53,7 +53,7 @@ export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(({ act
actions.closeModal();
}
},
[cancelProps?.onClick, onCancel, actions.closeModal],
[cancelProps, onCancel, closeOnCancel, actions],
);
const handleConfirm = useCallback(
@@ -73,7 +73,7 @@ export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(({ act
}
setLoading(false);
},
[confirmProps?.onClick, onConfirm, actions.closeModal],
[confirmProps, onConfirm, closeOnConfirm, actions],
);
return (
+2 -5
View File
@@ -38,7 +38,7 @@ export const ModalProvider = ({ children }: PropsWithChildren) => {
(id: string, canceled?: boolean) => {
dispatch({ type: "CLOSE", modalId: id, canceled });
},
[stateRef, dispatch],
[dispatch],
);
const openModalInner: ModalContextProps["openModalInner"] = useCallback(
@@ -63,10 +63,7 @@ export const ModalProvider = ({ children }: PropsWithChildren) => {
[dispatch],
);
const handleCloseModal = useCallback(
() => state.current && closeModal(state.current.id),
[closeModal, state.current?.id],
);
const handleCloseModal = useCallback(() => state.current && closeModal(state.current.id), [closeModal, state]);
const activeModals = state.modals.filter((modal) => modal.id === state.current?.id || modal.props.keepMounted);
+3 -3
View File
@@ -24,14 +24,14 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/ui": "workspace:^0.1.0",
"@mantine/notifications": "^7.13.2",
"@tabler/icons-react": "^3.19.0"
"@mantine/notifications": "^7.13.4",
"@tabler/icons-react": "^3.20.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"typescript": "^5.6.3"
}
}
+1 -1
View File
@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"typescript": "^5.6.3"
},
"prettier": "@homarr/prettier-config"
@@ -4,6 +4,7 @@ export type OldmarrRssDefinition = CommonOldmarrWidgetDefinition<
"rss",
{
rssFeedUrl: string[];
enableRtl: boolean;
refreshInterval: number;
dangerousAllowSanitizedItemContent: boolean;
textLinesClamp: number;
@@ -23,6 +23,7 @@ const optionMapping: OptionMapping = {
},
"mediaRequests-requestStats": {},
calendar: {
releaseType: (oldOptions) => [oldOptions.radarrReleaseType],
filterFutureMonths: () => undefined,
filterPastMonths: () => undefined,
},
@@ -81,6 +82,7 @@ const optionMapping: OptionMapping = {
},
rssFeed: {
feedUrls: (oldOptions) => oldOptions.rssFeedUrl,
enableRtl: (oldOptions) => oldOptions.enableRtl,
maximumAmountPosts: (oldOptions) => oldOptions.maximumAmountOfPosts,
textLinesClamp: (oldOptions) => oldOptions.textLinesClamp,
},
+1 -1
View File
@@ -27,7 +27,7 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"typescript": "^5.6.3"
},
"prettier": "@homarr/prettier-config"
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff. Show more