feat: add server settings (#487)

* feat: add server settings

* feat: remove old migration

* feat: add new migrations

* refactor: format

* fix: build error

* refactor: format

* fix: lint
This commit is contained in:
Manuel
2024-05-19 22:29:15 +02:00
committed by GitHub
parent d9f5158662
commit 919161798e
25 changed files with 2888 additions and 1 deletions

View File

@@ -27,6 +27,7 @@
"@homarr/redis": "workspace:^0.1.0",
"@homarr/tasks": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@trpc/client": "next",
"@trpc/server": "next",
"superjson": "2.2.1"

View File

@@ -7,6 +7,7 @@ import { integrationRouter } from "./router/integration";
import { inviteRouter } from "./router/invite";
import { locationRouter } from "./router/location";
import { logRouter } from "./router/log";
import { serverSettingsRouter } from "./router/serverSettings";
import { userRouter } from "./router/user";
import { widgetRouter } from "./router/widgets";
import { createTRPCRouter } from "./trpc";
@@ -23,6 +24,7 @@ export const appRouter = createTRPCRouter({
log: logRouter,
icon: iconsRouter,
home: homeRouter,
serverSettings: serverSettingsRouter,
});
// export type definition of API

View File

@@ -0,0 +1,43 @@
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;
}),
});

View File

@@ -0,0 +1,134 @@
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,
}),
},
]);
});
});

View File

@@ -0,0 +1,6 @@
CREATE TABLE `serverSetting` (
`key` varchar(64) NOT NULL,
`value` text NOT NULL DEFAULT ('{"json": {}}'),
CONSTRAINT `serverSetting_key` PRIMARY KEY(`key`),
CONSTRAINT `serverSetting_key_unique` UNIQUE(`key`)
);

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,13 @@
"when": 1715885855801,
"tag": "0001_wild_alex_wilder",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1716148439439,
"tag": "0002_freezing_black_panther",
"breakpoints": true
}
]
}

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

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,13 @@
"when": 1715871797713,
"tag": "0001_mixed_titanium_man",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1716148434186,
"tag": "0002_adorable_raider",
"breakpoints": true
}
]
}

View File

@@ -311,6 +311,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],

View File

@@ -304,6 +304,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],

View File

@@ -0,0 +1 @@
export * from "./src";

View File

@@ -0,0 +1,36 @@
{
"name": "@homarr/server-settings",
"private": true,
"version": "0.1.0",
"exports": {
".": "./index.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"type": "module",
"license": "MIT",
"scripts": {
"clean": "rm -rf .turbo node_modules",
"lint": "eslint .",
"format": "prettier --check . --ignore-path ../../.gitignore",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^8.57.0",
"typescript": "^5.4.5"
},
"eslintConfig": {
"extends": [
"@homarr/eslint-config/base"
]
},
"prettier": "@homarr/prettier-config"
}

View File

@@ -0,0 +1,16 @@
export const defaultServerSettingsKeys = ["analytics"] as const;
export type ServerSettingsRecord = {
[key in (typeof defaultServerSettingsKeys)[number]]: Record<string, unknown>;
};
export const defaultServerSettings = {
analytics: {
enableGeneral: true,
enableWidgetData: false,
enableIntegrationData: false,
enableUserData: false,
},
} satisfies ServerSettingsRecord;
export type ServerSettings = typeof defaultServerSettings;

View File

@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View File

@@ -1114,6 +1114,7 @@ export default {
logs: "Logs",
},
},
settings: "Settings",
help: {
label: "Help",
items: {
@@ -1283,6 +1284,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.",