chore(release): automatic release v0.1.0

This commit is contained in:
homarr-releases[bot]
2024-11-29 19:15:06 +00:00
committed by GitHub
163 changed files with 6964 additions and 5417 deletions

View File

@@ -37,17 +37,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.14.1", "@mantine/colors-generator": "^7.14.3",
"@mantine/core": "^7.14.1", "@mantine/core": "^7.14.3",
"@mantine/hooks": "^7.14.1", "@mantine/hooks": "^7.14.3",
"@mantine/modals": "^7.14.1", "@mantine/modals": "^7.14.3",
"@mantine/tiptap": "^7.14.1", "@mantine/tiptap": "^7.14.3",
"@million/lint": "1.0.12", "@million/lint": "1.0.13",
"@t3-oss/env-nextjs": "^0.11.1", "@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.22.0", "@tabler/icons-react": "^3.23.0",
"@tanstack/react-query": "^5.61.0", "@tanstack/react-query": "^5.62.0",
"@tanstack/react-query-devtools": "^5.61.0", "@tanstack/react-query-devtools": "^5.62.0",
"@tanstack/react-query-next-experimental": "5.61.0", "@tanstack/react-query-next-experimental": "5.62.0",
"@trpc/client": "next", "@trpc/client": "next",
"@trpc/next": "next", "@trpc/next": "next",
"@trpc/react-query": "next", "@trpc/react-query": "next",
@@ -80,7 +80,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": "^22.9.1", "@types/node": "^22.10.1",
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.5",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
@@ -88,7 +88,7 @@
"concurrently": "^9.1.0", "concurrently": "^9.1.0",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"node-loader": "^2.1.0", "node-loader": "^2.1.0",
"prettier": "^3.3.3", "prettier": "^3.4.1",
"typescript": "^5.6.3" "typescript": "^5.7.2"
} }
} }

View File

@@ -0,0 +1,5 @@
import definition from "../boards/(content)/(home)/_definition";
const { layout } = definition;
export default layout;

View File

@@ -0,0 +1,7 @@
import definition from "../boards/(content)/(home)/_definition";
const { generateMetadataAsync: generateMetadata, page } = definition;
export default page;
export { generateMetadata };

View File

@@ -1,14 +0,0 @@
import type { PropsWithChildren } from "react";
import { AppShellMain } from "@mantine/core";
import { MainHeader } from "~/components/layout/header";
import { ClientShell } from "~/components/layout/shell";
export default function MainLayout({ children }: PropsWithChildren) {
return (
<ClientShell hasNavigation={false}>
<MainHeader />
<AppShellMain>{children}</AppShellMain>
</ClientShell>
);
}

View File

@@ -1,9 +0,0 @@
import { Stack, Title } from "@mantine/core";
export default function HomePage() {
return (
<Stack>
<Title>Home</Title>
</Stack>
);
}

View File

@@ -1,6 +1,8 @@
"use client"; "use client";
import { useCallback } from "react"; import type { MouseEvent } from "react";
import { useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Group, Menu } from "@mantine/core"; import { Group, Menu } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks"; import { useHotkeys } from "@mantine/hooks";
import { import {
@@ -16,7 +18,7 @@ import {
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
@@ -26,6 +28,7 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
import { env } from "~/env.mjs";
import { useEditMode, useRequiredBoard } from "./_context"; import { useEditMode, useRequiredBoard } from "./_context";
export const BoardContentHeaderActions = () => { export const BoardContentHeaderActions = () => {
@@ -139,6 +142,7 @@ const EditModeMenu = () => {
}, [board, isEditMode, saveBoard, setEditMode]); }, [board, isEditMode, saveBoard, setEditMode]);
useHotkeys([["mod+e", toggle]]); useHotkeys([["mod+e", toggle]]);
usePreventLeaveWithDirty(isEditMode);
return ( return (
<HeaderButton onClick={toggle} loading={isPending}> <HeaderButton onClick={toggle} loading={isPending}>
@@ -146,3 +150,63 @@ const EditModeMenu = () => {
</HeaderButton> </HeaderButton>
); );
}; };
const usePreventLeaveWithDirty = (isDirty: boolean) => {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const router = useRouter();
useEffect(() => {
const handleClick = (event: MouseEvent<HTMLElement>) => {
const target = (event.target as HTMLElement).closest("a");
if (!target) return;
if (!isDirty) return;
event.preventDefault();
openConfirmModal({
title: t("board.action.edit.confirmLeave.title"),
children: t("board.action.edit.confirmLeave.message"),
onConfirm() {
router.push(target.href);
},
confirmProps: {
children: t("common.action.discard"),
},
});
};
const handlePopState = (event: Event) => {
if (isDirty) {
window.history.pushState(null, document.title, window.location.href);
event.preventDefault();
} else {
window.history.back();
}
};
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!isDirty) return;
if (env.NODE_ENV === "development") return; // Allow to reload in development
event.preventDefault();
event.returnValue = true;
};
document.querySelectorAll("a").forEach((link) => {
link.addEventListener("click", handleClick as never);
});
window.addEventListener("popstate", handlePopState);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
document.querySelectorAll("a").forEach((link) => {
link.removeEventListener("click", handleClick as never);
window.removeEventListener("popstate", handlePopState);
});
window.removeEventListener("beforeunload", handleBeforeUnload);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDirty]);
};

View File

@@ -95,7 +95,7 @@ export default async function BoardSettingsPage({ params, searchParams }: Props)
<BoardAccessSettings board={board} initialPermissions={permissions} /> <BoardAccessSettings board={board} initialPermissions={permissions} />
</AccordionItemFor> </AccordionItemFor>
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding> <AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
<DangerZoneSettingsContent hideVisibility={boardSettings.defaultBoardId === board.id} /> <DangerZoneSettingsContent hideVisibility={boardSettings.homeBoardId === board.id} />
</AccordionItemFor> </AccordionItemFor>
</> </>
)} )}

View File

@@ -1,7 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core"; import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconApps, IconPencil } from "@tabler/icons-react"; import { IconBox, 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";
@@ -12,6 +12,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { AppDeleteButton } from "./_app-delete-button"; import { AppDeleteButton } from "./_app-delete-button";
export default async function AppsPage() { export default async function AppsPage() {
@@ -113,16 +114,14 @@ const AppNoResults = async () => {
const session = await auth(); const session = await auth();
return ( return (
<Card withBorder bg="transparent"> <NoResults
<Stack align="center" gap="sm"> icon={IconBox}
<IconApps size="2rem" /> title={t("app.page.list.noResults.title")}
<Text fw={500} size="lg"> action={{
{t("app.page.list.noResults.title")} label: t("app.page.list.noResults.action"),
</Text> href: "/manage/apps/new",
{session?.user.permissions.includes("app-create") && ( hidden: !session?.user.permissions.includes("app-create"),
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.action")}</Anchor> }}
)} />
</Stack>
</Card>
); );
}; };

View File

@@ -27,7 +27,7 @@ import {
Text, Text,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { IconChevronDown, IconChevronUp, IconPencil } from "@tabler/icons-react"; import { IconChevronDown, IconChevronUp, IconPencil, IconPlugX } 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";
@@ -40,6 +40,7 @@ import { CountBadge, IntegrationAvatar } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion"; import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { DeleteIntegrationActionButton } from "./_integration-buttons"; import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown"; import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
@@ -120,7 +121,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false; const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
if (integrations.length === 0) { if (integrations.length === 0) {
return <div>{t("page.list.empty")}</div>; return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
} }
const grouppedIntegrations = integrations.reduce( const grouppedIntegrations = integrations.reduce(

View File

@@ -13,6 +13,7 @@ import { z } from "@homarr/validation";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { SearchEngineDeleteButton } from "./_search-engine-delete-button"; import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
const searchParamsSchema = z.object({ const searchParamsSchema = z.object({
@@ -142,16 +143,14 @@ const SearchEngineNoResults = async () => {
const session = await auth(); const session = await auth();
return ( return (
<Card withBorder bg="transparent"> <NoResults
<Stack align="center" gap="sm"> icon={IconSearch}
<IconSearch size="2rem" /> title={t("search.engine.page.list.noResults.title")}
<Text fw={500} size="lg"> action={{
{t("search.engine.page.list.noResults.title")} label: t("search.engine.page.list.noResults.action"),
</Text> href: "/manage/search-engines/new",
{session?.user.permissions.includes("search-engine-create") && ( hidden: !session?.user.permissions.includes("search-engine-create"),
<Anchor href="/manage/search-engines/new">{t("search.engine.page.list.noResults.action")}</Anchor> }}
)} />
</Stack>
</Card>
); );
}; };

View File

@@ -19,8 +19,8 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
{(form) => ( {(form) => (
<> <>
<SelectWithCustomItems <SelectWithCustomItems
label={tBoard("defaultBoard.label")} label={tBoard("homeBoard.label")}
description={tBoard("defaultBoard.description")} description={tBoard("homeBoard.description")}
data={selectableBoards.map((board) => ({ data={selectableBoards.map((board) => ({
value: board.id, value: board.id,
label: board.name, label: board.name,
@@ -35,7 +35,7 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
</Text> </Text>
</Group> </Group>
)} )}
{...form.getInputProps("defaultBoardId")} {...form.getInputProps("homeBoardId")}
/> />
</> </>
)} )}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import type { OpenAPIV3 } from "openapi-types"; import type { OpenAPIObject } from "openapi3-ts/oas31";
import SwaggerUI from "swagger-ui-react"; import SwaggerUI from "swagger-ui-react";
// workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045 // workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045
@@ -9,7 +9,7 @@ import "../swagger-ui-overrides.css";
import "../swagger-ui.css"; import "../swagger-ui.css";
interface SwaggerUIClientProps { interface SwaggerUIClientProps {
document: OpenAPIV3.Document; document: OpenAPIObject;
} }
export const SwaggerUIClient = ({ document }: SwaggerUIClientProps) => { export const SwaggerUIClient = ({ document }: SwaggerUIClientProps) => {

View File

@@ -31,7 +31,9 @@ export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
children: t("user.action.delete.confirm", { username: user.name }), children: t("user.action.delete.confirm", { username: user.name }),
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
async onConfirm() { async onConfirm() {
await mutateUserDeletionAsync(user.id); await mutateUserDeletionAsync({
userId: user.id,
});
}, },
}), }),
[user, mutateUserDeletionAsync, openConfirmModal, t], [user, mutateUserDeletionAsync, openConfirmModal, t],

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { Button, Group, Radio, Stack } from "@mantine/core"; import { Button, Group, Radio, Stack } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates";
import dayjs from "dayjs"; import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData"; import localeData from "dayjs/plugin/localeData";
@@ -43,7 +44,7 @@ export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => {
}); });
const form = useZodForm(validation.user.firstDayOfWeek, { const form = useZodForm(validation.user.firstDayOfWeek, {
initialValues: { initialValues: {
firstDayOfWeek: user.firstDayOfWeek, firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
}, },
}); });

View File

@@ -1,20 +1,48 @@
"use client"; "use client";
import { useCallback, useMemo, useState } from "react"; import { startTransition, useCallback, useMemo, useState } from "react";
import { Card, PasswordInput, Stack, Stepper, Text, TextInput, Title } from "@mantine/core"; import {
import { IconUserCheck } from "@tabler/icons-react"; Badge,
Button,
Card,
Group,
PasswordInput,
Stack,
Stepper,
Table,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { useListState } from "@mantine/hooks";
import { IconPlus, IconUserCheck } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { everyoneGroup, groupPermissions } from "@homarr/definitions";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications"; import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput, UserAvatar } from "@homarr/ui"; import { CustomPasswordInput, UserAvatar } from "@homarr/ui";
import { validation, z } from "@homarr/validation"; import { validation, z } from "@homarr/validation";
import { createCustomErrorParams } from "@homarr/validation/form"; import { createCustomErrorParams } from "@homarr/validation/form";
import { GroupSelectModal } from "~/components/access/group-select-modal";
import { StepperNavigationComponent } from "./stepper-navigation"; import { StepperNavigationComponent } from "./stepper-navigation";
export const UserCreateStepperComponent = () => { interface GroupWithPermissions {
id: string;
name: string;
permissions: GroupPermissionKey[];
}
interface UserCreateStepperComponentProps {
initialGroups: GroupWithPermissions[];
}
export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperComponentProps) => {
const t = useScopedI18n("management.page.user.create"); const t = useScopedI18n("management.page.user.create");
const tUserField = useScopedI18n("user.field"); const tUserField = useScopedI18n("user.field");
@@ -73,7 +101,18 @@ export const UserCreateStepperComponent = () => {
}, },
); );
const allForms = useMemo(() => [generalForm, securityForm], [generalForm, securityForm]); const groupsForm = useZodForm(
z.object({
groups: z.array(z.string()),
}),
{
initialValues: {
groups: initialGroups.map((group) => group.id),
},
},
);
const allForms = useMemo(() => [generalForm, securityForm, groupsForm], [generalForm, securityForm, groupsForm]);
const activeForm = allForms[active]; const activeForm = allForms[active];
const isCurrentFormValid = activeForm ? activeForm.isValid : () => true; const isCurrentFormValid = activeForm ? activeForm.isValid : () => true;
@@ -86,10 +125,11 @@ export const UserCreateStepperComponent = () => {
email: generalForm.values.email, email: generalForm.values.email,
password: securityForm.values.password, password: securityForm.values.password,
confirmPassword: securityForm.values.confirmPassword, confirmPassword: securityForm.values.confirmPassword,
groupIds: groupsForm.values.groups,
}); });
} }
nextStep(); nextStep();
}, [active, generalForm, mutateAsync, securityForm, nextStep]); }, [active, generalForm, securityForm, groupsForm, mutateAsync, nextStep]);
const reset = useCallback(() => { const reset = useCallback(() => {
setActive(0); setActive(0);
@@ -144,13 +184,18 @@ export const UserCreateStepperComponent = () => {
</Card> </Card>
</form> </form>
</Stepper.Step> </Stepper.Step>
<Stepper.Step <Stepper.Step label={t("step.groups.label")} allowStepSelect={false} allowStepClick={false}>
label={t("step.permissions.label")} <Card p="xl">
description={t("step.permissions.description")} <GroupsForm
allowStepSelect={false} initialGroups={initialGroups}
allowStepClick={false} addGroup={(groupId) =>
> groupsForm.setValues((value) => ({ groups: value.groups?.concat(groupId) ?? [groupId] }))
3 }
removeGroup={(groupId) => {
groupsForm.setValues((value) => ({ groups: value.groups?.filter((group) => group !== groupId) ?? [] }));
}}
/>
</Card>
</Stepper.Step> </Stepper.Step>
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}> <Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
<Card p="xl"> <Card p="xl">
@@ -183,3 +228,111 @@ export const UserCreateStepperComponent = () => {
</> </>
); );
}; };
interface GroupsFormProps {
addGroup: (groupId: string) => void;
removeGroup: (groupId: string) => void;
initialGroups: GroupWithPermissions[];
}
const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) => {
const t = useI18n();
const [groups, { append, filter }] = useListState<GroupWithPermissions>(initialGroups);
const { openModal } = useModalAction(GroupSelectModal);
const handleAddClick = () => {
openModal({
presentGroupIds: groups.map((group) => group.id),
withPermissions: true,
onSelect({ id, name, permissions }) {
if (!permissions) return;
startTransition(() => {
addGroup(id);
append({ id, name, permissions });
});
},
});
};
const handleGroupRemove = (id: string) => {
filter((group) => group.id !== id);
removeGroup(id);
};
return (
<form>
<Stack>
<Group justify="space-between">
<Stack gap={0}>
<Text fw={500}>{t("management.page.user.create.step.groups.title")}</Text>
<Text size="sm" c="gray.6">
{t("management.page.user.create.step.groups.description", { everyoneGroup })}
</Text>
</Stack>
<Button
variant="subtle"
color="gray"
leftSection={<IconPlus size={16} stroke={1.5} />}
onClick={handleAddClick}
>
{t("common.action.add")}
</Button>
</Group>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("group.field.name")}</Table.Th>
<Table.Th>{t("permission.title")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{groups.map((group) => (
<Table.Tr key={group.id}>
<Table.Td>{group.name}</Table.Td>
<Table.Td w="100%">
<Group gap="xs">
{Object.entries(groupPermissions)
.flatMap(([key, values]) =>
Array.isArray(values)
? values.map((value) => ({ key, value: value as string }))
: [{ key, value: key }],
)
.filter(({ key, value }) =>
group.permissions.some(
(permission) => permission === (key === value ? key : `${key}-${value}`),
),
)
.map(({ key, value }) => (
<PermissionBadge key={`${key}-${value}`} category={key} value={value} />
))}
</Group>
</Table.Td>
<Table.Td>
{group.name !== everyoneGroup && (
<Button variant="subtle" onClick={() => handleGroupRemove(group.id)}>
{t("common.action.remove")}
</Button>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
</form>
);
};
const PermissionBadge = ({ category, value }: { category: string; value: string }) => {
const t = useI18n();
return (
<Tooltip label={t(`group.permission.${category}.item.${value}.description` as never)}>
<Badge color={category === "admin" ? "red" : "blue"} size="sm" variant="dot">
{t(`group.permission.${category}.item.${value}.label` as never)}
</Badge>
</Tooltip>
);
};

View File

@@ -2,6 +2,9 @@ import { notFound } from "next/navigation";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server"; import { isProviderEnabled } from "@homarr/auth/server";
import { db, inArray } from "@homarr/db";
import { groups } from "@homarr/db/schema/sqlite";
import { everyoneGroup } from "@homarr/definitions";
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";
@@ -33,10 +36,22 @@ export default async function CreateUserPage() {
return notFound(); return notFound();
} }
const initialGroups = await db.query.groups.findMany({
where: inArray(groups.name, [everyoneGroup]),
with: {
permissions: true,
},
});
return ( return (
<> <>
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<UserCreateStepperComponent /> <UserCreateStepperComponent
initialGroups={initialGroups.map((group) => ({
...group,
permissions: group.permissions.map(({ permission }) => permission),
}))}
/>
</> </>
); );
} }

View File

@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
import { Button, useMatches } from "@mantine/core"; import { Button, useMatches } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
@@ -46,7 +47,7 @@ export const TransferGroupOwnership = ({ group }: TransferGroupOwnershipProps) =
userId: id, userId: id,
}, },
{ {
onSuccess() { async onSuccess() {
setInnerOwnerId(id); setInnerOwnerId(id);
showSuccessNotification({ showSuccessNotification({
title: tRoot("common.notification.transfer.success"), title: tRoot("common.notification.transfer.success"),
@@ -55,6 +56,7 @@ export const TransferGroupOwnership = ({ group }: TransferGroupOwnershipProps) =
user: name, user: name,
}), }),
}); });
await revalidatePathActionAsync(`/manage/users/groups/${group.id}`);
}, },
onError() { onError() {
showErrorNotification({ showErrorNotification({

View File

@@ -32,6 +32,7 @@ export const AddGroupMember = ({ groupId, presentUserIds }: AddGroupMemberProps)
await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`); await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`);
}, },
presentUserIds, presentUserIds,
excludeExternalProviders: true,
}, },
{ {
title: tMembersAdd("label"), title: tMembersAdd("label"),

View File

@@ -11,13 +11,9 @@ import { useModalAction } from "@homarr/modals";
import { showSuccessNotification } from "@homarr/notifications"; import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { BoardItemAdvancedOptions } from "@homarr/validation"; import type { BoardItemAdvancedOptions } from "@homarr/validation";
import { import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
loadWidgetDynamic,
reduceWidgetOptionsWithDefaultValues,
WidgetEditModal,
widgetImports,
} from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors"; import { WidgetError } from "@homarr/widgets/errors";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { Dimensions } from "./_dimension-modal"; import type { Dimensions } from "./_dimension-modal";
import { PreviewDimensionsModal } from "./_dimension-modal"; import { PreviewDimensionsModal } from "./_dimension-modal";

View File

@@ -1,7 +1,7 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import { userAgent } from "next/server"; import { userAgent } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { createOpenApiFetchHandler } from "trpc-swagger/build/index.mjs"; import { createOpenApiFetchHandler } from "trpc-to-openapi";
import { appRouter, createTRPCContext } from "@homarr/api"; import { appRouter, createTRPCContext } from "@homarr/api";
import { hashPasswordAsync } from "@homarr/auth"; import { hashPasswordAsync } from "@homarr/auth";
@@ -17,6 +17,11 @@ const handlerAsync = async (req: NextRequest) => {
const { ua } = userAgent(req); const { ua } = userAgent(req);
const session: Session | null = await getSessionOrDefaultFromHeadersAsync(apiKeyHeaderValue, ipAddress, ua); const session: Session | null = await getSessionOrDefaultFromHeadersAsync(apiKeyHeaderValue, ipAddress, ua);
// Fallback to JSON if no content type is set
if (!req.headers.has("Content-Type")) {
req.headers.set("Content-Type", "application/json");
}
return createOpenApiFetchHandler({ return createOpenApiFetchHandler({
req, req,
endpoint: "/", endpoint: "/",
@@ -82,4 +87,10 @@ const getSessionOrDefaultFromHeadersAsync = async (
return await createSessionAsync(db, apiKeyFromDb.user); return await createSessionAsync(db, apiKeyFromDb.user);
}; };
export { handlerAsync as GET, handlerAsync as POST }; export {
handlerAsync as GET,
handlerAsync as POST,
handlerAsync as PUT,
handlerAsync as DELETE,
handlerAsync as PATCH,
};

View File

@@ -2,13 +2,15 @@ import { useState } from "react";
import { Button, Group, Loader, Select, Stack } from "@mantine/core"; import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useForm } from "@homarr/form"; import { useForm } 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";
interface InnerProps { interface InnerProps {
withPermissions?: boolean;
presentGroupIds: string[]; presentGroupIds: string[];
onSelect: (props: { id: string; name: string }) => void | Promise<void>; onSelect: (props: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>;
confirmLabel?: string; confirmLabel?: string;
} }
@@ -18,7 +20,9 @@ interface GroupSelectFormType {
export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => { export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n(); const t = useI18n();
const { data: groups, isPending } = clientApi.group.selectable.useQuery(); const { data: groups, isPending } = clientApi.group.selectable.useQuery({
withPermissions: innerProps.withPermissions,
});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const form = useForm<GroupSelectFormType>(); const form = useForm<GroupSelectFormType>();
const handleSubmitAsync = async (values: GroupSelectFormType) => { const handleSubmitAsync = async (values: GroupSelectFormType) => {
@@ -28,6 +32,7 @@ export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }
await innerProps.onSelect({ await innerProps.onSelect({
id: currentGroup.id, id: currentGroup.id,
name: currentGroup.name, name: currentGroup.name,
permissions: "permissions" in currentGroup ? (currentGroup.permissions as GroupPermissionKey[]) : undefined,
}); });
setLoading(false); setLoading(false);

View File

@@ -12,6 +12,7 @@ import { UserAvatar } from "@homarr/ui";
interface InnerProps { interface InnerProps {
presentUserIds: string[]; presentUserIds: string[];
excludeExternalProviders?: boolean;
onSelect: (props: { id: string; name: string; image: string }) => void | Promise<void>; onSelect: (props: { id: string; name: string; image: string }) => void | Promise<void>;
confirmLabel?: string; confirmLabel?: string;
} }
@@ -22,7 +23,9 @@ interface UserSelectFormType {
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => { export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n(); const t = useI18n();
const { data: users, isPending } = clientApi.user.selectable.useQuery(); const { data: users, isPending } = clientApi.user.selectable.useQuery({
excludeExternalProviders: innerProps.excludeExternalProviders,
});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const form = useForm<UserSelectFormType>(); const form = useForm<UserSelectFormType>();
const handleSubmitAsync = async (values: UserSelectFormType) => { const handleSubmitAsync = async (values: UserSelectFormType) => {

View File

@@ -5,7 +5,8 @@ import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } f
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { WidgetEditModal, widgetImports } from "@homarr/widgets"; import { widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { Item } from "~/app/[locale]/boards/_types"; import type { Item } from "~/app/[locale]/boards/_types";
import { useEditMode } from "~/app/[locale]/boards/(content)/_context"; import { useEditMode } from "~/app/[locale]/boards/(content)/_context";

View File

@@ -0,0 +1,54 @@
import type { RouterOutputs } from "@homarr/api";
import type { CategorySection } from "~/app/[locale]/boards/_types";
export interface MoveCategoryInput {
id: string;
direction: "up" | "down";
}
export const moveCategoryCallback =
(input: MoveCategoryInput) =>
(previous: RouterOutputs["board"]["getHomeBoard"]): RouterOutputs["board"]["getHomeBoard"] => {
const currentCategory = previous.sections.find(
(section): section is CategorySection => section.kind === "category" && section.id === input.id,
);
if (!currentCategory) {
return previous;
}
if (currentCategory.yOffset === 1 && input.direction === "up") {
return previous;
}
if (currentCategory.yOffset === previous.sections.length - 2 && input.direction === "down") {
return previous;
}
return {
...previous,
sections: previous.sections.map((section) => {
if (section.kind !== "category" && section.kind !== "empty") {
return section;
}
const offset = input.direction === "up" ? -2 : 2;
// Move category and empty section
if (section.yOffset === currentCategory.yOffset || section.yOffset === currentCategory.yOffset + 1) {
return {
...section,
yOffset: section.yOffset + offset,
};
}
if (
section.yOffset === currentCategory.yOffset + offset ||
section.yOffset === currentCategory.yOffset + offset + 1
) {
return {
...section,
yOffset: section.yOffset - offset,
};
}
return section;
}),
};
};

View File

@@ -0,0 +1,76 @@
import { describe, expect, test } from "vitest";
import type { Section } from "~/app/[locale]/boards/_types";
import { moveCategoryCallback } from "../move-category";
describe("Move Category", () => {
test.each([
[3, [0, 3, 4, 1, 2, 5, 6]],
[5, [0, 1, 2, 5, 6, 3, 4]],
])("should move category up", (moveId, expectedOrder) => {
const sections = createSections(3);
const input = {
id: moveId.toString(),
direction: "up" as const,
};
const result = moveCategoryCallback(input)({ sections } as never);
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual(expectedOrder);
});
test.each([
[1, [0, 3, 4, 1, 2, 5, 6]],
[3, [0, 1, 2, 5, 6, 3, 4]],
])("should move category down", (moveId, expectedOrder) => {
const sections = createSections(3);
const input = {
id: moveId.toString(),
direction: "down" as const,
};
const result = moveCategoryCallback(input)({ sections } as never);
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual(expectedOrder);
});
test("should not move category up if it is at the top", () => {
const sections = createSections(3);
const input = {
id: "1",
direction: "up" as const,
};
const result = moveCategoryCallback(input)({ sections } as never);
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
test("should not move category down if it is at the bottom", () => {
const sections = createSections(3);
const input = {
id: "5",
direction: "down" as const,
};
const result = moveCategoryCallback(input)({ sections } as never);
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
});
const createSections = (categoryCount: number) => {
return Array.from({ length: categoryCount * 2 + 1 }, (_, index) => ({
id: index.toString(),
kind: index % 2 === 1 ? ("category" as const) : ("empty" as const),
name: `Category ${index}`,
yOffset: index,
xOffset: 0,
items: [],
})) satisfies Section[];
};
const sortSections = (sections: Section[]) => {
return sections.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
};

View File

@@ -4,6 +4,8 @@ import { createId } from "@homarr/db/client";
import type { CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types"; import type { CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client"; import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
import type { MoveCategoryInput } from "./actions/move-category";
import { moveCategoryCallback } from "./actions/move-category";
interface AddCategory { interface AddCategory {
name: string; name: string;
@@ -15,11 +17,6 @@ interface RenameCategory {
name: string; name: string;
} }
interface MoveCategory {
id: string;
direction: "up" | "down";
}
interface RemoveCategory { interface RemoveCategory {
id: string; id: string;
} }
@@ -128,52 +125,8 @@ export const useCategoryActions = () => {
); );
const moveCategory = useCallback( const moveCategory = useCallback(
({ id, direction }: MoveCategory) => { (input: MoveCategoryInput) => {
updateBoard((previous) => { updateBoard(moveCategoryCallback(input));
const currentCategory = previous.sections.find(
(section): section is CategorySection => section.kind === "category" && section.id === id,
);
if (!currentCategory) return previous;
if (currentCategory.yOffset === 1 && direction === "up") return previous;
if (currentCategory.yOffset === previous.sections.length - 2 && direction === "down") return previous;
return {
...previous,
sections: previous.sections.map((section) => {
if (section.kind !== "category" && section.kind !== "empty") return section;
const offset = direction === "up" ? -2 : 2;
// Move category and empty section
if (section.yOffset === currentCategory.yOffset || section.yOffset - 1 === currentCategory.yOffset) {
return {
...section,
yOffset: section.yOffset + offset,
};
}
if (
direction === "up" &&
(section.yOffset === currentCategory.yOffset - 2 || section.yOffset === currentCategory.yOffset - 1)
) {
return {
...section,
position: section.yOffset + 2,
};
}
if (
direction === "down" &&
(section.yOffset === currentCategory.yOffset + 2 || section.yOffset === currentCategory.yOffset + 3)
) {
return {
...section,
position: section.yOffset - 2,
};
}
return section;
}),
};
});
}, },
[updateBoard], [updateBoard],
); );

View File

@@ -15,6 +15,7 @@ import {
UnstyledButton, UnstyledButton,
useCombobox, useCombobox,
} from "@mantine/core"; } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
@@ -34,16 +35,19 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
const tCommon = useScopedI18n("common"); const tCommon = useScopedI18n("common");
const [data] = clientApi.icon.findIcons.useSuspenseQuery({ const [debouncedSearch] = useDebouncedValue(search, 100);
searchText: search,
// We use not useSuspenseQuery as it would cause an above Suspense boundary to trigger and so searching for something is bad UX.
const { data } = clientApi.icon.findIcons.useQuery({
searchText: debouncedSearch,
}); });
const combobox = useCombobox({ const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(), onDropdownClose: () => combobox.resetSelectedOption(),
}); });
const totalOptions = data.icons.reduce((acc, group) => acc + group.icons.length, 0); const totalOptions = data?.icons.reduce((acc, group) => acc + group.icons.length, 0) ?? 0;
const groups = data.icons.map((group) => { const groups = data?.icons.map((group) => {
const options = group.icons.map((item) => ( const options = group.icons.map((item) => (
<UnstyledButton <UnstyledButton
onClick={() => { onClick={() => {
@@ -92,8 +96,12 @@ 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={
leftSection={previewUrl ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null} previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={previewUrl} alt="" style={{ width: 20, height: 20 }} />
) : null
}
value={search} value={search}
onChange={(event) => { onChange={(event) => {
combobox.openDropdown(); combobox.openDropdown();
@@ -118,7 +126,7 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
withAsterisk withAsterisk
error={error} error={error}
label={tCommon("iconPicker.label")} label={tCommon("iconPicker.label")}
placeholder={tCommon("iconPicker.header", { countIcons: data.countIcons })} placeholder={tCommon("iconPicker.header", { countIcons: data?.countIcons ?? 0 })}
/> />
</Combobox.Target> </Combobox.Target>

View File

@@ -0,0 +1,26 @@
import { Anchor, Card, Stack, Text } from "@mantine/core";
import type { TablerIcon } from "@tabler/icons-react";
interface NoResultsProps {
icon: TablerIcon;
title: string;
action?: {
label: string;
href: string;
hidden?: boolean;
};
}
export const NoResults = ({ icon: Icon, title, action }: NoResultsProps) => {
return (
<Card withBorder bg="transparent">
<Stack align="center" gap="sm">
<Icon size="2rem" />
<Text fw={500} size="lg">
{title}
</Text>
{!action?.hidden && <Anchor href={action?.href}>{action?.label}</Anchor>}
</Stack>
</Card>
);
};

View File

@@ -61,7 +61,8 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
}, [logoutUrl, openModal, router]); }, [logoutUrl, openModal, router]);
return ( return (
<Menu width={300} withArrow withinPortal> // We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withArrow withinPortal keepMounted>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item onClick={toggleColorScheme} leftSection={<ColorSchemeIcon size="1rem" />}> <Menu.Item onClick={toggleColorScheme} leftSection={<ColorSchemeIcon size="1rem" />}>
{colorSchemeText} {colorSchemeText}

View File

@@ -3,6 +3,7 @@ import { createTRPCClient, httpLink } from "@trpc/client";
import SuperJSON from "superjson"; import SuperJSON from "superjson";
import type { AppRouter } from "@homarr/api"; import type { AppRouter } from "@homarr/api";
import { createHeadersCallbackForSource } from "@homarr/api/client";
import { createI18nMiddleware } from "@homarr/translation/middleware"; import { createI18nMiddleware } from "@homarr/translation/middleware";
export async function middleware(request: NextRequest) { export async function middleware(request: NextRequest) {
@@ -25,11 +26,7 @@ export const serverFetchApi = createTRPCClient<AppRouter>({
httpLink({ httpLink({
url: `http://${process.env.HOSTNAME ?? "localhost"}:3000/api/trpc`, url: `http://${process.env.HOSTNAME ?? "localhost"}:3000/api/trpc`,
transformer: SuperJSON, transformer: SuperJSON,
headers() { headers: createHeadersCallbackForSource("server-fetch"),
const headers = new Headers();
headers.set("x-trpc-source", "server-fetch");
return headers;
},
}), }),
], ],
}); });

View File

@@ -10,7 +10,7 @@
"main": "./src/main.ts", "main": "./src/main.ts",
"types": "./src/main.ts", "types": "./src/main.ts",
"scripts": { "scripts": {
"build": "esbuild src/main.ts --bundle --platform=node --outfile=tasks.cjs", "build": "esbuild src/main.ts --bundle --platform=node --loader:.scss=text --external:@opentelemetry/api --outfile=tasks.cjs",
"clean": "rm -rf .turbo node_modules", "clean": "rm -rf .turbo node_modules",
"dev": "pnpm with-env tsx ./src/main.ts", "dev": "pnpm with-env tsx ./src/main.ts",
"format": "prettier --check . --ignore-path ../../.gitignore", "format": "prettier --check . --ignore-path ../../.gitignore",
@@ -38,17 +38,17 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"superjson": "2.2.1", "superjson": "2.2.1",
"undici": "6.21.0" "undici": "7.0.0"
}, },
"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": "^22.9.1", "@types/node": "^22.10.1",
"dotenv-cli": "^7.4.4", "dotenv-cli": "^7.4.4",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"prettier": "^3.3.3", "prettier": "^3.4.1",
"tsx": "4.19.2", "tsx": "4.19.2",
"typescript": "^5.6.3" "typescript": "^5.7.2"
} }
} }

View File

@@ -8,7 +8,7 @@ import { LoggingAgent } from "~/undici-log-agent-override";
vi.mock("undici", () => { vi.mock("undici", () => {
return { return {
Agent: class Agent { Agent: class Agent {
dispatch(_options: Dispatcher.DispatchOptions, _handler: Dispatcher.DispatchHandlers): boolean { dispatch(_options: Dispatcher.DispatchOptions, _handler: Dispatcher.DispatchHandler): boolean {
return true; return true;
} }
}, },

View File

@@ -8,7 +8,7 @@ export class LoggingAgent extends Agent {
super(...props); super(...props);
} }
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers): boolean { dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
const url = new URL(`${options.origin as string}${options.path}`); const url = new URL(`${options.origin as string}${options.path}`);
// The below code should prevent sensitive data from being logged as // The below code should prevent sensitive data from being logged as

View File

@@ -7,7 +7,7 @@
"main": "./src/main.ts", "main": "./src/main.ts",
"types": "./src/main.ts", "types": "./src/main.ts",
"scripts": { "scripts": {
"build": "esbuild src/main.ts --bundle --platform=node --outfile=wssServer.cjs --external:bcrypt --external:cpu-features --loader:.html=text --loader:.node=text", "build": "esbuild src/main.ts --bundle --platform=node --outfile=wssServer.cjs --external:bcrypt --external:@opentelemetry/api --external:cpu-features --loader:.html=text --loader:.scss=text --loader:.node=text",
"clean": "rm -rf .turbo node_modules", "clean": "rm -rf .turbo node_modules",
"dev": "pnpm with-env tsx ./src/main.ts", "dev": "pnpm with-env tsx ./src/main.ts",
"format": "prettier --check . --ignore-path ../../.gitignore", "format": "prettier --check . --ignore-path ../../.gitignore",
@@ -35,7 +35,7 @@
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.5.13", "@types/ws": "^8.5.13",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"prettier": "^3.3.3", "prettier": "^3.4.1",
"typescript": "^5.6.3" "typescript": "^5.7.2"
} }
} }

View File

@@ -19,6 +19,7 @@
"lint": "turbo lint --continue -- --cache --cache-location node_modules/.cache/.eslintcache", "lint": "turbo lint --continue -- --cache --cache-location node_modules/.cache/.eslintcache",
"lint:fix": "turbo lint --continue -- --fix --cache --cache-location node_modules/.cache/.eslintcache", "lint:fix": "turbo lint --continue -- --fix --cache --cache-location node_modules/.cache/.eslintcache",
"lint:ws": "pnpm dlx sherif@latest", "lint:ws": "pnpm dlx sherif@latest",
"package:new": "turbo gen init",
"test": "cross-env NODE_ENV=development vitest run --exclude e2e --coverage.enabled ", "test": "cross-env NODE_ENV=development vitest run --exclude e2e --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development vitest e2e", "test:e2e": "cross-env NODE_ENV=development vitest e2e",
"test:ui": "cross-env NODE_ENV=development vitest --exclude e2e --ui --coverage.enabled", "test:ui": "cross-env NODE_ENV=development vitest --exclude e2e --ui --coverage.enabled",
@@ -28,26 +29,21 @@
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"devDependencies": { "devDependencies": {
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@turbo/gen": "^2.3.1", "@turbo/gen": "^2.3.3",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^2.1.5", "@vitest/coverage-v8": "^2.1.6",
"@vitest/ui": "^2.1.5", "@vitest/ui": "^2.1.6",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"prettier": "^3.3.3", "prettier": "^3.4.1",
"testcontainers": "^10.15.0", "testcontainers": "^10.15.0",
"turbo": "^2.3.1", "turbo": "^2.3.3",
"typescript": "^5.6.3", "typescript": "^5.7.2",
"vite-tsconfig-paths": "^5.1.3", "vite-tsconfig-paths": "^5.1.3",
"vitest": "^2.1.5" "vitest": "^2.1.6"
}, },
"packageManager": "pnpm@9.14.2", "packageManager": "pnpm@9.14.4",
"engines": { "engines": {
"node": ">=22.11.0" "node": ">=22.11.0"
},
"pnpm": {
"patchedDependencies": {
"trpc-swagger@1.2.6": "patches/trpc-swagger@1.2.6.patch"
}
} }
} }

View File

@@ -33,6 +33,6 @@
"@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.15.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.7.2"
} }
} }

View File

@@ -33,6 +33,7 @@
"@homarr/old-schema": "workspace:^0.1.0", "@homarr/old-schema": "workspace:^0.1.0",
"@homarr/ping": "workspace:^0.1.0", "@homarr/ping": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0",
"@homarr/request-handler": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@trpc/client": "next", "@trpc/client": "next",
@@ -42,7 +43,7 @@
"next": "^14.2.18", "next": "^14.2.18",
"react": "^18.3.1", "react": "^18.3.1",
"superjson": "2.2.1", "superjson": "2.2.1",
"trpc-swagger": "^1.2.6" "trpc-to-openapi": "^2.0.2"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
@@ -50,7 +51,7 @@
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.32", "@types/dockerode": "^3.3.32",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"prettier": "^3.3.3", "prettier": "^3.4.1",
"typescript": "^5.6.3" "typescript": "^5.7.2"
} }
} }

View File

@@ -127,7 +127,7 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
if (offset !== 0) { if (offset !== 0) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.join(",")}])`, message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
}); });
} }
@@ -150,83 +150,6 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
}); });
}; };
/**
* Creates a middleware that provides the integrations and their items in the context that are of the specified kinds and have the specified item
* It also ensures that the user has permission to perform the specified action on the integrations
* @param action query for showing data or interact for mutating data
* @param kinds kinds of integrations that are supported
* @returns middleware that can be used with trpc
* @example publicProcedure.unstable_concat(createManyIntegrationOfOneItemMiddleware("query", "piHole", "homeAssistant")).query(...)
* @throws TRPCError NOT_FOUND if the integration for the item was not found
* @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
*/
export const createManyIntegrationOfOneItemMiddleware = <TKind extends IntegrationKind>(
action: IntegrationAction,
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
) => {
return publicProcedure
.input(z.object({ integrationIds: z.array(z.string()).min(1), itemId: z.string() }))
.use(async ({ ctx, input, next }) => {
const dbIntegrations = await ctx.db.query.integrations.findMany({
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
secrets: true,
items: {
with: {
item: {
with: {
section: {
columns: {
boardId: true,
},
},
},
},
},
},
userPermissions: true,
groupPermissions: true,
},
});
const offset = input.integrationIds.length - dbIntegrations.length;
if (offset !== 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}`,
});
}
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
const dbIntegrationWithItem = dbIntegrations.filter((integration) =>
integration.items.some((item) => item.itemId === input.itemId),
);
if (dbIntegrationWithItem.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integrations for item were not found",
});
}
return next({
ctx: {
integrations: dbIntegrationWithItem.map(
({ secrets, kind, groupPermissions: _ignore1, userPermissions: _ignore2, ...rest }) => ({
...rest,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
}),
),
},
});
});
};
/** /**
* Throws a TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations * Throws a TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
* @param action action to perform * @param action action to perform

View File

@@ -1,4 +1,4 @@
import { generateOpenApiDocument } from "trpc-swagger"; import { generateOpenApiDocument } from "trpc-to-openapi";
import { appRouter } from "./root"; import { appRouter } from "./root";

View File

@@ -2,25 +2,17 @@ import { TRPCError } from "@trpc/server";
import { asc, createId, eq, inArray, like } from "@homarr/db"; import { asc, createId, eq, inArray, like } from "@homarr/db";
import { apps } from "@homarr/db/schema/sqlite"; import { apps } from "@homarr/db/schema/sqlite";
import { selectAppSchema } from "@homarr/db/validationSchemas";
import { validation, z } from "@homarr/validation"; import { validation, z } from "@homarr/validation";
import { convertIntersectionToZodObject } from "../schema-merger";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { canUserSeeAppAsync } from "./app/app-access-control"; import { canUserSeeAppAsync } from "./app/app-access-control";
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
all: protectedProcedure all: protectedProcedure
.input(z.void()) .input(z.void())
.output( .output(z.array(selectAppSchema))
z.array(
z.object({
name: z.string(),
id: z.string(),
description: z.string().nullable(),
iconUrl: z.string(),
href: z.string().nullable(),
}),
),
)
.meta({ openapi: { method: "GET", path: "/api/apps", tags: ["apps"], protect: true } }) .meta({ openapi: { method: "GET", path: "/api/apps", tags: ["apps"], protect: true } })
.query(({ ctx }) => { .query(({ ctx }) => {
return ctx.db.query.apps.findMany({ return ctx.db.query.apps.findMany({
@@ -29,17 +21,7 @@ export const appRouter = createTRPCRouter({
}), }),
search: protectedProcedure search: protectedProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) })) .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.output( .output(z.array(selectAppSchema))
z.array(
z.object({
name: z.string(),
id: z.string(),
description: z.string().nullable(),
iconUrl: z.string(),
href: z.string().nullable(),
}),
),
)
.meta({ openapi: { method: "GET", path: "/api/apps/search", tags: ["apps"], protect: true } }) .meta({ openapi: { method: "GET", path: "/api/apps/search", tags: ["apps"], protect: true } })
.query(({ ctx, input }) => { .query(({ ctx, input }) => {
return ctx.db.query.apps.findMany({ return ctx.db.query.apps.findMany({
@@ -50,17 +32,7 @@ export const appRouter = createTRPCRouter({
}), }),
selectable: protectedProcedure selectable: protectedProcedure
.input(z.void()) .input(z.void())
.output( .output(z.array(selectAppSchema.pick({ id: true, name: true, iconUrl: true, href: true, description: true })))
z.array(
z.object({
name: z.string(),
id: z.string(),
iconUrl: z.string(),
description: z.string().nullable(),
href: z.string().nullable(),
}),
),
)
.meta({ .meta({
openapi: { openapi: {
method: "GET", method: "GET",
@@ -83,15 +55,7 @@ export const appRouter = createTRPCRouter({
}), }),
byId: publicProcedure byId: publicProcedure
.input(validation.common.byId) .input(validation.common.byId)
.output( .output(selectAppSchema)
z.object({
name: z.string(),
id: z.string(),
description: z.string().nullable(),
iconUrl: z.string(),
href: z.string().nullable(),
}),
)
.meta({ openapi: { method: "GET", path: "/api/apps/{id}", tags: ["apps"], protect: true } }) .meta({ openapi: { method: "GET", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({ const app = await ctx.db.query.apps.findFirst({
@@ -136,7 +100,9 @@ export const appRouter = createTRPCRouter({
}), }),
update: permissionRequiredProcedure update: permissionRequiredProcedure
.requiresPermission("app-modify-all") .requiresPermission("app-modify-all")
.input(validation.app.edit) .input(convertIntersectionToZodObject(validation.app.edit))
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({ const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id), where: eq(apps.id, input.id),

View File

@@ -229,10 +229,10 @@ export const boardRouter = createTRPCRouter({
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full"); await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board"); const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
if (input.visibility !== "public" && boardSettings.defaultBoardId === input.id) { if (input.visibility !== "public" && boardSettings.homeBoardId === input.id) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Cannot make default board private", message: "Cannot make home board private",
}); });
} }
@@ -259,13 +259,13 @@ export const boardRouter = createTRPCRouter({
}) })
: null; : null;
// 1. user home board, 2. default board, 3. not found // 1. user home board, 2. home board, 3. not found
let boardWhere: SQL<unknown> | null = null; let boardWhere: SQL<unknown> | null = null;
if (user?.homeBoardId) { if (user?.homeBoardId) {
boardWhere = eq(boards.id, user.homeBoardId); boardWhere = eq(boards.id, user.homeBoardId);
} else { } else {
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board"); const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
boardWhere = boardSettings.defaultBoardId ? eq(boards.id, boardSettings.defaultBoardId) : null; boardWhere = boardSettings.homeBoardId ? eq(boards.id, boardSettings.homeBoardId) : null;
} }
if (!boardWhere) { if (!boardWhere) {

View File

@@ -100,14 +100,33 @@ export const groupRouter = createTRPCRouter({
}; };
}), }),
// Is protected because also used in board access / integration access forms // Is protected because also used in board access / integration access forms
selectable: protectedProcedure.query(async ({ ctx }) => { selectable: protectedProcedure
return await ctx.db.query.groups.findMany({ .input(z.object({ withPermissions: z.boolean().default(false) }).optional())
columns: { .query(async ({ ctx, input }) => {
id: true, const withPermissions = input?.withPermissions && ctx.session.user.permissions.includes("admin");
name: true,
}, if (!withPermissions) {
}); return await ctx.db.query.groups.findMany({
}), columns: {
id: true,
name: true,
},
});
}
const groups = await ctx.db.query.groups.findMany({
columns: {
id: true,
name: true,
},
with: { permissions: { columns: { permission: true } } },
});
return groups.map((group) => ({
...group,
permissions: group.permissions.map((permission) => permission.permission),
}));
}),
search: permissionRequiredProcedure search: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
.input( .input(

View File

@@ -3,36 +3,51 @@ import { TRPCError } from "@trpc/server";
import { asc, createId, eq } from "@homarr/db"; import { asc, createId, eq } from "@homarr/db";
import { invites } from "@homarr/db/schema/sqlite"; import { invites } from "@homarr/db/schema/sqlite";
import { selectInviteSchema } from "@homarr/db/validationSchemas";
import { z } from "@homarr/validation"; import { z } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { throwIfCredentialsDisabled } from "./invite/checks"; import { throwIfCredentialsDisabled } from "./invite/checks";
export const inviteRouter = createTRPCRouter({ export const inviteRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => { getAll: protectedProcedure
throwIfCredentialsDisabled(); .output(
const dbInvites = await ctx.db.query.invites.findMany({ z.array(
orderBy: asc(invites.expirationDate), selectInviteSchema
columns: { .pick({
token: false,
},
with: {
creator: {
columns: {
id: true, id: true,
name: true, expirationDate: true,
})
.extend({ creator: z.object({ name: z.string().nullable(), id: z.string() }) }),
),
)
.input(z.undefined())
.meta({ openapi: { method: "GET", path: "/api/invites", tags: ["invites"], protect: true } })
.query(async ({ ctx }) => {
throwIfCredentialsDisabled();
return await ctx.db.query.invites.findMany({
orderBy: asc(invites.expirationDate),
columns: {
token: false,
},
with: {
creator: {
columns: {
id: true,
name: true,
},
}, },
}, },
}, });
}); }),
return dbInvites;
}),
createInvite: protectedProcedure createInvite: protectedProcedure
.input( .input(
z.object({ z.object({
expirationDate: z.date(), expirationDate: z.date(),
}), }),
) )
.output(z.object({ id: z.string(), token: z.string() }))
.meta({ openapi: { method: "POST", path: "/api/invites", tags: ["invites"], protect: true } })
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled(); throwIfCredentialsDisabled();
const id = createId(); const id = createId();
@@ -56,6 +71,8 @@ export const inviteRouter = createTRPCRouter({
id: z.string(), id: z.string(),
}), }),
) )
.output(z.undefined())
.meta({ openapi: { method: "DELETE", path: "/api/invites/{id}", tags: ["invites"], protect: true } })
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled(); throwIfCredentialsDisabled();
const dbInvite = await ctx.db.query.invites.findFirst({ const dbInvite = await ctx.db.query.invites.findFirst({

View File

@@ -507,7 +507,7 @@ describe("getHomeBoard should return home board", () => {
const fullBoardProps = await createFullBoardAsync(db, "home"); const fullBoardProps = await createFullBoardAsync(db, "home");
await db.insert(serverSettings).values({ await db.insert(serverSettings).values({
settingKey: "board", settingKey: "board",
value: SuperJSON.stringify({ defaultBoardId: fullBoardProps.boardId }), value: SuperJSON.stringify({ homeBoardId: fullBoardProps.boardId }),
}); });
// Act // Act

View File

@@ -316,7 +316,7 @@ describe("delete should delete user", () => {
await db.insert(schema.users).values(initialUsers); await db.insert(schema.users).values(initialUsers);
await caller.delete(defaultOwnerId); await caller.delete({ userId: defaultOwnerId });
const usersInDb = await db.select().from(schema.users); const usersInDb = await db.select().from(schema.users);
expect(usersInDb).toHaveLength(2); expect(usersInDb).toHaveLength(2);

View File

@@ -4,10 +4,12 @@ import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
import type { Database } from "@homarr/db"; import type { Database } from "@homarr/db";
import { and, createId, eq, like, schema } from "@homarr/db"; import { and, createId, eq, like, schema } from "@homarr/db";
import { groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema/sqlite"; import { groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema/sqlite";
import { selectUserSchema } from "@homarr/db/validationSchemas";
import type { SupportedAuthProvider } from "@homarr/definitions"; import type { SupportedAuthProvider } from "@homarr/definitions";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import { validation, z } from "@homarr/validation"; import { validation, z } from "@homarr/validation";
import { convertIntersectionToZodObject } from "../schema-merger";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfCredentialsDisabled } from "./invite/checks"; import { throwIfCredentialsDisabled } from "./invite/checks";
@@ -44,31 +46,34 @@ export const userRouter = createTRPCRouter({
userId, userId,
}); });
}), }),
register: publicProcedure.input(validation.user.registrationApi).mutation(async ({ ctx, input }) => { register: publicProcedure
throwIfCredentialsDisabled(); .input(validation.user.registrationApi)
const inviteWhere = and(eq(invites.id, input.inviteId), eq(invites.token, input.token)); .output(z.void())
const dbInvite = await ctx.db.query.invites.findFirst({ .mutation(async ({ ctx, input }) => {
columns: { throwIfCredentialsDisabled();
id: true, const inviteWhere = and(eq(invites.id, input.inviteId), eq(invites.token, input.token));
expirationDate: true, const dbInvite = await ctx.db.query.invites.findFirst({
}, columns: {
where: inviteWhere, id: true,
}); expirationDate: true,
},
if (!dbInvite || dbInvite.expirationDate < new Date()) { where: inviteWhere,
throw new TRPCError({
code: "FORBIDDEN",
message: "Invalid invite",
}); });
}
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username); if (!dbInvite || dbInvite.expirationDate < new Date()) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invalid invite",
});
}
await createUserAsync(ctx.db, input); await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
// Delete invite as it's used await createUserAsync(ctx.db, input);
await ctx.db.delete(invites).where(inviteWhere);
}), // Delete invite as it's used
await ctx.db.delete(invites).where(inviteWhere);
}),
create: permissionRequiredProcedure create: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
.meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"], protect: true } }) .meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"], protect: true } })
@@ -78,9 +83,15 @@ export const userRouter = createTRPCRouter({
throwIfCredentialsDisabled(); throwIfCredentialsDisabled();
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username); await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
await createUserAsync(ctx.db, input); const userId = await createUserAsync(ctx.db, input);
if (input.groupIds.length >= 1) {
await ctx.db.insert(groupMembers).values(input.groupIds.map((groupId) => ({ groupId, userId })));
}
}), }),
setProfileImage: protectedProcedure setProfileImage: protectedProcedure
.output(z.void())
.meta({ openapi: { method: "PUT", path: "/api/users/profileImage", tags: ["users"], protect: true } })
.input( .input(
z.object({ z.object({
userId: z.string(), userId: z.string(),
@@ -134,17 +145,7 @@ export const userRouter = createTRPCRouter({
getAll: permissionRequiredProcedure getAll: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
.input(z.void()) .input(z.void())
.output( .output(z.array(selectUserSchema.pick({ id: true, name: true, email: true, emailVerified: true, image: true })))
z.array(
z.object({
id: z.string(),
name: z.string().nullable(),
email: z.string().nullable(),
emailVerified: z.date().nullable(),
image: z.string().nullable(),
}),
),
)
.meta({ openapi: { method: "GET", path: "/api/users", tags: ["users"], protect: true } }) .meta({ openapi: { method: "GET", path: "/api/users", tags: ["users"], protect: true } })
.query(({ ctx }) => { .query(({ ctx }) => {
return ctx.db.query.users.findMany({ return ctx.db.query.users.findMany({
@@ -158,15 +159,20 @@ export const userRouter = createTRPCRouter({
}); });
}), }),
// Is protected because also used in board access / integration access forms // Is protected because also used in board access / integration access forms
selectable: protectedProcedure.query(({ ctx }) => { selectable: protectedProcedure
return ctx.db.query.users.findMany({ .input(z.object({ excludeExternalProviders: z.boolean().default(false) }).optional())
columns: { .output(z.array(selectUserSchema.pick({ id: true, name: true, image: true })))
id: true, .meta({ openapi: { method: "GET", path: "/api/users/selectable", tags: ["users"], protect: true } })
name: true, .query(({ ctx, input }) => {
image: true, return ctx.db.query.users.findMany({
}, columns: {
}); id: true,
}), name: true,
image: true,
},
where: input?.excludeExternalProviders ? eq(users.provider, "credentials") : undefined,
});
}),
search: permissionRequiredProcedure search: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
.input( .input(
@@ -175,6 +181,8 @@ export const userRouter = createTRPCRouter({
limit: z.number().min(1).max(100).default(10), limit: z.number().min(1).max(100).default(10),
}), }),
) )
.output(z.array(selectUserSchema.pick({ id: true, name: true, image: true })))
.meta({ openapi: { method: "POST", path: "/api/users/search", tags: ["users"], protect: true } })
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const dbUsers = await ctx.db.query.users.findMany({ const dbUsers = await ctx.db.query.users.findMany({
columns: { columns: {
@@ -191,16 +199,10 @@ export const userRouter = createTRPCRouter({
image: user.image, image: user.image,
})); }));
}), }),
getById: protectedProcedure.input(z.object({ userId: z.string() })).query(async ({ input, ctx }) => { getById: protectedProcedure
// Only admins can view other users details .input(z.object({ userId: z.string() }))
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) { .output(
throw new TRPCError({ selectUserSchema.pick({
code: "FORBIDDEN",
message: "You are not allowed to view other users details",
});
}
const user = await ctx.db.query.users.findFirst({
columns: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
@@ -210,134 +212,170 @@ export const userRouter = createTRPCRouter({
homeBoardId: true, homeBoardId: true,
firstDayOfWeek: true, firstDayOfWeek: true,
pingIconsEnabled: true, pingIconsEnabled: true,
}, }),
where: eq(users.id, input.userId), )
}); .meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
.query(async ({ input, ctx }) => {
if (!user) { // Only admins can view other users details
throw new TRPCError({ if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
code: "NOT_FOUND",
message: "User not found",
});
}
return user;
}),
editProfile: protectedProcedure.input(validation.user.editProfile).mutation(async ({ input, ctx }) => {
// Only admins can view other users details
if (ctx.session.user.id !== input.id && !ctx.session.user.permissions.includes("admin")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to edit other users details",
});
}
const user = await ctx.db.query.users.findFirst({
columns: { email: true, provider: true },
where: eq(users.id, input.id),
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
if (user.provider !== "credentials") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Username and email can not be changed for users with external providers",
});
}
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.name, input.id);
const emailDirty = input.email && user.email !== input.email;
await ctx.db
.update(users)
.set({
name: input.name,
email: emailDirty === true ? input.email : undefined,
emailVerified: emailDirty === true ? null : undefined,
})
.where(eq(users.id, input.id));
}),
delete: protectedProcedure.input(z.string()).mutation(async ({ input, ctx }) => {
// Only admins and user itself can delete a user
if (ctx.session.user.id !== input && !ctx.session.user.permissions.includes("admin")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to delete other users",
});
}
await ctx.db.delete(users).where(eq(users.id, input));
}),
changePassword: protectedProcedure.input(validation.user.changePasswordApi).mutation(async ({ ctx, input }) => {
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,
password: true,
salt: true,
provider: true,
},
where: eq(users.id, input.userId),
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
if (dbUser.provider !== "credentials") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Password can not be changed for users with external providers",
});
}
// Admins can change the password of other users without providing the previous password
const isPreviousPasswordRequired = ctx.session.user.id === input.userId;
logger.info(
`User ${user.id} is changing password for user ${input.userId}, previous password is required: ${isPreviousPasswordRequired}`,
);
if (isPreviousPasswordRequired) {
const previousPasswordHash = await hashPasswordAsync(input.previousPassword, dbUser.salt ?? "");
const isValid = previousPasswordHash === dbUser.password;
if (!isValid) {
throw new TRPCError({ throw new TRPCError({
code: "FORBIDDEN", code: "FORBIDDEN",
message: "Invalid password", message: "You are not allowed to view other users details",
}); });
} }
} const user = await ctx.db.query.users.findFirst({
columns: {
id: true,
name: true,
email: true,
emailVerified: true,
image: true,
provider: true,
homeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
},
where: eq(users.id, input.userId),
});
const salt = await createSaltAsync(); if (!user) {
const hashedPassword = await hashPasswordAsync(input.password, salt); throw new TRPCError({
await ctx.db code: "NOT_FOUND",
.update(users) message: "User not found",
.set({ });
password: hashedPassword, }
})
.where(eq(users.id, input.userId)); return user;
}), }),
editProfile: protectedProcedure
.input(validation.user.editProfile)
.output(z.void())
.meta({ openapi: { method: "PUT", path: "/api/users/profile", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => {
// Only admins can view other users details
if (ctx.session.user.id !== input.id && !ctx.session.user.permissions.includes("admin")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to edit other users details",
});
}
const user = await ctx.db.query.users.findFirst({
columns: { email: true, provider: true },
where: eq(users.id, input.id),
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
if (user.provider !== "credentials") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Username and email can not be changed for users with external providers",
});
}
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.name, input.id);
const emailDirty = input.email && user.email !== input.email;
await ctx.db
.update(users)
.set({
name: input.name,
email: emailDirty === true ? input.email : undefined,
emailVerified: emailDirty === true ? null : undefined,
})
.where(eq(users.id, input.id));
}),
delete: protectedProcedure
.input(z.object({ userId: z.string() }))
.output(z.void())
.meta({ openapi: { method: "DELETE", path: "/api/users/{userId}", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => {
// Only admins and user itself can delete a user
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to delete other users",
});
}
await ctx.db.delete(users).where(eq(users.id, input.userId));
}),
changePassword: protectedProcedure
.input(validation.user.changePasswordApi)
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/users/{userId}/changePassword", tags: ["users"], protect: true } })
.mutation(async ({ ctx, input }) => {
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,
password: true,
salt: true,
provider: true,
},
where: eq(users.id, input.userId),
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
if (dbUser.provider !== "credentials") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Password can not be changed for users with external providers",
});
}
// Admins can change the password of other users without providing the previous password
const isPreviousPasswordRequired = ctx.session.user.id === input.userId;
logger.info(
`User ${user.id} is changing password for user ${input.userId}, previous password is required: ${isPreviousPasswordRequired}`,
);
if (isPreviousPasswordRequired) {
const previousPasswordHash = await hashPasswordAsync(input.previousPassword, dbUser.salt ?? "");
const isValid = previousPasswordHash === dbUser.password;
if (!isValid) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invalid password",
});
}
}
const salt = await createSaltAsync();
const hashedPassword = await hashPasswordAsync(input.password, salt);
await ctx.db
.update(users)
.set({
password: hashedPassword,
})
.where(eq(users.id, input.userId));
}),
changeHomeBoardId: protectedProcedure changeHomeBoardId: protectedProcedure
.input(validation.user.changeHomeBoard.and(z.object({ userId: z.string() }))) .input(convertIntersectionToZodObject(validation.user.changeHomeBoard.and(z.object({ userId: z.string() }))))
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/users/changeHome", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const user = ctx.session.user; const user = ctx.session.user;
// Only admins can change other users passwords // Only admins can change other users passwords
@@ -369,14 +407,18 @@ export const userRouter = createTRPCRouter({
}) })
.where(eq(users.id, input.userId)); .where(eq(users.id, input.userId));
}), }),
changeColorScheme: protectedProcedure.input(validation.user.changeColorScheme).mutation(async ({ input, ctx }) => { changeColorScheme: protectedProcedure
await ctx.db .input(validation.user.changeColorScheme)
.update(users) .output(z.void())
.set({ .meta({ openapi: { method: "PATCH", path: "/api/users/changeScheme", tags: ["users"], protect: true } })
colorScheme: input.colorScheme, .mutation(async ({ input, ctx }) => {
}) await ctx.db
.where(eq(users.id, ctx.session.user.id)); .update(users)
}), .set({
colorScheme: input.colorScheme,
})
.where(eq(users.id, ctx.session.user.id));
}),
getPingIconsEnabledOrDefault: publicProcedure.query(async ({ ctx }) => { getPingIconsEnabledOrDefault: publicProcedure.query(async ({ ctx }) => {
if (!ctx.session?.user) { if (!ctx.session?.user) {
return false; return false;
@@ -410,7 +452,7 @@ export const userRouter = createTRPCRouter({
}) })
.where(eq(users.id, ctx.session.user.id)); .where(eq(users.id, ctx.session.user.id));
}), }),
getFirstDayOfWeekForUserOrDefault: publicProcedure.query(async ({ ctx }) => { getFirstDayOfWeekForUserOrDefault: publicProcedure.input(z.undefined()).query(async ({ ctx }) => {
if (!ctx.session?.user) { if (!ctx.session?.user) {
return 1 as const; return 1 as const;
} }
@@ -426,7 +468,9 @@ export const userRouter = createTRPCRouter({
return user?.firstDayOfWeek ?? (1 as const); return user?.firstDayOfWeek ?? (1 as const);
}), }),
changeFirstDayOfWeek: protectedProcedure changeFirstDayOfWeek: protectedProcedure
.input(validation.user.firstDayOfWeek.and(validation.common.byId)) .input(convertIntersectionToZodObject(validation.user.firstDayOfWeek.and(validation.common.byId)))
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/users/firstDayOfWeek", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
// Only admins can change other users first day of week // Only admins can change other users first day of week
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) { if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
@@ -459,7 +503,7 @@ export const userRouter = createTRPCRouter({
}), }),
}); });
const createUserAsync = async (db: Database, input: z.infer<typeof validation.user.create>) => { const createUserAsync = async (db: Database, input: Omit<z.infer<typeof validation.user.baseCreate>, "groupIds">) => {
const salt = await createSaltAsync(); const salt = await createSaltAsync();
const hashedPassword = await hashPasswordAsync(input.password, salt); const hashedPassword = await hashPasswordAsync(input.password, salt);

View File

@@ -1,20 +1,24 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { CalendarEvent } from "@homarr/integrations/types"; import { radarrReleaseTypes } from "@homarr/integrations/types";
import { createItemAndIntegrationChannel } from "@homarr/redis"; import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
import { z } from "@homarr/validation";
import { createManyIntegrationOfOneItemMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
export const calendarRouter = createTRPCRouter({ export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure findAllEvents: publicProcedure
.unstable_concat(createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("calendar"))) .input(z.object({ year: z.number(), month: z.number(), releaseType: z.array(z.enum(radarrReleaseTypes)) }))
.query(async ({ ctx }) => { .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
const result = await Promise.all( .query(async ({ ctx, input }) => {
ctx.integrations.flatMap(async (integration) => { const results = await Promise.all(
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id); ctx.integrations.map(async (integration) => {
return await cache.getAsync(); const innerHandler = calendarMonthRequestHandler.handler(integration, input);
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}), }),
); );
return result.filter((item) => item !== null).flatMap((item) => item.data); return results.flat();
}), }),
}); });

View File

@@ -2,31 +2,33 @@ import { observable } from "@trpc/server/observable";
import type { Modify } from "@homarr/common/types"; import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema/sqlite"; import type { Integration } from "@homarr/db/schema/sqlite";
import type { IntegrationKindByCategory, WidgetKind } from "@homarr/definitions"; import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { integrationCreator } from "@homarr/integrations"; import { integrationCreator } from "@homarr/integrations";
import type { DnsHoleSummary } from "@homarr/integrations/types"; import type { DnsHoleSummary } from "@homarr/integrations/types";
import { controlsInputSchema } from "@homarr/integrations/types"; import { controlsInputSchema } from "@homarr/integrations/types";
import { createItemAndIntegrationChannel } from "@homarr/redis"; import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
import { z } from "@homarr/validation";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; 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 summary: publicProcedure
.input(z.object({ widgetKind: z.enum(["dnsHoleSummary", "dnsHoleControls"]) }))
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole"))) .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
.query(async ({ input: { widgetKind }, ctx }) => { .query(async ({ ctx }) => {
const results = await Promise.all( const results = await Promise.all(
ctx.integrations.map(async ({ decryptedSecrets: _, ...integration }) => { ctx.integrations.map(async (integration) => {
const channel = createItemAndIntegrationChannel<DnsHoleSummary>(widgetKind, integration.id); const innerHandler = dnsHoleRequestHandler.handler(integration, {});
const { data: summary, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) }; const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { return {
integration, integration: {
timestamp, id: integration.id,
summary, name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
}; };
}), }),
); );
@@ -34,22 +36,19 @@ export const dnsHoleRouter = createTRPCRouter({
}), }),
subscribeToSummary: publicProcedure subscribeToSummary: publicProcedure
.input(z.object({ widgetKind: z.enum(["dnsHoleSummary", "dnsHoleControls"]) }))
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole"))) .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
.subscription(({ input: { widgetKind }, ctx }) => { .subscription(({ ctx }) => {
return observable<{ return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"dnsHole"> }>; integration: Modify<Integration, { kind: IntegrationKindByCategory<"dnsHole"> }>;
timestamp: Date;
summary: DnsHoleSummary; summary: DnsHoleSummary;
}>((emit) => { }>((emit) => {
const unsubscribes: (() => void)[] = []; const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) { for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets; const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const channel = createItemAndIntegrationChannel<DnsHoleSummary>(widgetKind as WidgetKind, integration.id); const innerHandler = dnsHoleRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = channel.subscribe((summary) => { const unsubscribe = innerHandler.subscribe((summary) => {
emit.next({ emit.next({
integration, integration,
timestamp: new Date(),
summary, summary,
}); });
}); });
@@ -68,6 +67,12 @@ export const dnsHoleRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration } }) => { .mutation(async ({ ctx: { integration } }) => {
const client = integrationCreator(integration); const client = integrationCreator(integration);
await client.enableAsync(); await client.enableAsync();
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
// We need to wait for the integration to be enabled before invalidating the cache
await new Promise<void>((resolve) => {
setTimeout(() => void innerHandler.invalidateAsync().then(resolve), 1000);
});
}), }),
disable: publicProcedure disable: publicProcedure
@@ -76,5 +81,11 @@ export const dnsHoleRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration }, input }) => { .mutation(async ({ ctx: { integration }, input }) => {
const client = integrationCreator(integration); const client = integrationCreator(integration);
await client.disableAsync(input.duration); await client.disableAsync(input.duration);
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
// We need to wait for the integration to be disabled before invalidating the cache
await new Promise<void>((resolve) => {
setTimeout(() => void innerHandler.invalidateAsync().then(resolve), 1000);
});
}), }),
}); });

View File

@@ -6,7 +6,7 @@ import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations"; import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { downloadClientItemSchema, integrationCreator } from "@homarr/integrations"; import { downloadClientItemSchema, integrationCreator } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis"; import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
import { z } from "@homarr/validation"; import { z } from "@homarr/validation";
import type { IntegrationAction } from "../../middlewares/integration"; import type { IntegrationAction } from "../../middlewares/integration";
@@ -21,12 +21,18 @@ export const downloadsRouter = createTRPCRouter({
.unstable_concat(createDownloadClientIntegrationMiddleware("query")) .unstable_concat(createDownloadClientIntegrationMiddleware("query"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await Promise.all( return await Promise.all(
ctx.integrations.map(async ({ decryptedSecrets: _, ...integration }) => { ctx.integrations.map(async (integration) => {
const channel = createItemAndIntegrationChannel<DownloadClientJobsAndStatus>("downloads", integration.id); const innerHandler = downloadClientRequestHandler.handler(integration, {});
const { data, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) };
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { return {
integration, integration: {
timestamp, id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
data, data,
}; };
}), }),
@@ -37,17 +43,15 @@ export const downloadsRouter = createTRPCRouter({
.subscription(({ ctx }) => { .subscription(({ ctx }) => {
return observable<{ return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>; integration: Modify<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>;
timestamp: Date;
data: DownloadClientJobsAndStatus; data: DownloadClientJobsAndStatus;
}>((emit) => { }>((emit) => {
const unsubscribes: (() => void)[] = []; const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) { for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets; const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const channel = createItemAndIntegrationChannel<DownloadClientJobsAndStatus>("downloads", integration.id); const innerHandler = downloadClientRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = channel.subscribe((data) => { const unsubscribe = innerHandler.subscribe((data) => {
emit.next({ emit.next({
integration, integration,
timestamp: new Date(),
data, data,
}); });
}); });

View File

@@ -1,7 +1,7 @@
import { observable } from "@trpc/server/observable"; import { observable } from "@trpc/server/observable";
import type { HealthMonitoring } from "@homarr/integrations"; import type { HealthMonitoring } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis"; import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -12,14 +12,14 @@ export const healthMonitoringRouter = createTRPCRouter({
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await Promise.all( return await Promise.all(
ctx.integrations.map(async (integration) => { ctx.integrations.map(async (integration) => {
const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id); const innerHandler = systemInfoRequestHandler.handler(integration, {});
const { data: healthInfo, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) }; const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { return {
integrationId: integration.id, integrationId: integration.id,
integrationName: integration.name, integrationName: integration.name,
healthInfo, healthInfo: data,
timestamp, updatedAt: timestamp,
}; };
}), }),
); );
@@ -31,8 +31,8 @@ export const healthMonitoringRouter = createTRPCRouter({
return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => { return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => {
const unsubscribes: (() => void)[] = []; const unsubscribes: (() => void)[] = [];
for (const integration of ctx.integrations) { for (const integration of ctx.integrations) {
const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id); const innerHandler = systemInfoRequestHandler.handler(integration, {});
const unsubscribe = channel.subscribe((healthInfo) => { const unsubscribe = innerHandler.subscribe((healthInfo) => {
emit.next({ emit.next({
integrationId: integration.id, integrationId: integration.id,
healthInfo, healthInfo,

View File

@@ -5,7 +5,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { integrationCreator } from "@homarr/integrations"; import { integrationCreator } from "@homarr/integrations";
import type { Indexer } from "@homarr/integrations/types"; import type { Indexer } from "@homarr/integrations/types";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import { createItemAndIntegrationChannel } from "@homarr/redis"; import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
import type { IntegrationAction } from "../../middlewares/integration"; import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
@@ -20,14 +20,8 @@ export const indexerManagerRouter = createTRPCRouter({
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const results = await Promise.all( const results = await Promise.all(
ctx.integrations.map(async (integration) => { ctx.integrations.map(async (integration) => {
const client = integrationCreator(integration); const innerHandler = indexerManagerRequestHandler.handler(integration, {});
const indexers = await client.getIndexersAsync().catch((err) => { const { data: indexers } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
logger.error("indexer-manager router - ", err);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Failed to fetch indexers for ${integration.name} (${integration.id})`,
});
});
return { return {
integrationId: integration.id, integrationId: integration.id,
@@ -43,11 +37,11 @@ export const indexerManagerRouter = createTRPCRouter({
.subscription(({ ctx }) => { .subscription(({ ctx }) => {
return observable<{ integrationId: string; indexers: Indexer[] }>((emit) => { return observable<{ integrationId: string; indexers: Indexer[] }>((emit) => {
const unsubscribes: (() => void)[] = []; const unsubscribes: (() => void)[] = [];
for (const integration of ctx.integrations) { for (const integrationWithSecrets of ctx.integrations) {
const channel = createItemAndIntegrationChannel<Indexer[]>("indexerManager", integration.id); const innerHandler = indexerManagerRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = channel.subscribe((indexers) => { const unsubscribe = innerHandler.subscribe((indexers) => {
emit.next({ emit.next({
integrationId: integration.id, integrationId: integrationWithSecrets.id,
indexers, indexers,
}); });
}); });
@@ -60,7 +54,6 @@ export const indexerManagerRouter = createTRPCRouter({
}; };
}); });
}), }),
testAllIndexers: publicProcedure testAllIndexers: publicProcedure
.unstable_concat(createIndexerManagerIntegrationMiddleware("interact")) .unstable_concat(createIndexerManagerIntegrationMiddleware("interact"))
.mutation(async ({ ctx }) => { .mutation(async ({ ctx }) => {

View File

@@ -1,53 +1,110 @@
import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { MediaRequestList, MediaRequestStats } from "@homarr/integrations"; import { integrationCreator, MediaRequestStatus } from "@homarr/integrations";
import { integrationCreator } from "@homarr/integrations"; import type { MediaRequest } from "@homarr/integrations/types";
import { createItemAndIntegrationChannel } from "@homarr/redis"; import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { z } from "@homarr/validation"; import { z } from "@homarr/validation";
import { import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
createManyIntegrationOfOneItemMiddleware,
createOneIntegrationMiddleware,
} from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
export const mediaRequestsRouter = createTRPCRouter({ export const mediaRequestsRouter = createTRPCRouter({
getLatestRequests: publicProcedure getLatestRequests: publicProcedure
.unstable_concat( .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")), .query(async ({ ctx }) => {
) const results = await Promise.all(
.query(async ({ input }) => { ctx.integrations.map(async (integration) => {
return await Promise.all( const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
input.integrationIds.map(async (integrationId) => { const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
const channel = createItemAndIntegrationChannel<MediaRequestList>("mediaRequests-requestList", integrationId); return {
return await channel.getAsync(); integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
data,
};
}), }),
); );
return results
.flatMap(({ data, integration }) => data.map((request) => ({ ...request, integrationId: integration.id })))
.sort(({ status: statusA }, { status: statusB }) => {
if (statusA === MediaRequestStatus.PendingApproval) {
return -1;
}
if (statusB === MediaRequestStatus.PendingApproval) {
return 1;
}
return 0;
});
}),
subscribeToLatestRequests: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.subscription(({ ctx }) => {
return observable<{
integrationId: string;
requests: MediaRequest[];
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = mediaRequestListRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((requests) => {
emit.next({
integrationId: integration.id,
requests,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}), }),
getStats: publicProcedure getStats: publicProcedure
.unstable_concat( .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")), .query(async ({ ctx }) => {
) const results = await Promise.all(
.query(async ({ input }) => { ctx.integrations.map(async (integration) => {
return await Promise.all( const innerHandler = mediaRequestStatsRequestHandler.handler(integration, {});
input.integrationIds.map(async (integrationId) => { const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
const channel = createItemAndIntegrationChannel<MediaRequestStats>( return {
"mediaRequests-requestStats", integration: {
integrationId, id: integration.id,
); name: integration.name,
return await channel.getAsync(); kind: integration.kind,
},
data,
};
}), }),
); );
return {
stats: results.flatMap((result) => result.data.stats),
users: results
.map((result) => result.data.users.map((user) => ({ ...user, integration: result.integration })))
.flat()
.sort(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
integrations: results.map((result) => result.integration),
};
}), }),
answerRequest: protectedProcedure answerRequest: protectedProcedure
.unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest"))) .unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
.input(z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]) })) .input(z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]) }))
.mutation(async ({ ctx: { integration }, input }) => { .mutation(async ({ ctx: { integration }, input }) => {
const integrationInstance = integrationCreator(integration); const integrationInstance = integrationCreator(integration);
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
if (input.answer === "approve") { if (input.answer === "approve") {
await integrationInstance.approveRequestAsync(input.requestId); await integrationInstance.approveRequestAsync(input.requestId);
await innerHandler.invalidateAsync();
return; return;
} }
await integrationInstance.declineRequestAsync(input.requestId); await integrationInstance.declineRequestAsync(input.requestId);
await innerHandler.invalidateAsync();
}), }),
}); });

View File

@@ -2,7 +2,7 @@ import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { StreamSession } from "@homarr/integrations"; import type { StreamSession } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis"; import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
import type { IntegrationAction } from "../../middlewares/integration"; import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
@@ -17,11 +17,11 @@ export const mediaServerRouter = createTRPCRouter({
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await Promise.all( return await Promise.all(
ctx.integrations.map(async (integration) => { ctx.integrations.map(async (integration) => {
const channel = createItemAndIntegrationChannel<StreamSession[]>("mediaServer", integration.id); const innerHandler = mediaServerRequestHandler.handler(integration, {});
const data = await channel.getAsync(); const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { return {
integrationId: integration.id, integrationId: integration.id,
sessions: data?.data ?? [], sessions: data,
}; };
}), }),
); );
@@ -32,8 +32,9 @@ export const mediaServerRouter = createTRPCRouter({
return observable<{ integrationId: string; data: StreamSession[] }>((emit) => { return observable<{ integrationId: string; data: StreamSession[] }>((emit) => {
const unsubscribes: (() => void)[] = []; const unsubscribes: (() => void)[] = [];
for (const integration of ctx.integrations) { for (const integration of ctx.integrations) {
const channel = createItemAndIntegrationChannel<StreamSession[]>("mediaServer", integration.id); const innerHandler = mediaServerRequestHandler.handler(integration, {});
const unsubscribe = channel.subscribe((sessions) => {
const unsubscribe = innerHandler.subscribe((sessions) => {
emit.next({ emit.next({
integrationId: integration.id, integrationId: integration.id,
data: sessions, data: sessions,

View File

@@ -2,7 +2,7 @@ import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { integrationCreator } from "@homarr/integrations"; import { integrationCreator } from "@homarr/integrations";
import { homeAssistantEntityState } from "@homarr/redis"; import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state";
import { z } from "@homarr/validation"; import { z } from "@homarr/validation";
import type { IntegrationAction } from "../../middlewares/integration"; import type { IntegrationAction } from "../../middlewares/integration";
@@ -13,29 +13,45 @@ const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("smartHomeServer")); createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("smartHomeServer"));
export const smartHomeRouter = createTRPCRouter({ export const smartHomeRouter = createTRPCRouter({
subscribeEntityState: publicProcedure.input(z.object({ entityId: z.string() })).subscription(({ input }) => { entityState: publicProcedure
return observable<{ .input(z.object({ entityId: z.string() }))
entityId: string; .unstable_concat(createSmartHomeIntegrationMiddleware("query"))
state: string; .query(async ({ ctx: { integration }, input }) => {
}>((emit) => { const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const unsubscribe = homeAssistantEntityState.subscribe((message) => { const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
if (message.entityId !== input.entityId) { return data;
return; }),
} subscribeEntityState: publicProcedure
emit.next(message); .unstable_concat(createSmartHomeIntegrationMiddleware("query"))
}); .input(z.object({ entityId: z.string() }))
.subscription(({ input, ctx }) => {
return observable<{
entityId: string;
state: string;
}>((emit) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(ctx.integration, {
entityId: input.entityId,
});
const unsubscribe = innerHandler.subscribe((state) => {
emit.next({ state, entityId: input.entityId });
});
return () => { return () => {
unsubscribe(); unsubscribe();
}; };
}); });
}), }),
switchEntity: publicProcedure switchEntity: publicProcedure
.unstable_concat(createSmartHomeIntegrationMiddleware("interact")) .unstable_concat(createSmartHomeIntegrationMiddleware("interact"))
.input(z.object({ entityId: z.string() })) .input(z.object({ entityId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => { .mutation(async ({ ctx: { integration }, input }) => {
const client = integrationCreator(integration); const client = integrationCreator(integration);
return await client.triggerToggleAsync(input.entityId); const success = await client.triggerToggleAsync(input.entityId);
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
await innerHandler.invalidateAsync();
return success;
}), }),
executeAutomation: publicProcedure executeAutomation: publicProcedure
.unstable_concat(createSmartHomeIntegrationMiddleware("interact")) .unstable_concat(createSmartHomeIntegrationMiddleware("interact"))

View File

@@ -0,0 +1,22 @@
import type { AnyZodObject, ZodIntersection, ZodObject } from "@homarr/validation";
import { z } from "@homarr/validation";
export function convertIntersectionToZodObject<TIntersection extends ZodIntersection<AnyZodObject, AnyZodObject>>(
intersection: TIntersection,
) {
const { _def } = intersection;
// Merge the shapes
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const mergedShape = { ..._def.left.shape, ..._def.right.shape };
// Return a new ZodObject
return z.object(mergedShape) as unknown as TIntersection extends ZodIntersection<infer TLeft, infer TRight>
? TLeft extends AnyZodObject
? TRight extends AnyZodObject
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
ZodObject<TLeft["shape"] & TRight["shape"], any, any, z.infer<TLeft> & z.infer<TRight>>
: never
: never
: never;
}

View File

@@ -0,0 +1,16 @@
import { expect, test, vi } from "vitest";
import { openApiDocument } from "../open-api";
vi.mock("@homarr/auth", () => ({}));
test("OpenAPI documentation should be generated", () => {
// Arrange
const base = "https://homarr.dev";
// Act
const act = () => openApiDocument(base);
// Assert
expect(act).not.toThrow();
});

View File

@@ -8,7 +8,7 @@
*/ */
import { initTRPC, TRPCError } from "@trpc/server"; import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson"; import superjson from "superjson";
import type { OpenApiMeta } from "trpc-swagger"; import type { OpenApiMeta } from "trpc-to-openapi";
import type { Session } from "@homarr/auth"; import type { Session } from "@homarr/auth";
import { FlattenError } from "@homarr/common"; import { FlattenError } from "@homarr/common";

View File

@@ -3,11 +3,11 @@ import { DrizzleAdapter } from "@auth/drizzle-adapter";
import type { Database } from "@homarr/db"; import type { Database } from "@homarr/db";
import { and, eq } from "@homarr/db"; import { and, eq } from "@homarr/db";
import { accounts, users } from "@homarr/db/schema/sqlite"; import { accounts, sessions, users } from "@homarr/db/schema/sqlite";
import type { SupportedAuthProvider } from "@homarr/definitions"; import type { SupportedAuthProvider } from "@homarr/definitions";
export const createAdapter = (db: Database, provider: SupportedAuthProvider | "unknown"): Adapter => { export const createAdapter = (db: Database, provider: SupportedAuthProvider | "unknown"): Adapter => {
const drizzleAdapter = DrizzleAdapter(db, { usersTable: users, accountsTable: accounts }); const drizzleAdapter = DrizzleAdapter(db, { usersTable: users, sessionsTable: sessions, accountsTable: accounts });
return { return {
...drizzleAdapter, ...drizzleAdapter,

View File

@@ -46,7 +46,7 @@
"@types/bcrypt": "5.0.2", "@types/bcrypt": "5.0.2",
"@types/cookies": "0.9.0", "@types/cookies": "0.9.0",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"prettier": "^3.3.3", "prettier": "^3.4.1",
"typescript": "^5.6.3" "typescript": "^5.7.2"
} }
} }

View File

@@ -59,10 +59,11 @@ export class LdapClient {
private static convertEntryPropertyToString(value: Entry[string]) { private static convertEntryPropertyToString(value: Entry[string]) {
const firstValue = Array.isArray(value) ? (value[0] ?? "") : value; const firstValue = Array.isArray(value) ? (value[0] ?? "") : value;
if (firstValue instanceof Buffer) { if (typeof firstValue === "string") {
return firstValue.toString("utf8"); return firstValue;
} }
return firstValue;
return firstValue.toString("utf8");
} }
/** /**

View File

@@ -34,6 +34,6 @@
"@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.15.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.7.2"
} }
} }

View File

@@ -29,13 +29,13 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"next": "^14.2.18", "next": "^14.2.18",
"react": "^18.3.1", "react": "^18.3.1",
"tldts": "^6.1.63" "tldts": "^6.1.64"
}, },
"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",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.7.2"
} }
} }

View File

@@ -21,3 +21,20 @@ export const useTimeAgo = (timestamp: Date) => {
return timeAgo; return timeAgo;
}; };
export const useIntegrationConnected = (updatedAt: Date, { timeout = 30000 }) => {
const [connected, setConnected] = useState(Math.abs(dayjs(updatedAt).diff()) < timeout);
useEffect(() => {
setConnected(Math.abs(dayjs(updatedAt).diff()) < timeout);
const delayUntilTimeout = timeout - Math.abs(dayjs(updatedAt).diff());
const timeoutRef = setTimeout(() => {
setConnected(false);
}, delayUntilTimeout);
return () => clearTimeout(timeoutRef);
}, [updatedAt, timeout]);
return connected;
};

View File

@@ -1,3 +1,5 @@
import { hashKey } from "@tanstack/query-core";
export function objectKeys<O extends object>(obj: O): (keyof O)[] { export function objectKeys<O extends object>(obj: O): (keyof O)[] {
return Object.keys(obj) as (keyof O)[]; return Object.keys(obj) as (keyof O)[];
} }
@@ -7,3 +9,7 @@ type Entries<T> = {
}[keyof T][]; }[keyof T][];
export const objectEntries = <T extends object>(obj: T) => Object.entries(obj) as Entries<T>; export const objectEntries = <T extends object>(obj: T) => Object.entries(obj) as Entries<T>;
export const hashObjectBase64 = (obj: object) => {
return Buffer.from(hashKey([obj])).toString("base64");
};

View File

@@ -9,3 +9,5 @@ export type Modify<T, R extends Partial<Record<keyof T, unknown>>> = {
export type RemoveReadonly<T> = { export type RemoveReadonly<T> = {
-readonly [P in keyof T]: T[P] extends Record<string, unknown> ? RemoveReadonly<T[P]> : T[P]; -readonly [P in keyof T]: T[P] extends Record<string, unknown> ? RemoveReadonly<T[P]> : T[P];
}; };
export type MaybeArray<T> = T | T[];

View File

@@ -31,6 +31,6 @@
"@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.15.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.7.2"
} }
} }

View File

@@ -30,6 +30,6 @@
"@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.15.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.7.2"
} }
} }

View File

@@ -33,6 +33,6 @@
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.7.2"
} }
} }

View File

@@ -35,6 +35,7 @@
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/ping": "workspace:^0.1.0", "@homarr/ping": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0",
"@homarr/request-handler": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0" "@homarr/validation": "workspace:^0.1.0"
@@ -44,6 +45,6 @@
"@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.15.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.7.2"
} }
} }

View File

@@ -6,7 +6,7 @@ import { healthMonitoringJob } from "./jobs/integrations/health-monitoring";
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant"; import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
import { indexerManagerJob } from "./jobs/integrations/indexer-manager"; import { indexerManagerJob } from "./jobs/integrations/indexer-manager";
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer"; import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
import { mediaRequestsJob } from "./jobs/integrations/media-requests"; import { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/media-requests";
import { mediaServerJob } from "./jobs/integrations/media-server"; import { mediaServerJob } from "./jobs/integrations/media-server";
import { pingJob } from "./jobs/ping"; import { pingJob } from "./jobs/ping";
import type { RssFeed } from "./jobs/rss-feeds"; import type { RssFeed } from "./jobs/rss-feeds";
@@ -23,7 +23,8 @@ export const jobGroup = createCronJobGroup({
mediaOrganizer: mediaOrganizerJob, mediaOrganizer: mediaOrganizerJob,
downloads: downloadsJob, downloads: downloadsJob,
dnsHole: dnsHoleJob, dnsHole: dnsHoleJob,
mediaRequests: mediaRequestsJob, mediaRequestStats: mediaRequestStatsJob,
mediaRequestList: mediaRequestListJob,
rssFeeds: rssFeedsJob, rssFeeds: rssFeedsJob,
indexerManager: indexerManagerJob, indexerManager: indexerManagerJob,
healthMonitoring: healthMonitoringJob, healthMonitoring: healthMonitoringJob,

View File

@@ -1,28 +1,15 @@
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db"; import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import type { DnsHoleSummary } from "@homarr/integrations/types";
import { logger } from "@homarr/log";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { createCronJob } from "../../lib"; import { createCronJob } from "../../lib";
export const dnsHoleJob = createCronJob("dnsHole", EVERY_5_SECONDS).withCallback(async () => { export const dnsHoleJob = createCronJob("dnsHole", EVERY_5_SECONDS).withCallback(
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { createRequestIntegrationJobHandler(dnsHoleRequestHandler.handler, {
kinds: ["dnsHoleSummary", "dnsHoleControls"], widgetKinds: ["dnsHoleSummary", "dnsHoleControls"],
}); getInput: {
dnsHoleSummary: () => ({}),
for (const itemForIntegration of itemsForIntegration) { dnsHoleControls: () => ({}),
for (const { integration } of itemForIntegration.integrations) { },
const integrationInstance = integrationCreatorFromSecrets(integration); }),
await integrationInstance );
.getSummaryAsync()
.then(async (data) => {
const channel = createItemAndIntegrationChannel<DnsHoleSummary>(itemForIntegration.kind, integration.id);
await channel.publishAndUpdateLastStateAsync(data);
})
.catch((error) => logger.error(`Could not retrieve data for ${integration.name}: "${error}"`));
}
}
});

View File

@@ -1,27 +1,14 @@
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db"; import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { createCronJob } from "../../lib"; import { createCronJob } from "../../lib";
export const downloadsJob = createCronJob("downloads", EVERY_5_SECONDS).withCallback(async () => { export const downloadsJob = createCronJob("downloads", EVERY_5_SECONDS).withCallback(
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { createRequestIntegrationJobHandler(downloadClientRequestHandler.handler, {
kinds: ["downloads"], widgetKinds: ["downloads"],
}); getInput: {
downloads: () => ({}),
for (const itemForIntegration of itemsForIntegration) { },
for (const { integration } of itemForIntegration.integrations) { }),
const integrationInstance = integrationCreatorFromSecrets(integration); );
await integrationInstance
.getClientJobsAndStatusAsync()
.then(async (data) => {
const channel = createItemAndIntegrationChannel<DownloadClientJobsAndStatus>("downloads", integration.id);
await channel.publishAndUpdateLastStateAsync(data);
})
.catch((error) => console.error(`Could not retrieve data for ${integration.name}: "${error}"`));
}
}
});

View File

@@ -1,22 +1,14 @@
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db"; import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { createCronJob } from "../../lib"; import { createCronJob } from "../../lib";
export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback(async () => { export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback(
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { createRequestIntegrationJobHandler(systemInfoRequestHandler.handler, {
kinds: ["healthMonitoring"], widgetKinds: ["healthMonitoring"],
}); getInput: {
healthMonitoring: () => ({}),
for (const itemForIntegration of itemsForIntegration) { },
for (const integration of itemForIntegration.integrations) { }),
const openmediavault = integrationCreatorFromSecrets(integration.integration); );
const healthInfo = await openmediavault.getSystemInfoAsync();
const channel = createItemAndIntegrationChannel("healthMonitoring", integration.integrationId);
await channel.publishAndUpdateLastStateAsync(healthInfo);
}
}
});

View File

@@ -1,42 +1,16 @@
import SuperJSON from "superjson";
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db"; import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import { logger } from "@homarr/log";
import { homeAssistantEntityState } from "@homarr/redis";
// This import is done that way to avoid circular dependencies.
import type { WidgetComponentProps } from "../../../../widgets";
import { createCronJob } from "../../lib"; import { createCronJob } from "../../lib";
export const smartHomeEntityStateJob = createCronJob("smartHomeEntityState", EVERY_MINUTE).withCallback(async () => { export const smartHomeEntityStateJob = createCronJob("smartHomeEntityState", EVERY_MINUTE).withCallback(
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { createRequestIntegrationJobHandler(smartHomeEntityStateRequestHandler.handler, {
kinds: ["smartHome-entityState"], widgetKinds: ["smartHome-entityState"],
}); getInput: {
"smartHome-entityState": (options) => ({
for (const itemForIntegration of itemsForIntegration) { entityId: options.entityId,
const integration = itemForIntegration.integrations[0]?.integration; }),
if (!integration) { },
continue; }),
} );
const options = SuperJSON.parse<WidgetComponentProps<"smartHome-entityState">["options"]>(
itemForIntegration.options,
);
const homeAssistant = integrationCreatorFromSecrets(integration);
const state = await homeAssistant.getEntityStateAsync(options.entityId);
if (!state.success) {
logger.error("Unable to fetch data from Home Assistant");
continue;
}
await homeAssistantEntityState.publishAsync({
entityId: options.entityId,
state: state.data.state,
});
}
});

View File

@@ -1,19 +1,14 @@
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db"; import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import { createCronJob } from "../../lib"; import { createCronJob } from "../../lib";
export const indexerManagerJob = createCronJob("indexerManager", EVERY_MINUTE).withCallback(async () => { export const indexerManagerJob = createCronJob("indexerManager", EVERY_5_MINUTES).withCallback(
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { createRequestIntegrationJobHandler(indexerManagerRequestHandler.handler, {
kinds: ["indexerManager"], widgetKinds: ["indexerManager"],
}); getInput: {
indexerManager: () => ({}),
for (const itemForIntegration of itemsForIntegration) { },
for (const { integration } of itemForIntegration.integrations) { }),
const integrationInstance = integrationCreatorFromSecrets(integration); );
await integrationInstance.getIndexersAsync();
}
}
});

View File

@@ -1,36 +1,35 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import SuperJSON from "superjson";
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db"; import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import type { CalendarEvent } from "@homarr/integrations/types";
import { createItemAndIntegrationChannel } from "@homarr/redis";
// This import is done that way to avoid circular dependencies.
import type { WidgetComponentProps } from "../../../../widgets";
import { createCronJob } from "../../lib"; import { createCronJob } from "../../lib";
export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).withCallback(async () => { export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).withCallback(
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { createRequestIntegrationJobHandler(calendarMonthRequestHandler.handler, {
kinds: ["calendar"], widgetKinds: ["calendar"],
}); getInput: {
// Request handler will run for all specified months
calendar: (options) => {
const inputs = [];
for (const itemForIntegration of itemsForIntegration) { const startOffset = -Number(options.filterPastMonths);
for (const { integration } of itemForIntegration.integrations) { const endOffset = Number(options.filterFutureMonths);
const options = SuperJSON.parse<WidgetComponentProps<"calendar">["options"]>(itemForIntegration.options);
const start = dayjs().subtract(Number(options.filterPastMonths), "months").toDate(); for (let offsetMonths = startOffset; offsetMonths <= endOffset; offsetMonths++) {
const end = dayjs().add(Number(options.filterFutureMonths), "months").toDate(); const year = dayjs().subtract(offsetMonths, "months").year();
const month = dayjs().subtract(offsetMonths, "months").month();
//Asserting the integration kind until all of them get implemented inputs.push({
const integrationInstance = integrationCreatorFromSecrets(integration); year,
month,
releaseType: options.releaseType,
});
}
const events = await integrationInstance.getCalendarEventsAsync(start, end); return inputs;
},
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id); },
await cache.setAsync(events); }),
} );
}
});

View File

@@ -1,42 +1,24 @@
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db"; import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
import type { MediaRequestList, MediaRequestStats } from "@homarr/integrations"; import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { createCronJob } from "../../lib"; import { createCronJob } from "../../lib";
export const mediaRequestsJob = createCronJob("mediaRequests", EVERY_5_SECONDS).withCallback(async () => { export const mediaRequestStatsJob = createCronJob("mediaRequestStats", EVERY_MINUTE).withCallback(
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { createRequestIntegrationJobHandler(mediaRequestStatsRequestHandler.handler, {
kinds: ["mediaRequests-requestList", "mediaRequests-requestStats"], widgetKinds: ["mediaRequests-requestStats"],
}); getInput: {
"mediaRequests-requestStats": () => ({}),
},
}),
);
for (const itemForIntegration of itemsForIntegration) { export const mediaRequestListJob = createCronJob("mediaRequestList", EVERY_MINUTE).withCallback(
for (const { integration } of itemForIntegration.integrations) { createRequestIntegrationJobHandler(mediaRequestListRequestHandler.handler, {
const requestsIntegration = integrationCreatorFromSecrets(integration); widgetKinds: ["mediaRequests-requestList"],
getInput: {
const mediaRequests = await requestsIntegration.getRequestsAsync(); "mediaRequests-requestList": () => ({}),
const requestsStats = await requestsIntegration.getStatsAsync(); },
const requestsUsers = await requestsIntegration.getUsersAsync(); }),
const requestListChannel = createItemAndIntegrationChannel<MediaRequestList>( );
"mediaRequests-requestList",
integration.id,
);
await requestListChannel.publishAndUpdateLastStateAsync({
integration: { id: integration.id },
medias: mediaRequests,
});
const requestStatsChannel = createItemAndIntegrationChannel<MediaRequestStats>(
"mediaRequests-requestStats",
integration.id,
);
await requestStatsChannel.publishAndUpdateLastStateAsync({
integration: { kind: integration.kind, name: integration.name },
stats: requestsStats,
users: requestsUsers,
});
}
}
});

View File

@@ -1,22 +1,14 @@
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db"; import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { createCronJob } from "../../lib"; import { createCronJob } from "../../lib";
export const mediaServerJob = createCronJob("mediaServer", EVERY_5_SECONDS).withCallback(async () => { export const mediaServerJob = createCronJob("mediaServer", EVERY_5_SECONDS).withCallback(
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { createRequestIntegrationJobHandler(mediaServerRequestHandler.handler, {
kinds: ["mediaServer"], widgetKinds: ["mediaServer"],
}); getInput: {
mediaServer: () => ({}),
for (const itemForIntegration of itemsForIntegration) { },
for (const { integration } of itemForIntegration.integrations) { }),
const integrationInstance = integrationCreatorFromSecrets(integration); );
const streamSessions = await integrationInstance.getCurrentSessionsAsync();
const channel = createItemAndIntegrationChannel("mediaServer", integration.id);
await channel.publishAndUpdateLastStateAsync(streamSessions);
}
}
});

View File

@@ -7,6 +7,7 @@ dotenv.config({ path: "../../.env" });
export default { export default {
dialect: "mysql", dialect: "mysql",
schema: "./schema", schema: "./schema",
casing: "snake_case",
dbCredentials: { dbCredentials: {
host: process.env.DB_HOST!, host: process.env.DB_HOST!,
user: process.env.DB_USER!, user: process.env.DB_USER!,

View File

@@ -6,6 +6,7 @@ dotenv.config({ path: "../../.env" });
export default { export default {
dialect: "sqlite", dialect: "sqlite",
schema: "./schema", schema: "./schema",
casing: "snake_case",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
dbCredentials: { url: process.env.DB_URL! }, dbCredentials: { url: process.env.DB_URL! },
out: "./migrations/sqlite", out: "./migrations/sqlite",

View File

@@ -40,6 +40,7 @@ const initBetterSqlite = () => {
database = drizzleSqlite(connection, { database = drizzleSqlite(connection, {
schema: sqliteSchema, schema: sqliteSchema,
logger: new WinstonDrizzleLogger(), logger: new WinstonDrizzleLogger(),
casing: "snake_case",
}) as unknown as never; }) as unknown as never;
}; };
@@ -61,6 +62,7 @@ const initMySQL2 = () => {
schema: mysqlSchema, schema: mysqlSchema,
mode: "default", mode: "default",
logger: new WinstonDrizzleLogger(), logger: new WinstonDrizzleLogger(),
casing: "snake_case",
}) as unknown as HomarrDatabase; }) as unknown as HomarrDatabase;
}; };

View File

@@ -0,0 +1,64 @@
ALTER TABLE `account` RENAME COLUMN `userId` TO `user_id`;--> statement-breakpoint
ALTER TABLE `account` RENAME COLUMN `providerAccountId` TO `provider_account_id`;--> statement-breakpoint
ALTER TABLE `apiKey` RENAME COLUMN `apiKey` TO `api_key`;--> statement-breakpoint
ALTER TABLE `apiKey` RENAME COLUMN `userId` TO `user_id`;--> statement-breakpoint
ALTER TABLE `groupMember` RENAME COLUMN `groupId` TO `group_id`;--> statement-breakpoint
ALTER TABLE `groupMember` RENAME COLUMN `userId` TO `user_id`;--> statement-breakpoint
ALTER TABLE `groupPermission` RENAME COLUMN `groupId` TO `group_id`;--> statement-breakpoint
ALTER TABLE `iconRepository` RENAME COLUMN `iconRepository_id` TO `id`;--> statement-breakpoint
ALTER TABLE `iconRepository` RENAME COLUMN `iconRepository_slug` TO `slug`;--> statement-breakpoint
ALTER TABLE `icon` RENAME COLUMN `icon_id` TO `id`;--> statement-breakpoint
ALTER TABLE `icon` RENAME COLUMN `icon_name` TO `name`;--> statement-breakpoint
ALTER TABLE `icon` RENAME COLUMN `icon_url` TO `url`;--> statement-breakpoint
ALTER TABLE `icon` RENAME COLUMN `icon_checksum` TO `checksum`;--> statement-breakpoint
ALTER TABLE `icon` RENAME COLUMN `iconRepository_id` TO `icon_repository_id`;--> statement-breakpoint
ALTER TABLE `serverSetting` RENAME COLUMN `key` TO `setting_key`;--> statement-breakpoint
ALTER TABLE `session` RENAME COLUMN `sessionToken` TO `session_token`;--> statement-breakpoint
ALTER TABLE `session` RENAME COLUMN `userId` TO `user_id`;--> statement-breakpoint
ALTER TABLE `user` RENAME COLUMN `emailVerified` TO `email_verified`;--> statement-breakpoint
ALTER TABLE `user` RENAME COLUMN `homeBoardId` TO `home_board_id`;--> statement-breakpoint
ALTER TABLE `user` RENAME COLUMN `colorScheme` TO `color_scheme`;--> statement-breakpoint
ALTER TABLE `user` RENAME COLUMN `firstDayOfWeek` TO `first_day_of_week`;--> statement-breakpoint
ALTER TABLE `user` RENAME COLUMN `pingIconsEnabled` TO `ping_icons_enabled`;--> statement-breakpoint
ALTER TABLE `serverSetting` DROP INDEX `serverSetting_key_unique`;--> statement-breakpoint
ALTER TABLE `account` DROP FOREIGN KEY `account_userId_user_id_fk`;
--> statement-breakpoint
ALTER TABLE `apiKey` DROP FOREIGN KEY `apiKey_userId_user_id_fk`;
--> statement-breakpoint
ALTER TABLE `groupMember` DROP FOREIGN KEY `groupMember_groupId_group_id_fk`;
--> statement-breakpoint
ALTER TABLE `groupMember` DROP FOREIGN KEY `groupMember_userId_user_id_fk`;
--> statement-breakpoint
ALTER TABLE `groupPermission` DROP FOREIGN KEY `groupPermission_groupId_group_id_fk`;
--> statement-breakpoint
ALTER TABLE `icon` DROP FOREIGN KEY `icon_iconRepository_id_iconRepository_iconRepository_id_fk`;
--> statement-breakpoint
ALTER TABLE `session` DROP FOREIGN KEY `session_userId_user_id_fk`;
--> statement-breakpoint
ALTER TABLE `user` DROP FOREIGN KEY `user_homeBoardId_board_id_fk`;
--> statement-breakpoint
DROP INDEX `userId_idx` ON `account`;--> statement-breakpoint
DROP INDEX `user_id_idx` ON `session`;--> statement-breakpoint
ALTER TABLE `account` DROP PRIMARY KEY;--> statement-breakpoint
ALTER TABLE `groupMember` DROP PRIMARY KEY;--> statement-breakpoint
ALTER TABLE `iconRepository` DROP PRIMARY KEY;--> statement-breakpoint
ALTER TABLE `icon` DROP PRIMARY KEY;--> statement-breakpoint
ALTER TABLE `serverSetting` DROP PRIMARY KEY;--> statement-breakpoint
ALTER TABLE `session` DROP PRIMARY KEY;--> statement-breakpoint
ALTER TABLE `account` ADD PRIMARY KEY(`provider`,`provider_account_id`);--> statement-breakpoint
ALTER TABLE `groupMember` ADD PRIMARY KEY(`group_id`,`user_id`);--> statement-breakpoint
ALTER TABLE `iconRepository` ADD PRIMARY KEY(`id`);--> statement-breakpoint
ALTER TABLE `icon` ADD PRIMARY KEY(`id`);--> statement-breakpoint
ALTER TABLE `serverSetting` ADD PRIMARY KEY(`setting_key`);--> statement-breakpoint
ALTER TABLE `session` ADD PRIMARY KEY(`session_token`);--> statement-breakpoint
ALTER TABLE `serverSetting` ADD CONSTRAINT `serverSetting_settingKey_unique` UNIQUE(`setting_key`);--> statement-breakpoint
ALTER TABLE `account` ADD CONSTRAINT `account_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `apiKey` ADD CONSTRAINT `apiKey_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `groupMember` ADD CONSTRAINT `groupMember_group_id_group_id_fk` FOREIGN KEY (`group_id`) REFERENCES `group`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `groupMember` ADD CONSTRAINT `groupMember_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `groupPermission` ADD CONSTRAINT `groupPermission_group_id_group_id_fk` FOREIGN KEY (`group_id`) REFERENCES `group`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `icon` ADD CONSTRAINT `icon_icon_repository_id_iconRepository_id_fk` FOREIGN KEY (`icon_repository_id`) REFERENCES `iconRepository`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `session` ADD CONSTRAINT `session_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `user` ADD CONSTRAINT `user_home_board_id_board_id_fk` FOREIGN KEY (`home_board_id`) REFERENCES `board`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `userId_idx` ON `account` (`user_id`);--> statement-breakpoint
CREATE INDEX `user_id_idx` ON `session` (`user_id`);

File diff suppressed because it is too large Load Diff

View File

@@ -113,6 +113,13 @@
"when": 1730653393442, "when": 1730653393442,
"tag": "0015_unknown_firedrake", "tag": "0015_unknown_firedrake",
"breakpoints": true "breakpoints": true
},
{
"idx": 16,
"version": "5",
"when": 1732212709518,
"tag": "0016_change_all_to_snake_case",
"breakpoints": true
} }
] ]
} }

View File

@@ -25,6 +25,7 @@ const migrateAsync = async () => {
const db = drizzle(mysql2, { const db = drizzle(mysql2, {
mode: "default", mode: "default",
schema: mysqlSchema, schema: mysqlSchema,
casing: "snake_case",
}); });
await migrate(db, { migrationsFolder }); await migrate(db, { migrationsFolder });

View File

@@ -0,0 +1,102 @@
ALTER TABLE `iconRepository` RENAME COLUMN "iconRepository_id" TO "id";--> statement-breakpoint
ALTER TABLE `iconRepository` RENAME COLUMN "iconRepository_slug" TO "slug";--> statement-breakpoint
ALTER TABLE `serverSetting` RENAME COLUMN "key" TO "setting_key";--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_account` (
`user_id` text NOT NULL,
`type` text NOT NULL,
`provider` text NOT NULL,
`provider_account_id` text NOT NULL,
`refresh_token` text,
`access_token` text,
`expires_at` integer,
`token_type` text,
`scope` text,
`id_token` text,
`session_state` text,
PRIMARY KEY(`provider`, `provider_account_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_account`("user_id", "type", "provider", "provider_account_id", "refresh_token", "access_token", "expires_at", "token_type", "scope", "id_token", "session_state") SELECT "userId", "type", "provider", "providerAccountId", "refresh_token", "access_token", "expires_at", "token_type", "scope", "id_token", "session_state" FROM `account`;--> statement-breakpoint
DROP TABLE `account`;--> statement-breakpoint
ALTER TABLE `__new_account` RENAME TO `account`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `userId_idx` ON `account` (`user_id`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_apiKey` (
`id` text PRIMARY KEY NOT NULL,
`api_key` text NOT NULL,
`salt` text NOT NULL,
`user_id` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_apiKey`("id", "api_key", "salt", "user_id") SELECT "id", "apiKey", "salt", "userId" FROM `apiKey`;--> statement-breakpoint
DROP TABLE `apiKey`;--> statement-breakpoint
ALTER TABLE `__new_apiKey` RENAME TO `apiKey`;--> statement-breakpoint
CREATE TABLE `__new_groupMember` (
`group_id` text NOT NULL,
`user_id` text NOT NULL,
PRIMARY KEY(`group_id`, `user_id`),
FOREIGN KEY (`group_id`) REFERENCES `group`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_groupMember`("group_id", "user_id") SELECT "groupId", "userId" FROM `groupMember`;--> statement-breakpoint
DROP TABLE `groupMember`;--> statement-breakpoint
ALTER TABLE `__new_groupMember` RENAME TO `groupMember`;--> statement-breakpoint
CREATE TABLE `__new_groupPermission` (
`group_id` text NOT NULL,
`permission` text NOT NULL,
FOREIGN KEY (`group_id`) REFERENCES `group`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_groupPermission`("group_id", "permission") SELECT "groupId", "permission" FROM `groupPermission`;--> statement-breakpoint
DROP TABLE `groupPermission`;--> statement-breakpoint
ALTER TABLE `__new_groupPermission` RENAME TO `groupPermission`;--> statement-breakpoint
CREATE TABLE `__new_icon` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`url` text NOT NULL,
`checksum` text NOT NULL,
`icon_repository_id` text NOT NULL,
FOREIGN KEY (`icon_repository_id`) REFERENCES `iconRepository`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_icon`("id", "name", "url", "checksum", "icon_repository_id") SELECT "icon_id", "icon_name", "icon_url", "icon_checksum", "iconRepository_id" FROM `icon`;--> statement-breakpoint
DROP TABLE `icon`;--> statement-breakpoint
ALTER TABLE `__new_icon` RENAME TO `icon`;--> statement-breakpoint
DROP INDEX IF EXISTS `serverSetting_key_unique`;--> statement-breakpoint
CREATE UNIQUE INDEX `serverSetting_settingKey_unique` ON `serverSetting` (`setting_key`);--> statement-breakpoint
CREATE TABLE `__new_session` (
`session_token` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`expires` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_session`("session_token", "user_id", "expires") SELECT "sessionToken", "userId", "expires" FROM `session`;--> statement-breakpoint
DROP TABLE `session`;--> statement-breakpoint
ALTER TABLE `__new_session` RENAME TO `session`;--> statement-breakpoint
CREATE INDEX `user_id_idx` ON `session` (`user_id`);--> statement-breakpoint
CREATE TABLE `__new_user` (
`id` text PRIMARY KEY NOT NULL,
`name` text,
`email` text,
`email_verified` integer,
`image` text,
`password` text,
`salt` text,
`provider` text DEFAULT 'credentials' NOT NULL,
`home_board_id` text,
`color_scheme` text DEFAULT 'dark' NOT NULL,
`first_day_of_week` integer DEFAULT 1 NOT NULL,
`ping_icons_enabled` integer DEFAULT false NOT NULL,
FOREIGN KEY (`home_board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
INSERT INTO `__new_user`("id", "name", "email", "email_verified", "image", "password", "salt", "provider", "home_board_id", "color_scheme", "first_day_of_week", "ping_icons_enabled") 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;

File diff suppressed because it is too large Load Diff

View File

@@ -113,6 +113,13 @@
"when": 1730653336134, "when": 1730653336134,
"tag": "0015_superb_psylocke", "tag": "0015_superb_psylocke",
"breakpoints": true "breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1732210918783,
"tag": "0016_change_all_to_snake_case",
"breakpoints": true
} }
] ]
} }

View File

@@ -10,7 +10,7 @@ const migrationsFolder = process.argv[2] ?? ".";
const migrateAsync = async () => { const migrateAsync = async () => {
const sqlite = new Database(process.env.DB_URL?.replace("file:", "")); const sqlite = new Database(process.env.DB_URL?.replace("file:", ""));
const db = drizzle(sqlite, { schema }); const db = drizzle(sqlite, { schema, casing: "snake_case" });
migrate(db, { migrationsFolder }); migrate(db, { migrationsFolder });

View File

@@ -9,7 +9,8 @@
"./client": "./client.ts", "./client": "./client.ts",
"./schema/sqlite": "./schema/sqlite.ts", "./schema/sqlite": "./schema/sqlite.ts",
"./test": "./test/index.ts", "./test": "./test/index.ts",
"./queries": "./queries/index.ts" "./queries": "./queries/index.ts",
"./validationSchemas": "./validationSchemas.ts"
}, },
"main": "./index.ts", "main": "./index.ts",
"types": "./index.ts", "types": "./index.ts",
@@ -42,11 +43,12 @@
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@paralleldrive/cuid2": "^2.2.2", "@paralleldrive/cuid2": "^2.2.2",
"@testcontainers/mysql": "^10.15.0", "@testcontainers/mysql": "^10.15.0",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.6.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"drizzle-kit": "^0.28.1", "drizzle-kit": "^0.28.1",
"drizzle-orm": "^0.36.4", "drizzle-orm": "^0.36.4",
"mysql2": "3.11.4" "drizzle-zod": "^0.5.1",
"mysql2": "3.11.5"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
@@ -55,8 +57,8 @@
"@types/better-sqlite3": "7.6.12", "@types/better-sqlite3": "7.6.12",
"dotenv-cli": "^7.4.4", "dotenv-cli": "^7.4.4",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"prettier": "^3.3.3", "prettier": "^3.4.1",
"tsx": "4.19.2", "tsx": "4.19.2",
"typescript": "^5.6.3" "typescript": "^5.7.2"
} }
} }

View File

@@ -39,10 +39,10 @@ const customBlob = customType<{ data: Buffer }>({
}); });
export const apiKeys = mysqlTable("apiKey", { export const apiKeys = mysqlTable("apiKey", {
id: varchar("id", { length: 64 }).notNull().primaryKey(), id: varchar({ length: 64 }).notNull().primaryKey(),
apiKey: text("apiKey").notNull(), apiKey: text().notNull(),
salt: text("salt").notNull(), salt: text().notNull(),
userId: varchar("userId", { length: 64 }) userId: varchar({ length: 64 })
.notNull() .notNull()
.references((): AnyMySqlColumn => users.id, { .references((): AnyMySqlColumn => users.id, {
onDelete: "cascade", onDelete: "cascade",
@@ -50,38 +50,38 @@ export const apiKeys = mysqlTable("apiKey", {
}); });
export const users = mysqlTable("user", { export const users = mysqlTable("user", {
id: varchar("id", { length: 64 }).notNull().primaryKey(), id: varchar({ length: 64 }).notNull().primaryKey(),
name: text("name"), name: text(),
email: text("email"), email: text(),
emailVerified: timestamp("emailVerified"), emailVerified: timestamp(),
image: text("image"), image: text(),
password: text("password"), password: text(),
salt: text("salt"), salt: text(),
provider: varchar("provider", { length: 64 }).$type<SupportedAuthProvider>().default("credentials").notNull(), provider: varchar({ length: 64 }).$type<SupportedAuthProvider>().default("credentials").notNull(),
homeBoardId: varchar("homeBoardId", { length: 64 }).references((): AnyMySqlColumn => boards.id, { homeBoardId: varchar({ length: 64 }).references((): AnyMySqlColumn => boards.id, {
onDelete: "set null", onDelete: "set null",
}), }),
colorScheme: varchar("colorScheme", { length: 5 }).$type<ColorScheme>().default("dark").notNull(), colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: tinyint("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean("pingIconsEnabled").default(false).notNull(), pingIconsEnabled: boolean().default(false).notNull(),
}); });
export const accounts = mysqlTable( export const accounts = mysqlTable(
"account", "account",
{ {
userId: varchar("userId", { length: 64 }) userId: varchar({ length: 64 })
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
type: text("type").$type<AdapterAccount["type"]>().notNull(), type: text().$type<AdapterAccount["type"]>().notNull(),
provider: varchar("provider", { length: 64 }).notNull(), provider: varchar({ length: 64 }).notNull(),
providerAccountId: varchar("providerAccountId", { length: 64 }).notNull(), providerAccountId: varchar({ length: 64 }).notNull(),
refresh_token: text("refresh_token"), refresh_token: text(),
access_token: text("access_token"), access_token: text(),
expires_at: int("expires_at"), expires_at: int(),
token_type: text("token_type"), token_type: text(),
scope: text("scope"), scope: text(),
id_token: text("id_token"), id_token: text(),
session_state: text("session_state"), session_state: text(),
}, },
(account) => ({ (account) => ({
compoundKey: primaryKey({ compoundKey: primaryKey({
@@ -94,11 +94,11 @@ export const accounts = mysqlTable(
export const sessions = mysqlTable( export const sessions = mysqlTable(
"session", "session",
{ {
sessionToken: varchar("sessionToken", { length: 512 }).notNull().primaryKey(), sessionToken: varchar({ length: 512 }).notNull().primaryKey(),
userId: varchar("userId", { length: 64 }) userId: varchar({ length: 64 })
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
expires: timestamp("expires").notNull(), expires: timestamp().notNull(),
}, },
(session) => ({ (session) => ({
userIdIdx: index("user_id_idx").on(session.userId), userIdIdx: index("user_id_idx").on(session.userId),
@@ -108,9 +108,9 @@ export const sessions = mysqlTable(
export const verificationTokens = mysqlTable( export const verificationTokens = mysqlTable(
"verificationToken", "verificationToken",
{ {
identifier: varchar("identifier", { length: 64 }).notNull(), identifier: varchar({ length: 64 }).notNull(),
token: varchar("token", { length: 512 }).notNull(), token: varchar({ length: 512 }).notNull(),
expires: timestamp("expires").notNull(), expires: timestamp().notNull(),
}, },
(verificationToken) => ({ (verificationToken) => ({
compoundKey: primaryKey({ compoundKey: primaryKey({
@@ -122,10 +122,10 @@ export const verificationTokens = mysqlTable(
export const groupMembers = mysqlTable( export const groupMembers = mysqlTable(
"groupMember", "groupMember",
{ {
groupId: varchar("groupId", { length: 64 }) groupId: varchar({ length: 64 })
.notNull() .notNull()
.references(() => groups.id, { onDelete: "cascade" }), .references(() => groups.id, { onDelete: "cascade" }),
userId: varchar("userId", { length: 64 }) userId: varchar({ length: 64 })
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
}, },
@@ -137,46 +137,46 @@ export const groupMembers = mysqlTable(
); );
export const groups = mysqlTable("group", { export const groups = mysqlTable("group", {
id: varchar("id", { length: 64 }).notNull().primaryKey(), id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar("name", { length: 64 }).unique().notNull(), name: varchar({ length: 64 }).unique().notNull(),
ownerId: varchar("owner_id", { length: 64 }).references(() => users.id, { ownerId: varchar({ length: 64 }).references(() => users.id, {
onDelete: "set null", onDelete: "set null",
}), }),
}); });
export const groupPermissions = mysqlTable("groupPermission", { export const groupPermissions = mysqlTable("groupPermission", {
groupId: varchar("groupId", { length: 64 }) groupId: varchar({ length: 64 })
.notNull() .notNull()
.references(() => groups.id, { onDelete: "cascade" }), .references(() => groups.id, { onDelete: "cascade" }),
permission: text("permission").$type<GroupPermissionKey>().notNull(), permission: text().$type<GroupPermissionKey>().notNull(),
}); });
export const invites = mysqlTable("invite", { export const invites = mysqlTable("invite", {
id: varchar("id", { length: 64 }).notNull().primaryKey(), id: varchar({ length: 64 }).notNull().primaryKey(),
token: varchar("token", { length: 512 }).notNull().unique(), token: varchar({ length: 512 }).notNull().unique(),
expirationDate: timestamp("expiration_date").notNull(), expirationDate: timestamp().notNull(),
creatorId: varchar("creator_id", { length: 64 }) creatorId: varchar({ length: 64 })
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
}); });
export const medias = mysqlTable("media", { export const medias = mysqlTable("media", {
id: varchar("id", { length: 64 }).notNull().primaryKey(), id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar("name", { length: 512 }).notNull(), name: varchar({ length: 512 }).notNull(),
content: customBlob("content").notNull(), content: customBlob().notNull(),
contentType: text("content_type").notNull(), contentType: text().notNull(),
size: int("size").notNull(), size: int().notNull(),
createdAt: timestamp("created_at", { mode: "date" }).notNull().defaultNow(), createdAt: timestamp({ mode: "date" }).notNull().defaultNow(),
creatorId: varchar("creator_id", { length: 64 }).references(() => users.id, { onDelete: "set null" }), creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
}); });
export const integrations = mysqlTable( export const integrations = mysqlTable(
"integration", "integration",
{ {
id: varchar("id", { length: 64 }).notNull().primaryKey(), id: varchar({ length: 64 }).notNull().primaryKey(),
name: text("name").notNull(), name: text().notNull(),
url: text("url").notNull(), url: text().notNull(),
kind: varchar("kind", { length: 128 }).$type<IntegrationKind>().notNull(), kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
}, },
(integrations) => ({ (integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind), kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -186,12 +186,12 @@ export const integrations = mysqlTable(
export const integrationSecrets = mysqlTable( export const integrationSecrets = mysqlTable(
"integrationSecret", "integrationSecret",
{ {
kind: varchar("kind", { length: 16 }).$type<IntegrationSecretKind>().notNull(), kind: varchar({ length: 16 }).$type<IntegrationSecretKind>().notNull(),
value: text("value").$type<`${string}.${string}`>().notNull(), value: text().$type<`${string}.${string}`>().notNull(),
updatedAt: timestamp("updated_at") updatedAt: timestamp()
.$onUpdateFn(() => new Date()) .$onUpdateFn(() => new Date())
.notNull(), .notNull(),
integrationId: varchar("integration_id", { length: 64 }) integrationId: varchar({ length: 64 })
.notNull() .notNull()
.references(() => integrations.id, { onDelete: "cascade" }), .references(() => integrations.id, { onDelete: "cascade" }),
}, },
@@ -207,13 +207,13 @@ export const integrationSecrets = mysqlTable(
export const integrationUserPermissions = mysqlTable( export const integrationUserPermissions = mysqlTable(
"integrationUserPermission", "integrationUserPermission",
{ {
integrationId: varchar("integration_id", { length: 64 }) integrationId: varchar({ length: 64 })
.notNull() .notNull()
.references(() => integrations.id, { onDelete: "cascade" }), .references(() => integrations.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 64 }) userId: varchar({ length: 64 })
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
permission: varchar("permission", { length: 128 }).$type<IntegrationPermission>().notNull(), permission: varchar({ length: 128 }).$type<IntegrationPermission>().notNull(),
}, },
(table) => ({ (table) => ({
compoundKey: primaryKey({ compoundKey: primaryKey({
@@ -225,13 +225,13 @@ export const integrationUserPermissions = mysqlTable(
export const integrationGroupPermissions = mysqlTable( export const integrationGroupPermissions = mysqlTable(
"integrationGroupPermissions", "integrationGroupPermissions",
{ {
integrationId: varchar("integration_id", { length: 64 }) integrationId: varchar({ length: 64 })
.notNull() .notNull()
.references(() => integrations.id, { onDelete: "cascade" }), .references(() => integrations.id, { onDelete: "cascade" }),
groupId: varchar("group_id", { length: 64 }) groupId: varchar({ length: 64 })
.notNull() .notNull()
.references(() => groups.id, { onDelete: "cascade" }), .references(() => groups.id, { onDelete: "cascade" }),
permission: varchar("permission", { length: 128 }).$type<IntegrationPermission>().notNull(), permission: varchar({ length: 128 }).$type<IntegrationPermission>().notNull(),
}, },
(table) => ({ (table) => ({
compoundKey: primaryKey({ compoundKey: primaryKey({
@@ -242,46 +242,40 @@ export const integrationGroupPermissions = mysqlTable(
); );
export const boards = mysqlTable("board", { export const boards = mysqlTable("board", {
id: varchar("id", { length: 64 }).notNull().primaryKey(), id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar("name", { length: 256 }).unique().notNull(), name: varchar({ length: 256 }).unique().notNull(),
isPublic: boolean("is_public").default(false).notNull(), isPublic: boolean().default(false).notNull(),
creatorId: varchar("creator_id", { length: 64 }).references(() => users.id, { creatorId: varchar({ length: 64 }).references(() => users.id, {
onDelete: "set null", onDelete: "set null",
}), }),
pageTitle: text("page_title"), pageTitle: text(),
metaTitle: text("meta_title"), metaTitle: text(),
logoImageUrl: text("logo_image_url"), logoImageUrl: text(),
faviconImageUrl: text("favicon_image_url"), faviconImageUrl: text(),
backgroundImageUrl: text("background_image_url"), backgroundImageUrl: text(),
backgroundImageAttachment: text("background_image_attachment") backgroundImageAttachment: text()
.$type<BackgroundImageAttachment>() .$type<BackgroundImageAttachment>()
.default(backgroundImageAttachments.defaultValue) .default(backgroundImageAttachments.defaultValue)
.notNull(), .notNull(),
backgroundImageRepeat: text("background_image_repeat") backgroundImageRepeat: text().$type<BackgroundImageRepeat>().default(backgroundImageRepeats.defaultValue).notNull(),
.$type<BackgroundImageRepeat>() backgroundImageSize: text().$type<BackgroundImageSize>().default(backgroundImageSizes.defaultValue).notNull(),
.default(backgroundImageRepeats.defaultValue) primaryColor: text().default("#fa5252").notNull(),
.notNull(), secondaryColor: text().default("#fd7e14").notNull(),
backgroundImageSize: text("background_image_size") opacity: int().default(100).notNull(),
.$type<BackgroundImageSize>() customCss: text(),
.default(backgroundImageSizes.defaultValue) columnCount: int().default(10).notNull(),
.notNull(),
primaryColor: text("primary_color").default("#fa5252").notNull(),
secondaryColor: text("secondary_color").default("#fd7e14").notNull(),
opacity: int("opacity").default(100).notNull(),
customCss: text("custom_css"),
columnCount: int("column_count").default(10).notNull(),
}); });
export const boardUserPermissions = mysqlTable( export const boardUserPermissions = mysqlTable(
"boardUserPermission", "boardUserPermission",
{ {
boardId: varchar("board_id", { length: 64 }) boardId: varchar({ length: 64 })
.notNull() .notNull()
.references(() => boards.id, { onDelete: "cascade" }), .references(() => boards.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 64 }) userId: varchar({ length: 64 })
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
permission: varchar("permission", { length: 128 }).$type<BoardPermission>().notNull(), permission: varchar({ length: 128 }).$type<BoardPermission>().notNull(),
}, },
(table) => ({ (table) => ({
compoundKey: primaryKey({ compoundKey: primaryKey({
@@ -293,13 +287,13 @@ export const boardUserPermissions = mysqlTable(
export const boardGroupPermissions = mysqlTable( export const boardGroupPermissions = mysqlTable(
"boardGroupPermission", "boardGroupPermission",
{ {
boardId: varchar("board_id", { length: 64 }) boardId: varchar({ length: 64 })
.notNull() .notNull()
.references(() => boards.id, { onDelete: "cascade" }), .references(() => boards.id, { onDelete: "cascade" }),
groupId: varchar("group_id", { length: 64 }) groupId: varchar({ length: 64 })
.notNull() .notNull()
.references(() => groups.id, { onDelete: "cascade" }), .references(() => groups.id, { onDelete: "cascade" }),
permission: varchar("permission", { length: 128 }).$type<BoardPermission>().notNull(), permission: varchar({ length: 128 }).$type<BoardPermission>().notNull(),
}, },
(table) => ({ (table) => ({
compoundKey: primaryKey({ compoundKey: primaryKey({
@@ -309,50 +303,50 @@ export const boardGroupPermissions = mysqlTable(
); );
export const sections = mysqlTable("section", { export const sections = mysqlTable("section", {
id: varchar("id", { length: 64 }).notNull().primaryKey(), id: varchar({ length: 64 }).notNull().primaryKey(),
boardId: varchar("board_id", { length: 64 }) boardId: varchar({ length: 64 })
.notNull() .notNull()
.references(() => boards.id, { onDelete: "cascade" }), .references(() => boards.id, { onDelete: "cascade" }),
kind: text("kind").$type<SectionKind>().notNull(), kind: text().$type<SectionKind>().notNull(),
xOffset: int("x_offset").notNull(), xOffset: int().notNull(),
yOffset: int("y_offset").notNull(), yOffset: int().notNull(),
width: int("width"), width: int(),
height: int("height"), height: int(),
name: text("name"), name: text(),
parentSectionId: varchar("parent_section_id", { length: 64 }).references((): AnyMySqlColumn => sections.id, { parentSectionId: varchar({ length: 64 }).references((): AnyMySqlColumn => sections.id, {
onDelete: "cascade", onDelete: "cascade",
}), }),
}); });
export const items = mysqlTable("item", { export const items = mysqlTable("item", {
id: varchar("id", { length: 64 }).notNull().primaryKey(), id: varchar({ length: 64 }).notNull().primaryKey(),
sectionId: varchar("section_id", { length: 64 }) sectionId: varchar({ length: 64 })
.notNull() .notNull()
.references(() => sections.id, { onDelete: "cascade" }), .references(() => sections.id, { onDelete: "cascade" }),
kind: text("kind").$type<WidgetKind>().notNull(), kind: text().$type<WidgetKind>().notNull(),
xOffset: int("x_offset").notNull(), xOffset: int().notNull(),
yOffset: int("y_offset").notNull(), yOffset: int().notNull(),
width: int("width").notNull(), width: int().notNull(),
height: int("height").notNull(), height: int().notNull(),
options: text("options").default('{"json": {}}').notNull(), // empty superjson object options: text().default('{"json": {}}').notNull(), // empty superjson object
advancedOptions: text("advanced_options").default('{"json": {}}').notNull(), // empty superjson object advancedOptions: text().default('{"json": {}}').notNull(), // empty superjson object
}); });
export const apps = mysqlTable("app", { export const apps = mysqlTable("app", {
id: varchar("id", { length: 64 }).notNull().primaryKey(), id: varchar({ length: 64 }).notNull().primaryKey(),
name: text("name").notNull(), name: text().notNull(),
description: text("description"), description: text(),
iconUrl: text("icon_url").notNull(), iconUrl: text().notNull(),
href: text("href"), href: text(),
}); });
export const integrationItems = mysqlTable( export const integrationItems = mysqlTable(
"integration_item", "integration_item",
{ {
itemId: varchar("item_id", { length: 64 }) itemId: varchar({ length: 64 })
.notNull() .notNull()
.references(() => items.id, { onDelete: "cascade" }), .references(() => items.id, { onDelete: "cascade" }),
integrationId: varchar("integration_id", { length: 64 }) integrationId: varchar({ length: 64 })
.notNull() .notNull()
.references(() => integrations.id, { onDelete: "cascade" }), .references(() => integrations.id, { onDelete: "cascade" }),
}, },
@@ -364,23 +358,23 @@ export const integrationItems = mysqlTable(
); );
export const icons = mysqlTable("icon", { export const icons = mysqlTable("icon", {
id: varchar("icon_id", { length: 64 }).notNull().primaryKey(), id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar("icon_name", { length: 250 }).notNull(), name: varchar({ length: 250 }).notNull(),
url: text("icon_url").notNull(), url: text().notNull(),
checksum: text("icon_checksum").notNull(), checksum: text().notNull(),
iconRepositoryId: varchar("iconRepository_id", { length: 64 }) iconRepositoryId: varchar({ length: 64 })
.notNull() .notNull()
.references(() => iconRepositories.id, { onDelete: "cascade" }), .references(() => iconRepositories.id, { onDelete: "cascade" }),
}); });
export const iconRepositories = mysqlTable("iconRepository", { export const iconRepositories = mysqlTable("iconRepository", {
id: varchar("iconRepository_id", { length: 64 }).notNull().primaryKey(), id: varchar({ length: 64 }).notNull().primaryKey(),
slug: varchar("iconRepository_slug", { length: 150 }).notNull(), slug: varchar({ length: 150 }).notNull(),
}); });
export const serverSettings = mysqlTable("serverSetting", { export const serverSettings = mysqlTable("serverSetting", {
settingKey: varchar("key", { length: 64 }).notNull().unique().primaryKey(), settingKey: varchar({ length: 64 }).notNull().unique().primaryKey(),
value: text("value").default('{"json": {}}').notNull(), // empty superjson object value: text().default('{"json": {}}').notNull(), // empty superjson object
}); });
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({ export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
@@ -391,14 +385,14 @@ export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
})); }));
export const searchEngines = mysqlTable("search_engine", { export const searchEngines = mysqlTable("search_engine", {
id: varchar("id", { length: 64 }).notNull().primaryKey(), id: varchar({ length: 64 }).notNull().primaryKey(),
iconUrl: text("icon_url").notNull(), iconUrl: text().notNull(),
name: varchar("name", { length: 64 }).notNull(), name: varchar({ length: 64 }).notNull(),
short: varchar("short", { length: 8 }).notNull(), short: varchar({ length: 8 }).notNull(),
description: text("description"), description: text(),
urlTemplate: text("url_template"), urlTemplate: text(),
type: varchar("type", { length: 64 }).$type<SearchEngineType>().notNull().default("generic"), type: varchar({ length: 64 }).$type<SearchEngineType>().notNull().default("generic"),
integrationId: varchar("integration_id", { length: 64 }).references(() => integrations.id, { onDelete: "cascade" }), integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "cascade" }),
}); });
export const accountRelations = relations(accounts, ({ one }) => ({ export const accountRelations = relations(accounts, ({ one }) => ({

View File

@@ -3,7 +3,7 @@ import type { DayOfWeek } from "@mantine/dates";
import type { InferSelectModel } from "drizzle-orm"; import type { InferSelectModel } from "drizzle-orm";
import { relations, sql } from "drizzle-orm"; import { relations, sql } from "drizzle-orm";
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core"; import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
import { blob, index, int, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { blob, index, int, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions"; import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
import type { import type {
@@ -23,10 +23,10 @@ import type {
} from "@homarr/definitions"; } from "@homarr/definitions";
export const apiKeys = sqliteTable("apiKey", { export const apiKeys = sqliteTable("apiKey", {
id: text("id").notNull().primaryKey(), id: text().notNull().primaryKey(),
apiKey: text("apiKey").notNull(), apiKey: text().notNull(),
salt: text("salt").notNull(), salt: text().notNull(),
userId: text("userId") userId: text()
.notNull() .notNull()
.references((): AnySQLiteColumn => users.id, { .references((): AnySQLiteColumn => users.id, {
onDelete: "cascade", onDelete: "cascade",
@@ -34,38 +34,38 @@ export const apiKeys = sqliteTable("apiKey", {
}); });
export const users = sqliteTable("user", { export const users = sqliteTable("user", {
id: text("id").notNull().primaryKey(), id: text().notNull().primaryKey(),
name: text("name"), name: text(),
email: text("email"), email: text(),
emailVerified: integer("emailVerified", { mode: "timestamp_ms" }), emailVerified: int({ mode: "timestamp_ms" }),
image: text("image"), image: text(),
password: text("password"), password: text(),
salt: text("salt"), salt: text(),
provider: text("provider").$type<SupportedAuthProvider>().default("credentials").notNull(), provider: text().$type<SupportedAuthProvider>().default("credentials").notNull(),
homeBoardId: text("homeBoardId").references((): AnySQLiteColumn => boards.id, { homeBoardId: text().references((): AnySQLiteColumn => boards.id, {
onDelete: "set null", onDelete: "set null",
}), }),
colorScheme: text("colorScheme").$type<ColorScheme>().default("dark").notNull(), colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: int("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: int("pingIconsEnabled", { mode: "boolean" }).default(false).notNull(), pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
}); });
export const accounts = sqliteTable( export const accounts = sqliteTable(
"account", "account",
{ {
userId: text("userId") userId: text()
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
type: text("type").$type<AdapterAccount["type"]>().notNull(), type: text().$type<AdapterAccount["type"]>().notNull(),
provider: text("provider").notNull(), provider: text().notNull(),
providerAccountId: text("providerAccountId").notNull(), providerAccountId: text().notNull(),
refresh_token: text("refresh_token"), refresh_token: text(),
access_token: text("access_token"), access_token: text(),
expires_at: integer("expires_at"), expires_at: int(),
token_type: text("token_type"), token_type: text(),
scope: text("scope"), scope: text(),
id_token: text("id_token"), id_token: text(),
session_state: text("session_state"), session_state: text(),
}, },
(account) => ({ (account) => ({
compoundKey: primaryKey({ compoundKey: primaryKey({
@@ -78,11 +78,11 @@ export const accounts = sqliteTable(
export const sessions = sqliteTable( export const sessions = sqliteTable(
"session", "session",
{ {
sessionToken: text("sessionToken").notNull().primaryKey(), sessionToken: text().notNull().primaryKey(),
userId: text("userId") userId: text()
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
expires: integer("expires", { mode: "timestamp_ms" }).notNull(), expires: int({ mode: "timestamp_ms" }).notNull(),
}, },
(session) => ({ (session) => ({
userIdIdx: index("user_id_idx").on(session.userId), userIdIdx: index("user_id_idx").on(session.userId),
@@ -92,9 +92,9 @@ export const sessions = sqliteTable(
export const verificationTokens = sqliteTable( export const verificationTokens = sqliteTable(
"verificationToken", "verificationToken",
{ {
identifier: text("identifier").notNull(), identifier: text().notNull(),
token: text("token").notNull(), token: text().notNull(),
expires: integer("expires", { mode: "timestamp_ms" }).notNull(), expires: int({ mode: "timestamp_ms" }).notNull(),
}, },
(verificationToken) => ({ (verificationToken) => ({
compoundKey: primaryKey({ compoundKey: primaryKey({
@@ -106,10 +106,10 @@ export const verificationTokens = sqliteTable(
export const groupMembers = sqliteTable( export const groupMembers = sqliteTable(
"groupMember", "groupMember",
{ {
groupId: text("groupId") groupId: text()
.notNull() .notNull()
.references(() => groups.id, { onDelete: "cascade" }), .references(() => groups.id, { onDelete: "cascade" }),
userId: text("userId") userId: text()
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
}, },
@@ -121,50 +121,50 @@ export const groupMembers = sqliteTable(
); );
export const groups = sqliteTable("group", { export const groups = sqliteTable("group", {
id: text("id").notNull().primaryKey(), id: text().notNull().primaryKey(),
name: text("name").unique().notNull(), name: text().unique().notNull(),
ownerId: text("owner_id").references(() => users.id, { ownerId: text().references(() => users.id, {
onDelete: "set null", onDelete: "set null",
}), }),
}); });
export const groupPermissions = sqliteTable("groupPermission", { export const groupPermissions = sqliteTable("groupPermission", {
groupId: text("groupId") groupId: text()
.notNull() .notNull()
.references(() => groups.id, { onDelete: "cascade" }), .references(() => groups.id, { onDelete: "cascade" }),
permission: text("permission").$type<GroupPermissionKey>().notNull(), permission: text().$type<GroupPermissionKey>().notNull(),
}); });
export const invites = sqliteTable("invite", { export const invites = sqliteTable("invite", {
id: text("id").notNull().primaryKey(), id: text().notNull().primaryKey(),
token: text("token").notNull().unique(), token: text().notNull().unique(),
expirationDate: int("expiration_date", { expirationDate: int({
mode: "timestamp", mode: "timestamp",
}).notNull(), }).notNull(),
creatorId: text("creator_id") creatorId: text()
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
}); });
export const medias = sqliteTable("media", { export const medias = sqliteTable("media", {
id: text("id").notNull().primaryKey(), id: text().notNull().primaryKey(),
name: text("name").notNull(), name: text().notNull(),
content: blob("content", { mode: "buffer" }).$type<Buffer>().notNull(), content: blob({ mode: "buffer" }).$type<Buffer>().notNull(),
contentType: text("content_type").notNull(), contentType: text().notNull(),
size: int("size").notNull(), size: int().notNull(),
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: int({ mode: "timestamp" })
.notNull() .notNull()
.default(sql`(unixepoch())`), .default(sql`(unixepoch())`),
creatorId: text("creator_id").references(() => users.id, { onDelete: "set null" }), creatorId: text().references(() => users.id, { onDelete: "set null" }),
}); });
export const integrations = sqliteTable( export const integrations = sqliteTable(
"integration", "integration",
{ {
id: text("id").notNull().primaryKey(), id: text().notNull().primaryKey(),
name: text("name").notNull(), name: text().notNull(),
url: text("url").notNull(), url: text().notNull(),
kind: text("kind").$type<IntegrationKind>().notNull(), kind: text().$type<IntegrationKind>().notNull(),
}, },
(integrations) => ({ (integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind), kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -174,12 +174,12 @@ export const integrations = sqliteTable(
export const integrationSecrets = sqliteTable( export const integrationSecrets = sqliteTable(
"integrationSecret", "integrationSecret",
{ {
kind: text("kind").$type<IntegrationSecretKind>().notNull(), kind: text().$type<IntegrationSecretKind>().notNull(),
value: text("value").$type<`${string}.${string}`>().notNull(), value: text().$type<`${string}.${string}`>().notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }) updatedAt: int({ mode: "timestamp" })
.$onUpdateFn(() => new Date()) .$onUpdateFn(() => new Date())
.notNull(), .notNull(),
integrationId: text("integration_id") integrationId: text()
.notNull() .notNull()
.references(() => integrations.id, { onDelete: "cascade" }), .references(() => integrations.id, { onDelete: "cascade" }),
}, },
@@ -195,13 +195,13 @@ export const integrationSecrets = sqliteTable(
export const integrationUserPermissions = sqliteTable( export const integrationUserPermissions = sqliteTable(
"integrationUserPermission", "integrationUserPermission",
{ {
integrationId: text("integration_id") integrationId: text()
.notNull() .notNull()
.references(() => integrations.id, { onDelete: "cascade" }), .references(() => integrations.id, { onDelete: "cascade" }),
userId: text("user_id") userId: text()
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
permission: text("permission").$type<IntegrationPermission>().notNull(), permission: text().$type<IntegrationPermission>().notNull(),
}, },
(table) => ({ (table) => ({
compoundKey: primaryKey({ compoundKey: primaryKey({
@@ -213,13 +213,13 @@ export const integrationUserPermissions = sqliteTable(
export const integrationGroupPermissions = sqliteTable( export const integrationGroupPermissions = sqliteTable(
"integrationGroupPermissions", "integrationGroupPermissions",
{ {
integrationId: text("integration_id") integrationId: text()
.notNull() .notNull()
.references(() => integrations.id, { onDelete: "cascade" }), .references(() => integrations.id, { onDelete: "cascade" }),
groupId: text("group_id") groupId: text()
.notNull() .notNull()
.references(() => groups.id, { onDelete: "cascade" }), .references(() => groups.id, { onDelete: "cascade" }),
permission: text("permission").$type<IntegrationPermission>().notNull(), permission: text().$type<IntegrationPermission>().notNull(),
}, },
(table) => ({ (table) => ({
compoundKey: primaryKey({ compoundKey: primaryKey({
@@ -229,46 +229,40 @@ export const integrationGroupPermissions = sqliteTable(
); );
export const boards = sqliteTable("board", { export const boards = sqliteTable("board", {
id: text("id").notNull().primaryKey(), id: text().notNull().primaryKey(),
name: text("name").unique().notNull(), name: text().unique().notNull(),
isPublic: int("is_public", { mode: "boolean" }).default(false).notNull(), isPublic: int({ mode: "boolean" }).default(false).notNull(),
creatorId: text("creator_id").references(() => users.id, { creatorId: text().references(() => users.id, {
onDelete: "set null", onDelete: "set null",
}), }),
pageTitle: text("page_title"), pageTitle: text(),
metaTitle: text("meta_title"), metaTitle: text(),
logoImageUrl: text("logo_image_url"), logoImageUrl: text(),
faviconImageUrl: text("favicon_image_url"), faviconImageUrl: text(),
backgroundImageUrl: text("background_image_url"), backgroundImageUrl: text(),
backgroundImageAttachment: text("background_image_attachment") backgroundImageAttachment: text()
.$type<BackgroundImageAttachment>() .$type<BackgroundImageAttachment>()
.default(backgroundImageAttachments.defaultValue) .default(backgroundImageAttachments.defaultValue)
.notNull(), .notNull(),
backgroundImageRepeat: text("background_image_repeat") backgroundImageRepeat: text().$type<BackgroundImageRepeat>().default(backgroundImageRepeats.defaultValue).notNull(),
.$type<BackgroundImageRepeat>() backgroundImageSize: text().$type<BackgroundImageSize>().default(backgroundImageSizes.defaultValue).notNull(),
.default(backgroundImageRepeats.defaultValue) primaryColor: text().default("#fa5252").notNull(),
.notNull(), secondaryColor: text().default("#fd7e14").notNull(),
backgroundImageSize: text("background_image_size") opacity: int().default(100).notNull(),
.$type<BackgroundImageSize>() customCss: text(),
.default(backgroundImageSizes.defaultValue) columnCount: int().default(10).notNull(),
.notNull(),
primaryColor: text("primary_color").default("#fa5252").notNull(),
secondaryColor: text("secondary_color").default("#fd7e14").notNull(),
opacity: int("opacity").default(100).notNull(),
customCss: text("custom_css"),
columnCount: int("column_count").default(10).notNull(),
}); });
export const boardUserPermissions = sqliteTable( export const boardUserPermissions = sqliteTable(
"boardUserPermission", "boardUserPermission",
{ {
boardId: text("board_id") boardId: text()
.notNull() .notNull()
.references(() => boards.id, { onDelete: "cascade" }), .references(() => boards.id, { onDelete: "cascade" }),
userId: text("user_id") userId: text()
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
permission: text("permission").$type<BoardPermission>().notNull(), permission: text().$type<BoardPermission>().notNull(),
}, },
(table) => ({ (table) => ({
compoundKey: primaryKey({ compoundKey: primaryKey({
@@ -280,13 +274,13 @@ export const boardUserPermissions = sqliteTable(
export const boardGroupPermissions = sqliteTable( export const boardGroupPermissions = sqliteTable(
"boardGroupPermission", "boardGroupPermission",
{ {
boardId: text("board_id") boardId: text()
.notNull() .notNull()
.references(() => boards.id, { onDelete: "cascade" }), .references(() => boards.id, { onDelete: "cascade" }),
groupId: text("group_id") groupId: text()
.notNull() .notNull()
.references(() => groups.id, { onDelete: "cascade" }), .references(() => groups.id, { onDelete: "cascade" }),
permission: text("permission").$type<BoardPermission>().notNull(), permission: text().$type<BoardPermission>().notNull(),
}, },
(table) => ({ (table) => ({
compoundKey: primaryKey({ compoundKey: primaryKey({
@@ -296,50 +290,50 @@ export const boardGroupPermissions = sqliteTable(
); );
export const sections = sqliteTable("section", { export const sections = sqliteTable("section", {
id: text("id").notNull().primaryKey(), id: text().notNull().primaryKey(),
boardId: text("board_id") boardId: text()
.notNull() .notNull()
.references(() => boards.id, { onDelete: "cascade" }), .references(() => boards.id, { onDelete: "cascade" }),
kind: text("kind").$type<SectionKind>().notNull(), kind: text().$type<SectionKind>().notNull(),
xOffset: int("x_offset").notNull(), xOffset: int().notNull(),
yOffset: int("y_offset").notNull(), yOffset: int().notNull(),
width: int("width"), width: int(),
height: int("height"), height: int(),
name: text("name"), name: text(),
parentSectionId: text("parent_section_id").references((): AnySQLiteColumn => sections.id, { parentSectionId: text().references((): AnySQLiteColumn => sections.id, {
onDelete: "cascade", onDelete: "cascade",
}), }),
}); });
export const items = sqliteTable("item", { export const items = sqliteTable("item", {
id: text("id").notNull().primaryKey(), id: text().notNull().primaryKey(),
sectionId: text("section_id") sectionId: text()
.notNull() .notNull()
.references(() => sections.id, { onDelete: "cascade" }), .references(() => sections.id, { onDelete: "cascade" }),
kind: text("kind").$type<WidgetKind>().notNull(), kind: text().$type<WidgetKind>().notNull(),
xOffset: int("x_offset").notNull(), xOffset: int().notNull(),
yOffset: int("y_offset").notNull(), yOffset: int().notNull(),
width: int("width").notNull(), width: int().notNull(),
height: int("height").notNull(), height: int().notNull(),
options: text("options").default('{"json": {}}').notNull(), // empty superjson object options: text().default('{"json": {}}').notNull(), // empty superjson object
advancedOptions: text("advanced_options").default('{"json": {}}').notNull(), // empty superjson object advancedOptions: text().default('{"json": {}}').notNull(), // empty superjson object
}); });
export const apps = sqliteTable("app", { export const apps = sqliteTable("app", {
id: text("id").notNull().primaryKey(), id: text().notNull().primaryKey(),
name: text("name").notNull(), name: text().notNull(),
description: text("description"), description: text(),
iconUrl: text("icon_url").notNull(), iconUrl: text().notNull(),
href: text("href"), href: text(),
}); });
export const integrationItems = sqliteTable( export const integrationItems = sqliteTable(
"integration_item", "integration_item",
{ {
itemId: text("item_id") itemId: text()
.notNull() .notNull()
.references(() => items.id, { onDelete: "cascade" }), .references(() => items.id, { onDelete: "cascade" }),
integrationId: text("integration_id") integrationId: text()
.notNull() .notNull()
.references(() => integrations.id, { onDelete: "cascade" }), .references(() => integrations.id, { onDelete: "cascade" }),
}, },
@@ -351,23 +345,23 @@ export const integrationItems = sqliteTable(
); );
export const icons = sqliteTable("icon", { export const icons = sqliteTable("icon", {
id: text("icon_id").notNull().primaryKey(), id: text().notNull().primaryKey(),
name: text("icon_name").notNull(), name: text().notNull(),
url: text("icon_url").notNull(), url: text().notNull(),
checksum: text("icon_checksum").notNull(), checksum: text().notNull(),
iconRepositoryId: text("iconRepository_id") iconRepositoryId: text()
.notNull() .notNull()
.references(() => iconRepositories.id, { onDelete: "cascade" }), .references(() => iconRepositories.id, { onDelete: "cascade" }),
}); });
export const iconRepositories = sqliteTable("iconRepository", { export const iconRepositories = sqliteTable("iconRepository", {
id: text("iconRepository_id").notNull().primaryKey(), id: text().notNull().primaryKey(),
slug: text("iconRepository_slug").notNull(), slug: text().notNull(),
}); });
export const serverSettings = sqliteTable("serverSetting", { export const serverSettings = sqliteTable("serverSetting", {
settingKey: text("key").notNull().unique().primaryKey(), settingKey: text().notNull().unique().primaryKey(),
value: text("value").default('{"json": {}}').notNull(), // empty superjson object value: text().default('{"json": {}}').notNull(), // empty superjson object
}); });
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({ export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
@@ -378,14 +372,14 @@ export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
})); }));
export const searchEngines = sqliteTable("search_engine", { export const searchEngines = sqliteTable("search_engine", {
id: text("id").notNull().primaryKey(), id: text().notNull().primaryKey(),
iconUrl: text("icon_url").notNull(), iconUrl: text().notNull(),
name: text("name").notNull(), name: text().notNull(),
short: text("short").notNull(), short: text().notNull(),
description: text("description"), description: text(),
urlTemplate: text("url_template"), urlTemplate: text(),
type: text("type").$type<SearchEngineType>().notNull().default("generic"), type: text().$type<SearchEngineType>().notNull().default("generic"),
integrationId: text("integration_id").references(() => integrations.id, { onDelete: "cascade" }), integrationId: text().references(() => integrations.id, { onDelete: "cascade" }),
}); });
export const accountRelations = relations(accounts, ({ one }) => ({ export const accountRelations = relations(accounts, ({ one }) => ({

View File

@@ -6,7 +6,7 @@ import { schema } from "..";
export const createDb = (debug?: boolean) => { export const createDb = (debug?: boolean) => {
const sqlite = new Database(":memory:"); const sqlite = new Database(":memory:");
const db = drizzle(sqlite, { schema, logger: debug }); const db = drizzle(sqlite, { schema, logger: debug, casing: "snake_case" });
migrate(db, { migrate(db, {
migrationsFolder: "./packages/db/migrations/sqlite", migrationsFolder: "./packages/db/migrations/sqlite",
}); });

View File

@@ -22,6 +22,7 @@ describe("Mysql Migration", () => {
const database = drizzle(connection, { const database = drizzle(connection, {
schema: mysqlSchema, schema: mysqlSchema,
mode: "default", mode: "default",
casing: "snake_case",
}); });
// Run migrations and check if it works // Run migrations and check if it works

View File

@@ -0,0 +1,11 @@
import { createSelectSchema } from "drizzle-zod";
import { apps, boards, groups, invites, searchEngines, serverSettings, users } from "./schema/sqlite";
export const selectAppSchema = createSelectSchema(apps);
export const selectBoardSchema = createSelectSchema(boards);
export const selectGroupSchema = createSelectSchema(groups);
export const selectInviteSchema = createSelectSchema(invites);
export const selectSearchEnginesSchema = createSelectSchema(searchEngines);
export const selectSeverSettingsSchema = createSelectSchema(serverSettings);
export const selectUserSchema = createSelectSchema(users);

View File

@@ -30,6 +30,6 @@
"@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.15.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.7.2"
} }
} }

View File

@@ -24,13 +24,13 @@
"dependencies": { "dependencies": {
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^7.14.1" "@mantine/form": "^7.14.3"
}, },
"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",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.7.2"
} }
} }

View File

@@ -31,6 +31,6 @@
"@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.15.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.7.2"
} }
} }

View File

@@ -42,6 +42,6 @@
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/xml2js": "^0.4.14", "@types/xml2js": "^0.4.14",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.7.2"
} }
} }

View File

@@ -1,11 +1,11 @@
export const radarrReleaseTypes = ["inCinemas", "digitalRelease", "physicalRelease"] as const; export const radarrReleaseTypes = ["inCinemas", "digitalRelease", "physicalRelease"] as const;
type ReleaseType = (typeof radarrReleaseTypes)[number]; export type RadarrReleaseType = (typeof radarrReleaseTypes)[number];
export interface CalendarEvent { export interface CalendarEvent {
name: string; name: string;
subName: string; subName: string;
date: Date; date: Date;
dates?: { type: ReleaseType; date: Date }[]; dates?: { type: RadarrReleaseType; date: Date }[];
description?: string; description?: string;
thumbnail?: string; thumbnail?: string;
mediaInformation?: { mediaInformation?: {

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