feat: add onboarding with oldmarr import (#1606)

This commit is contained in:
Meier Lukas
2024-12-15 15:40:26 +01:00
committed by GitHub
parent 82ec77d2da
commit 6de74d9525
108 changed files with 6045 additions and 312 deletions

View File

@@ -29,6 +29,7 @@
"@homarr/modals": "workspace:^0.1.0",
"@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/old-import": "workspace:^0.1.0",
"@homarr/old-schema": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
@@ -39,6 +40,7 @@
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.15.1",
"@mantine/core": "^7.15.1",
"@mantine/dropzone": "^7.15.1",
"@mantine/hooks": "^7.15.1",
"@mantine/modals": "^7.15.1",
"@mantine/tiptap": "^7.15.1",

View File

@@ -15,6 +15,7 @@ import {
wsLink,
} from "@trpc/client";
import superjson from "superjson";
import type { SuperJSONResult } from "superjson";
import type { AppRouter } from "@homarr/api";
import { clientApi, createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/client";
@@ -82,8 +83,8 @@ export function TRPCReactProvider(props: PropsWithChildren) {
serialize(object: unknown) {
return object;
},
deserialize(data: unknown) {
return data;
deserialize(data: SuperJSONResult) {
return superjson.deserialize<unknown>(data);
},
},
url: getTrpcUrl(),

View File

@@ -0,0 +1,23 @@
"use client";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useI18n } from "@homarr/translation/client";
export const BackToStart = () => {
const t = useI18n();
const { mutateAsync, isPending } = clientApi.onboard.previousStep.useMutation();
const handleBackToStartAsync = async () => {
await mutateAsync();
await revalidatePathActionAsync("/init");
};
return (
<Button loading={isPending} variant="subtle" color="gray" fullWidth onClick={handleBackToStartAsync}>
{t("init.backToStart")}
</Button>
);
};

View File

@@ -0,0 +1,87 @@
import Link from "next/link";
import type { MantineColor } from "@mantine/core";
import { Button, Card, Stack, Text } from "@mantine/core";
import { IconBook2, IconCategoryPlus, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
import { isProviderEnabled } from "@homarr/auth/server";
import { getMantineColor } from "@homarr/common";
import { db } from "@homarr/db";
import { createDocumentationLink } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import type { TablerIcon } from "@homarr/ui";
export const InitFinish = async () => {
const firstBoard = await db.query.boards.findFirst({ columns: { name: true } });
const tFinish = await getScopedI18n("init.step.finish");
return (
<Card w={64 * 6} maw="90vw" withBorder>
<Stack>
<Text>{tFinish("description")}</Text>
{firstBoard ? (
<InternalLinkButton
href={`/auth/login?callbackUrl=/boards/${firstBoard.name}`}
iconProps={{ icon: IconLayoutDashboard, color: "blue" }}
>
{tFinish("action.goToBoard", { name: firstBoard.name })}
</InternalLinkButton>
) : (
<InternalLinkButton
href="/auth/login?callbackUrl=/manage/boards"
iconProps={{ icon: IconCategoryPlus, color: "blue" }}
>
{tFinish("action.createBoard")}
</InternalLinkButton>
)}
{isProviderEnabled("credentials") && (
<InternalLinkButton
href="/auth/login?callbackUrl=/manage/users/invites"
iconProps={{ icon: IconMailForward, color: "pink" }}
>
{tFinish("action.inviteUser")}
</InternalLinkButton>
)}
<ExternalLinkButton
href={createDocumentationLink("/docs/getting-started/after-the-installation")}
iconProps={{ icon: IconBook2, color: "yellow" }}
>
{tFinish("action.docs")}
</ExternalLinkButton>
</Stack>
</Card>
);
};
interface LinkButtonProps {
href: string;
children: string;
iconProps: IconProps;
}
interface IconProps {
icon: TablerIcon;
color: MantineColor;
}
const Icon = ({ icon: IcomComponent, color }: IconProps) => {
return <IcomComponent color={getMantineColor(color, 6)} size={16} stroke={1.5} />;
};
const InternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => {
return (
<Button variant="default" component={Link} href={href} leftSection={<Icon {...iconProps} />}>
{children}
</Button>
);
};
const ExternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => {
return (
<Button variant="default" component="a" href={href} leftSection={<Icon {...iconProps} />}>
{children}
</Button>
);
};

View File

@@ -0,0 +1,52 @@
"use client";
import { Button, Card, Stack, TextInput } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
export const InitGroup = () => {
const t = useI18n();
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation();
const form = useZodForm(validation.group.create, {
initialValues: {
name: "",
},
});
const handleSubmitAsync = async (values: z.infer<typeof validation.group.create>) => {
await mutateAsync(values, {
async onSuccess() {
await revalidatePathActionAsync("/init");
},
onError(error) {
if (error.data?.code === "CONFLICT") {
form.setErrors({ name: t("common.zod.errors.custom.groupNameTaken") });
}
},
});
};
return (
<Card w={64 * 6} maw="90vw" withBorder>
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack>
<TextInput
label={t("init.step.group.form.name.label")}
description={t("init.step.group.form.name.description")}
withAsterisk
{...form.getInputProps("name")}
/>
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
{t("common.action.continue")}
</Button>
</Stack>
</form>
</Card>
);
};

View File

@@ -0,0 +1,41 @@
import { ActionIcon, Button, Card, Group, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { IconPencil } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
interface FileInfoCardProps {
file: FileWithPath;
onRemove: () => void;
}
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
return (
<Card w={64 * 12 + 8} maw="90vw">
<Group justify="space-between" align="center" wrap="nowrap">
<Group>
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
{file.name}
</Text>
<Text visibleFrom="md" c="gray.6" size="sm">
{humanFileSize(file.size)}
</Text>
</Group>
<Button
variant="subtle"
color="gray"
rightSection={<IconPencil size={16} stroke={1.5} />}
onClick={onRemove}
visibleFrom="md"
>
{tFileInfo("action.change")}
</Button>
<ActionIcon size="sm" variant="subtle" color="gray" hiddenFrom="md" onClick={onRemove}>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
</Group>
</Card>
);
};

View File

@@ -0,0 +1,54 @@
import { Group, rem, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
import "@mantine/dropzone/styles.css";
import { useScopedI18n } from "@homarr/translation/client";
interface ImportDropZoneProps {
loading: boolean;
updateFile: (file: FileWithPath) => void;
}
export const ImportDropZone = ({ loading, updateFile }: ImportDropZoneProps) => {
const tDropzone = useScopedI18n("init.step.import.dropzone");
return (
<Dropzone
onDrop={(files) => {
const firstFile = files[0];
if (!firstFile) return;
updateFile(firstFile);
}}
acceptColor="blue.6"
rejectColor="red.6"
accept={[MIME_TYPES.zip]}
loading={loading}
multiple={false}
maxSize={1024 * 1024 * 1024 * 64} // 64 MB
>
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: "none" }}>
<Dropzone.Accept>
<IconUpload style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-blue-6)" }} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-red-6)" }} stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconFileZip style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-dimmed)" }} stroke={1.5} />
</Dropzone.Idle>
<div>
<Text size="xl" inline>
{tDropzone("title")}
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
{tDropzone("description")}
</Text>
</div>
</Group>
</Dropzone>
);
};

View File

@@ -0,0 +1,53 @@
"use client";
import { startTransition, useState } from "react";
import { Card, Stack } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { InitialOldmarrImport } from "@homarr/old-import/components";
import { FileInfoCard } from "./file-info-card";
import { ImportDropZone } from "./import-dropzone";
export const InitImport = () => {
const [file, setFile] = useState<FileWithPath | null>(null);
const { isPending, mutate } = clientApi.import.analyseInitialOldmarrImport.useMutation();
const [analyseResult, setAnalyseResult] = useState<RouterOutputs["import"]["analyseInitialOldmarrImport"] | null>(
null,
);
if (!file) {
return (
<Card w={64 * 12 + 8} maw="90vw" withBorder>
<ImportDropZone
loading={isPending}
updateFile={(file) => {
const formData = new FormData();
formData.append("file", file);
mutate(formData, {
onSuccess: (result) => {
startTransition(() => {
setAnalyseResult(result);
setFile(file);
});
},
onError: (error) => {
console.error(error);
},
});
}}
/>
</Card>
);
}
return (
<Stack mb="sm">
<FileInfoCard file={file} onRemove={() => setFile(null)} />
{analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />}
</Stack>
);
};

View File

@@ -0,0 +1,156 @@
"use client";
import { startTransition } from "react";
import { Button, Card, Group, Stack, Switch, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import type { CheckboxProps } from "@homarr/form/types";
import { defaultServerSettings } from "@homarr/server-settings";
import type { TranslationObject } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
export const InitSettings = () => {
const tSection = useScopedI18n("management.page.settings.section");
const t = useI18n();
const { mutateAsync } = clientApi.serverSettings.initSettings.useMutation();
const form = useZodForm(validation.settings.init, { initialValues: defaultServerSettings });
form.watch("analytics.enableGeneral", ({ value }) => {
if (!value) {
startTransition(() => {
form.setFieldValue("analytics.enableWidgetData", false);
form.setFieldValue("analytics.enableIntegrationData", false);
form.setFieldValue("analytics.enableUserData", false);
});
}
});
const handleSubmitAsync = async (values: z.infer<typeof validation.settings.init>) => {
await mutateAsync(values, {
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
};
return (
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack>
<Card w={64 * 12 + 8} maw="90vw" withBorder>
<Stack gap="sm">
<Text fw={500}>{tSection("analytics.title")}</Text>
<Stack gap="xs">
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
<Stack gap="xs" ps="md" w="100%">
<AnalyticsRow
kind="integrationData"
disabled={!form.values.analytics.enableGeneral}
{...form.getInputProps("analytics.enableWidgetData", { type: "checkbox" })}
/>
<AnalyticsRow
kind="widgetData"
disabled={!form.values.analytics.enableGeneral}
{...form.getInputProps("analytics.enableIntegrationData", { type: "checkbox" })}
/>
<AnalyticsRow
kind="usersData"
disabled={!form.values.analytics.enableGeneral}
{...form.getInputProps("analytics.enableUserData", { type: "checkbox" })}
/>
</Stack>
</Stack>
</Stack>
</Card>
<Card w={64 * 12 + 8} maw="90vw" withBorder>
<Stack gap="sm">
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
<Stack gap="xs">
<CrawlingRow
kind="noIndex"
{...form.getInputProps("crawlingAndIndexing.noIndex", { type: "checkbox" })}
/>
<CrawlingRow
kind="noFollow"
{...form.getInputProps("crawlingAndIndexing.noFollow", { type: "checkbox" })}
/>
<CrawlingRow
kind="noTranslate"
{...form.getInputProps("crawlingAndIndexing.noTranslate", { type: "checkbox" })}
/>
<CrawlingRow
kind="noSiteLinksSearchBox"
{...form.getInputProps("crawlingAndIndexing.noSiteLinksSearchBox", { type: "checkbox" })}
/>
</Stack>
</Stack>
</Card>
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
{t("common.action.continue")}
</Button>
</Stack>
</form>
);
};
interface AnalyticsRowProps {
kind: Exclude<keyof TranslationObject["management"]["page"]["settings"]["section"]["analytics"], "title">;
disabled?: boolean;
}
const AnalyticsRow = ({ kind, ...props }: AnalyticsRowProps & CheckboxProps) => {
const tSection = useI18n("management.page.settings.section");
return (
<SettingRow title={tSection(`analytics.${kind}.title`)} text={tSection(`analytics.${kind}.text`)} {...props} />
);
};
interface CrawlingRowProps {
kind: Exclude<
keyof TranslationObject["management"]["page"]["settings"]["section"]["crawlingAndIndexing"],
"title" | "warning"
>;
}
const CrawlingRow = ({ kind, ...inputProps }: CrawlingRowProps & CheckboxProps) => {
const tSection = useI18n("management.page.settings.section");
return (
<SettingRow
title={tSection(`crawlingAndIndexing.${kind}.title`)}
text={tSection(`crawlingAndIndexing.${kind}.text`)}
{...inputProps}
/>
);
};
const SettingRow = ({
title,
text,
disabled,
...inputProps
}: { title: string; text: string; disabled?: boolean } & CheckboxProps) => {
return (
<Group wrap="nowrap" align="center">
<Stack gap={0} style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{title}
</Text>
<Text size="xs" c="gray.5">
{text}
</Text>
</Stack>
<Switch disabled={disabled} {...inputProps} />
</Group>
);
};

View File

@@ -0,0 +1,32 @@
import { Card, Stack, Text } from "@mantine/core";
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server";
import { InitStartButton } from "./next-button";
export const InitStart = async () => {
const tStart = await getScopedI18n("init.step.start");
return (
<Card w={64 * 6} maw="90vw" withBorder>
<Stack>
<Text>{tStart("description")}</Text>
<InitStartButton
preferredStep={undefined}
icon={<IconPlayerPlay color={getMantineColor("green", 6)} size={16} stroke={1.5} />}
>
{tStart("action.scratch")}
</InitStartButton>
<InitStartButton
preferredStep="import"
icon={<IconFileImport color={getMantineColor("cyan", 6)} size={16} stroke={1.5} />}
>
{tStart("action.importOldmarr")}
</InitStartButton>
</Stack>
</Card>
);
};

View File

@@ -0,0 +1,28 @@
"use client";
import type { PropsWithChildren, ReactNode } from "react";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { OnboardingStep } from "@homarr/definitions";
interface InitStartButtonProps {
icon: ReactNode;
preferredStep: OnboardingStep | undefined;
}
export const InitStartButton = ({ preferredStep, icon, children }: PropsWithChildren<InitStartButtonProps>) => {
const { mutateAsync } = clientApi.onboard.nextStep.useMutation();
const handleClickAsync = async () => {
await mutateAsync({ preferredStep });
await revalidatePathActionAsync("/init");
};
return (
<Button onClick={handleClickAsync} variant="default" leftSection={icon}>
{children}
</Button>
);
};

View File

@@ -1,9 +1,9 @@
"use client";
import { useRouter } from "next/navigation";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
@@ -12,9 +12,9 @@ import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
export const InitUserForm = () => {
const router = useRouter();
const t = useScopedI18n("user");
const { mutateAsync, error, isPending } = clientApi.user.initUser.useMutation();
const tUser = useScopedI18n("init.step.user");
const { mutateAsync, isPending } = clientApi.user.initUser.useMutation();
const form = useZodForm(validation.user.init, {
initialValues: {
username: "",
@@ -25,17 +25,17 @@ export const InitUserForm = () => {
const handleSubmitAsync = async (values: FormType) => {
await mutateAsync(values, {
onSuccess: () => {
async onSuccess() {
showSuccessNotification({
title: "User created",
message: "You can now log in",
title: tUser("notification.success.title"),
message: tUser("notification.success.message"),
});
router.push("/auth/login");
await revalidatePathActionAsync("/init");
},
onError: () => {
onError: (error) => {
showErrorNotification({
title: "User creation failed",
message: error?.message ?? "Unknown error",
title: tUser("notification.error.title"),
message: error.message,
});
},
});

View File

@@ -0,0 +1,11 @@
import { Card } from "@mantine/core";
import { InitUserForm } from "./init-user-form";
export const InitUser = () => {
return (
<Card w={64 * 6} maw="90vw" withBorder>
<InitUserForm />
</Card>
);
};

View File

@@ -0,0 +1,56 @@
import type { JSX } from "react";
import { Box, Center, Stack, Text, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import type { MaybePromise } from "@homarr/common/types";
import type { OnboardingStep } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { BackToStart } from "./_steps/back";
import { InitFinish } from "./_steps/finish/init-finish";
import { InitGroup } from "./_steps/group/init-group";
import { InitImport } from "./_steps/import/init-import";
import { InitSettings } from "./_steps/settings/init-settings";
import { InitStart } from "./_steps/start/init-start";
import { InitUser } from "./_steps/user/init-user";
const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Element>)> = {
start: InitStart,
import: InitImport,
user: InitUser,
group: InitGroup,
settings: InitSettings,
finish: InitFinish,
};
export default async function InitPage() {
const t = await getScopedI18n("init.step");
const currentStep = await api.onboard.currentStep();
const CurrentComponent = stepComponents[currentStep.current];
return (
<Box mih="100dvh">
<Center>
<Stack align="center" mt="xl">
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t(`${currentStep.current}.title`)}
</Title>
<Text size="sm" c="gray.5" ta="center">
{t(`${currentStep.current}.subtitle`)}
</Text>
</Stack>
<CurrentLanguageCombobox width="100%" />
<CurrentColorSchemeCombobox w="100%" />
{CurrentComponent && <CurrentComponent />}
{currentStep.previous === "start" && <BackToStart />}
</Stack>
</Center>
</Box>
);
}

View File

@@ -1,41 +0,0 @@
import { notFound } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { db } from "@homarr/db";
import { getScopedI18n } from "@homarr/translation/server";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { InitUserForm } from "./_init-user-form";
export default async function InitUser() {
const firstUser = await db.query.users.findFirst({
columns: {
id: true,
},
});
if (firstUser) {
notFound();
}
const t = await getScopedI18n("user.page.init");
return (
<Center>
<Stack align="center" mt="xl">
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t("title")}
</Title>
<Text size="sm" c="gray.5" ta="center">
{t("subtitle")}
</Text>
</Stack>
<Card bg="dark.8" w={64 * 6} maw="90vw">
<InitUserForm />
</Card>
</Stack>
</Center>
);
}

View File

@@ -1,13 +1,14 @@
"use client";
import { useState } from "react";
import { ActionIcon, Avatar, Button, Card, Collapse, Group, Kbd, Stack, Text } from "@mantine/core";
import { ActionIcon, Avatar, Badge, Button, Card, Collapse, Group, Kbd, Stack, Text, Tooltip } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconEye, IconEyeOff } from "@tabler/icons-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import type { RouterOutputs } from "@homarr/api";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { integrationSecretKindObject } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
@@ -16,7 +17,9 @@ import { integrationSecretIcons } from "./integration-secret-icons";
dayjs.extend(relativeTime);
interface SecretCardProps {
secret: RouterOutputs["integration"]["byId"]["secrets"][number];
secret:
| RouterOutputs["integration"]["byId"]["secrets"][number]
| { kind: IntegrationSecretKind; value: null; updatedAt: null };
children: React.ReactNode;
onCancel: () => Promise<boolean>;
}
@@ -41,11 +44,19 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
</Group>
<Group>
<Text c="gray.6" size="sm">
{t("integration.secrets.lastUpdated", {
date: dayjs().to(dayjs(secret.updatedAt)),
})}
</Text>
{secret.updatedAt ? (
<Text c="gray.6" size="sm">
{t("integration.secrets.lastUpdated", {
date: dayjs().to(dayjs(secret.updatedAt)),
})}
</Text>
) : (
<Tooltip label={t("integration.secrets.notSet.tooltip")} position="left">
<Badge color="orange" variant="light" size="sm">
{t("integration.secrets.notSet.label")}
</Badge>
</Tooltip>
)}
{isPublic ? (
<ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}>
<DisplayIcon size={16} stroke={1.5} />

View File

@@ -98,8 +98,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
{secretsKinds.map((kind, index) => (
<SecretCard
key={kind}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
secret={secretsMap.get(kind)!}
secret={secretsMap.get(kind) ?? { kind, value: null, updatedAt: null }}
onCancel={() =>
new Promise((resolve) => {
// When nothing changed, just close the secret card

View File

@@ -0,0 +1,49 @@
"use client";
import { Group, Text, useMantineColorScheme } from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithCustomItems } from "@homarr/ui";
interface CurrentColorSchemeComboboxProps {
w?: string;
}
export const CurrentColorSchemeCombobox = ({ w }: CurrentColorSchemeComboboxProps) => {
const tOptions = useScopedI18n("common.colorScheme.options");
const { colorScheme, setColorScheme } = useMantineColorScheme();
return (
<SelectWithCustomItems
value={colorScheme}
onChange={(value) => setColorScheme((value as ColorScheme | null) ?? "light")}
data={colorSchemes.map((scheme) => ({
value: scheme,
label: tOptions(scheme),
}))}
SelectOption={ColorSchemeCustomOption}
w={w}
/>
);
};
const appearanceIcons = {
light: IconSun,
dark: IconMoon,
};
const ColorSchemeCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
const Icon = appearanceIcons[value];
return (
<Group>
<Icon size={16} stroke={1.5} />
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
);
};

View File

@@ -4,9 +4,13 @@ import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client";
import { LanguageCombobox } from "./language-combobox";
export const CurrentLanguageCombobox = () => {
interface CurrentLanguageComboboxProps {
width?: string;
}
export const CurrentLanguageCombobox = ({ width }: CurrentLanguageComboboxProps) => {
const currentLocale = useCurrentLocale();
const { changeLocale, isPending } = useChangeLocale();
return <LanguageCombobox value={currentLocale} onChange={changeLocale} isPending={isPending} />;
return <LanguageCombobox value={currentLocale} onChange={changeLocale} isPending={isPending} width={width} />;
};

View File

@@ -9,14 +9,17 @@ import { localeConfigurations, supportedLanguages } from "@homarr/translation";
import classes from "./language-combobox.module.css";
import "flag-icons/css/flag-icons.min.css";
interface LanguageComboboxProps {
label?: string;
value: SupportedLanguage;
onChange: (value: SupportedLanguage) => void;
isPending?: boolean;
width?: string;
}
export const LanguageCombobox = ({ label, value, onChange, isPending }: LanguageComboboxProps) => {
export const LanguageCombobox = ({ label, value, onChange, isPending, width }: LanguageComboboxProps) => {
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
});
@@ -49,6 +52,7 @@ export const LanguageCombobox = ({ label, value, onChange, isPending }: Language
rightSectionPointerEvents="none"
onClick={handleOnClick}
variant="filled"
w={width}
>
<OptionItem currentLocale={value} localeKey={value} />
</InputBase>

View File

@@ -18,14 +18,11 @@ import {
IconTool,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client";
import { createModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import "flag-icons/css/flag-icons.min.css";
import type { RouterOutputs } from "@homarr/api";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { CurrentLanguageCombobox } from "./language/current-language-combobox";

View File

@@ -1,3 +1,4 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { createTRPCClient, httpLink } from "@trpc/client";
import SuperJSON from "superjson";
@@ -11,6 +12,15 @@ export async function middleware(request: NextRequest) {
// In next 15 we will be able to use node apis and such the db directly
const culture = await serverFetchApi.serverSettings.getCulture.query();
// Redirect to onboarding if it's not finished yet
const pathname = request.nextUrl.pathname;
if (!pathname.endsWith("/init")) {
const currentOnboardingStep = await serverFetchApi.onboard.currentStep.query();
if (currentOnboardingStep.current !== "finish") {
return NextResponse.redirect(new URL("/init", request.url));
}
}
// We don't want to fallback to accept-language header so we clear it
request.headers.set("accept-language", "");
const next = createI18nMiddleware(culture.defaultLocale);

View File

@@ -6,11 +6,13 @@ import { dockerRouter } from "./router/docker/docker-router";
import { groupRouter } from "./router/group";
import { homeRouter } from "./router/home";
import { iconsRouter } from "./router/icons";
import { importRouter } from "./router/import/import-router";
import { integrationRouter } from "./router/integration/integration-router";
import { inviteRouter } from "./router/invite";
import { locationRouter } from "./router/location";
import { logRouter } from "./router/log";
import { mediaRouter } from "./router/medias/media-router";
import { onboardRouter } from "./router/onboard/onboard-router";
import { searchEngineRouter } from "./router/search-engine/search-engine-router";
import { serverSettingsRouter } from "./router/serverSettings";
import { updateCheckerRouter } from "./router/update-checker";
@@ -30,6 +32,8 @@ export const appRouter = createTRPCRouter({
location: locationRouter,
log: logRouter,
icon: iconsRouter,
import: importRouter,
onboard: onboardRouter,
home: homeRouter,
docker: dockerRouter,
serverSettings: serverSettingsRouter,

View File

@@ -18,12 +18,12 @@ import {
} from "@homarr/db/schema/sqlite";
import type { WidgetKind } from "@homarr/definitions";
import { getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
import { importAsync } from "@homarr/old-import";
import { importOldmarrAsync } from "@homarr/old-import";
import { importJsonFileSchema } from "@homarr/old-import/shared";
import { oldmarrConfigSchema } from "@homarr/old-schema";
import type { BoardItemAdvancedOptions } from "@homarr/validation";
import { createSectionSchema, sharedItemSchema, validation, z } from "@homarr/validation";
import { createSectionSchema, sharedItemSchema, validation, z, zodUnionFromArray } from "@homarr/validation";
import { zodUnionFromArray } from "../../../validation/src/enums";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
@@ -575,13 +575,11 @@ export const boardRouter = createTRPCRouter({
);
});
}),
importOldmarrConfig: protectedProcedure
.input(validation.board.importOldmarrConfig)
.mutation(async ({ input, ctx }) => {
const content = await input.file.text();
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
await importAsync(ctx.db, oldmarr, input.configuration);
}),
importOldmarrConfig: protectedProcedure.input(importJsonFileSchema).mutation(async ({ input, ctx }) => {
const content = await input.file.text();
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
}),
});
const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => {

View File

@@ -6,8 +6,9 @@ import { groupMembers, groupPermissions, groups } from "@homarr/db/schema/sqlite
import { everyoneGroup } from "@homarr/definitions";
import { validation, z } from "@homarr/validation";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, protectedProcedure } from "../trpc";
import { throwIfCredentialsDisabled } from "./invite/checks";
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
export const groupRouter = createTRPCRouter({
getPaginated: permissionRequiredProcedure
@@ -145,6 +146,19 @@ export const groupRouter = createTRPCRouter({
limit: input.limit,
});
}),
createInitialExternalGroup: onboardingProcedure
.requiresStep("group")
.input(validation.group.create)
.mutation(async ({ input, ctx }) => {
await checkSimilarNameAndThrowAsync(ctx.db, input.name);
await ctx.db.insert(groups).values({
id: createId(),
name: input.name,
});
await nextOnboardingStepAsync(ctx.db, undefined);
}),
createGroup: permissionRequiredProcedure
.requiresPermission("admin")
.input(validation.group.create)

View File

@@ -0,0 +1,42 @@
import { analyseOldmarrImportForRouterAsync, analyseOldmarrImportInputSchema } from "@homarr/old-import/analyse";
import {
ensureValidTokenOrThrow,
importInitialOldmarrAsync,
importInitialOldmarrInputSchema,
} from "@homarr/old-import/import";
import { z } from "@homarr/validation";
import { createTRPCRouter, onboardingProcedure } from "../../trpc";
import { nextOnboardingStepAsync } from "../onboard/onboard-queries";
export const importRouter = createTRPCRouter({
analyseInitialOldmarrImport: onboardingProcedure
.requiresStep("import")
.input(analyseOldmarrImportInputSchema)
.mutation(async ({ input }) => {
return await analyseOldmarrImportForRouterAsync(input);
}),
validateToken: onboardingProcedure
.requiresStep("import")
.input(
z.object({
checksum: z.string(),
token: z.string(),
}),
)
.mutation(({ input }) => {
try {
ensureValidTokenOrThrow(input.checksum, input.token);
return true;
} catch {
return false;
}
}),
importInitialOldmarrImport: onboardingProcedure
.requiresStep("import")
.input(importInitialOldmarrInputSchema)
.mutation(async ({ ctx, input }) => {
await importInitialOldmarrAsync(ctx.db, input);
await nextOnboardingStepAsync(ctx.db, undefined);
}),
});

View File

@@ -0,0 +1,81 @@
import { isProviderEnabled } from "@homarr/auth/server";
import { objectEntries } from "@homarr/common";
import type { MaybePromise } from "@homarr/common/types";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { groups, onboarding } from "@homarr/db/schema/sqlite";
import type { OnboardingStep } from "@homarr/definitions";
import { credentialsAdminGroup } from "@homarr/definitions";
export const nextOnboardingStepAsync = async (db: Database, preferredStep: OnboardingStep | undefined) => {
const { current } = await getOnboardingOrFallbackAsync(db);
const nextStepConfiguration = nextSteps[current];
if (!nextStepConfiguration) return;
for (const conditionalStep of objectEntries(nextStepConfiguration)) {
if (!conditionalStep) continue;
const [nextStep, condition] = conditionalStep;
if (condition === "preferred" && nextStep !== preferredStep) continue;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (typeof condition === "boolean" && !condition) continue;
if (typeof condition === "function" && !(await condition(db))) continue;
await db.update(onboarding).set({
previousStep: current,
step: nextStep,
});
return;
}
};
export const getOnboardingOrFallbackAsync = async (db: Database) => {
const value = await db.query.onboarding.findFirst();
if (!value) return { current: "start" as const, previous: null };
return { current: value.step, previous: value.previousStep };
};
type NextStepCondition = true | "preferred" | ((db: Database) => MaybePromise<boolean>);
/**
* The below object is a definition of which can be the next step of the current one.
* If the value is `true`, it means the step can always be the next one.
* If the value is `preferred`, it means that the step can only be reached if the input `preferredStep` is set to the step.
* If the value is a function, it will be called with the database instance and should return a boolean.
* If the value or result is `false`, the step has to be skipped and the next value or callback should be checked.
*/
const nextSteps: Partial<Record<OnboardingStep, Partial<Record<OnboardingStep, NextStepCondition>>>> = {
start: {
import: "preferred" as const,
user: () => isProviderEnabled("credentials"),
group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"),
settings: true,
},
import: {
// eslint-disable-next-line no-restricted-syntax
user: async (db: Database) => {
if (!isProviderEnabled("credentials")) return false;
const adminGroup = await db.query.groups.findFirst({
where: eq(groups.name, credentialsAdminGroup),
with: {
members: true,
},
});
return !adminGroup || adminGroup.members.length === 0;
},
group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"),
settings: true,
},
user: {
group: () => isProviderEnabled("ldap") || isProviderEnabled("oidc"),
settings: true,
},
group: {
settings: true,
},
settings: {
finish: true,
},
};

View File

@@ -0,0 +1,34 @@
import { onboarding } from "@homarr/db/schema/sqlite";
import { onboardingSteps } from "@homarr/definitions";
import { z, zodEnumFromArray } from "@homarr/validation";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries";
export const onboardRouter = createTRPCRouter({
currentStep: publicProcedure.query(async ({ ctx }) => {
return await getOnboardingOrFallbackAsync(ctx.db);
}),
nextStep: publicProcedure
.input(
z.object({
// Preferred step is only needed for 'preferred' conditions
preferredStep: zodEnumFromArray(onboardingSteps).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
await nextOnboardingStepAsync(ctx.db, input.preferredStep);
}),
previousStep: publicProcedure.mutation(async ({ ctx }) => {
const { previous } = await getOnboardingOrFallbackAsync(ctx.db);
if (previous !== "start") {
return;
}
await ctx.db.update(onboarding).set({
previousStep: null,
step: "start",
});
}),
});

View File

@@ -1,9 +1,10 @@
import { getServerSettingByKeyAsync, getServerSettingsAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettingsKeys } from "@homarr/server-settings";
import { z } from "@homarr/validation";
import { validation, z } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { createTRPCRouter, onboardingProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
export const serverSettingsRouter = createTRPCRouter({
getCulture: publicProcedure.query(async ({ ctx }) => {
@@ -26,4 +27,12 @@ export const serverSettingsRouter = createTRPCRouter({
input.value as ServerSettings[keyof ServerSettings],
);
}),
initSettings: onboardingProcedure
.requiresStep("settings")
.input(validation.settings.init)
.mutation(async ({ ctx, input }) => {
await updateServerSettingByKeyAsync(ctx.db, "analytics", input.analytics);
await updateServerSettingByKeyAsync(ctx.db, "crawlingAndIndexing", input.crawlingAndIndexing);
await nextOnboardingStepAsync(ctx.db, undefined);
}),
});

View File

@@ -1,10 +1,11 @@
import { describe, expect, it, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { createId, eq, schema } from "@homarr/db";
import { users } from "@homarr/db/schema/sqlite";
import { onboarding, users } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
import { userRouter } from "../user";
@@ -36,31 +37,9 @@ vi.mock("@homarr/auth/env.mjs", () => {
});
describe("initUser should initialize the first user", () => {
it("should throw an error if a user already exists", async () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
session: null,
});
await db.insert(schema.users).values({
id: "test",
name: "test",
password: "test",
});
const actAsync = async () =>
await caller.initUser({
username: "test",
password: "123ABCdef+/-",
confirmPassword: "123ABCdef+/-",
});
await expect(actAsync()).rejects.toThrow("User already exists");
});
it("should create a user if none exists", async () => {
const db = createDb();
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
session: null,
@@ -83,6 +62,7 @@ describe("initUser should initialize the first user", () => {
it("should not create a user if the password and confirmPassword do not match", async () => {
const db = createDb();
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
session: null,
@@ -106,6 +86,7 @@ describe("initUser should initialize the first user", () => {
["abc123+/-"], // does not contain uppercase
])("should throw error that password requirements do not match for '%s' as password", async (password) => {
const db = createDb();
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
session: null,
@@ -324,3 +305,10 @@ describe("delete should delete user", () => {
expect(usersInDb[1]).containSubset(initialUsers[2]);
});
});
const createOnboardingStepAsync = async (db: Database, step: OnboardingStep) => {
await db.insert(onboarding).values({
id: createId(),
step,
});
};

View File

@@ -5,47 +5,46 @@ import type { Database } from "@homarr/db";
import { and, createId, eq, like, schema } from "@homarr/db";
import { groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema/sqlite";
import { selectUserSchema } from "@homarr/db/validationSchemas";
import { credentialsAdminGroup } from "@homarr/definitions";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { validation, z } from "@homarr/validation";
import { convertIntersectionToZodObject } from "../schema-merger";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import {
createTRPCRouter,
onboardingProcedure,
permissionRequiredProcedure,
protectedProcedure,
publicProcedure,
} from "../trpc";
import { throwIfCredentialsDisabled } from "./invite/checks";
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
export const userRouter = createTRPCRouter({
initUser: publicProcedure.input(validation.user.init).mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled();
initUser: onboardingProcedure
.requiresStep("user")
.input(validation.user.init)
.mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled();
const firstUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
},
});
if (firstUser) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User already exists",
const userId = await createUserAsync(ctx.db, input);
const groupId = createId();
await ctx.db.insert(groups).values({
id: groupId,
name: credentialsAdminGroup,
ownerId: userId,
});
}
const userId = await createUserAsync(ctx.db, input);
const groupId = createId();
await ctx.db.insert(groups).values({
id: groupId,
name: "admin",
ownerId: userId,
});
await ctx.db.insert(groupPermissions).values({
groupId,
permission: "admin",
});
await ctx.db.insert(groupMembers).values({
groupId,
userId,
});
}),
await ctx.db.insert(groupPermissions).values({
groupId,
permission: "admin",
});
await ctx.db.insert(groupMembers).values({
groupId,
userId,
});
await nextOnboardingStepAsync(ctx.db, undefined);
}),
register: publicProcedure
.input(validation.user.registrationApi)
.output(z.void())

View File

@@ -13,10 +13,12 @@ import type { OpenApiMeta } from "trpc-to-openapi";
import type { Session } from "@homarr/auth";
import { FlattenError } from "@homarr/common";
import { db } from "@homarr/db";
import type { GroupPermissionKey } from "@homarr/definitions";
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { ZodError } from "@homarr/validation";
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
/**
* 1. CONTEXT
*
@@ -138,3 +140,19 @@ export const permissionRequiredProcedure = {
});
},
};
export const onboardingProcedure = {
requiresStep: (step: OnboardingStep) => {
return publicProcedure.use(async ({ ctx, input, next }) => {
const currentStep = await getOnboardingOrFallbackAsync(ctx.db).then(({ current }) => current);
if (currentStep !== step) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Step denied",
});
}
return next({ input, ctx });
});
},
};

View File

@@ -25,6 +25,10 @@ export function encryptSecret(text: string): `${string}.${string}` {
}
export function decryptSecret(value: `${string}.${string}`) {
return decryptSecretWithKey(value, key);
}
export function decryptSecretWithKey(value: `${string}.${string}`, key: Buffer) {
const [data, dataIv] = value.split(".") as [string, string];
const initializationVector = Buffer.from(dataIv, "hex");
const encryptedText = Buffer.from(data, "hex");

View File

@@ -8,3 +8,4 @@ export * from "./url";
export * from "./number";
export * from "./error";
export * from "./fetch-with-timeout";
export * from "./theme";

View File

@@ -0,0 +1,5 @@
import type { DefaultMantineColor, MantineColorShade } from "@mantine/core";
import { DEFAULT_THEME } from "@mantine/core";
export const getMantineColor = (color: DefaultMantineColor, shade: MantineColorShade) =>
DEFAULT_THEME.colors[color]?.[shade] ?? "#fff";

View File

@@ -0,0 +1,6 @@
CREATE TABLE `onboarding` (
`id` varchar(64) NOT NULL,
`step` varchar(64) NOT NULL,
`previous_step` varchar(64),
CONSTRAINT `onboarding_id` PRIMARY KEY(`id`)
);

File diff suppressed because it is too large Load Diff

View File

@@ -120,6 +120,13 @@
"when": 1732212709518,
"tag": "0016_change_all_to_snake_case",
"breakpoints": true
},
{
"idx": 17,
"version": "5",
"when": 1733777544067,
"tag": "0017_tired_penance",
"breakpoints": true
}
]
}

View File

@@ -7,10 +7,11 @@ import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server
import { createId, eq } from "..";
import type { Database } from "..";
import { groups } from "../schema/mysql";
import { serverSettings } from "../schema/sqlite";
import { onboarding, serverSettings } from "../schema/sqlite";
export const seedDataAsync = async (db: Database) => {
await seedEveryoneGroupAsync(db);
await seedOnboardingAsync(db);
await seedServerSettingsAsync(db);
};
@@ -31,6 +32,21 @@ const seedEveryoneGroupAsync = async (db: Database) => {
console.log("Created group 'everyone' through seed");
};
const seedOnboardingAsync = async (db: Database) => {
const existing = await db.query.onboarding.findFirst();
if (existing) {
console.log("Skipping seeding of onboarding as it already exists");
return;
}
await db.insert(onboarding).values({
id: createId(),
step: "start",
});
console.log("Created onboarding step through seed");
};
const seedServerSettingsAsync = async (db: Database) => {
const serverSettingsData = await db.query.serverSettings.findMany();

View File

@@ -0,0 +1,5 @@
CREATE TABLE `onboarding` (
`id` text PRIMARY KEY NOT NULL,
`step` text NOT NULL,
`previous_step` text
);

File diff suppressed because it is too large Load Diff

View File

@@ -120,6 +120,13 @@
"when": 1732210918783,
"tag": "0016_change_all_to_snake_case",
"breakpoints": true
},
{
"idx": 17,
"version": "6",
"when": 1733777395703,
"tag": "0017_small_rumiko_fujikawa",
"breakpoints": true
}
]
}

View File

@@ -25,6 +25,7 @@ import type {
IntegrationKind,
IntegrationPermission,
IntegrationSecretKind,
OnboardingStep,
SearchEngineType,
SectionKind,
SupportedAuthProvider,
@@ -395,6 +396,12 @@ export const searchEngines = mysqlTable("search_engine", {
integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "cascade" }),
});
export const onboarding = mysqlTable("onboarding", {
id: varchar({ length: 64 }).notNull().primaryKey(),
step: varchar({ length: 64 }).$type<OnboardingStep>().notNull(),
previousStep: varchar({ length: 64 }).$type<OnboardingStep>(),
});
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],

View File

@@ -16,6 +16,7 @@ import type {
IntegrationKind,
IntegrationPermission,
IntegrationSecretKind,
OnboardingStep,
SearchEngineType,
SectionKind,
SupportedAuthProvider,
@@ -382,6 +383,12 @@ export const searchEngines = sqliteTable("search_engine", {
integrationId: text().references(() => integrations.id, { onDelete: "cascade" }),
});
export const onboarding = sqliteTable("onboarding", {
id: text().notNull().primaryKey(),
step: text().$type<OnboardingStep>().notNull(),
previousStep: text().$type<OnboardingStep>(),
});
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],

View File

@@ -1 +1,2 @@
export const everyoneGroup = "everyone";
export const credentialsAdminGroup = "admin";

View File

@@ -10,3 +10,4 @@ export * from "./group";
export * from "./docs";
export * from "./cookie";
export * from "./search-engine";
export * from "./onboarding";

View File

@@ -0,0 +1,2 @@
export const onboardingSteps = ["start", "import", "user", "group", "settings", "finish"] as const;
export type OnboardingStep = (typeof onboardingSteps)[number];

View File

@@ -5,7 +5,8 @@
"license": "MIT",
"type": "module",
"exports": {
".": "./index.ts"
".": "./index.ts",
"./types": "./src/types.ts"
},
"typesVersions": {
"*": {
@@ -22,6 +23,7 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^7.15.1"

View File

@@ -0,0 +1,21 @@
import type { ChangeEvent, FocusEvent } from "react";
export interface InputPropsFor<T, TOnChangeArg, TComponent extends HTMLElement = HTMLInputElement>
extends BasePropsFor<TOnChangeArg, TComponent> {
value?: T;
defaultValue?: T;
}
interface BasePropsFor<TOnChangeArg, TComponent extends HTMLElement> {
onChange: (value: TOnChangeArg) => void;
error?: string;
onBlur?: (event: FocusEvent<TComponent>) => void;
onFocus?: (event: FocusEvent<TComponent>) => void;
}
export interface CheckboxProps<
TOnChangeArg = ChangeEvent<HTMLInputElement>,
TComponent extends HTMLElement = HTMLInputElement,
> extends BasePropsFor<TOnChangeArg, TComponent> {
checked?: boolean;
}

View File

@@ -27,6 +27,7 @@
"@homarr/form": "workspace:^0.1.0",
"@homarr/modals": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/old-import": "workspace:^0.1.0",
"@homarr/old-schema": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { Button, Fieldset, FileInput, Grid, Group, Radio, Stack, Switch, TextInput } from "@mantine/core";
import { Button, FileInput, Group, Radio, Stack, TextInput } from "@mantine/core";
import { IconFileUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -7,16 +7,18 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { OldmarrImportAppsSettings, SidebarBehaviourSelect } from "@homarr/old-import/components";
import type { OldmarrImportConfiguration } from "@homarr/old-import/shared";
import { oldmarrImportConfigurationSchema, superRefineJsonImportFile } from "@homarr/old-import/shared";
import { oldmarrConfigSchema } from "@homarr/old-schema";
import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithDescription } from "@homarr/ui";
import type { OldmarrImportConfiguration } from "@homarr/validation";
import { oldmarrImportConfigurationSchema, superRefineJsonImportFile, z } from "@homarr/validation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { z } from "@homarr/validation";
import { useBoardNameStatus } from "./add-board-modal";
export const ImportBoardModal = createModal(({ actions }) => {
const tOldImport = useScopedI18n("board.action.oldImport");
const t = useI18n();
const tCommon = useScopedI18n("common");
const [fileValid, setFileValid] = useState(true);
const form = useZodForm(
@@ -30,7 +32,6 @@ export const ImportBoardModal = createModal(({ actions }) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
file: null!,
configuration: {
distinctAppsByHref: true,
onlyImportApps: false,
screenSize: "lg",
sidebarBehaviour: "last-section",
@@ -119,24 +120,7 @@ export const ImportBoardModal = createModal(({ actions }) => {
label={tOldImport("form.file.label")}
/>
<Fieldset legend={tOldImport("form.apps.label")}>
<Grid>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Switch
label={tOldImport("form.apps.avoidDuplicates.label")}
description={tOldImport("form.apps.avoidDuplicates.description")}
{...form.getInputProps("configuration.distinctAppsByHref", { type: "checkbox" })}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Switch
label={tOldImport("form.apps.onlyImportApps.label")}
description={tOldImport("form.apps.onlyImportApps.description")}
{...form.getInputProps("configuration.onlyImportApps", { type: "checkbox" })}
/>
</Grid.Col>
</Grid>
</Fieldset>
<OldmarrImportAppsSettings onlyImportApps={form.getInputProps("configuration.onlyImportApps")} />
<TextInput
withAsterisk
@@ -155,33 +139,17 @@ export const ImportBoardModal = createModal(({ actions }) => {
<Radio.Group
withAsterisk
label={tOldImport("form.screenSize.label")}
description={t("board.action.oldImport.form.screenSize.description")}
{...form.getInputProps("configuration.screenSize")}
>
<Group mt="xs">
<Radio value="sm" label={tOldImport("form.screenSize.option.sm")} />
<Radio value="md" label={tOldImport("form.screenSize.option.md")} />
<Radio value="lg" label={tOldImport("form.screenSize.option.lg")} />
<Radio value="sm" label={t("board.action.oldImport.form.screenSize.option.sm")} />
<Radio value="md" label={t("board.action.oldImport.form.screenSize.option.md")} />
<Radio value="lg" label={t("board.action.oldImport.form.screenSize.option.lg")} />
</Group>
</Radio.Group>
<SelectWithDescription
withAsterisk
label={tOldImport("form.sidebarBehavior.label")}
description={tOldImport("form.sidebarBehavior.description")}
data={[
{
value: "last-section",
label: tOldImport("form.sidebarBehavior.option.lastSection.label"),
description: tOldImport("form.sidebarBehavior.option.lastSection.description"),
},
{
value: "remove-items",
label: tOldImport("form.sidebarBehavior.option.removeItems.label"),
description: tOldImport("form.sidebarBehavior.option.removeItems.description"),
},
]}
{...form.getInputProps("configuration.sidebarBehaviour")}
/>
<SidebarBehaviourSelect {...form.getInputProps("configuration.sidebarBehaviour")} />
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>

View File

@@ -5,7 +5,11 @@
"license": "MIT",
"type": "module",
"exports": {
".": "./index.ts"
".": "./index.ts",
"./analyse": "./src/analyse/index.ts",
"./components": "./src/components/index.ts",
"./import": "./src/import/index.ts",
"./shared": "./src/shared.ts"
},
"typesVersions": {
"*": {
@@ -25,15 +29,28 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/modals": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/old-schema": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"superjson": "2.2.2"
"@mantine/core": "^7.15.1",
"@mantine/hooks": "^7.15.1",
"adm-zip": "0.5.16",
"next": "^14.2.20",
"react": "^19.0.0",
"superjson": "2.2.2",
"zod": "^3.24.1",
"zod-form-data": "^2.0.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/adm-zip": "0.5.7",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
}

View File

@@ -0,0 +1,64 @@
import AdmZip from "adm-zip";
import { z } from "zod";
import { logger } from "@homarr/log";
import { oldmarrConfigSchema } from "@homarr/old-schema";
import { oldmarrImportUserSchema } from "../user-schema";
import type { analyseOldmarrImportInputSchema } from "./input";
export const analyseOldmarrImportForRouterAsync = async (input: z.infer<typeof analyseOldmarrImportInputSchema>) => {
const { configs, checksum, users } = await analyseOldmarrImportAsync(input.file);
return {
configs,
checksum,
userCount: users.length,
};
};
export const analyseOldmarrImportAsync = async (file: File) => {
const arrayBuffer = await file.arrayBuffer();
const zip = new AdmZip(Buffer.from(arrayBuffer));
const entries = zip.getEntries();
const configEntries = entries.filter((entry) => entry.entryName.endsWith(".json") && !entry.entryName.includes("/"));
const configs = configEntries.map((entry) => {
const result = oldmarrConfigSchema.safeParse(JSON.parse(entry.getData().toString()));
if (!result.success) {
logger.error(`Failed to parse config ${entry.entryName} with error: ${JSON.stringify(result.error)}`);
}
return {
name: entry.name,
config: result.data ?? null,
isError: !result.success,
};
});
const userEntry = entries.find((entry) => entry.entryName === "users/users.json");
const users = parseUsers(userEntry);
const checksum = entries
.find((entry) => entry.entryName === "checksum.txt")
?.getData()
.toString("utf-8");
return {
configs,
users,
checksum,
};
};
export type AnalyseResult = Awaited<ReturnType<typeof analyseOldmarrImportForRouterAsync>>;
const parseUsers = (entry: AdmZip.IZipEntry | undefined) => {
if (!entry) return [];
const result = z.array(oldmarrImportUserSchema).safeParse(JSON.parse(entry.getData().toString()));
if (!result.success) {
logger.error(`Failed to parse users with error: ${JSON.stringify(result.error)}`);
}
return result.data ?? [];
};

View File

@@ -0,0 +1,2 @@
export * from "./input";
export { analyseOldmarrImportForRouterAsync } from "./analyse-oldmarr-import";

View File

@@ -0,0 +1,5 @@
import { zfd } from "zod-form-data";
export const analyseOldmarrImportInputSchema = zfd.formData({
file: zfd.file(),
});

View File

@@ -0,0 +1,7 @@
import type { Modify } from "@homarr/common/types";
import type { OldmarrConfig } from "@homarr/old-schema";
import type { AnalyseResult } from "./analyse-oldmarr-import";
export type AnalyseConfig = AnalyseResult["configs"][number];
export type ValidAnalyseConfig = Modify<AnalyseConfig, { config: OldmarrConfig }>;

View File

@@ -0,0 +1,3 @@
export { InitialOldmarrImport } from "./initial-oldmarr-import";
export { SidebarBehaviourSelect } from "./shared/sidebar-behaviour-select";
export { OldmarrImportAppsSettings } from "./shared/apps-section";

View File

@@ -0,0 +1,112 @@
import { useMemo, useState } from "react";
import { Stack } from "@mantine/core";
import SuperJSON from "superjson";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { boardSizes } from "@homarr/old-schema";
// We don't have access to the API client here, so we need to import it from the API package
// In the future we should consider having the used router also in this package
import { clientApi } from "../../../api/src/client";
import type { AnalyseResult } from "../analyse/analyse-oldmarr-import";
import { prepareMultipleImports } from "../prepare/prepare-multiple";
import type { InitialOldmarrImportSettings } from "../settings";
import { defaultSidebarBehaviour } from "../settings";
import type { BoardSelectionMap, BoardSizeRecord } from "./initial/board-selection-card";
import { BoardSelectionCard } from "./initial/board-selection-card";
import { ImportSettingsCard } from "./initial/import-settings-card";
import { ImportSummaryCard } from "./initial/import-summary-card";
import { ImportTokenModal } from "./initial/token-modal";
interface InitialOldmarrImportProps {
file: File;
analyseResult: AnalyseResult;
}
export const InitialOldmarrImport = ({ file, analyseResult }: InitialOldmarrImportProps) => {
const [boardSelections, setBoardSelections] = useState<BoardSelectionMap>(
new Map(createDefaultSelections(analyseResult.configs)),
);
const [settings, setSettings] = useState<InitialOldmarrImportSettings>({
onlyImportApps: false,
sidebarBehaviour: defaultSidebarBehaviour,
});
const { preparedApps, preparedBoards, preparedIntegrations } = useMemo(
() => prepareMultipleImports(analyseResult.configs, settings, boardSelections),
[analyseResult, boardSelections, settings],
);
const { mutateAsync, isPending } = clientApi.import.importInitialOldmarrImport.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
const { openModal } = useModalAction(ImportTokenModal);
const createFormData = (token: string | null) => {
const formData = new FormData();
formData.set("file", file);
formData.set("settings", JSON.stringify(settings));
// Map can not be send over the wire without superjson
formData.set("boardSelections", SuperJSON.stringify(boardSelections));
if (token) {
formData.set("token", token);
}
return formData;
};
const handleSubmitAsync = async () => {
if (analyseResult.checksum) {
openModal({
checksum: analyseResult.checksum,
onSuccessAsync: async (token) => {
await mutateAsync(createFormData(token));
},
});
return;
}
await mutateAsync(createFormData(null));
};
return (
<Stack mb="sm">
<ImportSettingsCard
settings={settings}
updateSetting={(setting, value) => {
setSettings((settings) => ({ ...settings, [setting]: value }));
}}
/>
{settings.onlyImportApps ? null : (
<BoardSelectionCard selections={boardSelections} updateSelections={setBoardSelections} />
)}
<ImportSummaryCard
counts={{
apps: preparedApps.length,
boards: preparedBoards.length,
integrations: preparedIntegrations.length,
credentialUsers: analyseResult.userCount,
}}
onSubmit={handleSubmitAsync}
loading={isPending}
/>
</Stack>
);
};
const createDefaultSelections = (configs: AnalyseResult["configs"]) => {
return configs
.map(({ name, config }) => {
if (!config) return null;
const shapes = config.apps.flatMap((app) => app.shape).concat(config.widgets.flatMap((widget) => widget.shape));
const boardSizeRecord = boardSizes.reduce<BoardSizeRecord>((acc, size) => {
const allInclude = shapes.every((shape) => Boolean(shape[size]));
acc[size] = allInclude ? true : null;
return acc;
}, {} as BoardSizeRecord);
return [name, boardSizeRecord];
})
.filter((selection): selection is [string, BoardSizeRecord] => Boolean(selection));
};

View File

@@ -0,0 +1,156 @@
import type { ChangeEvent } from "react";
import { Anchor, Card, Checkbox, Group, Stack, Text } from "@mantine/core";
import { objectEntries, objectKeys } from "@homarr/common";
import { boardSizes } from "@homarr/old-schema";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
type BoardSize = (typeof boardSizes)[number];
export type BoardSizeRecord = Record<BoardSize, boolean | null>;
export type BoardSelectionMap = Map<string, BoardSizeRecord>;
interface BoardSelectionCardProps {
selections: BoardSelectionMap;
updateSelections: (callback: (selections: BoardSelectionMap) => BoardSelectionMap) => void;
}
const allChecked = (map: BoardSelectionMap) => {
return [...map.values()].every((selection) => groupChecked(selection));
};
const groupChecked = (selection: BoardSizeRecord) =>
objectEntries(selection).every(([_, value]) => value === true || value === null);
export const BoardSelectionCard = ({ selections, updateSelections }: BoardSelectionCardProps) => {
const tBoardSelection = useScopedI18n("init.step.import.boardSelection");
const t = useI18n();
const areAllChecked = allChecked(selections);
const handleToggleAll = () => {
updateSelections((selections) => {
const updated = new Map(selections);
[...selections.entries()].forEach(([name, selection]) => {
objectKeys(selection).forEach((size) => {
if (selection[size] === null) return;
selection[size] = !areAllChecked;
});
updated.set(name, selection);
});
return updated;
});
};
const registerToggleGroup = (name: string) => (event: ChangeEvent<HTMLInputElement>) => {
updateSelections((selections) => {
const updated = new Map(selections);
const selection = selections.get(name);
if (!selection) return updated;
objectKeys(selection).forEach((size) => {
if (selection[size] === null) return;
selection[size] = event.target.checked;
});
updated.set(name, selection);
return updated;
});
};
const registerToggle = (name: string, size: BoardSize) => (event: ChangeEvent<HTMLInputElement>) => {
updateSelections((selections) => {
const updated = new Map(selections);
const selection = selections.get(name);
if (!selection) return updated;
selection[size] = event.target.checked;
updated.set(name, selection);
return updated;
});
};
if (selections.size === 0) {
return null;
}
return (
<Card w={64 * 12 + 8} maw="90vw">
<Stack gap="sm">
<Stack gap={0}>
<Group justify="space-between" align="center">
<Text fw={500}>{tBoardSelection("title", { count: selections.size })}</Text>
<Anchor component="button" onClick={handleToggleAll}>
{areAllChecked ? tBoardSelection("action.unselectAll") : tBoardSelection("action.selectAll")}
</Anchor>
</Group>
<Text size="sm" c="gray.6">
{tBoardSelection("description")}
</Text>
<Text size="xs" c="gray.6">
{t("board.action.oldImport.form.screenSize.description")}
</Text>
</Stack>
<Stack gap="sm">
{[...selections.entries()].map(([name, selection]) => (
<Card key={name} withBorder>
<Group justify="space-between" align="center" visibleFrom="md">
<Checkbox
checked={groupChecked(selection)}
onChange={registerToggleGroup(name)}
label={
<Text fw={500} size="sm">
{name}
</Text>
}
/>
<Group>
{boardSizes.map((size) => (
<Checkbox
key={size}
disabled={selection[size] === null}
checked={selection[size] ?? undefined}
onChange={registerToggle(name, size)}
label={t(`board.action.oldImport.form.screenSize.option.${size}`)}
/>
))}
</Group>
</Group>
<Stack hiddenFrom="md">
<Checkbox
checked={groupChecked(selection)}
onChange={registerToggleGroup(name)}
label={
<Text fw={500} size="sm">
{name}
</Text>
}
/>
<Stack gap="sm" ps="sm">
{objectEntries(selection)
.filter(([_, value]) => value !== null)
.map(([size, value]) => (
<Checkbox
key={size}
checked={value ?? undefined}
onChange={registerToggle(name, size)}
label={`screenSize.${size}`}
/>
))}
</Stack>
</Stack>
</Card>
))}
</Stack>
</Stack>
</Card>
);
};

View File

@@ -0,0 +1,44 @@
import { Card, Stack, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import type { InitialOldmarrImportSettings } from "../../settings";
import { OldmarrImportAppsSettings } from "../shared/apps-section";
import { SidebarBehaviourSelect } from "../shared/sidebar-behaviour-select";
interface ImportSettingsCardProps {
settings: InitialOldmarrImportSettings;
updateSetting: <TKey extends keyof InitialOldmarrImportSettings>(
setting: TKey,
value: InitialOldmarrImportSettings[TKey],
) => void;
}
export const ImportSettingsCard = ({ settings, updateSetting }: ImportSettingsCardProps) => {
const tImportSettings = useScopedI18n("init.step.import.importSettings");
return (
<Card w={64 * 12 + 8} maw="90vw">
<Stack gap="sm">
<Stack gap={0}>
<Text fw={500}>{tImportSettings("title")}</Text>
<Text size="sm" c="gray.6">
{tImportSettings("description")}
</Text>
</Stack>
<OldmarrImportAppsSettings
background="transparent"
onlyImportApps={{
checked: settings.onlyImportApps,
onChange: (event) => updateSetting("onlyImportApps", event.target.checked),
}}
/>
<SidebarBehaviourSelect
value={settings.sidebarBehaviour}
onChange={(value) => updateSetting("sidebarBehaviour", value)}
/>
</Stack>
</Card>
);
};

View File

@@ -0,0 +1,44 @@
import { Button, Card, Group, Stack, Text } from "@mantine/core";
import { objectEntries } from "@homarr/common";
import type { MaybePromise } from "@homarr/common/types";
import { useScopedI18n } from "@homarr/translation/client";
interface ImportSummaryCardProps {
counts: { apps: number; boards: number; integrations: number; credentialUsers: number };
loading: boolean;
onSubmit: () => MaybePromise<void>;
}
export const ImportSummaryCard = ({ counts, onSubmit, loading }: ImportSummaryCardProps) => {
const tSummary = useScopedI18n("init.step.import.summary");
return (
<Card w={64 * 12 + 8} maw="90vw">
<Stack gap="sm">
<Stack gap={0}>
<Text fw={500}>{tSummary("title")}</Text>
<Text size="sm" c="gray.6">
{tSummary("description")}
</Text>
</Stack>
<Stack gap="xs">
{objectEntries(counts).map(([key, count]) => (
<Card key={key} withBorder p="sm">
<Group justify="space-between" align="center">
<Text fw={500} size="sm">
{tSummary(`entities.${key}`)}
</Text>
<Text size="sm">{count}</Text>
</Group>
</Card>
))}
</Stack>
<Button onClick={onSubmit} loading={loading}>
{tSummary("action.import")}
</Button>
</Stack>
</Card>
);
};

View File

@@ -0,0 +1,67 @@
import { Button, Group, PasswordInput, Stack } from "@mantine/core";
import { z } from "zod";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
// We don't have access to the API client here, so we need to import it from the API package
// In the future we should consider having the used router also in this package
import { clientApi } from "../../../../api/src/client";
interface InnerProps {
checksum: string;
onSuccessAsync: (token: string) => Promise<void>;
}
const formSchema = z.object({
token: z.string().min(1).max(256),
});
export const ImportTokenModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const tTokenModal = useScopedI18n("init.step.import.tokenModal");
const { mutate, isPending } = clientApi.import.validateToken.useMutation();
const form = useZodForm(formSchema, { initialValues: { token: "" } });
const handleSubmit = (values: z.infer<typeof formSchema>) => {
mutate(
{ checksum: innerProps.checksum, token: values.token },
{
async onSuccess(isValid) {
if (isValid) {
actions.closeModal();
await innerProps.onSuccessAsync(values.token);
} else {
showErrorNotification({
title: tTokenModal("notification.error.title"),
message: tTokenModal("notification.error.message"),
});
}
},
},
);
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<PasswordInput
{...form.getInputProps("token")}
label={tTokenModal("field.token.label")}
description={tTokenModal("field.token.description")}
withAsterisk
/>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{t("common.action.confirm")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({ defaultTitle: (t) => t("init.step.import.tokenModal.title") });

View File

@@ -0,0 +1,23 @@
import { Fieldset, Switch } from "@mantine/core";
import type { CheckboxProps } from "@homarr/form/types";
import { useScopedI18n } from "@homarr/translation/client";
interface OldmarrImportAppsSettingsProps {
onlyImportApps: CheckboxProps;
background?: string;
}
export const OldmarrImportAppsSettings = ({ background, onlyImportApps }: OldmarrImportAppsSettingsProps) => {
const tApps = useScopedI18n("board.action.oldImport.form.apps");
return (
<Fieldset legend={tApps("label")} bg={background}>
<Switch
{...onlyImportApps}
label={tApps("onlyImportApps.label")}
description={tApps("onlyImportApps.description")}
/>
</Fieldset>
);
};

View File

@@ -0,0 +1,31 @@
import type { InputPropsFor } from "@homarr/form/types";
import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithDescription } from "@homarr/ui";
import type { SidebarBehaviour } from "../../settings";
export const SidebarBehaviourSelect = (props: InputPropsFor<SidebarBehaviour, SidebarBehaviour, HTMLButtonElement>) => {
const tSidebarBehaviour = useScopedI18n("board.action.oldImport.form.sidebarBehavior");
return (
<SelectWithDescription
withAsterisk
label={tSidebarBehaviour("label")}
description={tSidebarBehaviour("description")}
data={[
{
value: "last-section",
label: tSidebarBehaviour("option.lastSection.label"),
description: tSidebarBehaviour("option.lastSection.description"),
},
{
value: "remove-items",
label: tSidebarBehaviour("option.removeItems.label"),
description: tSidebarBehaviour("option.removeItems.description"),
},
]}
{...props}
onChange={(value) => (value ? props.onChange(value as SidebarBehaviour) : null)}
/>
);
};

View File

@@ -3,10 +3,10 @@ import { createId } from "@homarr/db";
import { boards } from "@homarr/db/schema/sqlite";
import { logger } from "@homarr/log";
import type { OldmarrConfig } from "@homarr/old-schema";
import type { OldmarrImportConfiguration } from "@homarr/validation";
import { mapColor } from "./mappers/map-colors";
import { mapColumnCount } from "./mappers/map-column-count";
import type { OldmarrImportConfiguration } from "./settings";
export const insertBoardAsync = async (db: Database, old: OldmarrConfig, configuration: OldmarrImportConfiguration) => {
logger.info(`Importing old homarr board configuration=${old.configProperties.name}`);

View File

@@ -1,5 +1,6 @@
import type { OldmarrConfig } from "@homarr/old-schema";
import type { OldmarrImportConfiguration } from "@homarr/validation";
import type { OldmarrImportConfiguration } from "./settings";
export class OldHomarrImportError extends Error {
constructor(oldConfig: OldmarrConfig, cause: unknown) {

View File

@@ -5,10 +5,10 @@ import { createId } from "@homarr/db";
import { items } from "@homarr/db/schema/sqlite";
import { logger } from "@homarr/log";
import type { OldmarrApp, OldmarrWidget } from "@homarr/old-schema";
import type { OldmarrImportConfiguration } from "@homarr/validation";
import type { WidgetComponentProps } from "../../widgets/src/definition";
import { OldHomarrScreenSizeError } from "./import-error";
import type { OldmarrImportConfiguration } from "./settings";
import { mapKind } from "./widgets/definitions";
import { mapOptions } from "./widgets/options";

View File

@@ -0,0 +1,78 @@
import { createId } from "@homarr/db";
import { logger } from "@homarr/log";
import { fixSectionIssues } from "../../fix-section-issues";
import { mapBoard } from "../../mappers/map-board";
import { moveWidgetsAndAppsIfMerge } from "../../move-widgets-and-apps-merge";
import { prepareItems } from "../../prepare/prepare-items";
import type { prepareMultipleImports } from "../../prepare/prepare-multiple";
import { prepareSections } from "../../prepare/prepare-sections";
import type { InitialOldmarrImportSettings } from "../../settings";
import { createDbInsertCollection } from "./common";
export const createBoardInsertCollection = (
{ preparedApps, preparedBoards }: Omit<ReturnType<typeof prepareMultipleImports>, "preparedIntegrations">,
settings: InitialOldmarrImportSettings,
) => {
const insertCollection = createDbInsertCollection(["apps", "boards", "sections", "items"]);
logger.info("Preparing boards for insert collection");
const appsMap = new Map(
preparedApps.flatMap(({ ids, ...app }) => {
const id = app.existingId ?? createId();
return ids.map((oldId) => [oldId, { id, ...app }] as const);
}),
);
for (const app of appsMap.values()) {
// Skip duplicate apps
if (insertCollection.apps.some((appEntry) => appEntry.id === app.id)) {
continue;
}
// Skip apps that already exist in the database
if (app.existingId) {
continue;
}
insertCollection.apps.push(app);
}
if (settings.onlyImportApps) {
logger.info(
`Skipping boards and sections import due to onlyImportApps setting appCount=${insertCollection.apps.length}`,
);
return insertCollection;
}
logger.debug(`Added apps to board insert collection count=${insertCollection.apps.length}`);
preparedBoards.forEach((board) => {
const { wrappers, categories, wrapperIdsToMerge } = fixSectionIssues(board.config);
const { apps, widgets } = moveWidgetsAndAppsIfMerge(board.config, wrapperIdsToMerge, {
...settings,
screenSize: board.size,
name: board.name,
});
logger.debug(`Fixed issues with sections and item positions fileName=${board.name}`);
const mappedBoard = mapBoard(board);
logger.debug(`Mapped board fileName=${board.name} boardId=${mappedBoard.id}`);
insertCollection.boards.push(mappedBoard);
const preparedSections = prepareSections(mappedBoard.id, { wrappers, categories });
for (const section of preparedSections.values()) {
insertCollection.sections.push(section);
}
logger.debug(`Added sections to board insert collection count=${insertCollection.sections.length}`);
const preparedItems = prepareItems({ apps, widgets }, board.size, appsMap, preparedSections);
preparedItems.forEach((item) => insertCollection.items.push(item));
logger.debug(`Added items to board insert collection count=${insertCollection.items.length}`);
});
logger.info(
`Board collection prepared boardCount=${insertCollection.boards.length} sectionCount=${insertCollection.sections.length} itemCount=${insertCollection.items.length} appCount=${insertCollection.apps.length}`,
);
return insertCollection;
};

View File

@@ -0,0 +1,33 @@
import { objectEntries } from "@homarr/common";
import type { Database, InferInsertModel } from "@homarr/db";
import { schema } from "@homarr/db";
type TableKey = {
[K in keyof typeof schema]: (typeof schema)[K] extends { _: { brand: "Table" } } ? K : never;
}[keyof typeof schema];
export const createDbInsertCollection = <TTableKey extends TableKey>(tablesInInsertOrder: TTableKey[]) => {
const context = tablesInInsertOrder.reduce(
(acc, key) => {
acc[key] = [];
return acc;
},
{} as { [K in TTableKey]: InferInsertModel<(typeof schema)[K]>[] },
);
return {
...context,
insertAll: (db: Database) => {
db.transaction((transaction) => {
for (const [key, values] of objectEntries(context)) {
if (values.length >= 1) {
transaction
.insert(schema[key])
.values(values as never)
.run();
}
}
});
},
};
};

View File

@@ -0,0 +1,47 @@
import { encryptSecret } from "@homarr/common/server";
import { logger } from "@homarr/log";
import { mapAndDecryptIntegrations } from "../../mappers/map-integration";
import type { PreparedIntegration } from "../../prepare/prepare-integrations";
import { createDbInsertCollection } from "./common";
export const createIntegrationInsertCollection = (
preparedIntegrations: PreparedIntegration[],
encryptionToken: string | null,
) => {
const insertCollection = createDbInsertCollection(["integrations", "integrationSecrets"]);
logger.info(`Preparing integrations for insert collection count=${preparedIntegrations.length}`);
if (encryptionToken === null) {
logger.debug("Skipping integration decryption due to missing token");
return insertCollection;
}
const preparedIntegrationsDecrypted = mapAndDecryptIntegrations(preparedIntegrations, encryptionToken);
preparedIntegrationsDecrypted.forEach((integration) => {
insertCollection.integrations.push({
id: integration.id,
kind: integration.kind,
name: integration.name,
url: integration.url,
});
integration.secrets
.filter((secret) => secret.value !== null)
.forEach((secret) => {
insertCollection.integrationSecrets.push({
integrationId: integration.id,
kind: secret.field,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
value: encryptSecret(secret.value!),
});
});
});
logger.info(
`Added integrations and secrets to insert collection integrationCount=${insertCollection.integrations.length} secretCount=${insertCollection.integrationSecrets.length}`,
);
return insertCollection;
};

View File

@@ -0,0 +1,53 @@
import { createId } from "@homarr/db";
import { credentialsAdminGroup } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { mapAndDecryptUsers } from "../../mappers/map-user";
import type { OldmarrImportUser } from "../../user-schema";
import { createDbInsertCollection } from "./common";
export const createUserInsertCollection = (importUsers: OldmarrImportUser[], encryptionToken: string | null) => {
const insertCollection = createDbInsertCollection(["users", "groups", "groupMembers", "groupPermissions"]);
logger.info(`Preparing users for insert collection count=${importUsers.length}`);
if (encryptionToken === null) {
logger.debug("Skipping user decryption due to missing token");
return insertCollection;
}
const preparedUsers = mapAndDecryptUsers(importUsers, encryptionToken);
preparedUsers.forEach((user) => insertCollection.users.push(user));
logger.debug(`Added users to insert collection count=${insertCollection.users.length}`);
if (!preparedUsers.some((user) => user.isAdmin)) {
logger.warn("No admin users found, skipping admin group creation");
return insertCollection;
}
const adminGroupId = createId();
insertCollection.groups.push({
id: adminGroupId,
name: credentialsAdminGroup,
});
insertCollection.groupPermissions.push({
groupId: adminGroupId,
permission: "admin",
});
const admins = preparedUsers.filter((user) => user.isAdmin);
admins.forEach((user) => {
insertCollection.groupMembers.push({
groupId: adminGroupId,
userId: user.id,
});
});
logger.info(
`Added admin group and permissions to insert collection adminGroupId=${adminGroupId} adminUsersCount=${admins.length}`,
);
return insertCollection;
};

View File

@@ -0,0 +1,45 @@
import type { z } from "zod";
import { Stopwatch } from "@homarr/common";
import type { Database } from "@homarr/db";
import { logger } from "@homarr/log";
import { analyseOldmarrImportAsync } from "../analyse/analyse-oldmarr-import";
import { prepareMultipleImports } from "../prepare/prepare-multiple";
import { createBoardInsertCollection } from "./collections/board-collection";
import { createIntegrationInsertCollection } from "./collections/integration-collection";
import { createUserInsertCollection } from "./collections/user-collection";
import type { importInitialOldmarrInputSchema } from "./input";
import { ensureValidTokenOrThrow } from "./validate-token";
export const importInitialOldmarrAsync = async (
db: Database,
input: z.infer<typeof importInitialOldmarrInputSchema>,
) => {
const stopwatch = new Stopwatch();
const { checksum, configs, users: importUsers } = await analyseOldmarrImportAsync(input.file);
ensureValidTokenOrThrow(checksum, input.token);
const { preparedApps, preparedBoards, preparedIntegrations } = prepareMultipleImports(
configs,
input.settings,
input.boardSelections,
);
logger.info("Preparing import data in insert collections for database");
const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, input.settings);
const userInsertCollection = createUserInsertCollection(importUsers, input.token);
const integrationInsertCollection = createIntegrationInsertCollection(preparedIntegrations, input.token);
logger.info("Inserting import data to database");
// Due to a limitation with better-sqlite it's only possible to use it synchronously
db.transaction((transaction) => {
boardInsertCollection.insertAll(transaction);
userInsertCollection.insertAll(transaction);
integrationInsertCollection.insertAll(transaction);
});
logger.info(`Import successful (in ${stopwatch.getElapsedInHumanWords()})`);
};

View File

@@ -0,0 +1,36 @@
import { inArray } from "@homarr/db";
import type { Database } from "@homarr/db";
import { apps } from "@homarr/db/schema/sqlite";
import type { OldmarrConfig } from "@homarr/old-schema";
import { doAppsMatch } from "../prepare/prepare-apps";
import { prepareSingleImport } from "../prepare/prepare-single";
import type { OldmarrImportConfiguration } from "../settings";
import { createBoardInsertCollection } from "./collections/board-collection";
export const importSingleOldmarrConfigAsync = async (
db: Database,
config: OldmarrConfig,
settings: OldmarrImportConfiguration,
) => {
const { preparedApps, preparedBoards } = prepareSingleImport(config, settings);
const existingApps = await db.query.apps.findMany({
where: inArray(
apps.href,
preparedApps.map((app) => app.href).filter((href) => href !== null),
),
});
preparedApps.forEach((app) => {
const existingApp = existingApps.find((existingApp) => doAppsMatch(existingApp, app));
if (existingApp) {
app.existingId = existingApp.id;
}
return app;
});
const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, settings);
// Due to a limitation with better-sqlite it's only possible to use it synchronously
boardInsertCollection.insertAll(db);
};

View File

@@ -0,0 +1,3 @@
export { importInitialOldmarrAsync } from "./import-initial-oldmarr";
export * from "./input";
export { ensureValidTokenOrThrow } from "./validate-token";

View File

@@ -0,0 +1,24 @@
import SuperJSON from "superjson";
import { z } from "zod";
import { zfd } from "zod-form-data";
import { initialOldmarrImportSettings } from "../settings";
const boardSelectionMapSchema = z.map(
z.string(),
z.object({
sm: z.boolean().nullable(),
md: z.boolean().nullable(),
lg: z.boolean().nullable(),
}),
);
export const importInitialOldmarrInputSchema = zfd.formData({
file: zfd.file(),
settings: zfd.json(initialOldmarrImportSettings),
boardSelections: zfd.text().transform((value) => {
const map = boardSelectionMapSchema.parse(SuperJSON.parse(value));
return map;
}),
token: zfd.text().nullable(),
});

View File

@@ -0,0 +1,18 @@
import { decryptSecretWithKey } from "@homarr/common/server";
export const ensureValidTokenOrThrow = (checksum: string | undefined, encryptionToken: string | null) => {
if (!encryptionToken || !checksum) return;
const [first, second] = checksum.split("\n");
if (!first || !second) throw new Error("Malformed checksum");
const key = Buffer.from(encryptionToken, "hex");
let decrypted: string;
try {
decrypted = decryptSecretWithKey(second as `${string}.${string}`, key);
} catch {
throw new Error("Invalid checksum");
}
const isValid = decrypted === first;
if (!isValid) throw new Error("Invalid checksum");
};

View File

@@ -1,60 +1,13 @@
import type { Database } from "@homarr/db";
import type { OldmarrConfig } from "@homarr/old-schema";
import type { OldmarrImportConfiguration } from "@homarr/validation";
import { fixSectionIssues } from "./fix-section-issues";
import { insertAppsAsync } from "./import-apps";
import { insertBoardAsync } from "./import-board";
import { OldHomarrImportError, OldHomarrScreenSizeError } from "./import-error";
import { insertItemsAsync } from "./import-items";
import { insertSectionsAsync } from "./import-sections";
import { moveWidgetsAndAppsIfMerge } from "./move-widgets-and-apps-merge";
import type { BookmarkApp } from "./widgets/definitions/bookmark";
import { importSingleOldmarrConfigAsync } from "./import/import-single-oldmarr";
import type { OldmarrImportConfiguration } from "./settings";
export const importAsync = async (db: Database, old: OldmarrConfig, configuration: OldmarrImportConfiguration) => {
const bookmarkApps = old.widgets
.filter((widget) => widget.type === "bookmark")
.map((widget) => widget.properties.items)
.flat() as BookmarkApp[];
if (configuration.onlyImportApps) {
await db
.transaction(async (trasaction) => {
await insertAppsAsync(
trasaction,
old.apps,
bookmarkApps,
configuration.distinctAppsByHref,
old.configProperties.name,
);
})
.catch((error) => {
throw new OldHomarrImportError(old, error);
});
return;
}
await db
.transaction(async (trasaction) => {
const { wrappers, categories, wrapperIdsToMerge } = fixSectionIssues(old);
const { apps, widgets } = moveWidgetsAndAppsIfMerge(old, wrapperIdsToMerge, configuration);
const boardId = await insertBoardAsync(trasaction, old, configuration);
const sectionIdMaps = await insertSectionsAsync(trasaction, categories, wrappers, boardId);
const appsMap = await insertAppsAsync(
trasaction,
apps,
bookmarkApps,
configuration.distinctAppsByHref,
old.configProperties.name,
);
await insertItemsAsync(trasaction, widgets, apps, appsMap, sectionIdMaps, configuration);
})
.catch((error) => {
if (error instanceof OldHomarrScreenSizeError) {
throw error;
}
throw new OldHomarrImportError(old, error);
});
export const importOldmarrAsync = async (
db: Database,
old: OldmarrConfig,
configuration: OldmarrImportConfiguration,
) => {
await importSingleOldmarrConfigAsync(db, old, configuration);
};

View File

@@ -0,0 +1,27 @@
import type { InferSelectModel } from "@homarr/db";
import type { apps } from "@homarr/db/schema/sqlite";
import type { OldmarrApp } from "@homarr/old-schema";
import type { OldmarrBookmarkDefinition } from "../widgets/definitions/bookmark";
export const mapOldmarrApp = (app: OldmarrApp): InferSelectModel<typeof apps> => {
return {
id: app.id,
name: app.name,
iconUrl: app.appearance.iconUrl,
description: app.behaviour.tooltipDescription ?? null,
href: app.behaviour.externalUrl || app.url,
};
};
export const mapOldmarrBookmarkApp = (
app: OldmarrBookmarkDefinition["options"]["items"][number],
): InferSelectModel<typeof apps> => {
return {
id: app.id,
name: app.name,
iconUrl: app.iconUrl,
description: null,
href: app.href,
};
};

View File

@@ -0,0 +1,27 @@
import type { InferInsertModel } from "@homarr/db";
import { createId } from "@homarr/db";
import type { boards } from "@homarr/db/schema/sqlite";
import type { prepareMultipleImports } from "../prepare/prepare-multiple";
import { mapColor } from "./map-colors";
import { mapColumnCount } from "./map-column-count";
type PreparedBoard = ReturnType<typeof prepareMultipleImports>["preparedBoards"][number];
export const mapBoard = (preparedBoard: PreparedBoard): InferInsertModel<typeof boards> => ({
id: createId(),
name: preparedBoard.name,
backgroundImageAttachment: preparedBoard.config.settings.customization.backgroundImageAttachment,
backgroundImageUrl: preparedBoard.config.settings.customization.backgroundImageUrl,
backgroundImageRepeat: preparedBoard.config.settings.customization.backgroundImageRepeat,
backgroundImageSize: preparedBoard.config.settings.customization.backgroundImageSize,
columnCount: mapColumnCount(preparedBoard.config, preparedBoard.size),
faviconImageUrl: preparedBoard.config.settings.customization.faviconUrl,
isPublic: preparedBoard.config.settings.access.allowGuests,
logoImageUrl: preparedBoard.config.settings.customization.logoImageUrl,
pageTitle: preparedBoard.config.settings.customization.pageTitle,
metaTitle: preparedBoard.config.settings.customization.metaTitle,
opacity: preparedBoard.config.settings.customization.appOpacity,
primaryColor: mapColor(preparedBoard.config.settings.customization.colors.primary, "#fa5252"),
secondaryColor: mapColor(preparedBoard.config.settings.customization.colors.secondary, "#fd7e14"),
});

View File

@@ -1,5 +1,6 @@
import type { OldmarrConfig } from "@homarr/old-schema";
import type { OldmarrImportConfiguration } from "@homarr/validation";
import type { OldmarrImportConfiguration } from "../settings";
export const mapColumnCount = (old: OldmarrConfig, screenSize: OldmarrImportConfiguration["screenSize"]) => {
switch (screenSize) {

View File

@@ -0,0 +1,60 @@
import { decryptSecretWithKey } from "@homarr/common/server";
import { createId } from "@homarr/db";
import type { IntegrationKind } from "@homarr/definitions";
import type { OldmarrIntegrationType } from "@homarr/old-schema";
import type { PreparedIntegration } from "../prepare/prepare-integrations";
export const mapIntegrationType = (type: OldmarrIntegrationType) => {
const kind = mapping[type];
if (!kind) {
throw new Error(`Integration type ${type} is not supported yet`);
}
return kind;
};
const mapping: Record<OldmarrIntegrationType, IntegrationKind | null> = {
adGuardHome: "adGuardHome",
deluge: "deluge",
homeAssistant: "homeAssistant",
jellyfin: "jellyfin",
jellyseerr: "jellyseerr",
lidarr: "lidarr",
nzbGet: "nzbGet",
openmediavault: "openmediavault",
overseerr: "overseerr",
pihole: "piHole",
prowlarr: "prowlarr",
proxmox: null,
qBittorrent: "qBittorrent",
radarr: "radarr",
readarr: "readarr",
sabnzbd: "sabNzbd",
sonarr: "sonarr",
tdarr: null,
transmission: "transmission",
plex: "plex",
};
export const mapAndDecryptIntegrations = (
preparedIntegrations: PreparedIntegration[],
encryptionToken: string | null,
) => {
if (encryptionToken === null) {
return [];
}
const key = Buffer.from(encryptionToken, "hex");
return preparedIntegrations.map(({ type, name, url, properties }) => ({
id: createId(),
name,
url,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
kind: mapIntegrationType(type!),
secrets: properties.map((property) => ({
...property,
value: property.value ? decryptSecretWithKey(property.value as `${string}.${string}`, key) : null,
})),
}));
};

View File

@@ -0,0 +1,89 @@
import SuperJSON from "superjson";
import type { InferInsertModel } from "@homarr/db";
import { createId } from "@homarr/db";
import type { items } from "@homarr/db/schema/sqlite";
import { logger } from "@homarr/log";
import type { BoardSize, OldmarrApp, OldmarrWidget } from "@homarr/old-schema";
import type { WidgetComponentProps } from "../../../widgets/src/definition";
import { mapKind } from "../widgets/definitions";
import { mapOptions } from "../widgets/options";
export const mapApp = (
app: OldmarrApp,
boardSize: BoardSize,
appsMap: Map<string, { id: string }>,
sectionMap: Map<string, { id: string }>,
): InferInsertModel<typeof items> => {
if (app.area.type === "sidebar") throw new Error("Mapping app in sidebar is not supported");
const shapeForSize = app.shape[boardSize];
if (!shapeForSize) {
throw new Error(`Failed to find a shape for appId='${app.id}' screenSize='${boardSize}'`);
}
const sectionId = sectionMap.get(app.area.properties.id)?.id;
if (!sectionId) {
throw new Error(`Failed to find section for app appId='${app.id}' sectionId='${app.area.properties.id}'`);
}
return {
id: createId(),
sectionId,
height: shapeForSize.size.height,
width: shapeForSize.size.width,
xOffset: shapeForSize.location.x,
yOffset: shapeForSize.location.y,
kind: "app",
options: SuperJSON.stringify({
// it's safe to assume that the app exists in the map
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-non-null-asserted-optional-chain
appId: appsMap.get(app.id)?.id!,
openInNewTab: app.behaviour.isOpeningNewTab,
pingEnabled: app.network.enabledStatusChecker,
showDescriptionTooltip: app.behaviour.tooltipDescription !== "",
showTitle: app.appearance.appNameStatus === "normal",
} satisfies WidgetComponentProps<"app">["options"]),
};
};
export const mapWidget = (
widget: OldmarrWidget,
boardSize: BoardSize,
appsMap: Map<string, { id: string }>,
sectionMap: Map<string, { id: string }>,
): InferInsertModel<typeof items> | null => {
if (widget.area.type === "sidebar") throw new Error("Mapping widget in sidebar is not supported");
const shapeForSize = widget.shape[boardSize];
if (!shapeForSize) {
throw new Error(`Failed to find a shape for widgetId='${widget.id}' screenSize='${boardSize}'`);
}
const kind = mapKind(widget.type);
if (!kind) {
logger.warn(`Failed to map widget type='${widget.type}'. It's no longer supported`);
return null;
}
const sectionId = sectionMap.get(widget.area.properties.id)?.id;
if (!sectionId) {
throw new Error(
`Failed to find section for widget widgetId='${widget.id}' sectionId='${widget.area.properties.id}'`,
);
}
return {
id: createId(),
sectionId,
height: shapeForSize.size.height,
width: shapeForSize.size.width,
xOffset: shapeForSize.location.x,
yOffset: shapeForSize.location.y,
kind,
options: SuperJSON.stringify(
mapOptions(kind, widget.properties, new Map([...appsMap.entries()].map(([key, value]) => [key, value.id]))),
),
};
};

View File

@@ -0,0 +1,24 @@
import type { InferInsertModel } from "@homarr/db";
import { createId } from "@homarr/db";
import type { sections } from "@homarr/db/schema/sqlite";
import type { OldmarrCategorySection, OldmarrEmptySection } from "@homarr/old-schema";
export const mapCategorySection = (
boardId: string,
category: OldmarrCategorySection,
): InferInsertModel<typeof sections> => ({
id: createId(),
boardId,
kind: "category",
xOffset: 0,
yOffset: category.position,
name: category.name,
});
export const mapEmptySection = (boardId: string, wrapper: OldmarrEmptySection): InferInsertModel<typeof sections> => ({
id: createId(),
boardId,
kind: "empty",
xOffset: 0,
yOffset: wrapper.position,
});

View File

@@ -0,0 +1,35 @@
import { decryptSecretWithKey } from "@homarr/common/server";
import type { InferInsertModel } from "@homarr/db";
import { createId } from "@homarr/db";
import type { users } from "@homarr/db/schema/sqlite";
import type { OldmarrImportUser } from "../user-schema";
export const mapAndDecryptUsers = (importUsers: OldmarrImportUser[], encryptionToken: string | null) => {
if (encryptionToken === null) {
return [];
}
const key = Buffer.from(encryptionToken, "hex");
return importUsers.map(
({
id,
password,
salt,
settings,
...user
}): InferInsertModel<typeof users> & { oldId: string; isAdmin: boolean } => ({
...user,
oldId: id,
id: createId(),
colorScheme: settings?.colorScheme === "environment" ? undefined : settings?.colorScheme,
firstDayOfWeek: settings?.firstDayOfWeek === "sunday" ? 0 : settings?.firstDayOfWeek === "monday" ? 1 : 6,
provider: "credentials",
pingIconsEnabled: settings?.replacePingWithIcons,
isAdmin: user.isAdmin || user.isOwner,
password: decryptSecretWithKey(password, key),
salt: decryptSecretWithKey(salt, key),
}),
);
};

View File

@@ -1,10 +1,10 @@
import { objectEntries } from "@homarr/common";
import { logger } from "@homarr/log";
import type { OldmarrApp, OldmarrConfig, OldmarrWidget } from "@homarr/old-schema";
import type { OldmarrImportConfiguration } from "@homarr/validation";
import { OldHomarrScreenSizeError } from "./import-error";
import { mapColumnCount } from "./mappers/map-column-count";
import type { OldmarrImportConfiguration } from "./settings";
export const moveWidgetsAndAppsIfMerge = (
old: OldmarrConfig,

View File

@@ -0,0 +1,59 @@
import type { InferSelectModel } from "@homarr/db";
import type { apps } from "@homarr/db/schema/sqlite";
import type { OldmarrConfig } from "@homarr/old-schema";
import type { ValidAnalyseConfig } from "../analyse/types";
import { mapOldmarrApp, mapOldmarrBookmarkApp } from "../mappers/map-app";
import type { OldmarrBookmarkDefinition } from "../widgets/definitions/bookmark";
export type PreparedApp = Omit<InferSelectModel<typeof apps>, "id"> & { ids: string[]; existingId?: string };
export const prepareApps = (analyseConfigs: ValidAnalyseConfig[]) => {
const preparedApps: PreparedApp[] = [];
analyseConfigs.forEach(({ config }) => {
const appsFromConfig = extractAppsFromConfig(config).concat(extractBookmarkAppsFromConfig(config));
addAppsToPreparedApps(preparedApps, appsFromConfig);
});
return preparedApps;
};
const extractAppsFromConfig = (config: OldmarrConfig) => {
return config.apps.map(mapOldmarrApp);
};
const extractBookmarkAppsFromConfig = (config: OldmarrConfig) => {
const bookmarkWidgets = config.widgets.filter((widget) => widget.type === "bookmark");
return bookmarkWidgets.flatMap((widget) =>
(widget.properties as OldmarrBookmarkDefinition["options"]).items.map(mapOldmarrBookmarkApp),
);
};
const addAppsToPreparedApps = (preparedApps: PreparedApp[], configApps: InferSelectModel<typeof apps>[]) => {
configApps.forEach(({ id, ...app }) => {
const existingApp = preparedApps.find((preparedApp) => doAppsMatch(preparedApp, app));
if (existingApp) {
existingApp.ids.push(id);
return;
}
preparedApps.push({
...app,
ids: [id],
});
});
};
export const doAppsMatch = (
app1: Omit<InferSelectModel<typeof apps>, "id">,
app2: Omit<InferSelectModel<typeof apps>, "id">,
) => {
return (
app1.name === app2.name &&
app1.iconUrl === app2.iconUrl &&
app1.description === app2.description &&
app1.href === app2.href
);
};

View File

@@ -0,0 +1,34 @@
import { objectEntries } from "@homarr/common";
import type { BoardSize } from "@homarr/old-schema";
import type { ValidAnalyseConfig } from "../analyse/types";
import type { BoardSelectionMap } from "../components/initial/board-selection-card";
const boardSizeSuffix: Record<BoardSize, string> = {
lg: "large",
md: "medium",
sm: "small",
};
export const createBoardName = (fileName: string, boardSize: BoardSize) => {
return `${fileName.replace(".json", "")}-${boardSizeSuffix[boardSize]}`;
};
export const prepareBoards = (analyseConfigs: ValidAnalyseConfig[], selections: BoardSelectionMap) => {
return analyseConfigs.flatMap(({ name, config }) => {
const selectedSizes = selections.get(name);
if (!selectedSizes) return [];
return objectEntries(selectedSizes)
.map(([size, selected]) => {
if (!selected) return null;
return {
name: createBoardName(name, size),
size,
config,
};
})
.filter((board) => board !== null);
});
};

View File

@@ -0,0 +1,19 @@
import type { ValidAnalyseConfig } from "../analyse/types";
export type PreparedIntegration = ReturnType<typeof prepareIntegrations>[number];
export const prepareIntegrations = (analyseConfigs: ValidAnalyseConfig[]) => {
return analyseConfigs.flatMap(({ config }) => {
return config.apps
.map((app) =>
app.integration?.type
? {
...app.integration,
name: app.name,
url: app.url,
}
: null,
)
.filter((integration) => integration !== null);
});
};

View File

@@ -0,0 +1,14 @@
import type { BoardSize, OldmarrConfig } from "@homarr/old-schema";
import { mapApp, mapWidget } from "../mappers/map-item";
export const prepareItems = (
{ apps, widgets }: Pick<OldmarrConfig, "apps" | "widgets">,
boardSize: BoardSize,
appsMap: Map<string, { id: string }>,
sectionMap: Map<string, { id: string }>,
) =>
widgets
.map((widget) => mapWidget(widget, boardSize, appsMap, sectionMap))
.filter((widget) => widget !== null)
.concat(apps.map((app) => mapApp(app, boardSize, appsMap, sectionMap)));

View File

@@ -0,0 +1,25 @@
import type { AnalyseConfig, ValidAnalyseConfig } from "../analyse/types";
import type { BoardSelectionMap } from "../components/initial/board-selection-card";
import type { InitialOldmarrImportSettings } from "../settings";
import { prepareApps } from "./prepare-apps";
import { prepareBoards } from "./prepare-boards";
import { prepareIntegrations } from "./prepare-integrations";
export const prepareMultipleImports = (
analyseConfigs: AnalyseConfig[],
settings: InitialOldmarrImportSettings,
selections: BoardSelectionMap,
) => {
const invalidConfigs = analyseConfigs.filter((item) => item.config === null);
invalidConfigs.forEach(({ name }) => {
console.warn(`Skipping import of ${name} due to error in configuration. See logs of container for more details.`);
});
const filteredConfigs = analyseConfigs.filter((item): item is ValidAnalyseConfig => item.config !== null);
return {
preparedApps: prepareApps(filteredConfigs),
preparedBoards: settings.onlyImportApps ? [] : prepareBoards(filteredConfigs, selections),
preparedIntegrations: prepareIntegrations(filteredConfigs),
};
};

View File

@@ -0,0 +1,13 @@
import type { OldmarrConfig } from "@homarr/old-schema";
import { mapCategorySection, mapEmptySection } from "../mappers/map-section";
export const prepareSections = (
boardId: string,
{ categories, wrappers }: Pick<OldmarrConfig, "categories" | "wrappers">,
) =>
new Map(
categories
.map((category) => [category.id, mapCategorySection(boardId, category)] as const)
.concat(wrappers.map((wrapper) => [wrapper.id, mapEmptySection(boardId, wrapper)] as const)),
);

View File

@@ -0,0 +1,21 @@
import type { OldmarrConfig } from "@homarr/old-schema";
import type { OldmarrImportConfiguration } from "../settings";
import { prepareApps } from "./prepare-apps";
export const prepareSingleImport = (config: OldmarrConfig, settings: OldmarrImportConfiguration) => {
const validAnalyseConfigs = [{ name: settings.name, config, isError: false }];
return {
preparedApps: prepareApps(validAnalyseConfigs),
preparedBoards: settings.onlyImportApps
? []
: [
{
name: settings.name,
size: settings.screenSize,
config,
},
],
};
};

View File

@@ -0,0 +1,63 @@
import { z } from "zod";
import { zfd } from "zod-form-data";
import { boardSizes } from "@homarr/old-schema";
import { validation, zodEnumFromArray } from "@homarr/validation";
import { createCustomErrorParams } from "@homarr/validation/form";
export const sidebarBehaviours = ["remove-items", "last-section"] as const;
export const defaultSidebarBehaviour = "last-section";
export type SidebarBehaviour = (typeof sidebarBehaviours)[number];
export const oldmarrImportConfigurationSchema = z.object({
name: validation.board.name,
onlyImportApps: z.boolean().default(false),
screenSize: zodEnumFromArray(boardSizes).default("lg"),
sidebarBehaviour: z.enum(sidebarBehaviours).default(defaultSidebarBehaviour),
});
export type OldmarrImportConfiguration = z.infer<typeof oldmarrImportConfigurationSchema>;
export const initialOldmarrImportSettings = oldmarrImportConfigurationSchema.pick({
onlyImportApps: true,
sidebarBehaviour: true,
});
export type InitialOldmarrImportSettings = z.infer<typeof initialOldmarrImportSettings>;
export const superRefineJsonImportFile = (value: File | null, context: z.RefinementCtx) => {
if (!value) {
return context.addIssue({
code: "invalid_type",
expected: "object",
received: "null",
});
}
if (value.type !== "application/json") {
return context.addIssue({
code: "custom",
params: createCustomErrorParams({
key: "invalidFileType",
params: { expected: "JSON" },
}),
});
}
if (value.size > 1024 * 1024) {
return context.addIssue({
code: "custom",
params: createCustomErrorParams({
key: "fileTooLarge",
params: { maxSize: "1 MB" },
}),
});
}
return null;
};
export const importJsonFileSchema = zfd.formData({
file: zfd.file().superRefine(superRefineJsonImportFile),
configuration: zfd.json(oldmarrImportConfigurationSchema),
});

View File

@@ -0,0 +1,2 @@
export { importJsonFileSchema, superRefineJsonImportFile, oldmarrImportConfigurationSchema } from "./settings";
export type { OldmarrImportConfiguration } from "./settings";

View File

@@ -0,0 +1,27 @@
import { z } from "zod";
const regexEncryptedSchema = z.string().regex(/^[a-f0-9]+\.[a-f0-9]+$/g);
const encryptedSchema = z.custom<`${string}.${string}`>((value) => regexEncryptedSchema.safeParse(value).success);
export const oldmarrImportUserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email().nullable(),
emailVerified: z.date().nullable(),
image: z.string().nullable(),
isAdmin: z.boolean(),
isOwner: z.boolean(),
settings: z
.object({
colorScheme: z.enum(["environment", "light", "dark"]),
defaultBoard: z.string(),
firstDayOfWeek: z.enum(["monday", "saturday", "sunday"]),
replacePingWithIcons: z.boolean(),
})
.nullable(),
password: encryptedSchema,
salt: encryptedSchema,
});
export type OldmarrImportUser = z.infer<typeof oldmarrImportUserSchema>;

View File

@@ -22,6 +22,7 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"zod": "^3.24.1"
},
"devDependencies": {

View File

@@ -47,6 +47,8 @@ const integrationSchema = z.enum([
"tdarr",
]);
export type OldmarrIntegrationType = z.infer<typeof integrationSchema>;
const appIntegrationPropertySchema = z.object({
type: z.enum(["private", "public"]),
field: z.enum(["apiKey", "password", "username"]),

View File

@@ -28,3 +28,5 @@ export const oldmarrConfigSchema = z.object({
});
export type OldmarrConfig = z.infer<typeof oldmarrConfigSchema>;
export type OldmarrCategorySection = z.infer<typeof categorySchema>;
export type OldmarrEmptySection = z.infer<typeof wrapperSchema>;

View File

@@ -1,5 +1,7 @@
export type { OldmarrConfig } from "./config";
export type { OldmarrConfig, OldmarrCategorySection, OldmarrEmptySection } from "./config";
export { oldmarrConfigSchema } from "./config";
export type { OldmarrApp } from "./app";
export type { OldmarrApp, OldmarrIntegrationType } from "./app";
export type { OldmarrWidget, OldmarrWidgetKind } from "./widget";
export { oldmarrWidgetKinds } from "./widget";
export { boardSizes } from "./tile";
export type { BoardSize } from "./tile";

View File

@@ -32,11 +32,17 @@ const accessSettingsSchema = z.object({
allowGuests: z.boolean(),
});
const gridstackSettingsSchema = z.object({
columnCountSmall: z.number(),
columnCountMedium: z.number(),
columnCountLarge: z.number(),
});
const gridstackSettingsSchema = z
.object({
columnCountSmall: z.number(),
columnCountMedium: z.number(),
columnCountLarge: z.number(),
})
.catch({
columnCountSmall: 3,
columnCountMedium: 6,
columnCountLarge: 12,
});
const layoutSettingsSchema = z.object({
enabledLeftSidebar: z.boolean(),

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