diff --git a/apps/nextjs/src/app/[locale]/manage/_components/hero-banner.tsx b/apps/nextjs/src/app/[locale]/manage/_components/hero-banner.tsx index f1f4ddf39..b694ee81d 100644 --- a/apps/nextjs/src/app/[locale]/manage/_components/hero-banner.tsx +++ b/apps/nextjs/src/app/[locale]/manage/_components/hero-banner.tsx @@ -2,6 +2,7 @@ import { Box, Grid, GridCol, Group, Image, Stack, Title } from "@mantine/core"; import { splitToNChunks } from "@homarr/common"; import { integrationDefs } from "@homarr/definitions"; +import { getScopedI18n } from "@homarr/translation/server"; import classes from "./hero-banner.module.css"; @@ -12,19 +13,20 @@ const icons = Object.values(integrationDefs) const countIconGroups = 3; const animationDurationInSeconds = icons.length; const arrayInChunks = splitToNChunks(icons, countIconGroups); +const gridSpan = 12 / countIconGroups; -export const HeroBanner = () => { - const gridSpan = 12 / countIconGroups; +export const HeroBanner = async () => { + const t = await getScopedI18n("management.page.home"); return ( - Welcome back to your + {t("heroBanner.title")} - Homarr Board + {t("heroBanner.subtitle", { app: "Homarr" })} diff --git a/apps/nextjs/src/app/[locale]/manage/page.tsx b/apps/nextjs/src/app/[locale]/manage/page.tsx index ed0cec2bc..a77794f92 100644 --- a/apps/nextjs/src/app/[locale]/manage/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/page.tsx @@ -1,25 +1,16 @@ +import type { Metadata } from "next"; import Link from "next/link"; import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core"; import { IconArrowRight } from "@tabler/icons-react"; import { api } from "@homarr/api/server"; -import { auth } from "@homarr/auth/next"; -import { isProviderEnabled } from "@homarr/auth/server"; import { getScopedI18n } from "@homarr/translation/server"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { createMetaTitle } from "~/metadata"; import { HeroBanner } from "./_components/hero-banner"; -interface LinkProps { - title: string; - subtitle: string; - count: number; - href: string; - hidden?: boolean; -} - -export async function generateMetadata() { +export async function generateMetadata(): Promise { const t = await getScopedI18n("management"); return { @@ -29,78 +20,32 @@ export async function generateMetadata() { export default async function ManagementPage() { const statistics = await api.home.getStats(); - const session = await auth(); const t = await getScopedI18n("management.page.home"); - const links: LinkProps[] = [ - { - count: statistics.countBoards, - href: "/manage/boards", - subtitle: t("statisticLabel.boards"), - title: t("statistic.board"), - }, - { - count: statistics.countUsers, - href: "/manage/users", - subtitle: t("statisticLabel.authentication"), - title: t("statistic.user"), - hidden: !session?.user.permissions.includes("admin"), - }, - { - count: statistics.countInvites, - href: "/manage/users/invites", - subtitle: t("statisticLabel.authentication"), - title: t("statistic.invite"), - hidden: !isProviderEnabled("credentials") || !session?.user.permissions.includes("admin"), - }, - { - count: statistics.countIntegrations, - href: "/manage/integrations", - subtitle: t("statisticLabel.resources"), - title: t("statistic.integration"), - }, - { - count: statistics.countApps, - href: "/manage/apps", - subtitle: t("statisticLabel.resources"), - title: t("statistic.app"), - hidden: !session?.user, - }, - { - count: statistics.countGroups, - href: "/manage/users/groups", - subtitle: t("statisticLabel.authorization"), - title: t("statistic.group"), - hidden: !session?.user.permissions.includes("admin"), - }, - ]; return ( <> - {links.map( - (link) => - !link.hidden && ( - - - - - {link.count} - - - - {link.subtitle} - - {link.title} - - - - - - ), - )} + {statistics.map((statistic) => ( + + + + + {statistic.count} + + + + {t(`statisticLabel.${statistic.subtitleKey}`)} + + {t(`statistic.${statistic.titleKey}`)} + + + + + + ))} ); diff --git a/packages/api/package.json b/packages/api/package.json index 997dfa2cb..ce12939ac 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -40,6 +40,7 @@ "@homarr/redis": "workspace:^0.1.0", "@homarr/request-handler": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0", + "@homarr/translation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", "@kubernetes/client-node": "^1.4.0", "@tanstack/react-query": "^5.90.2", diff --git a/packages/api/src/router/home.ts b/packages/api/src/router/home.ts index 16d31ffba..fac941fa8 100644 --- a/packages/api/src/router/home.ts +++ b/packages/api/src/router/home.ts @@ -1,31 +1,147 @@ -import type { AnySQLiteTable } from "drizzle-orm/sqlite-core"; - import { isProviderEnabled } from "@homarr/auth/server"; -import type { Database } from "@homarr/db"; -import { apps, boards, groups, integrations, invites, users } from "@homarr/db/schema"; +import { db, eq, inArray, or } from "@homarr/db"; +import { + apps, + boards, + boardUserPermissions, + groupMembers, + groups, + integrations, + invites, + medias, + searchEngines, + users, +} from "@homarr/db/schema"; +import type { TranslationObject } from "@homarr/translation"; import { createTRPCRouter, publicProcedure } from "../trpc"; +interface HomeStatistic { + titleKey: keyof TranslationObject["management"]["page"]["home"]["statistic"]; + subtitleKey: keyof TranslationObject["management"]["page"]["home"]["statisticLabel"]; + count: number; + path: string; +} + export const homeRouter = createTRPCRouter({ getStats: publicProcedure.query(async ({ ctx }) => { const isAdmin = ctx.session?.user.permissions.includes("admin") ?? false; const isCredentialsEnabled = isProviderEnabled("credentials"); - return { - countBoards: await getCountForTableAsync(ctx.db, boards, true), - countUsers: await getCountForTableAsync(ctx.db, users, isAdmin), - countGroups: await getCountForTableAsync(ctx.db, groups, true), - countInvites: await getCountForTableAsync(ctx.db, invites, isAdmin), - countIntegrations: await getCountForTableAsync(ctx.db, integrations, isCredentialsEnabled && isAdmin), - countApps: await getCountForTableAsync(ctx.db, apps, true), - }; + const statistics: HomeStatistic[] = []; + + const boardIds: string[] = []; + if (ctx.session?.user && !ctx.session.user.permissions.includes("board-view-all")) { + const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({ + where: eq(boardUserPermissions.userId, ctx.session.user.id), + }); + + const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({ + where: eq(groupMembers.userId, ctx.session.user.id), + with: { + group: { + with: { + boardPermissions: {}, + }, + }, + }, + }); + + boardIds.push( + ...permissionsOfCurrentUserWhenPresent + .map((permission) => permission.boardId) + .concat( + permissionsOfCurrentUserGroupsWhenPresent + .map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId)) + .flat(), + ), + ); + } + + statistics.push({ + titleKey: "board", + subtitleKey: "boards", + count: await db.$count( + boards, + ctx.session?.user.permissions.includes("board-view-all") + ? undefined + : or( + eq(boards.isPublic, true), + eq(boards.creatorId, ctx.session?.user.id ?? ""), + boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined, + ), + ), + path: "/manage/boards", + }); + + if (isAdmin) { + statistics.push({ + titleKey: "user", + subtitleKey: "authentication", + count: await db.$count(users), + path: "/manage/users", + }); + } + + if (isAdmin && isCredentialsEnabled) { + statistics.push({ + titleKey: "invite", + subtitleKey: "authentication", + count: await db.$count(invites), + path: "/manage/users/invites", + }); + } + + if (ctx.session?.user.permissions.includes("integration-create")) { + statistics.push({ + titleKey: "integration", + subtitleKey: "resources", + count: await db.$count(integrations), + path: "/manage/integrations", + }); + } + + if (ctx.session?.user) { + statistics.push({ + titleKey: "app", + subtitleKey: "resources", + count: await db.$count(apps), + path: "/manage/apps", + }); + } + + if (isAdmin) { + statistics.push({ + titleKey: "group", + subtitleKey: "authorization", + count: await db.$count(groups), + path: "/manage/users/groups", + }); + } + + if (ctx.session?.user.permissions.includes("search-engine-create")) { + statistics.push({ + titleKey: "searchEngine", + subtitleKey: "resources", + count: await db.$count(searchEngines), + path: "/manage/search-engines", + }); + } + + if (ctx.session?.user.permissions.includes("media-upload")) { + statistics.push({ + titleKey: "media", + subtitleKey: "resources", + count: await db.$count( + medias, + ctx.session.user.permissions.includes("media-view-all") + ? undefined + : eq(medias.creatorId, ctx.session.user.id), + ), + path: "/manage/medias", + }); + } + + return statistics; }), }); - -const getCountForTableAsync = async (db: Database, table: AnySQLiteTable, canView: boolean) => { - if (!canView) { - return 0; - } - - return await db.$count(table); -}; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 5331e306a..7d5151f12 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2986,13 +2986,19 @@ "invite": "Invites", "integration": "Integrations", "app": "Apps", - "group": "Groups" + "group": "Groups", + "searchEngine": "Search engines", + "media": "Medias" }, "statisticLabel": { "boards": "Boards", "resources": "Resources", "authentication": "Authentication", "authorization": "Authorization" + }, + "heroBanner": { + "title": "Welcome back to your", + "subtitle": "{app} Board" } }, "board": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e42deb24f..c135adff4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -623,6 +623,9 @@ importers: '@homarr/server-settings': specifier: workspace:^0.1.0 version: link:../server-settings + '@homarr/translation': + specifier: workspace:^0.1.0 + version: link:../translation '@homarr/validation': specifier: workspace:^0.1.0 version: link:../validation