Merge branch 'dev' into ajnart/fix-duplicate-users
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/tasks": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@trpc/client": "next",
|
||||
"@trpc/server": "next",
|
||||
"superjson": "2.2.1"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { integrationRouter } from "./router/integration";
|
||||
import { inviteRouter } from "./router/invite";
|
||||
import { locationRouter } from "./router/location";
|
||||
import { logRouter } from "./router/log";
|
||||
import { serverSettingsRouter } from "./router/serverSettings";
|
||||
import { userRouter } from "./router/user";
|
||||
import { widgetRouter } from "./router/widgets";
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
@@ -23,6 +24,7 @@ export const appRouter = createTRPCRouter({
|
||||
log: logRouter,
|
||||
icon: iconsRouter,
|
||||
home: homeRouter,
|
||||
serverSettings: serverSettingsRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -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));
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -12,54 +12,46 @@ import {
|
||||
integrationItems,
|
||||
items,
|
||||
sections,
|
||||
users,
|
||||
} from "@homarr/db/schema/sqlite";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
|
||||
import {
|
||||
createSectionSchema,
|
||||
sharedItemSchema,
|
||||
validation,
|
||||
z,
|
||||
} from "@homarr/validation";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
||||
import { createSectionSchema, sharedItemSchema, validation, z } from "@homarr/validation";
|
||||
|
||||
import { zodUnionFromArray } from "../../../validation/src/enums";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
permissionRequiredProcedure,
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from "../trpc";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
getAllBoards: publicProcedure.query(async ({ ctx }) => {
|
||||
const permissionsOfCurrentUserWhenPresent =
|
||||
await ctx.db.query.boardUserPermissions.findMany({
|
||||
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
|
||||
});
|
||||
const userId = ctx.session?.user.id;
|
||||
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||
where: eq(boardUserPermissions.userId, userId ?? ""),
|
||||
});
|
||||
|
||||
const permissionsOfCurrentUserGroupsWhenPresent =
|
||||
await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
|
||||
with: {
|
||||
group: {
|
||||
with: {
|
||||
boardPermissions: {},
|
||||
},
|
||||
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId ?? ""),
|
||||
with: {
|
||||
group: {
|
||||
with: {
|
||||
boardPermissions: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const boardIds = permissionsOfCurrentUserWhenPresent
|
||||
.map((permission) => permission.boardId)
|
||||
.concat(
|
||||
permissionsOfCurrentUserGroupsWhenPresent
|
||||
.map((groupMember) =>
|
||||
groupMember.group.boardPermissions.map(
|
||||
(permission) => permission.boardId,
|
||||
),
|
||||
)
|
||||
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
|
||||
.flat(),
|
||||
);
|
||||
|
||||
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, userId ?? ""),
|
||||
});
|
||||
|
||||
const dbBoards = await ctx.db.query.boards.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
@@ -82,9 +74,7 @@ export const boardRouter = createTRPCRouter({
|
||||
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
|
||||
? inArray(
|
||||
boardGroupPermissions.groupId,
|
||||
permissionsOfCurrentUserGroupsWhenPresent.map(
|
||||
(groupMember) => groupMember.groupId,
|
||||
),
|
||||
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
@@ -98,7 +88,10 @@ export const boardRouter = createTRPCRouter({
|
||||
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
|
||||
),
|
||||
});
|
||||
return dbBoards;
|
||||
return dbBoards.map((board) => ({
|
||||
...board,
|
||||
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
||||
}));
|
||||
}),
|
||||
createBoard: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
@@ -119,77 +112,56 @@ export const boardRouter = createTRPCRouter({
|
||||
});
|
||||
});
|
||||
}),
|
||||
renameBoard: protectedProcedure
|
||||
.input(validation.board.rename)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, input.id),
|
||||
"full-access",
|
||||
);
|
||||
renameBoard: protectedProcedure.input(validation.board.rename).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
|
||||
|
||||
await noBoardWithSimilarNameAsync(ctx.db, input.name, [input.id]);
|
||||
await noBoardWithSimilarNameAsync(ctx.db, input.name, [input.id]);
|
||||
|
||||
await ctx.db
|
||||
.update(boards)
|
||||
.set({ name: input.name })
|
||||
.where(eq(boards.id, input.id));
|
||||
}),
|
||||
await ctx.db.update(boards).set({ name: input.name }).where(eq(boards.id, input.id));
|
||||
}),
|
||||
changeBoardVisibility: protectedProcedure
|
||||
.input(validation.board.changeVisibility)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, input.id),
|
||||
"full-access",
|
||||
);
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
|
||||
|
||||
await ctx.db
|
||||
.update(boards)
|
||||
.set({ isPublic: input.visibility === "public" })
|
||||
.where(eq(boards.id, input.id));
|
||||
}),
|
||||
deleteBoard: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, input.id),
|
||||
"full-access",
|
||||
);
|
||||
deleteBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
|
||||
|
||||
await ctx.db.delete(boards).where(eq(boards.id, input.id));
|
||||
}),
|
||||
getDefaultBoard: publicProcedure.query(async ({ ctx }) => {
|
||||
const boardWhere = eq(boards.name, "default");
|
||||
await ctx.db.delete(boards).where(eq(boards.id, input.id));
|
||||
}),
|
||||
setHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "board-view");
|
||||
|
||||
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
getHomeBoard: publicProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const user = userId
|
||||
? await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
})
|
||||
: null;
|
||||
|
||||
const boardWhere = user?.homeBoardId ? eq(boards.id, user.homeBoardId) : eq(boards.name, "home");
|
||||
await throwIfActionForbiddenAsync(ctx, boardWhere, "board-view");
|
||||
|
||||
return await getFullBoardWithWhereAsync(
|
||||
ctx.db,
|
||||
boardWhere,
|
||||
ctx.session?.user.id ?? null,
|
||||
);
|
||||
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
||||
}),
|
||||
getBoardByName: publicProcedure
|
||||
.input(validation.board.byName)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const boardWhere = eq(boards.name, input.name);
|
||||
await throwIfActionForbiddenAsync(ctx, boardWhere, "board-view");
|
||||
getBoardByName: publicProcedure.input(validation.board.byName).query(async ({ input, ctx }) => {
|
||||
const boardWhere = eq(boards.name, input.name);
|
||||
await throwIfActionForbiddenAsync(ctx, boardWhere, "board-view");
|
||||
|
||||
return await getFullBoardWithWhereAsync(
|
||||
ctx.db,
|
||||
boardWhere,
|
||||
ctx.session?.user.id ?? null,
|
||||
);
|
||||
}),
|
||||
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
||||
}),
|
||||
savePartialBoardSettings: protectedProcedure
|
||||
.input(validation.board.savePartialSettings)
|
||||
.input(validation.board.savePartialSettings.and(z.object({ id: z.string() })))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, input.id),
|
||||
"board-change",
|
||||
);
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "board-change");
|
||||
|
||||
await ctx.db
|
||||
.update(boards)
|
||||
@@ -219,271 +191,223 @@ export const boardRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(boards.id, input.id));
|
||||
}),
|
||||
saveBoard: protectedProcedure
|
||||
.input(validation.board.save)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, input.id),
|
||||
"board-change",
|
||||
saveBoard: protectedProcedure.input(validation.board.save).mutation(async ({ input, ctx }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "board-change");
|
||||
|
||||
await ctx.db.transaction(async (transaction) => {
|
||||
const dbBoard = await getFullBoardWithWhereAsync(transaction, eq(boards.id, input.id), ctx.session.user.id);
|
||||
|
||||
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
|
||||
|
||||
if (addedSections.length > 0) {
|
||||
await transaction.insert(sections).values(
|
||||
addedSections.map((section) => ({
|
||||
id: section.id,
|
||||
kind: section.kind,
|
||||
position: section.position,
|
||||
name: "name" in section ? section.name : null,
|
||||
boardId: dbBoard.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const inputItems = input.sections.flatMap((section) =>
|
||||
section.items.map((item) => ({ ...item, sectionId: section.id })),
|
||||
);
|
||||
const dbItems = dbBoard.sections.flatMap((section) =>
|
||||
section.items.map((item) => ({ ...item, sectionId: section.id })),
|
||||
);
|
||||
|
||||
await ctx.db.transaction(async (transaction) => {
|
||||
const dbBoard = await getFullBoardWithWhereAsync(
|
||||
transaction,
|
||||
eq(boards.id, input.id),
|
||||
ctx.session.user.id,
|
||||
const addedItems = filterAddedItems(inputItems, dbItems);
|
||||
|
||||
if (addedItems.length > 0) {
|
||||
await transaction.insert(items).values(
|
||||
addedItems.map((item) => ({
|
||||
id: item.id,
|
||||
kind: item.kind,
|
||||
height: item.height,
|
||||
width: item.width,
|
||||
xOffset: item.xOffset,
|
||||
yOffset: item.yOffset,
|
||||
options: superjson.stringify(item.options),
|
||||
advancedOptions: superjson.stringify(item.advancedOptions),
|
||||
sectionId: item.sectionId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const addedSections = filterAddedItems(
|
||||
input.sections,
|
||||
dbBoard.sections,
|
||||
);
|
||||
|
||||
if (addedSections.length > 0) {
|
||||
await transaction.insert(sections).values(
|
||||
addedSections.map((section) => ({
|
||||
id: section.id,
|
||||
kind: section.kind,
|
||||
position: section.position,
|
||||
name: "name" in section ? section.name : null,
|
||||
boardId: dbBoard.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const inputItems = input.sections.flatMap((section) =>
|
||||
section.items.map((item) => ({ ...item, sectionId: section.id })),
|
||||
);
|
||||
const dbItems = dbBoard.sections.flatMap((section) =>
|
||||
section.items.map((item) => ({ ...item, sectionId: section.id })),
|
||||
);
|
||||
|
||||
const addedItems = filterAddedItems(inputItems, dbItems);
|
||||
|
||||
if (addedItems.length > 0) {
|
||||
await transaction.insert(items).values(
|
||||
addedItems.map((item) => ({
|
||||
id: item.id,
|
||||
kind: item.kind,
|
||||
height: item.height,
|
||||
width: item.width,
|
||||
xOffset: item.xOffset,
|
||||
yOffset: item.yOffset,
|
||||
options: superjson.stringify(item.options),
|
||||
sectionId: item.sectionId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const inputIntegrationRelations = inputItems.flatMap(
|
||||
({ integrations, id: itemId }) =>
|
||||
integrations.map((integration) => ({
|
||||
integrationId: integration.id,
|
||||
itemId,
|
||||
})),
|
||||
);
|
||||
const dbIntegrationRelations = dbItems.flatMap(
|
||||
({ integrations, id: itemId }) =>
|
||||
integrations.map((integration) => ({
|
||||
integrationId: integration.id,
|
||||
itemId,
|
||||
})),
|
||||
);
|
||||
const addedIntegrationRelations = inputIntegrationRelations.filter(
|
||||
(inputRelation) =>
|
||||
!dbIntegrationRelations.some(
|
||||
(dbRelation) =>
|
||||
dbRelation.itemId === inputRelation.itemId &&
|
||||
dbRelation.integrationId === inputRelation.integrationId,
|
||||
),
|
||||
);
|
||||
|
||||
if (addedIntegrationRelations.length > 0) {
|
||||
await transaction.insert(integrationItems).values(
|
||||
addedIntegrationRelations.map((relation) => ({
|
||||
itemId: relation.itemId,
|
||||
integrationId: relation.integrationId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const updatedItems = filterUpdatedItems(inputItems, dbItems);
|
||||
|
||||
for (const item of updatedItems) {
|
||||
await transaction
|
||||
.update(items)
|
||||
.set({
|
||||
kind: item.kind,
|
||||
height: item.height,
|
||||
width: item.width,
|
||||
xOffset: item.xOffset,
|
||||
yOffset: item.yOffset,
|
||||
options: superjson.stringify(item.options),
|
||||
sectionId: item.sectionId,
|
||||
})
|
||||
.where(eq(items.id, item.id));
|
||||
}
|
||||
|
||||
const updatedSections = filterUpdatedItems(
|
||||
input.sections,
|
||||
dbBoard.sections,
|
||||
);
|
||||
|
||||
for (const section of updatedSections) {
|
||||
const prev = dbBoard.sections.find(
|
||||
(dbSection) => dbSection.id === section.id,
|
||||
);
|
||||
await transaction
|
||||
.update(sections)
|
||||
.set({
|
||||
position: section.position,
|
||||
name:
|
||||
prev?.kind === "category" && "name" in section
|
||||
? section.name
|
||||
: null,
|
||||
})
|
||||
.where(eq(sections.id, section.id));
|
||||
}
|
||||
|
||||
const removedIntegrationRelations = dbIntegrationRelations.filter(
|
||||
(dbRelation) =>
|
||||
!inputIntegrationRelations.some(
|
||||
(inputRelation) =>
|
||||
dbRelation.itemId === inputRelation.itemId &&
|
||||
dbRelation.integrationId === inputRelation.integrationId,
|
||||
),
|
||||
);
|
||||
|
||||
for (const relation of removedIntegrationRelations) {
|
||||
await transaction
|
||||
.delete(integrationItems)
|
||||
.where(
|
||||
and(
|
||||
eq(integrationItems.itemId, relation.itemId),
|
||||
eq(integrationItems.integrationId, relation.integrationId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const removedItems = filterRemovedItems(inputItems, dbItems);
|
||||
|
||||
const itemIds = removedItems.map((item) => item.id);
|
||||
if (itemIds.length > 0) {
|
||||
await transaction.delete(items).where(inArray(items.id, itemIds));
|
||||
}
|
||||
|
||||
const removedSections = filterRemovedItems(
|
||||
input.sections,
|
||||
dbBoard.sections,
|
||||
);
|
||||
const sectionIds = removedSections.map((section) => section.id);
|
||||
|
||||
if (sectionIds.length > 0) {
|
||||
await transaction
|
||||
.delete(sections)
|
||||
.where(inArray(sections.id, sectionIds));
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
||||
getBoardPermissions: protectedProcedure
|
||||
.input(validation.board.permissions)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, input.id),
|
||||
"full-access",
|
||||
const inputIntegrationRelations = inputItems.flatMap(({ integrations, id: itemId }) =>
|
||||
integrations.map((integration) => ({
|
||||
integrationId: integration.id,
|
||||
itemId,
|
||||
})),
|
||||
);
|
||||
const dbIntegrationRelations = dbItems.flatMap(({ integrations, id: itemId }) =>
|
||||
integrations.map((integration) => ({
|
||||
integrationId: integration.id,
|
||||
itemId,
|
||||
})),
|
||||
);
|
||||
const addedIntegrationRelations = inputIntegrationRelations.filter(
|
||||
(inputRelation) =>
|
||||
!dbIntegrationRelations.some(
|
||||
(dbRelation) =>
|
||||
dbRelation.itemId === inputRelation.itemId && dbRelation.integrationId === inputRelation.integrationId,
|
||||
),
|
||||
);
|
||||
|
||||
const dbGroupPermissions = await ctx.db.query.groupPermissions.findMany({
|
||||
where: inArray(
|
||||
groupPermissions.permission,
|
||||
getPermissionsWithParents([
|
||||
"board-view-all",
|
||||
"board-modify-all",
|
||||
"board-full-access",
|
||||
]),
|
||||
),
|
||||
columns: {
|
||||
groupId: false,
|
||||
if (addedIntegrationRelations.length > 0) {
|
||||
await transaction.insert(integrationItems).values(
|
||||
addedIntegrationRelations.map((relation) => ({
|
||||
itemId: relation.itemId,
|
||||
integrationId: relation.integrationId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const updatedItems = filterUpdatedItems(inputItems, dbItems);
|
||||
|
||||
for (const item of updatedItems) {
|
||||
await transaction
|
||||
.update(items)
|
||||
.set({
|
||||
kind: item.kind,
|
||||
height: item.height,
|
||||
width: item.width,
|
||||
xOffset: item.xOffset,
|
||||
yOffset: item.yOffset,
|
||||
options: superjson.stringify(item.options),
|
||||
sectionId: item.sectionId,
|
||||
})
|
||||
.where(eq(items.id, item.id));
|
||||
}
|
||||
|
||||
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
|
||||
|
||||
for (const section of updatedSections) {
|
||||
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
|
||||
await transaction
|
||||
.update(sections)
|
||||
.set({
|
||||
position: section.position,
|
||||
name: prev?.kind === "category" && "name" in section ? section.name : null,
|
||||
})
|
||||
.where(eq(sections.id, section.id));
|
||||
}
|
||||
|
||||
const removedIntegrationRelations = dbIntegrationRelations.filter(
|
||||
(dbRelation) =>
|
||||
!inputIntegrationRelations.some(
|
||||
(inputRelation) =>
|
||||
dbRelation.itemId === inputRelation.itemId && dbRelation.integrationId === inputRelation.integrationId,
|
||||
),
|
||||
);
|
||||
|
||||
for (const relation of removedIntegrationRelations) {
|
||||
await transaction
|
||||
.delete(integrationItems)
|
||||
.where(
|
||||
and(
|
||||
eq(integrationItems.itemId, relation.itemId),
|
||||
eq(integrationItems.integrationId, relation.integrationId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const removedItems = filterRemovedItems(inputItems, dbItems);
|
||||
|
||||
const itemIds = removedItems.map((item) => item.id);
|
||||
if (itemIds.length > 0) {
|
||||
await transaction.delete(items).where(inArray(items.id, itemIds));
|
||||
}
|
||||
|
||||
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
|
||||
const sectionIds = removedSections.map((section) => section.id);
|
||||
|
||||
if (sectionIds.length > 0) {
|
||||
await transaction.delete(sections).where(inArray(sections.id, sectionIds));
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
||||
getBoardPermissions: protectedProcedure.input(validation.board.permissions).query(async ({ input, ctx }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
|
||||
|
||||
const dbGroupPermissions = await ctx.db.query.groupPermissions.findMany({
|
||||
where: inArray(
|
||||
groupPermissions.permission,
|
||||
getPermissionsWithParents(["board-view-all", "board-modify-all", "board-full-access"]),
|
||||
),
|
||||
columns: {
|
||||
groupId: false,
|
||||
},
|
||||
with: {
|
||||
group: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
with: {
|
||||
},
|
||||
});
|
||||
|
||||
const userPermissions = await ctx.db.query.boardUserPermissions.findMany({
|
||||
where: eq(boardUserPermissions.boardId, input.id),
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dbGroupBoardPermission = await ctx.db.query.boardGroupPermissions.findMany({
|
||||
where: eq(boardGroupPermissions.boardId, input.id),
|
||||
with: {
|
||||
group: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
|
||||
return permissionA.group.name.localeCompare(permissionB.group.name);
|
||||
}),
|
||||
userPermissions: userPermissions
|
||||
.map(({ user, permission }) => ({
|
||||
user,
|
||||
permission,
|
||||
}))
|
||||
.sort((permissionA, permissionB) => {
|
||||
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
|
||||
}),
|
||||
groupPermissions: dbGroupBoardPermission
|
||||
.map(({ group, permission }) => ({
|
||||
group: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const userPermissions = await ctx.db.query.boardUserPermissions.findMany({
|
||||
where: eq(boardUserPermissions.boardId, input.id),
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dbGroupBoardPermission =
|
||||
await ctx.db.query.boardGroupPermissions.findMany({
|
||||
where: eq(boardGroupPermissions.boardId, input.id),
|
||||
with: {
|
||||
group: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
|
||||
permission,
|
||||
}))
|
||||
.sort((permissionA, permissionB) => {
|
||||
return permissionA.group.name.localeCompare(permissionB.group.name);
|
||||
}),
|
||||
userPermissions: userPermissions
|
||||
.map(({ user, permission }) => ({
|
||||
user,
|
||||
permission,
|
||||
}))
|
||||
.sort((permissionA, permissionB) => {
|
||||
return (permissionA.user.name ?? "").localeCompare(
|
||||
permissionB.user.name ?? "",
|
||||
);
|
||||
}),
|
||||
groupPermissions: dbGroupBoardPermission
|
||||
.map(({ group, permission }) => ({
|
||||
group: {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
},
|
||||
permission,
|
||||
}))
|
||||
.sort((permissionA, permissionB) => {
|
||||
return permissionA.group.name.localeCompare(permissionB.group.name);
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
saveUserBoardPermissions: protectedProcedure
|
||||
.input(validation.board.savePermissions)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, input.id),
|
||||
"full-access",
|
||||
);
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
|
||||
|
||||
await ctx.db.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.delete(boardUserPermissions)
|
||||
.where(eq(boardUserPermissions.boardId, input.id));
|
||||
await transaction.delete(boardUserPermissions).where(eq(boardUserPermissions.boardId, input.id));
|
||||
if (input.permissions.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -499,16 +423,10 @@ export const boardRouter = createTRPCRouter({
|
||||
saveGroupBoardPermissions: protectedProcedure
|
||||
.input(validation.board.savePermissions)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, input.id),
|
||||
"full-access",
|
||||
);
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full-access");
|
||||
|
||||
await ctx.db.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.delete(boardGroupPermissions)
|
||||
.where(eq(boardGroupPermissions.boardId, input.id));
|
||||
await transaction.delete(boardGroupPermissions).where(eq(boardGroupPermissions.boardId, input.id));
|
||||
if (input.permissions.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -523,11 +441,7 @@ export const boardRouter = createTRPCRouter({
|
||||
}),
|
||||
});
|
||||
|
||||
const noBoardWithSimilarNameAsync = async (
|
||||
db: Database,
|
||||
name: string,
|
||||
ignoredIds: string[] = [],
|
||||
) => {
|
||||
const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => {
|
||||
const boards = await db.query.boards.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
@@ -536,9 +450,7 @@ const noBoardWithSimilarNameAsync = async (
|
||||
});
|
||||
|
||||
const board = boards.find(
|
||||
(board) =>
|
||||
board.name.toLowerCase() === name.toLowerCase() &&
|
||||
!ignoredIds.includes(board.id),
|
||||
(board) => board.name.toLowerCase() === name.toLowerCase() && !ignoredIds.includes(board.id),
|
||||
);
|
||||
|
||||
if (board) {
|
||||
@@ -549,11 +461,7 @@ const noBoardWithSimilarNameAsync = async (
|
||||
}
|
||||
};
|
||||
|
||||
const getFullBoardWithWhereAsync = async (
|
||||
db: Database,
|
||||
where: SQL<unknown>,
|
||||
userId: string | null,
|
||||
) => {
|
||||
const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, userId: string | null) => {
|
||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId ?? ""),
|
||||
});
|
||||
@@ -587,10 +495,7 @@ const getFullBoardWithWhereAsync = async (
|
||||
},
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
boardGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId).concat(""),
|
||||
),
|
||||
where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -612,6 +517,7 @@ const getFullBoardWithWhereAsync = async (
|
||||
items: section.items.map((item) => ({
|
||||
...item,
|
||||
integrations: item.integrations.map((item) => item.integration),
|
||||
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
||||
options: superjson.parse<Record<string, unknown>>(item.options),
|
||||
})),
|
||||
}),
|
||||
@@ -625,9 +531,7 @@ const forKind = <T extends WidgetKind>(kind: T) =>
|
||||
options: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
const outputItemSchema = zodUnionFromArray(
|
||||
widgetKinds.map((kind) => forKind(kind)),
|
||||
).and(sharedItemSchema);
|
||||
const outputItemSchema = zodUnionFromArray(widgetKinds.map((kind) => forKind(kind))).and(sharedItemSchema);
|
||||
|
||||
const parseSection = (section: unknown) => {
|
||||
const result = createSectionSchema(outputItemSchema).safeParse(section);
|
||||
@@ -637,26 +541,11 @@ const parseSection = (section: unknown) => {
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const filterAddedItems = <TInput extends { id: string }>(
|
||||
inputArray: TInput[],
|
||||
dbArray: TInput[],
|
||||
) =>
|
||||
inputArray.filter(
|
||||
(inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id),
|
||||
);
|
||||
const filterAddedItems = <TInput extends { id: string }>(inputArray: TInput[], dbArray: TInput[]) =>
|
||||
inputArray.filter((inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id));
|
||||
|
||||
const filterRemovedItems = <TInput extends { id: string }>(
|
||||
inputArray: TInput[],
|
||||
dbArray: TInput[],
|
||||
) =>
|
||||
dbArray.filter(
|
||||
(dbItem) => !inputArray.some((inputItem) => dbItem.id === inputItem.id),
|
||||
);
|
||||
const filterRemovedItems = <TInput extends { id: string }>(inputArray: TInput[], dbArray: TInput[]) =>
|
||||
dbArray.filter((dbItem) => !inputArray.some((inputItem) => dbItem.id === inputItem.id));
|
||||
|
||||
const filterUpdatedItems = <TInput extends { id: string }>(
|
||||
inputArray: TInput[],
|
||||
dbArray: TInput[],
|
||||
) =>
|
||||
inputArray.filter((inputItem) =>
|
||||
dbArray.some((dbItem) => dbItem.id === inputItem.id),
|
||||
);
|
||||
const filterUpdatedItems = <TInput extends { id: string }>(inputArray: TInput[], dbArray: TInput[]) =>
|
||||
inputArray.filter((inputItem) => dbArray.some((dbItem) => dbItem.id === inputItem.id));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,11 +8,7 @@ export const locationRouter = createTRPCRouter({
|
||||
.input(validation.location.searchCity.input)
|
||||
.output(validation.location.searchCity.output)
|
||||
.query(async ({ input }) => {
|
||||
const res = await fetch(
|
||||
`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`,
|
||||
);
|
||||
return (await res.json()) as z.infer<
|
||||
typeof validation.location.searchCity.output
|
||||
>;
|
||||
const res = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`);
|
||||
return (await res.json()) as z.infer<typeof validation.location.searchCity.output>;
|
||||
}),
|
||||
});
|
||||
|
||||
41
packages/api/src/router/serverSettings.ts
Normal file
41
packages/api/src/router/serverSettings.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { eq } from "@homarr/db";
|
||||
import { serverSettings } from "@homarr/db/schema/sqlite";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
export const serverSettingsRouter = createTRPCRouter({
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
const settings = await ctx.db.query.serverSettings.findMany();
|
||||
|
||||
const data = {} as ServerSettings;
|
||||
defaultServerSettingsKeys.forEach((key) => {
|
||||
const settingValue = settings.find((setting) => setting.settingKey === key)?.value;
|
||||
if (!settingValue) {
|
||||
return;
|
||||
}
|
||||
data[key] = SuperJSON.parse(settingValue);
|
||||
});
|
||||
return data;
|
||||
}),
|
||||
saveSettings: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
settingsKey: z.enum(defaultServerSettingsKeys),
|
||||
value: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const databaseRunResult = await ctx.db
|
||||
.update(serverSettings)
|
||||
.set({
|
||||
value: SuperJSON.stringify(input.value),
|
||||
})
|
||||
.where(eq(serverSettings.settingKey, input.settingsKey));
|
||||
return databaseRunResult.changes === 1;
|
||||
}),
|
||||
});
|
||||
@@ -41,6 +41,7 @@ const createRandomUserAsync = async (db: Database) => {
|
||||
const userId = createId();
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
homeBoardId: null,
|
||||
});
|
||||
return userId;
|
||||
};
|
||||
@@ -154,10 +155,7 @@ describe("getAllBoards should return all boards accessable to the current user",
|
||||
|
||||
// Assert
|
||||
expect(result.length).toBe(2);
|
||||
expect(result.map(({ name }) => name)).toStrictEqual([
|
||||
"public",
|
||||
"private2",
|
||||
]);
|
||||
expect(result.map(({ name }) => name)).toStrictEqual(["public", "private2"]);
|
||||
});
|
||||
|
||||
test.each([["board-view"], ["board-change"]] satisfies [BoardPermission][])(
|
||||
@@ -220,10 +218,7 @@ describe("getAllBoards should return all boards accessable to the current user",
|
||||
|
||||
// Assert
|
||||
expect(result.length).toBe(2);
|
||||
expect(result.map(({ name }) => name)).toStrictEqual([
|
||||
"public",
|
||||
"private1",
|
||||
]);
|
||||
expect(result.map(({ name }) => name)).toStrictEqual(["public", "private1"]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -276,10 +271,7 @@ describe("getAllBoards should return all boards accessable to the current user",
|
||||
|
||||
// Assert
|
||||
expect(result.length).toBe(2);
|
||||
expect(result.map(({ name }) => name)).toStrictEqual([
|
||||
"public",
|
||||
"private1",
|
||||
]);
|
||||
expect(result.map(({ name }) => name)).toStrictEqual(["public", "private1"]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -355,11 +347,7 @@ describe("rename board should rename board", () => {
|
||||
});
|
||||
expect(dbBoard).toBeDefined();
|
||||
expect(dbBoard?.name).toBe("newName");
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"full-access",
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
|
||||
});
|
||||
|
||||
test("should throw error when similar board name exists", async () => {
|
||||
@@ -383,13 +371,10 @@ describe("rename board should rename board", () => {
|
||||
});
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
await caller.renameBoard({ id: boardId, name: "Newname" });
|
||||
const actAsync = async () => await caller.renameBoard({ id: boardId, name: "Newname" });
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrowError(
|
||||
"Board with similar name already exists",
|
||||
);
|
||||
await expect(actAsync()).rejects.toThrowError("Board with similar name already exists");
|
||||
});
|
||||
|
||||
test("should throw error when board not found", async () => {
|
||||
@@ -398,8 +383,7 @@ describe("rename board should rename board", () => {
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
await caller.renameBoard({ id: "nonExistentBoardId", name: "newName" });
|
||||
const actAsync = async () => await caller.renameBoard({ id: "nonExistentBoardId", name: "newName" });
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrowError("Board not found");
|
||||
@@ -438,11 +422,7 @@ describe("changeBoardVisibility should change board visibility", () => {
|
||||
});
|
||||
expect(dbBoard).toBeDefined();
|
||||
expect(dbBoard?.isPublic).toBe(visibility === "public");
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"full-access",
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -472,11 +452,7 @@ describe("deleteBoard should delete board", () => {
|
||||
where: eq(boards.id, boardId),
|
||||
});
|
||||
expect(dbBoard).toBeUndefined();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"full-access",
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
|
||||
});
|
||||
|
||||
test("should throw error when board not found", async () => {
|
||||
@@ -485,65 +461,53 @@ describe("deleteBoard should delete board", () => {
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
await caller.deleteBoard({ id: "nonExistentBoardId" });
|
||||
const actAsync = async () => await caller.deleteBoard({ id: "nonExistentBoardId" });
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrowError("Board not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultBoard should return default board", () => {
|
||||
it("should return default board", async () => {
|
||||
describe("getHomeBoard should return home board", () => {
|
||||
it("should return home board", async () => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const fullBoardProps = await createFullBoardAsync(db, "default");
|
||||
const fullBoardProps = await createFullBoardAsync(db, "home");
|
||||
|
||||
// Act
|
||||
const result = await caller.getDefaultBoard();
|
||||
const result = await caller.getHomeBoard();
|
||||
|
||||
// Assert
|
||||
expectInputToBeFullBoardWithName(result, {
|
||||
name: "default",
|
||||
name: "home",
|
||||
...fullBoardProps,
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"board-view",
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-view");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBoardByName should return board by name", () => {
|
||||
it.each([["default"], ["something"]])(
|
||||
"should return board by name %s when present",
|
||||
async (name) => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
it.each([["default"], ["something"]])("should return board by name %s when present", async (name) => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const fullBoardProps = await createFullBoardAsync(db, name);
|
||||
const fullBoardProps = await createFullBoardAsync(db, name);
|
||||
|
||||
// Act
|
||||
const result = await caller.getBoardByName({ name });
|
||||
// Act
|
||||
const result = await caller.getBoardByName({ name });
|
||||
|
||||
// Assert
|
||||
expectInputToBeFullBoardWithName(result, {
|
||||
name,
|
||||
...fullBoardProps,
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"board-view",
|
||||
);
|
||||
},
|
||||
);
|
||||
// Assert
|
||||
expectInputToBeFullBoardWithName(result, {
|
||||
name,
|
||||
...fullBoardProps,
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-view");
|
||||
});
|
||||
|
||||
it("should throw error when not present", async () => {
|
||||
// Arrange
|
||||
@@ -552,8 +516,7 @@ describe("getBoardByName should return board by name", () => {
|
||||
await createFullBoardAsync(db, "default");
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
await caller.getBoardByName({ name: "nonExistentBoard" });
|
||||
const actAsync = async () => await caller.getBoardByName({ name: "nonExistentBoard" });
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrowError("Board not found");
|
||||
@@ -610,9 +573,7 @@ describe("savePartialBoardSettings should save general settings", () => {
|
||||
expect(dbBoard?.metaTitle).toBe(newMetaTitle);
|
||||
expect(dbBoard?.logoImageUrl).toBe(newLogoImageUrl);
|
||||
expect(dbBoard?.faviconImageUrl).toBe(newFaviconImageUrl);
|
||||
expect(dbBoard?.backgroundImageAttachment).toBe(
|
||||
newBackgroundImageAttachment,
|
||||
);
|
||||
expect(dbBoard?.backgroundImageAttachment).toBe(newBackgroundImageAttachment);
|
||||
expect(dbBoard?.backgroundImageRepeat).toBe(newBackgroundImageRepeat);
|
||||
expect(dbBoard?.backgroundImageSize).toBe(newBackgroundImageSize);
|
||||
expect(dbBoard?.backgroundImageUrl).toBe(newBackgroundImageUrl);
|
||||
@@ -622,11 +583,7 @@ describe("savePartialBoardSettings should save general settings", () => {
|
||||
expect(dbBoard?.primaryColor).toBe(newPrimaryColor);
|
||||
expect(dbBoard?.secondaryColor).toBe(newSecondaryColor);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"board-change",
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
|
||||
});
|
||||
|
||||
it("should throw error when board not found", async () => {
|
||||
@@ -681,21 +638,14 @@ describe("saveBoard should save full board", () => {
|
||||
expect(definedBoard.sections.length).toBe(1);
|
||||
expect(definedBoard.sections[0]?.id).not.toBe(sectionId);
|
||||
expect(section).toBeUndefined();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"board-change",
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
|
||||
});
|
||||
it("should remove item when not present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const { boardId, itemId, sectionId } = await createFullBoardAsync(
|
||||
db,
|
||||
"default",
|
||||
);
|
||||
const { boardId, itemId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
@@ -714,6 +664,7 @@ describe("saveBoard should save full board", () => {
|
||||
width: 1,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
advancedOptions: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -741,11 +692,7 @@ describe("saveBoard should save full board", () => {
|
||||
expect(firstSection.items.length).toBe(1);
|
||||
expect(firstSection.items[0]?.id).not.toBe(itemId);
|
||||
expect(item).toBeUndefined();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"board-change",
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
|
||||
});
|
||||
it("should remove integration reference when not present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
@@ -758,8 +705,7 @@ describe("saveBoard should save full board", () => {
|
||||
url: "http://localhost:3000",
|
||||
} as const;
|
||||
|
||||
const { boardId, itemId, integrationId, sectionId } =
|
||||
await createFullBoardAsync(db, "default");
|
||||
const { boardId, itemId, integrationId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
await db.insert(integrations).values(anotherIntegration);
|
||||
|
||||
await caller.saveBoard({
|
||||
@@ -779,6 +725,7 @@ describe("saveBoard should save full board", () => {
|
||||
width: 1,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
advancedOptions: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -812,71 +759,61 @@ describe("saveBoard should save full board", () => {
|
||||
expect(firstItem.integrations.length).toBe(1);
|
||||
expect(firstItem.integrations[0]?.integrationId).not.toBe(integrationId);
|
||||
expect(integration).toBeUndefined();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"board-change",
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
|
||||
});
|
||||
it.each([
|
||||
[{ kind: "empty" as const }],
|
||||
[{ kind: "category" as const, name: "My first category" }],
|
||||
])("should add section when present in input", async (partialSection) => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
it.each([[{ kind: "empty" as const }], [{ kind: "category" as const, name: "My first category" }]])(
|
||||
"should add section when present in input",
|
||||
async (partialSection) => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
const newSectionId = createId();
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: newSectionId,
|
||||
position: 1,
|
||||
items: [],
|
||||
...partialSection,
|
||||
const newSectionId = createId();
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: newSectionId,
|
||||
position: 1,
|
||||
items: [],
|
||||
...partialSection,
|
||||
},
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "empty",
|
||||
position: 0,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const board = await db.query.boards.findFirst({
|
||||
where: eq(boards.id, boardId),
|
||||
with: {
|
||||
sections: true,
|
||||
},
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "empty",
|
||||
position: 0,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const board = await db.query.boards.findFirst({
|
||||
where: eq(boards.id, boardId),
|
||||
with: {
|
||||
sections: true,
|
||||
},
|
||||
});
|
||||
const section = await db.query.sections.findFirst({
|
||||
where: eq(sections.id, newSectionId),
|
||||
});
|
||||
|
||||
const section = await db.query.sections.findFirst({
|
||||
where: eq(sections.id, newSectionId),
|
||||
});
|
||||
|
||||
const definedBoard = expectToBeDefined(board);
|
||||
expect(definedBoard.sections.length).toBe(2);
|
||||
const addedSection = expectToBeDefined(
|
||||
definedBoard.sections.find((section) => section.id === newSectionId),
|
||||
);
|
||||
expect(addedSection).toBeDefined();
|
||||
expect(addedSection.id).toBe(newSectionId);
|
||||
expect(addedSection.kind).toBe(partialSection.kind);
|
||||
expect(addedSection.position).toBe(1);
|
||||
if ("name" in partialSection) {
|
||||
expect(addedSection.name).toBe(partialSection.name);
|
||||
}
|
||||
expect(section).toBeDefined();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"board-change",
|
||||
);
|
||||
});
|
||||
const definedBoard = expectToBeDefined(board);
|
||||
expect(definedBoard.sections.length).toBe(2);
|
||||
const addedSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === newSectionId));
|
||||
expect(addedSection).toBeDefined();
|
||||
expect(addedSection.id).toBe(newSectionId);
|
||||
expect(addedSection.kind).toBe(partialSection.kind);
|
||||
expect(addedSection.position).toBe(1);
|
||||
if ("name" in partialSection) {
|
||||
expect(addedSection.name).toBe(partialSection.name);
|
||||
}
|
||||
expect(section).toBeDefined();
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
|
||||
},
|
||||
);
|
||||
it("should add item when present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
@@ -902,6 +839,7 @@ describe("saveBoard should save full board", () => {
|
||||
width: 1,
|
||||
xOffset: 3,
|
||||
yOffset: 2,
|
||||
advancedOptions: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -927,25 +865,17 @@ describe("saveBoard should save full board", () => {
|
||||
expect(definedBoard.sections.length).toBe(1);
|
||||
const firstSection = expectToBeDefined(definedBoard.sections[0]);
|
||||
expect(firstSection.items.length).toBe(1);
|
||||
const addedItem = expectToBeDefined(
|
||||
firstSection.items.find((item) => item.id === newItemId),
|
||||
);
|
||||
const addedItem = expectToBeDefined(firstSection.items.find((item) => item.id === newItemId));
|
||||
expect(addedItem).toBeDefined();
|
||||
expect(addedItem.id).toBe(newItemId);
|
||||
expect(addedItem.kind).toBe("clock");
|
||||
expect(addedItem.options).toBe(
|
||||
SuperJSON.stringify({ is24HourFormat: true }),
|
||||
);
|
||||
expect(addedItem.options).toBe(SuperJSON.stringify({ is24HourFormat: true }));
|
||||
expect(addedItem.height).toBe(1);
|
||||
expect(addedItem.width).toBe(1);
|
||||
expect(addedItem.xOffset).toBe(3);
|
||||
expect(addedItem.yOffset).toBe(2);
|
||||
expect(item).toBeDefined();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"board-change",
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
|
||||
});
|
||||
it("should add integration reference when present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
@@ -958,10 +888,7 @@ describe("saveBoard should save full board", () => {
|
||||
url: "http://plex.local",
|
||||
} as const;
|
||||
|
||||
const { boardId, itemId, sectionId } = await createFullBoardAsync(
|
||||
db,
|
||||
"default",
|
||||
);
|
||||
const { boardId, itemId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
await db.insert(integrations).values(integration);
|
||||
|
||||
await caller.saveBoard({
|
||||
@@ -981,6 +908,7 @@ describe("saveBoard should save full board", () => {
|
||||
width: 1,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
advancedOptions: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1010,17 +938,11 @@ describe("saveBoard should save full board", () => {
|
||||
expect(definedBoard.sections.length).toBe(1);
|
||||
const firstSection = expectToBeDefined(definedBoard.sections[0]);
|
||||
expect(firstSection.items.length).toBe(1);
|
||||
const firstItem = expectToBeDefined(
|
||||
firstSection.items.find((item) => item.id === itemId),
|
||||
);
|
||||
const firstItem = expectToBeDefined(firstSection.items.find((item) => item.id === itemId));
|
||||
expect(firstItem.integrations.length).toBe(1);
|
||||
expect(firstItem.integrations[0]?.integrationId).toBe(integration.id);
|
||||
expect(integrationItem).toBeDefined();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"board-change",
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
|
||||
});
|
||||
it("should update section when present in input", async () => {
|
||||
const db = createDb();
|
||||
@@ -1065,16 +987,12 @@ describe("saveBoard should save full board", () => {
|
||||
|
||||
const definedBoard = expectToBeDefined(board);
|
||||
expect(definedBoard.sections.length).toBe(2);
|
||||
const firstSection = expectToBeDefined(
|
||||
definedBoard.sections.find((section) => section.id === sectionId),
|
||||
);
|
||||
const firstSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === sectionId));
|
||||
expect(firstSection.id).toBe(sectionId);
|
||||
expect(firstSection.kind).toBe("empty");
|
||||
expect(firstSection.position).toBe(1);
|
||||
expect(firstSection.name).toBe(null);
|
||||
const secondSection = expectToBeDefined(
|
||||
definedBoard.sections.find((section) => section.id === newSectionId),
|
||||
);
|
||||
const secondSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === newSectionId));
|
||||
expect(secondSection.id).toBe(newSectionId);
|
||||
expect(secondSection.kind).toBe("category");
|
||||
expect(secondSection.position).toBe(0);
|
||||
@@ -1085,10 +1003,7 @@ describe("saveBoard should save full board", () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const { boardId, itemId, sectionId } = await createFullBoardAsync(
|
||||
db,
|
||||
"default",
|
||||
);
|
||||
const { boardId, itemId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
@@ -1107,6 +1022,7 @@ describe("saveBoard should save full board", () => {
|
||||
width: 2,
|
||||
xOffset: 7,
|
||||
yOffset: 5,
|
||||
advancedOptions: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1128,24 +1044,15 @@ describe("saveBoard should save full board", () => {
|
||||
expect(definedBoard.sections.length).toBe(1);
|
||||
const firstSection = expectToBeDefined(definedBoard.sections[0]);
|
||||
expect(firstSection.items.length).toBe(1);
|
||||
const firstItem = expectToBeDefined(
|
||||
firstSection.items.find((item) => item.id === itemId),
|
||||
);
|
||||
const firstItem = expectToBeDefined(firstSection.items.find((item) => item.id === itemId));
|
||||
expect(firstItem.id).toBe(itemId);
|
||||
expect(firstItem.kind).toBe("clock");
|
||||
expect(
|
||||
SuperJSON.parse<{ is24HourFormat: boolean }>(firstItem.options)
|
||||
.is24HourFormat,
|
||||
).toBe(false);
|
||||
expect(SuperJSON.parse<{ is24HourFormat: boolean }>(firstItem.options).is24HourFormat).toBe(false);
|
||||
expect(firstItem.height).toBe(3);
|
||||
expect(firstItem.width).toBe(2);
|
||||
expect(firstItem.xOffset).toBe(7);
|
||||
expect(firstItem.yOffset).toBe(5);
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"board-change",
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "board-change");
|
||||
});
|
||||
it("should fail when board not found", async () => {
|
||||
const db = createDb();
|
||||
@@ -1215,9 +1122,7 @@ describe("getBoardPermissions should return board permissions", () => {
|
||||
const result = await caller.getBoardPermissions({ id: boardId });
|
||||
|
||||
// Assert
|
||||
expect(result.groupPermissions).toEqual([
|
||||
{ group: { id: groupId, name: "group1" }, permission: "board-view" },
|
||||
]);
|
||||
expect(result.groupPermissions).toEqual([{ group: { id: groupId, name: "group1" }, permission: "board-view" }]);
|
||||
expect(result.userPermissions).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
@@ -1230,14 +1135,8 @@ describe("getBoardPermissions should return board permissions", () => {
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(result.inherited).toEqual([
|
||||
{ group: { id: groupId, name: "group1" }, permission: "admin" },
|
||||
]);
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"full-access",
|
||||
);
|
||||
expect(result.inherited).toEqual([{ group: { id: groupId, name: "group1" }, permission: "admin" }]);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1278,11 +1177,7 @@ describe("saveUserBoardPermissions should save user board permissions", () => {
|
||||
where: eq(boardUserPermissions.userId, user1),
|
||||
});
|
||||
expect(dbUserPermission).toBeDefined();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"full-access",
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1329,17 +1224,13 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
|
||||
where: eq(boardGroupPermissions.groupId, groupId),
|
||||
});
|
||||
expect(dbGroupPermission).toBeDefined();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"full-access",
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "full-access");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const expectInputToBeFullBoardWithName = (
|
||||
input: RouterOutputs["board"]["getDefaultBoard"],
|
||||
input: RouterOutputs["board"]["getHomeBoard"],
|
||||
props: { name: string } & Awaited<ReturnType<typeof createFullBoardAsync>>,
|
||||
) => {
|
||||
expect(input.id).toBe(props.boardId);
|
||||
|
||||
@@ -9,10 +9,7 @@ import { throwIfActionForbiddenAsync } from "../../board/board-access";
|
||||
|
||||
const defaultCreatorId = createId();
|
||||
|
||||
const expectActToBeAsync = async (
|
||||
act: () => Promise<void>,
|
||||
success: boolean,
|
||||
) => {
|
||||
const expectActToBeAsync = async (act: () => Promise<void>, success: boolean) => {
|
||||
if (!success) {
|
||||
await expect(act()).rejects.toThrow("Board not found");
|
||||
return;
|
||||
@@ -29,161 +26,124 @@ describe("throwIfActionForbiddenAsync should check access to board and return bo
|
||||
["full-access" as const, true],
|
||||
["board-change" as const, true],
|
||||
["board-view" as const, true],
|
||||
])(
|
||||
"with permission %s should return %s when hasFullAccess is true",
|
||||
async (permission, expectedResult) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const spy = vi.spyOn(authShared, "constructBoardPermissions");
|
||||
spy.mockReturnValue({
|
||||
hasFullAccess: true,
|
||||
hasChangeAccess: false,
|
||||
hasViewAccess: false,
|
||||
});
|
||||
])("with permission %s should return %s when hasFullAccess is true", async (permission, expectedResult) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const spy = vi.spyOn(authShared, "constructBoardPermissions");
|
||||
spy.mockReturnValue({
|
||||
hasFullAccess: true,
|
||||
hasChangeAccess: false,
|
||||
hasViewAccess: false,
|
||||
});
|
||||
|
||||
await db.insert(users).values({ id: defaultCreatorId });
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "test",
|
||||
creatorId: defaultCreatorId,
|
||||
});
|
||||
await db.insert(users).values({ id: defaultCreatorId });
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "test",
|
||||
creatorId: defaultCreatorId,
|
||||
});
|
||||
|
||||
// Act
|
||||
const act = () =>
|
||||
throwIfActionForbiddenAsync(
|
||||
{ db, session: null },
|
||||
eq(boards.id, boardId),
|
||||
permission,
|
||||
);
|
||||
// Act
|
||||
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, boardId), permission);
|
||||
|
||||
// Assert
|
||||
await expectActToBeAsync(act, expectedResult);
|
||||
},
|
||||
);
|
||||
// Assert
|
||||
await expectActToBeAsync(act, expectedResult);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["full-access" as const, false],
|
||||
["board-change" as const, true],
|
||||
["board-view" as const, true],
|
||||
])(
|
||||
"with permission %s should return %s when hasChangeAccess is true",
|
||||
async (permission, expectedResult) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const spy = vi.spyOn(authShared, "constructBoardPermissions");
|
||||
spy.mockReturnValue({
|
||||
hasFullAccess: false,
|
||||
hasChangeAccess: true,
|
||||
hasViewAccess: false,
|
||||
});
|
||||
])("with permission %s should return %s when hasChangeAccess is true", async (permission, expectedResult) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const spy = vi.spyOn(authShared, "constructBoardPermissions");
|
||||
spy.mockReturnValue({
|
||||
hasFullAccess: false,
|
||||
hasChangeAccess: true,
|
||||
hasViewAccess: false,
|
||||
});
|
||||
|
||||
await db.insert(users).values({ id: defaultCreatorId });
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "test",
|
||||
creatorId: defaultCreatorId,
|
||||
});
|
||||
await db.insert(users).values({ id: defaultCreatorId });
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "test",
|
||||
creatorId: defaultCreatorId,
|
||||
});
|
||||
|
||||
// Act
|
||||
const act = () =>
|
||||
throwIfActionForbiddenAsync(
|
||||
{ db, session: null },
|
||||
eq(boards.id, boardId),
|
||||
permission,
|
||||
);
|
||||
// Act
|
||||
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, boardId), permission);
|
||||
|
||||
// Assert
|
||||
await expectActToBeAsync(act, expectedResult);
|
||||
},
|
||||
);
|
||||
// Assert
|
||||
await expectActToBeAsync(act, expectedResult);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["full-access" as const, false],
|
||||
["board-change" as const, false],
|
||||
["board-view" as const, true],
|
||||
])(
|
||||
"with permission %s should return %s when hasViewAccess is true",
|
||||
async (permission, expectedResult) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const spy = vi.spyOn(authShared, "constructBoardPermissions");
|
||||
spy.mockReturnValue({
|
||||
hasFullAccess: false,
|
||||
hasChangeAccess: false,
|
||||
hasViewAccess: true,
|
||||
});
|
||||
])("with permission %s should return %s when hasViewAccess is true", async (permission, expectedResult) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const spy = vi.spyOn(authShared, "constructBoardPermissions");
|
||||
spy.mockReturnValue({
|
||||
hasFullAccess: false,
|
||||
hasChangeAccess: false,
|
||||
hasViewAccess: true,
|
||||
});
|
||||
|
||||
await db.insert(users).values({ id: defaultCreatorId });
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "test",
|
||||
creatorId: defaultCreatorId,
|
||||
});
|
||||
await db.insert(users).values({ id: defaultCreatorId });
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "test",
|
||||
creatorId: defaultCreatorId,
|
||||
});
|
||||
|
||||
// Act
|
||||
const act = () =>
|
||||
throwIfActionForbiddenAsync(
|
||||
{ db, session: null },
|
||||
eq(boards.id, boardId),
|
||||
permission,
|
||||
);
|
||||
// Act
|
||||
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, boardId), permission);
|
||||
|
||||
// Assert
|
||||
await expectActToBeAsync(act, expectedResult);
|
||||
},
|
||||
);
|
||||
// Assert
|
||||
await expectActToBeAsync(act, expectedResult);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["full-access" as const, false],
|
||||
["board-change" as const, false],
|
||||
["board-view" as const, false],
|
||||
])(
|
||||
"with permission %s should return %s when hasViewAccess is false",
|
||||
async (permission, expectedResult) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const spy = vi.spyOn(authShared, "constructBoardPermissions");
|
||||
spy.mockReturnValue({
|
||||
hasFullAccess: false,
|
||||
hasChangeAccess: false,
|
||||
hasViewAccess: false,
|
||||
});
|
||||
])("with permission %s should return %s when hasViewAccess is false", async (permission, expectedResult) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const spy = vi.spyOn(authShared, "constructBoardPermissions");
|
||||
spy.mockReturnValue({
|
||||
hasFullAccess: false,
|
||||
hasChangeAccess: false,
|
||||
hasViewAccess: false,
|
||||
});
|
||||
|
||||
await db.insert(users).values({ id: defaultCreatorId });
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "test",
|
||||
creatorId: defaultCreatorId,
|
||||
});
|
||||
await db.insert(users).values({ id: defaultCreatorId });
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "test",
|
||||
creatorId: defaultCreatorId,
|
||||
});
|
||||
|
||||
// Act
|
||||
const act = () =>
|
||||
throwIfActionForbiddenAsync(
|
||||
{ db, session: null },
|
||||
eq(boards.id, boardId),
|
||||
permission,
|
||||
);
|
||||
// Act
|
||||
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, boardId), permission);
|
||||
|
||||
// Assert
|
||||
await expectActToBeAsync(act, expectedResult);
|
||||
},
|
||||
);
|
||||
// Assert
|
||||
await expectActToBeAsync(act, expectedResult);
|
||||
});
|
||||
|
||||
test("should throw when board is not found", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
|
||||
// Act
|
||||
const act = () =>
|
||||
throwIfActionForbiddenAsync(
|
||||
{ db, session: null },
|
||||
eq(boards.id, createId()),
|
||||
"full-access",
|
||||
);
|
||||
const act = () => throwIfActionForbiddenAsync({ db, session: null }, eq(boards.id, createId()), "full-access");
|
||||
|
||||
// Assert
|
||||
await expect(act()).rejects.toThrow("Board not found");
|
||||
|
||||
@@ -2,12 +2,7 @@ import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId, eq } from "@homarr/db";
|
||||
import {
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
groups,
|
||||
users,
|
||||
} from "@homarr/db/schema/sqlite";
|
||||
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { groupRouter } from "../group";
|
||||
@@ -103,9 +98,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
||||
expect(item?.members.length).toBe(1);
|
||||
const userKeys = Object.keys(item?.members[0] ?? {});
|
||||
expect(userKeys.length).toBe(4);
|
||||
expect(
|
||||
["id", "name", "email", "image"].some((key) => userKeys.includes(key)),
|
||||
);
|
||||
expect(["id", "name", "email", "image"].some((key) => userKeys.includes(key)));
|
||||
});
|
||||
|
||||
test.each([
|
||||
@@ -178,9 +171,7 @@ describe("byId should return group by id including members and permissions", ()
|
||||
|
||||
const userKeys = Object.keys(result?.members[0] ?? {});
|
||||
expect(userKeys.length).toBe(4);
|
||||
expect(
|
||||
["id", "name", "email", "image"].some((key) => userKeys.includes(key)),
|
||||
);
|
||||
expect(["id", "name", "email", "image"].some((key) => userKeys.includes(key)));
|
||||
expect(result.permissions.length).toBe(1);
|
||||
expect(result.permissions[0]).toBe("admin");
|
||||
});
|
||||
@@ -249,98 +240,88 @@ describe("create should create group in database", () => {
|
||||
["test", "Test "],
|
||||
["test", "test"],
|
||||
["test", " TeSt"],
|
||||
])(
|
||||
"with similar name %s it should fail to create %s",
|
||||
async (similarName, nameToCreate) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
])("with similar name %s it should fail to create %s", async (similarName, nameToCreate) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
name: similarName,
|
||||
});
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
name: similarName,
|
||||
});
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
await caller.createGroup({ name: nameToCreate });
|
||||
// Act
|
||||
const actAsync = async () => await caller.createGroup({ name: nameToCreate });
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("similar name");
|
||||
},
|
||||
);
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("similar name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("update should update name with value that is no duplicate", () => {
|
||||
test.each([
|
||||
["first", "second ", "second"],
|
||||
["first", " first", "first"],
|
||||
])(
|
||||
"update should update name from %s to %s normalized",
|
||||
async (initialValue, updateValue, expectedValue) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
])("update should update name from %s to %s normalized", async (initialValue, updateValue, expectedValue) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values([
|
||||
{
|
||||
id: groupId,
|
||||
name: initialValue,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "Third",
|
||||
},
|
||||
]);
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values([
|
||||
{
|
||||
id: groupId,
|
||||
name: initialValue,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "Third",
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
// Act
|
||||
await caller.updateGroup({
|
||||
id: groupId,
|
||||
name: updateValue,
|
||||
});
|
||||
|
||||
// Assert
|
||||
const value = await db.query.groups.findFirst({
|
||||
where: eq(groups.id, groupId),
|
||||
});
|
||||
expect(value?.name).toBe(expectedValue);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["Second ", "second"],
|
||||
[" seCond", "second"],
|
||||
])("with similar name %s it should fail to update %s", async (updateValue, initialDuplicate) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values([
|
||||
{
|
||||
id: groupId,
|
||||
name: "Something",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: initialDuplicate,
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
await caller.updateGroup({
|
||||
id: groupId,
|
||||
name: updateValue,
|
||||
});
|
||||
|
||||
// Assert
|
||||
const value = await db.query.groups.findFirst({
|
||||
where: eq(groups.id, groupId),
|
||||
});
|
||||
expect(value?.name).toBe(expectedValue);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
["Second ", "second"],
|
||||
[" seCond", "second"],
|
||||
])(
|
||||
"with similar name %s it should fail to update %s",
|
||||
async (updateValue, initialDuplicate) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values([
|
||||
{
|
||||
id: groupId,
|
||||
name: "Something",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: initialDuplicate,
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
await caller.updateGroup({
|
||||
id: groupId,
|
||||
name: updateValue,
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("similar name");
|
||||
},
|
||||
);
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("similar name");
|
||||
});
|
||||
|
||||
test("with non existing id it should throw not found error", async () => {
|
||||
// Arrange
|
||||
@@ -392,10 +373,7 @@ describe("savePermissions should save permissions for group", () => {
|
||||
});
|
||||
|
||||
expect(permissions.length).toBe(2);
|
||||
expect(permissions.map(({ permission }) => permission)).toEqual([
|
||||
"integration-use-all",
|
||||
"board-full-access",
|
||||
]);
|
||||
expect(permissions.map(({ permission }) => permission)).toEqual(["integration-use-all", "board-full-access"]);
|
||||
});
|
||||
|
||||
test("with non existing group it should throw not found error", async () => {
|
||||
|
||||
@@ -118,17 +118,11 @@ describe("byId should return an integration by id", () => {
|
||||
|
||||
const result = await caller.byId({ id: "1" });
|
||||
expect(result.secrets.length).toBe(3);
|
||||
const username = expectToBeDefined(
|
||||
result.secrets.find((secret) => secret.kind === "username"),
|
||||
);
|
||||
const username = expectToBeDefined(result.secrets.find((secret) => secret.kind === "username"));
|
||||
expect(username.value).not.toBeNull();
|
||||
const password = expectToBeDefined(
|
||||
result.secrets.find((secret) => secret.kind === "password"),
|
||||
);
|
||||
const password = expectToBeDefined(result.secrets.find((secret) => secret.kind === "password"));
|
||||
expect(password.value).toBeNull();
|
||||
const apiKey = expectToBeDefined(
|
||||
result.secrets.find((secret) => secret.kind === "apiKey"),
|
||||
);
|
||||
const apiKey = expectToBeDefined(result.secrets.find((secret) => secret.kind === "apiKey"));
|
||||
expect(apiKey.value).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -200,9 +194,7 @@ describe("update should update an integration", () => {
|
||||
integrationId,
|
||||
updatedAt: lastWeek,
|
||||
};
|
||||
await db
|
||||
.insert(integrationSecrets)
|
||||
.values([usernameToInsert, passwordToInsert]);
|
||||
await db.insert(integrationSecrets).values([usernameToInsert, passwordToInsert]);
|
||||
|
||||
const input = {
|
||||
id: integrationId,
|
||||
@@ -231,15 +223,9 @@ describe("update should update an integration", () => {
|
||||
expect(dbIntegration!.url).toBe(input.url);
|
||||
|
||||
expect(dbSecrets.length).toBe(3);
|
||||
const username = expectToBeDefined(
|
||||
dbSecrets.find((secret) => secret.kind === "username"),
|
||||
);
|
||||
const password = expectToBeDefined(
|
||||
dbSecrets.find((secret) => secret.kind === "password"),
|
||||
);
|
||||
const apiKey = expectToBeDefined(
|
||||
dbSecrets.find((secret) => secret.kind === "apiKey"),
|
||||
);
|
||||
const username = expectToBeDefined(dbSecrets.find((secret) => secret.kind === "username"));
|
||||
const password = expectToBeDefined(dbSecrets.find((secret) => secret.kind === "password"));
|
||||
const apiKey = expectToBeDefined(dbSecrets.find((secret) => secret.kind === "apiKey"));
|
||||
expect(username.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
|
||||
expect(password.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
|
||||
expect(apiKey.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
|
||||
@@ -327,26 +313,23 @@ describe("testConnection should test the connection to an integration", () => {
|
||||
{ kind: "password" as const, value: "Password123!" },
|
||||
],
|
||||
],
|
||||
])(
|
||||
"should fail when a required secret is missing when creating %s integration",
|
||||
async (kind, secrets) => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
});
|
||||
])("should fail when a required secret is missing when creating %s integration", async (kind, secrets) => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
});
|
||||
|
||||
const input: RouterInputs["integration"]["testConnection"] = {
|
||||
id: null,
|
||||
kind,
|
||||
url: `http://${kind}.local`,
|
||||
secrets,
|
||||
};
|
||||
const input: RouterInputs["integration"]["testConnection"] = {
|
||||
id: null,
|
||||
kind,
|
||||
url: `http://${kind}.local`,
|
||||
secrets,
|
||||
};
|
||||
|
||||
const actAsync = async () => await caller.testConnection(input);
|
||||
await expect(actAsync()).rejects.toThrow("SECRETS_NOT_DEFINED");
|
||||
},
|
||||
);
|
||||
const actAsync = async () => await caller.testConnection(input);
|
||||
await expect(actAsync()).rejects.toThrow("SECRETS_NOT_DEFINED");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
|
||||
131
packages/api/src/router/test/serverSettings.spec.ts
Normal file
131
packages/api/src/router/test/serverSettings.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import SuperJSON from "superjson";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/db";
|
||||
import { serverSettings } from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
|
||||
import { serverSettingsRouter } from "../serverSettings";
|
||||
|
||||
// Mock the auth module to return an empty session
|
||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||
|
||||
const defaultSession = {
|
||||
user: {
|
||||
id: createId(),
|
||||
permissions: [],
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
describe("getAll server settings", () => {
|
||||
test("getAll should throw error when unauthorized", async () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
});
|
||||
|
||||
await db.insert(serverSettings).values([
|
||||
{
|
||||
settingKey: defaultServerSettingsKeys[0],
|
||||
value: SuperJSON.stringify(defaultServerSettings.analytics),
|
||||
},
|
||||
]);
|
||||
|
||||
const actAsync = async () => await caller.getAll();
|
||||
|
||||
await expect(actAsync()).rejects.toThrow();
|
||||
});
|
||||
test("getAll should return server", async () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
db,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
await db.insert(serverSettings).values([
|
||||
{
|
||||
settingKey: defaultServerSettingsKeys[0],
|
||||
value: SuperJSON.stringify(defaultServerSettings.analytics),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.getAll();
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
analytics: {
|
||||
enableGeneral: true,
|
||||
enableWidgetData: false,
|
||||
enableIntegrationData: false,
|
||||
enableUserData: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveSettings", () => {
|
||||
test("saveSettings should return false when it did not update one", async () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
db,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
const result = await caller.saveSettings({
|
||||
settingsKey: "analytics",
|
||||
value: {
|
||||
enableGeneral: true,
|
||||
enableWidgetData: true,
|
||||
enableIntegrationData: true,
|
||||
enableUserData: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
expect(dbSettings.length).toBe(0);
|
||||
});
|
||||
test("saveSettings should update settings and return true when it updated only one", async () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
db,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
await db.insert(serverSettings).values([
|
||||
{
|
||||
settingKey: defaultServerSettingsKeys[0],
|
||||
value: SuperJSON.stringify(defaultServerSettings.analytics),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.saveSettings({
|
||||
settingsKey: "analytics",
|
||||
value: {
|
||||
enableGeneral: true,
|
||||
enableWidgetData: true,
|
||||
enableIntegrationData: true,
|
||||
enableUserData: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
expect(dbSettings).toStrictEqual([
|
||||
{
|
||||
settingKey: "analytics",
|
||||
value: SuperJSON.stringify({
|
||||
enableGeneral: true,
|
||||
enableWidgetData: true,
|
||||
enableIntegrationData: true,
|
||||
enableUserData: true,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -73,7 +73,7 @@ describe("initUser should initialize the first user", () => {
|
||||
confirmPassword: "12345679",
|
||||
});
|
||||
|
||||
await expect(actAsync()).rejects.toThrow("Passwords do not match");
|
||||
await expect(actAsync()).rejects.toThrow("passwordsDoNotMatch");
|
||||
});
|
||||
|
||||
it("should not create a user if the password is too short", async () => {
|
||||
@@ -218,10 +218,7 @@ describe("editProfile shoud update user", () => {
|
||||
});
|
||||
|
||||
// assert
|
||||
const user = await db
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, id));
|
||||
const user = await db.select().from(schema.users).where(eq(schema.users.id, id));
|
||||
|
||||
expect(user).toHaveLength(1);
|
||||
expect(user[0]).toStrictEqual({
|
||||
@@ -232,6 +229,7 @@ describe("editProfile shoud update user", () => {
|
||||
salt: null,
|
||||
password: null,
|
||||
image: null,
|
||||
homeBoardId: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -260,10 +258,7 @@ describe("editProfile shoud update user", () => {
|
||||
});
|
||||
|
||||
// assert
|
||||
const user = await db
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, id));
|
||||
const user = await db.select().from(schema.users).where(eq(schema.users.id, id));
|
||||
|
||||
expect(user).toHaveLength(1);
|
||||
expect(user[0]).toStrictEqual({
|
||||
@@ -274,6 +269,7 @@ describe("editProfile shoud update user", () => {
|
||||
salt: null,
|
||||
password: null,
|
||||
image: null,
|
||||
homeBoardId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -297,6 +293,7 @@ describe("delete should delete user", () => {
|
||||
image: null,
|
||||
password: null,
|
||||
salt: null,
|
||||
homeBoardId: null,
|
||||
},
|
||||
{
|
||||
id: userToDelete,
|
||||
@@ -306,6 +303,7 @@ describe("delete should delete user", () => {
|
||||
image: null,
|
||||
password: null,
|
||||
salt: null,
|
||||
homeBoardId: null,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
@@ -315,6 +313,7 @@ describe("delete should delete user", () => {
|
||||
image: null,
|
||||
password: null,
|
||||
salt: null,
|
||||
homeBoardId: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -11,60 +11,58 @@ import { validation, z } from "@homarr/validation";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
initUser: publicProcedure
|
||||
.input(validation.user.init)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const firstUser = await ctx.db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
initUser: publicProcedure.input(validation.user.init).mutation(async ({ ctx, input }) => {
|
||||
const firstUser = await ctx.db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (firstUser) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User already exists",
|
||||
});
|
||||
}
|
||||
|
||||
if (firstUser) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User already exists",
|
||||
});
|
||||
}
|
||||
await createUserAsync(ctx.db, input);
|
||||
}),
|
||||
register: publicProcedure.input(validation.user.registrationApi).mutation(async ({ ctx, input }) => {
|
||||
const inviteWhere = and(eq(invites.id, input.inviteId), eq(invites.token, input.token));
|
||||
const dbInvite = await ctx.db.query.invites.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
expirationDate: true,
|
||||
},
|
||||
where: inviteWhere,
|
||||
});
|
||||
|
||||
await createUserAsync(ctx.db, input);
|
||||
}),
|
||||
register: publicProcedure
|
||||
.input(validation.user.registrationApi)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const inviteWhere = and(
|
||||
eq(invites.id, input.inviteId),
|
||||
eq(invites.token, input.token),
|
||||
);
|
||||
const dbInvite = await ctx.db.query.invites.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
expirationDate: true,
|
||||
},
|
||||
where: inviteWhere,
|
||||
if (!dbInvite || dbInvite.expirationDate < new Date()) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Invalid invite",
|
||||
});
|
||||
}
|
||||
|
||||
if (!dbInvite || dbInvite.expirationDate < new Date()) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Invalid invite",
|
||||
});
|
||||
}
|
||||
if (!dbInvite || dbInvite.expirationDate < new Date()) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Invalid invite",
|
||||
});
|
||||
}
|
||||
|
||||
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, input.username);
|
||||
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, input.username);
|
||||
|
||||
await createUserAsync(ctx.db, input);
|
||||
await createUserAsync(ctx.db, input);
|
||||
|
||||
// Delete invite as it's used
|
||||
await ctx.db.delete(invites).where(inviteWhere);
|
||||
}),
|
||||
create: publicProcedure
|
||||
.input(validation.user.create)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, input.username);
|
||||
// Delete invite as it's used
|
||||
await ctx.db.delete(invites).where(inviteWhere);
|
||||
}),
|
||||
create: publicProcedure.input(validation.user.create).mutation(async ({ ctx, input }) => {
|
||||
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, input.username);
|
||||
|
||||
await createUserAsync(ctx.db, input);
|
||||
}),
|
||||
await createUserAsync(ctx.db, input);
|
||||
}),
|
||||
setProfileImage: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -79,10 +77,7 @@ export const userRouter = createTRPCRouter({
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
// Only admins can change other users profile images
|
||||
if (
|
||||
ctx.session.user.id !== input.userId &&
|
||||
!ctx.session.user.permissions.includes("admin")
|
||||
) {
|
||||
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not allowed to change other users profile images",
|
||||
@@ -131,118 +126,105 @@ export const userRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
}),
|
||||
getById: publicProcedure
|
||||
.input(z.object({ userId: z.string() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
getById: publicProcedure.input(z.object({ userId: z.string() })).query(async ({ input, ctx }) => {
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
image: true,
|
||||
},
|
||||
where: eq(users.id, input.userId),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}),
|
||||
editProfile: publicProcedure.input(validation.user.editProfile).mutation(async ({ input, ctx }) => {
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
columns: { email: true },
|
||||
where: eq(users.id, input.id),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, input.name, input.id);
|
||||
|
||||
const emailDirty = input.email && user.email !== input.email;
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
name: input.name,
|
||||
email: emailDirty === true ? input.email : undefined,
|
||||
emailVerified: emailDirty === true ? null : undefined,
|
||||
})
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
delete: publicProcedure.input(z.string()).mutation(async ({ input, ctx }) => {
|
||||
await ctx.db.delete(users).where(eq(users.id, input));
|
||||
}),
|
||||
changePassword: protectedProcedure.input(validation.user.changePasswordApi).mutation(async ({ ctx, input }) => {
|
||||
const user = ctx.session.user;
|
||||
// Only admins can change other users' passwords
|
||||
if (!user.permissions.includes("admin") && user.id !== input.userId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Admins can change the password of other users without providing the previous password
|
||||
const isPreviousPasswordRequired = ctx.session.user.id === input.userId;
|
||||
|
||||
if (isPreviousPasswordRequired) {
|
||||
const dbUser = await ctx.db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
image: true,
|
||||
password: true,
|
||||
salt: true,
|
||||
},
|
||||
where: eq(users.id, input.userId),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
if (!dbUser) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}),
|
||||
editProfile: publicProcedure
|
||||
.input(validation.user.editProfile)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
columns: { email: true },
|
||||
where: eq(users.id, input.id),
|
||||
});
|
||||
const previousPasswordHash = await hashPasswordAsync(input.previousPassword, dbUser.salt ?? "");
|
||||
const isValid = previousPasswordHash === dbUser.password;
|
||||
|
||||
if (!user) {
|
||||
if (!isValid) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
code: "FORBIDDEN",
|
||||
message: "Invalid password",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await checkUsernameAlreadyTakenAndThrowAsync(
|
||||
ctx.db,
|
||||
input.name,
|
||||
input.id,
|
||||
);
|
||||
|
||||
const emailDirty = input.email && user.email !== input.email;
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
name: input.name,
|
||||
email: emailDirty === true ? input.email : undefined,
|
||||
emailVerified: emailDirty === true ? null : undefined,
|
||||
})
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
delete: publicProcedure.input(z.string()).mutation(async ({ input, ctx }) => {
|
||||
await ctx.db.delete(users).where(eq(users.id, input));
|
||||
const salt = await createSaltAsync();
|
||||
const hashedPassword = await hashPasswordAsync(input.password, salt);
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
})
|
||||
.where(eq(users.id, input.userId));
|
||||
}),
|
||||
changePassword: protectedProcedure
|
||||
.input(validation.user.changePasswordApi)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = ctx.session.user;
|
||||
// Only admins can change other users' passwords
|
||||
if (!user.permissions.includes("admin") && user.id !== input.userId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Admins can change the password of other users without providing the previous password
|
||||
const isPreviousPasswordRequired = ctx.session.user.id === input.userId;
|
||||
|
||||
if (isPreviousPasswordRequired) {
|
||||
const dbUser = await ctx.db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
password: true,
|
||||
salt: true,
|
||||
},
|
||||
where: eq(users.id, input.userId),
|
||||
});
|
||||
|
||||
if (!dbUser) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const previousPasswordHash = await hashPasswordAsync(
|
||||
input.previousPassword,
|
||||
dbUser.salt ?? "",
|
||||
);
|
||||
const isValid = previousPasswordHash === dbUser.password;
|
||||
|
||||
if (!isValid) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Invalid password",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const salt = await createSaltAsync();
|
||||
const hashedPassword = await hashPasswordAsync(input.password, salt);
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
})
|
||||
.where(eq(users.id, input.userId));
|
||||
}),
|
||||
setMessage: publicProcedure.input(z.string()).mutation(async ({ input }) => {
|
||||
await exampleChannel.publishAsync({ message: input });
|
||||
}),
|
||||
@@ -255,10 +237,7 @@ export const userRouter = createTRPCRouter({
|
||||
}),
|
||||
});
|
||||
|
||||
const createUserAsync = async (
|
||||
db: Database,
|
||||
input: z.infer<typeof validation.user.create>,
|
||||
) => {
|
||||
const createUserAsync = async (db: Database, input: z.infer<typeof validation.user.create>) => {
|
||||
const salt = await createSaltAsync();
|
||||
const hashedPassword = await hashPasswordAsync(input.password, salt);
|
||||
|
||||
@@ -268,16 +247,13 @@ const createUserAsync = async (
|
||||
await db.insert(schema.users).values({
|
||||
id: userId,
|
||||
name: username,
|
||||
email: input.email,
|
||||
password: hashedPassword,
|
||||
salt,
|
||||
});
|
||||
};
|
||||
|
||||
const checkUsernameAlreadyTakenAndThrowAsync = async (
|
||||
db: Database,
|
||||
username: string,
|
||||
ignoreId?: string,
|
||||
) => {
|
||||
const checkUsernameAlreadyTakenAndThrowAsync = async (db: Database, username: string, ignoreId?: string) => {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.name, username.toLowerCase()),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -35,13 +35,8 @@ export const createCredentialsConfiguration = (db: Database) =>
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`user ${user.name} is trying to log in. checking password...`,
|
||||
);
|
||||
const isValidPassword = await bcrypt.compare(
|
||||
data.password,
|
||||
user.password,
|
||||
);
|
||||
console.log(`user ${user.name} is trying to log in. checking password...`);
|
||||
const isValidPassword = await bcrypt.compare(data.password, user.password);
|
||||
|
||||
if (!isValidPassword) {
|
||||
console.log(`password for user ${user.name} was incorrect`);
|
||||
|
||||
@@ -26,13 +26,7 @@ describe("Credentials authorization", () => {
|
||||
expect(result).toEqual({ id: userId, name: "test" });
|
||||
});
|
||||
|
||||
const passwordsThatShouldNotAuthorize = [
|
||||
"wrong",
|
||||
"Test",
|
||||
"test ",
|
||||
" test",
|
||||
" test ",
|
||||
];
|
||||
const passwordsThatShouldNotAuthorize = ["wrong", "Test", "test ", " test", " test "];
|
||||
|
||||
passwordsThatShouldNotAuthorize.forEach((password) => {
|
||||
it(`should not authorize user with incorrect credentials (${password})`, async () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -39,7 +39,7 @@ const initBetterSqlite = () => {
|
||||
database = drizzleSqlite(connection, {
|
||||
schema: sqliteSchema,
|
||||
logger: new WinstonDrizzleLogger(),
|
||||
});
|
||||
}) as unknown as never;
|
||||
};
|
||||
|
||||
const initMySQL2 = () => {
|
||||
|
||||
2
packages/db/migrations/mysql/0001_wild_alex_wilder.sql
Normal file
2
packages/db/migrations/mysql/0001_wild_alex_wilder.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `user` ADD `homeBoardId` varchar(64);--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD CONSTRAINT `user_homeBoardId_board_id_fk` FOREIGN KEY (`homeBoardId`) REFERENCES `board`(`id`) ON DELETE set null ON UPDATE no action;
|
||||
1
packages/db/migrations/mysql/0002_flimsy_deathbird.sql
Normal file
1
packages/db/migrations/mysql/0002_flimsy_deathbird.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `item` ADD `advanced_options` text DEFAULT ('{"json": {}}') NOT NULL;
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE `serverSetting` (
|
||||
`key` varchar(64) NOT NULL,
|
||||
`value` text NOT NULL DEFAULT ('{"json": {}}'),
|
||||
CONSTRAINT `serverSetting_key` PRIMARY KEY(`key`),
|
||||
CONSTRAINT `serverSetting_key_unique` UNIQUE(`key`)
|
||||
);
|
||||
1166
packages/db/migrations/mysql/meta/0001_snapshot.json
Normal file
1166
packages/db/migrations/mysql/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1174
packages/db/migrations/mysql/meta/0002_snapshot.json
Normal file
1174
packages/db/migrations/mysql/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1208
packages/db/migrations/mysql/meta/0003_snapshot.json
Normal file
1208
packages/db/migrations/mysql/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,27 @@
|
||||
"when": 1715334452118,
|
||||
"tag": "0000_harsh_photon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "5",
|
||||
"when": 1715885855801,
|
||||
"tag": "0001_wild_alex_wilder",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "5",
|
||||
"when": 1715980459023,
|
||||
"tag": "0002_flimsy_deathbird",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "5",
|
||||
"when": 1716148439439,
|
||||
"tag": "0003_freezing_black_panther",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
33
packages/db/migrations/sqlite/0001_mixed_titanium_man.sql
Normal file
33
packages/db/migrations/sqlite/0001_mixed_titanium_man.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
COMMIT TRANSACTION;
|
||||
--> statement-breakpoint
|
||||
PRAGMA foreign_keys = OFF;
|
||||
--> statement-breakpoint
|
||||
BEGIN TRANSACTION;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `user` RENAME TO `__user_old`;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text,
|
||||
`email` text,
|
||||
`emailVerified` integer,
|
||||
`image` text,
|
||||
`password` text,
|
||||
`salt` text,
|
||||
`homeBoardId` text,
|
||||
FOREIGN KEY (`homeBoardId`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `user` SELECT `id`, `name`, `email`, `emailVerified`, `image`, `password`, `salt`, null FROM `__user_old`;
|
||||
--> statement-breakpoint
|
||||
DROP TABLE `__user_old`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `user` RENAME TO `__user_old`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `__user_old` RENAME TO `user`;
|
||||
--> statement-breakpoint
|
||||
COMMIT TRANSACTION;
|
||||
--> statement-breakpoint
|
||||
PRAGMA foreign_keys = ON;
|
||||
--> statement-breakpoint
|
||||
BEGIN TRANSACTION;
|
||||
1
packages/db/migrations/sqlite/0002_cooing_sumo.sql
Normal file
1
packages/db/migrations/sqlite/0002_cooing_sumo.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `item` ADD `advanced_options` text DEFAULT '{"json": {}}' NOT NULL;
|
||||
6
packages/db/migrations/sqlite/0003_adorable_raider.sql
Normal file
6
packages/db/migrations/sqlite/0003_adorable_raider.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE `serverSetting` (
|
||||
`key` text PRIMARY KEY NOT NULL,
|
||||
`value` text DEFAULT '{"json": {}}' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `serverSetting_key_unique` ON `serverSetting` (`key`);
|
||||
1114
packages/db/migrations/sqlite/meta/0001_snapshot.json
Normal file
1114
packages/db/migrations/sqlite/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1122
packages/db/migrations/sqlite/meta/0002_snapshot.json
Normal file
1122
packages/db/migrations/sqlite/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1152
packages/db/migrations/sqlite/meta/0003_snapshot.json
Normal file
1152
packages/db/migrations/sqlite/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,27 @@
|
||||
"when": 1715334238443,
|
||||
"tag": "0000_talented_ben_parker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1715871797713,
|
||||
"tag": "0001_mixed_titanium_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1715973963014,
|
||||
"tag": "0002_cooing_sumo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1716148434186,
|
||||
"tag": "0003_adorable_raider",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import type { AdapterAccount } from "@auth/core/adapters";
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
int,
|
||||
mysqlTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
|
||||
import { boolean, index, int, mysqlTable, primaryKey, text, timestamp, varchar } from "drizzle-orm/mysql-core";
|
||||
|
||||
import type {
|
||||
BackgroundImageAttachment,
|
||||
@@ -22,11 +14,7 @@ import type {
|
||||
SectionKind,
|
||||
WidgetKind,
|
||||
} from "@homarr/definitions";
|
||||
import {
|
||||
backgroundImageAttachments,
|
||||
backgroundImageRepeats,
|
||||
backgroundImageSizes,
|
||||
} from "@homarr/definitions";
|
||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
||||
|
||||
export const users = mysqlTable("user", {
|
||||
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
||||
@@ -36,6 +24,9 @@ export const users = mysqlTable("user", {
|
||||
image: text("image"),
|
||||
password: text("password"),
|
||||
salt: text("salt"),
|
||||
homeBoardId: varchar("homeBoardId", { length: 64 }).references((): AnyMySqlColumn => boards.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
});
|
||||
|
||||
export const accounts = mysqlTable(
|
||||
@@ -66,9 +57,7 @@ export const accounts = mysqlTable(
|
||||
export const sessions = mysqlTable(
|
||||
"session",
|
||||
{
|
||||
sessionToken: varchar("sessionToken", { length: 512 })
|
||||
.notNull()
|
||||
.primaryKey(),
|
||||
sessionToken: varchar("sessionToken", { length: 512 }).notNull().primaryKey(),
|
||||
userId: varchar("userId", { length: 64 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
@@ -150,9 +139,7 @@ export const integrations = mysqlTable(
|
||||
export const integrationSecrets = mysqlTable(
|
||||
"integrationSecret",
|
||||
{
|
||||
kind: varchar("kind", { length: 16 })
|
||||
.$type<IntegrationSecretKind>()
|
||||
.notNull(),
|
||||
kind: varchar("kind", { length: 16 }).$type<IntegrationSecretKind>().notNull(),
|
||||
value: text("value").$type<`${string}.${string}`>().notNull(),
|
||||
updatedAt: timestamp("updated_at").notNull(),
|
||||
integrationId: varchar("integration_id", { length: 64 })
|
||||
@@ -164,9 +151,7 @@ export const integrationSecrets = mysqlTable(
|
||||
columns: [integrationSecret.integrationId, integrationSecret.kind],
|
||||
}),
|
||||
kindIdx: index("integration_secret__kind_idx").on(integrationSecret.kind),
|
||||
updatedAtIdx: index("integration_secret__updated_at_idx").on(
|
||||
integrationSecret.updatedAt,
|
||||
),
|
||||
updatedAtIdx: index("integration_secret__updated_at_idx").on(integrationSecret.updatedAt),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -210,9 +195,7 @@ export const boardUserPermissions = mysqlTable(
|
||||
userId: varchar("user_id", { length: 64 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
permission: varchar("permission", { length: 128 })
|
||||
.$type<BoardPermission>()
|
||||
.notNull(),
|
||||
permission: varchar("permission", { length: 128 }).$type<BoardPermission>().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
@@ -230,9 +213,7 @@ export const boardGroupPermissions = mysqlTable(
|
||||
groupId: varchar("group_id", { length: 64 })
|
||||
.notNull()
|
||||
.references(() => groups.id, { onDelete: "cascade" }),
|
||||
permission: varchar("permission", { length: 128 })
|
||||
.$type<BoardPermission>()
|
||||
.notNull(),
|
||||
permission: varchar("permission", { length: 128 }).$type<BoardPermission>().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
@@ -262,6 +243,7 @@ export const items = mysqlTable("item", {
|
||||
width: int("width").notNull(),
|
||||
height: int("height").notNull(),
|
||||
options: text("options").default('{"json": {}}').notNull(), // empty superjson object
|
||||
advancedOptions: text("advanced_options").default('{"json": {}}').notNull(), // empty superjson object
|
||||
});
|
||||
|
||||
export const apps = mysqlTable("app", {
|
||||
@@ -304,6 +286,11 @@ export const iconRepositories = mysqlTable("iconRepository", {
|
||||
slug: varchar("iconRepository_slug", { length: 150 }).notNull(),
|
||||
});
|
||||
|
||||
export const serverSettings = mysqlTable("serverSetting", {
|
||||
settingKey: varchar("key", { length: 64 }).notNull().unique().primaryKey(),
|
||||
value: text("value").default('{"json": {}}').notNull(), // empty superjson object
|
||||
});
|
||||
|
||||
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [accounts.userId],
|
||||
@@ -327,12 +314,9 @@ export const iconRelations = relations(icons, ({ one }) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const iconRepositoryRelations = relations(
|
||||
iconRepositories,
|
||||
({ many }) => ({
|
||||
icons: many(icons),
|
||||
}),
|
||||
);
|
||||
export const iconRepositoryRelations = relations(iconRepositories, ({ many }) => ({
|
||||
icons: many(icons),
|
||||
}));
|
||||
|
||||
export const inviteRelations = relations(invites, ({ one }) => ({
|
||||
creator: one(users, {
|
||||
@@ -369,58 +353,46 @@ export const groupRelations = relations(groups, ({ one, many }) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const groupPermissionRelations = relations(
|
||||
groupPermissions,
|
||||
({ one }) => ({
|
||||
group: one(groups, {
|
||||
fields: [groupPermissions.groupId],
|
||||
references: [groups.id],
|
||||
}),
|
||||
export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({
|
||||
group: one(groups, {
|
||||
fields: [groupPermissions.groupId],
|
||||
references: [groups.id],
|
||||
}),
|
||||
);
|
||||
}));
|
||||
|
||||
export const boardUserPermissionRelations = relations(
|
||||
boardUserPermissions,
|
||||
({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [boardUserPermissions.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
board: one(boards, {
|
||||
fields: [boardUserPermissions.boardId],
|
||||
references: [boards.id],
|
||||
}),
|
||||
export const boardUserPermissionRelations = relations(boardUserPermissions, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [boardUserPermissions.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
);
|
||||
board: one(boards, {
|
||||
fields: [boardUserPermissions.boardId],
|
||||
references: [boards.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const boardGroupPermissionRelations = relations(
|
||||
boardGroupPermissions,
|
||||
({ one }) => ({
|
||||
group: one(groups, {
|
||||
fields: [boardGroupPermissions.groupId],
|
||||
references: [groups.id],
|
||||
}),
|
||||
board: one(boards, {
|
||||
fields: [boardGroupPermissions.boardId],
|
||||
references: [boards.id],
|
||||
}),
|
||||
export const boardGroupPermissionRelations = relations(boardGroupPermissions, ({ one }) => ({
|
||||
group: one(groups, {
|
||||
fields: [boardGroupPermissions.groupId],
|
||||
references: [groups.id],
|
||||
}),
|
||||
);
|
||||
board: one(boards, {
|
||||
fields: [boardGroupPermissions.boardId],
|
||||
references: [boards.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const integrationRelations = relations(integrations, ({ many }) => ({
|
||||
secrets: many(integrationSecrets),
|
||||
items: many(integrationItems),
|
||||
}));
|
||||
|
||||
export const integrationSecretRelations = relations(
|
||||
integrationSecrets,
|
||||
({ one }) => ({
|
||||
integration: one(integrations, {
|
||||
fields: [integrationSecrets.integrationId],
|
||||
references: [integrations.id],
|
||||
}),
|
||||
export const integrationSecretRelations = relations(integrationSecrets, ({ one }) => ({
|
||||
integration: one(integrations, {
|
||||
fields: [integrationSecrets.integrationId],
|
||||
references: [integrations.id],
|
||||
}),
|
||||
);
|
||||
}));
|
||||
|
||||
export const boardRelations = relations(boards, ({ many, one }) => ({
|
||||
sections: many(sections),
|
||||
@@ -448,16 +420,13 @@ export const itemRelations = relations(items, ({ one, many }) => ({
|
||||
integrations: many(integrationItems),
|
||||
}));
|
||||
|
||||
export const integrationItemRelations = relations(
|
||||
integrationItems,
|
||||
({ one }) => ({
|
||||
integration: one(integrations, {
|
||||
fields: [integrationItems.integrationId],
|
||||
references: [integrations.id],
|
||||
}),
|
||||
item: one(items, {
|
||||
fields: [integrationItems.itemId],
|
||||
references: [items.id],
|
||||
}),
|
||||
export const integrationItemRelations = relations(integrationItems, ({ one }) => ({
|
||||
integration: one(integrations, {
|
||||
fields: [integrationItems.integrationId],
|
||||
references: [integrations.id],
|
||||
}),
|
||||
);
|
||||
item: one(items, {
|
||||
fields: [integrationItems.itemId],
|
||||
references: [items.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import type { AdapterAccount } from "@auth/core/adapters";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
index,
|
||||
int,
|
||||
integer,
|
||||
primaryKey,
|
||||
sqliteTable,
|
||||
text,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||
import { index, int, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
import {
|
||||
backgroundImageAttachments,
|
||||
backgroundImageRepeats,
|
||||
backgroundImageSizes,
|
||||
} from "@homarr/definitions";
|
||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
||||
import type {
|
||||
BackgroundImageAttachment,
|
||||
BackgroundImageRepeat,
|
||||
@@ -35,6 +25,9 @@ export const users = sqliteTable("user", {
|
||||
image: text("image"),
|
||||
password: text("password"),
|
||||
salt: text("salt"),
|
||||
homeBoardId: text("homeBoardId").references((): AnySQLiteColumn => boards.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
});
|
||||
|
||||
export const accounts = sqliteTable(
|
||||
@@ -161,9 +154,7 @@ export const integrationSecrets = sqliteTable(
|
||||
columns: [integrationSecret.integrationId, integrationSecret.kind],
|
||||
}),
|
||||
kindIdx: index("integration_secret__kind_idx").on(integrationSecret.kind),
|
||||
updatedAtIdx: index("integration_secret__updated_at_idx").on(
|
||||
integrationSecret.updatedAt,
|
||||
),
|
||||
updatedAtIdx: index("integration_secret__updated_at_idx").on(integrationSecret.updatedAt),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -255,6 +246,7 @@ export const items = sqliteTable("item", {
|
||||
width: int("width").notNull(),
|
||||
height: int("height").notNull(),
|
||||
options: text("options").default('{"json": {}}').notNull(), // empty superjson object
|
||||
advancedOptions: text("advanced_options").default('{"json": {}}').notNull(), // empty superjson object
|
||||
});
|
||||
|
||||
export const apps = sqliteTable("app", {
|
||||
@@ -297,6 +289,11 @@ export const iconRepositories = sqliteTable("iconRepository", {
|
||||
slug: text("iconRepository_slug").notNull(),
|
||||
});
|
||||
|
||||
export const serverSettings = sqliteTable("serverSetting", {
|
||||
settingKey: text("key").notNull().unique().primaryKey(),
|
||||
value: text("value").default('{"json": {}}').notNull(), // empty superjson object
|
||||
});
|
||||
|
||||
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [accounts.userId],
|
||||
@@ -320,12 +317,9 @@ export const iconRelations = relations(icons, ({ one }) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const iconRepositoryRelations = relations(
|
||||
iconRepositories,
|
||||
({ many }) => ({
|
||||
icons: many(icons),
|
||||
}),
|
||||
);
|
||||
export const iconRepositoryRelations = relations(iconRepositories, ({ many }) => ({
|
||||
icons: many(icons),
|
||||
}));
|
||||
|
||||
export const inviteRelations = relations(invites, ({ one }) => ({
|
||||
creator: one(users, {
|
||||
@@ -362,58 +356,46 @@ export const groupRelations = relations(groups, ({ one, many }) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const groupPermissionRelations = relations(
|
||||
groupPermissions,
|
||||
({ one }) => ({
|
||||
group: one(groups, {
|
||||
fields: [groupPermissions.groupId],
|
||||
references: [groups.id],
|
||||
}),
|
||||
export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({
|
||||
group: one(groups, {
|
||||
fields: [groupPermissions.groupId],
|
||||
references: [groups.id],
|
||||
}),
|
||||
);
|
||||
}));
|
||||
|
||||
export const boardUserPermissionRelations = relations(
|
||||
boardUserPermissions,
|
||||
({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [boardUserPermissions.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
board: one(boards, {
|
||||
fields: [boardUserPermissions.boardId],
|
||||
references: [boards.id],
|
||||
}),
|
||||
export const boardUserPermissionRelations = relations(boardUserPermissions, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [boardUserPermissions.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
);
|
||||
board: one(boards, {
|
||||
fields: [boardUserPermissions.boardId],
|
||||
references: [boards.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const boardGroupPermissionRelations = relations(
|
||||
boardGroupPermissions,
|
||||
({ one }) => ({
|
||||
group: one(groups, {
|
||||
fields: [boardGroupPermissions.groupId],
|
||||
references: [groups.id],
|
||||
}),
|
||||
board: one(boards, {
|
||||
fields: [boardGroupPermissions.boardId],
|
||||
references: [boards.id],
|
||||
}),
|
||||
export const boardGroupPermissionRelations = relations(boardGroupPermissions, ({ one }) => ({
|
||||
group: one(groups, {
|
||||
fields: [boardGroupPermissions.groupId],
|
||||
references: [groups.id],
|
||||
}),
|
||||
);
|
||||
board: one(boards, {
|
||||
fields: [boardGroupPermissions.boardId],
|
||||
references: [boards.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const integrationRelations = relations(integrations, ({ many }) => ({
|
||||
secrets: many(integrationSecrets),
|
||||
items: many(integrationItems),
|
||||
}));
|
||||
|
||||
export const integrationSecretRelations = relations(
|
||||
integrationSecrets,
|
||||
({ one }) => ({
|
||||
integration: one(integrations, {
|
||||
fields: [integrationSecrets.integrationId],
|
||||
references: [integrations.id],
|
||||
}),
|
||||
export const integrationSecretRelations = relations(integrationSecrets, ({ one }) => ({
|
||||
integration: one(integrations, {
|
||||
fields: [integrationSecrets.integrationId],
|
||||
references: [integrations.id],
|
||||
}),
|
||||
);
|
||||
}));
|
||||
|
||||
export const boardRelations = relations(boards, ({ many, one }) => ({
|
||||
sections: many(sections),
|
||||
@@ -441,19 +423,16 @@ export const itemRelations = relations(items, ({ one, many }) => ({
|
||||
integrations: many(integrationItems),
|
||||
}));
|
||||
|
||||
export const integrationItemRelations = relations(
|
||||
integrationItems,
|
||||
({ one }) => ({
|
||||
integration: one(integrations, {
|
||||
fields: [integrationItems.integrationId],
|
||||
references: [integrations.id],
|
||||
}),
|
||||
item: one(items, {
|
||||
fields: [integrationItems.itemId],
|
||||
references: [items.id],
|
||||
}),
|
||||
export const integrationItemRelations = relations(integrationItems, ({ one }) => ({
|
||||
integration: one(integrations, {
|
||||
fields: [integrationItems.integrationId],
|
||||
references: [integrations.id],
|
||||
}),
|
||||
);
|
||||
item: one(items, {
|
||||
fields: [integrationItems.itemId],
|
||||
references: [items.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Account = InferSelectModel<typeof accounts>;
|
||||
|
||||
@@ -4,11 +4,16 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
|
||||
import { schema } from "..";
|
||||
|
||||
export const createDb = () => {
|
||||
export const createDb = (debug?: boolean) => {
|
||||
const sqlite = new Database(":memory:");
|
||||
const db = drizzle(sqlite, { schema });
|
||||
const db = drizzle(sqlite, { schema, logger: debug });
|
||||
migrate(db, {
|
||||
migrationsFolder: "./packages/db/migrations/sqlite",
|
||||
});
|
||||
|
||||
if (debug) {
|
||||
console.log("Database created");
|
||||
}
|
||||
|
||||
return db;
|
||||
};
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import type { Column, InferSelectModel } from "drizzle-orm";
|
||||
import type {
|
||||
ForeignKey as MysqlForeignKey,
|
||||
MySqlTableWithColumns,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
import type {
|
||||
ForeignKey as SqliteForeignKey,
|
||||
SQLiteTableWithColumns,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import type { ForeignKey as MysqlForeignKey, MySqlTableWithColumns } from "drizzle-orm/mysql-core";
|
||||
import type { ForeignKey as SqliteForeignKey, SQLiteTableWithColumns } from "drizzle-orm/sqlite-core";
|
||||
import { expect, expectTypeOf, test } from "vitest";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
@@ -21,54 +15,44 @@ test("schemas should match", () => {
|
||||
expectTypeOf<MysqlConfig>().toEqualTypeOf<SqliteConfig>();
|
||||
|
||||
objectEntries(sqliteSchema).forEach(([tableName, sqliteTable]) => {
|
||||
Object.entries(sqliteTable).forEach(
|
||||
([columnName, sqliteColumn]: [string, object]) => {
|
||||
if (!("isUnique" in sqliteColumn)) return;
|
||||
if (!("uniqueName" in sqliteColumn)) return;
|
||||
if (!("primary" in sqliteColumn)) return;
|
||||
Object.entries(sqliteTable).forEach(([columnName, sqliteColumn]: [string, object]) => {
|
||||
if (!("isUnique" in sqliteColumn)) return;
|
||||
if (!("uniqueName" in sqliteColumn)) return;
|
||||
if (!("primary" in sqliteColumn)) return;
|
||||
|
||||
const mysqlTable = mysqlSchema[tableName];
|
||||
const mysqlTable = mysqlSchema[tableName];
|
||||
|
||||
const mysqlColumn = mysqlTable[
|
||||
columnName as keyof typeof mysqlTable
|
||||
] as object;
|
||||
if (!("isUnique" in mysqlColumn)) return;
|
||||
if (!("uniqueName" in mysqlColumn)) return;
|
||||
if (!("primary" in mysqlColumn)) return;
|
||||
const mysqlColumn = mysqlTable[columnName as keyof typeof mysqlTable] as object;
|
||||
if (!("isUnique" in mysqlColumn)) return;
|
||||
if (!("uniqueName" in mysqlColumn)) return;
|
||||
if (!("primary" in mysqlColumn)) return;
|
||||
|
||||
expect(
|
||||
sqliteColumn.isUnique,
|
||||
`expect unique of column ${columnName} in table ${tableName} to be the same for both schemas`,
|
||||
).toEqual(mysqlColumn.isUnique);
|
||||
expect(
|
||||
sqliteColumn.uniqueName,
|
||||
`expect uniqueName of column ${columnName} in table ${tableName} to be the same for both schemas`,
|
||||
).toEqual(mysqlColumn.uniqueName);
|
||||
expect(
|
||||
sqliteColumn.primary,
|
||||
`expect primary of column ${columnName} in table ${tableName} to be the same for both schemas`,
|
||||
).toEqual(mysqlColumn.primary);
|
||||
},
|
||||
);
|
||||
expect(
|
||||
sqliteColumn.isUnique,
|
||||
`expect unique of column ${columnName} in table ${tableName} to be the same for both schemas`,
|
||||
).toEqual(mysqlColumn.isUnique);
|
||||
expect(
|
||||
sqliteColumn.uniqueName,
|
||||
`expect uniqueName of column ${columnName} in table ${tableName} to be the same for both schemas`,
|
||||
).toEqual(mysqlColumn.uniqueName);
|
||||
expect(
|
||||
sqliteColumn.primary,
|
||||
`expect primary of column ${columnName} in table ${tableName} to be the same for both schemas`,
|
||||
).toEqual(mysqlColumn.primary);
|
||||
});
|
||||
|
||||
const mysqlTable = mysqlSchema[tableName];
|
||||
const sqliteForeignKeys = sqliteTable[
|
||||
Symbol.for("drizzle:SQLiteInlineForeignKeys") as keyof typeof sqliteTable
|
||||
] as SqliteForeignKey[] | undefined;
|
||||
const mysqlForeignKeys = mysqlTable[
|
||||
Symbol.for("drizzle:MySqlInlineForeignKeys") as keyof typeof mysqlTable
|
||||
] as MysqlForeignKey[] | undefined;
|
||||
const sqliteForeignKeys = sqliteTable[Symbol.for("drizzle:SQLiteInlineForeignKeys") as keyof typeof sqliteTable] as
|
||||
| SqliteForeignKey[]
|
||||
| undefined;
|
||||
const mysqlForeignKeys = mysqlTable[Symbol.for("drizzle:MySqlInlineForeignKeys") as keyof typeof mysqlTable] as
|
||||
| MysqlForeignKey[]
|
||||
| undefined;
|
||||
|
||||
if (!sqliteForeignKeys && !mysqlForeignKeys) return;
|
||||
|
||||
expect(
|
||||
mysqlForeignKeys,
|
||||
`mysql foreign key for ${tableName} to be defined`,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
sqliteForeignKeys,
|
||||
`sqlite foreign key for ${tableName} to be defined`,
|
||||
).toBeDefined();
|
||||
expect(mysqlForeignKeys, `mysql foreign key for ${tableName} to be defined`).toBeDefined();
|
||||
expect(sqliteForeignKeys, `sqlite foreign key for ${tableName} to be defined`).toBeDefined();
|
||||
|
||||
expect(
|
||||
sqliteForeignKeys!.length,
|
||||
@@ -77,9 +61,7 @@ test("schemas should match", () => {
|
||||
|
||||
sqliteForeignKeys?.forEach((sqliteForeignKey) => {
|
||||
sqliteForeignKey.getName();
|
||||
const mysqlForeignKey = mysqlForeignKeys!.find(
|
||||
(key) => key.getName() === sqliteForeignKey.getName(),
|
||||
);
|
||||
const mysqlForeignKey = mysqlForeignKeys!.find((key) => key.getName() === sqliteForeignKey.getName());
|
||||
expect(
|
||||
mysqlForeignKey,
|
||||
`expect foreign key ${sqliteForeignKey.getName()} to be defined in mysql schema`,
|
||||
@@ -97,9 +79,7 @@ test("schemas should match", () => {
|
||||
|
||||
sqliteForeignKey.reference().foreignColumns.forEach((column) => {
|
||||
expect(
|
||||
mysqlForeignKey!
|
||||
.reference()
|
||||
.foreignColumns.map((column) => column.name),
|
||||
mysqlForeignKey!.reference().foreignColumns.map((column) => column.name),
|
||||
`expect foreign key (${sqliteForeignKey.getName()}) columns to be the same for both schemas`,
|
||||
).toContainEqual(column.name);
|
||||
});
|
||||
@@ -127,9 +107,7 @@ type MysqlTables = {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type InferColumnConfig<T extends Column<any, object>> =
|
||||
T extends Column<infer C, object>
|
||||
? Omit<C, "columnType" | "enumValues" | "driverParam">
|
||||
: never;
|
||||
T extends Column<infer C, object> ? Omit<C, "columnType" | "enumValues" | "driverParam"> : never;
|
||||
|
||||
type SqliteConfig = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -1,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,
|
||||
) => ({
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -20,14 +20,10 @@ const groupPermissionParents = {
|
||||
admin: ["board-full-access", "integration-full-access"],
|
||||
} satisfies Partial<Record<GroupPermissionKey, GroupPermissionKey[]>>;
|
||||
|
||||
export const getPermissionsWithParents = (
|
||||
permissions: GroupPermissionKey[],
|
||||
): GroupPermissionKey[] => {
|
||||
export const getPermissionsWithParents = (permissions: GroupPermissionKey[]): GroupPermissionKey[] => {
|
||||
const res = permissions.map((permission) => {
|
||||
return objectEntries(groupPermissionParents)
|
||||
.filter(([_key, value]: [string, GroupPermissionKey[]]) =>
|
||||
value.includes(permission),
|
||||
)
|
||||
.filter(([_key, value]: [string, GroupPermissionKey[]]) => value.includes(permission))
|
||||
.map(([key]) => getPermissionsWithParents([key]))
|
||||
.flat();
|
||||
});
|
||||
@@ -35,13 +31,9 @@ export const getPermissionsWithParents = (
|
||||
return permissions.concat(res.flat());
|
||||
};
|
||||
|
||||
const getPermissionsInner = (
|
||||
permissionSet: Set<GroupPermissionKey>,
|
||||
permissions: GroupPermissionKey[],
|
||||
) => {
|
||||
const getPermissionsInner = (permissionSet: Set<GroupPermissionKey>, permissions: GroupPermissionKey[]) => {
|
||||
permissions.forEach((permission) => {
|
||||
const children =
|
||||
groupPermissionParents[permission as keyof typeof groupPermissionParents];
|
||||
const children = groupPermissionParents[permission as keyof typeof groupPermissionParents];
|
||||
if (children) {
|
||||
getPermissionsInner(permissionSet, children);
|
||||
}
|
||||
@@ -50,9 +42,7 @@ const getPermissionsInner = (
|
||||
});
|
||||
};
|
||||
|
||||
export const getPermissionsWithChildren = (
|
||||
permissions: GroupPermissionKey[],
|
||||
) => {
|
||||
export const getPermissionsWithChildren = (permissions: GroupPermissionKey[]) => {
|
||||
const permissionSet = new Set<GroupPermissionKey>();
|
||||
getPermissionsInner(permissionSet, permissions);
|
||||
return Array.from(permissionSet);
|
||||
@@ -66,19 +56,14 @@ export type GroupPermissionKey = {
|
||||
: key;
|
||||
}[keyof GroupPermissions];
|
||||
|
||||
export const groupPermissionKeys = objectKeys(groupPermissions).reduce(
|
||||
(acc, key) => {
|
||||
const item = groupPermissions[key];
|
||||
if (typeof item !== "boolean") {
|
||||
acc.push(
|
||||
...item.map((subKey) => `${key}-${subKey}` as GroupPermissionKey),
|
||||
);
|
||||
} else {
|
||||
acc.push(key as GroupPermissionKey);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as GroupPermissionKey[],
|
||||
);
|
||||
export const groupPermissionKeys = objectKeys(groupPermissions).reduce((acc, key) => {
|
||||
const item = groupPermissions[key];
|
||||
if (typeof item !== "boolean") {
|
||||
acc.push(...item.map((subKey) => `${key}-${subKey}` as GroupPermissionKey));
|
||||
} else {
|
||||
acc.push(key as GroupPermissionKey);
|
||||
}
|
||||
return acc;
|
||||
}, [] as GroupPermissionKey[]);
|
||||
|
||||
export type BoardPermission = (typeof boardPermissions)[number];
|
||||
|
||||
@@ -1,47 +1,22 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { GroupPermissionKey } from "../permissions";
|
||||
import {
|
||||
getPermissionsWithChildren,
|
||||
getPermissionsWithParents,
|
||||
} from "../permissions";
|
||||
import { getPermissionsWithChildren, getPermissionsWithParents } from "../permissions";
|
||||
|
||||
describe("getPermissionsWithParents should return the correct permissions", () => {
|
||||
test.each([
|
||||
[
|
||||
["board-view-all"],
|
||||
["board-view-all", "board-modify-all", "board-full-access", "admin"],
|
||||
],
|
||||
[["board-view-all"], ["board-view-all", "board-modify-all", "board-full-access", "admin"]],
|
||||
[["board-modify-all"], ["board-modify-all", "board-full-access", "admin"]],
|
||||
[["board-create"], ["board-create", "board-full-access", "admin"]],
|
||||
[["board-full-access"], ["board-full-access", "admin"]],
|
||||
[
|
||||
["integration-use-all"],
|
||||
[
|
||||
"integration-use-all",
|
||||
"integration-interact-all",
|
||||
"integration-full-access",
|
||||
"admin",
|
||||
],
|
||||
],
|
||||
[
|
||||
["integration-create"],
|
||||
["integration-create", "integration-full-access", "admin"],
|
||||
],
|
||||
[
|
||||
["integration-interact-all"],
|
||||
["integration-interact-all", "integration-full-access", "admin"],
|
||||
],
|
||||
[["integration-use-all"], ["integration-use-all", "integration-interact-all", "integration-full-access", "admin"]],
|
||||
[["integration-create"], ["integration-create", "integration-full-access", "admin"]],
|
||||
[["integration-interact-all"], ["integration-interact-all", "integration-full-access", "admin"]],
|
||||
[["integration-full-access"], ["integration-full-access", "admin"]],
|
||||
[["admin"], ["admin"]],
|
||||
] satisfies [GroupPermissionKey[], GroupPermissionKey[]][])(
|
||||
"expect %s to return %s",
|
||||
(input, expectedOutput) => {
|
||||
expect(getPermissionsWithParents(input)).toEqual(
|
||||
expect.arrayContaining(expectedOutput),
|
||||
);
|
||||
},
|
||||
);
|
||||
] satisfies [GroupPermissionKey[], GroupPermissionKey[]][])("expect %s to return %s", (input, expectedOutput) => {
|
||||
expect(getPermissionsWithParents(input)).toEqual(expect.arrayContaining(expectedOutput));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPermissionsWithChildren should return the correct permissions", () => {
|
||||
@@ -49,24 +24,11 @@ describe("getPermissionsWithChildren should return the correct permissions", ()
|
||||
[["board-view-all"], ["board-view-all"]],
|
||||
[["board-modify-all"], ["board-view-all", "board-modify-all"]],
|
||||
[["board-create"], ["board-create"]],
|
||||
[
|
||||
["board-full-access"],
|
||||
["board-full-access", "board-modify-all", "board-view-all"],
|
||||
],
|
||||
[["board-full-access"], ["board-full-access", "board-modify-all", "board-view-all"]],
|
||||
[["integration-use-all"], ["integration-use-all"]],
|
||||
[["integration-create"], ["integration-create"]],
|
||||
[
|
||||
["integration-interact-all"],
|
||||
["integration-interact-all", "integration-use-all"],
|
||||
],
|
||||
[
|
||||
["integration-full-access"],
|
||||
[
|
||||
"integration-full-access",
|
||||
"integration-interact-all",
|
||||
"integration-use-all",
|
||||
],
|
||||
],
|
||||
[["integration-interact-all"], ["integration-interact-all", "integration-use-all"]],
|
||||
[["integration-full-access"], ["integration-full-access", "integration-interact-all", "integration-use-all"]],
|
||||
[
|
||||
["admin"],
|
||||
[
|
||||
@@ -79,12 +41,7 @@ describe("getPermissionsWithChildren should return the correct permissions", ()
|
||||
"integration-use-all",
|
||||
],
|
||||
],
|
||||
] satisfies [GroupPermissionKey[], GroupPermissionKey[]][])(
|
||||
"expect %s to return %s",
|
||||
(input, expectedOutput) => {
|
||||
expect(getPermissionsWithChildren(input)).toEqual(
|
||||
expect.arrayContaining(expectedOutput),
|
||||
);
|
||||
},
|
||||
);
|
||||
] satisfies [GroupPermissionKey[], GroupPermissionKey[]][])("expect %s to return %s", (input, expectedOutput) => {
|
||||
expect(getPermissionsWithChildren(input)).toEqual(expect.arrayContaining(expectedOutput));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,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];
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@mantine/form": "^7.9.1"
|
||||
"@mantine/form": "^7.9.2",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,26 @@
|
||||
export const name = "form";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { z } from "@homarr/validation";
|
||||
import type { AnyZodObject, ZodEffects, ZodIntersection } from "@homarr/validation";
|
||||
import { zodErrorMap } from "@homarr/validation/form";
|
||||
|
||||
export const useZodForm = <
|
||||
TSchema extends AnyZodObject | ZodEffects<AnyZodObject> | ZodIntersection<AnyZodObject, AnyZodObject>,
|
||||
>(
|
||||
schema: TSchema,
|
||||
options: Omit<
|
||||
Exclude<Parameters<typeof useForm<z.infer<TSchema>>>[0], undefined>,
|
||||
"validate" | "validateInputOnBlur" | "validateInputOnChange"
|
||||
>,
|
||||
) => {
|
||||
const t = useI18n();
|
||||
|
||||
z.setErrorMap(zodErrorMap(t));
|
||||
return useForm<z.infer<TSchema>>({
|
||||
...options,
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
validate: zodResolver(schema),
|
||||
});
|
||||
};
|
||||
|
||||
102
packages/form/src/messages.ts
Normal file
102
packages/form/src/messages.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import type { ErrorMapCtx, z, ZodTooBigIssue, ZodTooSmallIssue } from "@homarr/validation";
|
||||
import { ZodIssueCode } from "@homarr/validation";
|
||||
|
||||
const handleStringError = (issue: z.ZodInvalidStringIssue) => {
|
||||
if (typeof issue.validation === "object") {
|
||||
// Check if object contains startsWith / endsWith key to determine the error type. If not, it's an includes error. (see type of issue.validation)
|
||||
if ("startsWith" in issue.validation) {
|
||||
return {
|
||||
key: "errors.string.startsWith",
|
||||
params: {
|
||||
startsWith: issue.validation.startsWith,
|
||||
},
|
||||
} as const;
|
||||
} else if ("endsWith" in issue.validation) {
|
||||
return {
|
||||
key: "errors.string.endsWith",
|
||||
params: {
|
||||
endsWith: issue.validation.endsWith,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
key: "errors.invalid_string.includes",
|
||||
params: {
|
||||
includes: issue.validation.includes,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
message: issue.message,
|
||||
};
|
||||
};
|
||||
|
||||
const handleTooSmallError = (issue: ZodTooSmallIssue) => {
|
||||
if (issue.type !== "string" && issue.type !== "number") {
|
||||
return {
|
||||
message: issue.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: `errors.tooSmall.${issue.type}`,
|
||||
params: {
|
||||
minimum: issue.minimum,
|
||||
count: issue.minimum,
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
const handleTooBigError = (issue: ZodTooBigIssue) => {
|
||||
if (issue.type !== "string" && issue.type !== "number") {
|
||||
return {
|
||||
message: issue.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: `errors.tooBig.${issue.type}`,
|
||||
params: {
|
||||
maximum: issue.maximum,
|
||||
count: issue.maximum,
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
export const handleZodError = (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => {
|
||||
if (ctx.defaultError === "Required") {
|
||||
return {
|
||||
key: "errors.required",
|
||||
params: {},
|
||||
} as const;
|
||||
}
|
||||
if (issue.code === ZodIssueCode.invalid_string) {
|
||||
return handleStringError(issue);
|
||||
}
|
||||
if (issue.code === ZodIssueCode.too_small) {
|
||||
return handleTooSmallError(issue);
|
||||
}
|
||||
if (issue.code === ZodIssueCode.too_big) {
|
||||
return handleTooBigError(issue);
|
||||
}
|
||||
if (issue.code === ZodIssueCode.custom && issue.params?.i18n) {
|
||||
const { i18n } = issue.params as CustomErrorParams;
|
||||
return {
|
||||
key: `errors.custom.${i18n.key}`,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
message: issue.message,
|
||||
};
|
||||
};
|
||||
|
||||
export interface CustomErrorParams {
|
||||
i18n: {
|
||||
key: keyof TranslationObject["common"]["zod"]["errors"]["custom"];
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
@@ -8,9 +8,7 @@ const repositories = [
|
||||
"walkxcode/dashboard-icons",
|
||||
undefined,
|
||||
new URL("https://github.com/walkxcode/dashboard-icons"),
|
||||
new URL(
|
||||
"https://api.github.com/repos/walkxcode/dashboard-icons/git/trees/main?recursive=true",
|
||||
),
|
||||
new URL("https://api.github.com/repos/walkxcode/dashboard-icons/git/trees/main?recursive=true"),
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/{0}",
|
||||
),
|
||||
new JsdelivrIconRepository(
|
||||
@@ -18,9 +16,7 @@ const repositories = [
|
||||
"PapirusDevelopmentTeam/papirus-icon-theme",
|
||||
"GPL-3.0",
|
||||
new URL("https://github.com/PapirusDevelopmentTeam/papirus-icon-theme"),
|
||||
new URL(
|
||||
"https://data.jsdelivr.com/v1/packages/gh/PapirusDevelopmentTeam/papirus_icons@master?structure=flat",
|
||||
),
|
||||
new URL("https://data.jsdelivr.com/v1/packages/gh/PapirusDevelopmentTeam/papirus_icons@master?structure=flat"),
|
||||
"https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons/{0}",
|
||||
),
|
||||
new JsdelivrIconRepository(
|
||||
@@ -28,15 +24,11 @@ const repositories = [
|
||||
"loganmarchione/homelab-svg-assets",
|
||||
"MIT",
|
||||
new URL("https://github.com/loganmarchione/homelab-svg-assets"),
|
||||
new URL(
|
||||
"https://data.jsdelivr.com/v1/packages/gh/loganmarchione/homelab-svg-assets@main?structure=flat",
|
||||
),
|
||||
new URL("https://data.jsdelivr.com/v1/packages/gh/loganmarchione/homelab-svg-assets@main?structure=flat"),
|
||||
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/{0}",
|
||||
),
|
||||
];
|
||||
|
||||
export const fetchIconsAsync = async (): Promise<RepositoryIconGroup[]> => {
|
||||
return await Promise.all(
|
||||
repositories.map(async (repository) => await repository.getAllIconsAsync()),
|
||||
);
|
||||
return await Promise.all(repositories.map(async (repository) => await repository.getAllIconsAsync()));
|
||||
};
|
||||
|
||||
@@ -11,14 +11,7 @@ export class GitHubIconRepository extends IconRepository {
|
||||
public readonly repositoryIndexingUrl?: URL,
|
||||
public readonly repositoryBlobUrlTemplate?: string,
|
||||
) {
|
||||
super(
|
||||
name,
|
||||
slug,
|
||||
license,
|
||||
repositoryUrl,
|
||||
repositoryIndexingUrl,
|
||||
repositoryBlobUrlTemplate,
|
||||
);
|
||||
super(name, slug, license, repositoryUrl, repositoryIndexingUrl, repositoryBlobUrlTemplate);
|
||||
}
|
||||
|
||||
protected async getAllIconsInternalAsync(): Promise<RepositoryIconGroup> {
|
||||
@@ -33,18 +26,13 @@ export class GitHubIconRepository extends IconRepository {
|
||||
success: true,
|
||||
icons: listOfFiles.tree
|
||||
.filter((treeItem) =>
|
||||
this.allowedImageFileTypes.some((allowedExtension) =>
|
||||
treeItem.path.includes(allowedExtension),
|
||||
),
|
||||
this.allowedImageFileTypes.some((allowedExtension) => treeItem.path.includes(allowedExtension)),
|
||||
)
|
||||
.map((treeItem) => {
|
||||
const fileNameWithExtension =
|
||||
this.getFileNameWithoutExtensionFromPath(treeItem.path);
|
||||
const fileNameWithExtension = this.getFileNameWithoutExtensionFromPath(treeItem.path);
|
||||
|
||||
return {
|
||||
imageUrl: new URL(
|
||||
this.repositoryBlobUrlTemplate!.replace("{0}", treeItem.path),
|
||||
),
|
||||
imageUrl: new URL(this.repositoryBlobUrlTemplate!.replace("{0}", treeItem.path)),
|
||||
fileNameWithExtension: fileNameWithExtension,
|
||||
local: false,
|
||||
sizeInBytes: treeItem.size,
|
||||
|
||||
@@ -19,9 +19,7 @@ export abstract class IconRepository {
|
||||
try {
|
||||
return await this.getAllIconsInternalAsync();
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Unable to request icons from repository "${this.slug}": ${JSON.stringify(err)}`,
|
||||
);
|
||||
logger.error(`Unable to request icons from repository "${this.slug}": ${JSON.stringify(err)}`);
|
||||
return {
|
||||
success: false,
|
||||
icons: [],
|
||||
|
||||
@@ -11,14 +11,7 @@ export class JsdelivrIconRepository extends IconRepository {
|
||||
public readonly repositoryIndexingUrl: URL,
|
||||
public readonly repositoryBlobUrlTemplate: string,
|
||||
) {
|
||||
super(
|
||||
name,
|
||||
slug,
|
||||
license,
|
||||
repositoryUrl,
|
||||
repositoryIndexingUrl,
|
||||
repositoryBlobUrlTemplate,
|
||||
);
|
||||
super(name, slug, license, repositoryUrl, repositoryIndexingUrl, repositoryBlobUrlTemplate);
|
||||
}
|
||||
|
||||
protected async getAllIconsInternalAsync(): Promise<RepositoryIconGroup> {
|
||||
@@ -29,18 +22,13 @@ export class JsdelivrIconRepository extends IconRepository {
|
||||
success: true,
|
||||
icons: listOfFiles.files
|
||||
.filter((file) =>
|
||||
this.allowedImageFileTypes.some((allowedImageFileType) =>
|
||||
file.name.includes(allowedImageFileType),
|
||||
),
|
||||
this.allowedImageFileTypes.some((allowedImageFileType) => file.name.includes(allowedImageFileType)),
|
||||
)
|
||||
.map((file) => {
|
||||
const fileNameWithExtension =
|
||||
this.getFileNameWithoutExtensionFromPath(file.name);
|
||||
const fileNameWithExtension = this.getFileNameWithoutExtensionFromPath(file.name);
|
||||
|
||||
return {
|
||||
imageUrl: new URL(
|
||||
this.repositoryBlobUrlTemplate.replace("{0}", file.name),
|
||||
),
|
||||
imageUrl: new URL(this.repositoryBlobUrlTemplate.replace("{0}", file.name)),
|
||||
fileNameWithExtension: fileNameWithExtension,
|
||||
local: false,
|
||||
sizeInBytes: file.size,
|
||||
|
||||
@@ -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()],
|
||||
});
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import type { ButtonProps, GroupProps } from "@mantine/core";
|
||||
import { Box, Button, Group } from "@mantine/core";
|
||||
|
||||
import type {
|
||||
stringOrTranslation,
|
||||
TranslationFunction,
|
||||
} from "@homarr/translation";
|
||||
import type { stringOrTranslation, TranslationFunction } from "@homarr/translation";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -31,70 +28,50 @@ export interface ConfirmModalProps {
|
||||
};
|
||||
}
|
||||
|
||||
export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(
|
||||
({ actions, innerProps }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const t = useI18n();
|
||||
const {
|
||||
children,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
cancelProps,
|
||||
confirmProps,
|
||||
groupProps,
|
||||
labels,
|
||||
} = innerProps;
|
||||
export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(({ actions, innerProps }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const t = useI18n();
|
||||
const { children, onConfirm, onCancel, cancelProps, confirmProps, groupProps, labels } = innerProps;
|
||||
|
||||
const closeOnConfirm = innerProps.closeOnConfirm ?? true;
|
||||
const closeOnCancel = innerProps.closeOnCancel ?? true;
|
||||
const closeOnConfirm = innerProps.closeOnConfirm ?? true;
|
||||
const closeOnCancel = innerProps.closeOnCancel ?? true;
|
||||
|
||||
const cancelLabel =
|
||||
labels?.cancel ?? ((t: TranslationFunction) => t("common.action.cancel"));
|
||||
const confirmLabel =
|
||||
labels?.confirm ??
|
||||
((t: TranslationFunction) => t("common.action.confirm"));
|
||||
const cancelLabel = labels?.cancel ?? ((t: TranslationFunction) => t("common.action.cancel"));
|
||||
const confirmLabel = labels?.confirm ?? ((t: TranslationFunction) => t("common.action.confirm"));
|
||||
|
||||
const handleCancel = useCallback(
|
||||
async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
typeof cancelProps?.onClick === "function" &&
|
||||
cancelProps?.onClick(event);
|
||||
typeof onCancel === "function" && (await onCancel());
|
||||
closeOnCancel && actions.closeModal();
|
||||
},
|
||||
[cancelProps?.onClick, onCancel, actions.closeModal],
|
||||
);
|
||||
const handleCancel = useCallback(
|
||||
async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
typeof cancelProps?.onClick === "function" && cancelProps?.onClick(event);
|
||||
typeof onCancel === "function" && (await onCancel());
|
||||
closeOnCancel && actions.closeModal();
|
||||
},
|
||||
[cancelProps?.onClick, onCancel, actions.closeModal],
|
||||
);
|
||||
|
||||
const handleConfirm = useCallback(
|
||||
async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setLoading(true);
|
||||
typeof confirmProps?.onClick === "function" &&
|
||||
confirmProps?.onClick(event);
|
||||
typeof onConfirm === "function" && (await onConfirm());
|
||||
closeOnConfirm && actions.closeModal();
|
||||
setLoading(false);
|
||||
},
|
||||
[confirmProps?.onClick, onConfirm, actions.closeModal],
|
||||
);
|
||||
const handleConfirm = useCallback(
|
||||
async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setLoading(true);
|
||||
typeof confirmProps?.onClick === "function" && confirmProps?.onClick(event);
|
||||
typeof onConfirm === "function" && (await onConfirm());
|
||||
closeOnConfirm && actions.closeModal();
|
||||
setLoading(false);
|
||||
},
|
||||
[confirmProps?.onClick, onConfirm, actions.closeModal],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children && <Box mb="md">{children}</Box>}
|
||||
return (
|
||||
<>
|
||||
{children && <Box mb="md">{children}</Box>}
|
||||
|
||||
<Group justify="flex-end" {...groupProps}>
|
||||
<Button variant="default" {...cancelProps} onClick={handleCancel}>
|
||||
{cancelProps?.children || translateIfNecessary(t, cancelLabel)}
|
||||
</Button>
|
||||
<Group justify="flex-end" {...groupProps}>
|
||||
<Button variant="default" {...cancelProps} onClick={handleCancel}>
|
||||
{cancelProps?.children || translateIfNecessary(t, cancelLabel)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
{...confirmProps}
|
||||
onClick={handleConfirm}
|
||||
color="red.9"
|
||||
loading={loading}
|
||||
>
|
||||
{confirmProps?.children || translateIfNecessary(t, confirmLabel)}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
},
|
||||
).withOptions({});
|
||||
<Button {...confirmProps} onClick={handleConfirm} color="red.9" loading={loading}>
|
||||
{confirmProps?.children || translateIfNecessary(t, confirmLabel)}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}).withOptions({});
|
||||
|
||||
@@ -1,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 {
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useReducer, useRef, useState } from "react";
|
||||
import { getDefaultZIndex, Modal } from "@mantine/core";
|
||||
import { randomId } from "@mantine/hooks";
|
||||
|
||||
@@ -76,19 +68,12 @@ export const ModalProvider = ({ children }: PropsWithChildren) => {
|
||||
[closeModal, state.current?.id],
|
||||
);
|
||||
|
||||
const activeModals = state.modals.filter(
|
||||
(modal) => modal.id === state.current?.id || modal.props.keepMounted,
|
||||
);
|
||||
const activeModals = state.modals.filter((modal) => modal.id === state.current?.id || modal.props.keepMounted);
|
||||
|
||||
return (
|
||||
<ModalContext.Provider value={{ openModalInner, closeModal }}>
|
||||
{activeModals.map((modal) => (
|
||||
<ActiveModal
|
||||
key={modal.id}
|
||||
modal={modal}
|
||||
state={state}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
<ActiveModal key={modal.id} modal={modal} state={state} handleCloseModal={handleCloseModal} />
|
||||
))}
|
||||
|
||||
{children}
|
||||
@@ -112,14 +97,12 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
|
||||
setTimeout(() => setOpened(true), 0);
|
||||
}, []);
|
||||
|
||||
const { defaultTitle: _ignored, ...otherModalProps } =
|
||||
modal.reference.modalProps;
|
||||
const { defaultTitle: _ignored, ...otherModalProps } = modal.reference.modalProps;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
key={modal.id}
|
||||
zIndex={getDefaultZIndex("modal") + 1}
|
||||
display={modal.id === state.current?.id ? undefined : "none"}
|
||||
style={{
|
||||
userSelect: modal.id === state.current?.id ? undefined : "none",
|
||||
}}
|
||||
@@ -128,6 +111,9 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
|
||||
fontSize: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
inner: {
|
||||
display: modal.id === state.current?.id ? undefined : "none",
|
||||
},
|
||||
}}
|
||||
trapFocus={modal.id === state.current?.id}
|
||||
{...otherModalProps}
|
||||
@@ -145,18 +131,13 @@ interface OpenModalOptions {
|
||||
title?: stringOrTranslation;
|
||||
}
|
||||
|
||||
export const useModalAction = <TModal extends ModalDefinition>(
|
||||
modal: TModal,
|
||||
) => {
|
||||
export const useModalAction = <TModal extends ModalDefinition>(modal: TModal) => {
|
||||
const context = useContext(ModalContext);
|
||||
|
||||
if (!context) throw new Error("ModalContext is not provided");
|
||||
|
||||
return {
|
||||
openModal: (
|
||||
innerProps: inferInnerProps<TModal>,
|
||||
options: OpenModalOptions | void,
|
||||
) => {
|
||||
openModal: (innerProps: inferInnerProps<TModal>, options: OpenModalOptions | void) => {
|
||||
context.openModalInner({ modal, innerProps, options: options ?? {} });
|
||||
},
|
||||
};
|
||||
@@ -166,7 +147,6 @@ export const useConfirmModal = () => {
|
||||
const { openModal } = useModalAction(ConfirmModal);
|
||||
|
||||
return {
|
||||
openConfirmModal: (props: ConfirmModalProps) =>
|
||||
openModal(props, { title: props.title }),
|
||||
openConfirmModal: (props: ConfirmModalProps) => openModal(props, { title: props.title }),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/notifications": "^7.9.1",
|
||||
"@mantine/notifications": "^7.9.2",
|
||||
"@homarr/ui": "workspace:^0.1.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
|
||||
@@ -1,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;
|
||||
|
||||
@@ -32,9 +32,7 @@ export const createSubPubChannel = <TData>(name: string) => {
|
||||
if (!err) {
|
||||
return;
|
||||
}
|
||||
logger.error(
|
||||
`Error with channel '${channelName}': ${err.name} (${err.message})`,
|
||||
);
|
||||
logger.error(`Error with channel '${channelName}': ${err.name} (${err.message})`);
|
||||
});
|
||||
subscriber.on("message", (channel, message) => {
|
||||
if (channel !== channelName) return; // TODO: check if this is necessary - it should be handled by the redis client
|
||||
@@ -53,6 +51,34 @@ export const createSubPubChannel = <TData>(name: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
const cacheClient = createRedisConnection();
|
||||
|
||||
/**
|
||||
* Creates a new cache channel.
|
||||
* @param name name of the channel
|
||||
* @returns cache channel object
|
||||
*/
|
||||
export const createCacheChannel = <TData>(name: string) => {
|
||||
const cacheChannelName = `cache:${name}`;
|
||||
return {
|
||||
/**
|
||||
* Get the data from the cache channel.
|
||||
* @returns data or undefined if not found
|
||||
*/
|
||||
getAsync: async () => {
|
||||
const data = await cacheClient.get(cacheChannelName);
|
||||
return data ? superjson.parse<TData>(data) : undefined;
|
||||
},
|
||||
/**
|
||||
* Set the data in the cache channel.
|
||||
* @param data data to be stored in the cache channel
|
||||
*/
|
||||
setAsync: async (data: TData) => {
|
||||
await cacheClient.set(cacheChannelName, superjson.stringify(data));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const queueClient = createRedisConnection();
|
||||
|
||||
type WithId<TItem> = TItem & { _id: string };
|
||||
|
||||
1
packages/server-settings/index.ts
Normal file
1
packages/server-settings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
36
packages/server-settings/package.json
Normal file
36
packages/server-settings/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@homarr/server-settings",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^8.57.0",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"@homarr/eslint-config/base"
|
||||
]
|
||||
},
|
||||
"prettier": "@homarr/prettier-config"
|
||||
}
|
||||
16
packages/server-settings/src/index.ts
Normal file
16
packages/server-settings/src/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const defaultServerSettingsKeys = ["analytics"] as const;
|
||||
|
||||
export type ServerSettingsRecord = {
|
||||
[key in (typeof defaultServerSettingsKeys)[number]]: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const defaultServerSettings = {
|
||||
analytics: {
|
||||
enableGeneral: true,
|
||||
enableWidgetData: false,
|
||||
enableIntegrationData: false,
|
||||
enableUserData: false,
|
||||
},
|
||||
} satisfies ServerSettingsRecord;
|
||||
|
||||
export type ServerSettings = typeof defaultServerSettings;
|
||||
8
packages/server-settings/tsconfig.json
Normal file
8
packages/server-settings/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@mantine/spotlight": "^7.9.1",
|
||||
"@mantine/spotlight": "^7.9.2",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createI18nClient } from "next-international/client";
|
||||
import { languageMapping } from "./lang";
|
||||
import enTranslation from "./lang/en";
|
||||
|
||||
export const { useI18n, useScopedI18n, I18nProviderClient } = createI18nClient(
|
||||
export const { useI18n, useScopedI18n, useCurrentLocale, useChangeLocale, I18nProviderClient } = createI18nClient(
|
||||
languageMapping(),
|
||||
{
|
||||
fallbackLocale: enTranslation,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { stringOrTranslation, TranslationFunction } from "./type";
|
||||
|
||||
export * from "./type";
|
||||
export * from "./locale-attributes";
|
||||
|
||||
export const supportedLanguages = ["en", "de"] as const;
|
||||
export type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
@@ -8,10 +9,7 @@ export type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
export const defaultLocale = "en";
|
||||
export { languageMapping } from "./lang";
|
||||
|
||||
export const translateIfNecessary = (
|
||||
t: TranslationFunction,
|
||||
value: stringOrTranslation | undefined,
|
||||
) => {
|
||||
export const translateIfNecessary = (t: TranslationFunction, value: stringOrTranslation | undefined) => {
|
||||
if (typeof value === "function") {
|
||||
return value(t);
|
||||
}
|
||||
|
||||
@@ -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>>;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -121,8 +121,7 @@ export default {
|
||||
label: "Delete user permanently",
|
||||
description:
|
||||
"Deletes this user including their preferences. Will not delete any boards. User will not be notified.",
|
||||
confirm:
|
||||
"Are you sure, that you want to delete the user {username} with his preferences?",
|
||||
confirm: "Are you sure, that you want to delete the user {username} with his preferences?",
|
||||
},
|
||||
select: {
|
||||
label: "Select user",
|
||||
@@ -147,8 +146,7 @@ export default {
|
||||
item: {
|
||||
admin: {
|
||||
label: "Administrator",
|
||||
description:
|
||||
"Members with this permission have full access to all features and settings",
|
||||
description: "Members with this permission have full access to all features and settings",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -165,8 +163,7 @@ export default {
|
||||
},
|
||||
"modify-all": {
|
||||
label: "Modify all boards",
|
||||
description:
|
||||
"Allow members to modify all boards (Does not include access control and danger zone)",
|
||||
description: "Allow members to modify all boards (Does not include access control and danger zone)",
|
||||
},
|
||||
"full-access": {
|
||||
label: "Full board access",
|
||||
@@ -184,8 +181,7 @@ export default {
|
||||
},
|
||||
"use-all": {
|
||||
label: "Use all integrations",
|
||||
description:
|
||||
"Allows members to add any integrations to their boards",
|
||||
description: "Allows members to add any integrations to their boards",
|
||||
},
|
||||
"interact-all": {
|
||||
label: "Interact with any integration",
|
||||
@@ -193,8 +189,7 @@ export default {
|
||||
},
|
||||
"full-access": {
|
||||
label: "Full integration access",
|
||||
description:
|
||||
"Allow members to manage, use and interact with any integration",
|
||||
description: "Allow members to manage, use and interact with any integration",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -214,8 +209,7 @@ export default {
|
||||
transfer: {
|
||||
label: "Transfer ownership",
|
||||
description: "Transfer ownership of this group to another user.",
|
||||
confirm:
|
||||
"Are you sure you want to transfer ownership for the group {name} to {username}?",
|
||||
confirm: "Are you sure you want to transfer ownership for the group {name} to {username}?",
|
||||
notification: {
|
||||
success: {
|
||||
message: "Transfered group {group} successfully to {user}",
|
||||
@@ -234,8 +228,7 @@ export default {
|
||||
},
|
||||
delete: {
|
||||
label: "Delete group",
|
||||
description:
|
||||
"Once you delete a group, there is no going back. Please be certain.",
|
||||
description: "Once you delete a group, there is no going back. Please be certain.",
|
||||
confirm: "Are you sure you want to delete the group {name}?",
|
||||
notification: {
|
||||
success: {
|
||||
@@ -386,8 +379,7 @@ export default {
|
||||
},
|
||||
testConnection: {
|
||||
action: "Test connection",
|
||||
alertNotice:
|
||||
"The Save button is enabled once a successful connection is established",
|
||||
alertNotice: "The Save button is enabled once a successful connection is established",
|
||||
notification: {
|
||||
success: {
|
||||
title: "Connection successful",
|
||||
@@ -457,8 +449,7 @@ export default {
|
||||
checkoutDocs: "Check out the documentation",
|
||||
},
|
||||
iconPicker: {
|
||||
header:
|
||||
"Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
|
||||
header: "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
|
||||
},
|
||||
notification: {
|
||||
create: {
|
||||
@@ -481,6 +472,10 @@ export default {
|
||||
multiSelect: {
|
||||
placeholder: "Pick one or more values",
|
||||
},
|
||||
multiText: {
|
||||
placeholder: "Add more values",
|
||||
addLabel: `Add {value}`,
|
||||
},
|
||||
select: {
|
||||
placeholder: "Pick value",
|
||||
badge: {
|
||||
@@ -505,7 +500,7 @@ export default {
|
||||
preferences: "Your preferences",
|
||||
logout: "Logout",
|
||||
login: "Login",
|
||||
navigateDefaultBoard: "Navigate to default board",
|
||||
homeBoard: "Your home board",
|
||||
loggedOut: "Logged out",
|
||||
},
|
||||
},
|
||||
@@ -515,6 +510,31 @@ export default {
|
||||
show: "Show preview",
|
||||
hide: "Hide preview",
|
||||
},
|
||||
zod: {
|
||||
errors: {
|
||||
default: "This field is invalid",
|
||||
required: "This field is required",
|
||||
string: {
|
||||
startsWith: "This field must start with {startsWith}",
|
||||
endsWith: "This field must end with {endsWith}",
|
||||
includes: "This field must include {includes}",
|
||||
invalidEmail: "This field must be a valid email",
|
||||
},
|
||||
tooSmall: {
|
||||
string: "This field must be at least {minimum} characters long",
|
||||
number: "This field must be greater than or equal to {minimum}",
|
||||
},
|
||||
tooBig: {
|
||||
string: "This field must be at most {maximum} characters long",
|
||||
number: "This field must be less than or equal to {maximum}",
|
||||
},
|
||||
custom: {
|
||||
passwordsDoNotMatch: "Passwords do not match",
|
||||
boardAlreadyExists: "A board with this name already exists",
|
||||
// TODO: Add custom error messages
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
section: {
|
||||
category: {
|
||||
@@ -581,10 +601,17 @@ export default {
|
||||
},
|
||||
edit: {
|
||||
title: "Edit item",
|
||||
advancedOptions: {
|
||||
label: "Advanced options",
|
||||
title: "Advanced item options",
|
||||
},
|
||||
field: {
|
||||
integrations: {
|
||||
label: "Integrations",
|
||||
},
|
||||
customCssClasses: {
|
||||
label: "Custom css classes",
|
||||
},
|
||||
},
|
||||
},
|
||||
remove: {
|
||||
@@ -620,8 +647,7 @@ export default {
|
||||
option: {
|
||||
customTitleToggle: {
|
||||
label: "Custom Title/City display",
|
||||
description:
|
||||
"Show off a custom title or the name of the city/country on top of the clock.",
|
||||
description: "Show off a custom title or the name of the city/country on top of the clock.",
|
||||
},
|
||||
customTitle: {
|
||||
label: "Title",
|
||||
@@ -712,8 +738,7 @@ export default {
|
||||
},
|
||||
iframe: {
|
||||
name: "iFrame",
|
||||
description:
|
||||
"Embed any content from the internet. Some websites may restrict access.",
|
||||
description: "Embed any content from the internet. Some websites may restrict access.",
|
||||
option: {
|
||||
embedUrl: {
|
||||
label: "Embed URL",
|
||||
@@ -745,14 +770,12 @@ export default {
|
||||
},
|
||||
error: {
|
||||
noUrl: "No iFrame URL provided",
|
||||
noBrowerSupport:
|
||||
"Your Browser does not support iframes. Please update your browser.",
|
||||
noBrowerSupport: "Your Browser does not support iframes. Please update your browser.",
|
||||
},
|
||||
},
|
||||
weather: {
|
||||
name: "Weather",
|
||||
description:
|
||||
"Displays the current weather information of a set location.",
|
||||
description: "Displays the current weather information of a set location.",
|
||||
option: {
|
||||
isFormatFahrenheit: {
|
||||
label: "Temperature in Fahrenheit",
|
||||
@@ -768,8 +791,7 @@ export default {
|
||||
},
|
||||
forecastDayCount: {
|
||||
label: "Amount of forecast days",
|
||||
description:
|
||||
"When the widget is not wide enough, less days are shown",
|
||||
description: "When the widget is not wide enough, less days are shown",
|
||||
},
|
||||
},
|
||||
kind: {
|
||||
@@ -822,8 +844,7 @@ export default {
|
||||
},
|
||||
hasAutoPlay: {
|
||||
label: "Autoplay",
|
||||
description:
|
||||
"Autoplay only works when muted because of browser restrictions",
|
||||
description: "Autoplay only works when muted because of browser restrictions",
|
||||
},
|
||||
isMuted: {
|
||||
label: "Muted",
|
||||
@@ -896,13 +917,11 @@ export default {
|
||||
option: {
|
||||
repeat: {
|
||||
label: "Repeat",
|
||||
description:
|
||||
"The image is repeated as much as needed to cover the whole background image painting area.",
|
||||
description: "The image is repeated as much as needed to cover the whole background image painting area.",
|
||||
},
|
||||
"no-repeat": {
|
||||
label: "No repeat",
|
||||
description:
|
||||
"The image is not repeated and may not fill the entire space.",
|
||||
description: "The image is not repeated and may not fill the entire space.",
|
||||
},
|
||||
"repeat-x": {
|
||||
label: "Repeat X",
|
||||
@@ -939,7 +958,13 @@ export default {
|
||||
label: "Opacity",
|
||||
},
|
||||
customCss: {
|
||||
label: "Custom CSS",
|
||||
label: "Custom css for this board",
|
||||
description: "Further, customize your dashboard using CSS, only recommended for experienced users",
|
||||
customClassesAlert: {
|
||||
title: "Custom classes",
|
||||
description:
|
||||
"You can add custom classes to your board items in the advanced options of each item and use them in the custom CSS above.",
|
||||
},
|
||||
},
|
||||
columnCount: {
|
||||
label: "Column count",
|
||||
@@ -948,13 +973,15 @@ export default {
|
||||
label: "Name",
|
||||
},
|
||||
},
|
||||
content: {
|
||||
metaTitle: "{boardName} board",
|
||||
},
|
||||
setting: {
|
||||
title: "Settings for {boardName} board",
|
||||
section: {
|
||||
general: {
|
||||
title: "General",
|
||||
unrecognizedLink:
|
||||
"The provided link is not recognized and won't preview, it might still work.",
|
||||
unrecognizedLink: "The provided link is not recognized and won't preview, it might still work.",
|
||||
},
|
||||
layout: {
|
||||
title: "Layout",
|
||||
@@ -1011,8 +1038,7 @@ export default {
|
||||
action: {
|
||||
rename: {
|
||||
label: "Rename board",
|
||||
description:
|
||||
"Changing the name will break any links to this board.",
|
||||
description: "Changing the name will break any links to this board.",
|
||||
button: "Change name",
|
||||
modal: {
|
||||
title: "Rename board",
|
||||
@@ -1043,8 +1069,7 @@ export default {
|
||||
},
|
||||
delete: {
|
||||
label: "Delete this board",
|
||||
description:
|
||||
"Once you delete a board, there is no going back. Please be certain.",
|
||||
description: "Once you delete a board, there is no going back. Please be certain.",
|
||||
button: "Delete this board",
|
||||
confirm: {
|
||||
title: "Delete board",
|
||||
@@ -1089,6 +1114,7 @@ export default {
|
||||
logs: "Logs",
|
||||
},
|
||||
},
|
||||
settings: "Settings",
|
||||
help: {
|
||||
label: "Help",
|
||||
items: {
|
||||
@@ -1130,6 +1156,13 @@ export default {
|
||||
settings: {
|
||||
label: "Settings",
|
||||
},
|
||||
setHomeBoard: {
|
||||
label: "Set as your home board",
|
||||
badge: {
|
||||
label: "Home",
|
||||
tooltip: "This board will show as your home board",
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
label: "Delete permanently",
|
||||
confirm: {
|
||||
@@ -1254,6 +1287,30 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: "Settings",
|
||||
section: {
|
||||
analytics: {
|
||||
title: "Analytics",
|
||||
general: {
|
||||
title: "Send anonymous analytics",
|
||||
text: "Homarr will send anonymized analytics using the open source software Umami. It never collects any personal information and is therefore fully GDPR & CCPA compliant. We encourage you to enable analytics because it helps our open source team to identify issues and prioritize our backlog.",
|
||||
},
|
||||
widgetData: {
|
||||
title: "Widget data",
|
||||
text: "Send which widgets (and their quantity) you have configured. Does not include URLs, names or any other data.",
|
||||
},
|
||||
integrationData: {
|
||||
title: "Integration data",
|
||||
text: "Send which integrations (and their quantity) you have configured. Does not include URLs, names or any other data.",
|
||||
},
|
||||
usersData: {
|
||||
title: "Users data",
|
||||
text: "Send the amount of users and whether you've activated SSO",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
about: {
|
||||
version: "Version {version}",
|
||||
text: "Homarr is a community driven open source project that is being maintained by volunteers. Thanks to these people, Homarr has been a growing project since 2021. Our team is working completely remote from many different countries on Homarr in their leisure time for no compensation.",
|
||||
|
||||
21
packages/translation/src/locale-attributes.ts
Normal file
21
packages/translation/src/locale-attributes.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { SupportedLanguage } from ".";
|
||||
|
||||
export const localeAttributes: Record<
|
||||
SupportedLanguage,
|
||||
{
|
||||
name: string;
|
||||
translatedName: string;
|
||||
flagIcon: string;
|
||||
}
|
||||
> = {
|
||||
de: {
|
||||
name: "Deutsch",
|
||||
translatedName: "German",
|
||||
flagIcon: "de",
|
||||
},
|
||||
en: {
|
||||
name: "English",
|
||||
translatedName: "English",
|
||||
flagIcon: "us",
|
||||
},
|
||||
};
|
||||
@@ -3,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,
|
||||
});
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/log": "workspace:^0.1.0"
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
|
||||
@@ -5,3 +5,4 @@ export { UserAvatar } from "./user-avatar";
|
||||
export { UserAvatarGroup } from "./user-avatar-group";
|
||||
export { TablePagination } from "./table-pagination";
|
||||
export { SearchInput } from "./search-input";
|
||||
export { TextMultiSelect } from "./text-multi-select";
|
||||
|
||||
@@ -12,10 +12,7 @@ interface SearchInputProps {
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
export const SearchInput = ({
|
||||
placeholder,
|
||||
defaultValue,
|
||||
}: SearchInputProps) => {
|
||||
export const SearchInput = ({ placeholder, defaultValue }: SearchInputProps) => {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const { replace } = useRouter();
|
||||
const pathName = usePathname();
|
||||
|
||||
@@ -11,19 +11,15 @@ interface BaseSelectItem {
|
||||
}
|
||||
|
||||
export interface SelectWithCustomItemsProps<TSelectItem extends BaseSelectItem>
|
||||
extends Pick<
|
||||
SelectProps,
|
||||
"label" | "error" | "defaultValue" | "value" | "onChange" | "placeholder"
|
||||
> {
|
||||
extends Pick<SelectProps, "label" | "error" | "defaultValue" | "value" | "onChange" | "placeholder"> {
|
||||
data: TSelectItem[];
|
||||
onBlur?: (event: React.FocusEvent<HTMLButtonElement>) => void;
|
||||
onFocus?: (event: React.FocusEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
type Props<TSelectItem extends BaseSelectItem> =
|
||||
SelectWithCustomItemsProps<TSelectItem> & {
|
||||
SelectOption: React.ComponentType<TSelectItem>;
|
||||
};
|
||||
type Props<TSelectItem extends BaseSelectItem> = SelectWithCustomItemsProps<TSelectItem> & {
|
||||
SelectOption: React.ComponentType<TSelectItem>;
|
||||
};
|
||||
|
||||
export const SelectWithCustomItems = <TSelectItem extends BaseSelectItem>({
|
||||
data,
|
||||
@@ -45,10 +41,7 @@ export const SelectWithCustomItems = <TSelectItem extends BaseSelectItem>({
|
||||
onChange,
|
||||
});
|
||||
|
||||
const selectedOption = useMemo(
|
||||
() => data.find((item) => item.value === _value),
|
||||
[data, _value],
|
||||
);
|
||||
const selectedOption = useMemo(() => data.find((item) => item.value === _value), [data, _value]);
|
||||
|
||||
const options = data.map((item) => (
|
||||
<Combobox.Option value={item.value} key={item.value}>
|
||||
@@ -69,11 +62,7 @@ export const SelectWithCustomItems = <TSelectItem extends BaseSelectItem>({
|
||||
);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
withinPortal={false}
|
||||
onOptionSubmit={onOptionSubmit}
|
||||
>
|
||||
<Combobox store={combobox} withinPortal={false} onOptionSubmit={onOptionSubmit}>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
{...props}
|
||||
@@ -85,11 +74,7 @@ export const SelectWithCustomItems = <TSelectItem extends BaseSelectItem>({
|
||||
rightSectionPointerEvents="none"
|
||||
multiline
|
||||
>
|
||||
{selectedOption ? (
|
||||
<SelectOption {...selectedOption} />
|
||||
) : (
|
||||
<Input.Placeholder>{placeholder}</Input.Placeholder>
|
||||
)}
|
||||
{selectedOption ? <SelectOption {...selectedOption} /> : <Input.Placeholder>{placeholder}</Input.Placeholder>}
|
||||
</InputBase>
|
||||
</Combobox.Target>
|
||||
|
||||
|
||||
@@ -15,19 +15,10 @@ export interface SelectItemWithDescriptionBadge {
|
||||
type Props = SelectWithCustomItemsProps<SelectItemWithDescriptionBadge>;
|
||||
|
||||
export const SelectWithDescriptionBadge = (props: Props) => {
|
||||
return (
|
||||
<SelectWithCustomItems<SelectItemWithDescriptionBadge>
|
||||
{...props}
|
||||
SelectOption={SelectOption}
|
||||
/>
|
||||
);
|
||||
return <SelectWithCustomItems<SelectItemWithDescriptionBadge> {...props} SelectOption={SelectOption} />;
|
||||
};
|
||||
|
||||
const SelectOption = ({
|
||||
label,
|
||||
description,
|
||||
badge,
|
||||
}: SelectItemWithDescriptionBadge) => {
|
||||
const SelectOption = ({ label, description, badge }: SelectItemWithDescriptionBadge) => {
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
|
||||
@@ -13,12 +13,7 @@ export interface SelectItemWithDescription {
|
||||
type Props = SelectWithCustomItemsProps<SelectItemWithDescription>;
|
||||
|
||||
export const SelectWithDescription = (props: Props) => {
|
||||
return (
|
||||
<SelectWithCustomItems<SelectItemWithDescription>
|
||||
{...props}
|
||||
SelectOption={SelectOption}
|
||||
/>
|
||||
);
|
||||
return <SelectWithCustomItems<SelectItemWithDescription> {...props} SelectOption={SelectOption} />;
|
||||
};
|
||||
|
||||
const SelectOption = ({ label, description }: SelectItemWithDescription) => {
|
||||
|
||||
@@ -47,23 +47,12 @@ export const TablePagination = ({ total }: TablePaginationProps) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Pagination
|
||||
total={total}
|
||||
getItemProps={getItemProps}
|
||||
getControlProps={getControlProps}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Pagination total={total} getItemProps={getItemProps} getControlProps={getControlProps} onChange={handleChange} />
|
||||
);
|
||||
};
|
||||
|
||||
type ControlType = Parameters<
|
||||
Exclude<PaginationProps["getControlProps"], undefined>
|
||||
>[0];
|
||||
const calculatePageFor = (
|
||||
type: ControlType,
|
||||
current: number,
|
||||
total: number,
|
||||
) => {
|
||||
type ControlType = Parameters<Exclude<PaginationProps["getControlProps"], undefined>>[0];
|
||||
const calculatePageFor = (type: ControlType, current: number, total: number) => {
|
||||
switch (type) {
|
||||
case "first":
|
||||
return 1;
|
||||
|
||||
98
packages/ui/src/components/text-multi-select.tsx
Normal file
98
packages/ui/src/components/text-multi-select.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import type { FocusEventHandler } from "react";
|
||||
import { useState } from "react";
|
||||
import { Combobox, Group, Pill, PillsInput, Text, useCombobox } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface TextMultiSelectProps {
|
||||
label: string;
|
||||
value?: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
onFocus?: FocusEventHandler;
|
||||
onBlur?: FocusEventHandler;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const TextMultiSelect = ({ label, value = [], onChange, onBlur, onFocus, error }: TextMultiSelectProps) => {
|
||||
const t = useI18n();
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
|
||||
});
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const exactOptionMatch = value.some((item) => item === search);
|
||||
|
||||
const handleValueSelect = (selectedValue: string) => {
|
||||
setSearch("");
|
||||
|
||||
if (selectedValue === "$create") {
|
||||
onChange([...value, search]);
|
||||
} else {
|
||||
onChange(value.filter((filterValue) => filterValue !== selectedValue));
|
||||
}
|
||||
};
|
||||
|
||||
const handleValueRemove = (removedValue: string) =>
|
||||
onChange(value.filter((filterValue) => filterValue !== removedValue));
|
||||
|
||||
const values = value.map((item) => (
|
||||
<Pill key={item} withRemoveButton onRemove={() => handleValueRemove(item)}>
|
||||
{item}
|
||||
</Pill>
|
||||
));
|
||||
|
||||
return (
|
||||
<Combobox store={combobox} onOptionSubmit={handleValueSelect} withinPortal={false}>
|
||||
<Combobox.DropdownTarget>
|
||||
<PillsInput label={label} error={error} onClick={() => combobox.openDropdown()}>
|
||||
<Pill.Group>
|
||||
{values}
|
||||
|
||||
<Combobox.EventsTarget>
|
||||
<PillsInput.Field
|
||||
onFocus={(event) => {
|
||||
onFocus?.(event);
|
||||
combobox.openDropdown();
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
value={search}
|
||||
placeholder={t("common.multiText.placeholder")}
|
||||
onChange={(event) => {
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setSearch(event.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Backspace" && search.length === 0) {
|
||||
event.preventDefault();
|
||||
handleValueRemove(value.at(-1)!);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Combobox.EventsTarget>
|
||||
</Pill.Group>
|
||||
</PillsInput>
|
||||
</Combobox.DropdownTarget>
|
||||
|
||||
{!exactOptionMatch && search.trim().length > 0 && (
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
<Combobox.Option value="$create">
|
||||
<Group>
|
||||
<IconPlus size={12} />
|
||||
<Text size="sm">{t("common.multiText.addLabel", { value: search })}</Text>
|
||||
</Group>
|
||||
</Combobox.Option>
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
@@ -10,11 +10,7 @@ interface UserAvatarGroupProps {
|
||||
users: UserProps[];
|
||||
}
|
||||
|
||||
export const UserAvatarGroup = ({
|
||||
size,
|
||||
limit,
|
||||
users,
|
||||
}: UserAvatarGroupProps) => {
|
||||
export const UserAvatarGroup = ({ size, limit, users }: UserAvatarGroupProps) => {
|
||||
return (
|
||||
<TooltipGroup openDelay={300} closeDelay={300}>
|
||||
<AvatarGroup>
|
||||
|
||||
@@ -22,7 +22,5 @@ export const UserAvatar = ({ user, size }: UserAvatarProps) => {
|
||||
return <Avatar {...commonProps} src={user.image} alt={user.name} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar {...commonProps}>{user.name.substring(0, 2).toUpperCase()}</Avatar>
|
||||
);
|
||||
return <Avatar {...commonProps}>{user.name.substring(0, 2).toUpperCase()}</Avatar>;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user