feat: add onboarding with oldmarr import (#1606)
This commit is contained in:
@@ -29,6 +29,7 @@
|
|||||||
"@homarr/modals": "workspace:^0.1.0",
|
"@homarr/modals": "workspace:^0.1.0",
|
||||||
"@homarr/modals-collection": "workspace:^0.1.0",
|
"@homarr/modals-collection": "workspace:^0.1.0",
|
||||||
"@homarr/notifications": "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/old-schema": "workspace:^0.1.0",
|
||||||
"@homarr/redis": "workspace:^0.1.0",
|
"@homarr/redis": "workspace:^0.1.0",
|
||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"@homarr/widgets": "workspace:^0.1.0",
|
"@homarr/widgets": "workspace:^0.1.0",
|
||||||
"@mantine/colors-generator": "^7.15.1",
|
"@mantine/colors-generator": "^7.15.1",
|
||||||
"@mantine/core": "^7.15.1",
|
"@mantine/core": "^7.15.1",
|
||||||
|
"@mantine/dropzone": "^7.15.1",
|
||||||
"@mantine/hooks": "^7.15.1",
|
"@mantine/hooks": "^7.15.1",
|
||||||
"@mantine/modals": "^7.15.1",
|
"@mantine/modals": "^7.15.1",
|
||||||
"@mantine/tiptap": "^7.15.1",
|
"@mantine/tiptap": "^7.15.1",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
wsLink,
|
wsLink,
|
||||||
} from "@trpc/client";
|
} from "@trpc/client";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
import type { SuperJSONResult } from "superjson";
|
||||||
|
|
||||||
import type { AppRouter } from "@homarr/api";
|
import type { AppRouter } from "@homarr/api";
|
||||||
import { clientApi, createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/client";
|
import { clientApi, createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/client";
|
||||||
@@ -82,8 +83,8 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
serialize(object: unknown) {
|
serialize(object: unknown) {
|
||||||
return object;
|
return object;
|
||||||
},
|
},
|
||||||
deserialize(data: unknown) {
|
deserialize(data: SuperJSONResult) {
|
||||||
return data;
|
return superjson.deserialize<unknown>(data);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
url: getTrpcUrl(),
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
@@ -12,9 +12,9 @@ import type { z } from "@homarr/validation";
|
|||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
export const InitUserForm = () => {
|
export const InitUserForm = () => {
|
||||||
const router = useRouter();
|
|
||||||
const t = useScopedI18n("user");
|
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, {
|
const form = useZodForm(validation.user.init, {
|
||||||
initialValues: {
|
initialValues: {
|
||||||
username: "",
|
username: "",
|
||||||
@@ -25,17 +25,17 @@ export const InitUserForm = () => {
|
|||||||
|
|
||||||
const handleSubmitAsync = async (values: FormType) => {
|
const handleSubmitAsync = async (values: FormType) => {
|
||||||
await mutateAsync(values, {
|
await mutateAsync(values, {
|
||||||
onSuccess: () => {
|
async onSuccess() {
|
||||||
showSuccessNotification({
|
showSuccessNotification({
|
||||||
title: "User created",
|
title: tUser("notification.success.title"),
|
||||||
message: "You can now log in",
|
message: tUser("notification.success.message"),
|
||||||
});
|
});
|
||||||
router.push("/auth/login");
|
await revalidatePathActionAsync("/init");
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (error) => {
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
title: "User creation failed",
|
title: tUser("notification.error.title"),
|
||||||
message: error?.message ?? "Unknown error",
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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 { useDisclosure } from "@mantine/hooks";
|
||||||
import { IconEye, IconEyeOff } from "@tabler/icons-react";
|
import { IconEye, IconEyeOff } from "@tabler/icons-react";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||||
import { integrationSecretKindObject } from "@homarr/definitions";
|
import { integrationSecretKindObject } from "@homarr/definitions";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
@@ -16,7 +17,9 @@ import { integrationSecretIcons } from "./integration-secret-icons";
|
|||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
interface SecretCardProps {
|
interface SecretCardProps {
|
||||||
secret: RouterOutputs["integration"]["byId"]["secrets"][number];
|
secret:
|
||||||
|
| RouterOutputs["integration"]["byId"]["secrets"][number]
|
||||||
|
| { kind: IntegrationSecretKind; value: null; updatedAt: null };
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
onCancel: () => Promise<boolean>;
|
onCancel: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
@@ -41,11 +44,19 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
|||||||
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
|
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
|
||||||
</Group>
|
</Group>
|
||||||
<Group>
|
<Group>
|
||||||
<Text c="gray.6" size="sm">
|
{secret.updatedAt ? (
|
||||||
{t("integration.secrets.lastUpdated", {
|
<Text c="gray.6" size="sm">
|
||||||
date: dayjs().to(dayjs(secret.updatedAt)),
|
{t("integration.secrets.lastUpdated", {
|
||||||
})}
|
date: dayjs().to(dayjs(secret.updatedAt)),
|
||||||
</Text>
|
})}
|
||||||
|
</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 ? (
|
{isPublic ? (
|
||||||
<ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}>
|
<ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}>
|
||||||
<DisplayIcon size={16} stroke={1.5} />
|
<DisplayIcon size={16} stroke={1.5} />
|
||||||
|
|||||||
@@ -98,8 +98,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
{secretsKinds.map((kind, index) => (
|
{secretsKinds.map((kind, index) => (
|
||||||
<SecretCard
|
<SecretCard
|
||||||
key={kind}
|
key={kind}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
secret={secretsMap.get(kind) ?? { kind, value: null, updatedAt: null }}
|
||||||
secret={secretsMap.get(kind)!}
|
|
||||||
onCancel={() =>
|
onCancel={() =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
// When nothing changed, just close the secret card
|
// 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";
|
import { LanguageCombobox } from "./language-combobox";
|
||||||
|
|
||||||
export const CurrentLanguageCombobox = () => {
|
interface CurrentLanguageComboboxProps {
|
||||||
|
width?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CurrentLanguageCombobox = ({ width }: CurrentLanguageComboboxProps) => {
|
||||||
const currentLocale = useCurrentLocale();
|
const currentLocale = useCurrentLocale();
|
||||||
const { changeLocale, isPending } = useChangeLocale();
|
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 classes from "./language-combobox.module.css";
|
||||||
|
|
||||||
|
import "flag-icons/css/flag-icons.min.css";
|
||||||
|
|
||||||
interface LanguageComboboxProps {
|
interface LanguageComboboxProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
value: SupportedLanguage;
|
value: SupportedLanguage;
|
||||||
onChange: (value: SupportedLanguage) => void;
|
onChange: (value: SupportedLanguage) => void;
|
||||||
isPending?: boolean;
|
isPending?: boolean;
|
||||||
|
width?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LanguageCombobox = ({ label, value, onChange, isPending }: LanguageComboboxProps) => {
|
export const LanguageCombobox = ({ label, value, onChange, isPending, width }: LanguageComboboxProps) => {
|
||||||
const combobox = useCombobox({
|
const combobox = useCombobox({
|
||||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||||
});
|
});
|
||||||
@@ -49,6 +52,7 @@ export const LanguageCombobox = ({ label, value, onChange, isPending }: Language
|
|||||||
rightSectionPointerEvents="none"
|
rightSectionPointerEvents="none"
|
||||||
onClick={handleOnClick}
|
onClick={handleOnClick}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
|
w={width}
|
||||||
>
|
>
|
||||||
<OptionItem currentLocale={value} localeKey={value} />
|
<OptionItem currentLocale={value} localeKey={value} />
|
||||||
</InputBase>
|
</InputBase>
|
||||||
|
|||||||
@@ -18,14 +18,11 @@ import {
|
|||||||
IconTool,
|
IconTool,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { signOut, useSession } from "@homarr/auth/client";
|
import { signOut, useSession } from "@homarr/auth/client";
|
||||||
import { createModal, useModalAction } from "@homarr/modals";
|
import { createModal, useModalAction } from "@homarr/modals";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
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 { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { createTRPCClient, httpLink } from "@trpc/client";
|
import { createTRPCClient, httpLink } from "@trpc/client";
|
||||||
import SuperJSON from "superjson";
|
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
|
// In next 15 we will be able to use node apis and such the db directly
|
||||||
const culture = await serverFetchApi.serverSettings.getCulture.query();
|
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
|
// We don't want to fallback to accept-language header so we clear it
|
||||||
request.headers.set("accept-language", "");
|
request.headers.set("accept-language", "");
|
||||||
const next = createI18nMiddleware(culture.defaultLocale);
|
const next = createI18nMiddleware(culture.defaultLocale);
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import { dockerRouter } from "./router/docker/docker-router";
|
|||||||
import { groupRouter } from "./router/group";
|
import { groupRouter } from "./router/group";
|
||||||
import { homeRouter } from "./router/home";
|
import { homeRouter } from "./router/home";
|
||||||
import { iconsRouter } from "./router/icons";
|
import { iconsRouter } from "./router/icons";
|
||||||
|
import { importRouter } from "./router/import/import-router";
|
||||||
import { integrationRouter } from "./router/integration/integration-router";
|
import { integrationRouter } from "./router/integration/integration-router";
|
||||||
import { inviteRouter } from "./router/invite";
|
import { inviteRouter } from "./router/invite";
|
||||||
import { locationRouter } from "./router/location";
|
import { locationRouter } from "./router/location";
|
||||||
import { logRouter } from "./router/log";
|
import { logRouter } from "./router/log";
|
||||||
import { mediaRouter } from "./router/medias/media-router";
|
import { mediaRouter } from "./router/medias/media-router";
|
||||||
|
import { onboardRouter } from "./router/onboard/onboard-router";
|
||||||
import { searchEngineRouter } from "./router/search-engine/search-engine-router";
|
import { searchEngineRouter } from "./router/search-engine/search-engine-router";
|
||||||
import { serverSettingsRouter } from "./router/serverSettings";
|
import { serverSettingsRouter } from "./router/serverSettings";
|
||||||
import { updateCheckerRouter } from "./router/update-checker";
|
import { updateCheckerRouter } from "./router/update-checker";
|
||||||
@@ -30,6 +32,8 @@ export const appRouter = createTRPCRouter({
|
|||||||
location: locationRouter,
|
location: locationRouter,
|
||||||
log: logRouter,
|
log: logRouter,
|
||||||
icon: iconsRouter,
|
icon: iconsRouter,
|
||||||
|
import: importRouter,
|
||||||
|
onboard: onboardRouter,
|
||||||
home: homeRouter,
|
home: homeRouter,
|
||||||
docker: dockerRouter,
|
docker: dockerRouter,
|
||||||
serverSettings: serverSettingsRouter,
|
serverSettings: serverSettingsRouter,
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ import {
|
|||||||
} from "@homarr/db/schema/sqlite";
|
} from "@homarr/db/schema/sqlite";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { getPermissionsWithParents, widgetKinds } 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 { oldmarrConfigSchema } from "@homarr/old-schema";
|
||||||
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
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 { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||||
|
|
||||||
@@ -575,13 +575,11 @@ export const boardRouter = createTRPCRouter({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
importOldmarrConfig: protectedProcedure
|
importOldmarrConfig: protectedProcedure.input(importJsonFileSchema).mutation(async ({ input, ctx }) => {
|
||||||
.input(validation.board.importOldmarrConfig)
|
const content = await input.file.text();
|
||||||
.mutation(async ({ input, ctx }) => {
|
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
|
||||||
const content = await input.file.text();
|
await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
|
||||||
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
|
}),
|
||||||
await importAsync(ctx.db, oldmarr, input.configuration);
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => {
|
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 { everyoneGroup } from "@homarr/definitions";
|
||||||
import { validation, z } from "@homarr/validation";
|
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 { throwIfCredentialsDisabled } from "./invite/checks";
|
||||||
|
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||||
|
|
||||||
export const groupRouter = createTRPCRouter({
|
export const groupRouter = createTRPCRouter({
|
||||||
getPaginated: permissionRequiredProcedure
|
getPaginated: permissionRequiredProcedure
|
||||||
@@ -145,6 +146,19 @@ export const groupRouter = createTRPCRouter({
|
|||||||
limit: input.limit,
|
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
|
createGroup: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
.input(validation.group.create)
|
.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 { getServerSettingByKeyAsync, getServerSettingsAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||||
import type { ServerSettings } from "@homarr/server-settings";
|
import type { ServerSettings } from "@homarr/server-settings";
|
||||||
import { defaultServerSettingsKeys } 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({
|
export const serverSettingsRouter = createTRPCRouter({
|
||||||
getCulture: publicProcedure.query(async ({ ctx }) => {
|
getCulture: publicProcedure.query(async ({ ctx }) => {
|
||||||
@@ -26,4 +27,12 @@ export const serverSettingsRouter = createTRPCRouter({
|
|||||||
input.value as ServerSettings[keyof ServerSettings],
|
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 { describe, expect, it, test, vi } from "vitest";
|
||||||
|
|
||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
|
import type { Database } from "@homarr/db";
|
||||||
import { createId, eq, schema } 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 { createDb } from "@homarr/db/test";
|
||||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
|
||||||
|
|
||||||
import { userRouter } from "../user";
|
import { userRouter } from "../user";
|
||||||
|
|
||||||
@@ -36,31 +37,9 @@ vi.mock("@homarr/auth/env.mjs", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("initUser should initialize the first user", () => {
|
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 () => {
|
it("should create a user if none exists", async () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
|
await createOnboardingStepAsync(db, "user");
|
||||||
const caller = userRouter.createCaller({
|
const caller = userRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
session: null,
|
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 () => {
|
it("should not create a user if the password and confirmPassword do not match", async () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
|
await createOnboardingStepAsync(db, "user");
|
||||||
const caller = userRouter.createCaller({
|
const caller = userRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
session: null,
|
session: null,
|
||||||
@@ -106,6 +86,7 @@ describe("initUser should initialize the first user", () => {
|
|||||||
["abc123+/-"], // does not contain uppercase
|
["abc123+/-"], // does not contain uppercase
|
||||||
])("should throw error that password requirements do not match for '%s' as password", async (password) => {
|
])("should throw error that password requirements do not match for '%s' as password", async (password) => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
|
await createOnboardingStepAsync(db, "user");
|
||||||
const caller = userRouter.createCaller({
|
const caller = userRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
session: null,
|
session: null,
|
||||||
@@ -324,3 +305,10 @@ describe("delete should delete user", () => {
|
|||||||
expect(usersInDb[1]).containSubset(initialUsers[2]);
|
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 { and, createId, eq, like, schema } from "@homarr/db";
|
||||||
import { groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema/sqlite";
|
import { groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema/sqlite";
|
||||||
import { selectUserSchema } from "@homarr/db/validationSchemas";
|
import { selectUserSchema } from "@homarr/db/validationSchemas";
|
||||||
|
import { credentialsAdminGroup } from "@homarr/definitions";
|
||||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
import { validation, z } from "@homarr/validation";
|
import { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
import { convertIntersectionToZodObject } from "../schema-merger";
|
import { 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 { throwIfCredentialsDisabled } from "./invite/checks";
|
||||||
|
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||||
|
|
||||||
export const userRouter = createTRPCRouter({
|
export const userRouter = createTRPCRouter({
|
||||||
initUser: publicProcedure.input(validation.user.init).mutation(async ({ ctx, input }) => {
|
initUser: onboardingProcedure
|
||||||
throwIfCredentialsDisabled();
|
.requiresStep("user")
|
||||||
|
.input(validation.user.init)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
throwIfCredentialsDisabled();
|
||||||
|
|
||||||
const firstUser = await ctx.db.query.users.findFirst({
|
const userId = await createUserAsync(ctx.db, input);
|
||||||
columns: {
|
const groupId = createId();
|
||||||
id: true,
|
await ctx.db.insert(groups).values({
|
||||||
},
|
id: groupId,
|
||||||
});
|
name: credentialsAdminGroup,
|
||||||
|
ownerId: userId,
|
||||||
if (firstUser) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "User already exists",
|
|
||||||
});
|
});
|
||||||
}
|
await ctx.db.insert(groupPermissions).values({
|
||||||
|
groupId,
|
||||||
const userId = await createUserAsync(ctx.db, input);
|
permission: "admin",
|
||||||
const groupId = createId();
|
});
|
||||||
await ctx.db.insert(groups).values({
|
await ctx.db.insert(groupMembers).values({
|
||||||
id: groupId,
|
groupId,
|
||||||
name: "admin",
|
userId,
|
||||||
ownerId: userId,
|
});
|
||||||
});
|
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||||
await ctx.db.insert(groupPermissions).values({
|
}),
|
||||||
groupId,
|
|
||||||
permission: "admin",
|
|
||||||
});
|
|
||||||
await ctx.db.insert(groupMembers).values({
|
|
||||||
groupId,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
register: publicProcedure
|
register: publicProcedure
|
||||||
.input(validation.user.registrationApi)
|
.input(validation.user.registrationApi)
|
||||||
.output(z.void())
|
.output(z.void())
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import type { OpenApiMeta } from "trpc-to-openapi";
|
|||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
import { FlattenError } from "@homarr/common";
|
import { FlattenError } from "@homarr/common";
|
||||||
import { db } from "@homarr/db";
|
import { db } from "@homarr/db";
|
||||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
import { ZodError } from "@homarr/validation";
|
import { ZodError } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 1. CONTEXT
|
* 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}`) {
|
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 [data, dataIv] = value.split(".") as [string, string];
|
||||||
const initializationVector = Buffer.from(dataIv, "hex");
|
const initializationVector = Buffer.from(dataIv, "hex");
|
||||||
const encryptedText = Buffer.from(data, "hex");
|
const encryptedText = Buffer.from(data, "hex");
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export * from "./url";
|
|||||||
export * from "./number";
|
export * from "./number";
|
||||||
export * from "./error";
|
export * from "./error";
|
||||||
export * from "./fetch-with-timeout";
|
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,
|
"when": 1732212709518,
|
||||||
"tag": "0016_change_all_to_snake_case",
|
"tag": "0016_change_all_to_snake_case",
|
||||||
"breakpoints": true
|
"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 { createId, eq } from "..";
|
||||||
import type { Database } from "..";
|
import type { Database } from "..";
|
||||||
import { groups } from "../schema/mysql";
|
import { groups } from "../schema/mysql";
|
||||||
import { serverSettings } from "../schema/sqlite";
|
import { onboarding, serverSettings } from "../schema/sqlite";
|
||||||
|
|
||||||
export const seedDataAsync = async (db: Database) => {
|
export const seedDataAsync = async (db: Database) => {
|
||||||
await seedEveryoneGroupAsync(db);
|
await seedEveryoneGroupAsync(db);
|
||||||
|
await seedOnboardingAsync(db);
|
||||||
await seedServerSettingsAsync(db);
|
await seedServerSettingsAsync(db);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,6 +32,21 @@ const seedEveryoneGroupAsync = async (db: Database) => {
|
|||||||
console.log("Created group 'everyone' through seed");
|
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 seedServerSettingsAsync = async (db: Database) => {
|
||||||
const serverSettingsData = await db.query.serverSettings.findMany();
|
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,
|
"when": 1732210918783,
|
||||||
"tag": "0016_change_all_to_snake_case",
|
"tag": "0016_change_all_to_snake_case",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1733777395703,
|
||||||
|
"tag": "0017_small_rumiko_fujikawa",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import type {
|
|||||||
IntegrationKind,
|
IntegrationKind,
|
||||||
IntegrationPermission,
|
IntegrationPermission,
|
||||||
IntegrationSecretKind,
|
IntegrationSecretKind,
|
||||||
|
OnboardingStep,
|
||||||
SearchEngineType,
|
SearchEngineType,
|
||||||
SectionKind,
|
SectionKind,
|
||||||
SupportedAuthProvider,
|
SupportedAuthProvider,
|
||||||
@@ -395,6 +396,12 @@ export const searchEngines = mysqlTable("search_engine", {
|
|||||||
integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "cascade" }),
|
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 }) => ({
|
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [accounts.userId],
|
fields: [accounts.userId],
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
IntegrationKind,
|
IntegrationKind,
|
||||||
IntegrationPermission,
|
IntegrationPermission,
|
||||||
IntegrationSecretKind,
|
IntegrationSecretKind,
|
||||||
|
OnboardingStep,
|
||||||
SearchEngineType,
|
SearchEngineType,
|
||||||
SectionKind,
|
SectionKind,
|
||||||
SupportedAuthProvider,
|
SupportedAuthProvider,
|
||||||
@@ -382,6 +383,12 @@ export const searchEngines = sqliteTable("search_engine", {
|
|||||||
integrationId: text().references(() => integrations.id, { onDelete: "cascade" }),
|
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 }) => ({
|
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [accounts.userId],
|
fields: [accounts.userId],
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export const everyoneGroup = "everyone";
|
export const everyoneGroup = "everyone";
|
||||||
|
export const credentialsAdminGroup = "admin";
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export * from "./group";
|
|||||||
export * from "./docs";
|
export * from "./docs";
|
||||||
export * from "./cookie";
|
export * from "./cookie";
|
||||||
export * from "./search-engine";
|
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",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts"
|
".": "./index.ts",
|
||||||
|
"./types": "./src/types.ts"
|
||||||
},
|
},
|
||||||
"typesVersions": {
|
"typesVersions": {
|
||||||
"*": {
|
"*": {
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@mantine/form": "^7.15.1"
|
"@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/form": "workspace:^0.1.0",
|
||||||
"@homarr/modals": "workspace:^0.1.0",
|
"@homarr/modals": "workspace:^0.1.0",
|
||||||
"@homarr/notifications": "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/old-schema": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
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 { IconFileUpload } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -7,16 +7,18 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
|
|||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { createModal } from "@homarr/modals";
|
import { createModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
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 { oldmarrConfigSchema } from "@homarr/old-schema";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import { SelectWithDescription } from "@homarr/ui";
|
import { z } from "@homarr/validation";
|
||||||
import type { OldmarrImportConfiguration } from "@homarr/validation";
|
|
||||||
import { oldmarrImportConfigurationSchema, superRefineJsonImportFile, z } from "@homarr/validation";
|
|
||||||
|
|
||||||
import { useBoardNameStatus } from "./add-board-modal";
|
import { useBoardNameStatus } from "./add-board-modal";
|
||||||
|
|
||||||
export const ImportBoardModal = createModal(({ actions }) => {
|
export const ImportBoardModal = createModal(({ actions }) => {
|
||||||
const tOldImport = useScopedI18n("board.action.oldImport");
|
const tOldImport = useScopedI18n("board.action.oldImport");
|
||||||
|
const t = useI18n();
|
||||||
const tCommon = useScopedI18n("common");
|
const tCommon = useScopedI18n("common");
|
||||||
const [fileValid, setFileValid] = useState(true);
|
const [fileValid, setFileValid] = useState(true);
|
||||||
const form = useZodForm(
|
const form = useZodForm(
|
||||||
@@ -30,7 +32,6 @@ export const ImportBoardModal = createModal(({ actions }) => {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
file: null!,
|
file: null!,
|
||||||
configuration: {
|
configuration: {
|
||||||
distinctAppsByHref: true,
|
|
||||||
onlyImportApps: false,
|
onlyImportApps: false,
|
||||||
screenSize: "lg",
|
screenSize: "lg",
|
||||||
sidebarBehaviour: "last-section",
|
sidebarBehaviour: "last-section",
|
||||||
@@ -119,24 +120,7 @@ export const ImportBoardModal = createModal(({ actions }) => {
|
|||||||
label={tOldImport("form.file.label")}
|
label={tOldImport("form.file.label")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Fieldset legend={tOldImport("form.apps.label")}>
|
<OldmarrImportAppsSettings onlyImportApps={form.getInputProps("configuration.onlyImportApps")} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
withAsterisk
|
withAsterisk
|
||||||
@@ -155,33 +139,17 @@ export const ImportBoardModal = createModal(({ actions }) => {
|
|||||||
<Radio.Group
|
<Radio.Group
|
||||||
withAsterisk
|
withAsterisk
|
||||||
label={tOldImport("form.screenSize.label")}
|
label={tOldImport("form.screenSize.label")}
|
||||||
|
description={t("board.action.oldImport.form.screenSize.description")}
|
||||||
{...form.getInputProps("configuration.screenSize")}
|
{...form.getInputProps("configuration.screenSize")}
|
||||||
>
|
>
|
||||||
<Group mt="xs">
|
<Group mt="xs">
|
||||||
<Radio value="sm" label={tOldImport("form.screenSize.option.sm")} />
|
<Radio value="sm" label={t("board.action.oldImport.form.screenSize.option.sm")} />
|
||||||
<Radio value="md" label={tOldImport("form.screenSize.option.md")} />
|
<Radio value="md" label={t("board.action.oldImport.form.screenSize.option.md")} />
|
||||||
<Radio value="lg" label={tOldImport("form.screenSize.option.lg")} />
|
<Radio value="lg" label={t("board.action.oldImport.form.screenSize.option.lg")} />
|
||||||
</Group>
|
</Group>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
|
|
||||||
<SelectWithDescription
|
<SidebarBehaviourSelect {...form.getInputProps("configuration.sidebarBehaviour")} />
|
||||||
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")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group justify="end">
|
<Group justify="end">
|
||||||
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
|
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
|
||||||
|
|||||||
@@ -5,7 +5,11 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"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": {
|
"typesVersions": {
|
||||||
"*": {
|
"*": {
|
||||||
@@ -25,15 +29,28 @@
|
|||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
|
"@homarr/form": "workspace:^0.1.0",
|
||||||
"@homarr/log": "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/old-schema": "workspace:^0.1.0",
|
||||||
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "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": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
|
"@types/adm-zip": "0.5.7",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"typescript": "^5.7.2"
|
"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 { boards } from "@homarr/db/schema/sqlite";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
import type { OldmarrConfig } from "@homarr/old-schema";
|
import type { OldmarrConfig } from "@homarr/old-schema";
|
||||||
import type { OldmarrImportConfiguration } from "@homarr/validation";
|
|
||||||
|
|
||||||
import { mapColor } from "./mappers/map-colors";
|
import { mapColor } from "./mappers/map-colors";
|
||||||
import { mapColumnCount } from "./mappers/map-column-count";
|
import { mapColumnCount } from "./mappers/map-column-count";
|
||||||
|
import type { OldmarrImportConfiguration } from "./settings";
|
||||||
|
|
||||||
export const insertBoardAsync = async (db: Database, old: OldmarrConfig, configuration: OldmarrImportConfiguration) => {
|
export const insertBoardAsync = async (db: Database, old: OldmarrConfig, configuration: OldmarrImportConfiguration) => {
|
||||||
logger.info(`Importing old homarr board configuration=${old.configProperties.name}`);
|
logger.info(`Importing old homarr board configuration=${old.configProperties.name}`);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { OldmarrConfig } from "@homarr/old-schema";
|
import type { OldmarrConfig } from "@homarr/old-schema";
|
||||||
import type { OldmarrImportConfiguration } from "@homarr/validation";
|
|
||||||
|
import type { OldmarrImportConfiguration } from "./settings";
|
||||||
|
|
||||||
export class OldHomarrImportError extends Error {
|
export class OldHomarrImportError extends Error {
|
||||||
constructor(oldConfig: OldmarrConfig, cause: unknown) {
|
constructor(oldConfig: OldmarrConfig, cause: unknown) {
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { createId } from "@homarr/db";
|
|||||||
import { items } from "@homarr/db/schema/sqlite";
|
import { items } from "@homarr/db/schema/sqlite";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
import type { OldmarrApp, OldmarrWidget } from "@homarr/old-schema";
|
import type { OldmarrApp, OldmarrWidget } from "@homarr/old-schema";
|
||||||
import type { OldmarrImportConfiguration } from "@homarr/validation";
|
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../../widgets/src/definition";
|
import type { WidgetComponentProps } from "../../widgets/src/definition";
|
||||||
import { OldHomarrScreenSizeError } from "./import-error";
|
import { OldHomarrScreenSizeError } from "./import-error";
|
||||||
|
import type { OldmarrImportConfiguration } from "./settings";
|
||||||
import { mapKind } from "./widgets/definitions";
|
import { mapKind } from "./widgets/definitions";
|
||||||
import { mapOptions } from "./widgets/options";
|
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 { Database } from "@homarr/db";
|
||||||
import type { OldmarrConfig } from "@homarr/old-schema";
|
import type { OldmarrConfig } from "@homarr/old-schema";
|
||||||
import type { OldmarrImportConfiguration } from "@homarr/validation";
|
|
||||||
|
|
||||||
import { fixSectionIssues } from "./fix-section-issues";
|
import { importSingleOldmarrConfigAsync } from "./import/import-single-oldmarr";
|
||||||
import { insertAppsAsync } from "./import-apps";
|
import type { OldmarrImportConfiguration } from "./settings";
|
||||||
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";
|
|
||||||
|
|
||||||
export const importAsync = async (db: Database, old: OldmarrConfig, configuration: OldmarrImportConfiguration) => {
|
export const importOldmarrAsync = async (
|
||||||
const bookmarkApps = old.widgets
|
db: Database,
|
||||||
.filter((widget) => widget.type === "bookmark")
|
old: OldmarrConfig,
|
||||||
.map((widget) => widget.properties.items)
|
configuration: OldmarrImportConfiguration,
|
||||||
.flat() as BookmarkApp[];
|
) => {
|
||||||
|
await importSingleOldmarrConfigAsync(db, old, configuration);
|
||||||
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);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
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 { OldmarrConfig } from "@homarr/old-schema";
|
||||||
import type { OldmarrImportConfiguration } from "@homarr/validation";
|
|
||||||
|
import type { OldmarrImportConfiguration } from "../settings";
|
||||||
|
|
||||||
export const mapColumnCount = (old: OldmarrConfig, screenSize: OldmarrImportConfiguration["screenSize"]) => {
|
export const mapColumnCount = (old: OldmarrConfig, screenSize: OldmarrImportConfiguration["screenSize"]) => {
|
||||||
switch (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 { objectEntries } from "@homarr/common";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
import type { OldmarrApp, OldmarrConfig, OldmarrWidget } from "@homarr/old-schema";
|
import type { OldmarrApp, OldmarrConfig, OldmarrWidget } from "@homarr/old-schema";
|
||||||
import type { OldmarrImportConfiguration } from "@homarr/validation";
|
|
||||||
|
|
||||||
import { OldHomarrScreenSizeError } from "./import-error";
|
import { OldHomarrScreenSizeError } from "./import-error";
|
||||||
import { mapColumnCount } from "./mappers/map-column-count";
|
import { mapColumnCount } from "./mappers/map-column-count";
|
||||||
|
import type { OldmarrImportConfiguration } from "./settings";
|
||||||
|
|
||||||
export const moveWidgetsAndAppsIfMerge = (
|
export const moveWidgetsAndAppsIfMerge = (
|
||||||
old: OldmarrConfig,
|
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",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ const integrationSchema = z.enum([
|
|||||||
"tdarr",
|
"tdarr",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export type OldmarrIntegrationType = z.infer<typeof integrationSchema>;
|
||||||
|
|
||||||
const appIntegrationPropertySchema = z.object({
|
const appIntegrationPropertySchema = z.object({
|
||||||
type: z.enum(["private", "public"]),
|
type: z.enum(["private", "public"]),
|
||||||
field: z.enum(["apiKey", "password", "username"]),
|
field: z.enum(["apiKey", "password", "username"]),
|
||||||
|
|||||||
@@ -28,3 +28,5 @@ export const oldmarrConfigSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type OldmarrConfig = z.infer<typeof oldmarrConfigSchema>;
|
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 { oldmarrConfigSchema } from "./config";
|
||||||
export type { OldmarrApp } from "./app";
|
export type { OldmarrApp, OldmarrIntegrationType } from "./app";
|
||||||
export type { OldmarrWidget, OldmarrWidgetKind } from "./widget";
|
export type { OldmarrWidget, OldmarrWidgetKind } from "./widget";
|
||||||
export { oldmarrWidgetKinds } 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(),
|
allowGuests: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const gridstackSettingsSchema = z.object({
|
const gridstackSettingsSchema = z
|
||||||
columnCountSmall: z.number(),
|
.object({
|
||||||
columnCountMedium: z.number(),
|
columnCountSmall: z.number(),
|
||||||
columnCountLarge: z.number(),
|
columnCountMedium: z.number(),
|
||||||
});
|
columnCountLarge: z.number(),
|
||||||
|
})
|
||||||
|
.catch({
|
||||||
|
columnCountSmall: 3,
|
||||||
|
columnCountMedium: 6,
|
||||||
|
columnCountLarge: 12,
|
||||||
|
});
|
||||||
|
|
||||||
const layoutSettingsSchema = z.object({
|
const layoutSettingsSchema = z.object({
|
||||||
enabledLeftSidebar: z.boolean(),
|
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