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

@@ -1,9 +1,9 @@
import type { UmamiEventData } from "@umami/node";
import { Umami } from "@umami/node";
import SuperJSON from "superjson";
import { count, db, eq } from "@homarr/db";
import { integrations, items, serverSettings, users } from "@homarr/db/schema/sqlite";
import { count, db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { integrations, items, users } from "@homarr/db/schema/sqlite";
import { logger } from "@homarr/log";
import type { defaultServerSettings } from "@homarr/server-settings";
@@ -12,18 +12,7 @@ import { UMAMI_HOST_URL, UMAMI_WEBSITE_ID } from "./constants";
export const sendServerAnalyticsAsync = async () => {
const stopWatch = new Stopwatch();
const setting = await db.query.serverSettings.findFirst({
where: eq(serverSettings.settingKey, "analytics"),
});
if (!setting) {
logger.info(
"Server does not know the configured state of analytics. No data will be sent. Enable analytics in the settings",
);
return;
}
const analyticsSettings = SuperJSON.parse<typeof defaultServerSettings.analytics>(setting.value);
const analyticsSettings = await getServerSettingByKeyAsync(db, "analytics");
if (!analyticsSettings.enableGeneral) {
logger.info("Analytics are disabled. No data will be sent. Enable analytics in the settings");

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

View File

@@ -5,7 +5,7 @@ import type { NextAuthConfig } from "next-auth";
import { and, eq, inArray } from "@homarr/db";
import type { Database } from "@homarr/db";
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
import { everyoneGroup } from "@homarr/definitions";
import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { env } from "./env.mjs";
@@ -52,7 +52,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
}
// We use a cookie as localStorage is not shared with server (otherwise flickering would occur)
cookies().set("homarr-color-scheme", dbUser.colorScheme, {
cookies().set(colorSchemeCookieKey, dbUser.colorScheme, {
path: "/",
expires: dayjs().add(1, "year").toDate(),
});

View File

@@ -7,7 +7,7 @@ import { eq } from "@homarr/db";
import type { Database } from "@homarr/db";
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import { everyoneGroup } from "@homarr/definitions";
import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
import { createSignInEventHandler } from "../events";
@@ -224,7 +224,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
});
expect(dbUser?.name).toBe("test-new");
});
test("signInEventHandler should set homarr-color-scheme cookie", async () => {
test("signInEventHandler should set color-scheme cookie", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
@@ -239,7 +239,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
// Assert
expect(cookies().set).toHaveBeenCalledWith(
"homarr-color-scheme",
colorSchemeCookieKey,
"dark",
expect.objectContaining({
path: "/",

View File

@@ -1,27 +1,16 @@
import SuperJSON from "superjson";
import { sendServerAnalyticsAsync } from "@homarr/analytics";
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
import { db, eq } from "@homarr/db";
import { serverSettings } from "@homarr/db/schema/sqlite";
import type { defaultServerSettings } from "@homarr/server-settings";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { createCronJob } from "../lib";
export const analyticsJob = createCronJob("analytics", EVERY_WEEK, {
runOnStart: true,
}).withCallback(async () => {
const analyticSetting = await db.query.serverSettings.findFirst({
where: eq(serverSettings.settingKey, "analytics"),
});
const analyticSetting = await getServerSettingByKeyAsync(db, "analytics");
if (!analyticSetting) {
return;
}
const value = SuperJSON.parse<(typeof defaultServerSettings)["analytics"]>(analyticSetting.value);
if (!value.enableGeneral) {
if (!analyticSetting.enableGeneral) {
return;
}

View File

@@ -1,5 +1,6 @@
import SuperJSON from "superjson";
import { objectKeys } from "@homarr/common";
import { everyoneGroup } from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
@@ -32,21 +33,33 @@ const seedEveryoneGroupAsync = async (db: Database) => {
const seedServerSettingsAsync = async (db: Database) => {
const serverSettingsData = await db.query.serverSettings.findMany();
let insertedSettingsCount = 0;
for (const settingsKey of defaultServerSettingsKeys) {
if (serverSettingsData.some((setting) => setting.settingKey === settingsKey)) {
return;
const currentDbEntry = serverSettingsData.find((setting) => setting.settingKey === settingsKey);
if (!currentDbEntry) {
await db.insert(serverSettings).values({
settingKey: settingsKey,
value: SuperJSON.stringify(defaultServerSettings[settingsKey]),
});
console.log(`Created serverSetting through seed key=${settingsKey}`);
continue;
}
await db.insert(serverSettings).values({
settingKey: settingsKey,
value: SuperJSON.stringify(defaultServerSettings[settingsKey]),
});
insertedSettingsCount++;
}
const currentSettings = SuperJSON.parse<Record<string, unknown>>(currentDbEntry.value);
const defaultSettings = defaultServerSettings[settingsKey];
const missingKeys = objectKeys(defaultSettings).filter((key) => !(key in currentSettings));
if (insertedSettingsCount > 0) {
console.info(`Inserted ${insertedSettingsCount} missing settings`);
if (missingKeys.length === 0) {
console.info(`Skipping seeding for serverSetting as it already exists key=${settingsKey}`);
continue;
}
await db
.update(serverSettings)
.set({
value: SuperJSON.stringify({ ...defaultSettings, ...currentSettings }), // Add missing keys
})
.where(eq(serverSettings.settingKey, settingsKey));
console.log(`Updated serverSetting through seed key=${settingsKey}`);
}
};

View File

@@ -1 +1,2 @@
export * from "./item";
export * from "./server-setting";

View File

@@ -0,0 +1,52 @@
import SuperJSON from "superjson";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from "..";
import { eq } from "..";
import { serverSettings } from "../schema/sqlite";
export const getServerSettingsAsync = async (db: Database) => {
const settings = await db.query.serverSettings.findMany();
return defaultServerSettingsKeys.reduce((acc, settingKey) => {
const setting = settings.find((setting) => setting.settingKey === settingKey);
if (!setting) {
// Typescript is not happy because the key is a union and it does not know that they are the same
acc[settingKey] = defaultServerSettings[settingKey] as never;
return acc;
}
acc[settingKey] = {
...defaultServerSettings[settingKey],
...SuperJSON.parse(setting.value),
};
return acc;
}, {} as ServerSettings);
};
export const getServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(db: Database, key: TKey) => {
const dbSettings = await db.query.serverSettings.findFirst({
where: eq(serverSettings.settingKey, key),
});
if (!dbSettings) {
return defaultServerSettings[key];
}
return SuperJSON.parse<ServerSettings[TKey]>(dbSettings.value);
};
export const updateServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(
db: Database,
key: TKey,
value: ServerSettings[TKey],
) => {
await db
.update(serverSettings)
.set({
value: SuperJSON.stringify(value),
})
.where(eq(serverSettings.settingKey, key));
};

View File

@@ -0,0 +1,2 @@
export const colorSchemeCookieKey = "homarr.color-scheme";
export const localeCookieKey = "homarr.locale";

View File

@@ -8,3 +8,4 @@ export * from "./auth";
export * from "./user";
export * from "./group";
export * from "./docs";
export * from "./cookie";

View File

@@ -21,6 +21,10 @@
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",

View File

@@ -1,4 +1,13 @@
export const defaultServerSettingsKeys = ["analytics", "crawlingAndIndexing"] as const;
import type { ColorScheme } from "@homarr/definitions";
import type { SupportedLanguage } from "@homarr/translation";
export const defaultServerSettingsKeys = [
"analytics",
"crawlingAndIndexing",
"board",
"appearance",
"culture",
] as const;
export type ServerSettingsRecord = Record<(typeof defaultServerSettingsKeys)[number], Record<string, unknown>>;
@@ -15,6 +24,15 @@ export const defaultServerSettings = {
noTranslate: true,
noSiteLinksSearchBox: false,
},
board: {
defaultBoardId: null as string | null,
},
appearance: {
defaultColorScheme: "light" as ColorScheme,
},
culture: {
defaultLocale: "en" as SupportedLanguage,
},
} satisfies ServerSettingsRecord;
export type ServerSettings = typeof defaultServerSettings;

View File

@@ -27,7 +27,9 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"dayjs": "^1.11.13",
"deepmerge": "4.3.1",
"mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.16",
"next-intl": "3.24.0",

View File

@@ -23,4 +23,4 @@ export const localeConfigurations = {
export const supportedLanguages = objectKeys(localeConfigurations);
export type SupportedLanguage = (typeof supportedLanguages)[number];
export const defaultLocale = "en" satisfies SupportedLanguage;
export const fallbackLocale = "en" satisfies SupportedLanguage;

View File

@@ -1922,6 +1922,14 @@ export default {
},
settings: {
title: "Settings",
notification: {
success: {
message: "Settings saved successfully",
},
error: {
message: "Failed to save settings",
},
},
section: {
analytics: {
title: "Analytics",
@@ -1963,6 +1971,29 @@ export default {
text: "Google will build a search box with the crawled links along with other direct links. Enabling this will ask Google to disable that box.",
},
},
board: {
title: "Boards",
defaultBoard: {
label: "Global default board",
description: "Only public boards are available for selection",
},
},
appearance: {
title: "Appearance",
defaultColorScheme: {
label: "Default color scheme",
options: {
light: "Light",
dark: "Dark",
},
},
},
culture: {
title: "Culture",
defaultLocale: {
label: "Default language",
},
},
},
},
tool: {

View File

@@ -1,8 +1,10 @@
import createMiddleware from "next-intl/middleware";
import { routing } from "./routing";
import type { SupportedLanguage } from ".";
import { createRouting } from "./routing";
export const I18nMiddleware = createMiddleware(routing);
export const createI18nMiddleware = (defaultLocale: SupportedLanguage) =>
createMiddleware(createRouting(defaultLocale));
export const config = {
// Match only internationalized pathnames

View File

@@ -1,17 +1,16 @@
import deepmerge from "deepmerge";
import { getRequestConfig } from "next-intl/server";
import { isLocaleSupported } from ".";
import { fallbackLocale, isLocaleSupported } from ".";
import type { SupportedLanguage } from "./config";
import { createLanguageMapping } from "./mapping";
import { routing } from "./routing";
// This file is referenced in the `next.config.js` file. See https://next-intl-docs.vercel.app/docs/usage/configuration
export default getRequestConfig(async ({ requestLocale }) => {
let currentLocale = await requestLocale;
if (!currentLocale || !isLocaleSupported(currentLocale)) {
currentLocale = routing.defaultLocale;
currentLocale = fallbackLocale;
}
const typedLocale = currentLocale as SupportedLanguage;
@@ -19,8 +18,8 @@ export default getRequestConfig(async ({ requestLocale }) => {
const currentMessages = (await languageMap[typedLocale]()).default;
// Fallback to default locale if the current locales messages if not all messages are present
if (currentLocale !== routing.defaultLocale) {
const fallbackMessages = (await languageMap[routing.defaultLocale]()).default;
if (currentLocale !== fallbackLocale) {
const fallbackMessages = (await languageMap[fallbackLocale]()).default;
return {
locale: currentLocale,
messages: deepmerge(fallbackMessages, currentMessages),

View File

@@ -1,11 +1,18 @@
import { defineRouting } from "next-intl/routing";
import { defaultLocale, supportedLanguages } from "./config";
import { localeCookieKey } from "@homarr/definitions";
export const routing = defineRouting({
locales: supportedLanguages,
defaultLocale,
localePrefix: {
mode: "never", // Rewrite the URL with locale parameter but without shown in url
},
});
import type { SupportedLanguage } from "./config";
import { supportedLanguages } from "./config";
export const createRouting = (defaultLocale: SupportedLanguage) =>
defineRouting({
locales: supportedLanguages,
defaultLocale,
localeCookie: {
name: localeCookieKey,
},
localePrefix: {
mode: "never", // Rewrite the URL with locale parameter but without shown in url
},
});

View File

@@ -3,6 +3,7 @@ export { OverflowBadge } from "./overflow-badge";
export { SearchInput } from "./search-input";
export * from "./select-with-description";
export * from "./select-with-description-and-badge";
export { SelectWithCustomItems } from "./select-with-custom-items";
export { TablePagination } from "./table-pagination";
export { TextMultiSelect } from "./text-multi-select";
export { UserAvatar } from "./user-avatar";