Merge branch 'dev' into ajnart/fix-duplicate-users

This commit is contained in:
Meier Lukas
2024-05-19 23:08:04 +02:00
288 changed files with 11536 additions and 5631 deletions
+1
View File
@@ -27,6 +27,7 @@
"@homarr/redis": "workspace:^0.1.0",
"@homarr/tasks": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@trpc/client": "next",
"@trpc/server": "next",
"superjson": "2.2.1"
+2
View File
@@ -7,6 +7,7 @@ import { integrationRouter } from "./router/integration";
import { inviteRouter } from "./router/invite";
import { locationRouter } from "./router/location";
import { logRouter } from "./router/log";
import { serverSettingsRouter } from "./router/serverSettings";
import { userRouter } from "./router/user";
import { widgetRouter } from "./router/widgets";
import { createTRPCRouter } from "./trpc";
@@ -23,6 +24,7 @@ export const appRouter = createTRPCRouter({
log: logRouter,
icon: iconsRouter,
home: homeRouter,
serverSettings: serverSettingsRouter,
});
// export type definition of API
+41 -49
View File
@@ -22,60 +22,52 @@ export const appRouter = createTRPCRouter({
orderBy: asc(apps.name),
});
}),
byId: publicProcedure
.input(validation.app.byId)
.query(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
byId: publicProcedure.input(validation.app.byId).query(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
});
if (!app) {
throw new TRPCError({
code: "NOT_FOUND",
message: "App not found",
});
}
if (!app) {
throw new TRPCError({
code: "NOT_FOUND",
message: "App not found",
});
}
return app;
}),
create: publicProcedure.input(validation.app.manage).mutation(async ({ ctx, input }) => {
await ctx.db.insert(apps).values({
id: createId(),
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
href: input.href,
});
}),
update: publicProcedure.input(validation.app.edit).mutation(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
});
return app;
}),
create: publicProcedure
.input(validation.app.manage)
.mutation(async ({ ctx, input }) => {
await ctx.db.insert(apps).values({
id: createId(),
if (!app) {
throw new TRPCError({
code: "NOT_FOUND",
message: "App not found",
});
}
await ctx.db
.update(apps)
.set({
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
href: input.href,
});
}),
update: publicProcedure
.input(validation.app.edit)
.mutation(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
});
if (!app) {
throw new TRPCError({
code: "NOT_FOUND",
message: "App not found",
});
}
await ctx.db
.update(apps)
.set({
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
href: input.href,
})
.where(eq(apps.id, input.id));
}),
delete: publicProcedure
.input(validation.app.byId)
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(apps).where(eq(apps.id, input.id));
}),
})
.where(eq(apps.id, input.id));
}),
delete: publicProcedure.input(validation.app.byId).mutation(async ({ ctx, input }) => {
await ctx.db.delete(apps).where(eq(apps.id, input.id));
}),
});
+274 -385
View File
@@ -12,54 +12,46 @@ import {
integrationItems,
items,
sections,
users,
} from "@homarr/db/schema/sqlite";
import type { WidgetKind } from "@homarr/definitions";
import { getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
import {
createSectionSchema,
sharedItemSchema,
validation,
z,
} from "@homarr/validation";
import type { BoardItemAdvancedOptions } from "@homarr/validation";
import { createSectionSchema, sharedItemSchema, validation, z } from "@homarr/validation";
import { zodUnionFromArray } from "../../../validation/src/enums";
import {
createTRPCRouter,
permissionRequiredProcedure,
protectedProcedure,
publicProcedure,
} from "../trpc";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
export const boardRouter = createTRPCRouter({
getAllBoards: publicProcedure.query(async ({ ctx }) => {
const permissionsOfCurrentUserWhenPresent =
await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
});
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, ctx.session?.user.id ?? ""),
with: {
group: {
with: {
boardPermissions: {},
},
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,
),
)
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
.flat(),
);
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
where: eq(users.id, userId ?? ""),
});
const dbBoards = await ctx.db.query.boards.findMany({
columns: {
id: true,
@@ -82,9 +74,7 @@ export const boardRouter = createTRPCRouter({
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
? inArray(
boardGroupPermissions.groupId,
permissionsOfCurrentUserGroupsWhenPresent.map(
(groupMember) => groupMember.groupId,
),
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
)
: undefined,
},
@@ -98,7 +88,10 @@ export const boardRouter = createTRPCRouter({
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
),
});
return dbBoards;
return dbBoards.map((board) => ({
...board,
isHome: currentUserWhenPresent?.homeBoardId === board.id,
}));
}),
createBoard: permissionRequiredProcedure
.requiresPermission("board-create")
@@ -119,77 +112,56 @@ export const boardRouter = createTRPCRouter({
});
});
}),
renameBoard: protectedProcedure
.input(validation.board.rename)
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(
ctx,
eq(boards.id, input.id),
"full-access",
);
renameBoard: protectedProcedure.input(validation.board.rename).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
await noBoardWithSimilarNameAsync(ctx.db, input.name, [input.id]);
await noBoardWithSimilarNameAsync(ctx.db, input.name, [input.id]);
await ctx.db
.update(boards)
.set({ name: input.name })
.where(eq(boards.id, input.id));
}),
await ctx.db.update(boards).set({ name: input.name }).where(eq(boards.id, input.id));
}),
changeBoardVisibility: protectedProcedure
.input(validation.board.changeVisibility)
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(
ctx,
eq(boards.id, input.id),
"full-access",
);
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
await ctx.db
.update(boards)
.set({ isPublic: input.visibility === "public" })
.where(eq(boards.id, input.id));
}),
deleteBoard: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(
ctx,
eq(boards.id, input.id),
"full-access",
);
deleteBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
await ctx.db.delete(boards).where(eq(boards.id, input.id));
}),
getDefaultBoard: publicProcedure.query(async ({ ctx }) => {
const boardWhere = eq(boards.name, "default");
await ctx.db.delete(boards).where(eq(boards.id, input.id));
}),
setHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "board-view");
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
getHomeBoard: publicProcedure.query(async ({ ctx }) => {
const userId = ctx.session?.user.id;
const user = userId
? await ctx.db.query.users.findFirst({
where: eq(users.id, userId),
})
: null;
const boardWhere = user?.homeBoardId ? eq(boards.id, user.homeBoardId) : eq(boards.name, "home");
await throwIfActionForbiddenAsync(ctx, boardWhere, "board-view");
return await getFullBoardWithWhereAsync(
ctx.db,
boardWhere,
ctx.session?.user.id ?? null,
);
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
}),
getBoardByName: publicProcedure
.input(validation.board.byName)
.query(async ({ input, ctx }) => {
const boardWhere = eq(boards.name, input.name);
await throwIfActionForbiddenAsync(ctx, boardWhere, "board-view");
getBoardByName: publicProcedure.input(validation.board.byName).query(async ({ input, ctx }) => {
const boardWhere = eq(boards.name, input.name);
await throwIfActionForbiddenAsync(ctx, boardWhere, "board-view");
return await getFullBoardWithWhereAsync(
ctx.db,
boardWhere,
ctx.session?.user.id ?? null,
);
}),
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
}),
savePartialBoardSettings: protectedProcedure
.input(validation.board.savePartialSettings)
.input(validation.board.savePartialSettings.and(z.object({ id: z.string() })))
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(
ctx,
eq(boards.id, input.id),
"board-change",
);
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "board-change");
await ctx.db
.update(boards)
@@ -219,271 +191,223 @@ export const boardRouter = createTRPCRouter({
})
.where(eq(boards.id, input.id));
}),
saveBoard: protectedProcedure
.input(validation.board.save)
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(
ctx,
eq(boards.id, input.id),
"board-change",
saveBoard: protectedProcedure.input(validation.board.save).mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "board-change");
await ctx.db.transaction(async (transaction) => {
const dbBoard = await getFullBoardWithWhereAsync(transaction, eq(boards.id, input.id), ctx.session.user.id);
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
if (addedSections.length > 0) {
await transaction.insert(sections).values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
position: section.position,
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
})),
);
}
const inputItems = input.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })),
);
const dbItems = dbBoard.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })),
);
await ctx.db.transaction(async (transaction) => {
const dbBoard = await getFullBoardWithWhereAsync(
transaction,
eq(boards.id, input.id),
ctx.session.user.id,
const addedItems = filterAddedItems(inputItems, dbItems);
if (addedItems.length > 0) {
await transaction.insert(items).values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})),
);
}
const addedSections = filterAddedItems(
input.sections,
dbBoard.sections,
);
if (addedSections.length > 0) {
await transaction.insert(sections).values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
position: section.position,
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
})),
);
}
const inputItems = input.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })),
);
const dbItems = dbBoard.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })),
);
const addedItems = filterAddedItems(inputItems, dbItems);
if (addedItems.length > 0) {
await transaction.insert(items).values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
sectionId: item.sectionId,
})),
);
}
const inputIntegrationRelations = inputItems.flatMap(
({ integrations, id: itemId }) =>
integrations.map((integration) => ({
integrationId: integration.id,
itemId,
})),
);
const dbIntegrationRelations = dbItems.flatMap(
({ integrations, id: itemId }) =>
integrations.map((integration) => ({
integrationId: integration.id,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
if (addedIntegrationRelations.length > 0) {
await transaction.insert(integrationItems).values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
);
}
const updatedItems = filterUpdatedItems(inputItems, dbItems);
for (const item of updatedItems) {
await transaction
.update(items)
.set({
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
sectionId: item.sectionId,
})
.where(eq(items.id, item.id));
}
const updatedSections = filterUpdatedItems(
input.sections,
dbBoard.sections,
);
for (const section of updatedSections) {
const prev = dbBoard.sections.find(
(dbSection) => dbSection.id === section.id,
);
await transaction
.update(sections)
.set({
position: section.position,
name:
prev?.kind === "category" && "name" in section
? section.name
: null,
})
.where(eq(sections.id, section.id));
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
await transaction
.delete(integrationItems)
.where(
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
),
);
}
const removedItems = filterRemovedItems(inputItems, dbItems);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
await transaction.delete(items).where(inArray(items.id, itemIds));
}
const removedSections = filterRemovedItems(
input.sections,
dbBoard.sections,
);
const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) {
await transaction
.delete(sections)
.where(inArray(sections.id, sectionIds));
}
});
}),
getBoardPermissions: protectedProcedure
.input(validation.board.permissions)
.query(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(
ctx,
eq(boards.id, input.id),
"full-access",
const inputIntegrationRelations = inputItems.flatMap(({ integrations, id: itemId }) =>
integrations.map((integration) => ({
integrationId: integration.id,
itemId,
})),
);
const dbIntegrationRelations = dbItems.flatMap(({ integrations, id: itemId }) =>
integrations.map((integration) => ({
integrationId: integration.id,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId && dbRelation.integrationId === inputRelation.integrationId,
),
);
const dbGroupPermissions = await ctx.db.query.groupPermissions.findMany({
where: inArray(
groupPermissions.permission,
getPermissionsWithParents([
"board-view-all",
"board-modify-all",
"board-full-access",
]),
),
columns: {
groupId: false,
if (addedIntegrationRelations.length > 0) {
await transaction.insert(integrationItems).values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
);
}
const updatedItems = filterUpdatedItems(inputItems, dbItems);
for (const item of updatedItems) {
await transaction
.update(items)
.set({
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
sectionId: item.sectionId,
})
.where(eq(items.id, item.id));
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
await transaction
.update(sections)
.set({
position: section.position,
name: prev?.kind === "category" && "name" in section ? section.name : null,
})
.where(eq(sections.id, section.id));
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId && dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
await transaction
.delete(integrationItems)
.where(
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
),
);
}
const removedItems = filterRemovedItems(inputItems, dbItems);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
await transaction.delete(items).where(inArray(items.id, itemIds));
}
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) {
await transaction.delete(sections).where(inArray(sections.id, sectionIds));
}
});
}),
getBoardPermissions: protectedProcedure.input(validation.board.permissions).query(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
const dbGroupPermissions = await ctx.db.query.groupPermissions.findMany({
where: inArray(
groupPermissions.permission,
getPermissionsWithParents(["board-view-all", "board-modify-all", "board-full-access"]),
),
columns: {
groupId: false,
},
with: {
group: {
columns: {
id: true,
name: true,
},
},
with: {
},
});
const userPermissions = await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.boardId, input.id),
with: {
user: {
columns: {
id: true,
name: true,
image: true,
},
},
},
});
const dbGroupBoardPermission = await ctx.db.query.boardGroupPermissions.findMany({
where: eq(boardGroupPermissions.boardId, input.id),
with: {
group: {
columns: {
id: true,
name: true,
},
},
},
});
return {
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
userPermissions: userPermissions
.map(({ user, permission }) => ({
user,
permission,
}))
.sort((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
}),
groupPermissions: dbGroupBoardPermission
.map(({ group, permission }) => ({
group: {
columns: {
id: true,
name: true,
},
id: group.id,
name: group.name,
},
},
});
const userPermissions = await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.boardId, input.id),
with: {
user: {
columns: {
id: true,
name: true,
image: true,
},
},
},
});
const dbGroupBoardPermission =
await ctx.db.query.boardGroupPermissions.findMany({
where: eq(boardGroupPermissions.boardId, input.id),
with: {
group: {
columns: {
id: true,
name: true,
},
},
},
});
return {
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
permission,
}))
.sort((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
userPermissions: userPermissions
.map(({ user, permission }) => ({
user,
permission,
}))
.sort((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(
permissionB.user.name ?? "",
);
}),
groupPermissions: dbGroupBoardPermission
.map(({ group, permission }) => ({
group: {
id: group.id,
name: group.name,
},
permission,
}))
.sort((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
};
}),
};
}),
saveUserBoardPermissions: protectedProcedure
.input(validation.board.savePermissions)
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(
ctx,
eq(boards.id, input.id),
"full-access",
);
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
await ctx.db.transaction(async (transaction) => {
await transaction
.delete(boardUserPermissions)
.where(eq(boardUserPermissions.boardId, input.id));
await transaction.delete(boardUserPermissions).where(eq(boardUserPermissions.boardId, input.id));
if (input.permissions.length === 0) {
return;
}
@@ -499,16 +423,10 @@ export const boardRouter = createTRPCRouter({
saveGroupBoardPermissions: protectedProcedure
.input(validation.board.savePermissions)
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(
ctx,
eq(boards.id, input.id),
"full-access",
);
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
await ctx.db.transaction(async (transaction) => {
await transaction
.delete(boardGroupPermissions)
.where(eq(boardGroupPermissions.boardId, input.id));
await transaction.delete(boardGroupPermissions).where(eq(boardGroupPermissions.boardId, input.id));
if (input.permissions.length === 0) {
return;
}
@@ -523,11 +441,7 @@ export const boardRouter = createTRPCRouter({
}),
});
const noBoardWithSimilarNameAsync = async (
db: Database,
name: string,
ignoredIds: string[] = [],
) => {
const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => {
const boards = await db.query.boards.findMany({
columns: {
id: true,
@@ -536,9 +450,7 @@ const noBoardWithSimilarNameAsync = async (
});
const board = boards.find(
(board) =>
board.name.toLowerCase() === name.toLowerCase() &&
!ignoredIds.includes(board.id),
(board) => board.name.toLowerCase() === name.toLowerCase() && !ignoredIds.includes(board.id),
);
if (board) {
@@ -549,11 +461,7 @@ const noBoardWithSimilarNameAsync = async (
}
};
const getFullBoardWithWhereAsync = async (
db: Database,
where: SQL<unknown>,
userId: string | null,
) => {
const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, userId: string | null) => {
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),
});
@@ -587,10 +495,7 @@ const getFullBoardWithWhereAsync = async (
},
},
groupPermissions: {
where: inArray(
boardGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId).concat(""),
),
where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
},
},
});
@@ -612,6 +517,7 @@ const getFullBoardWithWhereAsync = async (
items: section.items.map((item) => ({
...item,
integrations: item.integrations.map((item) => item.integration),
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
options: superjson.parse<Record<string, unknown>>(item.options),
})),
}),
@@ -625,9 +531,7 @@ const forKind = <T extends WidgetKind>(kind: T) =>
options: z.record(z.unknown()),
});
const outputItemSchema = zodUnionFromArray(
widgetKinds.map((kind) => forKind(kind)),
).and(sharedItemSchema);
const outputItemSchema = zodUnionFromArray(widgetKinds.map((kind) => forKind(kind))).and(sharedItemSchema);
const parseSection = (section: unknown) => {
const result = createSectionSchema(outputItemSchema).safeParse(section);
@@ -637,26 +541,11 @@ const parseSection = (section: unknown) => {
return result.data;
};
const filterAddedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
inputArray.filter(
(inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id),
);
const filterAddedItems = <TInput extends { id: string }>(inputArray: TInput[], dbArray: TInput[]) =>
inputArray.filter((inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id));
const filterRemovedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
dbArray.filter(
(dbItem) => !inputArray.some((inputItem) => dbItem.id === inputItem.id),
);
const filterRemovedItems = <TInput extends { id: string }>(inputArray: TInput[], dbArray: TInput[]) =>
dbArray.filter((dbItem) => !inputArray.some((inputItem) => dbItem.id === inputItem.id));
const filterUpdatedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
inputArray.filter((inputItem) =>
dbArray.some((dbItem) => dbItem.id === inputItem.id),
);
const filterUpdatedItems = <TInput extends { id: string }>(inputArray: TInput[], dbArray: TInput[]) =>
inputArray.filter((inputItem) => dbArray.some((dbItem) => dbItem.id === inputItem.id));
+3 -11
View File
@@ -4,11 +4,7 @@ import type { Session } from "@homarr/auth";
import { constructBoardPermissions } from "@homarr/auth/shared";
import type { Database, SQL } from "@homarr/db";
import { eq, inArray } from "@homarr/db";
import {
boardGroupPermissions,
boardUserPermissions,
groupMembers,
} from "@homarr/db/schema/sqlite";
import { boardGroupPermissions, boardUserPermissions, groupMembers } from "@homarr/db/schema/sqlite";
import type { BoardPermission } from "@homarr/definitions";
/**
@@ -38,10 +34,7 @@ export const throwIfActionForbiddenAsync = async (
where: eq(boardUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
boardGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId).concat(""),
),
where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
},
},
});
@@ -50,8 +43,7 @@ export const throwIfActionForbiddenAsync = async (
notAllowed();
}
const { hasViewAccess, hasChangeAccess, hasFullAccess } =
constructBoardPermissions(board, session);
const { hasViewAccess, hasChangeAccess, hasFullAccess } = constructBoardPermissions(board, session);
if (hasFullAccess) {
return; // As full access is required and user has full access, allow
+142 -182
View File
@@ -2,98 +2,86 @@ import { TRPCError } from "@trpc/server";
import type { Database } from "@homarr/db";
import { and, createId, eq, like, not, sql } from "@homarr/db";
import {
groupMembers,
groupPermissions,
groups,
} from "@homarr/db/schema/sqlite";
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema/sqlite";
import { validation } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const groupRouter = createTRPCRouter({
getPaginated: protectedProcedure
.input(validation.group.paginated)
.query(async ({ input, ctx }) => {
const whereQuery = input.search
? like(groups.name, `%${input.search.trim()}%`)
: undefined;
const groupCount = await ctx.db
.select({
count: sql<number>`count(*)`,
})
.from(groups)
.where(whereQuery);
getPaginated: protectedProcedure.input(validation.group.paginated).query(async ({ input, ctx }) => {
const whereQuery = input.search ? like(groups.name, `%${input.search.trim()}%`) : undefined;
const groupCount = await ctx.db
.select({
count: sql<number>`count(*)`,
})
.from(groups)
.where(whereQuery);
const dbGroups = await ctx.db.query.groups.findMany({
with: {
members: {
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
},
const dbGroups = await ctx.db.query.groups.findMany({
with: {
members: {
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
},
},
},
},
limit: input.pageSize,
offset: (input.page - 1) * input.pageSize,
where: whereQuery,
});
},
limit: input.pageSize,
offset: (input.page - 1) * input.pageSize,
where: whereQuery,
});
return {
items: dbGroups.map((group) => ({
...group,
members: group.members.map((member) => member.user),
})),
totalCount: groupCount[0]!.count,
};
}),
getById: protectedProcedure
.input(validation.group.byId)
.query(async ({ input, ctx }) => {
const group = await ctx.db.query.groups.findFirst({
where: eq(groups.id, input.id),
with: {
members: {
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
},
},
},
},
permissions: {
columns: {
permission: true,
},
},
},
});
if (!group) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Group not found",
});
}
return {
return {
items: dbGroups.map((group) => ({
...group,
members: group.members.map((member) => member.user),
permissions: group.permissions.map(
(permission) => permission.permission,
),
};
}),
})),
totalCount: groupCount[0]!.count,
};
}),
getById: protectedProcedure.input(validation.group.byId).query(async ({ input, ctx }) => {
const group = await ctx.db.query.groups.findFirst({
where: eq(groups.id, input.id),
with: {
members: {
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
},
},
},
},
permissions: {
columns: {
permission: true,
},
},
},
});
if (!group) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Group not found",
});
}
return {
...group,
members: group.members.map((member) => member.user),
permissions: group.permissions.map((permission) => permission.permission),
};
}),
selectable: protectedProcedure.query(async ({ ctx }) => {
return await ctx.db.query.groups.findMany({
columns: {
@@ -102,120 +90,92 @@ export const groupRouter = createTRPCRouter({
},
});
}),
createGroup: protectedProcedure
.input(validation.group.create)
.mutation(async ({ input, ctx }) => {
const normalizedName = normalizeName(input.name);
await checkSimilarNameAndThrowAsync(ctx.db, normalizedName);
createGroup: protectedProcedure.input(validation.group.create).mutation(async ({ input, ctx }) => {
const normalizedName = normalizeName(input.name);
await checkSimilarNameAndThrowAsync(ctx.db, normalizedName);
const id = createId();
await ctx.db.insert(groups).values({
id,
const id = createId();
await ctx.db.insert(groups).values({
id,
name: normalizedName,
ownerId: ctx.session.user.id,
});
return id;
}),
updateGroup: protectedProcedure.input(validation.group.update).mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
const normalizedName = normalizeName(input.name);
await checkSimilarNameAndThrowAsync(ctx.db, normalizedName, input.id);
await ctx.db
.update(groups)
.set({
name: normalizedName,
ownerId: ctx.session.user.id,
});
})
.where(eq(groups.id, input.id));
}),
savePermissions: protectedProcedure.input(validation.group.savePermissions).mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
return id;
}),
updateGroup: protectedProcedure
.input(validation.group.update)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
await ctx.db.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId));
const normalizedName = normalizeName(input.name);
await checkSimilarNameAndThrowAsync(ctx.db, normalizedName, input.id);
await ctx.db
.update(groups)
.set({
name: normalizedName,
})
.where(eq(groups.id, input.id));
}),
savePermissions: protectedProcedure
.input(validation.group.savePermissions)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await ctx.db
.delete(groupPermissions)
.where(eq(groupPermissions.groupId, input.groupId));
await ctx.db.insert(groupPermissions).values(
input.permissions.map((permission) => ({
groupId: input.groupId,
permission,
})),
);
}),
transferOwnership: protectedProcedure
.input(validation.group.groupUser)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await ctx.db
.update(groups)
.set({
ownerId: input.userId,
})
.where(eq(groups.id, input.groupId));
}),
deleteGroup: protectedProcedure
.input(validation.group.byId)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
await ctx.db.delete(groups).where(eq(groups.id, input.id));
}),
addMember: protectedProcedure
.input(validation.group.groupUser)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
const user = await ctx.db.query.users.findFirst({
where: eq(groups.id, input.userId),
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db.insert(groupMembers).values({
await ctx.db.insert(groupPermissions).values(
input.permissions.map((permission) => ({
groupId: input.groupId,
userId: input.userId,
});
}),
removeMember: protectedProcedure
.input(validation.group.groupUser)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
permission,
})),
);
}),
transferOwnership: protectedProcedure.input(validation.group.groupUser).mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await ctx.db
.delete(groupMembers)
.where(
and(
eq(groupMembers.groupId, input.groupId),
eq(groupMembers.userId, input.userId),
),
);
}),
await ctx.db
.update(groups)
.set({
ownerId: input.userId,
})
.where(eq(groups.id, input.groupId));
}),
deleteGroup: protectedProcedure.input(validation.group.byId).mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
await ctx.db.delete(groups).where(eq(groups.id, input.id));
}),
addMember: protectedProcedure.input(validation.group.groupUser).mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
const user = await ctx.db.query.users.findFirst({
where: eq(groups.id, input.userId),
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db.insert(groupMembers).values({
groupId: input.groupId,
userId: input.userId,
});
}),
removeMember: protectedProcedure.input(validation.group.groupUser).mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await ctx.db
.delete(groupMembers)
.where(and(eq(groupMembers.groupId, input.groupId), eq(groupMembers.userId, input.userId)));
}),
});
const normalizeName = (name: string) => name.trim();
const checkSimilarNameAndThrowAsync = async (
db: Database,
name: string,
ignoreId?: string,
) => {
const checkSimilarNameAndThrowAsync = async (db: Database, name: string, ignoreId?: string) => {
const similar = await db.query.groups.findFirst({
where: and(
like(groups.name, `${name}`),
not(eq(groups.id, ignoreId ?? "")),
),
where: and(like(groups.name, `${name}`), not(eq(groups.id, ignoreId ?? ""))),
});
if (similar) {
+7 -21
View File
@@ -1,31 +1,17 @@
import { count } from "@homarr/db";
import {
apps,
boards,
groups,
integrations,
invites,
users,
} from "@homarr/db/schema/sqlite";
import { apps, boards, groups, integrations, invites, users } from "@homarr/db/schema/sqlite";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const homeRouter = createTRPCRouter({
getStats: protectedProcedure.query(async ({ ctx }) => {
return {
countBoards:
(await ctx.db.select({ count: count() }).from(boards))[0]?.count ?? 0,
countUsers:
(await ctx.db.select({ count: count() }).from(users))[0]?.count ?? 0,
countGroups:
(await ctx.db.select({ count: count() }).from(groups))[0]?.count ?? 0,
countInvites:
(await ctx.db.select({ count: count() }).from(invites))[0]?.count ?? 0,
countIntegrations:
(await ctx.db.select({ count: count() }).from(integrations))[0]
?.count ?? 0,
countApps:
(await ctx.db.select({ count: count() }).from(apps))[0]?.count ?? 0,
countBoards: (await ctx.db.select({ count: count() }).from(boards))[0]?.count ?? 0,
countUsers: (await ctx.db.select({ count: count() }).from(users))[0]?.count ?? 0,
countGroups: (await ctx.db.select({ count: count() }).from(groups))[0]?.count ?? 0,
countInvites: (await ctx.db.select({ count: count() }).from(invites))[0]?.count ?? 0,
countIntegrations: (await ctx.db.select({ count: count() }).from(integrations))[0]?.count ?? 0,
countApps: (await ctx.db.select({ count: count() }).from(apps))[0]?.count ?? 0,
};
}),
});
+16 -22
View File
@@ -5,28 +5,22 @@ import { validation } from "@homarr/validation";
import { createTRPCRouter, publicProcedure } from "../trpc";
export const iconsRouter = createTRPCRouter({
findIcons: publicProcedure
.input(validation.icons.findIcons)
.query(async ({ ctx, input }) => {
return {
icons: await ctx.db.query.iconRepositories.findMany({
with: {
icons: {
columns: {
id: true,
name: true,
url: true,
},
where:
input.searchText?.length ?? 0 > 0
? like(icons.name, `%${input.searchText}%`)
: undefined,
limit: 5,
findIcons: publicProcedure.input(validation.icons.findIcons).query(async ({ ctx, input }) => {
return {
icons: await ctx.db.query.iconRepositories.findMany({
with: {
icons: {
columns: {
id: true,
name: true,
url: true,
},
where: input.searchText?.length ?? 0 > 0 ? like(icons.name, `%${input.searchText}%`) : undefined,
limit: 5,
},
}),
countIcons:
(await ctx.db.select({ count: count() }).from(icons))[0]?.count ?? 0,
};
}),
},
}),
countIcons: (await ctx.db.select({ count: count() }).from(icons))[0]?.count ?? 0,
};
}),
});
+160 -206
View File
@@ -5,11 +5,7 @@ import type { Database } from "@homarr/db";
import { and, createId, eq } from "@homarr/db";
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
import type { IntegrationSecretKind } from "@homarr/definitions";
import {
getAllSecretKindOptions,
integrationKinds,
integrationSecretKindObject,
} from "@homarr/definitions";
import { getAllSecretKindOptions, integrationKinds, integrationSecretKindObject } from "@homarr/definitions";
import { validation } from "@homarr/validation";
import { createTRPCRouter, publicProcedure } from "../trpc";
@@ -26,231 +22,198 @@ export const integrationRouter = createTRPCRouter({
}))
.sort(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) -
integrationKinds.indexOf(integrationB.kind),
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
byId: publicProcedure
.input(validation.integration.byId)
.query(async ({ ctx, input }) => {
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
with: {
secrets: {
columns: {
kind: true,
value: true,
updatedAt: true,
},
byId: publicProcedure.input(validation.integration.byId).query(async ({ ctx, input }) => {
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
with: {
secrets: {
columns: {
kind: true,
value: true,
updatedAt: true,
},
},
},
});
if (!integration) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
});
}
if (!integration) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
});
}
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
secrets: integration.secrets.map((secret) => ({
kind: secret.kind,
// Only return the value if the secret is public, so for example the username
value: integrationSecretKindObject[secret.kind].isPublic ? decryptSecret(secret.value) : null,
updatedAt: secret.updatedAt,
})),
};
}),
create: publicProcedure.input(validation.integration.create).mutation(async ({ ctx, input }) => {
const integrationId = createId();
await ctx.db.insert(integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
});
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
secrets: integration.secrets.map((secret) => ({
kind: secret.kind,
// Only return the value if the secret is public, so for example the username
value: integrationSecretKindObject[secret.kind].isPublic
? decryptSecret(secret.value)
: null,
updatedAt: secret.updatedAt,
})),
};
}),
create: publicProcedure
.input(validation.integration.create)
.mutation(async ({ ctx, input }) => {
const integrationId = createId();
await ctx.db.insert(integrations).values({
id: integrationId,
for (const secret of input.secrets) {
await ctx.db.insert(integrationSecrets).values({
kind: secret.kind,
value: encryptSecret(secret.value),
updatedAt: new Date(),
integrationId,
});
}
}),
update: publicProcedure.input(validation.integration.update).mutation(async ({ ctx, input }) => {
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
with: {
secrets: true,
},
});
if (!integration) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
});
}
await ctx.db
.update(integrations)
.set({
name: input.name,
url: input.url,
kind: input.kind,
});
})
.where(eq(integrations.id, input.id));
for (const secret of input.secrets) {
await ctx.db.insert(integrationSecrets).values({
kind: secret.kind,
value: encryptSecret(secret.value),
updatedAt: new Date(),
integrationId,
});
const decryptedSecrets = integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
}));
const changedSecrets = input.secrets.filter(
(secret): secret is { kind: IntegrationSecretKind; value: string } =>
secret.value !== null && // only update secrets that have a value
!decryptedSecrets.find((dSecret) => dSecret.kind === secret.kind && dSecret.value === secret.value),
);
if (changedSecrets.length > 0) {
for (const changedSecret of changedSecrets) {
const secretInput = {
integrationId: input.id,
value: changedSecret.value,
kind: changedSecret.kind,
};
if (!decryptedSecrets.some((secret) => secret.kind === changedSecret.kind)) {
await addSecretAsync(ctx.db, secretInput);
} else {
await updateSecretAsync(ctx.db, secretInput);
}
}
}),
update: publicProcedure
.input(validation.integration.update)
.mutation(async ({ ctx, input }) => {
}
}),
delete: publicProcedure.input(validation.integration.delete).mutation(async ({ ctx, input }) => {
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
});
if (!integration) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
});
}
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
}),
testConnection: publicProcedure.input(validation.integration.testConnection).mutation(async ({ ctx, input }) => {
const secrets = input.secrets.filter((secret): secret is { kind: IntegrationSecretKind; value: string } =>
Boolean(secret.value),
);
// Find any matching secret kinds
let secretKinds = getAllSecretKindOptions(input.kind).find((secretKinds) =>
secretKinds.every((secretKind) => secrets.some((secret) => secret.kind === secretKind)),
);
if (!secretKinds && input.id === null) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "SECRETS_NOT_DEFINED",
});
}
if (!secretKinds && input.id !== null) {
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
with: {
secrets: true,
},
});
if (!integration) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
});
}
await ctx.db
.update(integrations)
.set({
name: input.name,
url: input.url,
})
.where(eq(integrations.id, input.id));
const decryptedSecrets = integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
}));
const changedSecrets = input.secrets.filter(
(secret): secret is { kind: IntegrationSecretKind; value: string } =>
secret.value !== null && // only update secrets that have a value
!decryptedSecrets.find(
(dSecret) =>
dSecret.kind === secret.kind && dSecret.value === secret.value,
),
);
if (changedSecrets.length > 0) {
for (const changedSecret of changedSecrets) {
const secretInput = {
integrationId: input.id,
value: changedSecret.value,
kind: changedSecret.kind,
};
if (
!decryptedSecrets.some(
(secret) => secret.kind === changedSecret.kind,
)
) {
await addSecretAsync(ctx.db, secretInput);
} else {
await updateSecretAsync(ctx.db, secretInput);
}
}
}
}),
delete: publicProcedure
.input(validation.integration.delete)
.mutation(async ({ ctx, input }) => {
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
});
if (!integration) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
});
}
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
}),
testConnection: publicProcedure
.input(validation.integration.testConnection)
.mutation(async ({ ctx, input }) => {
const secrets = input.secrets.filter(
(secret): secret is { kind: IntegrationSecretKind; value: string } =>
Boolean(secret.value),
);
// Find any matching secret kinds
let secretKinds = getAllSecretKindOptions(input.kind).find(
(secretKinds) =>
secretKinds.every((secretKind) =>
secrets.some((secret) => secret.kind === secretKind),
),
);
if (!secretKinds && input.id === null) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "SECRETS_NOT_DEFINED",
});
}
const decryptedSecrets = integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
}));
if (!secretKinds && input.id !== null) {
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
with: {
secrets: true,
},
});
if (!integration) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "SECRETS_NOT_DEFINED",
});
}
const decryptedSecrets = integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
}));
// Add secrets that are not defined in the input from the database
for (const dbSecret of decryptedSecrets) {
if (!secrets.find((secret) => secret.kind === dbSecret.kind)) {
secrets.push({
kind: dbSecret.kind,
value: dbSecret.value,
});
}
}
secretKinds = getAllSecretKindOptions(input.kind).find((secretKinds) =>
secretKinds.every((secretKind) =>
secrets.some((secret) => secret.kind === secretKind),
),
);
if (!secretKinds) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "SECRETS_NOT_DEFINED",
// Add secrets that are not defined in the input from the database
for (const dbSecret of decryptedSecrets) {
if (!secrets.find((secret) => secret.kind === dbSecret.kind)) {
secrets.push({
kind: dbSecret.kind,
value: dbSecret.value,
});
}
}
// TODO: actually test the connection
// Probably by calling a function on the integration class
// getIntegration(input.kind).testConnection(secrets)
// getIntegration(kind: IntegrationKind): Integration
// interface Integration {
// testConnection(): Promise<void>;
// }
}),
secretKinds = getAllSecretKindOptions(input.kind).find((secretKinds) =>
secretKinds.every((secretKind) => secrets.some((secret) => secret.kind === secretKind)),
);
if (!secretKinds) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "SECRETS_NOT_DEFINED",
});
}
}
// TODO: actually test the connection
// Probably by calling a function on the integration class
// getIntegration(input.kind).testConnection(secrets)
// getIntegration(kind: IntegrationKind): Integration
// interface Integration {
// testConnection(): Promise<void>;
// }
}),
});
const algorithm = "aes-256-cbc"; //Using AES encryption
const key = Buffer.from(
"1d71cceced68159ba59a277d056a66173613052cbeeccbfbd15ab1c909455a4d",
"hex",
); // TODO: generate with const data = crypto.randomBytes(32).toString('hex')
const key = Buffer.from("1d71cceced68159ba59a277d056a66173613052cbeeccbfbd15ab1c909455a4d", "hex"); // TODO: generate with const data = crypto.randomBytes(32).toString('hex')
//Encrypting text
export function encryptSecret(text: string): `${string}.${string}` {
const initializationVector = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(
algorithm,
Buffer.from(key),
initializationVector,
);
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), initializationVector);
let encrypted = cipher.update(text);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return `${encrypted.toString("hex")}.${initializationVector.toString("hex")}`;
@@ -261,11 +224,7 @@ function decryptSecret(value: `${string}.${string}`) {
const [data, dataIv] = value.split(".") as [string, string];
const initializationVector = Buffer.from(dataIv, "hex");
const encryptedText = Buffer.from(data, "hex");
const decipher = crypto.createDecipheriv(
algorithm,
Buffer.from(key),
initializationVector,
);
const decipher = crypto.createDecipheriv(algorithm, Buffer.from(key), initializationVector);
let decrypted = decipher.update(encryptedText);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
@@ -283,12 +242,7 @@ const updateSecretAsync = async (db: Database, input: UpdateSecretInput) => {
value: encryptSecret(input.value),
updatedAt: new Date(),
})
.where(
and(
eq(integrationSecrets.integrationId, input.integrationId),
eq(integrationSecrets.kind, input.kind),
),
);
.where(and(eq(integrationSecrets.integrationId, input.integrationId), eq(integrationSecrets.kind, input.kind)));
};
interface AddSecretInput {
+2 -6
View File
@@ -8,11 +8,7 @@ export const locationRouter = createTRPCRouter({
.input(validation.location.searchCity.input)
.output(validation.location.searchCity.output)
.query(async ({ input }) => {
const res = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`,
);
return (await res.json()) as z.infer<
typeof validation.location.searchCity.output
>;
const res = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`);
return (await res.json()) as z.infer<typeof validation.location.searchCity.output>;
}),
});
+41
View File
@@ -0,0 +1,41 @@
import SuperJSON from "superjson";
import { eq } from "@homarr/db";
import { serverSettings } from "@homarr/db/schema/sqlite";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettingsKeys } from "@homarr/server-settings";
import { z } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const serverSettingsRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => {
const settings = await ctx.db.query.serverSettings.findMany();
const data = {} as ServerSettings;
defaultServerSettingsKeys.forEach((key) => {
const settingValue = settings.find((setting) => setting.settingKey === key)?.value;
if (!settingValue) {
return;
}
data[key] = SuperJSON.parse(settingValue);
});
return data;
}),
saveSettings: protectedProcedure
.input(
z.object({
settingsKey: z.enum(defaultServerSettingsKeys),
value: z.record(z.string(), z.unknown()),
}),
)
.mutation(async ({ ctx, input }) => {
const databaseRunResult = await ctx.db
.update(serverSettings)
.set({
value: SuperJSON.stringify(input.value),
})
.where(eq(serverSettings.settingKey, input.settingsKey));
return databaseRunResult.changes === 1;
}),
});
+111 -220
View File
@@ -41,6 +41,7 @@ const createRandomUserAsync = async (db: Database) => {
const userId = createId();
await db.insert(users).values({
id: userId,
homeBoardId: null,
});
return userId;
};
@@ -154,10 +155,7 @@ describe("getAllBoards should return all boards accessable to the current user",
// Assert
expect(result.length).toBe(2);
expect(result.map(({ name }) => name)).toStrictEqual([
"public",
"private2",
]);
expect(result.map(({ name }) => name)).toStrictEqual(["public", "private2"]);
});
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
@@ -220,10 +218,7 @@ describe("getAllBoards should return all boards accessable to the current user",
// Assert
expect(result.length).toBe(2);
expect(result.map(({ name }) => name)).toStrictEqual([
"public",
"private1",
]);
expect(result.map(({ name }) => name)).toStrictEqual(["public", "private1"]);
},
);
@@ -276,10 +271,7 @@ describe("getAllBoards should return all boards accessable to the current user",
// Assert
expect(result.length).toBe(2);
expect(result.map(({ name }) => name)).toStrictEqual([
"public",
"private1",
]);
expect(result.map(({ name }) => name)).toStrictEqual(["public", "private1"]);
},
);
});
@@ -355,11 +347,7 @@ describe("rename board should rename board", () => {
});
expect(dbBoard).toBeDefined();
expect(dbBoard?.name).toBe("newName");
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"full-access",
);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
});
test("should throw error when similar board name exists", async () => {
@@ -383,13 +371,10 @@ describe("rename board should rename board", () => {
});
// Act
const actAsync = async () =>
await caller.renameBoard({ id: boardId, name: "Newname" });
const actAsync = async () => await caller.renameBoard({ id: boardId, name: "Newname" });
// Assert
await expect(actAsync()).rejects.toThrowError(
"Board with similar name already exists",
);
await expect(actAsync()).rejects.toThrowError("Board with similar name already exists");
});
test("should throw error when board not found", async () => {
@@ -398,8 +383,7 @@ describe("rename board should rename board", () => {
const caller = boardRouter.createCaller({ db, session: defaultSession });
// Act
const actAsync = async () =>
await caller.renameBoard({ id: "nonExistentBoardId", name: "newName" });
const actAsync = async () => await caller.renameBoard({ id: "nonExistentBoardId", name: "newName" });
// Assert
await expect(actAsync()).rejects.toThrowError("Board not found");
@@ -438,11 +422,7 @@ describe("changeBoardVisibility should change board visibility", () => {
});
expect(dbBoard).toBeDefined();
expect(dbBoard?.isPublic).toBe(visibility === "public");
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"full-access",
);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
},
);
});
@@ -472,11 +452,7 @@ describe("deleteBoard should delete board", () => {
where: eq(boards.id, boardId),
});
expect(dbBoard).toBeUndefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"full-access",
);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
});
test("should throw error when board not found", async () => {
@@ -485,65 +461,53 @@ describe("deleteBoard should delete board", () => {
const caller = boardRouter.createCaller({ db, session: defaultSession });
// Act
const actAsync = async () =>
await caller.deleteBoard({ id: "nonExistentBoardId" });
const actAsync = async () => await caller.deleteBoard({ id: "nonExistentBoardId" });
// Assert
await expect(actAsync()).rejects.toThrowError("Board not found");
});
});
describe("getDefaultBoard should return default board", () => {
it("should return default board", async () => {
describe("getHomeBoard should return home board", () => {
it("should return home board", async () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const fullBoardProps = await createFullBoardAsync(db, "default");
const fullBoardProps = await createFullBoardAsync(db, "home");
// Act
const result = await caller.getDefaultBoard();
const result = await caller.getHomeBoard();
// Assert
expectInputToBeFullBoardWithName(result, {
name: "default",
name: "home",
...fullBoardProps,
});
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"board-view",
);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-view");
});
});
describe("getBoardByName should return board by name", () => {
it.each([["default"], ["something"]])(
"should return board by name %s when present",
async (name) => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
it.each([["default"], ["something"]])("should return board by name %s when present", async (name) => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const fullBoardProps = await createFullBoardAsync(db, name);
const fullBoardProps = await createFullBoardAsync(db, name);
// Act
const result = await caller.getBoardByName({ name });
// Act
const result = await caller.getBoardByName({ name });
// Assert
expectInputToBeFullBoardWithName(result, {
name,
...fullBoardProps,
});
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"board-view",
);
},
);
// Assert
expectInputToBeFullBoardWithName(result, {
name,
...fullBoardProps,
});
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-view");
});
it("should throw error when not present", async () => {
// Arrange
@@ -552,8 +516,7 @@ describe("getBoardByName should return board by name", () => {
await createFullBoardAsync(db, "default");
// Act
const actAsync = async () =>
await caller.getBoardByName({ name: "nonExistentBoard" });
const actAsync = async () => await caller.getBoardByName({ name: "nonExistentBoard" });
// Assert
await expect(actAsync()).rejects.toThrowError("Board not found");
@@ -610,9 +573,7 @@ describe("savePartialBoardSettings should save general settings", () => {
expect(dbBoard?.metaTitle).toBe(newMetaTitle);
expect(dbBoard?.logoImageUrl).toBe(newLogoImageUrl);
expect(dbBoard?.faviconImageUrl).toBe(newFaviconImageUrl);
expect(dbBoard?.backgroundImageAttachment).toBe(
newBackgroundImageAttachment,
);
expect(dbBoard?.backgroundImageAttachment).toBe(newBackgroundImageAttachment);
expect(dbBoard?.backgroundImageRepeat).toBe(newBackgroundImageRepeat);
expect(dbBoard?.backgroundImageSize).toBe(newBackgroundImageSize);
expect(dbBoard?.backgroundImageUrl).toBe(newBackgroundImageUrl);
@@ -622,11 +583,7 @@ describe("savePartialBoardSettings should save general settings", () => {
expect(dbBoard?.primaryColor).toBe(newPrimaryColor);
expect(dbBoard?.secondaryColor).toBe(newSecondaryColor);
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"board-change",
);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
});
it("should throw error when board not found", async () => {
@@ -681,21 +638,14 @@ describe("saveBoard should save full board", () => {
expect(definedBoard.sections.length).toBe(1);
expect(definedBoard.sections[0]?.id).not.toBe(sectionId);
expect(section).toBeUndefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"board-change",
);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
});
it("should remove item when not present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const { boardId, itemId, sectionId } = await createFullBoardAsync(
db,
"default",
);
const { boardId, itemId, sectionId } = await createFullBoardAsync(db, "default");
await caller.saveBoard({
id: boardId,
@@ -714,6 +664,7 @@ describe("saveBoard should save full board", () => {
width: 1,
xOffset: 0,
yOffset: 0,
advancedOptions: {},
},
],
},
@@ -741,11 +692,7 @@ describe("saveBoard should save full board", () => {
expect(firstSection.items.length).toBe(1);
expect(firstSection.items[0]?.id).not.toBe(itemId);
expect(item).toBeUndefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"board-change",
);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
});
it("should remove integration reference when not present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
@@ -758,8 +705,7 @@ describe("saveBoard should save full board", () => {
url: "http://localhost:3000",
} as const;
const { boardId, itemId, integrationId, sectionId } =
await createFullBoardAsync(db, "default");
const { boardId, itemId, integrationId, sectionId } = await createFullBoardAsync(db, "default");
await db.insert(integrations).values(anotherIntegration);
await caller.saveBoard({
@@ -779,6 +725,7 @@ describe("saveBoard should save full board", () => {
width: 1,
xOffset: 0,
yOffset: 0,
advancedOptions: {},
},
],
},
@@ -812,71 +759,61 @@ describe("saveBoard should save full board", () => {
expect(firstItem.integrations.length).toBe(1);
expect(firstItem.integrations[0]?.integrationId).not.toBe(integrationId);
expect(integration).toBeUndefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"board-change",
);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
});
it.each([
[{ kind: "empty" as const }],
[{ kind: "category" as const, name: "My first category" }],
])("should add section when present in input", async (partialSection) => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
it.each([[{ kind: "empty" as const }], [{ kind: "category" as const, name: "My first category" }]])(
"should add section when present in input",
async (partialSection) => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
const newSectionId = createId();
await caller.saveBoard({
id: boardId,
sections: [
{
id: newSectionId,
position: 1,
items: [],
...partialSection,
const newSectionId = createId();
await caller.saveBoard({
id: boardId,
sections: [
{
id: newSectionId,
position: 1,
items: [],
...partialSection,
},
{
id: sectionId,
kind: "empty",
position: 0,
items: [],
},
],
});
const board = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
with: {
sections: true,
},
{
id: sectionId,
kind: "empty",
position: 0,
items: [],
},
],
});
});
const board = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
with: {
sections: true,
},
});
const section = await db.query.sections.findFirst({
where: eq(sections.id, newSectionId),
});
const section = await db.query.sections.findFirst({
where: eq(sections.id, newSectionId),
});
const definedBoard = expectToBeDefined(board);
expect(definedBoard.sections.length).toBe(2);
const addedSection = expectToBeDefined(
definedBoard.sections.find((section) => section.id === newSectionId),
);
expect(addedSection).toBeDefined();
expect(addedSection.id).toBe(newSectionId);
expect(addedSection.kind).toBe(partialSection.kind);
expect(addedSection.position).toBe(1);
if ("name" in partialSection) {
expect(addedSection.name).toBe(partialSection.name);
}
expect(section).toBeDefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"board-change",
);
});
const definedBoard = expectToBeDefined(board);
expect(definedBoard.sections.length).toBe(2);
const addedSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === newSectionId));
expect(addedSection).toBeDefined();
expect(addedSection.id).toBe(newSectionId);
expect(addedSection.kind).toBe(partialSection.kind);
expect(addedSection.position).toBe(1);
if ("name" in partialSection) {
expect(addedSection.name).toBe(partialSection.name);
}
expect(section).toBeDefined();
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
},
);
it("should add item when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
@@ -902,6 +839,7 @@ describe("saveBoard should save full board", () => {
width: 1,
xOffset: 3,
yOffset: 2,
advancedOptions: {},
},
],
},
@@ -927,25 +865,17 @@ describe("saveBoard should save full board", () => {
expect(definedBoard.sections.length).toBe(1);
const firstSection = expectToBeDefined(definedBoard.sections[0]);
expect(firstSection.items.length).toBe(1);
const addedItem = expectToBeDefined(
firstSection.items.find((item) => item.id === newItemId),
);
const addedItem = expectToBeDefined(firstSection.items.find((item) => item.id === newItemId));
expect(addedItem).toBeDefined();
expect(addedItem.id).toBe(newItemId);
expect(addedItem.kind).toBe("clock");
expect(addedItem.options).toBe(
SuperJSON.stringify({ is24HourFormat: true }),
);
expect(addedItem.options).toBe(SuperJSON.stringify({ is24HourFormat: true }));
expect(addedItem.height).toBe(1);
expect(addedItem.width).toBe(1);
expect(addedItem.xOffset).toBe(3);
expect(addedItem.yOffset).toBe(2);
expect(item).toBeDefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"board-change",
);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
});
it("should add integration reference when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
@@ -958,10 +888,7 @@ describe("saveBoard should save full board", () => {
url: "http://plex.local",
} as const;
const { boardId, itemId, sectionId } = await createFullBoardAsync(
db,
"default",
);
const { boardId, itemId, sectionId } = await createFullBoardAsync(db, "default");
await db.insert(integrations).values(integration);
await caller.saveBoard({
@@ -981,6 +908,7 @@ describe("saveBoard should save full board", () => {
width: 1,
xOffset: 0,
yOffset: 0,
advancedOptions: {},
},
],
},
@@ -1010,17 +938,11 @@ describe("saveBoard should save full board", () => {
expect(definedBoard.sections.length).toBe(1);
const firstSection = expectToBeDefined(definedBoard.sections[0]);
expect(firstSection.items.length).toBe(1);
const firstItem = expectToBeDefined(
firstSection.items.find((item) => item.id === itemId),
);
const firstItem = expectToBeDefined(firstSection.items.find((item) => item.id === itemId));
expect(firstItem.integrations.length).toBe(1);
expect(firstItem.integrations[0]?.integrationId).toBe(integration.id);
expect(integrationItem).toBeDefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"board-change",
);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
});
it("should update section when present in input", async () => {
const db = createDb();
@@ -1065,16 +987,12 @@ describe("saveBoard should save full board", () => {
const definedBoard = expectToBeDefined(board);
expect(definedBoard.sections.length).toBe(2);
const firstSection = expectToBeDefined(
definedBoard.sections.find((section) => section.id === sectionId),
);
const firstSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === sectionId));
expect(firstSection.id).toBe(sectionId);
expect(firstSection.kind).toBe("empty");
expect(firstSection.position).toBe(1);
expect(firstSection.name).toBe(null);
const secondSection = expectToBeDefined(
definedBoard.sections.find((section) => section.id === newSectionId),
);
const secondSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === newSectionId));
expect(secondSection.id).toBe(newSectionId);
expect(secondSection.kind).toBe("category");
expect(secondSection.position).toBe(0);
@@ -1085,10 +1003,7 @@ describe("saveBoard should save full board", () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const { boardId, itemId, sectionId } = await createFullBoardAsync(
db,
"default",
);
const { boardId, itemId, sectionId } = await createFullBoardAsync(db, "default");
await caller.saveBoard({
id: boardId,
@@ -1107,6 +1022,7 @@ describe("saveBoard should save full board", () => {
width: 2,
xOffset: 7,
yOffset: 5,
advancedOptions: {},
},
],
},
@@ -1128,24 +1044,15 @@ describe("saveBoard should save full board", () => {
expect(definedBoard.sections.length).toBe(1);
const firstSection = expectToBeDefined(definedBoard.sections[0]);
expect(firstSection.items.length).toBe(1);
const firstItem = expectToBeDefined(
firstSection.items.find((item) => item.id === itemId),
);
const firstItem = expectToBeDefined(firstSection.items.find((item) => item.id === itemId));
expect(firstItem.id).toBe(itemId);
expect(firstItem.kind).toBe("clock");
expect(
SuperJSON.parse<{ is24HourFormat: boolean }>(firstItem.options)
.is24HourFormat,
).toBe(false);
expect(SuperJSON.parse<{ is24HourFormat: boolean }>(firstItem.options).is24HourFormat).toBe(false);
expect(firstItem.height).toBe(3);
expect(firstItem.width).toBe(2);
expect(firstItem.xOffset).toBe(7);
expect(firstItem.yOffset).toBe(5);
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"board-change",
);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
});
it("should fail when board not found", async () => {
const db = createDb();
@@ -1215,9 +1122,7 @@ describe("getBoardPermissions should return board permissions", () => {
const result = await caller.getBoardPermissions({ id: boardId });
// Assert
expect(result.groupPermissions).toEqual([
{ group: { id: groupId, name: "group1" }, permission: "board-view" },
]);
expect(result.groupPermissions).toEqual([{ group: { id: groupId, name: "group1" }, permission: "board-view" }]);
expect(result.userPermissions).toEqual(
expect.arrayContaining([
{
@@ -1230,14 +1135,8 @@ describe("getBoardPermissions should return board permissions", () => {
},
]),
);
expect(result.inherited).toEqual([
{ group: { id: groupId, name: "group1" }, permission: "admin" },
]);
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"full-access",
);
expect(result.inherited).toEqual([{ group: { id: groupId, name: "group1" }, permission: "admin" }]);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
});
});
@@ -1278,11 +1177,7 @@ describe("saveUserBoardPermissions should save user board permissions", () => {
where: eq(boardUserPermissions.userId, user1),
});
expect(dbUserPermission).toBeDefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"full-access",
);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
},
);
});
@@ -1329,17 +1224,13 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
where: eq(boardGroupPermissions.groupId, groupId),
});
expect(dbGroupPermission).toBeDefined();
expect(spy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"full-access",
);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
},
);
});
const expectInputToBeFullBoardWithName = (
input: RouterOutputs["board"]["getDefaultBoard"],
input: RouterOutputs["board"]["getHomeBoard"],
props: { name: string } & Awaited<ReturnType<typeof createFullBoardAsync>>,
) => {
expect(input.id).toBe(props.boardId);
@@ -9,10 +9,7 @@ import { throwIfActionForbiddenAsync } from "../../board/board-access";
const defaultCreatorId = createId();
const expectActToBeAsync = async (
act: () => Promise<void>,
success: boolean,
) => {
const expectActToBeAsync = async (act: () => Promise<void>, success: boolean) => {
if (!success) {
await expect(act()).rejects.toThrow("Board not found");
return;
@@ -29,161 +26,124 @@ describe("throwIfActionForbiddenAsync should check access to board and return bo
["full-access" as const, true],
["board-change" as const, true],
["board-view" as const, true],
])(
"with permission %s should return %s when hasFullAccess is true",
async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructBoardPermissions");
spy.mockReturnValue({
hasFullAccess: true,
hasChangeAccess: false,
hasViewAccess: false,
});
])("with permission %s should return %s when hasFullAccess is true", async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructBoardPermissions");
spy.mockReturnValue({
hasFullAccess: true,
hasChangeAccess: false,
hasViewAccess: false,
});
await db.insert(users).values({ id: defaultCreatorId });
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "test",
creatorId: defaultCreatorId,
});
await db.insert(users).values({ id: defaultCreatorId });
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "test",
creatorId: defaultCreatorId,
});
// Act
const act = () =>
throwIfActionForbiddenAsync(
{ db, session: null },
eq(boards.id, boardId),
permission,
);
// Act
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, boardId), permission);
// Assert
await expectActToBeAsync(act, expectedResult);
},
);
// Assert
await expectActToBeAsync(act, expectedResult);
});
test.each([
["full-access" as const, false],
["board-change" as const, true],
["board-view" as const, true],
])(
"with permission %s should return %s when hasChangeAccess is true",
async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructBoardPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasChangeAccess: true,
hasViewAccess: false,
});
])("with permission %s should return %s when hasChangeAccess is true", async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructBoardPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasChangeAccess: true,
hasViewAccess: false,
});
await db.insert(users).values({ id: defaultCreatorId });
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "test",
creatorId: defaultCreatorId,
});
await db.insert(users).values({ id: defaultCreatorId });
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "test",
creatorId: defaultCreatorId,
});
// Act
const act = () =>
throwIfActionForbiddenAsync(
{ db, session: null },
eq(boards.id, boardId),
permission,
);
// Act
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, boardId), permission);
// Assert
await expectActToBeAsync(act, expectedResult);
},
);
// Assert
await expectActToBeAsync(act, expectedResult);
});
test.each([
["full-access" as const, false],
["board-change" as const, false],
["board-view" as const, true],
])(
"with permission %s should return %s when hasViewAccess is true",
async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructBoardPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasChangeAccess: false,
hasViewAccess: true,
});
])("with permission %s should return %s when hasViewAccess is true", async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructBoardPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasChangeAccess: false,
hasViewAccess: true,
});
await db.insert(users).values({ id: defaultCreatorId });
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "test",
creatorId: defaultCreatorId,
});
await db.insert(users).values({ id: defaultCreatorId });
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "test",
creatorId: defaultCreatorId,
});
// Act
const act = () =>
throwIfActionForbiddenAsync(
{ db, session: null },
eq(boards.id, boardId),
permission,
);
// Act
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, boardId), permission);
// Assert
await expectActToBeAsync(act, expectedResult);
},
);
// Assert
await expectActToBeAsync(act, expectedResult);
});
test.each([
["full-access" as const, false],
["board-change" as const, false],
["board-view" as const, false],
])(
"with permission %s should return %s when hasViewAccess is false",
async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructBoardPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasChangeAccess: false,
hasViewAccess: false,
});
])("with permission %s should return %s when hasViewAccess is false", async (permission, expectedResult) => {
// Arrange
const db = createDb();
const spy = vi.spyOn(authShared, "constructBoardPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasChangeAccess: false,
hasViewAccess: false,
});
await db.insert(users).values({ id: defaultCreatorId });
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "test",
creatorId: defaultCreatorId,
});
await db.insert(users).values({ id: defaultCreatorId });
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "test",
creatorId: defaultCreatorId,
});
// Act
const act = () =>
throwIfActionForbiddenAsync(
{ db, session: null },
eq(boards.id, boardId),
permission,
);
// Act
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, boardId), permission);
// Assert
await expectActToBeAsync(act, expectedResult);
},
);
// Assert
await expectActToBeAsync(act, expectedResult);
});
test("should throw when board is not found", async () => {
// Arrange
const db = createDb();
// Act
const act = () =>
throwIfActionForbiddenAsync(
{ db, session: null },
eq(boards.id, createId()),
"full-access",
);
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, createId()), "full-access");
// Assert
await expect(act()).rejects.toThrow("Board not found");
+70 -92
View File
@@ -2,12 +2,7 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId, eq } from "@homarr/db";
import {
groupMembers,
groupPermissions,
groups,
users,
} from "@homarr/db/schema/sqlite";
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import { groupRouter } from "../group";
@@ -103,9 +98,7 @@ describe("paginated should return a list of groups with pagination", () => {
expect(item?.members.length).toBe(1);
const userKeys = Object.keys(item?.members[0] ?? {});
expect(userKeys.length).toBe(4);
expect(
["id", "name", "email", "image"].some((key) => userKeys.includes(key)),
);
expect(["id", "name", "email", "image"].some((key) => userKeys.includes(key)));
});
test.each([
@@ -178,9 +171,7 @@ describe("byId should return group by id including members and permissions", ()
const userKeys = Object.keys(result?.members[0] ?? {});
expect(userKeys.length).toBe(4);
expect(
["id", "name", "email", "image"].some((key) => userKeys.includes(key)),
);
expect(["id", "name", "email", "image"].some((key) => userKeys.includes(key)));
expect(result.permissions.length).toBe(1);
expect(result.permissions[0]).toBe("admin");
});
@@ -249,98 +240,88 @@ describe("create should create group in database", () => {
["test", "Test "],
["test", "test"],
["test", " TeSt"],
])(
"with similar name %s it should fail to create %s",
async (similarName, nameToCreate) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
])("with similar name %s it should fail to create %s", async (similarName, nameToCreate) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(groups).values({
id: createId(),
name: similarName,
});
await db.insert(groups).values({
id: createId(),
name: similarName,
});
// Act
const actAsync = async () =>
await caller.createGroup({ name: nameToCreate });
// Act
const actAsync = async () => await caller.createGroup({ name: nameToCreate });
// Assert
await expect(actAsync()).rejects.toThrow("similar name");
},
);
// Assert
await expect(actAsync()).rejects.toThrow("similar name");
});
});
describe("update should update name with value that is no duplicate", () => {
test.each([
["first", "second ", "second"],
["first", " first", "first"],
])(
"update should update name from %s to %s normalized",
async (initialValue, updateValue, expectedValue) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
])("update should update name from %s to %s normalized", async (initialValue, updateValue, expectedValue) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const groupId = createId();
await db.insert(groups).values([
{
id: groupId,
name: initialValue,
},
{
id: createId(),
name: "Third",
},
]);
const groupId = createId();
await db.insert(groups).values([
{
id: groupId,
name: initialValue,
},
{
id: createId(),
name: "Third",
},
]);
// Act
// Act
await caller.updateGroup({
id: groupId,
name: updateValue,
});
// Assert
const value = await db.query.groups.findFirst({
where: eq(groups.id, groupId),
});
expect(value?.name).toBe(expectedValue);
});
test.each([
["Second ", "second"],
[" seCond", "second"],
])("with similar name %s it should fail to update %s", async (updateValue, initialDuplicate) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const groupId = createId();
await db.insert(groups).values([
{
id: groupId,
name: "Something",
},
{
id: createId(),
name: initialDuplicate,
},
]);
// Act
const actAsync = async () =>
await caller.updateGroup({
id: groupId,
name: updateValue,
});
// Assert
const value = await db.query.groups.findFirst({
where: eq(groups.id, groupId),
});
expect(value?.name).toBe(expectedValue);
},
);
test.each([
["Second ", "second"],
[" seCond", "second"],
])(
"with similar name %s it should fail to update %s",
async (updateValue, initialDuplicate) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const groupId = createId();
await db.insert(groups).values([
{
id: groupId,
name: "Something",
},
{
id: createId(),
name: initialDuplicate,
},
]);
// Act
const actAsync = async () =>
await caller.updateGroup({
id: groupId,
name: updateValue,
});
// Assert
await expect(actAsync()).rejects.toThrow("similar name");
},
);
// Assert
await expect(actAsync()).rejects.toThrow("similar name");
});
test("with non existing id it should throw not found error", async () => {
// Arrange
@@ -392,10 +373,7 @@ describe("savePermissions should save permissions for group", () => {
});
expect(permissions.length).toBe(2);
expect(permissions.map(({ permission }) => permission)).toEqual([
"integration-use-all",
"board-full-access",
]);
expect(permissions.map(({ permission }) => permission)).toEqual(["integration-use-all", "board-full-access"]);
});
test("with non existing group it should throw not found error", async () => {
@@ -118,17 +118,11 @@ describe("byId should return an integration by id", () => {
const result = await caller.byId({ id: "1" });
expect(result.secrets.length).toBe(3);
const username = expectToBeDefined(
result.secrets.find((secret) => secret.kind === "username"),
);
const username = expectToBeDefined(result.secrets.find((secret) => secret.kind === "username"));
expect(username.value).not.toBeNull();
const password = expectToBeDefined(
result.secrets.find((secret) => secret.kind === "password"),
);
const password = expectToBeDefined(result.secrets.find((secret) => secret.kind === "password"));
expect(password.value).toBeNull();
const apiKey = expectToBeDefined(
result.secrets.find((secret) => secret.kind === "apiKey"),
);
const apiKey = expectToBeDefined(result.secrets.find((secret) => secret.kind === "apiKey"));
expect(apiKey.value).toBeNull();
});
});
@@ -200,9 +194,7 @@ describe("update should update an integration", () => {
integrationId,
updatedAt: lastWeek,
};
await db
.insert(integrationSecrets)
.values([usernameToInsert, passwordToInsert]);
await db.insert(integrationSecrets).values([usernameToInsert, passwordToInsert]);
const input = {
id: integrationId,
@@ -231,15 +223,9 @@ describe("update should update an integration", () => {
expect(dbIntegration!.url).toBe(input.url);
expect(dbSecrets.length).toBe(3);
const username = expectToBeDefined(
dbSecrets.find((secret) => secret.kind === "username"),
);
const password = expectToBeDefined(
dbSecrets.find((secret) => secret.kind === "password"),
);
const apiKey = expectToBeDefined(
dbSecrets.find((secret) => secret.kind === "apiKey"),
);
const username = expectToBeDefined(dbSecrets.find((secret) => secret.kind === "username"));
const password = expectToBeDefined(dbSecrets.find((secret) => secret.kind === "password"));
const apiKey = expectToBeDefined(dbSecrets.find((secret) => secret.kind === "apiKey"));
expect(username.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(password.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(apiKey.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
@@ -327,26 +313,23 @@ describe("testConnection should test the connection to an integration", () => {
{ kind: "password" as const, value: "Password123!" },
],
],
])(
"should fail when a required secret is missing when creating %s integration",
async (kind, secrets) => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
});
])("should fail when a required secret is missing when creating %s integration", async (kind, secrets) => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: null,
});
const input: RouterInputs["integration"]["testConnection"] = {
id: null,
kind,
url: `http://${kind}.local`,
secrets,
};
const input: RouterInputs["integration"]["testConnection"] = {
id: null,
kind,
url: `http://${kind}.local`,
secrets,
};
const actAsync = async () => await caller.testConnection(input);
await expect(actAsync()).rejects.toThrow("SECRETS_NOT_DEFINED");
},
);
const actAsync = async () => await caller.testConnection(input);
await expect(actAsync()).rejects.toThrow("SECRETS_NOT_DEFINED");
});
it.each([
[
@@ -0,0 +1,131 @@
import SuperJSON from "superjson";
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/db";
import { serverSettings } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import { serverSettingsRouter } from "../serverSettings";
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
const defaultSession = {
user: {
id: createId(),
permissions: [],
},
expires: new Date().toISOString(),
} satisfies Session;
describe("getAll server settings", () => {
test("getAll should throw error when unauthorized", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
session: null,
});
await db.insert(serverSettings).values([
{
settingKey: defaultServerSettingsKeys[0],
value: SuperJSON.stringify(defaultServerSettings.analytics),
},
]);
const actAsync = async () => await caller.getAll();
await expect(actAsync()).rejects.toThrow();
});
test("getAll should return server", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
session: defaultSession,
});
await db.insert(serverSettings).values([
{
settingKey: defaultServerSettingsKeys[0],
value: SuperJSON.stringify(defaultServerSettings.analytics),
},
]);
const result = await caller.getAll();
expect(result).toStrictEqual({
analytics: {
enableGeneral: true,
enableWidgetData: false,
enableIntegrationData: false,
enableUserData: false,
},
});
});
});
describe("saveSettings", () => {
test("saveSettings should return false when it did not update one", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
session: defaultSession,
});
const result = await caller.saveSettings({
settingsKey: "analytics",
value: {
enableGeneral: true,
enableWidgetData: true,
enableIntegrationData: true,
enableUserData: true,
},
});
expect(result).toBe(false);
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings.length).toBe(0);
});
test("saveSettings should update settings and return true when it updated only one", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
session: defaultSession,
});
await db.insert(serverSettings).values([
{
settingKey: defaultServerSettingsKeys[0],
value: SuperJSON.stringify(defaultServerSettings.analytics),
},
]);
const result = await caller.saveSettings({
settingsKey: "analytics",
value: {
enableGeneral: true,
enableWidgetData: true,
enableIntegrationData: true,
enableUserData: true,
},
});
expect(result).toBe(true);
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
{
settingKey: "analytics",
value: SuperJSON.stringify({
enableGeneral: true,
enableWidgetData: true,
enableIntegrationData: true,
enableUserData: true,
}),
},
]);
});
});
+8 -9
View File
@@ -73,7 +73,7 @@ describe("initUser should initialize the first user", () => {
confirmPassword: "12345679",
});
await expect(actAsync()).rejects.toThrow("Passwords do not match");
await expect(actAsync()).rejects.toThrow("passwordsDoNotMatch");
});
it("should not create a user if the password is too short", async () => {
@@ -218,10 +218,7 @@ describe("editProfile shoud update user", () => {
});
// assert
const user = await db
.select()
.from(schema.users)
.where(eq(schema.users.id, id));
const user = await db.select().from(schema.users).where(eq(schema.users.id, id));
expect(user).toHaveLength(1);
expect(user[0]).toStrictEqual({
@@ -232,6 +229,7 @@ describe("editProfile shoud update user", () => {
salt: null,
password: null,
image: null,
homeBoardId: null,
});
});
@@ -260,10 +258,7 @@ describe("editProfile shoud update user", () => {
});
// assert
const user = await db
.select()
.from(schema.users)
.where(eq(schema.users.id, id));
const user = await db.select().from(schema.users).where(eq(schema.users.id, id));
expect(user).toHaveLength(1);
expect(user[0]).toStrictEqual({
@@ -274,6 +269,7 @@ describe("editProfile shoud update user", () => {
salt: null,
password: null,
image: null,
homeBoardId: null,
});
});
});
@@ -297,6 +293,7 @@ describe("delete should delete user", () => {
image: null,
password: null,
salt: null,
homeBoardId: null,
},
{
id: userToDelete,
@@ -306,6 +303,7 @@ describe("delete should delete user", () => {
image: null,
password: null,
salt: null,
homeBoardId: null,
},
{
id: createId(),
@@ -315,6 +313,7 @@ describe("delete should delete user", () => {
image: null,
password: null,
salt: null,
homeBoardId: null,
},
];
+128 -152
View File
@@ -11,60 +11,58 @@ import { validation, z } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
export const userRouter = createTRPCRouter({
initUser: publicProcedure
.input(validation.user.init)
.mutation(async ({ ctx, input }) => {
const firstUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
},
initUser: publicProcedure.input(validation.user.init).mutation(async ({ ctx, input }) => {
const firstUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
},
});
if (firstUser) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User already exists",
});
}
if (firstUser) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User already exists",
});
}
await createUserAsync(ctx.db, input);
}),
register: publicProcedure.input(validation.user.registrationApi).mutation(async ({ ctx, input }) => {
const inviteWhere = and(eq(invites.id, input.inviteId), eq(invites.token, input.token));
const dbInvite = await ctx.db.query.invites.findFirst({
columns: {
id: true,
expirationDate: true,
},
where: inviteWhere,
});
await createUserAsync(ctx.db, input);
}),
register: publicProcedure
.input(validation.user.registrationApi)
.mutation(async ({ ctx, input }) => {
const inviteWhere = and(
eq(invites.id, input.inviteId),
eq(invites.token, input.token),
);
const dbInvite = await ctx.db.query.invites.findFirst({
columns: {
id: true,
expirationDate: true,
},
where: inviteWhere,
if (!dbInvite || dbInvite.expirationDate < new Date()) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invalid invite",
});
}
if (!dbInvite || dbInvite.expirationDate < new Date()) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invalid invite",
});
}
if (!dbInvite || dbInvite.expirationDate < new Date()) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invalid invite",
});
}
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, input.username);
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, input.username);
await createUserAsync(ctx.db, input);
await createUserAsync(ctx.db, input);
// Delete invite as it's used
await ctx.db.delete(invites).where(inviteWhere);
}),
create: publicProcedure
.input(validation.user.create)
.mutation(async ({ ctx, input }) => {
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, input.username);
// Delete invite as it's used
await ctx.db.delete(invites).where(inviteWhere);
}),
create: publicProcedure.input(validation.user.create).mutation(async ({ ctx, input }) => {
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, input.username);
await createUserAsync(ctx.db, input);
}),
await createUserAsync(ctx.db, input);
}),
setProfileImage: protectedProcedure
.input(
z.object({
@@ -79,10 +77,7 @@ export const userRouter = createTRPCRouter({
)
.mutation(async ({ input, ctx }) => {
// Only admins can change other users profile images
if (
ctx.session.user.id !== input.userId &&
!ctx.session.user.permissions.includes("admin")
) {
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to change other users profile images",
@@ -131,118 +126,105 @@ export const userRouter = createTRPCRouter({
},
});
}),
getById: publicProcedure
.input(z.object({ userId: z.string() }))
.query(async ({ input, ctx }) => {
const user = await ctx.db.query.users.findFirst({
getById: publicProcedure.input(z.object({ userId: z.string() })).query(async ({ input, ctx }) => {
const user = await ctx.db.query.users.findFirst({
columns: {
id: true,
name: true,
email: true,
emailVerified: true,
image: true,
},
where: eq(users.id, input.userId),
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
return user;
}),
editProfile: publicProcedure.input(validation.user.editProfile).mutation(async ({ input, ctx }) => {
const user = await ctx.db.query.users.findFirst({
columns: { email: true },
where: eq(users.id, input.id),
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, input.name, input.id);
const emailDirty = input.email && user.email !== input.email;
await ctx.db
.update(users)
.set({
name: input.name,
email: emailDirty === true ? input.email : undefined,
emailVerified: emailDirty === true ? null : undefined,
})
.where(eq(users.id, input.id));
}),
delete: publicProcedure.input(z.string()).mutation(async ({ input, ctx }) => {
await ctx.db.delete(users).where(eq(users.id, input));
}),
changePassword: protectedProcedure.input(validation.user.changePasswordApi).mutation(async ({ ctx, input }) => {
const user = ctx.session.user;
// Only admins can change other users' passwords
if (!user.permissions.includes("admin") && user.id !== input.userId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
// Admins can change the password of other users without providing the previous password
const isPreviousPasswordRequired = ctx.session.user.id === input.userId;
if (isPreviousPasswordRequired) {
const dbUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
name: true,
email: true,
emailVerified: true,
image: true,
password: true,
salt: true,
},
where: eq(users.id, input.userId),
});
if (!user) {
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
return user;
}),
editProfile: publicProcedure
.input(validation.user.editProfile)
.mutation(async ({ input, ctx }) => {
const user = await ctx.db.query.users.findFirst({
columns: { email: true },
where: eq(users.id, input.id),
});
const previousPasswordHash = await hashPasswordAsync(input.previousPassword, dbUser.salt ?? "");
const isValid = previousPasswordHash === dbUser.password;
if (!user) {
if (!isValid) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
code: "FORBIDDEN",
message: "Invalid password",
});
}
}
await checkUsernameAlreadyTakenAndThrowAsync(
ctx.db,
input.name,
input.id,
);
const emailDirty = input.email && user.email !== input.email;
await ctx.db
.update(users)
.set({
name: input.name,
email: emailDirty === true ? input.email : undefined,
emailVerified: emailDirty === true ? null : undefined,
})
.where(eq(users.id, input.id));
}),
delete: publicProcedure.input(z.string()).mutation(async ({ input, ctx }) => {
await ctx.db.delete(users).where(eq(users.id, input));
const salt = await createSaltAsync();
const hashedPassword = await hashPasswordAsync(input.password, salt);
await ctx.db
.update(users)
.set({
password: hashedPassword,
})
.where(eq(users.id, input.userId));
}),
changePassword: protectedProcedure
.input(validation.user.changePasswordApi)
.mutation(async ({ ctx, input }) => {
const user = ctx.session.user;
// Only admins can change other users' passwords
if (!user.permissions.includes("admin") && user.id !== input.userId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
// Admins can change the password of other users without providing the previous password
const isPreviousPasswordRequired = ctx.session.user.id === input.userId;
if (isPreviousPasswordRequired) {
const dbUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
password: true,
salt: true,
},
where: eq(users.id, input.userId),
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const previousPasswordHash = await hashPasswordAsync(
input.previousPassword,
dbUser.salt ?? "",
);
const isValid = previousPasswordHash === dbUser.password;
if (!isValid) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invalid password",
});
}
}
const salt = await createSaltAsync();
const hashedPassword = await hashPasswordAsync(input.password, salt);
await ctx.db
.update(users)
.set({
password: hashedPassword,
})
.where(eq(users.id, input.userId));
}),
setMessage: publicProcedure.input(z.string()).mutation(async ({ input }) => {
await exampleChannel.publishAsync({ message: input });
}),
@@ -255,10 +237,7 @@ export const userRouter = createTRPCRouter({
}),
});
const createUserAsync = async (
db: Database,
input: z.infer<typeof validation.user.create>,
) => {
const createUserAsync = async (db: Database, input: z.infer<typeof validation.user.create>) => {
const salt = await createSaltAsync();
const hashedPassword = await hashPasswordAsync(input.password, salt);
@@ -268,16 +247,13 @@ const createUserAsync = async (
await db.insert(schema.users).values({
id: userId,
name: username,
email: input.email,
password: hashedPassword,
salt,
});
};
const checkUsernameAlreadyTakenAndThrowAsync = async (
db: Database,
username: string,
ignoreId?: string,
) => {
const checkUsernameAlreadyTakenAndThrowAsync = async (db: Database, username: string, ignoreId?: string) => {
const user = await db.query.users.findFirst({
where: eq(users.name, username.toLowerCase()),
});
+2 -8
View File
@@ -27,17 +27,11 @@ import { ZodError } from "@homarr/validation";
*
* @see https://trpc.io/docs/server/context
*/
export const createTRPCContext = (opts: {
headers: Headers;
session: Session | null;
}) => {
export const createTRPCContext = (opts: { headers: Headers; session: Session | null }) => {
const session = opts.session;
const source = opts.headers.get("x-trpc-source") ?? "unknown";
logger.info(
`tRPC request from ${source} by user '${session?.user.id}'`,
session?.user,
);
logger.info(`tRPC request from ${source} by user '${session?.user.id}'`, session?.user);
return {
session,
+9 -24
View File
@@ -7,17 +7,9 @@ import { eq, inArray } from "@homarr/db";
import { groupMembers, groupPermissions } from "@homarr/db/schema/sqlite";
import { getPermissionsWithChildren } from "@homarr/definitions";
import {
expireDateAfter,
generateSessionToken,
sessionMaxAgeInSeconds,
sessionTokenCookieName,
} from "./session";
import { expireDateAfter, generateSessionToken, sessionMaxAgeInSeconds, sessionTokenCookieName } from "./session";
export const getCurrentUserPermissionsAsync = async (
db: Database,
userId: string,
) => {
export const getCurrentUserPermissionsAsync = async (db: Database, userId: string) => {
const dbGroupMembers = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId),
});
@@ -27,19 +19,13 @@ export const getCurrentUserPermissionsAsync = async (
permission: groupPermissions.permission,
})
.from(groupPermissions)
.where(
groupIds.length > 0
? inArray(groupPermissions.groupId, groupIds)
: undefined,
);
.where(groupIds.length > 0 ? inArray(groupPermissions.groupId, groupIds) : undefined);
const permissionKeys = dbGroupPermissions.map(({ permission }) => permission);
return getPermissionsWithChildren(permissionKeys);
};
export const createSessionCallback = (
db: Database,
): NextAuthCallbackOf<"session"> => {
export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session"> => {
return async ({ session, user }) => {
return {
...session,
@@ -54,10 +40,7 @@ export const createSessionCallback = (
};
export const createSignInCallback =
(
adapter: Adapter,
isCredentialsRequest: boolean,
): NextAuthCallbackOf<"signIn"> =>
(adapter: Adapter, isCredentialsRequest: boolean): NextAuthCallbackOf<"signIn"> =>
async ({ user }) => {
if (!isCredentialsRequest) return true;
@@ -89,5 +72,7 @@ export const createSignInCallback =
};
type NextAuthCallbackRecord = Exclude<NextAuthConfig["callbacks"], undefined>;
export type NextAuthCallbackOf<TKey extends keyof NextAuthCallbackRecord> =
Exclude<NextAuthCallbackRecord[TKey], undefined>;
export type NextAuthCallbackOf<TKey extends keyof NextAuthCallbackRecord> = Exclude<
NextAuthCallbackRecord[TKey],
undefined
>;
+1 -4
View File
@@ -28,10 +28,7 @@ export const createConfiguration = (isCredentialsRequest: boolean) =>
},
trustHost: true,
adapter,
providers: [
Credentials(createCredentialsConfiguration(db)),
EmptyNextAuthProvider(),
],
providers: [Credentials(createCredentialsConfiguration(db)), EmptyNextAuthProvider()],
callbacks: {
session: createSessionCallback(db),
signIn: createSignInCallback(adapter, isCredentialsRequest),
+2 -6
View File
@@ -3,15 +3,11 @@ import { z } from "zod";
export const env = createEnv({
server: {
AUTH_SECRET:
process.env.NODE_ENV === "production"
? z.string().min(1)
: z.string().min(1).optional(),
AUTH_SECRET: process.env.NODE_ENV === "production" ? z.string().min(1) : z.string().min(1).optional(),
},
client: {},
runtimeEnv: {
AUTH_SECRET: process.env.AUTH_SECRET,
},
skipValidation:
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION),
skipValidation: Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION),
});
+2 -6
View File
@@ -17,10 +17,6 @@ declare module "@auth/core/types" {
export * from "./security";
export const createHandlers = (isCredentialsRequest: boolean) =>
createConfiguration(isCredentialsRequest);
export const createHandlers = (isCredentialsRequest: boolean) => createConfiguration(isCredentialsRequest);
export {
getSessionFromTokenAsync as getSessionFromToken,
sessionTokenCookieName,
} from "./session";
export { getSessionFromTokenAsync as getSessionFromToken, sessionTokenCookieName } from "./session";
+4 -13
View File
@@ -19,24 +19,15 @@ export type BoardPermissionsProps = (
isPublic: boolean;
};
export const constructBoardPermissions = (
board: BoardPermissionsProps,
session: Session | null,
) => {
export const constructBoardPermissions = (board: BoardPermissionsProps, session: Session | null) => {
const creatorId = "creator" in board ? board.creator?.id : board.creatorId;
return {
hasFullAccess:
session?.user?.id === creatorId ||
session?.user.permissions.includes("board-full-access"),
hasFullAccess: session?.user?.id === creatorId || session?.user.permissions.includes("board-full-access"),
hasChangeAccess:
session?.user?.id === creatorId ||
board.userPermissions.some(
({ permission }) => permission === "board-change",
) ||
board.groupPermissions.some(
({ permission }) => permission === "board-change",
) ||
board.userPermissions.some(({ permission }) => permission === "board-change") ||
board.groupPermissions.some(({ permission }) => permission === "board-change") ||
session?.user.permissions.includes("board-modify-all"),
hasViewAccess:
session?.user?.id === creatorId ||
+2 -7
View File
@@ -35,13 +35,8 @@ export const createCredentialsConfiguration = (db: Database) =>
return null;
}
console.log(
`user ${user.name} is trying to log in. checking password...`,
);
const isValidPassword = await bcrypt.compare(
data.password,
user.password,
);
console.log(`user ${user.name} is trying to log in. checking password...`);
const isValidPassword = await bcrypt.compare(data.password, user.password);
if (!isValidPassword) {
console.log(`password for user ${user.name} was incorrect`);
@@ -26,13 +26,7 @@ describe("Credentials authorization", () => {
expect(result).toEqual({ id: userId, name: "test" });
});
const passwordsThatShouldNotAuthorize = [
"wrong",
"Test",
"test ",
" test",
" test ",
];
const passwordsThatShouldNotAuthorize = ["wrong", "Test", "test ", " test", " test "];
passwordsThatShouldNotAuthorize.forEach((password) => {
it(`should not authorize user with incorrect credentials (${password})`, async () => {
+1 -4
View File
@@ -16,10 +16,7 @@ export const generateSessionToken = () => {
return randomUUID();
};
export const getSessionFromTokenAsync = async (
db: Database,
token: string | undefined,
): Promise<Session | null> => {
export const getSessionFromTokenAsync = async (db: Database, token: string | undefined): Promise<Session | null> => {
if (!token) {
return null;
}
+13 -35
View File
@@ -6,20 +6,11 @@ import type { Account, User } from "next-auth";
import type { JWT } from "next-auth/jwt";
import { describe, expect, it, test, vi } from "vitest";
import {
groupMembers,
groupPermissions,
groups,
users,
} from "@homarr/db/schema/sqlite";
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import * as definitions from "@homarr/definitions";
import {
createSessionCallback,
createSignInCallback,
getCurrentUserPermissionsAsync,
} from "../callbacks";
import { createSessionCallback, createSignInCallback, getCurrentUserPermissionsAsync } from "../callbacks";
describe("getCurrentUserPermissions", () => {
test("should return empty permissions when non existing user requested", async () => {
@@ -96,9 +87,7 @@ describe("session callback", () => {
});
});
type AdapterSessionInput = Parameters<
Exclude<Adapter["createSession"], undefined>
>[0];
type AdapterSessionInput = Parameters<Exclude<Adapter["createSession"], undefined>>[0];
const createAdapter = () => {
const result = {
@@ -131,8 +120,7 @@ vi.mock("next/headers", async (importOriginal) => {
const mod = await importOriginal<HeadersExport>();
const result = {
set: (name: string, value: string, options: Partial<ResponseCookie>) =>
options as ResponseCookie,
set: (name: string, value: string, options: Partial<ResponseCookie>) => options as ResponseCookie,
} as unknown as ReadonlyRequestCookies;
vi.spyOn(result, "set");
@@ -145,10 +133,7 @@ vi.mock("next/headers", async (importOriginal) => {
describe("createSignInCallback", () => {
it("should return true if not credentials request", async () => {
const isCredentialsRequest = false;
const signInCallback = createSignInCallback(
createAdapter(),
isCredentialsRequest,
);
const signInCallback = createSignInCallback(createAdapter(), isCredentialsRequest);
const result = await signInCallback({
user: { id: "1", emailVerified: new Date("2023-01-13") },
account: {} as Account,
@@ -158,10 +143,7 @@ describe("createSignInCallback", () => {
it("should return true if no user", async () => {
const isCredentialsRequest = true;
const signInCallback = createSignInCallback(
createAdapter(),
isCredentialsRequest,
);
const signInCallback = createSignInCallback(createAdapter(), isCredentialsRequest);
const result = await signInCallback({
user: undefined as unknown as User,
account: {} as Account,
@@ -195,16 +177,12 @@ describe("createSignInCallback", () => {
userId: user.id,
expires: mockSessionExpiry,
});
expect(cookies().set).toHaveBeenCalledWith(
"next-auth.session-token",
mockSessionToken,
{
path: "/",
expires: mockSessionExpiry,
httpOnly: true,
sameSite: "lax",
secure: true,
},
);
expect(cookies().set).toHaveBeenCalledWith("next-auth.session-token", mockSessionToken, {
path: "/",
expires: mockSessionExpiry,
httpOnly: true,
sameSite: "lax",
secure: true,
});
});
});
+5 -8
View File
@@ -20,14 +20,11 @@ describe("expireDateAfter should calculate date after specified seconds", () =>
["2023-07-01T00:00:00Z", 60 * 60 * 24 * 30, "2023-07-31T00:00:00Z"], // 30 days
["2023-07-01T00:00:00Z", 60 * 60 * 24 * 365, "2024-06-30T00:00:00Z"], // 1 year
["2023-07-01T00:00:00Z", 60 * 60 * 24 * 365 * 10, "2033-06-28T00:00:00Z"], // 10 years
])(
"should calculate date %s and after %i seconds to equal %s",
(initialDate, seconds, expectedDate) => {
vi.setSystemTime(new Date(initialDate));
const result = expireDateAfter(seconds);
expect(result).toEqual(new Date(expectedDate));
},
);
])("should calculate date %s and after %i seconds to equal %s", (initialDate, seconds, expectedDate) => {
vi.setSystemTime(new Date(initialDate));
const result = expireDateAfter(seconds);
expect(result).toEqual(new Date(expectedDate));
});
});
describe("generateSessionToken should return a random UUID", () => {
+1 -2
View File
@@ -6,5 +6,4 @@ type Entries<T> = {
[K in keyof T]: [K, T[K]];
}[keyof T][];
export const objectEntries = <T extends object>(obj: T) =>
Object.entries(obj) as Entries<T>;
export const objectEntries = <T extends object>(obj: T) => Object.entries(obj) as Entries<T>;
+1 -6
View File
@@ -2,12 +2,7 @@ import { describe, expect, it } from "vitest";
import { objectEntries, objectKeys } from "../object";
const testObjects = [
{ a: 1, c: 3, b: 2 },
{ a: 1, b: 2 },
{ a: 1 },
{},
] as const;
const testObjects = [{ a: 1, c: 3, b: 2 }, { a: 1, b: 2 }, { a: 1 }, {}] as const;
describe("objectKeys should return all keys of an object", () => {
testObjects.forEach((obj) => {
+1 -1
View File
@@ -39,7 +39,7 @@ const initBetterSqlite = () => {
database = drizzleSqlite(connection, {
schema: sqliteSchema,
logger: new WinstonDrizzleLogger(),
});
}) as unknown as never;
};
const initMySQL2 = () => {
@@ -0,0 +1,2 @@
ALTER TABLE `user` ADD `homeBoardId` varchar(64);--> statement-breakpoint
ALTER TABLE `user` ADD CONSTRAINT `user_homeBoardId_board_id_fk` FOREIGN KEY (`homeBoardId`) REFERENCES `board`(`id`) ON DELETE set null ON UPDATE no action;
@@ -0,0 +1 @@
ALTER TABLE `item` ADD `advanced_options` text DEFAULT ('{"json": {}}') NOT NULL;
@@ -0,0 +1,6 @@
CREATE TABLE `serverSetting` (
`key` varchar(64) NOT NULL,
`value` text NOT NULL DEFAULT ('{"json": {}}'),
CONSTRAINT `serverSetting_key` PRIMARY KEY(`key`),
CONSTRAINT `serverSetting_key_unique` UNIQUE(`key`)
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,27 @@
"when": 1715334452118,
"tag": "0000_harsh_photon",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1715885855801,
"tag": "0001_wild_alex_wilder",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1715980459023,
"tag": "0002_flimsy_deathbird",
"breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1716148439439,
"tag": "0003_freezing_black_panther",
"breakpoints": true
}
]
}
@@ -0,0 +1,33 @@
COMMIT TRANSACTION;
--> statement-breakpoint
PRAGMA foreign_keys = OFF;
--> statement-breakpoint
BEGIN TRANSACTION;
--> statement-breakpoint
ALTER TABLE `user` RENAME TO `__user_old`;
--> statement-breakpoint
CREATE TABLE `user` (
`id` text PRIMARY KEY NOT NULL,
`name` text,
`email` text,
`emailVerified` integer,
`image` text,
`password` text,
`salt` text,
`homeBoardId` text,
FOREIGN KEY (`homeBoardId`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
INSERT INTO `user` SELECT `id`, `name`, `email`, `emailVerified`, `image`, `password`, `salt`, null FROM `__user_old`;
--> statement-breakpoint
DROP TABLE `__user_old`;
--> statement-breakpoint
ALTER TABLE `user` RENAME TO `__user_old`;
--> statement-breakpoint
ALTER TABLE `__user_old` RENAME TO `user`;
--> statement-breakpoint
COMMIT TRANSACTION;
--> statement-breakpoint
PRAGMA foreign_keys = ON;
--> statement-breakpoint
BEGIN TRANSACTION;
@@ -0,0 +1 @@
ALTER TABLE `item` ADD `advanced_options` text DEFAULT '{"json": {}}' NOT NULL;
@@ -0,0 +1,6 @@
CREATE TABLE `serverSetting` (
`key` text PRIMARY KEY NOT NULL,
`value` text DEFAULT '{"json": {}}' NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `serverSetting_key_unique` ON `serverSetting` (`key`);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,27 @@
"when": 1715334238443,
"tag": "0000_talented_ben_parker",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1715871797713,
"tag": "0001_mixed_titanium_man",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1715973963014,
"tag": "0002_cooing_sumo",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1716148434186,
"tag": "0003_adorable_raider",
"breakpoints": true
}
]
}
+57 -88
View File
@@ -1,15 +1,7 @@
import type { AdapterAccount } from "@auth/core/adapters";
import { relations } from "drizzle-orm";
import {
boolean,
index,
int,
mysqlTable,
primaryKey,
text,
timestamp,
varchar,
} from "drizzle-orm/mysql-core";
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
import { boolean, index, int, mysqlTable, primaryKey, text, timestamp, varchar } from "drizzle-orm/mysql-core";
import type {
BackgroundImageAttachment,
@@ -22,11 +14,7 @@ import type {
SectionKind,
WidgetKind,
} from "@homarr/definitions";
import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
} from "@homarr/definitions";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
export const users = mysqlTable("user", {
id: varchar("id", { length: 64 }).notNull().primaryKey(),
@@ -36,6 +24,9 @@ export const users = mysqlTable("user", {
image: text("image"),
password: text("password"),
salt: text("salt"),
homeBoardId: varchar("homeBoardId", { length: 64 }).references((): AnyMySqlColumn => boards.id, {
onDelete: "set null",
}),
});
export const accounts = mysqlTable(
@@ -66,9 +57,7 @@ export const accounts = mysqlTable(
export const sessions = mysqlTable(
"session",
{
sessionToken: varchar("sessionToken", { length: 512 })
.notNull()
.primaryKey(),
sessionToken: varchar("sessionToken", { length: 512 }).notNull().primaryKey(),
userId: varchar("userId", { length: 64 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
@@ -150,9 +139,7 @@ export const integrations = mysqlTable(
export const integrationSecrets = mysqlTable(
"integrationSecret",
{
kind: varchar("kind", { length: 16 })
.$type<IntegrationSecretKind>()
.notNull(),
kind: varchar("kind", { length: 16 }).$type<IntegrationSecretKind>().notNull(),
value: text("value").$type<`${string}.${string}`>().notNull(),
updatedAt: timestamp("updated_at").notNull(),
integrationId: varchar("integration_id", { length: 64 })
@@ -164,9 +151,7 @@ export const integrationSecrets = mysqlTable(
columns: [integrationSecret.integrationId, integrationSecret.kind],
}),
kindIdx: index("integration_secret__kind_idx").on(integrationSecret.kind),
updatedAtIdx: index("integration_secret__updated_at_idx").on(
integrationSecret.updatedAt,
),
updatedAtIdx: index("integration_secret__updated_at_idx").on(integrationSecret.updatedAt),
}),
);
@@ -210,9 +195,7 @@ export const boardUserPermissions = mysqlTable(
userId: varchar("user_id", { length: 64 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permission: varchar("permission", { length: 128 })
.$type<BoardPermission>()
.notNull(),
permission: varchar("permission", { length: 128 }).$type<BoardPermission>().notNull(),
},
(table) => ({
compoundKey: primaryKey({
@@ -230,9 +213,7 @@ export const boardGroupPermissions = mysqlTable(
groupId: varchar("group_id", { length: 64 })
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
permission: varchar("permission", { length: 128 })
.$type<BoardPermission>()
.notNull(),
permission: varchar("permission", { length: 128 }).$type<BoardPermission>().notNull(),
},
(table) => ({
compoundKey: primaryKey({
@@ -262,6 +243,7 @@ export const items = mysqlTable("item", {
width: int("width").notNull(),
height: int("height").notNull(),
options: text("options").default('{"json": {}}').notNull(), // empty superjson object
advancedOptions: text("advanced_options").default('{"json": {}}').notNull(), // empty superjson object
});
export const apps = mysqlTable("app", {
@@ -304,6 +286,11 @@ export const iconRepositories = mysqlTable("iconRepository", {
slug: varchar("iconRepository_slug", { length: 150 }).notNull(),
});
export const serverSettings = mysqlTable("serverSetting", {
settingKey: varchar("key", { length: 64 }).notNull().unique().primaryKey(),
value: text("value").default('{"json": {}}').notNull(), // empty superjson object
});
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -327,12 +314,9 @@ export const iconRelations = relations(icons, ({ one }) => ({
}),
}));
export const iconRepositoryRelations = relations(
iconRepositories,
({ many }) => ({
icons: many(icons),
}),
);
export const iconRepositoryRelations = relations(iconRepositories, ({ many }) => ({
icons: many(icons),
}));
export const inviteRelations = relations(invites, ({ one }) => ({
creator: one(users, {
@@ -369,58 +353,46 @@ export const groupRelations = relations(groups, ({ one, many }) => ({
}),
}));
export const groupPermissionRelations = relations(
groupPermissions,
({ one }) => ({
group: one(groups, {
fields: [groupPermissions.groupId],
references: [groups.id],
}),
export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({
group: one(groups, {
fields: [groupPermissions.groupId],
references: [groups.id],
}),
);
}));
export const boardUserPermissionRelations = relations(
boardUserPermissions,
({ one }) => ({
user: one(users, {
fields: [boardUserPermissions.userId],
references: [users.id],
}),
board: one(boards, {
fields: [boardUserPermissions.boardId],
references: [boards.id],
}),
export const boardUserPermissionRelations = relations(boardUserPermissions, ({ one }) => ({
user: one(users, {
fields: [boardUserPermissions.userId],
references: [users.id],
}),
);
board: one(boards, {
fields: [boardUserPermissions.boardId],
references: [boards.id],
}),
}));
export const boardGroupPermissionRelations = relations(
boardGroupPermissions,
({ one }) => ({
group: one(groups, {
fields: [boardGroupPermissions.groupId],
references: [groups.id],
}),
board: one(boards, {
fields: [boardGroupPermissions.boardId],
references: [boards.id],
}),
export const boardGroupPermissionRelations = relations(boardGroupPermissions, ({ one }) => ({
group: one(groups, {
fields: [boardGroupPermissions.groupId],
references: [groups.id],
}),
);
board: one(boards, {
fields: [boardGroupPermissions.boardId],
references: [boards.id],
}),
}));
export const integrationRelations = relations(integrations, ({ many }) => ({
secrets: many(integrationSecrets),
items: many(integrationItems),
}));
export const integrationSecretRelations = relations(
integrationSecrets,
({ one }) => ({
integration: one(integrations, {
fields: [integrationSecrets.integrationId],
references: [integrations.id],
}),
export const integrationSecretRelations = relations(integrationSecrets, ({ one }) => ({
integration: one(integrations, {
fields: [integrationSecrets.integrationId],
references: [integrations.id],
}),
);
}));
export const boardRelations = relations(boards, ({ many, one }) => ({
sections: many(sections),
@@ -448,16 +420,13 @@ export const itemRelations = relations(items, ({ one, many }) => ({
integrations: many(integrationItems),
}));
export const integrationItemRelations = relations(
integrationItems,
({ one }) => ({
integration: one(integrations, {
fields: [integrationItems.integrationId],
references: [integrations.id],
}),
item: one(items, {
fields: [integrationItems.itemId],
references: [items.id],
}),
export const integrationItemRelations = relations(integrationItems, ({ one }) => ({
integration: one(integrations, {
fields: [integrationItems.integrationId],
references: [integrations.id],
}),
);
item: one(items, {
fields: [integrationItems.itemId],
references: [items.id],
}),
}));
+53 -74
View File
@@ -1,20 +1,10 @@
import type { AdapterAccount } from "@auth/core/adapters";
import type { InferSelectModel } from "drizzle-orm";
import { relations } from "drizzle-orm";
import {
index,
int,
integer,
primaryKey,
sqliteTable,
text,
} from "drizzle-orm/sqlite-core";
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
import { index, int, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
} from "@homarr/definitions";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
import type {
BackgroundImageAttachment,
BackgroundImageRepeat,
@@ -35,6 +25,9 @@ export const users = sqliteTable("user", {
image: text("image"),
password: text("password"),
salt: text("salt"),
homeBoardId: text("homeBoardId").references((): AnySQLiteColumn => boards.id, {
onDelete: "set null",
}),
});
export const accounts = sqliteTable(
@@ -161,9 +154,7 @@ export const integrationSecrets = sqliteTable(
columns: [integrationSecret.integrationId, integrationSecret.kind],
}),
kindIdx: index("integration_secret__kind_idx").on(integrationSecret.kind),
updatedAtIdx: index("integration_secret__updated_at_idx").on(
integrationSecret.updatedAt,
),
updatedAtIdx: index("integration_secret__updated_at_idx").on(integrationSecret.updatedAt),
}),
);
@@ -255,6 +246,7 @@ export const items = sqliteTable("item", {
width: int("width").notNull(),
height: int("height").notNull(),
options: text("options").default('{"json": {}}').notNull(), // empty superjson object
advancedOptions: text("advanced_options").default('{"json": {}}').notNull(), // empty superjson object
});
export const apps = sqliteTable("app", {
@@ -297,6 +289,11 @@ export const iconRepositories = sqliteTable("iconRepository", {
slug: text("iconRepository_slug").notNull(),
});
export const serverSettings = sqliteTable("serverSetting", {
settingKey: text("key").notNull().unique().primaryKey(),
value: text("value").default('{"json": {}}').notNull(), // empty superjson object
});
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -320,12 +317,9 @@ export const iconRelations = relations(icons, ({ one }) => ({
}),
}));
export const iconRepositoryRelations = relations(
iconRepositories,
({ many }) => ({
icons: many(icons),
}),
);
export const iconRepositoryRelations = relations(iconRepositories, ({ many }) => ({
icons: many(icons),
}));
export const inviteRelations = relations(invites, ({ one }) => ({
creator: one(users, {
@@ -362,58 +356,46 @@ export const groupRelations = relations(groups, ({ one, many }) => ({
}),
}));
export const groupPermissionRelations = relations(
groupPermissions,
({ one }) => ({
group: one(groups, {
fields: [groupPermissions.groupId],
references: [groups.id],
}),
export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({
group: one(groups, {
fields: [groupPermissions.groupId],
references: [groups.id],
}),
);
}));
export const boardUserPermissionRelations = relations(
boardUserPermissions,
({ one }) => ({
user: one(users, {
fields: [boardUserPermissions.userId],
references: [users.id],
}),
board: one(boards, {
fields: [boardUserPermissions.boardId],
references: [boards.id],
}),
export const boardUserPermissionRelations = relations(boardUserPermissions, ({ one }) => ({
user: one(users, {
fields: [boardUserPermissions.userId],
references: [users.id],
}),
);
board: one(boards, {
fields: [boardUserPermissions.boardId],
references: [boards.id],
}),
}));
export const boardGroupPermissionRelations = relations(
boardGroupPermissions,
({ one }) => ({
group: one(groups, {
fields: [boardGroupPermissions.groupId],
references: [groups.id],
}),
board: one(boards, {
fields: [boardGroupPermissions.boardId],
references: [boards.id],
}),
export const boardGroupPermissionRelations = relations(boardGroupPermissions, ({ one }) => ({
group: one(groups, {
fields: [boardGroupPermissions.groupId],
references: [groups.id],
}),
);
board: one(boards, {
fields: [boardGroupPermissions.boardId],
references: [boards.id],
}),
}));
export const integrationRelations = relations(integrations, ({ many }) => ({
secrets: many(integrationSecrets),
items: many(integrationItems),
}));
export const integrationSecretRelations = relations(
integrationSecrets,
({ one }) => ({
integration: one(integrations, {
fields: [integrationSecrets.integrationId],
references: [integrations.id],
}),
export const integrationSecretRelations = relations(integrationSecrets, ({ one }) => ({
integration: one(integrations, {
fields: [integrationSecrets.integrationId],
references: [integrations.id],
}),
);
}));
export const boardRelations = relations(boards, ({ many, one }) => ({
sections: many(sections),
@@ -441,19 +423,16 @@ export const itemRelations = relations(items, ({ one, many }) => ({
integrations: many(integrationItems),
}));
export const integrationItemRelations = relations(
integrationItems,
({ one }) => ({
integration: one(integrations, {
fields: [integrationItems.integrationId],
references: [integrations.id],
}),
item: one(items, {
fields: [integrationItems.itemId],
references: [items.id],
}),
export const integrationItemRelations = relations(integrationItems, ({ one }) => ({
integration: one(integrations, {
fields: [integrationItems.integrationId],
references: [integrations.id],
}),
);
item: one(items, {
fields: [integrationItems.itemId],
references: [items.id],
}),
}));
export type User = InferSelectModel<typeof users>;
export type Account = InferSelectModel<typeof accounts>;
+7 -2
View File
@@ -4,11 +4,16 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { schema } from "..";
export const createDb = () => {
export const createDb = (debug?: boolean) => {
const sqlite = new Database(":memory:");
const db = drizzle(sqlite, { schema });
const db = drizzle(sqlite, { schema, logger: debug });
migrate(db, {
migrationsFolder: "./packages/db/migrations/sqlite",
});
if (debug) {
console.log("Database created");
}
return db;
};
+35 -57
View File
@@ -1,12 +1,6 @@
import type { Column, InferSelectModel } from "drizzle-orm";
import type {
ForeignKey as MysqlForeignKey,
MySqlTableWithColumns,
} from "drizzle-orm/mysql-core";
import type {
ForeignKey as SqliteForeignKey,
SQLiteTableWithColumns,
} from "drizzle-orm/sqlite-core";
import type { ForeignKey as MysqlForeignKey, MySqlTableWithColumns } from "drizzle-orm/mysql-core";
import type { ForeignKey as SqliteForeignKey, SQLiteTableWithColumns } from "drizzle-orm/sqlite-core";
import { expect, expectTypeOf, test } from "vitest";
import { objectEntries } from "@homarr/common";
@@ -21,54 +15,44 @@ test("schemas should match", () => {
expectTypeOf<MysqlConfig>().toEqualTypeOf<SqliteConfig>();
objectEntries(sqliteSchema).forEach(([tableName, sqliteTable]) => {
Object.entries(sqliteTable).forEach(
([columnName, sqliteColumn]: [string, object]) => {
if (!("isUnique" in sqliteColumn)) return;
if (!("uniqueName" in sqliteColumn)) return;
if (!("primary" in sqliteColumn)) return;
Object.entries(sqliteTable).forEach(([columnName, sqliteColumn]: [string, object]) => {
if (!("isUnique" in sqliteColumn)) return;
if (!("uniqueName" in sqliteColumn)) return;
if (!("primary" in sqliteColumn)) return;
const mysqlTable = mysqlSchema[tableName];
const mysqlTable = mysqlSchema[tableName];
const mysqlColumn = mysqlTable[
columnName as keyof typeof mysqlTable
] as object;
if (!("isUnique" in mysqlColumn)) return;
if (!("uniqueName" in mysqlColumn)) return;
if (!("primary" in mysqlColumn)) return;
const mysqlColumn = mysqlTable[columnName as keyof typeof mysqlTable] as object;
if (!("isUnique" in mysqlColumn)) return;
if (!("uniqueName" in mysqlColumn)) return;
if (!("primary" in mysqlColumn)) return;
expect(
sqliteColumn.isUnique,
`expect unique of column ${columnName} in table ${tableName} to be the same for both schemas`,
).toEqual(mysqlColumn.isUnique);
expect(
sqliteColumn.uniqueName,
`expect uniqueName of column ${columnName} in table ${tableName} to be the same for both schemas`,
).toEqual(mysqlColumn.uniqueName);
expect(
sqliteColumn.primary,
`expect primary of column ${columnName} in table ${tableName} to be the same for both schemas`,
).toEqual(mysqlColumn.primary);
},
);
expect(
sqliteColumn.isUnique,
`expect unique of column ${columnName} in table ${tableName} to be the same for both schemas`,
).toEqual(mysqlColumn.isUnique);
expect(
sqliteColumn.uniqueName,
`expect uniqueName of column ${columnName} in table ${tableName} to be the same for both schemas`,
).toEqual(mysqlColumn.uniqueName);
expect(
sqliteColumn.primary,
`expect primary of column ${columnName} in table ${tableName} to be the same for both schemas`,
).toEqual(mysqlColumn.primary);
});
const mysqlTable = mysqlSchema[tableName];
const sqliteForeignKeys = sqliteTable[
Symbol.for("drizzle:SQLiteInlineForeignKeys") as keyof typeof sqliteTable
] as SqliteForeignKey[] | undefined;
const mysqlForeignKeys = mysqlTable[
Symbol.for("drizzle:MySqlInlineForeignKeys") as keyof typeof mysqlTable
] as MysqlForeignKey[] | undefined;
const sqliteForeignKeys = sqliteTable[Symbol.for("drizzle:SQLiteInlineForeignKeys") as keyof typeof sqliteTable] as
| SqliteForeignKey[]
| undefined;
const mysqlForeignKeys = mysqlTable[Symbol.for("drizzle:MySqlInlineForeignKeys") as keyof typeof mysqlTable] as
| MysqlForeignKey[]
| undefined;
if (!sqliteForeignKeys && !mysqlForeignKeys) return;
expect(
mysqlForeignKeys,
`mysql foreign key for ${tableName} to be defined`,
).toBeDefined();
expect(
sqliteForeignKeys,
`sqlite foreign key for ${tableName} to be defined`,
).toBeDefined();
expect(mysqlForeignKeys, `mysql foreign key for ${tableName} to be defined`).toBeDefined();
expect(sqliteForeignKeys, `sqlite foreign key for ${tableName} to be defined`).toBeDefined();
expect(
sqliteForeignKeys!.length,
@@ -77,9 +61,7 @@ test("schemas should match", () => {
sqliteForeignKeys?.forEach((sqliteForeignKey) => {
sqliteForeignKey.getName();
const mysqlForeignKey = mysqlForeignKeys!.find(
(key) => key.getName() === sqliteForeignKey.getName(),
);
const mysqlForeignKey = mysqlForeignKeys!.find((key) => key.getName() === sqliteForeignKey.getName());
expect(
mysqlForeignKey,
`expect foreign key ${sqliteForeignKey.getName()} to be defined in mysql schema`,
@@ -97,9 +79,7 @@ test("schemas should match", () => {
sqliteForeignKey.reference().foreignColumns.forEach((column) => {
expect(
mysqlForeignKey!
.reference()
.foreignColumns.map((column) => column.name),
mysqlForeignKey!.reference().foreignColumns.map((column) => column.name),
`expect foreign key (${sqliteForeignKey.getName()}) columns to be the same for both schemas`,
).toContainEqual(column.name);
});
@@ -127,9 +107,7 @@ type MysqlTables = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type InferColumnConfig<T extends Column<any, object>> =
T extends Column<infer C, object>
? Omit<C, "columnType" | "enumValues" | "driverParam">
: never;
T extends Column<infer C, object> ? Omit<C, "columnType" | "enumValues" | "driverParam"> : never;
type SqliteConfig = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+1 -4
View File
@@ -1,7 +1,4 @@
export const createDefinition = <
const TKeys extends string[],
TOptions extends { defaultValue: TKeys[number] } | void,
>(
export const createDefinition = <const TKeys extends string[], TOptions extends { defaultValue: TKeys[number] } | void>(
values: TKeys,
options: TOptions,
) => ({
+7 -17
View File
@@ -1,24 +1,14 @@
import type { inferDefinitionType } from "./_definition";
import { createDefinition } from "./_definition";
export const backgroundImageAttachments = createDefinition(
["fixed", "scroll"],
{ defaultValue: "fixed" },
);
export const backgroundImageRepeats = createDefinition(
["repeat", "repeat-x", "repeat-y", "no-repeat"],
{ defaultValue: "no-repeat" },
);
export const backgroundImageAttachments = createDefinition(["fixed", "scroll"], { defaultValue: "fixed" });
export const backgroundImageRepeats = createDefinition(["repeat", "repeat-x", "repeat-y", "no-repeat"], {
defaultValue: "no-repeat",
});
export const backgroundImageSizes = createDefinition(["cover", "contain"], {
defaultValue: "cover",
});
export type BackgroundImageAttachment = inferDefinitionType<
typeof backgroundImageAttachments
>;
export type BackgroundImageRepeat = inferDefinitionType<
typeof backgroundImageRepeats
>;
export type BackgroundImageSize = inferDefinitionType<
typeof backgroundImageSizes
>;
export type BackgroundImageAttachment = inferDefinitionType<typeof backgroundImageAttachments>;
export type BackgroundImageRepeat = inferDefinitionType<typeof backgroundImageRepeats>;
export type BackgroundImageSize = inferDefinitionType<typeof backgroundImageSizes>;
+21 -41
View File
@@ -12,113 +12,97 @@ export const integrationDefs = {
sabNzbd: {
name: "SABnzbd",
secretKinds: [["apiKey"]],
iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png",
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png",
category: ["useNetClient"],
},
nzbGet: {
name: "NZBGet",
secretKinds: [["username", "password"]],
iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png",
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png",
category: ["useNetClient"],
},
deluge: {
name: "Deluge",
secretKinds: [["password"]],
iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png",
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png",
category: ["downloadClient"],
},
transmission: {
name: "Transmission",
secretKinds: [["username", "password"]],
iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png",
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png",
category: ["downloadClient"],
},
qBittorrent: {
name: "qBittorrent",
secretKinds: [["username", "password"]],
iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png",
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png",
category: ["downloadClient"],
},
sonarr: {
name: "Sonarr",
secretKinds: [["apiKey"]],
iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png",
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png",
category: ["calendar"],
},
radarr: {
name: "Radarr",
secretKinds: [["apiKey"]],
iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png",
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png",
category: ["calendar"],
},
lidarr: {
name: "Lidarr",
secretKinds: [["apiKey"]],
iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png",
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png",
category: ["calendar"],
},
readarr: {
name: "Readarr",
secretKinds: [["apiKey"]],
iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png",
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png",
category: ["calendar"],
},
jellyfin: {
name: "Jellyfin",
secretKinds: [["username", "password"], ["apiKey"]],
iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png",
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png",
category: ["mediaService"],
},
plex: {
name: "Plex",
secretKinds: [["apiKey"]],
iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png",
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png",
category: ["mediaService"],
},
jellyseerr: {
name: "Jellyseerr",
secretKinds: [["apiKey"]],
iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png",
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png",
category: ["mediaSearch", "mediaRequest"],
},
overseerr: {
name: "Overseerr",
secretKinds: [["apiKey"]],
iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png",
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png",
category: ["mediaSearch", "mediaRequest"],
},
piHole: {
name: "Pi-hole",
secretKinds: [["apiKey"]],
iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png",
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png",
category: ["dnsHole"],
},
adGuardHome: {
name: "AdGuard Home",
secretKinds: [["username", "password"]],
iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png",
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png",
category: ["dnsHole"],
},
homeAssistant: {
name: "Home Assistant",
secretKinds: [["apiKey"]],
iconUrl:
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png",
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png",
category: [],
},
} satisfies Record<
@@ -131,20 +115,16 @@ export const integrationDefs = {
}
>;
export const getIconUrl = (integration: IntegrationKind) =>
integrationDefs[integration]?.iconUrl ?? null;
export const getIconUrl = (integration: IntegrationKind) => integrationDefs[integration]?.iconUrl ?? null;
export const getIntegrationName = (integration: IntegrationKind) =>
integrationDefs[integration].name;
export const getIntegrationName = (integration: IntegrationKind) => integrationDefs[integration].name;
export const getDefaultSecretKinds = (
integration: IntegrationKind,
): IntegrationSecretKind[] => integrationDefs[integration]?.secretKinds[0];
export const getDefaultSecretKinds = (integration: IntegrationKind): IntegrationSecretKind[] =>
integrationDefs[integration]?.secretKinds[0];
export const getAllSecretKindOptions = (
integration: IntegrationKind,
): [IntegrationSecretKind[], ...IntegrationSecretKind[][]] =>
integrationDefs[integration]?.secretKinds;
): [IntegrationSecretKind[], ...IntegrationSecretKind[][]] => integrationDefs[integration]?.secretKinds;
export const integrationKinds = objectKeys(integrationDefs);
+14 -29
View File
@@ -20,14 +20,10 @@ const groupPermissionParents = {
admin: ["board-full-access", "integration-full-access"],
} satisfies Partial<Record<GroupPermissionKey, GroupPermissionKey[]>>;
export const getPermissionsWithParents = (
permissions: GroupPermissionKey[],
): GroupPermissionKey[] => {
export const getPermissionsWithParents = (permissions: GroupPermissionKey[]): GroupPermissionKey[] => {
const res = permissions.map((permission) => {
return objectEntries(groupPermissionParents)
.filter(([_key, value]: [string, GroupPermissionKey[]]) =>
value.includes(permission),
)
.filter(([_key, value]: [string, GroupPermissionKey[]]) => value.includes(permission))
.map(([key]) => getPermissionsWithParents([key]))
.flat();
});
@@ -35,13 +31,9 @@ export const getPermissionsWithParents = (
return permissions.concat(res.flat());
};
const getPermissionsInner = (
permissionSet: Set<GroupPermissionKey>,
permissions: GroupPermissionKey[],
) => {
const getPermissionsInner = (permissionSet: Set<GroupPermissionKey>, permissions: GroupPermissionKey[]) => {
permissions.forEach((permission) => {
const children =
groupPermissionParents[permission as keyof typeof groupPermissionParents];
const children = groupPermissionParents[permission as keyof typeof groupPermissionParents];
if (children) {
getPermissionsInner(permissionSet, children);
}
@@ -50,9 +42,7 @@ const getPermissionsInner = (
});
};
export const getPermissionsWithChildren = (
permissions: GroupPermissionKey[],
) => {
export const getPermissionsWithChildren = (permissions: GroupPermissionKey[]) => {
const permissionSet = new Set<GroupPermissionKey>();
getPermissionsInner(permissionSet, permissions);
return Array.from(permissionSet);
@@ -66,19 +56,14 @@ export type GroupPermissionKey = {
: key;
}[keyof GroupPermissions];
export const groupPermissionKeys = objectKeys(groupPermissions).reduce(
(acc, key) => {
const item = groupPermissions[key];
if (typeof item !== "boolean") {
acc.push(
...item.map((subKey) => `${key}-${subKey}` as GroupPermissionKey),
);
} else {
acc.push(key as GroupPermissionKey);
}
return acc;
},
[] as GroupPermissionKey[],
);
export const groupPermissionKeys = objectKeys(groupPermissions).reduce((acc, key) => {
const item = groupPermissions[key];
if (typeof item !== "boolean") {
acc.push(...item.map((subKey) => `${key}-${subKey}` as GroupPermissionKey));
} else {
acc.push(key as GroupPermissionKey);
}
return acc;
}, [] as GroupPermissionKey[]);
export type BoardPermission = (typeof boardPermissions)[number];
@@ -1,47 +1,22 @@
import { describe, expect, test } from "vitest";
import type { GroupPermissionKey } from "../permissions";
import {
getPermissionsWithChildren,
getPermissionsWithParents,
} from "../permissions";
import { getPermissionsWithChildren, getPermissionsWithParents } from "../permissions";
describe("getPermissionsWithParents should return the correct permissions", () => {
test.each([
[
["board-view-all"],
["board-view-all", "board-modify-all", "board-full-access", "admin"],
],
[["board-view-all"], ["board-view-all", "board-modify-all", "board-full-access", "admin"]],
[["board-modify-all"], ["board-modify-all", "board-full-access", "admin"]],
[["board-create"], ["board-create", "board-full-access", "admin"]],
[["board-full-access"], ["board-full-access", "admin"]],
[
["integration-use-all"],
[
"integration-use-all",
"integration-interact-all",
"integration-full-access",
"admin",
],
],
[
["integration-create"],
["integration-create", "integration-full-access", "admin"],
],
[
["integration-interact-all"],
["integration-interact-all", "integration-full-access", "admin"],
],
[["integration-use-all"], ["integration-use-all", "integration-interact-all", "integration-full-access", "admin"]],
[["integration-create"], ["integration-create", "integration-full-access", "admin"]],
[["integration-interact-all"], ["integration-interact-all", "integration-full-access", "admin"]],
[["integration-full-access"], ["integration-full-access", "admin"]],
[["admin"], ["admin"]],
] satisfies [GroupPermissionKey[], GroupPermissionKey[]][])(
"expect %s to return %s",
(input, expectedOutput) => {
expect(getPermissionsWithParents(input)).toEqual(
expect.arrayContaining(expectedOutput),
);
},
);
] satisfies [GroupPermissionKey[], GroupPermissionKey[]][])("expect %s to return %s", (input, expectedOutput) => {
expect(getPermissionsWithParents(input)).toEqual(expect.arrayContaining(expectedOutput));
});
});
describe("getPermissionsWithChildren should return the correct permissions", () => {
@@ -49,24 +24,11 @@ describe("getPermissionsWithChildren should return the correct permissions", ()
[["board-view-all"], ["board-view-all"]],
[["board-modify-all"], ["board-view-all", "board-modify-all"]],
[["board-create"], ["board-create"]],
[
["board-full-access"],
["board-full-access", "board-modify-all", "board-view-all"],
],
[["board-full-access"], ["board-full-access", "board-modify-all", "board-view-all"]],
[["integration-use-all"], ["integration-use-all"]],
[["integration-create"], ["integration-create"]],
[
["integration-interact-all"],
["integration-interact-all", "integration-use-all"],
],
[
["integration-full-access"],
[
"integration-full-access",
"integration-interact-all",
"integration-use-all",
],
],
[["integration-interact-all"], ["integration-interact-all", "integration-use-all"]],
[["integration-full-access"], ["integration-full-access", "integration-interact-all", "integration-use-all"]],
[
["admin"],
[
@@ -79,12 +41,7 @@ describe("getPermissionsWithChildren should return the correct permissions", ()
"integration-use-all",
],
],
] satisfies [GroupPermissionKey[], GroupPermissionKey[]][])(
"expect %s to return %s",
(input, expectedOutput) => {
expect(getPermissionsWithChildren(input)).toEqual(
expect.arrayContaining(expectedOutput),
);
},
);
] satisfies [GroupPermissionKey[], GroupPermissionKey[]][])("expect %s to return %s", (input, expectedOutput) => {
expect(getPermissionsWithChildren(input)).toEqual(expect.arrayContaining(expectedOutput));
});
});
+1 -8
View File
@@ -1,9 +1,2 @@
export const widgetKinds = [
"clock",
"weather",
"app",
"iframe",
"video",
"notebook",
] as const;
export const widgetKinds = ["clock", "weather", "app", "iframe", "video", "notebook"] as const;
export type WidgetKind = (typeof widgetKinds)[number];
+3 -1
View File
@@ -33,6 +33,8 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@mantine/form": "^7.9.1"
"@mantine/form": "^7.9.2",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0"
}
}
+26 -1
View File
@@ -1 +1,26 @@
export const name = "form";
import { useForm, zodResolver } from "@mantine/form";
import { useI18n } from "@homarr/translation/client";
import { z } from "@homarr/validation";
import type { AnyZodObject, ZodEffects, ZodIntersection } from "@homarr/validation";
import { zodErrorMap } from "@homarr/validation/form";
export const useZodForm = <
TSchema extends AnyZodObject | ZodEffects<AnyZodObject> | ZodIntersection<AnyZodObject, AnyZodObject>,
>(
schema: TSchema,
options: Omit<
Exclude<Parameters<typeof useForm<z.infer<TSchema>>>[0], undefined>,
"validate" | "validateInputOnBlur" | "validateInputOnChange"
>,
) => {
const t = useI18n();
z.setErrorMap(zodErrorMap(t));
return useForm<z.infer<TSchema>>({
...options,
validateInputOnBlur: true,
validateInputOnChange: true,
validate: zodResolver(schema),
});
};
+102
View File
@@ -0,0 +1,102 @@
import type { TranslationObject } from "@homarr/translation";
import type { ErrorMapCtx, z, ZodTooBigIssue, ZodTooSmallIssue } from "@homarr/validation";
import { ZodIssueCode } from "@homarr/validation";
const handleStringError = (issue: z.ZodInvalidStringIssue) => {
if (typeof issue.validation === "object") {
// Check if object contains startsWith / endsWith key to determine the error type. If not, it's an includes error. (see type of issue.validation)
if ("startsWith" in issue.validation) {
return {
key: "errors.string.startsWith",
params: {
startsWith: issue.validation.startsWith,
},
} as const;
} else if ("endsWith" in issue.validation) {
return {
key: "errors.string.endsWith",
params: {
endsWith: issue.validation.endsWith,
},
} as const;
}
return {
key: "errors.invalid_string.includes",
params: {
includes: issue.validation.includes,
},
} as const;
}
return {
message: issue.message,
};
};
const handleTooSmallError = (issue: ZodTooSmallIssue) => {
if (issue.type !== "string" && issue.type !== "number") {
return {
message: issue.message,
};
}
return {
key: `errors.tooSmall.${issue.type}`,
params: {
minimum: issue.minimum,
count: issue.minimum,
},
} as const;
};
const handleTooBigError = (issue: ZodTooBigIssue) => {
if (issue.type !== "string" && issue.type !== "number") {
return {
message: issue.message,
};
}
return {
key: `errors.tooBig.${issue.type}`,
params: {
maximum: issue.maximum,
count: issue.maximum,
},
} as const;
};
export const handleZodError = (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => {
if (ctx.defaultError === "Required") {
return {
key: "errors.required",
params: {},
} as const;
}
if (issue.code === ZodIssueCode.invalid_string) {
return handleStringError(issue);
}
if (issue.code === ZodIssueCode.too_small) {
return handleTooSmallError(issue);
}
if (issue.code === ZodIssueCode.too_big) {
return handleTooBigError(issue);
}
if (issue.code === ZodIssueCode.custom && issue.params?.i18n) {
const { i18n } = issue.params as CustomErrorParams;
return {
key: `errors.custom.${i18n.key}`,
} as const;
}
return {
message: issue.message,
};
};
export interface CustomErrorParams {
i18n: {
key: keyof TranslationObject["common"]["zod"]["errors"]["custom"];
params?: Record<string, unknown>;
};
}
+4 -12
View File
@@ -8,9 +8,7 @@ const repositories = [
"walkxcode/dashboard-icons",
undefined,
new URL("https://github.com/walkxcode/dashboard-icons"),
new URL(
"https://api.github.com/repos/walkxcode/dashboard-icons/git/trees/main?recursive=true",
),
new URL("https://api.github.com/repos/walkxcode/dashboard-icons/git/trees/main?recursive=true"),
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/{0}",
),
new JsdelivrIconRepository(
@@ -18,9 +16,7 @@ const repositories = [
"PapirusDevelopmentTeam/papirus-icon-theme",
"GPL-3.0",
new URL("https://github.com/PapirusDevelopmentTeam/papirus-icon-theme"),
new URL(
"https://data.jsdelivr.com/v1/packages/gh/PapirusDevelopmentTeam/papirus_icons@master?structure=flat",
),
new URL("https://data.jsdelivr.com/v1/packages/gh/PapirusDevelopmentTeam/papirus_icons@master?structure=flat"),
"https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons/{0}",
),
new JsdelivrIconRepository(
@@ -28,15 +24,11 @@ const repositories = [
"loganmarchione/homelab-svg-assets",
"MIT",
new URL("https://github.com/loganmarchione/homelab-svg-assets"),
new URL(
"https://data.jsdelivr.com/v1/packages/gh/loganmarchione/homelab-svg-assets@main?structure=flat",
),
new URL("https://data.jsdelivr.com/v1/packages/gh/loganmarchione/homelab-svg-assets@main?structure=flat"),
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/{0}",
),
];
export const fetchIconsAsync = async (): Promise<RepositoryIconGroup[]> => {
return await Promise.all(
repositories.map(async (repository) => await repository.getAllIconsAsync()),
);
return await Promise.all(repositories.map(async (repository) => await repository.getAllIconsAsync()));
};
@@ -11,14 +11,7 @@ export class GitHubIconRepository extends IconRepository {
public readonly repositoryIndexingUrl?: URL,
public readonly repositoryBlobUrlTemplate?: string,
) {
super(
name,
slug,
license,
repositoryUrl,
repositoryIndexingUrl,
repositoryBlobUrlTemplate,
);
super(name, slug, license, repositoryUrl, repositoryIndexingUrl, repositoryBlobUrlTemplate);
}
protected async getAllIconsInternalAsync(): Promise<RepositoryIconGroup> {
@@ -33,18 +26,13 @@ export class GitHubIconRepository extends IconRepository {
success: true,
icons: listOfFiles.tree
.filter((treeItem) =>
this.allowedImageFileTypes.some((allowedExtension) =>
treeItem.path.includes(allowedExtension),
),
this.allowedImageFileTypes.some((allowedExtension) => treeItem.path.includes(allowedExtension)),
)
.map((treeItem) => {
const fileNameWithExtension =
this.getFileNameWithoutExtensionFromPath(treeItem.path);
const fileNameWithExtension = this.getFileNameWithoutExtensionFromPath(treeItem.path);
return {
imageUrl: new URL(
this.repositoryBlobUrlTemplate!.replace("{0}", treeItem.path),
),
imageUrl: new URL(this.repositoryBlobUrlTemplate!.replace("{0}", treeItem.path)),
fileNameWithExtension: fileNameWithExtension,
local: false,
sizeInBytes: treeItem.size,
@@ -19,9 +19,7 @@ export abstract class IconRepository {
try {
return await this.getAllIconsInternalAsync();
} catch (err) {
logger.error(
`Unable to request icons from repository "${this.slug}": ${JSON.stringify(err)}`,
);
logger.error(`Unable to request icons from repository "${this.slug}": ${JSON.stringify(err)}`);
return {
success: false,
icons: [],
@@ -11,14 +11,7 @@ export class JsdelivrIconRepository extends IconRepository {
public readonly repositoryIndexingUrl: URL,
public readonly repositoryBlobUrlTemplate: string,
) {
super(
name,
slug,
license,
repositoryUrl,
repositoryIndexingUrl,
repositoryBlobUrlTemplate,
);
super(name, slug, license, repositoryUrl, repositoryIndexingUrl, repositoryBlobUrlTemplate);
}
protected async getAllIconsInternalAsync(): Promise<RepositoryIconGroup> {
@@ -29,18 +22,13 @@ export class JsdelivrIconRepository extends IconRepository {
success: true,
icons: listOfFiles.files
.filter((file) =>
this.allowedImageFileTypes.some((allowedImageFileType) =>
file.name.includes(allowedImageFileType),
),
this.allowedImageFileTypes.some((allowedImageFileType) => file.name.includes(allowedImageFileType)),
)
.map((file) => {
const fileNameWithExtension =
this.getFileNameWithoutExtensionFromPath(file.name);
const fileNameWithExtension = this.getFileNameWithoutExtensionFromPath(file.name);
return {
imageUrl: new URL(
this.repositoryBlobUrlTemplate.replace("{0}", file.name),
),
imageUrl: new URL(this.repositoryBlobUrlTemplate.replace("{0}", file.name)),
fileNameWithExtension: fileNameWithExtension,
local: false,
sizeInBytes: file.size,
+1 -5
View File
@@ -7,11 +7,7 @@ const logMessageFormat = format.printf(({ level, message, timestamp }) => {
});
const logger = winston.createLogger({
format: format.combine(
format.colorize(),
format.timestamp(),
logMessageFormat,
),
format: format.combine(format.colorize(), format.timestamp(), logMessageFormat),
transports: [new transports.Console(), new RedisTransport()],
});
+41 -64
View File
@@ -3,10 +3,7 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react";
import type { ButtonProps, GroupProps } from "@mantine/core";
import { Box, Button, Group } from "@mantine/core";
import type {
stringOrTranslation,
TranslationFunction,
} from "@homarr/translation";
import type { stringOrTranslation, TranslationFunction } from "@homarr/translation";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
@@ -31,70 +28,50 @@ export interface ConfirmModalProps {
};
}
export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(
({ actions, innerProps }) => {
const [loading, setLoading] = useState(false);
const t = useI18n();
const {
children,
onConfirm,
onCancel,
cancelProps,
confirmProps,
groupProps,
labels,
} = innerProps;
export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(({ actions, innerProps }) => {
const [loading, setLoading] = useState(false);
const t = useI18n();
const { children, onConfirm, onCancel, cancelProps, confirmProps, groupProps, labels } = innerProps;
const closeOnConfirm = innerProps.closeOnConfirm ?? true;
const closeOnCancel = innerProps.closeOnCancel ?? true;
const closeOnConfirm = innerProps.closeOnConfirm ?? true;
const closeOnCancel = innerProps.closeOnCancel ?? true;
const cancelLabel =
labels?.cancel ?? ((t: TranslationFunction) => t("common.action.cancel"));
const confirmLabel =
labels?.confirm ??
((t: TranslationFunction) => t("common.action.confirm"));
const cancelLabel = labels?.cancel ?? ((t: TranslationFunction) => t("common.action.cancel"));
const confirmLabel = labels?.confirm ?? ((t: TranslationFunction) => t("common.action.confirm"));
const handleCancel = useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
typeof cancelProps?.onClick === "function" &&
cancelProps?.onClick(event);
typeof onCancel === "function" && (await onCancel());
closeOnCancel && actions.closeModal();
},
[cancelProps?.onClick, onCancel, actions.closeModal],
);
const handleCancel = useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
typeof cancelProps?.onClick === "function" && cancelProps?.onClick(event);
typeof onCancel === "function" && (await onCancel());
closeOnCancel && actions.closeModal();
},
[cancelProps?.onClick, onCancel, actions.closeModal],
);
const handleConfirm = useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
setLoading(true);
typeof confirmProps?.onClick === "function" &&
confirmProps?.onClick(event);
typeof onConfirm === "function" && (await onConfirm());
closeOnConfirm && actions.closeModal();
setLoading(false);
},
[confirmProps?.onClick, onConfirm, actions.closeModal],
);
const handleConfirm = useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
setLoading(true);
typeof confirmProps?.onClick === "function" && confirmProps?.onClick(event);
typeof onConfirm === "function" && (await onConfirm());
closeOnConfirm && actions.closeModal();
setLoading(false);
},
[confirmProps?.onClick, onConfirm, actions.closeModal],
);
return (
<>
{children && <Box mb="md">{children}</Box>}
return (
<>
{children && <Box mb="md">{children}</Box>}
<Group justify="flex-end" {...groupProps}>
<Button variant="default" {...cancelProps} onClick={handleCancel}>
{cancelProps?.children || translateIfNecessary(t, cancelLabel)}
</Button>
<Group justify="flex-end" {...groupProps}>
<Button variant="default" {...cancelProps} onClick={handleCancel}>
{cancelProps?.children || translateIfNecessary(t, cancelLabel)}
</Button>
<Button
{...confirmProps}
onClick={handleConfirm}
color="red.9"
loading={loading}
>
{confirmProps?.children || translateIfNecessary(t, confirmLabel)}
</Button>
</Group>
</>
);
},
).withOptions({});
<Button {...confirmProps} onClick={handleConfirm} color="red.9" loading={loading}>
{confirmProps?.children || translateIfNecessary(t, confirmLabel)}
</Button>
</Group>
</>
);
}).withOptions({});
+1 -3
View File
@@ -1,8 +1,6 @@
import type { CreateModalOptions, ModalComponent } from "./type";
export const createModal = <TInnerProps>(
component: ModalComponent<TInnerProps>,
) => {
export const createModal = <TInnerProps>(component: ModalComponent<TInnerProps>) => {
return {
withOptions: (options: Partial<CreateModalOptions>) => {
return {
+10 -30
View File
@@ -1,15 +1,7 @@
"use client";
import type { PropsWithChildren } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useReducer,
useRef,
useState,
} from "react";
import { createContext, useCallback, useContext, useEffect, useReducer, useRef, useState } from "react";
import { getDefaultZIndex, Modal } from "@mantine/core";
import { randomId } from "@mantine/hooks";
@@ -76,19 +68,12 @@ export const ModalProvider = ({ children }: PropsWithChildren) => {
[closeModal, state.current?.id],
);
const activeModals = state.modals.filter(
(modal) => modal.id === state.current?.id || modal.props.keepMounted,
);
const activeModals = state.modals.filter((modal) => modal.id === state.current?.id || modal.props.keepMounted);
return (
<ModalContext.Provider value={{ openModalInner, closeModal }}>
{activeModals.map((modal) => (
<ActiveModal
key={modal.id}
modal={modal}
state={state}
handleCloseModal={handleCloseModal}
/>
<ActiveModal key={modal.id} modal={modal} state={state} handleCloseModal={handleCloseModal} />
))}
{children}
@@ -112,14 +97,12 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
setTimeout(() => setOpened(true), 0);
}, []);
const { defaultTitle: _ignored, ...otherModalProps } =
modal.reference.modalProps;
const { defaultTitle: _ignored, ...otherModalProps } = modal.reference.modalProps;
return (
<Modal
key={modal.id}
zIndex={getDefaultZIndex("modal") + 1}
display={modal.id === state.current?.id ? undefined : "none"}
style={{
userSelect: modal.id === state.current?.id ? undefined : "none",
}}
@@ -128,6 +111,9 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
fontSize: "1.25rem",
fontWeight: 500,
},
inner: {
display: modal.id === state.current?.id ? undefined : "none",
},
}}
trapFocus={modal.id === state.current?.id}
{...otherModalProps}
@@ -145,18 +131,13 @@ interface OpenModalOptions {
title?: stringOrTranslation;
}
export const useModalAction = <TModal extends ModalDefinition>(
modal: TModal,
) => {
export const useModalAction = <TModal extends ModalDefinition>(modal: TModal) => {
const context = useContext(ModalContext);
if (!context) throw new Error("ModalContext is not provided");
return {
openModal: (
innerProps: inferInnerProps<TModal>,
options: OpenModalOptions | void,
) => {
openModal: (innerProps: inferInnerProps<TModal>, options: OpenModalOptions | void) => {
context.openModalInner({ modal, innerProps, options: options ?? {} });
},
};
@@ -166,7 +147,6 @@ export const useConfirmModal = () => {
const { openModal } = useModalAction(ConfirmModal);
return {
openConfirmModal: (props: ConfirmModalProps) =>
openModal(props, { title: props.title }),
openConfirmModal: (props: ConfirmModalProps) => openModal(props, { title: props.title }),
};
};
+3 -10
View File
@@ -39,10 +39,7 @@ interface CloseAllAction {
canceled?: boolean;
}
export const modalReducer = (
state: ModalsState,
action: OpenAction | CloseAction | CloseAllAction,
): ModalsState => {
export const modalReducer = (state: ModalsState, action: OpenAction | CloseAction | CloseAllAction): ModalsState => {
switch (action.type) {
case "OPEN": {
const newModal = {
@@ -62,9 +59,7 @@ export const modalReducer = (
modal.props.onClose?.();
const remainingModals = state.modals.filter(
(modal) => modal.id !== action.modalId,
);
const remainingModals = state.modals.filter((modal) => modal.id !== action.modalId);
return {
current: remainingModals[remainingModals.length - 1] || state.current,
@@ -95,9 +90,7 @@ export const modalReducer = (
}
};
const getModal = <TModal extends ModalDefinition>(
modal: ModalState<TModal>,
) => {
const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) => {
const ModalContent = modal.modal.component;
const { innerProps, ...rest } = modal.props;
+1 -1
View File
@@ -28,7 +28,7 @@
"typescript": "^5.4.5"
},
"dependencies": {
"@mantine/notifications": "^7.9.1",
"@mantine/notifications": "^7.9.2",
"@homarr/ui": "workspace:^0.1.0"
},
"eslintConfig": {
+1 -3
View File
@@ -1,8 +1,6 @@
import { createQueueChannel, createSubPubChannel } from "./lib/channel";
export const exampleChannel = createSubPubChannel<{ message: string }>(
"example",
);
export const exampleChannel = createSubPubChannel<{ message: string }>("example");
export const queueChannel = createQueueChannel<{
name: string;
executionDate: Date;
+29 -3
View File
@@ -32,9 +32,7 @@ export const createSubPubChannel = <TData>(name: string) => {
if (!err) {
return;
}
logger.error(
`Error with channel '${channelName}': ${err.name} (${err.message})`,
);
logger.error(`Error with channel '${channelName}': ${err.name} (${err.message})`);
});
subscriber.on("message", (channel, message) => {
if (channel !== channelName) return; // TODO: check if this is necessary - it should be handled by the redis client
@@ -53,6 +51,34 @@ export const createSubPubChannel = <TData>(name: string) => {
};
};
const cacheClient = createRedisConnection();
/**
* Creates a new cache channel.
* @param name name of the channel
* @returns cache channel object
*/
export const createCacheChannel = <TData>(name: string) => {
const cacheChannelName = `cache:${name}`;
return {
/**
* Get the data from the cache channel.
* @returns data or undefined if not found
*/
getAsync: async () => {
const data = await cacheClient.get(cacheChannelName);
return data ? superjson.parse<TData>(data) : undefined;
},
/**
* Set the data in the cache channel.
* @param data data to be stored in the cache channel
*/
setAsync: async (data: TData) => {
await cacheClient.set(cacheChannelName, superjson.stringify(data));
},
};
};
const queueClient = createRedisConnection();
type WithId<TItem> = TItem & { _id: string };
+1
View File
@@ -0,0 +1 @@
export * from "./src";
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@homarr/server-settings",
"private": true,
"version": "0.1.0",
"exports": {
".": "./index.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"type": "module",
"license": "MIT",
"scripts": {
"clean": "rm -rf .turbo node_modules",
"lint": "eslint .",
"format": "prettier --check . --ignore-path ../../.gitignore",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^8.57.0",
"typescript": "^5.4.5"
},
"eslintConfig": {
"extends": [
"@homarr/eslint-config/base"
]
},
"prettier": "@homarr/prettier-config"
}
+16
View File
@@ -0,0 +1,16 @@
export const defaultServerSettingsKeys = ["analytics"] as const;
export type ServerSettingsRecord = {
[key in (typeof defaultServerSettingsKeys)[number]]: Record<string, unknown>;
};
export const defaultServerSettings = {
analytics: {
enableGeneral: true,
enableWidgetData: false,
enableIntegrationData: false,
enableUserData: false,
},
} satisfies ServerSettingsRecord;
export type ServerSettings = typeof defaultServerSettings;
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}
+1 -2
View File
@@ -130,8 +130,7 @@ const ColorSchemeButton = () => {
{
id: "toggle-color-scheme",
title: (t) => t("common.colorScheme.toggle.title"),
description: (t) =>
t(`common.colorScheme.toggle.${colorScheme}.description`),
description: (t) => t(`common.colorScheme.toggle.${colorScheme}.description`),
icon: colorScheme === "light" ? IconSun : IconMoon,
group: "action",
type: "button",
+1 -1
View File
@@ -34,7 +34,7 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@mantine/spotlight": "^7.9.1",
"@mantine/spotlight": "^7.9.2",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0"
}
+4 -17
View File
@@ -2,17 +2,10 @@ import { Chip } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import {
selectNextAction,
selectPreviousAction,
spotlightStore,
triggerSelectedAction,
} from "./spotlight-store";
import { selectNextAction, selectPreviousAction, spotlightStore, triggerSelectedAction } from "./spotlight-store";
import type { SpotlightActionGroup } from "./type";
const disableArrowUpAndDown = (
event: React.KeyboardEvent<HTMLInputElement>,
) => {
const disableArrowUpAndDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "ArrowDown") {
selectNextAction(spotlightStore);
event.preventDefault();
@@ -27,8 +20,7 @@ const disableArrowUpAndDown = (
const focusActiveByDefault = (event: React.FocusEvent<HTMLInputElement>) => {
const relatedTarget = event.relatedTarget;
const isPreviousTargetRadio =
relatedTarget && "type" in relatedTarget && relatedTarget.type === "radio";
const isPreviousTargetRadio = relatedTarget && "type" in relatedTarget && relatedTarget.type === "radio";
if (isPreviousTargetRadio) return;
const group = event.currentTarget.parentElement?.parentElement;
@@ -45,12 +37,7 @@ interface Props {
export const GroupChip = ({ group }: Props) => {
const t = useScopedI18n("common.search.group");
return (
<Chip
key={group}
value={group}
onFocus={focusActiveByDefault}
onKeyDown={disableArrowUpAndDown}
>
<Chip key={group} value={group} onFocus={focusActiveByDefault} onKeyDown={disableArrowUpAndDown}>
{t(group)}
</Chip>
);
+7 -37
View File
@@ -3,10 +3,7 @@
import { useCallback, useState } from "react";
import Link from "next/link";
import { Center, Chip, Divider, Flex, Group, Text } from "@mantine/core";
import {
Spotlight as MantineSpotlight,
SpotlightAction,
} from "@mantine/spotlight";
import { Spotlight as MantineSpotlight, SpotlightAction } from "@mantine/spotlight";
import { IconSearch } from "@tabler/icons-react";
import { useAtomValue } from "jotai";
@@ -41,11 +38,7 @@ export const Spotlight = () => {
const renderRoot =
item.type === "link"
? (props: Record<string, unknown>) => (
<Link
href={prepareHref(item.href, query)}
target={item.openInNewTab ? "_blank" : undefined}
{...props}
/>
<Link href={prepareHref(item.href, query)} target={item.openInNewTab ? "_blank" : undefined} {...props} />
)
: undefined;
@@ -60,14 +53,7 @@ export const Spotlight = () => {
{item.icon && (
<Center w={50} h={50}>
{typeof item.icon !== "string" && <item.icon size={24} />}
{typeof item.icon === "string" && (
<img
src={item.icon}
alt={item.title}
width={24}
height={24}
/>
)}
{typeof item.icon === "string" && <img src={item.icon} alt={item.title} width={24} height={24} />}
</Center>
)}
@@ -94,11 +80,7 @@ export const Spotlight = () => {
);
return (
<MantineSpotlight.Root
query={query}
onQueryChange={setQuery}
store={spotlightStore}
>
<MantineSpotlight.Root query={query} onQueryChange={setQuery} store={spotlightStore}>
<MantineSpotlight.Search
placeholder={t("common.rtl", {
value: t("common.search.placeholder"),
@@ -119,13 +101,7 @@ export const Spotlight = () => {
</Group>
<MantineSpotlight.ActionsList>
{items.length > 0 ? (
items
) : (
<MantineSpotlight.Empty>
{t("common.search.nothingFound")}
</MantineSpotlight.Empty>
)}
{items.length > 0 ? items : <MantineSpotlight.Empty>{t("common.search.nothingFound")}</MantineSpotlight.Empty>}
</MantineSpotlight.ActionsList>
</MantineSpotlight.Root>
);
@@ -135,10 +111,7 @@ const prepareHref = (href: string, query: string) => {
return href.replace("%s", query);
};
const translateIfNecessary = (
value: string | ((t: TranslationFunction) => string),
t: TranslationFunction,
) => {
const translateIfNecessary = (value: string | ((t: TranslationFunction) => string), t: TranslationFunction) => {
if (typeof value === "function") {
return value(t);
}
@@ -146,10 +119,7 @@ const translateIfNecessary = (
return value;
};
const prepareAction = (
action: SpotlightActionData,
t: TranslationFunction,
) => ({
const prepareAction = (action: SpotlightActionData, t: TranslationFunction) => ({
...action,
title: translateIfNecessary(action.title, t),
description: translateIfNecessary(action.description, t),
+2 -5
View File
@@ -7,9 +7,7 @@ import type { SpotlightActionData, SpotlightActionGroup } from "./type";
const defaultGroups = ["all", "web", "action"] as const;
const reversedDefaultGroups = [...defaultGroups].reverse() as string[];
const actionsAtom = atom<Record<string, readonly SpotlightActionData[]>>({});
export const actionsAtomRead = atom((get) =>
Object.values(get(actionsAtom)).flatMap((item) => item),
);
export const actionsAtomRead = atom((get) => Object.values(get(actionsAtom)).flatMap((item) => item));
export const groupsAtomRead = atom((get) =>
Array.from(
@@ -43,8 +41,7 @@ export const useRegisterSpotlightActions = (
const setActions = useSetAtom(actionsAtom);
// Use deep compare effect if there are dependencies for the actions, this supports deep compare of the action dependencies
const useSpecificEffect =
dependencies.length >= 1 ? useDeepCompareEffect : useEffect;
const useSpecificEffect = dependencies.length >= 1 ? useDeepCompareEffect : useEffect;
useSpecificEffect(() => {
if (!registrations.has(key) || dependencies.length >= 1) {
+4 -9
View File
@@ -13,12 +13,9 @@ export const setSelectedAction = (index: number, store: SpotlightStore) => {
export const selectAction = (index: number, store: SpotlightStore): number => {
const state = store.getState();
const actionsList = document.getElementById(state.listId);
const selected =
actionsList?.querySelector<HTMLButtonElement>("[data-selected]");
const actions =
actionsList?.querySelectorAll<HTMLButtonElement>("[data-action]") ?? [];
const nextIndex =
index === -1 ? actions.length - 1 : index === actions.length ? 0 : index;
const selected = actionsList?.querySelector<HTMLButtonElement>("[data-selected]");
const actions = actionsList?.querySelectorAll<HTMLButtonElement>("[data-action]") ?? [];
const nextIndex = index === -1 ? actions.length - 1 : index === actions.length ? 0 : index;
const selectedIndex = clamp(nextIndex, 0, actions.length - 1);
selected?.removeAttribute("data-selected");
@@ -38,8 +35,6 @@ export const selectPreviousAction = (store: SpotlightStore) => {
};
export const triggerSelectedAction = (store: SpotlightStore) => {
const state = store.getState();
const selected = document.querySelector<HTMLButtonElement>(
`#${state.listId} [data-selected]`,
);
const selected = document.querySelector<HTMLButtonElement>(`#${state.listId} [data-selected]`);
selected?.click();
};
+2 -6
View File
@@ -1,11 +1,7 @@
import type {
TranslationFunction,
TranslationObject,
} from "@homarr/translation";
import type { TranslationFunction, TranslationObject } from "@homarr/translation";
import type { TablerIcon } from "@homarr/ui";
export type SpotlightActionGroup =
keyof TranslationObject["common"]["search"]["group"];
export type SpotlightActionGroup = keyof TranslationObject["common"]["search"]["group"];
interface BaseSpotlightAction {
id: string;
+1 -1
View File
@@ -5,7 +5,7 @@ import { createI18nClient } from "next-international/client";
import { languageMapping } from "./lang";
import enTranslation from "./lang/en";
export const { useI18n, useScopedI18n, I18nProviderClient } = createI18nClient(
export const { useI18n, useScopedI18n, useCurrentLocale, useChangeLocale, I18nProviderClient } = createI18nClient(
languageMapping(),
{
fallbackLocale: enTranslation,
+2 -4
View File
@@ -1,6 +1,7 @@
import type { stringOrTranslation, TranslationFunction } from "./type";
export * from "./type";
export * from "./locale-attributes";
export const supportedLanguages = ["en", "de"] as const;
export type SupportedLanguage = (typeof supportedLanguages)[number];
@@ -8,10 +9,7 @@ export type SupportedLanguage = (typeof supportedLanguages)[number];
export const defaultLocale = "en";
export { languageMapping } from "./lang";
export const translateIfNecessary = (
t: TranslationFunction,
value: stringOrTranslation | undefined,
) => {
export const translateIfNecessary = (t: TranslationFunction, value: stringOrTranslation | undefined) => {
if (typeof value === "function") {
return value(t);
}
+2 -6
View File
@@ -6,12 +6,8 @@ export const languageMapping = () => {
const mapping: Record<string, unknown> = {};
for (const language of supportedLanguages) {
mapping[language] = () =>
import(`./lang/${language}`) as ReturnType<typeof enTranslations>;
mapping[language] = () => import(`./lang/${language}`) as ReturnType<typeof enTranslations>;
}
return mapping as Record<
(typeof supportedLanguages)[number],
() => ReturnType<typeof enTranslations>
>;
return mapping as Record<(typeof supportedLanguages)[number], () => ReturnType<typeof enTranslations>>;
};
+3 -6
View File
@@ -93,8 +93,7 @@ export default {
},
testConnection: {
action: "Verbindung überprüfen",
alertNotice:
"Der Button zum Speichern wird aktiviert, sobald die Verbindung erfolgreich überprüft wurde",
alertNotice: "Der Button zum Speichern wird aktiviert, sobald die Verbindung erfolgreich überprüft wurde",
notification: {
success: {
title: "Verbindung erfolgreich",
@@ -121,8 +120,7 @@ export default {
secrets: {
title: "Zugangsdaten",
lastUpdated: "Zuletzt geändert {date}",
secureNotice:
"Diese Zugangsdaten können nach der Erstellung nicht mehr ausgelesen werden",
secureNotice: "Diese Zugangsdaten können nach der Erstellung nicht mehr ausgelesen werden",
reset: {
title: "Zugangsdaten zurücksetzen",
message: "Möchtest du diese Zugangsdaten wirklich zurücksetzen?",
@@ -172,8 +170,7 @@ export default {
option: {
is24HourFormat: {
label: "24-Stunden Format",
description:
"Verwende das 24-Stunden Format anstelle des 12-Stunden Formats",
description: "Verwende das 24-Stunden Format anstelle des 12-Stunden Formats",
},
isLocaleTime: {
label: "Lokale Zeit verwenden",
+99 -42
View File
@@ -121,8 +121,7 @@ export default {
label: "Delete user permanently",
description:
"Deletes this user including their preferences. Will not delete any boards. User will not be notified.",
confirm:
"Are you sure, that you want to delete the user {username} with his preferences?",
confirm: "Are you sure, that you want to delete the user {username} with his preferences?",
},
select: {
label: "Select user",
@@ -147,8 +146,7 @@ export default {
item: {
admin: {
label: "Administrator",
description:
"Members with this permission have full access to all features and settings",
description: "Members with this permission have full access to all features and settings",
},
},
},
@@ -165,8 +163,7 @@ export default {
},
"modify-all": {
label: "Modify all boards",
description:
"Allow members to modify all boards (Does not include access control and danger zone)",
description: "Allow members to modify all boards (Does not include access control and danger zone)",
},
"full-access": {
label: "Full board access",
@@ -184,8 +181,7 @@ export default {
},
"use-all": {
label: "Use all integrations",
description:
"Allows members to add any integrations to their boards",
description: "Allows members to add any integrations to their boards",
},
"interact-all": {
label: "Interact with any integration",
@@ -193,8 +189,7 @@ export default {
},
"full-access": {
label: "Full integration access",
description:
"Allow members to manage, use and interact with any integration",
description: "Allow members to manage, use and interact with any integration",
},
},
},
@@ -214,8 +209,7 @@ export default {
transfer: {
label: "Transfer ownership",
description: "Transfer ownership of this group to another user.",
confirm:
"Are you sure you want to transfer ownership for the group {name} to {username}?",
confirm: "Are you sure you want to transfer ownership for the group {name} to {username}?",
notification: {
success: {
message: "Transfered group {group} successfully to {user}",
@@ -234,8 +228,7 @@ export default {
},
delete: {
label: "Delete group",
description:
"Once you delete a group, there is no going back. Please be certain.",
description: "Once you delete a group, there is no going back. Please be certain.",
confirm: "Are you sure you want to delete the group {name}?",
notification: {
success: {
@@ -386,8 +379,7 @@ export default {
},
testConnection: {
action: "Test connection",
alertNotice:
"The Save button is enabled once a successful connection is established",
alertNotice: "The Save button is enabled once a successful connection is established",
notification: {
success: {
title: "Connection successful",
@@ -457,8 +449,7 @@ export default {
checkoutDocs: "Check out the documentation",
},
iconPicker: {
header:
"Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
header: "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
},
notification: {
create: {
@@ -481,6 +472,10 @@ export default {
multiSelect: {
placeholder: "Pick one or more values",
},
multiText: {
placeholder: "Add more values",
addLabel: `Add {value}`,
},
select: {
placeholder: "Pick value",
badge: {
@@ -505,7 +500,7 @@ export default {
preferences: "Your preferences",
logout: "Logout",
login: "Login",
navigateDefaultBoard: "Navigate to default board",
homeBoard: "Your home board",
loggedOut: "Logged out",
},
},
@@ -515,6 +510,31 @@ export default {
show: "Show preview",
hide: "Hide preview",
},
zod: {
errors: {
default: "This field is invalid",
required: "This field is required",
string: {
startsWith: "This field must start with {startsWith}",
endsWith: "This field must end with {endsWith}",
includes: "This field must include {includes}",
invalidEmail: "This field must be a valid email",
},
tooSmall: {
string: "This field must be at least {minimum} characters long",
number: "This field must be greater than or equal to {minimum}",
},
tooBig: {
string: "This field must be at most {maximum} characters long",
number: "This field must be less than or equal to {maximum}",
},
custom: {
passwordsDoNotMatch: "Passwords do not match",
boardAlreadyExists: "A board with this name already exists",
// TODO: Add custom error messages
},
},
},
},
section: {
category: {
@@ -581,10 +601,17 @@ export default {
},
edit: {
title: "Edit item",
advancedOptions: {
label: "Advanced options",
title: "Advanced item options",
},
field: {
integrations: {
label: "Integrations",
},
customCssClasses: {
label: "Custom css classes",
},
},
},
remove: {
@@ -620,8 +647,7 @@ export default {
option: {
customTitleToggle: {
label: "Custom Title/City display",
description:
"Show off a custom title or the name of the city/country on top of the clock.",
description: "Show off a custom title or the name of the city/country on top of the clock.",
},
customTitle: {
label: "Title",
@@ -712,8 +738,7 @@ export default {
},
iframe: {
name: "iFrame",
description:
"Embed any content from the internet. Some websites may restrict access.",
description: "Embed any content from the internet. Some websites may restrict access.",
option: {
embedUrl: {
label: "Embed URL",
@@ -745,14 +770,12 @@ export default {
},
error: {
noUrl: "No iFrame URL provided",
noBrowerSupport:
"Your Browser does not support iframes. Please update your browser.",
noBrowerSupport: "Your Browser does not support iframes. Please update your browser.",
},
},
weather: {
name: "Weather",
description:
"Displays the current weather information of a set location.",
description: "Displays the current weather information of a set location.",
option: {
isFormatFahrenheit: {
label: "Temperature in Fahrenheit",
@@ -768,8 +791,7 @@ export default {
},
forecastDayCount: {
label: "Amount of forecast days",
description:
"When the widget is not wide enough, less days are shown",
description: "When the widget is not wide enough, less days are shown",
},
},
kind: {
@@ -822,8 +844,7 @@ export default {
},
hasAutoPlay: {
label: "Autoplay",
description:
"Autoplay only works when muted because of browser restrictions",
description: "Autoplay only works when muted because of browser restrictions",
},
isMuted: {
label: "Muted",
@@ -896,13 +917,11 @@ export default {
option: {
repeat: {
label: "Repeat",
description:
"The image is repeated as much as needed to cover the whole background image painting area.",
description: "The image is repeated as much as needed to cover the whole background image painting area.",
},
"no-repeat": {
label: "No repeat",
description:
"The image is not repeated and may not fill the entire space.",
description: "The image is not repeated and may not fill the entire space.",
},
"repeat-x": {
label: "Repeat X",
@@ -939,7 +958,13 @@ export default {
label: "Opacity",
},
customCss: {
label: "Custom CSS",
label: "Custom css for this board",
description: "Further, customize your dashboard using CSS, only recommended for experienced users",
customClassesAlert: {
title: "Custom classes",
description:
"You can add custom classes to your board items in the advanced options of each item and use them in the custom CSS above.",
},
},
columnCount: {
label: "Column count",
@@ -948,13 +973,15 @@ export default {
label: "Name",
},
},
content: {
metaTitle: "{boardName} board",
},
setting: {
title: "Settings for {boardName} board",
section: {
general: {
title: "General",
unrecognizedLink:
"The provided link is not recognized and won't preview, it might still work.",
unrecognizedLink: "The provided link is not recognized and won't preview, it might still work.",
},
layout: {
title: "Layout",
@@ -1011,8 +1038,7 @@ export default {
action: {
rename: {
label: "Rename board",
description:
"Changing the name will break any links to this board.",
description: "Changing the name will break any links to this board.",
button: "Change name",
modal: {
title: "Rename board",
@@ -1043,8 +1069,7 @@ export default {
},
delete: {
label: "Delete this board",
description:
"Once you delete a board, there is no going back. Please be certain.",
description: "Once you delete a board, there is no going back. Please be certain.",
button: "Delete this board",
confirm: {
title: "Delete board",
@@ -1089,6 +1114,7 @@ export default {
logs: "Logs",
},
},
settings: "Settings",
help: {
label: "Help",
items: {
@@ -1130,6 +1156,13 @@ export default {
settings: {
label: "Settings",
},
setHomeBoard: {
label: "Set as your home board",
badge: {
label: "Home",
tooltip: "This board will show as your home board",
},
},
delete: {
label: "Delete permanently",
confirm: {
@@ -1254,6 +1287,30 @@ export default {
},
},
},
settings: {
title: "Settings",
section: {
analytics: {
title: "Analytics",
general: {
title: "Send anonymous analytics",
text: "Homarr will send anonymized analytics using the open source software Umami. It never collects any personal information and is therefore fully GDPR & CCPA compliant. We encourage you to enable analytics because it helps our open source team to identify issues and prioritize our backlog.",
},
widgetData: {
title: "Widget data",
text: "Send which widgets (and their quantity) you have configured. Does not include URLs, names or any other data.",
},
integrationData: {
title: "Integration data",
text: "Send which integrations (and their quantity) you have configured. Does not include URLs, names or any other data.",
},
usersData: {
title: "Users data",
text: "Send the amount of users and whether you've activated SSO",
},
},
},
},
about: {
version: "Version {version}",
text: "Homarr is a community driven open source project that is being maintained by volunteers. Thanks to these people, Homarr has been a growing project since 2021. Our team is working completely remote from many different countries on Homarr in their leisure time for no compensation.",
@@ -0,0 +1,21 @@
import type { SupportedLanguage } from ".";
export const localeAttributes: Record<
SupportedLanguage,
{
name: string;
translatedName: string;
flagIcon: string;
}
> = {
de: {
name: "Deutsch",
translatedName: "German",
flagIcon: "de",
},
en: {
name: "English",
translatedName: "English",
flagIcon: "us",
},
};
+3 -6
View File
@@ -3,9 +3,6 @@ import { createI18nServer } from "next-international/server";
import { languageMapping } from "./lang";
import enTranslation from "./lang/en";
export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer(
languageMapping(),
{
fallbackLocale: enTranslation,
},
);
export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer(languageMapping(), {
fallbackLocale: enTranslation,
});
+2 -1
View File
@@ -29,7 +29,8 @@
"typescript": "^5.4.5"
},
"dependencies": {
"@homarr/log": "workspace:^0.1.0"
"@homarr/log": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0"
},
"eslintConfig": {
"extends": [
+1
View File
@@ -5,3 +5,4 @@ export { UserAvatar } from "./user-avatar";
export { UserAvatarGroup } from "./user-avatar-group";
export { TablePagination } from "./table-pagination";
export { SearchInput } from "./search-input";
export { TextMultiSelect } from "./text-multi-select";
+1 -4
View File
@@ -12,10 +12,7 @@ interface SearchInputProps {
placeholder: string;
}
export const SearchInput = ({
placeholder,
defaultValue,
}: SearchInputProps) => {
export const SearchInput = ({ placeholder, defaultValue }: SearchInputProps) => {
// eslint-disable-next-line @typescript-eslint/unbound-method
const { replace } = useRouter();
const pathName = usePathname();
@@ -11,19 +11,15 @@ interface BaseSelectItem {
}
export interface SelectWithCustomItemsProps<TSelectItem extends BaseSelectItem>
extends Pick<
SelectProps,
"label" | "error" | "defaultValue" | "value" | "onChange" | "placeholder"
> {
extends Pick<SelectProps, "label" | "error" | "defaultValue" | "value" | "onChange" | "placeholder"> {
data: TSelectItem[];
onBlur?: (event: React.FocusEvent<HTMLButtonElement>) => void;
onFocus?: (event: React.FocusEvent<HTMLButtonElement>) => void;
}
type Props<TSelectItem extends BaseSelectItem> =
SelectWithCustomItemsProps<TSelectItem> & {
SelectOption: React.ComponentType<TSelectItem>;
};
type Props<TSelectItem extends BaseSelectItem> = SelectWithCustomItemsProps<TSelectItem> & {
SelectOption: React.ComponentType<TSelectItem>;
};
export const SelectWithCustomItems = <TSelectItem extends BaseSelectItem>({
data,
@@ -45,10 +41,7 @@ export const SelectWithCustomItems = <TSelectItem extends BaseSelectItem>({
onChange,
});
const selectedOption = useMemo(
() => data.find((item) => item.value === _value),
[data, _value],
);
const selectedOption = useMemo(() => data.find((item) => item.value === _value), [data, _value]);
const options = data.map((item) => (
<Combobox.Option value={item.value} key={item.value}>
@@ -69,11 +62,7 @@ export const SelectWithCustomItems = <TSelectItem extends BaseSelectItem>({
);
return (
<Combobox
store={combobox}
withinPortal={false}
onOptionSubmit={onOptionSubmit}
>
<Combobox store={combobox} withinPortal={false} onOptionSubmit={onOptionSubmit}>
<Combobox.Target>
<InputBase
{...props}
@@ -85,11 +74,7 @@ export const SelectWithCustomItems = <TSelectItem extends BaseSelectItem>({
rightSectionPointerEvents="none"
multiline
>
{selectedOption ? (
<SelectOption {...selectedOption} />
) : (
<Input.Placeholder>{placeholder}</Input.Placeholder>
)}
{selectedOption ? <SelectOption {...selectedOption} /> : <Input.Placeholder>{placeholder}</Input.Placeholder>}
</InputBase>
</Combobox.Target>
@@ -15,19 +15,10 @@ export interface SelectItemWithDescriptionBadge {
type Props = SelectWithCustomItemsProps<SelectItemWithDescriptionBadge>;
export const SelectWithDescriptionBadge = (props: Props) => {
return (
<SelectWithCustomItems<SelectItemWithDescriptionBadge>
{...props}
SelectOption={SelectOption}
/>
);
return <SelectWithCustomItems<SelectItemWithDescriptionBadge> {...props} SelectOption={SelectOption} />;
};
const SelectOption = ({
label,
description,
badge,
}: SelectItemWithDescriptionBadge) => {
const SelectOption = ({ label, description, badge }: SelectItemWithDescriptionBadge) => {
return (
<Group justify="space-between">
<div>
@@ -13,12 +13,7 @@ export interface SelectItemWithDescription {
type Props = SelectWithCustomItemsProps<SelectItemWithDescription>;
export const SelectWithDescription = (props: Props) => {
return (
<SelectWithCustomItems<SelectItemWithDescription>
{...props}
SelectOption={SelectOption}
/>
);
return <SelectWithCustomItems<SelectItemWithDescription> {...props} SelectOption={SelectOption} />;
};
const SelectOption = ({ label, description }: SelectItemWithDescription) => {
@@ -47,23 +47,12 @@ export const TablePagination = ({ total }: TablePaginationProps) => {
);
return (
<Pagination
total={total}
getItemProps={getItemProps}
getControlProps={getControlProps}
onChange={handleChange}
/>
<Pagination total={total} getItemProps={getItemProps} getControlProps={getControlProps} onChange={handleChange} />
);
};
type ControlType = Parameters<
Exclude<PaginationProps["getControlProps"], undefined>
>[0];
const calculatePageFor = (
type: ControlType,
current: number,
total: number,
) => {
type ControlType = Parameters<Exclude<PaginationProps["getControlProps"], undefined>>[0];
const calculatePageFor = (type: ControlType, current: number, total: number) => {
switch (type) {
case "first":
return 1;
@@ -0,0 +1,98 @@
"use client";
import type { FocusEventHandler } from "react";
import { useState } from "react";
import { Combobox, Group, Pill, PillsInput, Text, useCombobox } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface TextMultiSelectProps {
label: string;
value?: string[];
onChange: (value: string[]) => void;
onFocus?: FocusEventHandler;
onBlur?: FocusEventHandler;
error?: string;
}
export const TextMultiSelect = ({ label, value = [], onChange, onBlur, onFocus, error }: TextMultiSelectProps) => {
const t = useI18n();
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
});
const [search, setSearch] = useState("");
const exactOptionMatch = value.some((item) => item === search);
const handleValueSelect = (selectedValue: string) => {
setSearch("");
if (selectedValue === "$create") {
onChange([...value, search]);
} else {
onChange(value.filter((filterValue) => filterValue !== selectedValue));
}
};
const handleValueRemove = (removedValue: string) =>
onChange(value.filter((filterValue) => filterValue !== removedValue));
const values = value.map((item) => (
<Pill key={item} withRemoveButton onRemove={() => handleValueRemove(item)}>
{item}
</Pill>
));
return (
<Combobox store={combobox} onOptionSubmit={handleValueSelect} withinPortal={false}>
<Combobox.DropdownTarget>
<PillsInput label={label} error={error} onClick={() => combobox.openDropdown()}>
<Pill.Group>
{values}
<Combobox.EventsTarget>
<PillsInput.Field
onFocus={(event) => {
onFocus?.(event);
combobox.openDropdown();
}}
onBlur={(event) => {
onBlur?.(event);
combobox.closeDropdown();
}}
value={search}
placeholder={t("common.multiText.placeholder")}
onChange={(event) => {
combobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
}}
onKeyDown={(event) => {
if (event.key === "Backspace" && search.length === 0) {
event.preventDefault();
handleValueRemove(value.at(-1)!);
}
}}
/>
</Combobox.EventsTarget>
</Pill.Group>
</PillsInput>
</Combobox.DropdownTarget>
{!exactOptionMatch && search.trim().length > 0 && (
<Combobox.Dropdown>
<Combobox.Options>
<Combobox.Option value="$create">
<Group>
<IconPlus size={12} />
<Text size="sm">{t("common.multiText.addLabel", { value: search })}</Text>
</Group>
</Combobox.Option>
</Combobox.Options>
</Combobox.Dropdown>
)}
</Combobox>
);
};
@@ -10,11 +10,7 @@ interface UserAvatarGroupProps {
users: UserProps[];
}
export const UserAvatarGroup = ({
size,
limit,
users,
}: UserAvatarGroupProps) => {
export const UserAvatarGroup = ({ size, limit, users }: UserAvatarGroupProps) => {
return (
<TooltipGroup openDelay={300} closeDelay={300}>
<AvatarGroup>
+1 -3
View File
@@ -22,7 +22,5 @@ export const UserAvatar = ({ user, size }: UserAvatarProps) => {
return <Avatar {...commonProps} src={user.image} alt={user.name} />;
}
return (
<Avatar {...commonProps}>{user.name.substring(0, 2).toUpperCase()}</Avatar>
);
return <Avatar {...commonProps}>{user.name.substring(0, 2).toUpperCase()}</Avatar>;
};

Some files were not shown because too many files have changed in this diff Show More