chore: update prettier configuration for print width (#519)

* feat: update prettier configuration for print width

* chore: apply code formatting to entire repository

* fix: remove build files

* fix: format issue

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Thomas Camlong
2024-05-19 22:38:39 +02:00
committed by GitHub
parent 919161798e
commit f1b1ec59ec
234 changed files with 2444 additions and 5375 deletions

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));
}),
});

View File

@@ -16,50 +16,34 @@ import {
} 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 { 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 userId = ctx.session?.user.id;
const permissionsOfCurrentUserWhenPresent =
await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.userId, userId ?? ""),
});
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.userId, userId ?? ""),
});
const permissionsOfCurrentUserGroupsWhenPresent =
await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),
with: {
group: {
with: {
boardPermissions: {},
},
const 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(),
);
@@ -89,9 +73,7 @@ export const boardRouter = createTRPCRouter({
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
? inArray(
boardGroupPermissions.groupId,
permissionsOfCurrentUserGroupsWhenPresent.map(
(groupMember) => groupMember.groupId,
),
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
)
: undefined,
},
@@ -129,61 +111,33 @@ 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));
}),
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.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));
}),
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
@@ -192,39 +146,21 @@ export const boardRouter = createTRPCRouter({
})
: null;
const boardWhere = user?.homeBoardId
? eq(boards.id, user.homeBoardId)
: eq(boards.name, "home");
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.and(z.object({ id: z.string() })),
)
.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)
@@ -254,271 +190,222 @@ 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),
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;
}
@@ -534,16 +421,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;
}
@@ -558,11 +439,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,
@@ -571,9 +448,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) {
@@ -584,11 +459,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 ?? ""),
});
@@ -622,10 +493,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("")),
},
},
});
@@ -660,9 +528,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);
@@ -672,26 +538,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));

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

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) {

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,
};
}),
});

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,
};
}),
});

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 {

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>;
}),
});

View File

@@ -14,9 +14,7 @@ export const serverSettingsRouter = createTRPCRouter({
const data = {} as ServerSettings;
defaultServerSettingsKeys.forEach((key) => {
const settingValue = settings.find(
(setting) => setting.settingKey === key,
)?.value;
const settingValue = settings.find((setting) => setting.settingKey === key)?.value;
if (!settingValue) {
return;
}

View File

@@ -155,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][])(
@@ -221,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"]);
},
);
@@ -277,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"]);
},
);
});
@@ -356,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 () => {
@@ -384,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 () => {
@@ -399,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");
@@ -439,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");
},
);
});
@@ -473,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 () => {
@@ -486,8 +461,7 @@ 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");
@@ -511,40 +485,29 @@ describe("getHomeBoard should return home board", () => {
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
@@ -553,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");
@@ -611,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);
@@ -623,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 () => {
@@ -682,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,
@@ -742,11 +691,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");
@@ -759,8 +704,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({
@@ -813,71 +757,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();
@@ -928,25 +862,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");
@@ -959,10 +885,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({
@@ -1011,17 +934,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();
@@ -1066,16 +983,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);
@@ -1086,10 +999,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,
@@ -1129,24 +1039,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();
@@ -1216,9 +1117,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([
{
@@ -1231,14 +1130,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");
});
});
@@ -1279,11 +1172,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");
},
);
});
@@ -1330,11 +1219,7 @@ 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");
},
);
});

View File

@@ -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");

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 () => {

View File

@@ -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([
[

View File

@@ -5,10 +5,7 @@ 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 { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import { serverSettingsRouter } from "../serverSettings";

View File

@@ -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({
@@ -261,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({

View File

@@ -11,55 +11,46 @@ 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",
});
}
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 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 createUserAsync(ctx.db, input);
}),
setProfileImage: protectedProcedure
.input(
z.object({
@@ -74,10 +65,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",
@@ -126,112 +114,103 @@ 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",
});
}
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",
});
}
}
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 });
}),
@@ -244,10 +223,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);

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,

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
>;

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),

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),
});

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";

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 ||

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`);

View File

@@ -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 () => {

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;
}

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,
});
});
});

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", () => {

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>;

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) => {

View File

@@ -1,16 +1,7 @@
import type { AdapterAccount } from "@auth/core/adapters";
import { relations } from "drizzle-orm";
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
import {
boolean,
index,
int,
mysqlTable,
primaryKey,
text,
timestamp,
varchar,
} from "drizzle-orm/mysql-core";
import { boolean, index, int, mysqlTable, primaryKey, text, timestamp, varchar } from "drizzle-orm/mysql-core";
import type {
BackgroundImageAttachment,
@@ -23,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(),
@@ -37,12 +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",
},
),
homeBoardId: varchar("homeBoardId", { length: 64 }).references((): AnyMySqlColumn => boards.id, {
onDelete: "set null",
}),
});
export const accounts = mysqlTable(
@@ -73,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" }),
@@ -157,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 })
@@ -171,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),
}),
);
@@ -217,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({
@@ -237,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({
@@ -339,12 +313,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, {
@@ -381,58 +352,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),
@@ -460,16 +419,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],
}),
}));

View File

@@ -2,20 +2,9 @@ import type { AdapterAccount } from "@auth/core/adapters";
import type { InferSelectModel } from "drizzle-orm";
import { relations } from "drizzle-orm";
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
import {
index,
int,
integer,
primaryKey,
sqliteTable,
text,
} 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,
@@ -36,12 +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",
},
),
homeBoardId: text("homeBoardId").references((): AnySQLiteColumn => boards.id, {
onDelete: "set null",
}),
});
export const accounts = sqliteTable(
@@ -168,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),
}),
);
@@ -332,12 +316,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, {
@@ -374,58 +355,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),
@@ -453,19 +422,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>;

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

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,
) => ({

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>;

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);

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];

View File

@@ -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));
});
});

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];

View File

@@ -2,18 +2,11 @@ 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 type { AnyZodObject, ZodEffects, ZodIntersection } from "@homarr/validation";
import { zodErrorMap } from "@homarr/validation/form";
export const useZodForm = <
TSchema extends
| AnyZodObject
| ZodEffects<AnyZodObject>
| ZodIntersection<AnyZodObject, AnyZodObject>,
TSchema extends AnyZodObject | ZodEffects<AnyZodObject> | ZodIntersection<AnyZodObject, AnyZodObject>,
>(
schema: TSchema,
options: Omit<

View File

@@ -1,10 +1,5 @@
import type { TranslationObject } from "@homarr/translation";
import type {
ErrorMapCtx,
z,
ZodTooBigIssue,
ZodTooSmallIssue,
} from "@homarr/validation";
import type { ErrorMapCtx, z, ZodTooBigIssue, ZodTooSmallIssue } from "@homarr/validation";
import { ZodIssueCode } from "@homarr/validation";
const handleStringError = (issue: z.ZodInvalidStringIssue) => {
@@ -71,10 +66,7 @@ const handleTooBigError = (issue: ZodTooBigIssue) => {
} as const;
};
export const handleZodError = (
issue: z.ZodIssueOptionalMessage,
ctx: ErrorMapCtx,
) => {
export const handleZodError = (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => {
if (ctx.defaultError === "Required") {
return {
key: "errors.required",

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()));
};

View File

@@ -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,

View File

@@ -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: [],

View File

@@ -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,

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()],
});

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({});

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 {

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,8 +97,7 @@ 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
@@ -145,18 +129,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 +145,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 }),
};
};

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;

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;

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

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",

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>
);

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),

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) {

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();
};

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;

View File

@@ -5,12 +5,9 @@ import { createI18nClient } from "next-international/client";
import { languageMapping } from "./lang";
import enTranslation from "./lang/en";
export const {
useI18n,
useScopedI18n,
useCurrentLocale,
useChangeLocale,
I18nProviderClient,
} = createI18nClient(languageMapping(), {
fallbackLocale: enTranslation,
});
export const { useI18n, useScopedI18n, useCurrentLocale, useChangeLocale, I18nProviderClient } = createI18nClient(
languageMapping(),
{
fallbackLocale: enTranslation,
},
);

View File

@@ -9,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);
}

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>>;
};

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",

View File

@@ -118,8 +118,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",
@@ -144,8 +143,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",
},
},
},
@@ -162,8 +160,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",
@@ -181,8 +178,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",
@@ -190,8 +186,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",
},
},
},
@@ -211,8 +206,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}",
@@ -231,8 +225,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: {
@@ -383,8 +376,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",
@@ -454,8 +446,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: {
@@ -642,8 +633,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",
@@ -734,8 +724,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",
@@ -767,14 +756,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",
@@ -790,8 +777,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: {
@@ -844,8 +830,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",
@@ -918,13 +903,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",
@@ -978,8 +961,7 @@ export default {
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",
@@ -1036,8 +1018,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",
@@ -1068,8 +1049,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",

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,
});

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();

View File

@@ -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>

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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;

View File

@@ -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>

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>;
};

View File

@@ -2,10 +2,8 @@ import { z } from "zod";
type CouldBeReadonlyArray<T> = T[] | readonly T[];
export const zodEnumFromArray = <T extends string>(
array: CouldBeReadonlyArray<T>,
) => z.enum([array[0]!, ...array.slice(1)]);
export const zodEnumFromArray = <T extends string>(array: CouldBeReadonlyArray<T>) =>
z.enum([array[0]!, ...array.slice(1)]);
export const zodUnionFromArray = <T extends z.ZodTypeAny>(
array: CouldBeReadonlyArray<T>,
) => z.union([array[0]!, array[1]!, ...array.slice(2)]);
export const zodUnionFromArray = <T extends z.ZodTypeAny>(array: CouldBeReadonlyArray<T>) =>
z.union([array[0]!, array[1]!, ...array.slice(2)]);

View File

@@ -16,10 +16,7 @@ export const zodErrorMap = <
message: error.message ?? ctx.defaultError,
};
return {
message: t(
error.key ? `common.zod.${error.key}` : "common.zod.errors.default",
error.params ?? {},
),
message: t(error.key ? `common.zod.${error.key}` : "common.zod.errors.default", error.params ?? {}),
};
};
};
@@ -134,6 +131,5 @@ export interface CustomErrorParams {
};
}
export const createCustomErrorParams = (
i18n: CustomErrorParams["i18n"] | CustomErrorParams["i18n"]["key"],
) => (typeof i18n === "string" ? { i18n: { key: i18n } } : { i18n });
export const createCustomErrorParams = (i18n: CustomErrorParams["i18n"] | CustomErrorParams["i18n"]["key"]) =>
typeof i18n === "string" ? { i18n: { key: i18n } } : { i18n };

View File

@@ -18,8 +18,4 @@ export const validation = {
icons: iconsSchemas,
};
export {
createSectionSchema,
sharedItemSchema,
type BoardItemIntegration,
} from "./shared";
export { createSectionSchema, sharedItemSchema, type BoardItemIntegration } from "./shared";

View File

@@ -29,9 +29,7 @@ export const commonItemSchema = z
})
.and(sharedItemSchema);
const createCategorySchema = <TItemSchema extends z.ZodTypeAny>(
itemSchema: TItemSchema,
) =>
const createCategorySchema = <TItemSchema extends z.ZodTypeAny>(itemSchema: TItemSchema) =>
z.object({
id: z.string(),
name: z.string(),
@@ -40,9 +38,7 @@ const createCategorySchema = <TItemSchema extends z.ZodTypeAny>(
items: z.array(itemSchema),
});
const createEmptySchema = <TItemSchema extends z.ZodTypeAny>(
itemSchema: TItemSchema,
) =>
const createEmptySchema = <TItemSchema extends z.ZodTypeAny>(itemSchema: TItemSchema) =>
z.object({
id: z.string(),
kind: z.literal("empty"),
@@ -50,6 +46,5 @@ const createEmptySchema = <TItemSchema extends z.ZodTypeAny>(
items: z.array(itemSchema),
});
export const createSectionSchema = <TItemSchema extends z.ZodTypeAny>(
itemSchema: TItemSchema,
) => z.union([createCategorySchema(itemSchema), createEmptySchema(itemSchema)]);
export const createSectionSchema = <TItemSchema extends z.ZodTypeAny>(itemSchema: TItemSchema) =>
z.union([createCategorySchema(itemSchema), createEmptySchema(itemSchema)]);

View File

@@ -6,8 +6,7 @@ const usernameSchema = z.string().min(3).max(255);
const passwordSchema = z.string().min(8).max(255);
const confirmPasswordRefine = [
(data: { password: string; confirmPassword: string }) =>
data.password === data.confirmPassword,
(data: { password: string; confirmPassword: string }) => data.password === data.confirmPassword,
{
path: ["confirmPassword"],
params: createCustomErrorParams("passwordsDoNotMatch"),
@@ -66,9 +65,7 @@ const changePasswordSchema = z
})
.refine(confirmPasswordRefine[0], confirmPasswordRefine[1]);
const changePasswordApiSchema = changePasswordSchema.and(
z.object({ userId: z.string() }),
);
const changePasswordApiSchema = changePasswordSchema.and(z.object({ userId: z.string() }));
export const userSchemas = {
signIn: signInSchema,

View File

@@ -9,9 +9,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
}
type UseWidgetInputTranslationReturnType = (
key: "label" | "description",
) => string;
type UseWidgetInputTranslationReturnType = (key: "label" | "description") => string;
/**
* Short description why as and unknown convertions are used below:
@@ -23,10 +21,7 @@ type UseWidgetInputTranslationReturnType = (
* - The label translation can be used for every input, especially considering that all options should have defined a label for themself. The description translation should only be used when withDescription
* is defined for the option. The method does sadly not reconize issues with those definitions. So it does not yell at you when you somewhere show the label without having it defined in the translations.
*/
export const useWidgetInputTranslation = (
kind: WidgetKind,
property: string,
): UseWidgetInputTranslationReturnType => {
export const useWidgetInputTranslation = (kind: WidgetKind, property: string): UseWidgetInputTranslationReturnType => {
return useScopedI18n(
`widget.${kind}.option.${property}` as never, // Because the type is complex and not recognized by typescript, we need to cast it to never to make it work.
) as unknown as UseWidgetInputTranslationReturnType;

View File

@@ -4,5 +4,4 @@ import { createFormContext } from "@homarr/form";
import type { WidgetEditModalState } from "../modals/widget-edit-modal";
export const [FormProvider, useFormContext, useForm] =
createFormContext<WidgetEditModalState>();
export const [FormProvider, useFormContext, useForm] = createFormContext<WidgetEditModalState>();

View File

@@ -20,8 +20,6 @@ const mapping = {
app: WidgetAppInput,
} satisfies Record<WidgetOptionType, unknown>;
export const getInputForType = <TType extends WidgetOptionType>(
type: TType,
) => {
export const getInputForType = <TType extends WidgetOptionType>(type: TType) => {
return mapping[type];
};

View File

@@ -12,11 +12,7 @@ import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetAppInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"app">) => {
export const WidgetAppInput = ({ property, kind, options }: CommonWidgetInputProps<"app">) => {
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const { data: apps, isPending } = clientApi.app.selectable.useQuery();
@@ -31,9 +27,7 @@ export const WidgetAppInput = ({
label={t("label")}
searchable
limit={10}
leftSection={
<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />
}
leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />}
renderOption={renderSelectOption}
data={
apps?.map((app) => ({
@@ -55,18 +49,13 @@ const iconProps = {
size: 18,
};
const renderSelectOption: SelectProps["renderOption"] = ({
option,
checked,
}) => (
const renderSelectOption: SelectProps["renderOption"] = ({ option, checked }) => (
<Group flex="1" gap="xs">
{"iconUrl" in option && typeof option.iconUrl === "string" ? (
<img width={20} height={20} src={option.iconUrl} alt={option.label} />
) : null}
{option.label}
{checked && (
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
)}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
</Group>
);
@@ -82,14 +71,7 @@ const LeftSection = ({ isPending, currentApp }: LeftSectionProps) => {
}
if (currentApp) {
return (
<img
width={size}
height={size}
src={currentApp.iconUrl}
alt={currentApp.name}
/>
);
return <img width={size} height={size} src={currentApp.iconUrl} alt={currentApp.name} />;
}
return null;

View File

@@ -28,10 +28,7 @@ import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetLocationInput = ({
property,
kind,
}: CommonWidgetInputProps<"location">) => {
export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"location">) => {
const t = useWidgetInputTranslation(kind, property);
const tLocation = useScopedI18n("widget.common.location");
const form = useFormContext();
@@ -39,8 +36,7 @@ export const WidgetLocationInput = ({
const value = form.values.options[property] as OptionLocation;
const selectionEnabled = value.name.length > 1;
const handleChange = form.getInputProps(`options.${property}`)
.onChange as LocationOnChange;
const handleChange = form.getInputProps(`options.${property}`).onChange as LocationOnChange;
const unknownLocation = tLocation("unknownLocation");
const onQueryChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
@@ -95,16 +91,8 @@ export const WidgetLocationInput = ({
<Fieldset legend={t("label")}>
<Stack gap="xs">
<Group wrap="nowrap" align="end">
<TextInput
w="100%"
label={tLocation("query")}
value={value.name}
onChange={onQueryChange}
/>
<Tooltip
hidden={selectionEnabled}
label={tLocation("disabledTooltip")}
>
<TextInput w="100%" label={tLocation("query")} value={value.name} onChange={onQueryChange} />
<Tooltip hidden={selectionEnabled} label={tLocation("disabledTooltip")}>
<div>
<Button
disabled={!selectionEnabled}
@@ -151,61 +139,57 @@ interface LocationSearchInnerProps {
onLocationSelect: (location: OptionLocation) => void;
}
const LocationSearchModal = createModal<LocationSearchInnerProps>(
({ actions, innerProps }) => {
const t = useScopedI18n("widget.common.location.table");
const tCommon = useScopedI18n("common");
const { data, isPending, error } = clientApi.location.searchCity.useQuery({
query: innerProps.query,
});
const LocationSearchModal = createModal<LocationSearchInnerProps>(({ actions, innerProps }) => {
const t = useScopedI18n("widget.common.location.table");
const tCommon = useScopedI18n("common");
const { data, isPending, error } = clientApi.location.searchCity.useQuery({
query: innerProps.query,
});
if (error) {
throw error;
}
if (error) {
throw error;
}
return (
<Stack>
<Table striped>
<Table.Thead>
return (
<Stack>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: "70%" }}>{t("header.city")}</Table.Th>
<Table.Th style={{ width: "50%" }}>{t("header.country")}</Table.Th>
<Table.Th>{t("header.coordinates")}</Table.Th>
<Table.Th>{t("header.population")}</Table.Th>
<Table.Th style={{ width: 40 }} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isPending && (
<Table.Tr>
<Table.Th style={{ width: "70%" }}>{t("header.city")}</Table.Th>
<Table.Th style={{ width: "50%" }}>
{t("header.country")}
</Table.Th>
<Table.Th>{t("header.coordinates")}</Table.Th>
<Table.Th>{t("header.population")}</Table.Th>
<Table.Th style={{ width: 40 }} />
<Table.Td colSpan={5}>
<Group justify="center">
<Loader />
</Group>
</Table.Td>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isPending && (
<Table.Tr>
<Table.Td colSpan={5}>
<Group justify="center">
<Loader />
</Group>
</Table.Td>
</Table.Tr>
)}
{data?.results.map((city) => (
<LocationSelectTableRow
key={city.id}
city={city}
onLocationSelect={innerProps.onLocationSelect}
closeModal={actions.closeModal}
/>
))}
</Table.Tbody>
</Table>
<Group justify="right">
<Button variant="light" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
</Group>
</Stack>
);
},
).withOptions({
)}
{data?.results.map((city) => (
<LocationSelectTableRow
key={city.id}
city={city}
onLocationSelect={innerProps.onLocationSelect}
closeModal={actions.closeModal}
/>
))}
</Table.Tbody>
</Table>
<Group justify="right">
<Button variant="light" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
</Group>
</Stack>
);
}).withOptions({
defaultTitle(t) {
return t("widget.common.location.search");
},
@@ -218,11 +202,7 @@ interface LocationSearchTableRowProps {
closeModal: () => void;
}
const LocationSelectTableRow = ({
city,
onLocationSelect,
closeModal,
}: LocationSearchTableRowProps) => {
const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: LocationSearchTableRowProps) => {
const t = useScopedI18n("widget.common.location.table");
const onSelect = useCallback(() => {
onLocationSelect({
@@ -244,10 +224,7 @@ const LocationSelectTableRow = ({
<Text style={{ whiteSpace: "nowrap" }}>{city.country}</Text>
</Table.Td>
<Table.Td>
<Anchor
target="_blank"
href={`https://www.google.com/maps/place/${city.latitude},${city.longitude}`}
>
<Anchor target="_blank" href={`https://www.google.com/maps/place/${city.latitude},${city.longitude}`}>
<Text style={{ whiteSpace: "nowrap" }}>
{city.latitude}, {city.longitude}
</Text>
@@ -255,9 +232,7 @@ const LocationSelectTableRow = ({
</Table.Td>
<Table.Td>
{city.population ? (
<Text style={{ whiteSpace: "nowrap" }}>
{formatter.format(city.population)}
</Text>
<Text style={{ whiteSpace: "nowrap" }}>{formatter.format(city.population)}</Text>
) : (
<Text c="gray"> {t("population.fallback")}</Text>
)}

View File

@@ -7,11 +7,7 @@ import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
import type { SelectOption } from "./widget-select-input";
export const WidgetMultiSelectInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"multiSelect">) => {
export const WidgetMultiSelectInput = ({ property, kind, options }: CommonWidgetInputProps<"multiSelect">) => {
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();

View File

@@ -6,11 +6,7 @@ import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetNumberInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"number">) => {
export const WidgetNumberInput = ({ property, kind, options }: CommonWidgetInputProps<"number">) => {
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();

View File

@@ -13,18 +13,13 @@ export type SelectOption =
}
| string;
export type inferSelectOptionValue<TOption extends SelectOption> =
TOption extends {
value: infer TValue;
}
? TValue
: TOption;
export type inferSelectOptionValue<TOption extends SelectOption> = TOption extends {
value: infer TValue;
}
? TValue
: TOption;
export const WidgetSelectInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"select">) => {
export const WidgetSelectInput = ({ property, kind, options }: CommonWidgetInputProps<"select">) => {
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();

View File

@@ -6,11 +6,7 @@ import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetSliderInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"slider">) => {
export const WidgetSliderInput = ({ property, kind, options }: CommonWidgetInputProps<"slider">) => {
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();

View File

@@ -6,11 +6,7 @@ import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetSwitchInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"switch">) => {
export const WidgetSwitchInput = ({ property, kind, options }: CommonWidgetInputProps<"switch">) => {
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();

View File

@@ -6,11 +6,7 @@ import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetTextInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"text">) => {
export const WidgetTextInput = ({ property, kind, options }: CommonWidgetInputProps<"text">) => {
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();

View File

@@ -1,15 +1,7 @@
"use client";
import type { PropsWithChildren } from "react";
import {
Center,
Flex,
Loader,
Stack,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { Center, Flex, Loader, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconDeviceDesktopX } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -19,13 +11,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import classes from "./app.module.css";
export default function AppWidget({
options,
serverData,
isEditMode,
width,
height,
}: WidgetComponentProps<"app">) {
export default function AppWidget({ options, serverData, isEditMode, width, height }: WidgetComponentProps<"app">) {
const t = useScopedI18n("widget.app");
const isQueryEnabled = Boolean(options.appId);
const {
@@ -90,11 +76,7 @@ export default function AppWidget({
}
return (
<AppLink
href={app?.href ?? ""}
openInNewTab={options.openInNewTab}
enabled={Boolean(app?.href) && !isEditMode}
>
<AppLink href={app?.href ?? ""} openInNewTab={options.openInNewTab} enabled={Boolean(app?.href) && !isEditMode}>
<Flex align="center" justify="center" h="100%">
<Tooltip.Floating
label={app?.description}
@@ -118,11 +100,7 @@ export default function AppWidget({
{app?.name}
</Text>
)}
<img
src={app?.iconUrl}
alt={app?.name}
className={classes.appIcon}
/>
<img src={app?.iconUrl} alt={app?.name} className={classes.appIcon} />
</Flex>
</Tooltip.Floating>
</Flex>
@@ -136,20 +114,9 @@ interface AppLinkProps {
enabled: boolean;
}
const AppLink = ({
href,
openInNewTab,
enabled,
children,
}: PropsWithChildren<AppLinkProps>) =>
const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<AppLinkProps>) =>
enabled ? (
<UnstyledButton
component="a"
href={href}
target={openInNewTab ? "_blank" : undefined}
h="100%"
w="100%"
>
<UnstyledButton component="a" href={href} target={openInNewTab ? "_blank" : undefined} h="100%" w="100%">
{children}
</UnstyledButton>
) : (

View File

@@ -3,14 +3,13 @@ import { IconApps } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader, serverDataLoader } =
createWidgetDefinition("app", {
icon: IconApps,
options: optionsBuilder.from((factory) => ({
appId: factory.app(),
openInNewTab: factory.switch({ defaultValue: true }),
showDescriptionTooltip: factory.switch({ defaultValue: false }),
})),
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("app", {
icon: IconApps,
options: optionsBuilder.from((factory) => ({
appId: factory.app(),
openInNewTab: factory.switch({ defaultValue: true }),
showDescriptionTooltip: factory.switch({ defaultValue: false }),
})),
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -4,9 +4,7 @@ import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({
options,
}: WidgetProps<"app">) {
export default async function getServerDataAsync({ options }: WidgetProps<"app">) {
try {
const app = await api.app.byId({ id: options.appId });
return { app };

View File

@@ -13,37 +13,19 @@ dayjs.extend(advancedFormat);
dayjs.extend(utc);
dayjs.extend(timezones);
export default function ClockWidget({
options,
}: WidgetComponentProps<"clock">) {
export default function ClockWidget({ options }: WidgetComponentProps<"clock">) {
const secondsFormat = options.showSeconds ? ":ss" : "";
const timeFormat = options.is24HourFormat
? `HH:mm${secondsFormat}`
: `h:mm${secondsFormat} A`;
const timeFormat = options.is24HourFormat ? `HH:mm${secondsFormat}` : `h:mm${secondsFormat} A`;
const dateFormat = options.dateFormat;
const timezone = options.useCustomTimezone
? options.timezone
: Intl.DateTimeFormat().resolvedOptions().timeZone;
const timezone = options.useCustomTimezone ? options.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone;
const time = useCurrentTime(options);
return (
<Flex
classNames={{ root: "clock-wrapper" }}
align="center"
justify="center"
h="100%"
>
<Flex classNames={{ root: "clock-wrapper" }} align="center" justify="center" h="100%">
<Stack classNames={{ root: "clock-text-stack" }} align="center" gap="xs">
{options.customTitleToggle && (
<Text classNames={{ root: "clock-customTitle-text" }}>
{options.customTitle}
</Text>
<Text classNames={{ root: "clock-customTitle-text" }}>{options.customTitle}</Text>
)}
<Text
classNames={{ root: "clock-time-text" }}
fw={700}
size="2.125rem"
lh="1"
>
<Text classNames={{ root: "clock-time-text" }} fw={700} size="2.125rem" lh="1">
{dayjs(time).tz(timezone).format(timeFormat)}
</Text>
{options.showDate && (
@@ -64,10 +46,7 @@ const useCurrentTime = ({ showSeconds }: UseCurrentTimeProps) => {
const [time, setTime] = useState(new Date());
const timeoutRef = useRef<NodeJS.Timeout>();
const intervalRef = useRef<NodeJS.Timeout>();
const intervalMultiplier = useMemo(
() => (showSeconds ? 1 : 60),
[showSeconds],
);
const intervalMultiplier = useMemo(() => (showSeconds ? 1 : 60), [showSeconds]);
useEffect(() => {
setTime(new Date());
@@ -79,8 +58,7 @@ const useCurrentTime = ({ showSeconds }: UseCurrentTimeProps) => {
setTime(new Date());
}, intervalMultiplier * 1000);
},
intervalMultiplier * 1000 -
(1000 * (showSeconds ? 0 : dayjs().second()) + dayjs().millisecond()),
intervalMultiplier * 1000 - (1000 * (showSeconds ? 0 : dayjs().second()) + dayjs().millisecond()),
);
return () => {

View File

@@ -4,10 +4,7 @@ import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetImports } from ".";
import type {
inferOptionsFromDefinition,
WidgetOptionsRecord,
} from "./options";
import type { inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
import type { IntegrationSelectOption } from "./widget-integration-select";
type ServerDataLoader<TKind extends WidgetKind> = () => Promise<{
@@ -29,9 +26,7 @@ const createWithDynamicImport =
WidgetComponentProps<TKind> &
(TServerDataLoader extends ServerDataLoader<TKind>
? {
serverData: Awaited<
ReturnType<Awaited<ReturnType<TServerDataLoader>>["default"]>
>;
serverData: Awaited<ReturnType<Awaited<ReturnType<TServerDataLoader>>["default"]>>;
}
: never)
>,
@@ -46,30 +41,18 @@ const createWithDynamicImport =
});
const createWithServerData =
<TKind extends WidgetKind, TDefinition extends WidgetDefinition>(
kind: TKind,
definition: TDefinition,
) =>
<TServerDataLoader extends ServerDataLoader<TKind>>(
serverDataLoader: TServerDataLoader,
) => ({
<TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) =>
<TServerDataLoader extends ServerDataLoader<TKind>>(serverDataLoader: TServerDataLoader) => ({
definition: {
...definition,
kind,
},
kind,
serverDataLoader,
withDynamicImport: createWithDynamicImport(
kind,
definition,
serverDataLoader,
),
withDynamicImport: createWithDynamicImport(kind, definition, serverDataLoader),
});
export const createWidgetDefinition = <
TKind extends WidgetKind,
TDefinition extends WidgetDefinition,
>(
export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition extends WidgetDefinition>(
kind: TKind,
definition: TDefinition,
) => ({
@@ -85,41 +68,32 @@ export interface WidgetDefinition {
export interface WidgetProps<TKind extends WidgetKind> {
options: inferOptionsFromDefinition<WidgetOptionsRecordOf<TKind>>;
integrations: inferIntegrationsFromDefinition<
WidgetImports[TKind]["definition"]
>;
integrations: inferIntegrationsFromDefinition<WidgetImports[TKind]["definition"]>;
}
type inferServerDataForKind<TKind extends WidgetKind> =
WidgetImports[TKind] extends { serverDataLoader: ServerDataLoader<TKind> }
? Awaited<
ReturnType<
Awaited<
ReturnType<WidgetImports[TKind]["serverDataLoader"]>
>["default"]
>
>
: undefined;
type inferServerDataForKind<TKind extends WidgetKind> = WidgetImports[TKind] extends {
serverDataLoader: ServerDataLoader<TKind>;
}
? Awaited<ReturnType<Awaited<ReturnType<WidgetImports[TKind]["serverDataLoader"]>>["default"]>>
: undefined;
export type WidgetComponentProps<TKind extends WidgetKind> =
WidgetProps<TKind> & {
serverData?: inferServerDataForKind<TKind>;
} & {
itemId: string | undefined; // undefined when in preview mode
boardId: string | undefined; // undefined when in preview mode
isEditMode: boolean;
width: number;
height: number;
};
export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & {
serverData?: inferServerDataForKind<TKind>;
} & {
itemId: string | undefined; // undefined when in preview mode
boardId: string | undefined; // undefined when in preview mode
isEditMode: boolean;
width: number;
height: number;
};
type inferIntegrationsFromDefinition<TDefinition extends WidgetDefinition> =
TDefinition extends {
supportedIntegrations: infer TSupportedIntegrations;
} // check if definition has supportedIntegrations
? TSupportedIntegrations extends IntegrationKind[] // check if supportedIntegrations is an array of IntegrationKind
? IntegrationSelectOptionFor<TSupportedIntegrations[number]>[] // if so, return an array of IntegrationSelectOptionFor
: IntegrationSelectOption[] // otherwise, return an array of IntegrationSelectOption without specifying the kind
: IntegrationSelectOption[];
type inferIntegrationsFromDefinition<TDefinition extends WidgetDefinition> = TDefinition extends {
supportedIntegrations: infer TSupportedIntegrations;
} // check if definition has supportedIntegrations
? TSupportedIntegrations extends IntegrationKind[] // check if supportedIntegrations is an array of IntegrationKind
? IntegrationSelectOptionFor<TSupportedIntegrations[number]>[] // if so, return an array of IntegrationSelectOptionFor
: IntegrationSelectOption[] // otherwise, return an array of IntegrationSelectOption without specifying the kind
: IntegrationSelectOption[];
interface IntegrationSelectOptionFor<TIntegration extends IntegrationKind> {
id: string;
@@ -128,5 +102,4 @@ interface IntegrationSelectOptionFor<TIntegration extends IntegrationKind> {
kind: TIntegration[number];
}
export type WidgetOptionsRecordOf<TKind extends WidgetKind> =
WidgetImports[TKind]["definition"]["options"];
export type WidgetOptionsRecordOf<TKind extends WidgetKind> = WidgetImports[TKind]["definition"]["options"];

View File

@@ -7,9 +7,7 @@ import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
export default function IFrameWidget({
options,
}: WidgetComponentProps<"iframe">) {
export default function IFrameWidget({ options }: WidgetComponentProps<"iframe">) {
const t = useI18n();
const { embedUrl, ...permissions } = options;
const allowedPermissions = getAllowedPermissions(permissions);
@@ -18,12 +16,7 @@ export default function IFrameWidget({
return (
<Box h="100%" w="100%">
<iframe
className={classes.iframe}
src={embedUrl}
title="widget iframe"
allow={allowedPermissions.join(" ")}
>
<iframe className={classes.iframe} src={embedUrl} title="widget iframe" allow={allowedPermissions.join(" ")}>
<Text>{t("widget.iframe.error.noBrowerSupport")}</Text>
</iframe>
</Box>
@@ -41,9 +34,7 @@ const NoUrl = () => {
);
};
const getAllowedPermissions = (
permissions: Omit<WidgetComponentProps<"iframe">["options"], "embedUrl">,
) => {
const getAllowedPermissions = (permissions: Omit<WidgetComponentProps<"iframe">["options"], "embedUrl">) => {
return objectEntries(permissions)
.filter(([_key, value]) => value)
.map(([key]) => permissionMapping[key]);
@@ -58,7 +49,4 @@ const permissionMapping = {
allowPayment: "payment",
allowScrolling: "scrolling",
allowTransparency: "transparency",
} satisfies Record<
keyof Omit<WidgetComponentProps<"iframe">["options"], "embedUrl">,
string
>;
} satisfies Record<keyof Omit<WidgetComponentProps<"iframe">["options"], "embedUrl">, string>;

View File

@@ -3,22 +3,19 @@ import { IconBrowser } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition(
"iframe",
{
icon: IconBrowser,
options: optionsBuilder.from((factory) => ({
embedUrl: factory.text(),
allowFullScreen: factory.switch(),
allowScrolling: factory.switch({
defaultValue: true,
}),
allowTransparency: factory.switch(),
allowPayment: factory.switch(),
allowAutoPlay: factory.switch(),
allowMicrophone: factory.switch(),
allowCamera: factory.switch(),
allowGeolocation: factory.switch(),
})),
},
).withDynamicImport(() => import("./component"));
export const { definition, componentLoader } = createWidgetDefinition("iframe", {
icon: IconBrowser,
options: optionsBuilder.from((factory) => ({
embedUrl: factory.text(),
allowFullScreen: factory.switch(),
allowScrolling: factory.switch({
defaultValue: true,
}),
allowTransparency: factory.switch(),
allowPayment: factory.switch(),
allowAutoPlay: factory.switch(),
allowMicrophone: factory.switch(),
allowCamera: factory.switch(),
allowGeolocation: factory.switch(),
})),
}).withDynamicImport(() => import("./component"));

View File

@@ -32,10 +32,7 @@ export const widgetImports = {
export type WidgetImports = typeof widgetImports;
export type WidgetImportKey = keyof WidgetImports;
const loadedComponents = new Map<
WidgetKind,
ComponentType<WidgetComponentProps<WidgetKind>>
>();
const loadedComponents = new Map<WidgetKind, ComponentType<WidgetComponentProps<WidgetKind>>>();
export const loadWidgetDynamic = <TKind extends WidgetKind>(kind: TKind) => {
const existingComponent = loadedComponents.get(kind);

View File

@@ -27,69 +27,51 @@ interface ModalProps<TSort extends WidgetKind> {
integrationSupport: boolean;
}
export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(
({ actions, innerProps }) => {
const t = useI18n();
const form = useForm({
initialValues: innerProps.value,
});
export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, innerProps }) => {
const t = useI18n();
const form = useForm({
initialValues: innerProps.value,
});
const { definition } = widgetImports[innerProps.kind];
const { definition } = widgetImports[innerProps.kind];
return (
<form
onSubmit={form.onSubmit((values) => {
innerProps.onSuccessfulEdit(values);
actions.closeModal();
})}
>
<FormProvider form={form}>
<Stack>
{innerProps.integrationSupport && (
<WidgetIntegrationSelect
label={t("item.edit.field.integrations.label")}
data={innerProps.integrationData}
{...form.getInputProps("integrations")}
/>
)}
{Object.entries(definition.options).map(
([key, value]: [string, OptionsBuilderResult[string]]) => {
const Input = getInputForType(value.type);
return (
<form
onSubmit={form.onSubmit((values) => {
innerProps.onSuccessfulEdit(values);
actions.closeModal();
})}
>
<FormProvider form={form}>
<Stack>
{innerProps.integrationSupport && (
<WidgetIntegrationSelect
label={t("item.edit.field.integrations.label")}
data={innerProps.integrationData}
{...form.getInputProps("integrations")}
/>
)}
{Object.entries(definition.options).map(([key, value]: [string, OptionsBuilderResult[string]]) => {
const Input = getInputForType(value.type);
if (
!Input ||
value.shouldHide?.(form.values.options as never)
) {
return null;
}
if (!Input || value.shouldHide?.(form.values.options as never)) {
return null;
}
return (
<Input
key={key}
kind={innerProps.kind}
property={key}
options={value as never}
/>
);
},
)}
<Group justify="right">
<Button
onClick={actions.closeModal}
variant="subtle"
color="gray"
>
{t("common.action.cancel")}
</Button>
<Button type="submit" color="teal">
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</FormProvider>
</form>
);
},
).withOptions({
return <Input key={key} kind={innerProps.kind} property={key} options={value as never} />;
})}
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit" color="teal">
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</FormProvider>
</form>
);
}).withOptions({
keepMounted: true,
});

View File

@@ -4,15 +4,10 @@ import "@mantine/tiptap/styles.css";
import type { WidgetComponentProps } from "../definition";
const Notebook = dynamic(
() => import("./notebook").then((module) => module.Notebook),
{
ssr: false,
},
);
const Notebook = dynamic(() => import("./notebook").then((module) => module.Notebook), {
ssr: false,
});
export default function NotebookWidget(
props: WidgetComponentProps<"notebook">,
) {
export default function NotebookWidget(props: WidgetComponentProps<"notebook">) {
return <Notebook {...props} />;
}

View File

@@ -4,27 +4,24 @@ import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { defaultContent } from "./default-content";
export const { definition, componentLoader } = createWidgetDefinition(
"notebook",
{
icon: IconNotes,
options: optionsBuilder.from(
(factory) => ({
showToolbar: factory.switch({
defaultValue: true,
}),
allowReadOnlyCheck: factory.switch({
defaultValue: true,
}),
content: factory.text({
defaultValue: defaultContent,
}),
export const { definition, componentLoader } = createWidgetDefinition("notebook", {
icon: IconNotes,
options: optionsBuilder.from(
(factory) => ({
showToolbar: factory.switch({
defaultValue: true,
}),
{
content: {
shouldHide: () => true, // Hide the content option as it can be modified in the editor
},
allowReadOnlyCheck: factory.switch({
defaultValue: true,
}),
content: factory.text({
defaultValue: defaultContent,
}),
}),
{
content: {
shouldHide: () => true, // Hide the content option as it can be modified in the editor
},
),
},
).withDynamicImport(() => import("./component"));
},
),
}).withDynamicImport(() => import("./component"));

View File

@@ -16,11 +16,7 @@ import {
useMantineTheme,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
Link,
RichTextEditor,
useRichTextEditorContext,
} from "@mantine/tiptap";
import { Link, RichTextEditor, useRichTextEditorContext } from "@mantine/tiptap";
import {
IconCheck,
IconCircleOff,
@@ -78,12 +74,7 @@ const controlIconProps = {
stroke: 1.5,
};
export function Notebook({
options,
isEditMode,
boardId,
itemId,
}: WidgetComponentProps<"notebook">) {
export function Notebook({ options, isEditMode, boardId, itemId }: WidgetComponentProps<"notebook">) {
const [content, setContent] = useState(options.content);
const [toSaveContent, setToSaveContent] = useState(content);
@@ -137,12 +128,9 @@ export function Notebook({
backgroundColor: {
default: undefined,
renderHTML: (attributes) => ({
style: attributes.backgroundColor
? `background-color: ${attributes.backgroundColor}`
: undefined,
style: attributes.backgroundColor ? `background-color: ${attributes.backgroundColor}` : undefined,
}),
parseHTML: (element) =>
element.style.backgroundColor || undefined,
parseHTML: (element) => element.style.backgroundColor || undefined,
},
};
},
@@ -178,9 +166,7 @@ export function Notebook({
[toSaveContent],
);
const handleOnReadOnlyCheck = (
event: CustomEventInit<{ node: Node; checked: boolean }>,
) => {
const handleOnReadOnlyCheck = (event: CustomEventInit<{ node: Node; checked: boolean }>) => {
if (!options.allowReadOnlyCheck) return;
if (!editor) return;
@@ -251,8 +237,7 @@ export function Notebook({
"& .ProseMirror": {
padding: "0 !important",
},
backgroundColor:
colorScheme === "dark" ? theme.colors.dark[6] : "white",
backgroundColor: colorScheme === "dark" ? theme.colors.dark[6] : "white",
border: "none",
borderRadius: "0.5rem",
display: "flex",
@@ -270,8 +255,7 @@ export function Notebook({
>
<RichTextEditor.Toolbar
style={{
display:
isEditing && options.showToolbar === true ? "flex" : "none",
display: isEditing && options.showToolbar === true ? "flex" : "none",
}}
>
<RichTextEditor.ControlsGroup>
@@ -319,9 +303,7 @@ export function Notebook({
<RichTextEditor.BulletList title={tControls("bulletList")} />
<RichTextEditor.OrderedList title={tControls("orderedList")} />
<TaskListToggle />
{(editor?.isActive("taskList") ||
editor?.isActive("bulletList") ||
editor?.isActive("orderedList")) && (
{(editor?.isActive("taskList") || editor?.isActive("bulletList") || editor?.isActive("orderedList")) && (
<>
<ListIndentIncrease />
<ListIndentDecrease />
@@ -368,9 +350,7 @@ export function Notebook({
{enabled && (
<>
<ActionIcon
title={
isEditing ? t("common.action.save") : t("common.action.edit")
}
title={isEditing ? t("common.action.save") : t("common.action.edit")}
style={{
zIndex: 1,
}}
@@ -383,11 +363,7 @@ export function Notebook({
radius={"md"}
onClick={handleEditToggle}
>
{isEditing ? (
<IconDeviceFloppy {...iconProps} />
) : (
<IconEdit {...iconProps} />
)}
{isEditing ? <IconDeviceFloppy {...iconProps} /> : <IconEdit {...iconProps} />}
</ActionIcon>
{isEditing && (
<ActionIcon
@@ -482,9 +458,7 @@ function ColorCellControl() {
const { editor } = useRichTextEditorContext();
const getCurrent = useCallback(() => {
return editor?.getAttributes("tableCell").backgroundColor as
| string
| undefined;
return editor?.getAttributes("tableCell").backgroundColor as string | undefined;
}, [editor]);
const update = useCallback(
@@ -513,13 +487,7 @@ interface ColorControlProps {
ariaLabel: string;
}
const ColorControl = ({
defaultColor,
getCurrent,
update,
icon: Icon,
ariaLabel,
}: ColorControlProps) => {
const ColorControl = ({ defaultColor, getCurrent, update, icon: Icon, ariaLabel }: ColorControlProps) => {
const { editor } = useRichTextEditorContext();
const [color, setColor] = useState(defaultColor);
const { colors, white } = useMantineTheme();
@@ -591,33 +559,15 @@ const ColorControl = ({
</Popover.Target>
<Popover.Dropdown>
<Stack gap={8}>
<ColorPicker
value={color}
onChange={setColor}
format="hexa"
swatches={palette}
swatchesPerRow={6}
/>
<ColorPicker value={color} onChange={setColor} format="hexa" swatches={palette} swatchesPerRow={6} />
<Group justify="right" gap={8}>
<ActionIcon
title={t("common.action.cancel")}
variant="default"
onClick={close}
>
<ActionIcon title={t("common.action.cancel")} variant="default" onClick={close}>
<IconX stroke={1.5} size="1rem" />
</ActionIcon>
<ActionIcon
title={t("common.action.apply")}
variant="default"
onClick={handleApplyColor}
>
<ActionIcon title={t("common.action.apply")} variant="default" onClick={handleApplyColor}>
<IconCheck stroke={1.5} size="1rem" />
</ActionIcon>
<ActionIcon
title={t("widget.notebook.popover.clearColor")}
variant="default"
onClick={handleClearColor}
>
<ActionIcon title={t("widget.notebook.popover.clearColor")} variant="default" onClick={handleClearColor}>
<IconCircleOff stroke={1.5} size="1rem" />
</ActionIcon>
</Group>
@@ -676,11 +626,7 @@ function EmbedImage() {
trapFocus
>
<Popover.Target>
<RichTextEditor.Control
onClick={toggle}
title={tControls("image")}
active={editor?.isActive("image")}
>
<RichTextEditor.Control onClick={toggle} title={tControls("image")} active={editor?.isActive("image")}>
<IconPhoto stroke={1.5} size="1rem" />
</RichTextEditor.Control>
</Popover.Target>
@@ -777,11 +723,7 @@ const handleAddColumnBefore = (editor: Editor) => {
};
const TableAddColumnBefore = () => (
<TableControl
title="addColumnLeft"
onClick={handleAddColumnBefore}
icon={IconColumnInsertLeft}
/>
<TableControl title="addColumnLeft" onClick={handleAddColumnBefore} icon={IconColumnInsertLeft} />
);
const handleAddColumnAfter = (editor: Editor) => {
@@ -789,11 +731,7 @@ const handleAddColumnAfter = (editor: Editor) => {
};
const TableAddColumnAfter = () => (
<TableControl
title="addColumnRight"
onClick={handleAddColumnAfter}
icon={IconColumnInsertRight}
/>
<TableControl title="addColumnRight" onClick={handleAddColumnAfter} icon={IconColumnInsertRight} />
);
const handleRemoveColumn = (editor: Editor) => {
@@ -801,54 +739,31 @@ const handleRemoveColumn = (editor: Editor) => {
};
const TableRemoveColumn = () => (
<TableControl
title="deleteColumn"
onClick={handleRemoveColumn}
icon={IconColumnRemove}
/>
<TableControl title="deleteColumn" onClick={handleRemoveColumn} icon={IconColumnRemove} />
);
const handleAddRowBefore = (editor: Editor) => {
editor.commands.addRowBefore();
};
const TableAddRowBefore = () => (
<TableControl
title="addRowTop"
onClick={handleAddRowBefore}
icon={IconRowInsertTop}
/>
);
const TableAddRowBefore = () => <TableControl title="addRowTop" onClick={handleAddRowBefore} icon={IconRowInsertTop} />;
const handleAddRowAfter = (editor: Editor) => {
editor.commands.addRowAfter();
};
const TableAddRowAfter = () => (
<TableControl
title="addRowBelow"
onClick={handleAddRowAfter}
icon={IconRowInsertBottom}
/>
<TableControl title="addRowBelow" onClick={handleAddRowAfter} icon={IconRowInsertBottom} />
);
const handleRemoveRow = (editor: Editor) => {
editor.commands.deleteRow();
};
const TableRemoveRow = () => (
<TableControl
title="deleteRow"
onClick={handleRemoveRow}
icon={IconRowRemove}
/>
);
const TableRemoveRow = () => <TableControl title="deleteRow" onClick={handleRemoveRow} icon={IconRowRemove} />;
interface TableControlProps {
title: Exclude<
keyof TranslationObject["widget"]["notebook"]["controls"],
"align" | "heading"
>;
title: Exclude<keyof TranslationObject["widget"]["notebook"]["controls"], "align" | "heading">;
onClick: (editor: Editor) => void;
icon: TablerIcon;
}
@@ -862,10 +777,7 @@ const TableControl = ({ title, onClick, icon: Icon }: TableControlProps) => {
}, [editor, onClick]);
return (
<RichTextEditor.Control
title={tControls(title)}
onClick={handleControlClick}
>
<RichTextEditor.Control title={tControls(title)} onClick={handleControlClick}>
<Icon {...controlIconProps} />
</RichTextEditor.Control>
);
@@ -958,26 +870,14 @@ function TableToggle() {
active={isActive}
onClick={handleControlClick}
>
{isActive ? (
<IconTableOff stroke={1.5} size="1rem" />
) : (
<IconTablePlus stroke={1.5} size="1rem" />
)}
{isActive ? <IconTableOff stroke={1.5} size="1rem" /> : <IconTablePlus stroke={1.5} size="1rem" />}
</RichTextEditor.Control>
</Popover.Target>
<Popover.Dropdown>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap={5}>
<NumberInput
label={t("widget.notebook.popover.columns")}
min={1}
{...form.getInputProps("cols")}
/>
<NumberInput
label={t("widget.notebook.popover.rows")}
min={1}
{...form.getInputProps("rows")}
/>
<NumberInput label={t("widget.notebook.popover.columns")} min={1} {...form.getInputProps("cols")} />
<NumberInput label={t("widget.notebook.popover.rows")} min={1} {...form.getInputProps("rows")} />
<Button type="submit" variant="default" mt={10} mb={5}>
{t("common.action.insert")}
</Button>

View File

@@ -3,10 +3,7 @@ import type { WidgetKind } from "@homarr/definitions";
import type { z } from "@homarr/validation";
import { widgetImports } from ".";
import type {
inferSelectOptionValue,
SelectOption,
} from "./_inputs/widget-select-input";
import type { inferSelectOptionValue, SelectOption } from "./_inputs/widget-select-input";
interface CommonInput<TType> {
defaultValue?: TType;
@@ -57,21 +54,16 @@ const optionsFactory = {
withDescription: input?.withDescription ?? false,
validate: input?.validate,
}),
multiSelect: <const TOptions extends SelectOption[]>(
input: MultiSelectInput<TOptions>,
) => ({
multiSelect: <const TOptions extends SelectOption[]>(input: MultiSelectInput<TOptions>) => ({
type: "multiSelect" as const,
defaultValue: input.defaultValue ?? [],
options: input.options,
searchable: input.searchable ?? false,
withDescription: input.withDescription ?? false,
}),
select: <const TOptions extends SelectOption[]>(
input: SelectInput<TOptions>,
) => ({
select: <const TOptions extends SelectOption[]>(input: SelectInput<TOptions>) => ({
type: "select" as const,
defaultValue: (input.defaultValue ??
input.options[0]) as inferSelectOptionValue<TOptions[number]>,
defaultValue: (input.defaultValue ?? input.options[0]) as inferSelectOptionValue<TOptions[number]>,
options: input.options,
searchable: input.searchable ?? false,
withDescription: input.withDescription ?? false,
@@ -112,18 +104,12 @@ const optionsFactory = {
};
type WidgetOptionFactory = typeof optionsFactory;
export type WidgetOptionDefinition = ReturnType<
WidgetOptionFactory[keyof WidgetOptionFactory]
>;
export type WidgetOptionDefinition = ReturnType<WidgetOptionFactory[keyof WidgetOptionFactory]>;
export type WidgetOptionsRecord = Record<string, WidgetOptionDefinition>;
export type WidgetOptionType = WidgetOptionDefinition["type"];
export type WidgetOptionOfType<TType extends WidgetOptionType> = Extract<
WidgetOptionDefinition,
{ type: TType }
>;
export type WidgetOptionOfType<TType extends WidgetOptionType> = Extract<WidgetOptionDefinition, { type: TType }>;
type inferOptionFromDefinition<TDefinition extends WidgetOptionDefinition> =
TDefinition["defaultValue"];
type inferOptionFromDefinition<TDefinition extends WidgetOptionDefinition> = TDefinition["defaultValue"];
export type inferOptionsFromDefinition<TOptions extends WidgetOptionsRecord> = {
[key in keyof TOptions]: inferOptionFromDefinition<TOptions[key]>;
};
@@ -162,10 +148,7 @@ export const optionsBuilder = {
from: createOptions,
};
export const reduceWidgetOptionsWithDefaultValues = (
kind: WidgetKind,
currentValue: Record<string, unknown> = {},
) => {
export const reduceWidgetOptionsWithDefaultValues = (kind: WidgetKind, currentValue: Record<string, unknown> = {}) => {
const definition = widgetImports[kind].definition;
const options = definition.options as Record<string, WidgetOptionDefinition>;
return objectEntries(options).reduce(

View File

@@ -12,31 +12,21 @@ type Data = Record<
>;
interface GlobalItemServerDataContext {
setItemServerData: (
id: string,
data: Record<string, unknown> | undefined,
) => void;
setItemServerData: (id: string, data: Record<string, unknown> | undefined) => void;
data: Data;
initalItemIds: string[];
}
const GlobalItemServerDataContext =
createContext<GlobalItemServerDataContext | null>(null);
const GlobalItemServerDataContext = createContext<GlobalItemServerDataContext | null>(null);
interface Props {
initalItemIds: string[];
}
export const GlobalItemServerDataProvider = ({
children,
initalItemIds,
}: PropsWithChildren<Props>) => {
export const GlobalItemServerDataProvider = ({ children, initalItemIds }: PropsWithChildren<Props>) => {
const [data, setData] = useState<Data>({});
const setItemServerData = (
id: string,
itemData: Record<string, unknown> | undefined,
) => {
const setItemServerData = (id: string, itemData: Record<string, unknown> | undefined) => {
setData((prev) => ({
...prev,
[id]: {
@@ -47,9 +37,7 @@ export const GlobalItemServerDataProvider = ({
};
return (
<GlobalItemServerDataContext.Provider
value={{ setItemServerData, data, initalItemIds }}
>
<GlobalItemServerDataContext.Provider value={{ setItemServerData, data, initalItemIds }}>
{children}
</GlobalItemServerDataContext.Provider>
);
@@ -73,10 +61,7 @@ export const useServerDataFor = (id: string) => {
return context.data[id];
};
export const useServerDataInitializer = (
id: string,
serverData: Record<string, unknown> | undefined,
) => {
export const useServerDataInitializer = (id: string, serverData: Record<string, unknown> | undefined) => {
const context = useContext(GlobalItemServerDataContext);
if (!context) {

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