feat: rebuild onboarding experience
This commit is contained in:
1 parent
d091362db2
commit
e41605640b
249 files changed
+8216
-14060
No files matched your search
@@ -0,0 +1,112 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { serverSettings } from "@homarr/db/schema";
|
||||
|
||||
export const onboardingClaimCookieName = "homarr-onboarding-claim";
|
||||
export const onboardingClaimSettingKey = "__onboarding_claim";
|
||||
export const onboardingClaimTtlMs = 4 * 60 * 60 * 1000;
|
||||
|
||||
interface StoredOnboardingClaim {
|
||||
hash: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export type ClaimOnboardingResult =
|
||||
| { status: "issued" | "active"; token: string; expiresAt: number }
|
||||
| { status: "locked"; expiresAt: number }
|
||||
| { status: "finished" | "forbidden" };
|
||||
|
||||
const hashToken = (token: string) => createHash("sha256").update(token).digest("hex");
|
||||
|
||||
const parseStoredClaim = (value: string): StoredOnboardingClaim | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<StoredOnboardingClaim>;
|
||||
if (typeof parsed.hash !== "string" || !/^[0-9a-f]{64}$/.test(parsed.hash)) return null;
|
||||
if (typeof parsed.expiresAt !== "number" || !Number.isSafeInteger(parsed.expiresAt)) return null;
|
||||
return { hash: parsed.hash, expiresAt: parsed.expiresAt };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const tokenMatches = (claim: StoredOnboardingClaim, token: string, now: number) => {
|
||||
if (claim.expiresAt <= now || token.length === 0) return false;
|
||||
const expected = Buffer.from(claim.hash, "hex");
|
||||
const actual = Buffer.from(hashToken(token), "hex");
|
||||
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||
};
|
||||
|
||||
const getClaimRowAsync = async (db: Database) =>
|
||||
await db.query.serverSettings.findFirst({ where: eq(serverSettings.settingKey, onboardingClaimSettingKey) });
|
||||
|
||||
export const getOnboardingClaimTokenFromCookieHeader = (cookieHeader: string | null) => {
|
||||
if (!cookieHeader) return undefined;
|
||||
for (const part of cookieHeader.split(";")) {
|
||||
const separator = part.indexOf("=");
|
||||
if (separator < 0) continue;
|
||||
const name = part.slice(0, separator).trim();
|
||||
if (name !== onboardingClaimCookieName) continue;
|
||||
const value = part.slice(separator + 1).trim();
|
||||
return value || undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const isOnboardingClaimValidAsync = async (db: Database, token: string | undefined, now = Date.now()) => {
|
||||
if (!token) return false;
|
||||
const row = await getClaimRowAsync(db);
|
||||
const claim = row ? parseStoredClaim(row.value) : null;
|
||||
return claim ? tokenMatches(claim, token, now) : false;
|
||||
};
|
||||
|
||||
export const isClaimOnlyOnboardingAccessAllowedAsync = async (
|
||||
db: Database,
|
||||
token: string | undefined,
|
||||
now = Date.now(),
|
||||
) => {
|
||||
const existingUser = await db.query.users.findFirst({ columns: { id: true } });
|
||||
return !existingUser && (await isOnboardingClaimValidAsync(db, token, now));
|
||||
};
|
||||
|
||||
export const claimOnboardingAsync = async (
|
||||
db: Database,
|
||||
options: { currentToken?: string; force?: boolean; now?: number; ttlMs?: number } = {},
|
||||
): Promise<ClaimOnboardingResult> => {
|
||||
const onboarding = await db.query.onboarding.findFirst();
|
||||
if (onboarding?.step === "finish") return { status: "finished" };
|
||||
const existingUser = await db.query.users.findFirst({ columns: { id: true } });
|
||||
if (existingUser && !options.force) return { status: "forbidden" };
|
||||
|
||||
const now = options.now ?? Date.now();
|
||||
const currentRow = await getClaimRowAsync(db);
|
||||
const currentClaim = currentRow ? parseStoredClaim(currentRow.value) : null;
|
||||
if (currentClaim && options.currentToken && tokenMatches(currentClaim, options.currentToken, now)) {
|
||||
return { status: "active", token: options.currentToken, expiresAt: currentClaim.expiresAt };
|
||||
}
|
||||
if (currentClaim && currentClaim.expiresAt > now && !options.force) {
|
||||
return { status: "locked", expiresAt: currentClaim.expiresAt };
|
||||
}
|
||||
|
||||
if (currentRow) {
|
||||
await db
|
||||
.delete(serverSettings)
|
||||
.where(and(eq(serverSettings.settingKey, onboardingClaimSettingKey), eq(serverSettings.value, currentRow.value)));
|
||||
}
|
||||
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const expiresAt = now + (options.ttlMs ?? onboardingClaimTtlMs);
|
||||
const value = JSON.stringify({ hash: hashToken(token), expiresAt } satisfies StoredOnboardingClaim);
|
||||
try {
|
||||
await db.insert(serverSettings).values({ settingKey: onboardingClaimSettingKey, value });
|
||||
} catch (error) {
|
||||
const winner = await getClaimRowAsync(db);
|
||||
const winnerClaim = winner ? parseStoredClaim(winner.value) : null;
|
||||
if (winnerClaim?.expiresAt && winnerClaim.expiresAt > now) {
|
||||
return { status: "locked", expiresAt: winnerClaim.expiresAt };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return { status: "issued", token, expiresAt };
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { normalizeOnboardingStep } from "./router/onboard/onboard-queries";
|
||||
@@ -16,7 +16,6 @@ export const appRouter = createTRPCRouter({
|
||||
location: lazy(() => import("./router/location").then((mod) => mod.locationRouter)),
|
||||
log: lazy(() => import("./router/log").then((mod) => mod.logRouter)),
|
||||
icon: lazy(() => import("./router/icons").then((mod) => mod.iconsRouter)),
|
||||
import: lazy(() => import("./router/import/import-router").then((mod) => mod.importRouter)),
|
||||
onboard: lazy(() => import("./router/onboard/onboard-router").then((mod) => mod.onboardRouter)),
|
||||
home: lazy(() => import("./router/home").then((mod) => mod.homeRouter)),
|
||||
docker: lazy(() => import("./router/docker/docker-router").then((mod) => mod.dockerRouter)),
|
||||
|
||||
@@ -18,8 +18,7 @@ import { AppAccessControl } from "./app/app-access-control";
|
||||
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
getPaginated: permissionRequiredProcedure
|
||||
.requiresPermission("board-modify-all")
|
||||
getPaginated: protectedProcedure
|
||||
.input(paginatedSchema)
|
||||
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
|
||||
.meta({
|
||||
|
||||
@@ -31,26 +31,22 @@ import {
|
||||
sections,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import type { BoardLane, WidgetKind } from "@homarr/definitions";
|
||||
import type { BoardLane, IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import {
|
||||
boardLanes,
|
||||
emptySuperJSON,
|
||||
everyoneGroup,
|
||||
getBoardLaneColumnCount,
|
||||
getRootSectionLane,
|
||||
getWidgetIntegrationIssue,
|
||||
getWidgetIntegrationIssueMessage,
|
||||
getPermissionsWithChildren,
|
||||
getPermissionsWithParents,
|
||||
normalizeBoardLayoutRoles,
|
||||
rootSectionOffsets,
|
||||
widgetDefaultSizes,
|
||||
widgetIntegrationLimits,
|
||||
widgetIntegrationSupport,
|
||||
widgetKindsWithOptionalIntegrations,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
import { importOldmarrAsync } from "@homarr/old-import";
|
||||
import { importJsonFileSchema } from "@homarr/old-import/shared";
|
||||
import { oldmarrConfigSchema } from "@homarr/old-schema";
|
||||
import {
|
||||
addItemToBoardSchema,
|
||||
boardByNameSchema,
|
||||
@@ -193,31 +189,8 @@ export const boardRouter = createTRPCRouter({
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||
where: eq(boardUserPermissions.userId, userId ?? ""),
|
||||
});
|
||||
|
||||
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId ?? ""),
|
||||
with: {
|
||||
group: {
|
||||
with: {
|
||||
boardPermissions: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const boardIds = permissionsOfCurrentUserWhenPresent
|
||||
.map((permission) => permission.boardId)
|
||||
.concat(
|
||||
permissionsOfCurrentUserGroupsWhenPresent
|
||||
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
|
||||
.flat(),
|
||||
);
|
||||
|
||||
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, userId ?? ""),
|
||||
});
|
||||
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
||||
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
||||
|
||||
const dbBoards = await ctx.db.query.boards.findMany({
|
||||
columns: {
|
||||
@@ -239,30 +212,121 @@ export const boardRouter = createTRPCRouter({
|
||||
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where:
|
||||
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
|
||||
? inArray(
|
||||
boardGroupPermissions.groupId,
|
||||
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
|
||||
)
|
||||
: undefined,
|
||||
where: groupPermissionWhere,
|
||||
},
|
||||
},
|
||||
// Allow viewing all boards if the user has the permission
|
||||
where: ctx.session?.user.permissions.includes("board-view-all")
|
||||
? undefined
|
||||
: or(
|
||||
eq(boards.isPublic, true),
|
||||
eq(boards.creatorId, ctx.session?.user.id ?? ""),
|
||||
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
|
||||
),
|
||||
where: getAccessibleBoardsWhere(ctx.session?.user.permissions.includes("board-view-all"), userId, boardIds),
|
||||
});
|
||||
return dbBoards.map((board) => ({
|
||||
...board,
|
||||
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
||||
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
|
||||
isHome: currentUser?.homeBoardId === board.id,
|
||||
isMobileHome: currentUser?.mobileHomeBoardId === board.id,
|
||||
}));
|
||||
}),
|
||||
getManageOverview: publicProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
||||
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
||||
|
||||
const dbBoards = await ctx.db.query.boards.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
logoImageUrl: true,
|
||||
isPublic: true,
|
||||
},
|
||||
with: {
|
||||
creator: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
userPermissions: {
|
||||
columns: { permission: true },
|
||||
where: eq(boardUserPermissions.userId, userId ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
columns: { permission: true },
|
||||
where: groupPermissionWhere,
|
||||
},
|
||||
layouts: {
|
||||
columns: {
|
||||
id: true,
|
||||
columnCount: true,
|
||||
leftGutterColumnCount: true,
|
||||
rightGutterColumnCount: true,
|
||||
breakpoint: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
sections: {
|
||||
columns: {
|
||||
id: true,
|
||||
kind: true,
|
||||
xOffset: true,
|
||||
},
|
||||
with: {
|
||||
layouts: {
|
||||
columns: {
|
||||
layoutId: true,
|
||||
parentSectionId: true,
|
||||
xOffset: true,
|
||||
yOffset: true,
|
||||
width: true,
|
||||
height: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
items: {
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
with: {
|
||||
layouts: {
|
||||
columns: {
|
||||
layoutId: true,
|
||||
sectionId: true,
|
||||
xOffset: true,
|
||||
yOffset: true,
|
||||
width: true,
|
||||
height: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: getAccessibleBoardsWhere(ctx.session?.user.permissions.includes("board-view-all"), userId, boardIds),
|
||||
});
|
||||
|
||||
return dbBoards.map(({ layouts: boardLayouts, sections: boardSections, items: boardItems, ...board }) => {
|
||||
const previewLayout =
|
||||
boardLayouts.find((layout) => layout.role === "base") ??
|
||||
boardLayouts.toSorted((first, second) => second.breakpoint - first.breakpoint).at(0);
|
||||
|
||||
return {
|
||||
...board,
|
||||
isHome: currentUser?.homeBoardId === board.id,
|
||||
isMobileHome: currentUser?.mobileHomeBoardId === board.id,
|
||||
preview: previewLayout
|
||||
? {
|
||||
layouts: [previewLayout],
|
||||
sections: boardSections.map((section) => ({
|
||||
...section,
|
||||
layouts: section.layouts.filter((layout) => layout.layoutId === previewLayout.id),
|
||||
})),
|
||||
items: boardItems.map((item) => ({
|
||||
...item,
|
||||
layouts: item.layouts.filter((layout) => layout.layoutId === previewLayout.id),
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}),
|
||||
search: publicProcedure
|
||||
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
@@ -1846,14 +1910,6 @@ export const boardRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
}),
|
||||
importOldmarrConfig: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
.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);
|
||||
}),
|
||||
addItem: protectedProcedure
|
||||
.meta({
|
||||
openapi: { method: "POST", path: "/api/boards/items", tags: ["boards"], protect: true },
|
||||
@@ -1877,25 +1933,7 @@ export const boardRouter = createTRPCRouter({
|
||||
await validateTimetableOptionsChangeAsync(input.options);
|
||||
}
|
||||
|
||||
const supportedIntegrations = widgetIntegrationSupport[input.kind];
|
||||
const integrationLimit = widgetIntegrationLimits[input.kind] ?? Infinity;
|
||||
if (input.integrationIds.length > integrationLimit) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `${input.kind} supports at most ${integrationLimit} integration${integrationLimit === 1 ? "" : "s"}`,
|
||||
});
|
||||
}
|
||||
if (supportedIntegrations === undefined && input.integrationIds.length > 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `${input.kind} does not support integrations` });
|
||||
}
|
||||
if (
|
||||
supportedIntegrations !== undefined &&
|
||||
input.integrationIds.length === 0 &&
|
||||
!widgetKindsWithOptionalIntegrations.has(input.kind)
|
||||
) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `${input.kind} requires an integration` });
|
||||
}
|
||||
|
||||
let selectedIntegrationKinds: IntegrationKind[] = [];
|
||||
if (input.integrationIds.length > 0) {
|
||||
const existing = await ctx.db.query.integrations.findMany({
|
||||
columns: { id: true, kind: true },
|
||||
@@ -1907,19 +1945,21 @@ export const boardRouter = createTRPCRouter({
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` });
|
||||
}
|
||||
|
||||
selectedIntegrationKinds = existing.map((integration) => integration.kind);
|
||||
|
||||
await Promise.all(
|
||||
input.integrationIds.map((integrationId) =>
|
||||
throwIfIntegrationActionForbiddenAsync(ctx, eq(integrations.id, integrationId), "use"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const incompatible = existing.filter((integration) => !supportedIntegrations?.includes(integration.kind));
|
||||
if (incompatible.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `${input.kind} does not support integration kind${incompatible.length === 1 ? "" : "s"}: ${incompatible.map((integration) => integration.kind).join(", ")}`,
|
||||
});
|
||||
}
|
||||
const integrationIssue = getWidgetIntegrationIssue(input.kind, selectedIntegrationKinds);
|
||||
if (integrationIssue) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: getWidgetIntegrationIssueMessage(input.kind, integrationIssue),
|
||||
});
|
||||
}
|
||||
|
||||
return await serializeBoardItemPlacementAsync(input.boardId, async () => {
|
||||
@@ -2372,6 +2412,59 @@ const getElementsForLayout = (board: BoardForLayoutProjection, layoutId: string)
|
||||
|
||||
const protectedLayoutRepairPromises = new Map<string, Promise<void>>();
|
||||
|
||||
const getBoardAccessContextAsync = async (db: Database, userId: string | undefined) => {
|
||||
const [userPermissions, groupMemberships, currentUser] = await Promise.all([
|
||||
db.query.boardUserPermissions.findMany({
|
||||
where: eq(boardUserPermissions.userId, userId ?? ""),
|
||||
}),
|
||||
db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId ?? ""),
|
||||
with: {
|
||||
group: {
|
||||
with: {
|
||||
boardPermissions: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.query.users.findFirst({
|
||||
where: eq(users.id, userId ?? ""),
|
||||
columns: {
|
||||
homeBoardId: true,
|
||||
mobileHomeBoardId: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const boardIds = userPermissions
|
||||
.map((permission) => permission.boardId)
|
||||
.concat(
|
||||
groupMemberships.flatMap((membership) =>
|
||||
membership.group.boardPermissions.map((permission) => permission.boardId),
|
||||
),
|
||||
);
|
||||
|
||||
return { boardIds, currentUser, groupMemberships };
|
||||
};
|
||||
|
||||
const getBoardGroupPermissionWhere = (
|
||||
groupMemberships: Awaited<ReturnType<typeof getBoardAccessContextAsync>>["groupMemberships"],
|
||||
) =>
|
||||
groupMemberships.length > 0
|
||||
? inArray(
|
||||
boardGroupPermissions.groupId,
|
||||
groupMemberships.map((membership) => membership.groupId),
|
||||
)
|
||||
: eq(boardGroupPermissions.groupId, "");
|
||||
|
||||
const getAccessibleBoardsWhere = (canViewAll: boolean | undefined, userId: string | undefined, boardIds: string[]) =>
|
||||
canViewAll
|
||||
? undefined
|
||||
: or(
|
||||
eq(boards.isPublic, true),
|
||||
eq(boards.creatorId, userId ?? ""),
|
||||
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
|
||||
);
|
||||
|
||||
const getFullBoardWithWhereAsync = async (
|
||||
db: Database,
|
||||
where: SQL<unknown>,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createId } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq, handleTransactionsAsync, like, not } from "@homarr/db";
|
||||
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema";
|
||||
import { groupMembers, groupPermissions, groups, onboarding, users } from "@homarr/db/schema";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
|
||||
import {
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
} from "@homarr/validation/group";
|
||||
|
||||
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||
|
||||
export const groupRouter = createTRPCRouter({
|
||||
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
|
||||
@@ -185,18 +184,28 @@ export const groupRouter = createTRPCRouter({
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
|
||||
const groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
const groupRow = {
|
||||
id: groupId,
|
||||
name: input.name,
|
||||
position: maxPosition + 1,
|
||||
});
|
||||
};
|
||||
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
permission: "admin",
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction.insert(schema.groups).values(groupRow);
|
||||
await transaction.insert(schema.groupPermissions).values({ groupId, permission: "admin" });
|
||||
await transaction.update(schema.onboarding).set({ previousStep: "group", step: "setup" });
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction.insert(groups).values(groupRow).run();
|
||||
transaction.insert(groupPermissions).values({ groupId, permission: "admin" }).run();
|
||||
transaction.update(onboarding).set({ previousStep: "group", step: "setup" }).run();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
createGroup: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
|
||||
@@ -101,7 +101,7 @@ export const homeRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.session?.user.permissions.includes("board-modify-all")) {
|
||||
if (ctx.session?.user.permissions.includes("app-create")) {
|
||||
statistics.push({
|
||||
titleKey: "app",
|
||||
subtitleKey: "resources",
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { analyseOldmarrImportForRouterAsync, analyseOldmarrImportInputSchema } from "@homarr/old-import/analyse";
|
||||
import {
|
||||
ensureValidTokenOrThrow,
|
||||
importInitialOldmarrAsync,
|
||||
importInitialOldmarrInputSchema,
|
||||
} from "@homarr/old-import/import";
|
||||
|
||||
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);
|
||||
}),
|
||||
});
|
||||
@@ -1,84 +1,35 @@
|
||||
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";
|
||||
import { onboarding } from "@homarr/db/schema";
|
||||
import type { OnboardingStep } from "@homarr/definitions";
|
||||
import { credentialsAdminGroup } from "@homarr/definitions";
|
||||
|
||||
export const nextOnboardingStepAsync = async (db: Database, preferredStep: OnboardingStep | undefined) => {
|
||||
export const nextOnboardingStepAsync = async (db: Database) => {
|
||||
const { current } = await getOnboardingOrFallbackAsync(db);
|
||||
const nextStepConfiguration = nextSteps[current];
|
||||
if (!nextStepConfiguration) return;
|
||||
if (current !== "start") return;
|
||||
const nextStep = getNextOnboardingStep();
|
||||
if (!nextStep) 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;
|
||||
}
|
||||
await db.update(onboarding).set({ previousStep: current, step: nextStep });
|
||||
};
|
||||
|
||||
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 };
|
||||
return {
|
||||
current: normalizeOnboardingStep(value.step),
|
||||
previous: value.previousStep ? normalizeOnboardingStep(value.previousStep) : null,
|
||||
};
|
||||
};
|
||||
|
||||
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: {
|
||||
integrations: true,
|
||||
},
|
||||
integrations: {
|
||||
finish: true,
|
||||
},
|
||||
const getNextOnboardingStep = (): OnboardingStep => {
|
||||
if (isProviderEnabled("credentials")) return "user";
|
||||
if (isProviderEnabled("ldap") || isProviderEnabled("oidc")) return "group";
|
||||
return "setup";
|
||||
};
|
||||
|
||||
export const normalizeOnboardingStep = (step: string | null | undefined): OnboardingStep => {
|
||||
if (step === "user" || step === "group" || step === "setup" || step === "finish") return step;
|
||||
if (step === "settings" || step === "integrations") return "setup";
|
||||
return "start";
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -11,10 +11,8 @@ import {
|
||||
import { boards, serverSettings } from "@homarr/db/schema";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
import { settingsInitSchema } from "@homarr/validation/settings";
|
||||
|
||||
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, publicProcedure } from "../trpc";
|
||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, publicProcedure } from "../trpc";
|
||||
|
||||
const boardServerSettingsSchema = z.object({
|
||||
homeBoardId: z.string().nullable(),
|
||||
@@ -108,13 +106,4 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
...input.value,
|
||||
} as ServerSettings[keyof ServerSettings]);
|
||||
}),
|
||||
initSettings: onboardingProcedure
|
||||
.requiresStep("settings")
|
||||
.input(settingsInitSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const currentAnalytics = await getServerSettingByKeyAsync(ctx.db, "analytics");
|
||||
await updateServerSettingByKeyAsync(ctx.db, "analytics", { ...currentAnalytics, ...input.analytics });
|
||||
await updateServerSettingByKeyAsync(ctx.db, "crawlingAndIndexing", input.crawlingAndIndexing);
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
});
|
||||
@@ -76,23 +76,23 @@ describe("all should return all apps", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPaginated should require board modification access", () => {
|
||||
test("should throw FORBIDDEN without board-modify-all", async () => {
|
||||
describe("getPaginated should return apps to authenticated users", () => {
|
||||
test("should throw UNAUTHORIZED without a session", async () => {
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
session: null,
|
||||
});
|
||||
|
||||
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("Permission denied");
|
||||
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("UNAUTHORIZED");
|
||||
});
|
||||
|
||||
test("should return apps with board-modify-all", async () => {
|
||||
test("should return apps without board permissions", async () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(["board-modify-all"]),
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
|
||||
|
||||
|
||||
@@ -287,6 +287,113 @@ describe("getAllBoards should return all boards accessable to the current user",
|
||||
);
|
||||
});
|
||||
|
||||
describe("getManageOverview", () => {
|
||||
test("returns the same accessible board set as getAllBoards", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const otherUserId = await createRandomUserAsync(db);
|
||||
const publicBoardId = createId();
|
||||
const creatorBoardId = createId();
|
||||
const directBoardId = createId();
|
||||
const groupBoardId = createId();
|
||||
const hiddenBoardId = createId();
|
||||
const groupId = createId();
|
||||
|
||||
await db.insert(users).values({ id: defaultCreatorId });
|
||||
await db.insert(boards).values([
|
||||
{ id: publicBoardId, name: "public-overview", creatorId: otherUserId, isPublic: true },
|
||||
{ id: creatorBoardId, name: "creator-overview", creatorId: defaultCreatorId, isPublic: false },
|
||||
{ id: directBoardId, name: "direct-overview", creatorId: otherUserId, isPublic: false },
|
||||
{ id: groupBoardId, name: "group-overview", creatorId: otherUserId, isPublic: false },
|
||||
{ id: hiddenBoardId, name: "hidden-overview", creatorId: otherUserId, isPublic: false },
|
||||
]);
|
||||
await db.insert(boardUserPermissions).values({
|
||||
userId: defaultCreatorId,
|
||||
boardId: directBoardId,
|
||||
permission: "view",
|
||||
});
|
||||
await db.insert(groups).values({ id: groupId, name: "overview-group", position: 1 });
|
||||
await db.insert(groupMembers).values({ userId: defaultCreatorId, groupId });
|
||||
await db.insert(boardGroupPermissions).values({ groupId, boardId: groupBoardId, permission: "view" });
|
||||
|
||||
const [summaries, overview] = await Promise.all([caller.getAllBoards(), caller.getManageOverview()]);
|
||||
|
||||
expect(overview.map((board) => board.id).toSorted()).toEqual(summaries.map((board) => board.id).toSorted());
|
||||
expect(overview.map((board) => board.id)).not.toContain(hiddenBoardId);
|
||||
});
|
||||
|
||||
test("returns one compact base-layout preview for each accessible board", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const boardId = createId();
|
||||
const hiddenBoardId = createId();
|
||||
const baseLayoutId = createId();
|
||||
const mobileLayoutId = createId();
|
||||
const rootSectionId = createId();
|
||||
const itemId = createId();
|
||||
|
||||
await db.insert(users).values({ id: defaultCreatorId });
|
||||
await db.insert(boards).values([
|
||||
{ id: boardId, name: "overview", creatorId: defaultCreatorId, isPublic: false },
|
||||
{ id: hiddenBoardId, name: "hidden", creatorId: await createRandomUserAsync(db), isPublic: false },
|
||||
]);
|
||||
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, defaultCreatorId));
|
||||
await db.insert(layouts).values([
|
||||
{
|
||||
id: mobileLayoutId,
|
||||
name: "Mobile",
|
||||
boardId,
|
||||
columnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "mobile",
|
||||
},
|
||||
{
|
||||
id: baseLayoutId,
|
||||
name: "Base",
|
||||
boardId,
|
||||
columnCount: 12,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
},
|
||||
]);
|
||||
await db.insert(sections).values({
|
||||
id: rootSectionId,
|
||||
boardId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
await db.insert(items).values({ id: itemId, boardId, kind: "clock" });
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId,
|
||||
sectionId: rootSectionId,
|
||||
layoutId: baseLayoutId,
|
||||
xOffset: 2,
|
||||
yOffset: 1,
|
||||
width: 4,
|
||||
height: 2,
|
||||
});
|
||||
|
||||
const result = await caller.getManageOverview();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: boardId,
|
||||
isHome: true,
|
||||
preview: {
|
||||
layouts: [{ id: baseLayoutId, role: "base", columnCount: 12 }],
|
||||
items: [
|
||||
{
|
||||
id: itemId,
|
||||
layouts: [{ layoutId: baseLayoutId, sectionId: rootSectionId, width: 4, height: 2 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result[0]?.preview?.layouts).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBoard should create a new board", () => {
|
||||
test("should create a new board with permission board-create", async () => {
|
||||
// Arrange
|
||||
|
||||
@@ -0,0 +1,959 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import SuperJSON from "superjson";
|
||||
import { assert, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { eq, inArray } from "@homarr/db";
|
||||
import {
|
||||
apps,
|
||||
boards,
|
||||
integrationItems,
|
||||
integrationSecrets,
|
||||
integrations,
|
||||
itemLayouts,
|
||||
items,
|
||||
layouts,
|
||||
onboarding,
|
||||
sectionLayouts,
|
||||
sections,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import { claimOnboardingAsync, isOnboardingClaimValidAsync } from "../../onboarding-claim";
|
||||
import * as integrationConnection from "../integration/integration-test-connection";
|
||||
|
||||
const listDiscoveredContainersAsync = vi.hoisted(() => vi.fn());
|
||||
const createIdMock = vi.hoisted(() => vi.fn());
|
||||
const stableAppIdForSource = (sourceId: string) => {
|
||||
const digest = createHash("sha256").update(sourceId).digest("hex").slice(0, 32);
|
||||
return `onboarding_app_${digest}`;
|
||||
};
|
||||
const stableIntegrationIdForSource = (sourceId: string) => {
|
||||
const digest = createHash("sha256").update(sourceId).digest("hex").slice(0, 32);
|
||||
return `onboarding_integration_${digest}`;
|
||||
};
|
||||
|
||||
vi.mock("@homarr/auth/server", () => ({ isProviderEnabled: () => false }));
|
||||
vi.mock("@homarr/common", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@homarr/common")>()),
|
||||
createId: createIdMock,
|
||||
}));
|
||||
vi.mock("@homarr/docker", () => ({
|
||||
dockerLabels: { hide: "homarr.hide" },
|
||||
listDiscoveredContainersAsync,
|
||||
}));
|
||||
|
||||
import { onboardRouter } from "../onboard/onboard-router";
|
||||
import { normalizeOnboardingStep } from "../onboard/onboard-queries";
|
||||
|
||||
const createCaller = (db: ReturnType<typeof createDb>, onboardingClaimToken?: string) =>
|
||||
onboardRouter.createCaller({ db, deviceType: undefined, session: null, onboardingClaimToken });
|
||||
|
||||
const completionInput = (boardId: string, name = "Configured-board") => ({
|
||||
server: {
|
||||
defaultLocale: "en" as const,
|
||||
defaultColorScheme: "dark" as const,
|
||||
analyticsEnabled: false,
|
||||
},
|
||||
board: {
|
||||
id: boardId,
|
||||
name,
|
||||
primaryColor: "#228BE6",
|
||||
secondaryColor: "#15AABF",
|
||||
itemRadius: "md" as const,
|
||||
layoutPreset: "balanced" as const,
|
||||
leftSidebar: false,
|
||||
rightSidebar: false,
|
||||
},
|
||||
selectedIntegrationIds: [] as string[],
|
||||
selectedAppIds: [] as string[],
|
||||
selectedDockerSourceIds: [] as string[],
|
||||
selectedWidgetKinds: [] as WidgetKind[],
|
||||
});
|
||||
|
||||
const rectanglesOverlap = (
|
||||
first: { xOffset: number; yOffset: number; width: number; height: number },
|
||||
second: { xOffset: number; yOffset: number; width: number; height: number },
|
||||
) =>
|
||||
first.xOffset < second.xOffset + second.width &&
|
||||
first.xOffset + first.width > second.xOffset &&
|
||||
first.yOffset < second.yOffset + second.height &&
|
||||
first.yOffset + first.height > second.yOffset;
|
||||
|
||||
const seedOnboardingAsync = async (db: ReturnType<typeof createDb>) => {
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "setup", previousStep: "group" });
|
||||
const claim = await claimOnboardingAsync(db);
|
||||
if (claim.status !== "issued") throw new Error("Expected an onboarding claim");
|
||||
return claim.token;
|
||||
};
|
||||
|
||||
const seedBoardAsync = async (
|
||||
db: ReturnType<typeof createDb>,
|
||||
input: { boardId: string; itemId?: string; itemKind?: "notebook"; withMobile?: boolean },
|
||||
) => {
|
||||
const layoutId = `${input.boardId}-base`;
|
||||
const sectionId = `${input.boardId}-section`;
|
||||
await db.insert(boards).values({
|
||||
id: input.boardId,
|
||||
name: input.boardId,
|
||||
primaryColor: "#FA5252",
|
||||
secondaryColor: "#FD7E14",
|
||||
});
|
||||
await db.insert(layouts).values({
|
||||
id: layoutId,
|
||||
boardId: input.boardId,
|
||||
name: "Base",
|
||||
role: "base",
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
});
|
||||
if (input.withMobile) {
|
||||
await db.insert(layouts).values({
|
||||
id: `${input.boardId}-mobile`,
|
||||
boardId: input.boardId,
|
||||
name: "Mobile",
|
||||
role: "mobile",
|
||||
columnCount: 3,
|
||||
breakpoint: 0,
|
||||
});
|
||||
}
|
||||
await db.insert(sections).values({
|
||||
id: sectionId,
|
||||
boardId: input.boardId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
if (input.itemId) {
|
||||
await db.insert(items).values({
|
||||
id: input.itemId,
|
||||
boardId: input.boardId,
|
||||
kind: input.itemKind ?? "notebook",
|
||||
options: SuperJSON.stringify({ content: "keep me" }),
|
||||
});
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId: input.itemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 2,
|
||||
yOffset: 3,
|
||||
width: 4,
|
||||
height: 2,
|
||||
});
|
||||
}
|
||||
return { layoutId, sectionId };
|
||||
};
|
||||
|
||||
describe("onboard.completeSetup", () => {
|
||||
beforeEach(() => {
|
||||
listDiscoveredContainersAsync.mockReset();
|
||||
createIdMock.mockReset();
|
||||
let nextId = 0;
|
||||
createIdMock.mockImplementation(() => `generated-${++nextId}`);
|
||||
});
|
||||
|
||||
it("updates only the exact target board and preserves existing content for explicit empty selections", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
const target = await seedBoardAsync(db, { boardId: "target", itemId: "existing-item" });
|
||||
await seedBoardAsync(db, { boardId: "other" });
|
||||
|
||||
const result = await createCaller(db, claimToken).completeSetup(completionInput("target"));
|
||||
|
||||
expect(result).toEqual({
|
||||
boardId: "target",
|
||||
boardName: "Configured-board",
|
||||
docker: { missingSourceIds: [], ignoredSourceIds: [], skippedWidgets: [] },
|
||||
});
|
||||
expect(await db.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({
|
||||
name: "Configured-board",
|
||||
primaryColor: "#228BE6",
|
||||
secondaryColor: "#15AABF",
|
||||
itemRadius: "md",
|
||||
});
|
||||
expect(await db.query.boards.findFirst({ where: eq(boards.id, "other") })).toMatchObject({ name: "other" });
|
||||
expect(await db.query.items.findMany()).toEqual([
|
||||
expect.objectContaining({ id: "existing-item", boardId: "target", kind: "notebook" }),
|
||||
]);
|
||||
expect(await db.query.itemLayouts.findFirst({ where: eq(itemLayouts.itemId, "existing-item") })).toMatchObject({
|
||||
layoutId: target.layoutId,
|
||||
xOffset: 2,
|
||||
yOffset: 3,
|
||||
width: 4,
|
||||
height: 2,
|
||||
});
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "finish", previousStep: "setup" });
|
||||
expect(await isOnboardingClaimValidAsync(db, claimToken)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires an exact board id when more than one board exists", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target" });
|
||||
await seedBoardAsync(db, { boardId: "other" });
|
||||
const input = {
|
||||
...completionInput("target"),
|
||||
board: { ...completionInput("target").board, id: undefined },
|
||||
};
|
||||
|
||||
await expect(createCaller(db, claimToken).completeSetup(input)).rejects.toThrow(
|
||||
"Select the exact board to configure when more than one board exists.",
|
||||
);
|
||||
expect(await db.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({ name: "target" });
|
||||
expect(await db.query.boards.findFirst({ where: eq(boards.id, "other") })).toMatchObject({ name: "other" });
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "group" });
|
||||
});
|
||||
|
||||
it("creates a complete first board when setup resumes without any boards", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
const input = {
|
||||
...completionInput("missing"),
|
||||
board: {
|
||||
...completionInput("missing").board,
|
||||
id: undefined,
|
||||
name: "Recovered-board",
|
||||
leftSidebar: true,
|
||||
rightSidebar: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
expect(await db.query.boards.findMany()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: result.boardId,
|
||||
name: "Recovered-board",
|
||||
primaryColor: "#228BE6",
|
||||
secondaryColor: "#15AABF",
|
||||
itemRadius: "md",
|
||||
}),
|
||||
]);
|
||||
expect(await db.query.layouts.findMany({ where: eq(layouts.boardId, result.boardId) })).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: "mobile", columnCount: 3, breakpoint: 0 }),
|
||||
expect.objectContaining({
|
||||
role: "base",
|
||||
columnCount: 10,
|
||||
breakpoint: 768,
|
||||
leftGutterColumnCount: 1,
|
||||
rightGutterColumnCount: 1,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(await db.query.sections.findMany({ where: eq(sections.boardId, result.boardId) })).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "empty", xOffset: 0, yOffset: 0 }),
|
||||
expect.objectContaining({ kind: "empty", xOffset: -1, yOffset: 0 }),
|
||||
expect.objectContaining({ kind: "empty", xOffset: 1, yOffset: 0 }),
|
||||
]),
|
||||
);
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "finish", previousStep: "setup" });
|
||||
});
|
||||
|
||||
it("rolls back board and layout writes when planned item insertion fails", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
const { layoutId } = await seedBoardAsync(db, { boardId: "target", itemId: "generated-id" });
|
||||
const input = completionInput("target", "Must-roll-back");
|
||||
input.selectedWidgetKinds = ["clock"];
|
||||
createIdMock.mockReturnValue("generated-id");
|
||||
|
||||
await expect(createCaller(db, claimToken).completeSetup(input)).rejects.toThrow();
|
||||
|
||||
expect(await db.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({
|
||||
name: "target",
|
||||
primaryColor: "#FA5252",
|
||||
secondaryColor: "#FD7E14",
|
||||
});
|
||||
expect(await db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) })).toMatchObject({ columnCount: 12 });
|
||||
expect(await db.query.items.findMany()).toHaveLength(1);
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "group" });
|
||||
expect(await isOnboardingClaimValidAsync(db, claimToken)).toBe(true);
|
||||
});
|
||||
|
||||
it("retains partial Docker results and places an explicitly selected label widget", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target" });
|
||||
const service = {
|
||||
sourceId: "docker:good:clock",
|
||||
containerId: "clock",
|
||||
host: "good",
|
||||
group: "Utilities",
|
||||
name: "Clock service",
|
||||
href: "http://clock:3000",
|
||||
externalId: "clock",
|
||||
widgetKind: "clock" as const,
|
||||
};
|
||||
const integrationService = {
|
||||
sourceId: "docker:good:sonarr",
|
||||
containerId: "sonarr",
|
||||
host: "good",
|
||||
group: "Media",
|
||||
name: "Sonarr",
|
||||
href: "http://sonarr:8989",
|
||||
externalId: "sonarr",
|
||||
integrationKind: "sonarr" as const,
|
||||
description: "TV automation",
|
||||
pingUrl: "http://sonarr:8989/ping",
|
||||
icon: "custom-sonarr.svg",
|
||||
};
|
||||
listDiscoveredContainersAsync.mockResolvedValue({
|
||||
hosts: [
|
||||
{ host: "good", status: "success", containers: [], services: [service, integrationService] },
|
||||
{ host: "bad", status: "unavailable", reason: "permission denied", containers: [], services: [] },
|
||||
],
|
||||
services: [service, integrationService],
|
||||
});
|
||||
|
||||
const discovery = await createCaller(db, claimToken).discoverDockerServices();
|
||||
|
||||
expect(discovery).toMatchObject({
|
||||
status: "partial",
|
||||
hosts: [
|
||||
{ host: "good", status: "success", containerCount: 0 },
|
||||
{ host: "bad", status: "unavailable", reason: "permission denied", containerCount: 0 },
|
||||
],
|
||||
apps: [{ sourceId: service.sourceId, widgetKind: "clock" }],
|
||||
integrations: [
|
||||
{
|
||||
sourceId: integrationService.sourceId,
|
||||
iconUrl: integrationService.icon,
|
||||
description: integrationService.description,
|
||||
pingUrl: integrationService.pingUrl,
|
||||
},
|
||||
],
|
||||
});
|
||||
const input = completionInput("target");
|
||||
input.selectedWidgetKinds = discovery.apps.flatMap((app) => (app.widgetKind ? [app.widgetKind] : []));
|
||||
await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
expect(await db.query.items.findMany()).toEqual([expect.objectContaining({ boardId: "target", kind: "clock" })]);
|
||||
});
|
||||
|
||||
it("persists discovery metadata on created apps and integration companion apps", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
const caller = createCaller(db, claimToken);
|
||||
const appResult = await caller.createAppsFromDiscovery([
|
||||
{
|
||||
sourceId: "docker:good:status",
|
||||
name: "Status",
|
||||
href: "http://status:3001",
|
||||
pingUrl: "http://status:3001/health",
|
||||
iconUrl: "https://icons.example/status.svg",
|
||||
description: "Internal status page",
|
||||
},
|
||||
]);
|
||||
const createdApp = appResult.apps[0];
|
||||
assert(createdApp);
|
||||
expect(await db.query.apps.findFirst({ where: eq(apps.id, createdApp.id) })).toMatchObject({
|
||||
pingUrl: "http://status:3001/health",
|
||||
iconUrl: "https://icons.example/status.svg",
|
||||
description: "Internal status page",
|
||||
});
|
||||
|
||||
const testConnection = vi.spyOn(integrationConnection, "testConnectionAsync").mockResolvedValue({ success: true });
|
||||
try {
|
||||
const integrationResult = await caller.createIntegration({
|
||||
sourceId: "docker:good:sonarr",
|
||||
name: "Sonarr",
|
||||
url: "http://sonarr:8989",
|
||||
kind: "sonarr",
|
||||
secrets: [{ kind: "apiKey", value: "secret" }],
|
||||
pingUrl: "http://sonarr:8989/ping",
|
||||
iconUrl: "https://icons.example/sonarr.svg",
|
||||
description: "TV automation",
|
||||
});
|
||||
const createdAppId = "appId" in integrationResult ? integrationResult.appId : undefined;
|
||||
expect(createdAppId).toBeDefined();
|
||||
if (!createdAppId) throw new Error("Expected integration creation to succeed");
|
||||
expect(await db.query.apps.findFirst({ where: eq(apps.id, createdAppId) })).toMatchObject({
|
||||
pingUrl: "http://sonarr:8989/ping",
|
||||
iconUrl: "https://icons.example/sonarr.svg",
|
||||
description: "TV automation",
|
||||
});
|
||||
} finally {
|
||||
testConnection.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses a manual integration and companion app across retries", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
const caller = createCaller(db, claimToken);
|
||||
const sourceId = "manual:sonarr";
|
||||
const createInput = {
|
||||
sourceId,
|
||||
name: "Sonarr",
|
||||
url: "http://sonarr:8989",
|
||||
kind: "sonarr" as const,
|
||||
secrets: [{ kind: "apiKey" as const, value: "secret" }],
|
||||
};
|
||||
const testConnection = vi.spyOn(integrationConnection, "testConnectionAsync").mockResolvedValue({ success: true });
|
||||
|
||||
try {
|
||||
const first = await caller.createIntegration(createInput);
|
||||
const second = await caller.createIntegration(createInput);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
expect(first).toEqual({
|
||||
id: stableIntegrationIdForSource(sourceId),
|
||||
appId: stableAppIdForSource(sourceId),
|
||||
});
|
||||
expect(await db.query.integrations.findMany()).toHaveLength(1);
|
||||
expect(await db.query.apps.findMany()).toHaveLength(1);
|
||||
expect(
|
||||
await db.query.integrationSecrets.findMany({
|
||||
where: eq(integrationSecrets.integrationId, stableIntegrationIdForSource(sourceId)),
|
||||
}),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
testConnection.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("re-fetches selected labels into grouped responsive containers and ignores another board target", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
const { sectionId: mainSectionId } = await seedBoardAsync(db, {
|
||||
boardId: "target",
|
||||
itemId: "existing-item",
|
||||
withMobile: true,
|
||||
});
|
||||
const groupedSource = {
|
||||
sourceId: "docker:good:sonarr",
|
||||
containerId: "sonarr",
|
||||
host: "good",
|
||||
group: "Media",
|
||||
name: "Sonarr",
|
||||
href: "http://sonarr:8989",
|
||||
externalId: "sonarr",
|
||||
widgetKind: "clock" as const,
|
||||
};
|
||||
const ignoredSource = {
|
||||
...groupedSource,
|
||||
sourceId: "docker:good:radarr",
|
||||
containerId: "radarr",
|
||||
externalId: "radarr",
|
||||
name: "Radarr",
|
||||
boardName: "other-board",
|
||||
};
|
||||
listDiscoveredContainersAsync.mockResolvedValue({
|
||||
hosts: [{ host: "good", status: "success", containers: [], services: [groupedSource, ignoredSource] }],
|
||||
services: [groupedSource, ignoredSource],
|
||||
});
|
||||
const groupedAppId = stableAppIdForSource(groupedSource.sourceId);
|
||||
const ignoredAppId = stableAppIdForSource(ignoredSource.sourceId);
|
||||
await db.insert(apps).values([
|
||||
{ id: groupedAppId, name: groupedSource.name, iconUrl: "icon", href: groupedSource.href },
|
||||
{ id: ignoredAppId, name: ignoredSource.name, iconUrl: "icon", href: ignoredSource.href },
|
||||
]);
|
||||
const input = completionInput("target");
|
||||
input.selectedAppIds = [groupedAppId, ignoredAppId];
|
||||
input.selectedDockerSourceIds = [groupedSource.sourceId, ignoredSource.sourceId, "docker:missing"];
|
||||
input.selectedWidgetKinds = ["clock"];
|
||||
|
||||
const result = await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
expect(result.docker).toEqual({
|
||||
missingSourceIds: ["docker:missing"],
|
||||
ignoredSourceIds: [ignoredSource.sourceId],
|
||||
skippedWidgets: [],
|
||||
});
|
||||
const mediaSection = await db.query.sections.findFirst({
|
||||
where: (table, operators) =>
|
||||
operators.and(operators.eq(table.boardId, "target"), operators.eq(table.kind, "container")),
|
||||
});
|
||||
expect(mediaSection).toBeDefined();
|
||||
expect(SuperJSON.parse(mediaSection?.options ?? "{}")).toMatchObject({ title: "Media", showLabel: true });
|
||||
const containerPlacements = await db.query.sectionLayouts.findMany({
|
||||
where: eq(sectionLayouts.sectionId, mediaSection?.id ?? "missing"),
|
||||
});
|
||||
expect(containerPlacements).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ layoutId: "target-base", parentSectionId: mainSectionId, width: 10 }),
|
||||
expect.objectContaining({ layoutId: "target-mobile", parentSectionId: mainSectionId, width: 3 }),
|
||||
]),
|
||||
);
|
||||
const groupedItems = await db.query.items.findMany({
|
||||
where: (table, operators) => operators.inArray(table.kind, ["app", "clock"]),
|
||||
with: { layouts: true },
|
||||
});
|
||||
expect(groupedItems).toHaveLength(2);
|
||||
expect(groupedItems.flatMap((item) => item.layouts)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ layoutId: "target-base", sectionId: mediaSection?.id }),
|
||||
expect.objectContaining({ layoutId: "target-mobile", sectionId: mediaSection?.id }),
|
||||
]),
|
||||
);
|
||||
expect(await db.query.items.findFirst({ where: eq(items.id, "existing-item") })).toBeDefined();
|
||||
expect(
|
||||
groupedItems.some(
|
||||
(item) => item.kind === "app" && SuperJSON.parse<{ appId: string }>(item.options).appId === ignoredAppId,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("places a labeled integration app and linked widget together inside its group", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target", withMobile: true });
|
||||
const source = {
|
||||
sourceId: "docker:good:sonarr",
|
||||
containerId: "sonarr",
|
||||
host: "good",
|
||||
group: "Media",
|
||||
name: "Sonarr",
|
||||
href: "http://sonarr:8989",
|
||||
externalId: "sonarr",
|
||||
integrationKind: "sonarr" as const,
|
||||
widgetKind: "calendar" as const,
|
||||
};
|
||||
listDiscoveredContainersAsync.mockResolvedValue({
|
||||
hosts: [{ host: "good", status: "success", containers: [], services: [source] }],
|
||||
services: [source],
|
||||
});
|
||||
const appId = stableAppIdForSource(source.sourceId);
|
||||
const integrationId = stableIntegrationIdForSource(source.sourceId);
|
||||
await db.insert(apps).values({ id: appId, name: source.name, iconUrl: "icon", href: source.href });
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
appId,
|
||||
name: source.name,
|
||||
url: source.href,
|
||||
kind: source.integrationKind,
|
||||
});
|
||||
const input = completionInput("target");
|
||||
input.selectedIntegrationIds = [integrationId];
|
||||
input.selectedAppIds = [appId];
|
||||
input.selectedDockerSourceIds = [source.sourceId];
|
||||
input.selectedWidgetKinds = [source.widgetKind];
|
||||
|
||||
await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
const mediaSection = await db.query.sections.findFirst({
|
||||
where: (table, operators) => operators.eq(table.kind, "container"),
|
||||
});
|
||||
const groupedItems = await db.query.items.findMany({
|
||||
where: (table, operators) => operators.inArray(table.kind, ["app", "calendar"]),
|
||||
with: { layouts: true },
|
||||
});
|
||||
expect(groupedItems).toHaveLength(2);
|
||||
expect(groupedItems.every((item) => item.layouts.every((layout) => layout.sectionId === mediaSection?.id))).toBe(
|
||||
true,
|
||||
);
|
||||
const calendar = groupedItems.find((item) => item.kind === "calendar");
|
||||
expect(calendar).toBeDefined();
|
||||
expect(
|
||||
await db.query.integrationItems.findFirst({ where: eq(integrationItems.itemId, calendar?.id ?? "") }),
|
||||
).toEqual({
|
||||
itemId: calendar?.id,
|
||||
integrationId,
|
||||
});
|
||||
});
|
||||
|
||||
it("links a labeled integration to an existing compatible widget", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
const { sectionId, layoutId } = await seedBoardAsync(db, { boardId: "target", itemId: "existing-seed" });
|
||||
await db.insert(items).values({ id: "calendar", boardId: "target", kind: "calendar" });
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId: "calendar",
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 2,
|
||||
height: 2,
|
||||
});
|
||||
const source = {
|
||||
sourceId: "docker:good:sonarr",
|
||||
containerId: "sonarr",
|
||||
host: "good",
|
||||
group: "Media",
|
||||
name: "Sonarr",
|
||||
href: "http://sonarr:8989",
|
||||
externalId: "sonarr",
|
||||
integrationKind: "sonarr" as const,
|
||||
widgetKind: "calendar" as const,
|
||||
};
|
||||
listDiscoveredContainersAsync.mockResolvedValue({
|
||||
hosts: [{ host: "good", status: "success", containers: [], services: [source] }],
|
||||
services: [source],
|
||||
});
|
||||
const integrationId = stableIntegrationIdForSource(source.sourceId);
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
name: source.name,
|
||||
url: source.href,
|
||||
kind: source.integrationKind,
|
||||
});
|
||||
const input = completionInput("target");
|
||||
input.selectedIntegrationIds = [integrationId];
|
||||
input.selectedDockerSourceIds = [source.sourceId];
|
||||
input.selectedWidgetKinds = [source.widgetKind];
|
||||
|
||||
await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
expect(await db.query.integrationItems.findFirst({ where: eq(integrationItems.itemId, "calendar") })).toEqual({
|
||||
itemId: "calendar",
|
||||
integrationId,
|
||||
});
|
||||
expect(await db.query.items.findMany({ where: eq(items.kind, "calendar") })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips integration-required and incompatible label widgets but preserves their selected apps", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target", itemId: "seeded-content" });
|
||||
await db.update(boards).set({ name: "dashboard" }).where(eq(boards.id, "target"));
|
||||
const appOnly = {
|
||||
sourceId: "docker:good:app-only",
|
||||
containerId: "app-only",
|
||||
host: "good",
|
||||
group: "Media",
|
||||
name: "App only",
|
||||
href: "http://app-only:3000",
|
||||
externalId: "app-only",
|
||||
widgetKind: "mediaServer" as const,
|
||||
};
|
||||
const incompatible = {
|
||||
...appOnly,
|
||||
sourceId: "docker:good:sonarr",
|
||||
containerId: "sonarr",
|
||||
externalId: "sonarr",
|
||||
name: "Sonarr",
|
||||
href: "http://sonarr:8989",
|
||||
integrationKind: "sonarr" as const,
|
||||
};
|
||||
const services = [appOnly, incompatible];
|
||||
listDiscoveredContainersAsync.mockResolvedValue({
|
||||
hosts: [{ host: "good", status: "success", containers: [], services }],
|
||||
services,
|
||||
});
|
||||
const appIds = services.map((source) => stableAppIdForSource(source.sourceId));
|
||||
await db.insert(apps).values(
|
||||
services.map((source) => ({
|
||||
id: stableAppIdForSource(source.sourceId),
|
||||
name: source.name,
|
||||
iconUrl: "icon",
|
||||
href: source.href,
|
||||
})),
|
||||
);
|
||||
const integrationId = stableIntegrationIdForSource(incompatible.sourceId);
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
appId: appIds[1],
|
||||
name: incompatible.name,
|
||||
url: incompatible.href,
|
||||
kind: incompatible.integrationKind,
|
||||
});
|
||||
const input = completionInput("target");
|
||||
input.selectedAppIds = appIds;
|
||||
input.selectedIntegrationIds = [integrationId];
|
||||
input.selectedDockerSourceIds = services.map((source) => source.sourceId);
|
||||
input.selectedWidgetKinds = ["mediaServer"];
|
||||
|
||||
const result = await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
expect(result.docker.skippedWidgets).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ sourceId: appOnly.sourceId, code: "integration-required" }),
|
||||
expect.objectContaining({
|
||||
sourceId: incompatible.sourceId,
|
||||
code: "incompatible-integration",
|
||||
integrationKind: "sonarr",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(await db.query.items.findMany({ where: eq(items.kind, "mediaServer") })).toHaveLength(0);
|
||||
expect(await db.query.items.findMany({ where: eq(items.kind, "app") })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("splits limited automatic integration widgets instead of over-linking one item", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target" });
|
||||
await db.insert(integrations).values([
|
||||
{ id: "navidrome", name: "Navidrome", url: "http://navidrome:4533", kind: "navidrome" },
|
||||
{ id: "audiobookshelf", name: "Audiobookshelf", url: "http://audiobookshelf:13378", kind: "audiobookshelf" },
|
||||
]);
|
||||
const input = completionInput("target");
|
||||
input.selectedIntegrationIds = ["navidrome", "audiobookshelf"];
|
||||
|
||||
await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
const audioItems = await db.query.items.findMany({ where: eq(items.kind, "audioStats") });
|
||||
expect(audioItems).toHaveLength(2);
|
||||
const links = await db.query.integrationItems.findMany({
|
||||
where: inArray(
|
||||
integrationItems.itemId,
|
||||
audioItems.map((item) => item.id),
|
||||
),
|
||||
});
|
||||
expect(links).toHaveLength(2);
|
||||
expect(new Set(links.map((link) => link.itemId)).size).toBe(2);
|
||||
});
|
||||
|
||||
it("places an ungrouped labeled app and widget in the main root", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target" });
|
||||
const source = {
|
||||
sourceId: "docker:good:status",
|
||||
containerId: "status",
|
||||
host: "good",
|
||||
name: "Status",
|
||||
href: "http://status:3001",
|
||||
externalId: "status",
|
||||
widgetKind: "clock" as const,
|
||||
};
|
||||
listDiscoveredContainersAsync.mockResolvedValue({
|
||||
hosts: [{ host: "good", status: "success", containers: [], services: [source] }],
|
||||
services: [source],
|
||||
});
|
||||
const appId = stableAppIdForSource(source.sourceId);
|
||||
await db.insert(apps).values({ id: appId, name: source.name, iconUrl: "icon", href: source.href });
|
||||
const input = completionInput("target");
|
||||
input.selectedAppIds = [appId];
|
||||
input.selectedDockerSourceIds = [source.sourceId];
|
||||
input.selectedWidgetKinds = [source.widgetKind];
|
||||
|
||||
await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
expect(await db.query.sections.findMany({ where: eq(sections.kind, "container") })).toHaveLength(0);
|
||||
expect(await db.query.items.findMany()).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ kind: "app" }), expect.objectContaining({ kind: "clock" })]),
|
||||
);
|
||||
expect((await db.query.itemLayouts.findMany()).every((layout) => layout.sectionId === "target-section")).toBe(true);
|
||||
});
|
||||
|
||||
it("packs grouped items with their actual dimensions without overlapping pending root content", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target", withMobile: true });
|
||||
const sources = [
|
||||
{
|
||||
sourceId: "docker:good:clock",
|
||||
containerId: "clock",
|
||||
host: "good",
|
||||
group: "Utilities",
|
||||
name: "Clock",
|
||||
href: "http://clock:3000",
|
||||
externalId: "clock",
|
||||
widgetKind: "clock" as const,
|
||||
},
|
||||
{
|
||||
sourceId: "docker:good:weather",
|
||||
containerId: "weather",
|
||||
host: "good",
|
||||
group: "Utilities",
|
||||
name: "Weather",
|
||||
href: "http://weather:3000",
|
||||
externalId: "weather",
|
||||
widgetKind: "weather" as const,
|
||||
},
|
||||
];
|
||||
listDiscoveredContainersAsync.mockResolvedValue({
|
||||
hosts: [{ host: "good", status: "success", containers: [], services: sources }],
|
||||
services: sources,
|
||||
});
|
||||
const appIds = sources.map((source) => stableAppIdForSource(source.sourceId));
|
||||
await db.insert(apps).values(
|
||||
sources.map((source) => ({
|
||||
id: stableAppIdForSource(source.sourceId),
|
||||
name: source.name,
|
||||
iconUrl: "icon",
|
||||
href: source.href,
|
||||
})),
|
||||
);
|
||||
const input = completionInput("target");
|
||||
input.board.leftSidebar = true;
|
||||
input.board.rightSidebar = true;
|
||||
input.selectedAppIds = appIds;
|
||||
input.selectedDockerSourceIds = sources.map((source) => source.sourceId);
|
||||
input.selectedWidgetKinds = ["bookmarks", "clock", "weather"];
|
||||
|
||||
await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
const container = await db.query.sections.findFirst({ where: (table, { eq }) => eq(table.kind, "container") });
|
||||
expect(container).toBeDefined();
|
||||
const containerLayouts = await db.query.sectionLayouts.findMany({
|
||||
where: eq(sectionLayouts.sectionId, container?.id ?? "missing"),
|
||||
});
|
||||
const groupedLayouts = await db.query.itemLayouts.findMany({
|
||||
where: eq(itemLayouts.sectionId, container?.id ?? "missing"),
|
||||
});
|
||||
for (const layoutId of ["target-base", "target-mobile"]) {
|
||||
const positions = groupedLayouts.filter((layout) => layout.layoutId === layoutId);
|
||||
const containerLayout = containerLayouts.find((layout) => layout.layoutId === layoutId);
|
||||
expect(containerLayout).toBeDefined();
|
||||
expect(Math.max(...positions.map((position) => position.yOffset + position.height))).toBeLessThanOrEqual(
|
||||
containerLayout?.height ?? 0,
|
||||
);
|
||||
for (let firstIndex = 0; firstIndex < positions.length; firstIndex += 1) {
|
||||
for (let secondIndex = firstIndex + 1; secondIndex < positions.length; secondIndex += 1) {
|
||||
const firstPosition = positions[firstIndex];
|
||||
const secondPosition = positions[secondIndex];
|
||||
assert(firstPosition);
|
||||
assert(secondPosition);
|
||||
expect(rectanglesOverlap(firstPosition, secondPosition)).toBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
const rootBookmarks = await db.query.itemLayouts.findMany({
|
||||
where: (table, { and, eq, inArray }) =>
|
||||
and(eq(table.sectionId, "target-section"), inArray(table.layoutId, ["target-base", "target-mobile"])),
|
||||
});
|
||||
for (const containerLayout of containerLayouts) {
|
||||
const rootItem = rootBookmarks.find((layout) => layout.layoutId === containerLayout.layoutId);
|
||||
expect(rootItem).toBeDefined();
|
||||
assert(rootItem);
|
||||
expect(rectanglesOverlap(rootItem, containerLayout)).toBe(false);
|
||||
expect(containerLayout.width).toBeLessThanOrEqual(containerLayout.layoutId === "target-base" ? 8 : 3);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the main root and creates enabled sidebar roots transactionally", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await db.insert(boards).values({ id: "target", name: "target" });
|
||||
await db.insert(layouts).values({
|
||||
id: "target-base",
|
||||
boardId: "target",
|
||||
name: "Base",
|
||||
role: "base",
|
||||
columnCount: 10,
|
||||
breakpoint: 768,
|
||||
});
|
||||
await db.insert(sections).values([
|
||||
{ id: "right-root", boardId: "target", kind: "empty", xOffset: 1, yOffset: 0 },
|
||||
{ id: "main-root", boardId: "target", kind: "empty", xOffset: 0, yOffset: 0 },
|
||||
]);
|
||||
const input = completionInput("target");
|
||||
input.board.leftSidebar = true;
|
||||
input.board.rightSidebar = true;
|
||||
input.selectedWidgetKinds = ["clock"];
|
||||
|
||||
await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
expect(await db.query.sections.findMany({ where: eq(sections.boardId, "target") })).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "main-root", xOffset: 0 }),
|
||||
expect.objectContaining({ id: "right-root", xOffset: 1 }),
|
||||
expect.objectContaining({ xOffset: -1, kind: "empty" }),
|
||||
]),
|
||||
);
|
||||
expect(await db.query.itemLayouts.findFirst()).toMatchObject({ sectionId: "main-root" });
|
||||
expect(await db.query.layouts.findFirst({ where: eq(layouts.id, "target-base") })).toMatchObject({
|
||||
leftGutterColumnCount: 1,
|
||||
rightGutterColumnCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("adopts one label board name for a fresh sole board and ignores sources for other board targets", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target", itemId: "seeded-content" });
|
||||
await db.update(boards).set({ name: "dashboard" }).where(eq(boards.id, "target"));
|
||||
const media = {
|
||||
sourceId: "docker:good:media",
|
||||
containerId: "media",
|
||||
host: "good",
|
||||
group: "Media",
|
||||
name: "Media",
|
||||
href: "http://media:3000",
|
||||
externalId: "media",
|
||||
boardName: "Media board",
|
||||
};
|
||||
listDiscoveredContainersAsync.mockResolvedValue({
|
||||
hosts: [{ host: "good", status: "success", containers: [], services: [media] }],
|
||||
services: [media],
|
||||
});
|
||||
const input = completionInput("target");
|
||||
input.selectedDockerSourceIds = [media.sourceId];
|
||||
|
||||
const result = await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
expect(result.boardName).toBe("Media-board");
|
||||
expect(await db.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({ name: "Media-board" });
|
||||
expect(await db.query.items.findFirst({ where: eq(items.id, "seeded-content") })).toBeDefined();
|
||||
|
||||
const conflictDb = createDb();
|
||||
const conflictClaim = await seedOnboardingAsync(conflictDb);
|
||||
await seedBoardAsync(conflictDb, { boardId: "target", itemId: "seeded-content" });
|
||||
await conflictDb.update(boards).set({ name: "dashboard" }).where(eq(boards.id, "target"));
|
||||
const other = { ...media, sourceId: "docker:good:other", containerId: "other", boardName: "Other board" };
|
||||
const ignoredIntegrationSource = {
|
||||
...other,
|
||||
sourceId: "docker:good:other-sonarr",
|
||||
containerId: "other-sonarr",
|
||||
externalId: "other-sonarr",
|
||||
integrationKind: "sonarr" as const,
|
||||
};
|
||||
listDiscoveredContainersAsync.mockResolvedValue({
|
||||
hosts: [{ host: "good", status: "success", containers: [], services: [media, other, ignoredIntegrationSource] }],
|
||||
services: [media, other, ignoredIntegrationSource],
|
||||
});
|
||||
const conflictInput = completionInput("target", "Media-board");
|
||||
const ignoredIntegrationId = stableIntegrationIdForSource(ignoredIntegrationSource.sourceId);
|
||||
await conflictDb.insert(integrations).values({
|
||||
id: ignoredIntegrationId,
|
||||
name: ignoredIntegrationSource.name,
|
||||
url: ignoredIntegrationSource.href,
|
||||
kind: ignoredIntegrationSource.integrationKind,
|
||||
});
|
||||
conflictInput.selectedIntegrationIds = [ignoredIntegrationId];
|
||||
conflictInput.selectedDockerSourceIds = [media.sourceId, other.sourceId, ignoredIntegrationSource.sourceId];
|
||||
const conflictResult = await createCaller(conflictDb, conflictClaim).completeSetup(conflictInput);
|
||||
expect(conflictResult.docker.ignoredSourceIds).toEqual([other.sourceId, ignoredIntegrationSource.sourceId]);
|
||||
expect(await conflictDb.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({
|
||||
name: "Media-board",
|
||||
});
|
||||
expect(await conflictDb.query.items.findMany({ where: eq(items.kind, "calendar") })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects setup mutations from an unclaimed browser", async () => {
|
||||
const db = createDb();
|
||||
await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target" });
|
||||
await expect(createCaller(db).completeSetup(completionInput("target"))).rejects.toThrow(
|
||||
"This onboarding session is not claimed.",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires an authenticated administrator once any user exists", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target" });
|
||||
await db.insert(users).values({ id: "owner", name: "owner" });
|
||||
await expect(createCaller(db, claimToken).completeSetup(completionInput("target"))).rejects.toThrow(
|
||||
"This onboarding session is not claimed.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onboard.nextStep", () => {
|
||||
it("advances welcome exactly once", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "start" });
|
||||
const claim = await claimOnboardingAsync(db);
|
||||
if (claim.status !== "issued") throw new Error("Expected an onboarding claim");
|
||||
const caller = createCaller(db, claim.token);
|
||||
|
||||
await caller.nextStep();
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "start" });
|
||||
await expect(caller.nextStep()).rejects.toThrow("The welcome step is already complete.");
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "start" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeOnboardingStep", () => {
|
||||
it("keeps legacy import backups eligible for restore from the start step", () => {
|
||||
expect(normalizeOnboardingStep("import")).toBe("start");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { eq } from "@homarr/db";
|
||||
import { onboarding, serverSettings, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import {
|
||||
claimOnboardingAsync,
|
||||
getOnboardingClaimTokenFromCookieHeader,
|
||||
isOnboardingClaimValidAsync,
|
||||
onboardingClaimCookieName,
|
||||
onboardingClaimSettingKey,
|
||||
} from "../../onboarding-claim";
|
||||
|
||||
describe("onboarding claim", () => {
|
||||
it("stores only a token hash and validates the claimed browser", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "start" });
|
||||
|
||||
const result = await claimOnboardingAsync(db, { now: 1_000, ttlMs: 10_000 });
|
||||
expect(result.status).toBe("issued");
|
||||
if (result.status !== "issued") throw new Error("Expected an issued claim");
|
||||
|
||||
const row = await db.query.serverSettings.findFirst({
|
||||
where: eq(serverSettings.settingKey, onboardingClaimSettingKey),
|
||||
});
|
||||
expect(row?.value).not.toContain(result.token);
|
||||
expect(await isOnboardingClaimValidAsync(db, result.token, 5_000)).toBe(true);
|
||||
expect(await isOnboardingClaimValidAsync(db, "not-the-token", 5_000)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let another browser replace a live claim", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "start" });
|
||||
const issued = await claimOnboardingAsync(db, { now: 1_000, ttlMs: 10_000 });
|
||||
if (issued.status !== "issued") throw new Error("Expected an issued claim");
|
||||
|
||||
expect(await claimOnboardingAsync(db, { now: 2_000 })).toEqual({
|
||||
status: "locked",
|
||||
expiresAt: 11_000,
|
||||
});
|
||||
expect(await claimOnboardingAsync(db, { currentToken: issued.token, now: 2_000 })).toEqual({
|
||||
status: "active",
|
||||
token: issued.token,
|
||||
expiresAt: 11_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows a new claim after expiry and invalidates the old token", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "start" });
|
||||
const first = await claimOnboardingAsync(db, { now: 1_000, ttlMs: 100 });
|
||||
if (first.status !== "issued") throw new Error("Expected an issued claim");
|
||||
|
||||
const second = await claimOnboardingAsync(db, { now: 1_101, ttlMs: 100 });
|
||||
if (second.status !== "issued") throw new Error("Expected a replacement claim");
|
||||
expect(second.token).not.toBe(first.token);
|
||||
expect(await isOnboardingClaimValidAsync(db, first.token, 1_102)).toBe(false);
|
||||
expect(await isOnboardingClaimValidAsync(db, second.token, 1_102)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses claims after setup finishes", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "finish", previousStep: "setup" });
|
||||
await expect(claimOnboardingAsync(db)).resolves.toEqual({ status: "finished" });
|
||||
});
|
||||
|
||||
it("requires an administrator to rotate claims after a user exists", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "setup", previousStep: "user" });
|
||||
await db.insert(users).values({ id: "owner", name: "owner" });
|
||||
await expect(claimOnboardingAsync(db)).resolves.toEqual({ status: "forbidden" });
|
||||
await expect(claimOnboardingAsync(db, { force: true })).resolves.toMatchObject({ status: "issued" });
|
||||
});
|
||||
|
||||
it("extracts only the exact claim cookie", () => {
|
||||
expect(
|
||||
getOnboardingClaimTokenFromCookieHeader(`theme=dark; ${onboardingClaimCookieName}=secret-token; other=x`),
|
||||
).toBe("secret-token");
|
||||
expect(getOnboardingClaimTokenFromCookieHeader(`${onboardingClaimCookieName}-copy=wrong`)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { invites, onboarding, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
|
||||
|
||||
import { claimOnboardingAsync } from "../../onboarding-claim";
|
||||
import { userRouter } from "../user";
|
||||
|
||||
const defaultOwnerId = createId();
|
||||
@@ -40,11 +41,12 @@ vi.mock("@homarr/auth/env", () => {
|
||||
describe("initUser should initialize the first user", () => {
|
||||
it("should create a user if none exists", async () => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const onboardingClaimToken = await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
onboardingClaimToken,
|
||||
});
|
||||
|
||||
await caller.initUser({
|
||||
@@ -64,11 +66,12 @@ 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 onboardingClaimToken = await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
onboardingClaimToken,
|
||||
});
|
||||
|
||||
const actAsync = async () =>
|
||||
@@ -83,11 +86,12 @@ describe("initUser should initialize the first user", () => {
|
||||
|
||||
it.each([["aB2%"], ["short"]])("should reject passwords shorter than 8 characters for '%s'", async (password) => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const onboardingClaimToken = await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
onboardingClaimToken,
|
||||
});
|
||||
|
||||
const actAsync = async () =>
|
||||
@@ -102,11 +106,12 @@ describe("initUser should initialize the first user", () => {
|
||||
|
||||
it("should accept passwords without complexity requirements", async () => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const onboardingClaimToken = await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
onboardingClaimToken,
|
||||
});
|
||||
|
||||
await caller.initUser({
|
||||
@@ -126,11 +131,12 @@ describe("initUser should initialize the first user", () => {
|
||||
|
||||
it("should accept passwords with special characters like LoveHomarr<3", async () => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const onboardingClaimToken = await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
onboardingClaimToken,
|
||||
});
|
||||
|
||||
await caller.initUser({
|
||||
@@ -439,4 +445,7 @@ const createOnboardingStepAsync = async (db: Database, step: OnboardingStep) =>
|
||||
id: createId(),
|
||||
step,
|
||||
});
|
||||
const claim = await claimOnboardingAsync(db);
|
||||
if (claim.status !== "issued") throw new Error("Expected an onboarding claim");
|
||||
return claim.token;
|
||||
};
|
||||
@@ -4,12 +4,13 @@ import type { QueryResult } from "pg";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { comparePasswordsAsync, hashPasswordAsync } from "@homarr/auth";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq, handleTransactionsAsync, inArray, like } from "@homarr/db";
|
||||
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
|
||||
import { boards, groupMembers, groupPermissions, groups, invites, onboarding, users } from "@homarr/db/schema";
|
||||
import { selectUserSchema } from "@homarr/db/validationSchemas";
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
import { credentialsAdminGroup, supportedAuthProviders } from "@homarr/definitions";
|
||||
@@ -41,7 +42,6 @@ import {
|
||||
} from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
import { throwIfCredentialsDisabled } from "./invite/checks";
|
||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||
import { changeSearchPreferencesAsync, changeSearchPreferencesInputSchema } from "./user/change-search-preferences";
|
||||
|
||||
const logger = createLogger({ module: "userRouter" });
|
||||
@@ -52,25 +52,47 @@ export const userRouter = createTRPCRouter({
|
||||
.input(userInitSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfCredentialsDisabled();
|
||||
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
|
||||
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
const userId = await createUserAsync(ctx.db, input);
|
||||
const hashedPassword = await hashPasswordAsync(input.password);
|
||||
const userId = createId();
|
||||
const groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
const userRow = {
|
||||
id: userId,
|
||||
name: input.username,
|
||||
email: input.email,
|
||||
password: hashedPassword,
|
||||
};
|
||||
const groupRow = {
|
||||
id: groupId,
|
||||
name: credentialsAdminGroup,
|
||||
ownerId: userId,
|
||||
position: maxPosition + 1,
|
||||
};
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction.insert(schema.users).values(userRow);
|
||||
await transaction.insert(schema.groups).values(groupRow);
|
||||
await transaction.insert(schema.groupPermissions).values({ groupId, permission: "admin" });
|
||||
await transaction.insert(schema.groupMembers).values({ groupId, userId });
|
||||
const nextStep = isProviderEnabled("ldap") || isProviderEnabled("oidc") ? "group" : "setup";
|
||||
await transaction.update(schema.onboarding).set({ previousStep: "user", step: nextStep });
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction.insert(users).values(userRow).run();
|
||||
transaction.insert(groups).values(groupRow).run();
|
||||
transaction.insert(groupPermissions).values({ groupId, permission: "admin" }).run();
|
||||
transaction.insert(groupMembers).values({ groupId, userId }).run();
|
||||
const nextStep = isProviderEnabled("ldap") || isProviderEnabled("oidc") ? "group" : "setup";
|
||||
transaction.update(onboarding).set({ previousStep: "user", step: nextStep }).run();
|
||||
});
|
||||
},
|
||||
});
|
||||
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(userRegistrationApiSchema)
|
||||
|
||||
@@ -20,6 +20,7 @@ import { db } from "@homarr/db";
|
||||
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
|
||||
|
||||
import { env } from "./env";
|
||||
import { getOnboardingClaimTokenFromCookieHeader, isClaimOnlyOnboardingAccessAllowedAsync } from "./onboarding-claim";
|
||||
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
|
||||
import type { McpMeta } from "./mcp-tools";
|
||||
|
||||
@@ -41,6 +42,7 @@ interface ApiContext {
|
||||
session: Session | null;
|
||||
deviceType: DeviceType;
|
||||
baseUrl?: `${string}://${string}`;
|
||||
onboardingClaimToken?: string;
|
||||
db: typeof db;
|
||||
}
|
||||
|
||||
@@ -58,6 +60,7 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
|
||||
session,
|
||||
deviceType: userAgent(opts.headers).device.type,
|
||||
baseUrl: extractBaseUrlFromHeaders(opts.headers),
|
||||
onboardingClaimToken: getOnboardingClaimTokenFromCookieHeader(opts.headers.get("cookie")),
|
||||
db,
|
||||
};
|
||||
};
|
||||
@@ -129,6 +132,19 @@ const baseProcedure = isDemoReadOnly ? t.procedure.use(enforceDemoModeReadOnly)
|
||||
*/
|
||||
export const publicProcedure = baseProcedure;
|
||||
|
||||
const enforceOnboardingAccess = t.middleware(async ({ ctx, next }) => {
|
||||
const isAdmin = ctx.session?.user.permissions.includes("admin") ?? false;
|
||||
if (!isAdmin) {
|
||||
const hasValidClaim = await isClaimOnlyOnboardingAccessAllowedAsync(ctx.db, ctx.onboardingClaimToken);
|
||||
if (!hasValidClaim) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "This onboarding session is not claimed." });
|
||||
}
|
||||
}
|
||||
return next({ ctx });
|
||||
});
|
||||
|
||||
export const onboardingClaimedProcedure = baseProcedure.use(enforceOnboardingAccess);
|
||||
|
||||
export const internalProcedure = t.procedure;
|
||||
|
||||
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
|
||||
@@ -177,7 +193,7 @@ export const permissionRequiredProcedure = {
|
||||
|
||||
export const onboardingProcedure = {
|
||||
requiresStep: (step: OnboardingStep) => {
|
||||
return publicProcedure.use(async ({ ctx, input, next }) => {
|
||||
return onboardingClaimedProcedure.use(async ({ ctx, input, next }) => {
|
||||
const currentStep = await getOnboardingOrFallbackAsync(ctx.db).then(({ current }) => current);
|
||||
if (currentStep !== step) {
|
||||
throw new TRPCError({
|
||||
|
||||
Reference in new issue
Block a user