feat: add server settings for default board, default color scheme and default locale (#1373)

* feat: add server settings for default board, default color scheme and default locale

* chore: address pull request feedback

* test: adjust unit tests to match requirements

* fix: deepsource issue

* chore: add deepsource as dependency to translation library

* refactor: restructure language-combobox, adjust default locale for next-intl

* chore: change cookie keys prefix from homarr- to homarr.
This commit is contained in:
Meier Lukas
2024-11-02 21:15:46 +01:00
committed by GitHub
parent 49c0ebea6d
commit 326b769c23
42 changed files with 599 additions and 214 deletions

View File

@@ -4,6 +4,7 @@ import superjson from "superjson";
import { constructBoardPermissions } from "@homarr/auth/shared";
import type { Database, SQL } from "@homarr/db";
import { and, createId, eq, inArray, like, or } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import {
boardGroupPermissions,
boards,
@@ -41,6 +42,16 @@ export const boardRouter = createTRPCRouter({
throw error;
}
}),
getPublicBoards: publicProcedure.query(async ({ ctx }) => {
return await ctx.db.query.boards.findMany({
columns: {
id: true,
name: true,
logoImageUrl: true,
},
where: eq(boards.isPublic, true),
});
}),
getAllBoards: publicProcedure.query(async ({ ctx }) => {
const userId = ctx.session?.user.id;
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
@@ -216,6 +227,14 @@ export const boardRouter = createTRPCRouter({
.input(validation.board.changeVisibility)
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
if (input.visibility !== "public" && boardSettings.defaultBoardId === input.id) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot make default board private",
});
}
await ctx.db
.update(boards)
@@ -240,7 +259,22 @@ export const boardRouter = createTRPCRouter({
})
: null;
const boardWhere = user?.homeBoardId ? eq(boards.id, user.homeBoardId) : eq(boards.name, "home");
// 1. user home board, 2. default board, 3. not found
let boardWhere: SQL<unknown> | null = null;
if (user?.homeBoardId) {
boardWhere = eq(boards.id, user.homeBoardId);
} else {
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
boardWhere = boardSettings.defaultBoardId ? eq(boards.id, boardSettings.defaultBoardId) : null;
}
if (!boardWhere) {
throw new TRPCError({
code: "NOT_FOUND",
message: "No home board found",
});
}
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);

View File

@@ -1,47 +1,16 @@
import SuperJSON from "superjson";
import { eq } from "@homarr/db";
import { serverSettings } from "@homarr/db/schema/sqlite";
import { logger } from "@homarr/log";
import type { defaultServerSettings, ServerSettings } from "@homarr/server-settings";
import { getServerSettingByKeyAsync, getServerSettingsAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettingsKeys } from "@homarr/server-settings";
import { z } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
export const serverSettingsRouter = createTRPCRouter({
// this must be public so anonymous users also get analytics
getAnalytics: publicProcedure.query(async ({ ctx }) => {
const setting = await ctx.db.query.serverSettings.findFirst({
where: eq(serverSettings.settingKey, "analytics"),
});
if (!setting) {
logger.info(
"Server settings for analytics is currently undefined. Using default values instead. If this persists, there may be an issue with the server settings",
);
return {
enableGeneral: true,
enableIntegrationData: false,
enableUserData: false,
enableWidgetData: false,
} as (typeof defaultServerSettings)["analytics"];
}
return SuperJSON.parse<(typeof defaultServerSettings)["analytics"]>(setting.value);
getCulture: publicProcedure.query(async ({ ctx }) => {
return await getServerSettingByKeyAsync(ctx.db, "culture");
}),
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;
return await getServerSettingsAsync(ctx.db);
}),
saveSettings: protectedProcedure
.input(
@@ -51,12 +20,10 @@ export const serverSettingsRouter = createTRPCRouter({
}),
)
.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;
await updateServerSettingByKeyAsync(
ctx.db,
input.settingsKey,
input.value as ServerSettings[keyof ServerSettings],
);
}),
});

View File

@@ -15,6 +15,7 @@ import {
integrations,
items,
sections,
serverSettings,
users,
} from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
@@ -473,13 +474,19 @@ describe("deleteBoard should delete board", () => {
});
describe("getHomeBoard should return home board", () => {
it("should return home board", async () => {
test("should return user home board when user has one", async () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const fullBoardProps = await createFullBoardAsync(db, "home");
await db
.update(users)
.set({
homeBoardId: fullBoardProps.boardId,
})
.where(eq(users.id, defaultCreatorId));
// Act
const result = await caller.getHomeBoard();
@@ -491,6 +498,40 @@ describe("getHomeBoard should return home board", () => {
});
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
});
test("should return global home board when user doesn't have one", async () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
const fullBoardProps = await createFullBoardAsync(db, "home");
await db.insert(serverSettings).values({
settingKey: "board",
value: SuperJSON.stringify({ defaultBoardId: fullBoardProps.boardId }),
});
// Act
const result = await caller.getHomeBoard();
// Assert
expectInputToBeFullBoardWithName(result, {
name: "home",
...fullBoardProps,
});
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
});
test("should throw error when home board not configured in serverSettings", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, session: defaultSession });
await createFullBoardAsync(db, "home");
// Act
const actAsync = async () => await caller.getHomeBoard();
// Assert
await expect(actAsync()).rejects.toThrowError("No home board found");
});
});
describe("getBoardByName should return board by name", () => {

View File

@@ -40,56 +40,20 @@ describe("getAll server settings", () => {
await expect(actAsync()).rejects.toThrow();
});
test("getAll should return server", async () => {
test("getAll should return default server settings when nothing in database", 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,
},
});
expect(result).toStrictEqual(defaultServerSettings);
});
});
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({
@@ -104,7 +68,7 @@ describe("saveSettings", () => {
},
]);
const result = await caller.saveSettings({
await caller.saveSettings({
settingsKey: "analytics",
value: {
enableGeneral: true,
@@ -114,8 +78,6 @@ describe("saveSettings", () => {
},
});
expect(result).toBe(true);
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
{