feat: add configurable headers and instance branding
This commit is contained in:
1 parent
ec295fe26e
commit
86550aadc9
93 files changed
+28223
-567
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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"],
|
||||
) =>
|
||||
|
||||
@@ -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]);
|
||||
}),
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user