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:
@@ -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
|
||||
|
||||
43
packages/api/src/router/serverSettings.ts
Normal file
43
packages/api/src/router/serverSettings.ts
Normal 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;
|
||||
}),
|
||||
});
|
||||
134
packages/api/src/router/test/serverSettings.spec.ts
Normal file
134
packages/api/src/router/test/serverSettings.spec.ts
Normal 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,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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`)
|
||||
);
|
||||
1200
packages/db/migrations/mysql/meta/0002_snapshot.json
Normal file
1200
packages/db/migrations/mysql/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
6
packages/db/migrations/sqlite/0002_adorable_raider.sql
Normal file
6
packages/db/migrations/sqlite/0002_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`);
|
||||
1144
packages/db/migrations/sqlite/meta/0002_snapshot.json
Normal file
1144
packages/db/migrations/sqlite/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
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"]
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user