feat(groups): add home board settings (#2321)

This commit is contained in:
Meier Lukas
2025-02-15 10:08:06 +01:00
parent 33ef9f6678
commit ffe7259802
40 changed files with 4536 additions and 146 deletions

View File

@@ -5,7 +5,7 @@ import { z } from "zod";
import { constructBoardPermissions } from "@homarr/auth/shared";
import type { DeviceType } from "@homarr/common/server";
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
import { and, createId, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
import { and, asc, createId, eq, handleTransactionsAsync, inArray, isNull, like, not, or } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import {
boardGroupPermissions,
@@ -13,6 +13,7 @@ import {
boardUserPermissions,
groupMembers,
groupPermissions,
groups,
integrationGroupPermissions,
integrationItems,
integrationUserPermissions,
@@ -22,7 +23,7 @@ import {
users,
} from "@homarr/db/schema";
import type { WidgetKind } from "@homarr/definitions";
import { getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
import { everyoneGroup, getPermissionsWithChildren, getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
import { importOldmarrAsync } from "@homarr/old-import";
import { importJsonFileSchema } from "@homarr/old-import/shared";
import { oldmarrConfigSchema } from "@homarr/old-schema";
@@ -57,6 +58,37 @@ export const boardRouter = createTRPCRouter({
where: eq(boards.isPublic, true),
});
}),
getBoardsForGroup: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ groupId: z.string() }))
.query(async ({ ctx, input }) => {
const dbEveryoneAndCurrentGroup = await ctx.db.query.groups.findMany({
where: or(eq(groups.name, everyoneGroup), eq(groups.id, input.groupId)),
with: {
boardPermissions: true,
permissions: true,
},
});
const distinctPermissions = new Set(
dbEveryoneAndCurrentGroup.flatMap((group) => group.permissions.map(({ permission }) => permission)),
);
const canViewAllBoards = getPermissionsWithChildren([...distinctPermissions]).includes("board-view-all");
const boardIds = dbEveryoneAndCurrentGroup.flatMap((group) =>
group.boardPermissions.map(({ boardId }) => boardId),
);
const boardWhere = canViewAllBoards ? undefined : or(eq(boards.isPublic, true), inArray(boards.id, boardIds));
return await ctx.db.query.boards.findMany({
columns: {
id: true,
name: true,
logoImageUrl: true,
},
where: boardWhere,
});
}),
getAllBoards: publicProcedure.query(async ({ ctx }) => {
const userId = ctx.session?.user.id;
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
@@ -89,6 +121,7 @@ export const boardRouter = createTRPCRouter({
columns: {
id: true,
name: true,
logoImageUrl: true,
isPublic: true,
},
with: {
@@ -975,9 +1008,13 @@ export const boardRouter = createTRPCRouter({
* For an example of a user with deviceType = 'mobile' it would go through the following order:
* 1. user.mobileHomeBoardId
* 2. user.homeBoardId
* 3. serverSettings.mobileHomeBoardId
* 4. serverSettings.homeBoardId
* 5. show NOT_FOUND error
* 3. group.mobileHomeBoardId of the lowest positions group
* 4. group.homeBoardId of the lowest positions group
* 5. everyoneGroup.mobileHomeBoardId
* 6. everyoneGroup.homeBoardId
* 7. serverSettings.mobileHomeBoardId
* 8. serverSettings.homeBoardId
* 9. show NOT_FOUND error
*/
const getHomeIdBoardAsync = async (
db: Database,
@@ -985,12 +1022,46 @@ const getHomeIdBoardAsync = async (
deviceType: DeviceType,
) => {
const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
if (user?.[settingKey] || user?.homeBoardId) {
return user[settingKey] ?? user.homeBoardId;
} else {
if (!user) {
const boardSettings = await getServerSettingByKeyAsync(db, "board");
return boardSettings[settingKey] ?? boardSettings.homeBoardId;
}
if (user[settingKey]) return user[settingKey];
if (user.homeBoardId) return user.homeBoardId;
const lowestGroupExceptEveryone = await db
.select({
homeBoardId: groups.homeBoardId,
mobileHomeBoardId: groups.mobileHomeBoardId,
})
.from(groups)
.leftJoin(groupMembers, eq(groups.id, groupMembers.groupId))
.where(
and(
eq(groupMembers.userId, user.id),
not(eq(groups.name, everyoneGroup)),
not(isNull(groups[settingKey])),
not(isNull(groups.homeBoardId)),
),
)
.orderBy(asc(groups.position))
.limit(1)
.then((result) => result[0]);
if (lowestGroupExceptEveryone?.[settingKey]) return lowestGroupExceptEveryone[settingKey];
if (lowestGroupExceptEveryone?.homeBoardId) return lowestGroupExceptEveryone.homeBoardId;
const dbEveryoneGroup = await db.query.groups.findFirst({
where: eq(groups.name, everyoneGroup),
});
if (dbEveryoneGroup?.[settingKey]) return dbEveryoneGroup[settingKey];
if (dbEveryoneGroup?.homeBoardId) return dbEveryoneGroup.homeBoardId;
const boardSettings = await getServerSettingByKeyAsync(db, "board");
return boardSettings[settingKey] ?? boardSettings.homeBoardId;
};
const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => {

View File

@@ -2,7 +2,8 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod";
import type { Database } from "@homarr/db";
import { and, createId, eq, like, not, sql } from "@homarr/db";
import { and, createId, eq, handleTransactionsAsync, like, not, sql } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
import { everyoneGroup } from "@homarr/definitions";
import { validation } from "@homarr/validation";
@@ -12,6 +13,30 @@ import { throwIfCredentialsDisabled } from "./invite/checks";
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
export const groupRouter = createTRPCRouter({
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
const dbGroups = await ctx.db.query.groups.findMany({
with: {
members: {
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
},
},
},
},
},
});
return dbGroups.map((group) => ({
...group,
members: group.members.map((member) => member.user),
}));
}),
getPaginated: permissionRequiredProcedure
.requiresPermission("admin")
.input(validation.common.paginated)
@@ -153,10 +178,13 @@ export const groupRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
await checkSimilarNameAndThrowAsync(ctx.db, input.name);
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
const groupId = createId();
await ctx.db.insert(groups).values({
id: groupId,
name: input.name,
position: maxPosition + 1,
});
await ctx.db.insert(groupPermissions).values({
@@ -172,10 +200,13 @@ export const groupRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
await checkSimilarNameAndThrowAsync(ctx.db, input.name);
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
const id = createId();
await ctx.db.insert(groups).values({
id,
name: input.name,
position: maxPosition + 1,
ownerId: ctx.session.user.id,
});
@@ -197,6 +228,43 @@ export const groupRouter = createTRPCRouter({
})
.where(eq(groups.id, input.id));
}),
savePartialSettings: permissionRequiredProcedure
.requiresPermission("admin")
.input(validation.group.savePartialSettings)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
await ctx.db
.update(groups)
.set({
homeBoardId: input.settings.homeBoardId,
mobileHomeBoardId: input.settings.mobileHomeBoardId,
})
.where(eq(groups.id, input.id));
}),
savePositions: permissionRequiredProcedure
.requiresPermission("admin")
.input(validation.group.savePositions)
.mutation(async ({ input, ctx }) => {
const positions = input.positions.map((id, index) => ({ id, position: index + 1 }));
await handleTransactionsAsync(ctx.db, {
handleAsync: async (db, schema) => {
await db.transaction(async (trx) => {
for (const { id, position } of positions) {
await trx.update(schema.groups).set({ position }).where(eq(groups.id, id));
}
});
},
handleSync: (db) => {
db.transaction((trx) => {
for (const { id, position } of positions) {
trx.update(groups).set({ position }).where(eq(groups.id, id)).run();
}
});
},
});
}),
savePermissions: permissionRequiredProcedure
.requiresPermission("admin")
.input(validation.group.savePermissions)

View File

@@ -205,6 +205,7 @@ describe("getAllBoards should return all boards accessable to the current user",
await db.insert(groups).values({
id: groupId,
name: "group1",
position: 1,
});
await db.insert(groupMembers).values({
@@ -1166,6 +1167,7 @@ describe("getBoardPermissions should return board permissions", () => {
await db.insert(groups).values({
id: groupId,
name: "group1",
position: 1,
});
await db.insert(boardGroupPermissions).values({
@@ -1260,6 +1262,7 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
await db.insert(groups).values({
id: groupId,
name: "group1",
position: 1,
});
const boardId = createId();

View File

@@ -43,6 +43,7 @@ describe("paginated should return a list of groups with pagination", () => {
[1, 2, 3, 4, 5].map((number) => ({
id: number.toString(),
name: `Group ${number}`,
position: number,
})),
);
@@ -66,6 +67,7 @@ describe("paginated should return a list of groups with pagination", () => {
[1, 2, 3, 4, 5].map((number) => ({
id: number.toString(),
name: `Group ${number}`,
position: number,
})),
);
@@ -89,6 +91,7 @@ describe("paginated should return a list of groups with pagination", () => {
await db.insert(groups).values({
id: groupId,
name: "Group",
position: 1,
});
await db.insert(groupMembers).values({
groupId,
@@ -123,6 +126,7 @@ describe("paginated should return a list of groups with pagination", () => {
["first", "second", "third", "forth", "fifth"].map((key, index) => ({
id: index.toString(),
name: key,
position: index + 1,
})),
);
@@ -163,10 +167,12 @@ describe("byId should return group by id including members and permissions", ()
{
id: groupId,
name: "Group",
position: 1,
},
{
id: createId(),
name: "Another group",
position: 2,
},
]);
await db.insert(groupMembers).values({
@@ -202,6 +208,7 @@ describe("byId should return group by id including members and permissions", ()
await db.insert(groups).values({
id: "2",
name: "Group",
position: 1,
});
// Act
@@ -278,6 +285,7 @@ describe("create should create group in database", () => {
await db.insert(groups).values({
id: createId(),
name: similarName,
position: 1,
});
// Act
@@ -314,10 +322,12 @@ describe("update should update name with value that is no duplicate", () => {
{
id: groupId,
name: initialValue,
position: 1,
},
{
id: createId(),
name: "Third",
position: 2,
},
]);
@@ -347,10 +357,12 @@ describe("update should update name with value that is no duplicate", () => {
{
id: groupId,
name: "Something",
position: 1,
},
{
id: createId(),
name: initialDuplicate,
position: 2,
},
]);
@@ -373,6 +385,7 @@ describe("update should update name with value that is no duplicate", () => {
await db.insert(groups).values({
id: createId(),
name: "something",
position: 1,
});
// Act
@@ -413,6 +426,7 @@ describe("savePermissions should save permissions for group", () => {
await db.insert(groups).values({
id: groupId,
name: "Group",
position: 1,
});
await db.insert(groupPermissions).values({
groupId,
@@ -442,6 +456,7 @@ describe("savePermissions should save permissions for group", () => {
await db.insert(groups).values({
id: createId(),
name: "Group",
position: 1,
});
// Act
@@ -494,6 +509,7 @@ describe("transferOwnership should transfer ownership of group", () => {
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
position: 1,
});
// Act
@@ -518,6 +534,7 @@ describe("transferOwnership should transfer ownership of group", () => {
await db.insert(groups).values({
id: createId(),
name: "Group",
position: 1,
});
// Act
@@ -559,10 +576,12 @@ describe("deleteGroup should delete group", () => {
{
id: groupId,
name: "Group",
position: 1,
},
{
id: createId(),
name: "Another group",
position: 2,
},
]);
@@ -586,6 +605,7 @@ describe("deleteGroup should delete group", () => {
await db.insert(groups).values({
id: createId(),
name: "Group",
position: 1,
});
// Act
@@ -638,6 +658,7 @@ describe("addMember should add member to group", () => {
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
position: 1,
});
// Act
@@ -715,6 +736,7 @@ describe("addMember should add member to group", () => {
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
position: 1,
});
// Act
@@ -753,6 +775,7 @@ describe("removeMember should remove member from group", () => {
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
position: 1,
});
await db.insert(groupMembers).values({
groupId,
@@ -833,6 +856,7 @@ describe("removeMember should remove member from group", () => {
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
position: 1,
});
await db.insert(groupMembers).values({
groupId,

View File

@@ -4,6 +4,7 @@ import { z } from "zod";
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { and, createId, eq, like } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
import { selectUserSchema } from "@homarr/db/validationSchemas";
import { credentialsAdminGroup } from "@homarr/definitions";
@@ -31,12 +32,14 @@ export const userRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled();
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
const userId = await createUserAsync(ctx.db, input);
const groupId = createId();
await ctx.db.insert(groups).values({
id: groupId,
name: credentialsAdminGroup,
ownerId: userId,
position: maxPosition + 1,
});
await ctx.db.insert(groupPermissions).values({
groupId,