feat: add onboarding with oldmarr import (#1606)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
23
apps/nextjs/src/app/[locale]/init/_steps/back.tsx
Normal file
23
apps/nextjs/src/app/[locale]/init/_steps/back.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
11
apps/nextjs/src/app/[locale]/init/_steps/user/init-user.tsx
Normal file
11
apps/nextjs/src/app/[locale]/init/_steps/user/init-user.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
56
apps/nextjs/src/app/[locale]/init/page.tsx
Normal file
56
apps/nextjs/src/app/[locale]/init/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = []) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
42
packages/api/src/router/import/import-router.ts
Normal file
42
packages/api/src/router/import/import-router.ts
Normal 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);
|
||||
}),
|
||||
});
|
||||
81
packages/api/src/router/onboard/onboard-queries.ts
Normal file
81
packages/api/src/router/onboard/onboard-queries.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
34
packages/api/src/router/onboard/onboard-router.ts
Normal file
34
packages/api/src/router/onboard/onboard-router.ts
Normal 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",
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from "./url";
|
||||
export * from "./number";
|
||||
export * from "./error";
|
||||
export * from "./fetch-with-timeout";
|
||||
export * from "./theme";
|
||||
|
||||
5
packages/common/src/theme.ts
Normal file
5
packages/common/src/theme.ts
Normal 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";
|
||||
6
packages/db/migrations/mysql/0017_tired_penance.sql
Normal file
6
packages/db/migrations/mysql/0017_tired_penance.sql
Normal 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`)
|
||||
);
|
||||
1663
packages/db/migrations/mysql/meta/0017_snapshot.json
Normal file
1663
packages/db/migrations/mysql/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE `onboarding` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`step` text NOT NULL,
|
||||
`previous_step` text
|
||||
);
|
||||
1587
packages/db/migrations/sqlite/meta/0017_snapshot.json
Normal file
1587
packages/db/migrations/sqlite/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export const everyoneGroup = "everyone";
|
||||
export const credentialsAdminGroup = "admin";
|
||||
|
||||
@@ -10,3 +10,4 @@ export * from "./group";
|
||||
export * from "./docs";
|
||||
export * from "./cookie";
|
||||
export * from "./search-engine";
|
||||
export * from "./onboarding";
|
||||
|
||||
2
packages/definitions/src/onboarding.ts
Normal file
2
packages/definitions/src/onboarding.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const onboardingSteps = ["start", "import", "user", "group", "settings", "finish"] as const;
|
||||
export type OnboardingStep = (typeof onboardingSteps)[number];
|
||||
@@ -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"
|
||||
|
||||
21
packages/form/src/types.ts
Normal file
21
packages/form/src/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
64
packages/old-import/src/analyse/analyse-oldmarr-import.ts
Normal file
64
packages/old-import/src/analyse/analyse-oldmarr-import.ts
Normal 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 ?? [];
|
||||
};
|
||||
2
packages/old-import/src/analyse/index.ts
Normal file
2
packages/old-import/src/analyse/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./input";
|
||||
export { analyseOldmarrImportForRouterAsync } from "./analyse-oldmarr-import";
|
||||
5
packages/old-import/src/analyse/input.ts
Normal file
5
packages/old-import/src/analyse/input.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { zfd } from "zod-form-data";
|
||||
|
||||
export const analyseOldmarrImportInputSchema = zfd.formData({
|
||||
file: zfd.file(),
|
||||
});
|
||||
7
packages/old-import/src/analyse/types.ts
Normal file
7
packages/old-import/src/analyse/types.ts
Normal 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 }>;
|
||||
3
packages/old-import/src/components/index.ts
Normal file
3
packages/old-import/src/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { InitialOldmarrImport } from "./initial-oldmarr-import";
|
||||
export { SidebarBehaviourSelect } from "./shared/sidebar-behaviour-select";
|
||||
export { OldmarrImportAppsSettings } from "./shared/apps-section";
|
||||
112
packages/old-import/src/components/initial-oldmarr-import.tsx
Normal file
112
packages/old-import/src/components/initial-oldmarr-import.tsx
Normal 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));
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
67
packages/old-import/src/components/initial/token-modal.tsx
Normal file
67
packages/old-import/src/components/initial/token-modal.tsx
Normal 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") });
|
||||
23
packages/old-import/src/components/shared/apps-section.tsx
Normal file
23
packages/old-import/src/components/shared/apps-section.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
33
packages/old-import/src/import/collections/common.ts
Normal file
33
packages/old-import/src/import/collections/common.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
45
packages/old-import/src/import/import-initial-oldmarr.ts
Normal file
45
packages/old-import/src/import/import-initial-oldmarr.ts
Normal 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()})`);
|
||||
};
|
||||
36
packages/old-import/src/import/import-single-oldmarr.ts
Normal file
36
packages/old-import/src/import/import-single-oldmarr.ts
Normal 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);
|
||||
};
|
||||
3
packages/old-import/src/import/index.ts
Normal file
3
packages/old-import/src/import/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { importInitialOldmarrAsync } from "./import-initial-oldmarr";
|
||||
export * from "./input";
|
||||
export { ensureValidTokenOrThrow } from "./validate-token";
|
||||
24
packages/old-import/src/import/input.ts
Normal file
24
packages/old-import/src/import/input.ts
Normal 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(),
|
||||
});
|
||||
18
packages/old-import/src/import/validate-token.ts
Normal file
18
packages/old-import/src/import/validate-token.ts
Normal 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");
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
27
packages/old-import/src/mappers/map-app.ts
Normal file
27
packages/old-import/src/mappers/map-app.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
27
packages/old-import/src/mappers/map-board.ts
Normal file
27
packages/old-import/src/mappers/map-board.ts
Normal 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"),
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
60
packages/old-import/src/mappers/map-integration.ts
Normal file
60
packages/old-import/src/mappers/map-integration.ts
Normal 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,
|
||||
})),
|
||||
}));
|
||||
};
|
||||
89
packages/old-import/src/mappers/map-item.ts
Normal file
89
packages/old-import/src/mappers/map-item.ts
Normal 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]))),
|
||||
),
|
||||
};
|
||||
};
|
||||
24
packages/old-import/src/mappers/map-section.ts
Normal file
24
packages/old-import/src/mappers/map-section.ts
Normal 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,
|
||||
});
|
||||
35
packages/old-import/src/mappers/map-user.ts
Normal file
35
packages/old-import/src/mappers/map-user.ts
Normal 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),
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
59
packages/old-import/src/prepare/prepare-apps.ts
Normal file
59
packages/old-import/src/prepare/prepare-apps.ts
Normal 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
|
||||
);
|
||||
};
|
||||
34
packages/old-import/src/prepare/prepare-boards.ts
Normal file
34
packages/old-import/src/prepare/prepare-boards.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
19
packages/old-import/src/prepare/prepare-integrations.ts
Normal file
19
packages/old-import/src/prepare/prepare-integrations.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
14
packages/old-import/src/prepare/prepare-items.ts
Normal file
14
packages/old-import/src/prepare/prepare-items.ts
Normal 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)));
|
||||
25
packages/old-import/src/prepare/prepare-multiple.ts
Normal file
25
packages/old-import/src/prepare/prepare-multiple.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
13
packages/old-import/src/prepare/prepare-sections.ts
Normal file
13
packages/old-import/src/prepare/prepare-sections.ts
Normal 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)),
|
||||
);
|
||||
21
packages/old-import/src/prepare/prepare-single.ts
Normal file
21
packages/old-import/src/prepare/prepare-single.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
63
packages/old-import/src/settings.ts
Normal file
63
packages/old-import/src/settings.ts
Normal 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),
|
||||
});
|
||||
2
packages/old-import/src/shared.ts
Normal file
2
packages/old-import/src/shared.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { importJsonFileSchema, superRefineJsonImportFile, oldmarrImportConfigurationSchema } from "./settings";
|
||||
export type { OldmarrImportConfiguration } from "./settings";
|
||||
27
packages/old-import/src/user-schema.ts
Normal file
27
packages/old-import/src/user-schema.ts
Normal 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>;
|
||||
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user