feat: add configurable headers and instance branding

This commit is contained in:
ajnart committed 2026-08-23 22:28:13 +02:00
1 parent ec295fe26e
commit 86550aadc9
93 files changed
+28223 -567

No files matched your search

+19 -9
View File
@@ -19,6 +19,7 @@ import {
items,
users,
} from "@homarr/db/schema";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import {
assistantProviderCanUseOpenRouterServerTools,
assistantProviderIds,
@@ -37,6 +38,13 @@ import { createTRPCRouter, isDemoMode, permissionRequiredProcedure, protectedPro
import { boardRouter, getHomeIdBoardAsync } from "./board";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
const assistantEnabledProcedure = protectedProcedure.use(async ({ ctx, next }) => {
const featureControls = await getServerSettingByKeyAsync(ctx.db, "featureControls");
if (!featureControls.assistantEnabled) {
throw new TRPCError({ code: "NOT_FOUND", message: "Homarr Assistant is disabled." });
}
return next({ ctx });
});
const configurationId = "default";
const providerSchema = z.enum(assistantProviderIds);
@@ -453,6 +461,8 @@ const addFeedbackToMessageContent = (serializedContent: string, type: "positive"
export const assistantRouter = createTRPCRouter({
getAvailability: protectedProcedure.query(async ({ ctx }) => {
const featureControls = await getServerSettingByKeyAsync(ctx.db, "featureControls");
if (!featureControls.assistantEnabled) return { enabled: false };
if (isDemoMode) {
return { enabled: true };
}
@@ -465,11 +475,11 @@ export const assistantRouter = createTRPCRouter({
};
}),
getContextEntities: protectedProcedure.query(async ({ ctx }) => {
getContextEntities: assistantEnabledProcedure.query(async ({ ctx }) => {
return await getAssistantContextEntitiesAsync(ctx);
}),
getModelCapabilities: protectedProcedure
getModelCapabilities: assistantEnabledProcedure
.input(z.object({ modelId: z.string().trim().min(1).max(256).optional() }).optional())
.query(async ({ ctx, input }) => {
const configuration = await getConfigurationAsync(ctx.db);
@@ -486,7 +496,7 @@ export const assistantRouter = createTRPCRouter({
};
}),
getRuntimeOptions: protectedProcedure.query(async ({ ctx }) => {
getRuntimeOptions: assistantEnabledProcedure.query(async ({ ctx }) => {
const configuration = await getConfigurationAsync(ctx.db);
if (isDemoMode) {
return {
@@ -684,7 +694,7 @@ export const assistantRouter = createTRPCRouter({
});
}),
createThread: protectedProcedure
createThread: assistantEnabledProcedure
.input(z.object({ localId: z.string().max(128).optional() }).optional())
.mutation(async ({ ctx }) => {
const configuration = await getConfigurationAsync(ctx.db);
@@ -700,7 +710,7 @@ export const assistantRouter = createTRPCRouter({
return { id };
}),
updateThreadModel: protectedProcedure
updateThreadModel: assistantEnabledProcedure
.input(z.object({ threadId: z.string().max(64), modelId: z.string().trim().min(1).max(256) }))
.mutation(async ({ ctx, input }) => {
const [thread, configuration] = await Promise.all([
@@ -745,7 +755,7 @@ export const assistantRouter = createTRPCRouter({
};
}),
getGenerationTelemetry: protectedProcedure
getGenerationTelemetry: assistantEnabledProcedure
.input(
z.object({
threadId: z.string().max(64),
@@ -791,7 +801,7 @@ export const assistantRouter = createTRPCRouter({
};
}),
renameThread: protectedProcedure
renameThread: assistantEnabledProcedure
.input(z.object({ threadId: z.string().max(64), title: z.string().trim().min(1).max(120) }))
.mutation(async ({ ctx, input }) => {
await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
@@ -808,7 +818,7 @@ export const assistantRouter = createTRPCRouter({
await ctx.db.delete(assistantThreads).where(eq(assistantThreads.id, input.threadId));
}),
appendMessage: protectedProcedure
appendMessage: assistantEnabledProcedure
.input(
z.object({
threadId: z.string().max(64),
@@ -869,7 +879,7 @@ export const assistantRouter = createTRPCRouter({
.where(eq(assistantThreads.id, thread.id));
}),
submitFeedback: protectedProcedure
submitFeedback: assistantEnabledProcedure
.input(
z.object({
threadId: z.string().max(64),
+29 -3
View File
@@ -86,7 +86,9 @@ interface BoardItemPlacementRectangle {
const boardItemPlacementTails = new Map<string, Promise<void>>();
const maxManageOverviewPreviewRows = 12;
const manageOverviewInputSchema = z.object({ fullPreview: z.boolean().default(false) }).optional();
const manageOverviewInputSchema = z
.object({ fullPreview: z.boolean().default(false), userId: z.string().optional() })
.optional();
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
@@ -227,9 +229,14 @@ export const boardRouter = createTRPCRouter({
}));
}),
getManageOverview: publicProcedure.input(manageOverviewInputSchema).query(async ({ ctx, input }) => {
const userId = ctx.session?.user.id;
const sessionUserId = ctx.session?.user.id;
const userId = input?.userId ?? sessionUserId;
if (userId !== sessionUserId && !ctx.session?.user.permissions.includes("admin")) {
throw new TRPCError({ code: "FORBIDDEN", message: "You are not allowed to view boards for this user" });
}
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
const canViewAllBoards = getCanViewAllBoards(groupMemberships);
const dbBoards = await ctx.db.query.boards.findMany({
columns: {
@@ -279,7 +286,7 @@ export const boardRouter = createTRPCRouter({
orderBy: (section, { asc }) => [asc(section.xOffset), asc(section.id)],
},
},
where: getAccessibleBoardsWhere(ctx.session?.user.permissions.includes("board-view-all"), userId, boardIds),
where: getAccessibleBoardsWhere(canViewAllBoards, userId, boardIds),
});
const previewLayoutIds = dbBoards.flatMap((board) => board.layouts.map((layout) => layout.id));
@@ -2566,6 +2573,7 @@ const getBoardAccessContextAsync = async (db: Database, userId: string | undefin
group: {
with: {
boardPermissions: {},
permissions: {},
},
},
},
@@ -2589,6 +2597,24 @@ const getBoardAccessContextAsync = async (db: Database, userId: string | undefin
return { boardIds, currentUser, groupMemberships };
};
const getCanViewAllBoards = (
groupMemberships: Awaited<ReturnType<typeof getBoardAccessContextAsync>>["groupMemberships"],
) => {
const permissions = new Set(
groupMemberships.flatMap((membership) => membership.group.permissions.map(({ permission }) => permission)),
);
return getPermissionsWithChildren([...permissions]).includes("board-view-all");
};
export const getAccessibleBoardIdsForUserAsync = async (db: Database, userId: string) => {
const { boardIds, groupMemberships } = await getBoardAccessContextAsync(db, userId);
const accessibleBoards = await db.query.boards.findMany({
columns: { id: true },
where: getAccessibleBoardsWhere(getCanViewAllBoards(groupMemberships), userId, boardIds),
});
return new Set(accessibleBoards.map(({ id }) => id));
};
const getBoardGroupPermissionWhere = (
groupMemberships: Awaited<ReturnType<typeof getBoardAccessContextAsync>>["groupMemberships"],
) =>
+47 -2
View File
@@ -10,7 +10,14 @@ import {
} from "@homarr/db/queries";
import { boards, serverSettings } from "@homarr/db/schema";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettingsKeys } from "@homarr/server-settings";
import {
authBrandingSchema,
brandingServerSettingsSchema,
defaultServerSettingsKeys,
featureControlsServerSettingsSchema,
parseBrandingSettings,
parseFeatureControls,
} from "@homarr/server-settings";
import { createTRPCRouter, permissionRequiredProcedure, publicProcedure } from "../trpc";
@@ -22,6 +29,15 @@ const boardServerSettingsSchema = z.object({
}) satisfies z.ZodType<ServerSettings["board"]>;
const boardServerSettingsUpdateSchema = boardServerSettingsSchema.partial();
const brandingServerSettingsUpdateSchema = brandingServerSettingsSchema.partial().extend({
authBranding: authBrandingSchema.partial().optional(),
});
const legacyAuthBrandingUpdateSchema = z.object({
showCustomAppNameOnLogin: z.boolean().optional(),
showCustomLogoOnLogin: z.boolean().optional(),
showCustomGreetingOnLogin: z.boolean().optional(),
});
const featureControlsServerSettingsUpdateSchema = featureControlsServerSettingsSchema.partial();
export const serverSettingsRouter = createTRPCRouter({
getCulture: publicProcedure.query(async ({ ctx }) => {
@@ -30,6 +46,10 @@ export const serverSettingsRouter = createTRPCRouter({
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
return await getServerSettingsAsync(ctx.db);
}),
getBranding: publicProcedure.query(async ({ ctx }) => {
const branding = await getServerSettingByKeyAsync(ctx.db, "branding");
return parseBrandingSettings(branding);
}),
getBoardSettings: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
@@ -100,10 +120,35 @@ export const serverSettingsRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
if (input.settingsKey === "branding") {
const current = await getServerSettingByKeyAsync(ctx.db, "branding");
const parsedInput = brandingServerSettingsUpdateSchema.parse(input.value);
const legacyInput = legacyAuthBrandingUpdateSchema.parse(input.value);
const authBranding = { ...current.authBranding };
authBranding.showAppName = legacyInput.showCustomAppNameOnLogin ?? authBranding.showAppName;
authBranding.showLogo = legacyInput.showCustomLogoOnLogin ?? authBranding.showLogo;
authBranding.showGreeting = legacyInput.showCustomGreetingOnLogin ?? authBranding.showGreeting;
Object.assign(authBranding, parsedInput.authBranding);
const value = brandingServerSettingsSchema.parse({
...parseBrandingSettings(current),
...parsedInput,
authBranding,
});
await updateServerSettingByKeyAsync(ctx.db, "branding", value);
return;
}
if (input.settingsKey === "featureControls") {
const current = await getServerSettingByKeyAsync(ctx.db, "featureControls");
const parsedInput = featureControlsServerSettingsUpdateSchema.parse(input.value);
const value = parseFeatureControls({ ...current, ...parsedInput });
await updateServerSettingByKeyAsync(ctx.db, "featureControls", value);
return;
}
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
...current,
...input.value,
} as ServerSettings[keyof ServerSettings]);
} as ServerSettings[typeof input.settingsKey]);
}),
});
+63 -1
View File
@@ -15,8 +15,11 @@ import { selectUserSchema } from "@homarr/db/validationSchemas";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { credentialsAdminGroup, supportedAuthProviders } from "@homarr/definitions";
import { byIdSchema } from "@homarr/validation/common";
import type { userBaseCreateSchema } from "@homarr/validation/user";
import type { HeaderPreferences, userBaseCreateSchema } from "@homarr/validation/user";
import {
getHeaderItems,
headerPreferencesMutationSchema,
parseHeaderPreferences,
userChangeColorSchemeSchema,
userChangeHomeBoardsSchema,
userChangePasswordApiSchema,
@@ -30,6 +33,7 @@ import {
userPingIconsEnabledSchema,
userRegistrationApiSchema,
} from "@homarr/validation/user";
import { serializeHeaderPreferences } from "@homarr/validation/header-preferences";
import { convertIntersectionToZodObject } from "../schema-merger";
import {
@@ -40,6 +44,7 @@ import {
protectedProcedure,
publicProcedure,
} from "../trpc";
import { getAccessibleBoardIdsForUserAsync } from "./board";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import { throwIfCredentialsDisabled } from "./invite/checks";
import { changeSearchPreferencesAsync, changeSearchPreferencesInputSchema } from "./user/change-search-preferences";
@@ -368,6 +373,7 @@ export const userRouter = createTRPCRouter({
firstDayOfWeek: true,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
headerPreferences: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
@@ -405,6 +411,7 @@ export const userRouter = createTRPCRouter({
firstDayOfWeek: true,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
headerPreferences: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
@@ -719,6 +726,58 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, input.id));
}),
changeHeaderPreferences: protectedProcedure
.input(z.object({ id: z.string(), headerPreferences: headerPreferencesMutationSchema }))
.output(z.void())
.meta({
openapi: {
method: "PATCH",
path: "/api/users/header-preferences",
tags: ["users"],
protect: true,
},
})
.mutation(async ({ input, ctx }) => {
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const targetUser = await ctx.db.query.users.findFirst({
columns: { headerPreferences: true },
where: eq(users.id, input.id),
});
if (!targetUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const submittedBoardIds = getBoardShortcutIds(input.headerPreferences);
if (submittedBoardIds.length > 0) {
const existingBoardIds = new Set(getBoardShortcutIds(parseHeaderPreferences(targetUser.headerPreferences)));
const accessibleBoardIds = await getAccessibleBoardIdsForUserAsync(ctx.db, input.id);
const hasUnavailableShortcut = submittedBoardIds.some(
(boardId) => !accessibleBoardIds.has(boardId) && !existingBoardIds.has(boardId),
);
if (hasUnavailableShortcut) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "One or more board shortcuts are unavailable to this user",
});
}
}
await ctx.db
.update(users)
.set({
headerPreferences: serializeHeaderPreferences(input.headerPreferences),
})
.where(eq(users.id, input.id));
}),
changeDdgBangs: protectedProcedure
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
.output(z.void())
@@ -829,6 +888,9 @@ export const userRouter = createTRPCRouter({
}),
});
const getBoardShortcutIds = (preferences: HeaderPreferences) =>
getHeaderItems(preferences.zones).flatMap((item) => (item.type === "board" ? [item.boardId] : []));
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => {
const hashedPassword = await hashPasswordAsync(input.password);
+1
View File
@@ -13,6 +13,7 @@ export const getRscUserSettingsAsync = cache(async (userId: string) =>
firstDayOfWeek: true,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
headerPreferences: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,