feat: add onboarding with oldmarr import (#1606)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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