feat: add more group permissions (#1453)
* feat: add more group permissions * feat: restrict access with app permissions * feat: restrict access with search-engine permissions * feat: restrict access with media permissions * refactor: remove permissions for users, groups and invites * test: adjust app router tests with app permissions * fix: integration page accessible without session * fix: search for users, groups and integrations shown to unauthenticated users * chore: address pull request feedback
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
import { Container, Stack, Title } from "@mantine/core";
|
import { Container, Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
@@ -11,6 +13,11 @@ interface AppEditPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function AppEditPage({ params }: AppEditPageProps) {
|
export default async function AppEditPage({ params }: AppEditPageProps) {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user.permissions.includes("app-modify-all")) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
const app = await api.app.byId({ id: params.id });
|
const app = await api.app.byId({ id: params.id });
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
import { Container, Stack, Title } from "@mantine/core";
|
import { Container, Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { AppNewForm } from "./_app-new-form";
|
import { AppNewForm } from "./_app-new-form";
|
||||||
|
|
||||||
export default async function AppNewPage() {
|
export default async function AppNewPage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user.permissions.includes("app-create")) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
import { IconApps, IconPencil } from "@tabler/icons-react";
|
import { IconApps, IconPencil } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
|
import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
@@ -13,6 +15,12 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
|||||||
import { AppDeleteButton } from "./_app-delete-button";
|
import { AppDeleteButton } from "./_app-delete-button";
|
||||||
|
|
||||||
export default async function AppsPage() {
|
export default async function AppsPage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
const apps = await api.app.all();
|
const apps = await api.app.all();
|
||||||
const t = await getScopedI18n("app");
|
const t = await getScopedI18n("app");
|
||||||
|
|
||||||
@@ -22,9 +30,11 @@ export default async function AppsPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Title>{t("page.list.title")}</Title>
|
<Title>{t("page.list.title")}</Title>
|
||||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
{session.user.permissions.includes("app-create") && (
|
||||||
{t("page.create.title")}
|
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||||
</MobileAffixButton>
|
{t("page.create.title")}
|
||||||
|
</MobileAffixButton>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
{apps.length === 0 && <AppNoResults />}
|
{apps.length === 0 && <AppNoResults />}
|
||||||
{apps.length > 0 && (
|
{apps.length > 0 && (
|
||||||
@@ -45,6 +55,7 @@ interface AppCardProps {
|
|||||||
|
|
||||||
const AppCard = async ({ app }: AppCardProps) => {
|
const AppCard = async ({ app }: AppCardProps) => {
|
||||||
const t = await getScopedI18n("app");
|
const t = await getScopedI18n("app");
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -78,16 +89,18 @@ const AppCard = async ({ app }: AppCardProps) => {
|
|||||||
</Group>
|
</Group>
|
||||||
<Group>
|
<Group>
|
||||||
<ActionIconGroup>
|
<ActionIconGroup>
|
||||||
<ActionIcon
|
{session?.user.permissions.includes("app-modify-all") && (
|
||||||
component={Link}
|
<ActionIcon
|
||||||
href={`/manage/apps/edit/${app.id}`}
|
component={Link}
|
||||||
variant="subtle"
|
href={`/manage/apps/edit/${app.id}`}
|
||||||
color="gray"
|
variant="subtle"
|
||||||
aria-label={t("page.edit.title")}
|
color="gray"
|
||||||
>
|
aria-label={t("page.edit.title")}
|
||||||
<IconPencil size={16} stroke={1.5} />
|
>
|
||||||
</ActionIcon>
|
<IconPencil size={16} stroke={1.5} />
|
||||||
<AppDeleteButton app={app} />
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
{session?.user.permissions.includes("app-full-all") && <AppDeleteButton app={app} />}
|
||||||
</ActionIconGroup>
|
</ActionIconGroup>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -97,6 +110,7 @@ const AppCard = async ({ app }: AppCardProps) => {
|
|||||||
|
|
||||||
const AppNoResults = async () => {
|
const AppNoResults = async () => {
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder bg="transparent">
|
<Card withBorder bg="transparent">
|
||||||
@@ -105,7 +119,9 @@ const AppNoResults = async () => {
|
|||||||
<Text fw={500} size="lg">
|
<Text fw={500} size="lg">
|
||||||
{t("app.page.list.noResults.title")}
|
{t("app.page.list.noResults.title")}
|
||||||
</Text>
|
</Text>
|
||||||
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.action")}</Anchor>
|
{session?.user.permissions.includes("app-create") && (
|
||||||
|
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.action")}</Anchor>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
|||||||
import { IntegrationAvatar } from "@homarr/ui";
|
import { IntegrationAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||||
import { IntegrationAccessSettings } from "../../_components/integration-access-settings";
|
import { IntegrationAccessSettings } from "../../_components/integration-access-settings";
|
||||||
import { EditIntegrationForm } from "./_integration-edit-form";
|
import { EditIntegrationForm } from "./_integration-edit-form";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
AccordionControl,
|
AccordionControl,
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
@@ -50,11 +51,16 @@ interface IntegrationsPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
|
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
|
||||||
const integrations = await api.integration.all();
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const integrations = await api.integration.all();
|
||||||
const t = await getScopedI18n("integration");
|
const t = await getScopedI18n("integration");
|
||||||
|
|
||||||
const canCreateIntegrations = session?.user.permissions.includes("integration-create") ?? false;
|
const canCreateIntegrations = session.user.permissions.includes("integration-create");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageContainer>
|
<ManageContainer>
|
||||||
|
|||||||
@@ -52,16 +52,19 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
icon: IconBox,
|
icon: IconBox,
|
||||||
href: "/manage/apps",
|
href: "/manage/apps",
|
||||||
label: t("items.apps"),
|
label: t("items.apps"),
|
||||||
|
hidden: !session,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconPlug,
|
icon: IconPlug,
|
||||||
href: "/manage/integrations",
|
href: "/manage/integrations",
|
||||||
label: t("items.integrations"),
|
label: t("items.integrations"),
|
||||||
|
hidden: !session,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconSearch,
|
icon: IconSearch,
|
||||||
href: "/manage/search-engines",
|
href: "/manage/search-engines",
|
||||||
label: t("items.searchEngies"),
|
label: t("items.searchEngies"),
|
||||||
|
hidden: !session,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconPhoto,
|
icon: IconPhoto,
|
||||||
@@ -95,27 +98,32 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
{
|
{
|
||||||
label: t("items.tools.label"),
|
label: t("items.tools.label"),
|
||||||
icon: IconTool,
|
icon: IconTool,
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
// As permissions always include there children permissions, we can check other-view-logs as admin includes it
|
||||||
|
hidden: !session?.user.permissions.includes("other-view-logs"),
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.docker"),
|
label: t("items.tools.items.docker"),
|
||||||
icon: IconBrandDocker,
|
icon: IconBrandDocker,
|
||||||
href: "/manage/tools/docker",
|
href: "/manage/tools/docker",
|
||||||
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.api"),
|
label: t("items.tools.items.api"),
|
||||||
icon: IconPlug,
|
icon: IconPlug,
|
||||||
href: "/manage/tools/api",
|
href: "/manage/tools/api",
|
||||||
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.logs"),
|
label: t("items.tools.items.logs"),
|
||||||
icon: IconLogs,
|
icon: IconLogs,
|
||||||
href: "/manage/tools/logs",
|
href: "/manage/tools/logs",
|
||||||
|
hidden: !session?.user.permissions.includes("other-view-logs"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.tasks"),
|
label: t("items.tools.items.tasks"),
|
||||||
icon: IconReport,
|
icon: IconReport,
|
||||||
href: "/manage/tools/tasks",
|
href: "/manage/tools/tasks",
|
||||||
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export default async function GroupsListPage(props: MediaListPageProps) {
|
|||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
const searchParams = searchParamsSchema.parse(props.searchParams);
|
const searchParams = searchParamsSchema.parse(props.searchParams);
|
||||||
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
||||||
const isAdmin = session.user.permissions.includes("admin");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageContainer size="xl">
|
<ManageContainer size="xl">
|
||||||
@@ -57,10 +56,12 @@ export default async function GroupsListPage(props: MediaListPageProps) {
|
|||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Group>
|
<Group>
|
||||||
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
|
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
|
||||||
{isAdmin && <IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />}
|
{session.user.permissions.includes("media-view-all") && (
|
||||||
|
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<UploadMedia />
|
{session.user.permissions.includes("media-upload") && <UploadMedia />}
|
||||||
</Group>
|
</Group>
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
<TableThead>
|
<TableThead>
|
||||||
@@ -91,7 +92,10 @@ interface RowProps {
|
|||||||
media: RouterOutputs["media"]["getPaginated"]["items"][number];
|
media: RouterOutputs["media"]["getPaginated"]["items"][number];
|
||||||
}
|
}
|
||||||
|
|
||||||
const Row = ({ media }: RowProps) => {
|
const Row = async ({ media }: RowProps) => {
|
||||||
|
const session = await auth();
|
||||||
|
const canDelete = media.creatorId === session?.user.id || session?.user.permissions.includes("media-full-all");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableTr>
|
<TableTr>
|
||||||
<TableTd w={64}>
|
<TableTd w={64}>
|
||||||
@@ -120,7 +124,7 @@ const Row = ({ media }: RowProps) => {
|
|||||||
<TableTd w={64}>
|
<TableTd w={64}>
|
||||||
<Group wrap="nowrap" gap="xs">
|
<Group wrap="nowrap" gap="xs">
|
||||||
<CopyMedia media={media} />
|
<CopyMedia media={media} />
|
||||||
<DeleteMedia media={media} />
|
{canDelete && <DeleteMedia media={media} />}
|
||||||
</Group>
|
</Group>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
</TableTr>
|
</TableTr>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export default async function ManagementPage() {
|
|||||||
href: "/manage/apps",
|
href: "/manage/apps",
|
||||||
subtitle: t("statisticLabel.resources"),
|
subtitle: t("statisticLabel.resources"),
|
||||||
title: t("statistic.app"),
|
title: t("statistic.app"),
|
||||||
|
hidden: !session?.user,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
count: statistics.countGroups,
|
count: statistics.countGroups,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
import { Stack, Title } from "@mantine/core";
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
@@ -12,6 +14,12 @@ interface SearchEngineEditPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function SearchEngineEditPage({ params }: SearchEngineEditPageProps) {
|
export default async function SearchEngineEditPage({ params }: SearchEngineEditPageProps) {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user.permissions.includes("search-engine-modify-all")) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
const searchEngine = await api.searchEngine.byId({ id: params.id });
|
const searchEngine = await api.searchEngine.byId({ id: params.id });
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
import { Stack, Title } from "@mantine/core";
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
@@ -7,6 +9,12 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
|||||||
import { SearchEngineNewForm } from "./_search-engine-new-form";
|
import { SearchEngineNewForm } from "./_search-engine-new-form";
|
||||||
|
|
||||||
export default async function SearchEngineNewPage() {
|
export default async function SearchEngineNewPage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user.permissions.includes("search-engine-create")) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
import { IconPencil, IconSearch } from "@tabler/icons-react";
|
import { IconPencil, IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
import { SearchInput, TablePagination } from "@homarr/ui";
|
import { SearchInput, TablePagination } from "@homarr/ui";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
@@ -28,6 +30,12 @@ interface SearchEnginesPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
const searchParams = searchParamsSchema.parse(props.searchParams);
|
const searchParams = searchParamsSchema.parse(props.searchParams);
|
||||||
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
|
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
|
||||||
|
|
||||||
@@ -40,9 +48,11 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
|||||||
<Title>{tEngine("page.list.title")}</Title>
|
<Title>{tEngine("page.list.title")}</Title>
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} />
|
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} />
|
||||||
<MobileAffixButton component={Link} href="/manage/search-engines/new">
|
{session.user.permissions.includes("search-engine-create") && (
|
||||||
{tEngine("page.create.title")}
|
<MobileAffixButton component={Link} href="/manage/search-engines/new">
|
||||||
</MobileAffixButton>
|
{tEngine("page.create.title")}
|
||||||
|
</MobileAffixButton>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
{searchEngines.length === 0 && <SearchEngineNoResults />}
|
{searchEngines.length === 0 && <SearchEngineNoResults />}
|
||||||
{searchEngines.length > 0 && (
|
{searchEngines.length > 0 && (
|
||||||
@@ -67,6 +77,7 @@ interface SearchEngineCardProps {
|
|||||||
|
|
||||||
const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
||||||
const t = await getScopedI18n("search.engine");
|
const t = await getScopedI18n("search.engine");
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -105,16 +116,20 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
|||||||
</Group>
|
</Group>
|
||||||
<Group>
|
<Group>
|
||||||
<ActionIconGroup>
|
<ActionIconGroup>
|
||||||
<ActionIcon
|
{session?.user.permissions.includes("search-engine-modify-all") && (
|
||||||
component={Link}
|
<ActionIcon
|
||||||
href={`/manage/search-engines/edit/${searchEngine.id}`}
|
component={Link}
|
||||||
variant="subtle"
|
href={`/manage/search-engines/edit/${searchEngine.id}`}
|
||||||
color="gray"
|
variant="subtle"
|
||||||
aria-label={t("page.edit.title")}
|
color="gray"
|
||||||
>
|
aria-label={t("page.edit.title")}
|
||||||
<IconPencil size={16} stroke={1.5} />
|
>
|
||||||
</ActionIcon>
|
<IconPencil size={16} stroke={1.5} />
|
||||||
<SearchEngineDeleteButton searchEngine={searchEngine} />
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
{session?.user.permissions.includes("search-engine-full-all") && (
|
||||||
|
<SearchEngineDeleteButton searchEngine={searchEngine} />
|
||||||
|
)}
|
||||||
</ActionIconGroup>
|
</ActionIconGroup>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -124,6 +139,7 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
|||||||
|
|
||||||
const SearchEngineNoResults = async () => {
|
const SearchEngineNoResults = async () => {
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder bg="transparent">
|
<Card withBorder bg="transparent">
|
||||||
@@ -132,7 +148,9 @@ const SearchEngineNoResults = async () => {
|
|||||||
<Text fw={500} size="lg">
|
<Text fw={500} size="lg">
|
||||||
{t("search.engine.page.list.noResults.title")}
|
{t("search.engine.page.list.noResults.title")}
|
||||||
</Text>
|
</Text>
|
||||||
<Anchor href="/manage/search-engines/new">{t("search.engine.page.list.noResults.action")}</Anchor>
|
{session?.user.permissions.includes("search-engine-create") && (
|
||||||
|
<Anchor href="/manage/search-engines/new">{t("search.engine.page.list.noResults.action")}</Anchor>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export async function generateMetadata() {
|
|||||||
|
|
||||||
export default async function LogsManagementPage() {
|
export default async function LogsManagementPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user || !session.user.permissions.includes("admin")) {
|
if (!session?.user || !session.user.permissions.includes("other-view-logs")) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
|||||||
|
|
||||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||||
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { canAccessUserEditPage } from "../access";
|
import { canAccessUserEditPage } from "../access";
|
||||||
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
|
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { UserAvatar } from "@homarr/ui";
|
|||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||||
import { NavigationLink } from "../groups/[id]/_navigation";
|
import { NavigationLink } from "../groups/[id]/_navigation";
|
||||||
import { canAccessUserEditPage } from "./access";
|
import { canAccessUserEditPage } from "./access";
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { api } from "@homarr/api/server";
|
|||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||||
import { canAccessUserEditPage } from "../access";
|
import { canAccessUserEditPage } from "../access";
|
||||||
import { ChangePasswordForm } from "./_components/_change-password-form";
|
import { ChangePasswordForm } from "./_components/_change-password-form";
|
||||||
|
|
||||||
|
|||||||
23
apps/nextjs/src/errors/trpc-catch-error.ts
Normal file
23
apps/nextjs/src/errors/trpc-catch-error.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
import { logger } from "@homarr/log";
|
||||||
|
|
||||||
|
export const catchTrpcNotFound = (err: unknown) => {
|
||||||
|
if (err instanceof TRPCError && err.code === "NOT_FOUND") {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const catchTrpcUnauthorized = (err: unknown) => {
|
||||||
|
if (err instanceof TRPCError && err.code === "UNAUTHORIZED") {
|
||||||
|
logger.info("Somebody tried to access a protected route without being authenticated, redirecting to login page");
|
||||||
|
redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
};
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import "server-only";
|
|
||||||
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { TRPCError } from "@trpc/server";
|
|
||||||
|
|
||||||
export const catchTrpcNotFound = (err: unknown) => {
|
|
||||||
if (err instanceof TRPCError && err.code === "NOT_FOUND") {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
throw err;
|
|
||||||
};
|
|
||||||
@@ -4,10 +4,11 @@ import { asc, createId, eq, inArray, like } from "@homarr/db";
|
|||||||
import { apps } from "@homarr/db/schema/sqlite";
|
import { apps } from "@homarr/db/schema/sqlite";
|
||||||
import { validation, z } from "@homarr/validation";
|
import { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
|
import { canUserSeeAppAsync } from "./app/app-access-control";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
all: publicProcedure
|
all: protectedProcedure
|
||||||
.input(z.void())
|
.input(z.void())
|
||||||
.output(
|
.output(
|
||||||
z.array(
|
z.array(
|
||||||
@@ -26,7 +27,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
orderBy: asc(apps.name),
|
orderBy: asc(apps.name),
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
search: publicProcedure
|
search: protectedProcedure
|
||||||
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
|
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
|
||||||
.output(
|
.output(
|
||||||
z.array(
|
z.array(
|
||||||
@@ -47,7 +48,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
selectable: publicProcedure
|
selectable: protectedProcedure
|
||||||
.input(z.void())
|
.input(z.void())
|
||||||
.output(
|
.output(
|
||||||
z.array(
|
z.array(
|
||||||
@@ -104,14 +105,23 @@ export const appRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canUserSeeApp = await canUserSeeAppAsync(ctx.session?.user ?? null, app.id);
|
||||||
|
if (!canUserSeeApp) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "App not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}),
|
}),
|
||||||
byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => {
|
byIds: protectedProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => {
|
||||||
return await ctx.db.query.apps.findMany({
|
return await ctx.db.query.apps.findMany({
|
||||||
where: inArray(apps.id, input),
|
where: inArray(apps.id, input),
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
create: protectedProcedure
|
create: permissionRequiredProcedure
|
||||||
|
.requiresPermission("app-create")
|
||||||
.input(validation.app.manage)
|
.input(validation.app.manage)
|
||||||
.output(z.void())
|
.output(z.void())
|
||||||
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
|
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
|
||||||
@@ -124,29 +134,33 @@ export const appRouter = createTRPCRouter({
|
|||||||
href: input.href,
|
href: input.href,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
update: protectedProcedure.input(validation.app.edit).mutation(async ({ ctx, input }) => {
|
update: permissionRequiredProcedure
|
||||||
const app = await ctx.db.query.apps.findFirst({
|
.requiresPermission("app-modify-all")
|
||||||
where: eq(apps.id, input.id),
|
.input(validation.app.edit)
|
||||||
});
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const app = await ctx.db.query.apps.findFirst({
|
||||||
if (!app) {
|
where: eq(apps.id, input.id),
|
||||||
throw new TRPCError({
|
|
||||||
code: "NOT_FOUND",
|
|
||||||
message: "App not found",
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.db
|
if (!app) {
|
||||||
.update(apps)
|
throw new TRPCError({
|
||||||
.set({
|
code: "NOT_FOUND",
|
||||||
name: input.name,
|
message: "App not found",
|
||||||
description: input.description,
|
});
|
||||||
iconUrl: input.iconUrl,
|
}
|
||||||
href: input.href,
|
|
||||||
})
|
await ctx.db
|
||||||
.where(eq(apps.id, input.id));
|
.update(apps)
|
||||||
}),
|
.set({
|
||||||
delete: protectedProcedure
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
iconUrl: input.iconUrl,
|
||||||
|
href: input.href,
|
||||||
|
})
|
||||||
|
.where(eq(apps.id, input.id));
|
||||||
|
}),
|
||||||
|
delete: permissionRequiredProcedure
|
||||||
|
.requiresPermission("app-full-all")
|
||||||
.output(z.void())
|
.output(z.void())
|
||||||
.meta({ openapi: { method: "DELETE", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
|
.meta({ openapi: { method: "DELETE", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
|
||||||
.input(validation.common.byId)
|
.input(validation.common.byId)
|
||||||
|
|||||||
50
packages/api/src/router/app/app-access-control.ts
Normal file
50
packages/api/src/router/app/app-access-control.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
|
import type { Session } from "@homarr/auth";
|
||||||
|
import { db, eq, or } from "@homarr/db";
|
||||||
|
import { items } from "@homarr/db/schema/sqlite";
|
||||||
|
|
||||||
|
import type { WidgetComponentProps } from "../../../../widgets/src";
|
||||||
|
|
||||||
|
export const canUserSeeAppAsync = async (user: Session["user"] | null, appId: string) => {
|
||||||
|
return await canUserSeeAppsAsync(user, [appId]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const canUserSeeAppsAsync = async (user: Session["user"] | null, appIds: string[]) => {
|
||||||
|
if (user) return true;
|
||||||
|
|
||||||
|
const appIdsOnPublicBoards = await getAllAppIdsOnPublicBoardsAsync();
|
||||||
|
return appIds.every((appId) => appIdsOnPublicBoards.includes(appId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAllAppIdsOnPublicBoardsAsync = async () => {
|
||||||
|
const itemsWithApps = await db.query.items.findMany({
|
||||||
|
where: or(eq(items.kind, "app"), eq(items.kind, "bookmarks")),
|
||||||
|
with: {
|
||||||
|
section: {
|
||||||
|
columns: {}, // Nothing
|
||||||
|
with: {
|
||||||
|
board: {
|
||||||
|
columns: {
|
||||||
|
isPublic: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return itemsWithApps
|
||||||
|
.filter((item) => item.section.board.isPublic)
|
||||||
|
.flatMap((item) => {
|
||||||
|
if (item.kind === "app") {
|
||||||
|
const parsedOptions = SuperJSON.parse<WidgetComponentProps<"app">["options"]>(item.options);
|
||||||
|
return [parsedOptions.appId];
|
||||||
|
} else if (item.kind === "bookmarks") {
|
||||||
|
const parsedOptions = SuperJSON.parse<WidgetComponentProps<"bookmarks">["options"]>(item.options);
|
||||||
|
return parsedOptions.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Failed to get app ids from board. Invalid item kind: 'test'");
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -7,7 +7,7 @@ import { loggingChannel } from "@homarr/redis";
|
|||||||
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
||||||
|
|
||||||
export const logRouter = createTRPCRouter({
|
export const logRouter = createTRPCRouter({
|
||||||
subscribe: permissionRequiredProcedure.requiresPermission("admin").subscription(() => {
|
subscribe: permissionRequiredProcedure.requiresPermission("other-view-logs").subscription(() => {
|
||||||
return observable<LoggerMessage>((emit) => {
|
return observable<LoggerMessage>((emit) => {
|
||||||
const unsubscribe = loggingChannel.subscribe((data) => {
|
const unsubscribe = loggingChannel.subscribe((data) => {
|
||||||
emit.next(data);
|
emit.next(data);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { and, createId, desc, eq, like } from "@homarr/db";
|
|||||||
import { medias } from "@homarr/db/schema/sqlite";
|
import { medias } from "@homarr/db/schema/sqlite";
|
||||||
import { validation, z } from "@homarr/validation";
|
import { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../../trpc";
|
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const mediaRouter = createTRPCRouter({
|
export const mediaRouter = createTRPCRouter({
|
||||||
getPaginated: protectedProcedure
|
getPaginated: protectedProcedure
|
||||||
@@ -14,7 +14,7 @@ export const mediaRouter = createTRPCRouter({
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const includeFromAllUsers = ctx.session.user.permissions.includes("admin") && input.includeFromAllUsers;
|
const includeFromAllUsers = ctx.session.user.permissions.includes("media-view-all") && input.includeFromAllUsers;
|
||||||
|
|
||||||
const where = and(
|
const where = and(
|
||||||
input.search.length >= 1 ? like(medias.name, `%${input.search}%`) : undefined,
|
input.search.length >= 1 ? like(medias.name, `%${input.search}%`) : undefined,
|
||||||
@@ -46,20 +46,23 @@ export const mediaRouter = createTRPCRouter({
|
|||||||
totalCount,
|
totalCount,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
uploadMedia: protectedProcedure.input(validation.media.uploadMedia).mutation(async ({ ctx, input }) => {
|
uploadMedia: permissionRequiredProcedure
|
||||||
const content = Buffer.from(await input.file.arrayBuffer());
|
.requiresPermission("media-upload")
|
||||||
const id = createId();
|
.input(validation.media.uploadMedia)
|
||||||
await ctx.db.insert(medias).values({
|
.mutation(async ({ ctx, input }) => {
|
||||||
id,
|
const content = Buffer.from(await input.file.arrayBuffer());
|
||||||
creatorId: ctx.session.user.id,
|
const id = createId();
|
||||||
content,
|
await ctx.db.insert(medias).values({
|
||||||
size: input.file.size,
|
id,
|
||||||
contentType: input.file.type,
|
creatorId: ctx.session.user.id,
|
||||||
name: input.file.name,
|
content,
|
||||||
});
|
size: input.file.size,
|
||||||
|
contentType: input.file.type,
|
||||||
|
name: input.file.name,
|
||||||
|
});
|
||||||
|
|
||||||
return id;
|
return id;
|
||||||
}),
|
}),
|
||||||
deleteMedia: protectedProcedure.input(validation.common.byId).mutation(async ({ ctx, input }) => {
|
deleteMedia: protectedProcedure.input(validation.common.byId).mutation(async ({ ctx, input }) => {
|
||||||
const dbMedia = await ctx.db.query.medias.findFirst({
|
const dbMedia = await ctx.db.query.medias.findFirst({
|
||||||
where: eq(medias.id, input.id),
|
where: eq(medias.id, input.id),
|
||||||
@@ -75,8 +78,8 @@ export const mediaRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow admins and the creator of the media to delete it
|
// Only allow users with media-full-all permission and the creator of the media to delete it
|
||||||
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== dbMedia.creatorId) {
|
if (!ctx.session.user.permissions.includes("media-full-all") && ctx.session.user.id !== dbMedia.creatorId) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "You don't have permission to delete this media",
|
message: "You don't have permission to delete this media",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createId, eq, like, sql } from "@homarr/db";
|
|||||||
import { searchEngines } from "@homarr/db/schema/sqlite";
|
import { searchEngines } from "@homarr/db/schema/sqlite";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../../trpc";
|
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const searchEngineRouter = createTRPCRouter({
|
export const searchEngineRouter = createTRPCRouter({
|
||||||
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => {
|
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => {
|
||||||
@@ -59,43 +59,52 @@ export const searchEngineRouter = createTRPCRouter({
|
|||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
create: protectedProcedure.input(validation.searchEngine.manage).mutation(async ({ ctx, input }) => {
|
create: permissionRequiredProcedure
|
||||||
await ctx.db.insert(searchEngines).values({
|
.requiresPermission("search-engine-create")
|
||||||
id: createId(),
|
.input(validation.searchEngine.manage)
|
||||||
name: input.name,
|
.mutation(async ({ ctx, input }) => {
|
||||||
short: input.short.toLowerCase(),
|
await ctx.db.insert(searchEngines).values({
|
||||||
iconUrl: input.iconUrl,
|
id: createId(),
|
||||||
urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
|
|
||||||
description: input.description,
|
|
||||||
type: input.type,
|
|
||||||
integrationId: "integrationId" in input ? input.integrationId : null,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
update: protectedProcedure.input(validation.searchEngine.edit).mutation(async ({ ctx, input }) => {
|
|
||||||
const searchEngine = await ctx.db.query.searchEngines.findFirst({
|
|
||||||
where: eq(searchEngines.id, input.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!searchEngine) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "NOT_FOUND",
|
|
||||||
message: "Search engine not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.db
|
|
||||||
.update(searchEngines)
|
|
||||||
.set({
|
|
||||||
name: input.name,
|
name: input.name,
|
||||||
|
short: input.short.toLowerCase(),
|
||||||
iconUrl: input.iconUrl,
|
iconUrl: input.iconUrl,
|
||||||
urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
|
urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
integrationId: "integrationId" in input ? input.integrationId : null,
|
|
||||||
type: input.type,
|
type: input.type,
|
||||||
})
|
integrationId: "integrationId" in input ? input.integrationId : null,
|
||||||
.where(eq(searchEngines.id, input.id));
|
});
|
||||||
}),
|
}),
|
||||||
delete: protectedProcedure.input(validation.common.byId).mutation(async ({ ctx, input }) => {
|
update: permissionRequiredProcedure
|
||||||
await ctx.db.delete(searchEngines).where(eq(searchEngines.id, input.id));
|
.requiresPermission("search-engine-modify-all")
|
||||||
}),
|
.input(validation.searchEngine.edit)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const searchEngine = await ctx.db.query.searchEngines.findFirst({
|
||||||
|
where: eq(searchEngines.id, input.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!searchEngine) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Search engine not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.update(searchEngines)
|
||||||
|
.set({
|
||||||
|
name: input.name,
|
||||||
|
iconUrl: input.iconUrl,
|
||||||
|
urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
|
||||||
|
description: input.description,
|
||||||
|
integrationId: "integrationId" in input ? input.integrationId : null,
|
||||||
|
type: input.type,
|
||||||
|
})
|
||||||
|
.where(eq(searchEngines.id, input.id));
|
||||||
|
}),
|
||||||
|
delete: permissionRequiredProcedure
|
||||||
|
.requiresPermission("search-engine-full-all")
|
||||||
|
.input(validation.common.byId)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await ctx.db.delete(searchEngines).where(eq(searchEngines.id, input.id));
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,23 +5,26 @@ import type { Session } from "@homarr/auth";
|
|||||||
import { createId } from "@homarr/db";
|
import { createId } from "@homarr/db";
|
||||||
import { apps } from "@homarr/db/schema/sqlite";
|
import { apps } from "@homarr/db/schema/sqlite";
|
||||||
import { createDb } from "@homarr/db/test";
|
import { createDb } from "@homarr/db/test";
|
||||||
|
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||||
|
|
||||||
import { appRouter } from "../app";
|
import { appRouter } from "../app";
|
||||||
|
import * as appAccessControl from "../app/app-access-control";
|
||||||
|
|
||||||
// Mock the auth module to return an empty session
|
// Mock the auth module to return an empty session
|
||||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||||
|
|
||||||
const defaultSession: Session = {
|
const createDefaultSession = (permissions: GroupPermissionKey[] = []): Session => ({
|
||||||
user: { id: createId(), permissions: [], colorScheme: "light" },
|
user: { id: createId(), permissions, colorScheme: "light" },
|
||||||
expires: new Date().toISOString(),
|
expires: new Date().toISOString(),
|
||||||
};
|
});
|
||||||
|
|
||||||
describe("all should return all apps", () => {
|
describe("all should return all apps", () => {
|
||||||
test("should return all apps", async () => {
|
test("should return all apps with session", async () => {
|
||||||
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = appRouter.createCaller({
|
const caller = appRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
session: null,
|
session: createDefaultSession(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.insert(apps).values([
|
await db.insert(apps).values([
|
||||||
@@ -48,15 +51,30 @@ describe("all should return all apps", () => {
|
|||||||
expect(result[1]!.href).toBeNull();
|
expect(result[1]!.href).toBeNull();
|
||||||
expect(result[1]!.description).toBeNull();
|
expect(result[1]!.description).toBeNull();
|
||||||
});
|
});
|
||||||
|
test("should throw UNAUTHORIZED if the user is not authenticated", async () => {
|
||||||
|
// Arrange
|
||||||
|
const caller = appRouter.createCaller({
|
||||||
|
db: createDb(),
|
||||||
|
session: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const actAsync = async () => await caller.all();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expect(actAsync()).rejects.toThrow("UNAUTHORIZED");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("byId should return an app by id", () => {
|
describe("byId should return an app by id", () => {
|
||||||
test("should return an app by id", async () => {
|
test("should return an app by id when canUserSeeAppAsync returns true", async () => {
|
||||||
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = appRouter.createCaller({
|
const caller = appRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
session: null,
|
session: null,
|
||||||
});
|
});
|
||||||
|
vi.spyOn(appAccessControl, "canUserSeeAppAsync").mockReturnValue(Promise.resolve(true));
|
||||||
|
|
||||||
await db.insert(apps).values([
|
await db.insert(apps).values([
|
||||||
{
|
{
|
||||||
@@ -73,28 +91,61 @@ describe("byId should return an app by id", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
const result = await caller.byId({ id: "2" });
|
const result = await caller.byId({ id: "2" });
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(result.name).toBe("Mantine");
|
expect(result.name).toBe("Mantine");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should throw NOT_FOUND error when canUserSeeAppAsync returns false", async () => {
|
||||||
|
// Arrange
|
||||||
|
const db = createDb();
|
||||||
|
const caller = appRouter.createCaller({
|
||||||
|
db,
|
||||||
|
session: null,
|
||||||
|
});
|
||||||
|
await db.insert(apps).values([
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
name: "Mantine",
|
||||||
|
description: "React components and hooks library",
|
||||||
|
iconUrl: "https://mantine.dev/favicon.svg",
|
||||||
|
href: "https://mantine.dev",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.spyOn(appAccessControl, "canUserSeeAppAsync").mockReturnValue(Promise.resolve(false));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const actAsync = async () => await caller.byId({ id: "2" });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expect(actAsync()).rejects.toThrow("App not found");
|
||||||
|
});
|
||||||
|
|
||||||
test("should throw an error if the app does not exist", async () => {
|
test("should throw an error if the app does not exist", async () => {
|
||||||
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = appRouter.createCaller({
|
const caller = appRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
session: null,
|
session: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
const actAsync = async () => await caller.byId({ id: "2" });
|
const actAsync = async () => await caller.byId({ id: "2" });
|
||||||
|
|
||||||
|
// Assert
|
||||||
await expect(actAsync()).rejects.toThrow("App not found");
|
await expect(actAsync()).rejects.toThrow("App not found");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("create should create a new app with all arguments", () => {
|
describe("create should create a new app with all arguments", () => {
|
||||||
test("should create a new app", async () => {
|
test("should create a new app", async () => {
|
||||||
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = appRouter.createCaller({
|
const caller = appRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
session: defaultSession,
|
session: createDefaultSession(["app-create"]),
|
||||||
});
|
});
|
||||||
const input = {
|
const input = {
|
||||||
name: "Mantine",
|
name: "Mantine",
|
||||||
@@ -103,8 +154,10 @@ describe("create should create a new app with all arguments", () => {
|
|||||||
href: "https://mantine.dev",
|
href: "https://mantine.dev",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
await caller.create(input);
|
await caller.create(input);
|
||||||
|
|
||||||
|
// Assert
|
||||||
const dbApp = await db.query.apps.findFirst();
|
const dbApp = await db.query.apps.findFirst();
|
||||||
expect(dbApp).toBeDefined();
|
expect(dbApp).toBeDefined();
|
||||||
expect(dbApp!.name).toBe(input.name);
|
expect(dbApp!.name).toBe(input.name);
|
||||||
@@ -114,10 +167,11 @@ describe("create should create a new app with all arguments", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should create a new app only with required arguments", async () => {
|
test("should create a new app only with required arguments", async () => {
|
||||||
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = appRouter.createCaller({
|
const caller = appRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
session: defaultSession,
|
session: createDefaultSession(["app-create"]),
|
||||||
});
|
});
|
||||||
const input = {
|
const input = {
|
||||||
name: "Mantine",
|
name: "Mantine",
|
||||||
@@ -126,8 +180,10 @@ describe("create should create a new app with all arguments", () => {
|
|||||||
href: null,
|
href: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
await caller.create(input);
|
await caller.create(input);
|
||||||
|
|
||||||
|
// Assert
|
||||||
const dbApp = await db.query.apps.findFirst();
|
const dbApp = await db.query.apps.findFirst();
|
||||||
expect(dbApp).toBeDefined();
|
expect(dbApp).toBeDefined();
|
||||||
expect(dbApp!.name).toBe(input.name);
|
expect(dbApp!.name).toBe(input.name);
|
||||||
@@ -139,10 +195,11 @@ describe("create should create a new app with all arguments", () => {
|
|||||||
|
|
||||||
describe("update should update an app", () => {
|
describe("update should update an app", () => {
|
||||||
test("should update an app", async () => {
|
test("should update an app", async () => {
|
||||||
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = appRouter.createCaller({
|
const caller = appRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
session: defaultSession,
|
session: createDefaultSession(["app-modify-all"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const appId = createId();
|
const appId = createId();
|
||||||
@@ -162,8 +219,10 @@ describe("update should update an app", () => {
|
|||||||
href: "https://mantine.dev",
|
href: "https://mantine.dev",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
await caller.update(input);
|
await caller.update(input);
|
||||||
|
|
||||||
|
// Assert
|
||||||
const dbApp = await db.query.apps.findFirst();
|
const dbApp = await db.query.apps.findFirst();
|
||||||
|
|
||||||
expect(dbApp).toBeDefined();
|
expect(dbApp).toBeDefined();
|
||||||
@@ -174,12 +233,14 @@ describe("update should update an app", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should throw an error if the app does not exist", async () => {
|
test("should throw an error if the app does not exist", async () => {
|
||||||
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = appRouter.createCaller({
|
const caller = appRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
session: defaultSession,
|
session: createDefaultSession(["app-modify-all"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
const actAsync = async () =>
|
const actAsync = async () =>
|
||||||
await caller.update({
|
await caller.update({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -188,16 +249,19 @@ describe("update should update an app", () => {
|
|||||||
description: null,
|
description: null,
|
||||||
href: null,
|
href: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
await expect(actAsync()).rejects.toThrow("App not found");
|
await expect(actAsync()).rejects.toThrow("App not found");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("delete should delete an app", () => {
|
describe("delete should delete an app", () => {
|
||||||
test("should delete an app", async () => {
|
test("should delete an app", async () => {
|
||||||
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = appRouter.createCaller({
|
const caller = appRouter.createCaller({
|
||||||
db,
|
db,
|
||||||
session: defaultSession,
|
session: createDefaultSession(["app-full-all"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const appId = createId();
|
const appId = createId();
|
||||||
@@ -207,8 +271,10 @@ describe("delete should delete an app", () => {
|
|||||||
iconUrl: "https://mantine.dev/favicon.svg",
|
iconUrl: "https://mantine.dev/favicon.svg",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
await caller.delete({ id: appId });
|
await caller.delete({ id: appId });
|
||||||
|
|
||||||
|
// Assert
|
||||||
const dbApp = await db.query.apps.findFirst();
|
const dbApp = await db.query.apps.findFirst();
|
||||||
expect(dbApp).toBeUndefined();
|
expect(dbApp).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,8 +36,13 @@ export type IntegrationPermission = (typeof integrationPermissions)[number];
|
|||||||
* For example "board-create" is a generated key
|
* For example "board-create" is a generated key
|
||||||
*/
|
*/
|
||||||
export const groupPermissions = {
|
export const groupPermissions = {
|
||||||
|
// Order is the same in the UI, inspired from order in navigation here
|
||||||
board: ["create", "view-all", "modify-all", "full-all"],
|
board: ["create", "view-all", "modify-all", "full-all"],
|
||||||
|
app: ["create", "use-all", "modify-all", "full-all"],
|
||||||
integration: ["create", "use-all", "interact-all", "full-all"],
|
integration: ["create", "use-all", "interact-all", "full-all"],
|
||||||
|
"search-engine": ["create", "modify-all", "full-all"],
|
||||||
|
media: ["upload", "view-all", "full-all"],
|
||||||
|
other: ["view-logs"],
|
||||||
admin: true,
|
admin: true,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -49,9 +54,21 @@ export const groupPermissions = {
|
|||||||
const groupPermissionParents = {
|
const groupPermissionParents = {
|
||||||
"board-modify-all": ["board-view-all"],
|
"board-modify-all": ["board-view-all"],
|
||||||
"board-full-all": ["board-modify-all", "board-create"],
|
"board-full-all": ["board-modify-all", "board-create"],
|
||||||
|
"app-modify-all": ["app-create"],
|
||||||
|
"app-full-all": ["app-modify-all", "app-use-all"],
|
||||||
"integration-interact-all": ["integration-use-all"],
|
"integration-interact-all": ["integration-use-all"],
|
||||||
"integration-full-all": ["integration-interact-all", "integration-create"],
|
"integration-full-all": ["integration-interact-all", "integration-create"],
|
||||||
admin: ["board-full-all", "integration-full-all"],
|
"search-engine-modify-all": ["search-engine-create"],
|
||||||
|
"search-engine-full-all": ["search-engine-modify-all"],
|
||||||
|
"media-full-all": ["media-upload", "media-view-all"],
|
||||||
|
admin: [
|
||||||
|
"board-full-all",
|
||||||
|
"app-full-all",
|
||||||
|
"integration-full-all",
|
||||||
|
"search-engine-full-all",
|
||||||
|
"media-full-all",
|
||||||
|
"other-view-logs",
|
||||||
|
],
|
||||||
} satisfies Partial<Record<GroupPermissionKey, GroupPermissionKey[]>>;
|
} satisfies Partial<Record<GroupPermissionKey, GroupPermissionKey[]>>;
|
||||||
|
|
||||||
export const getPermissionsWithParents = (permissions: GroupPermissionKey[]): GroupPermissionKey[] => {
|
export const getPermissionsWithParents = (permissions: GroupPermissionKey[]): GroupPermissionKey[] => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
|
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
|
||||||
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
|
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
|
||||||
@@ -9,23 +10,42 @@ import type { TranslationObject } from "@homarr/translation";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { inferSearchInteractionOptions } from "../lib/interaction";
|
import type { inferSearchInteractionOptions } from "../lib/interaction";
|
||||||
|
import type { SearchMode } from "../lib/mode";
|
||||||
import { searchModes } from "../modes";
|
import { searchModes } from "../modes";
|
||||||
import { selectAction, spotlightStore } from "../spotlight-store";
|
import { selectAction, spotlightStore } from "../spotlight-store";
|
||||||
import { SpotlightChildrenActions } from "./actions/children-actions";
|
import { SpotlightChildrenActions } from "./actions/children-actions";
|
||||||
import { SpotlightActionGroups } from "./actions/groups/action-group";
|
import { SpotlightActionGroups } from "./actions/groups/action-group";
|
||||||
|
|
||||||
|
type SearchModeKey = keyof TranslationObject["search"]["mode"];
|
||||||
|
|
||||||
export const Spotlight = () => {
|
export const Spotlight = () => {
|
||||||
const [query, setQuery] = useState("");
|
const searchModeState = useState<SearchModeKey>("help");
|
||||||
const [mode, setMode] = useState<keyof TranslationObject["search"]["mode"]>("help");
|
const mode = searchModeState[0];
|
||||||
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
|
|
||||||
const t = useI18n();
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]);
|
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]);
|
||||||
|
|
||||||
if (!activeMode) {
|
if (!activeMode) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We use the "key" below to prevent the 'Different amounts of hooks' error
|
||||||
|
return <SpotlightWithActiveMode key={mode} modeState={searchModeState} activeMode={activeMode} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SpotlightWithActiveModeProps {
|
||||||
|
modeState: [SearchModeKey, Dispatch<SetStateAction<SearchModeKey>>];
|
||||||
|
activeMode: SearchMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveModeProps) => {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [mode, setMode] = modeState;
|
||||||
|
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
|
||||||
|
const t = useI18n();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
// Works as always the same amount of hooks are executed
|
||||||
|
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
|
||||||
|
const groups = useGroups();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineSpotlight.Root
|
<MantineSpotlight.Root
|
||||||
yOffset={8}
|
yOffset={8}
|
||||||
@@ -115,7 +135,7 @@ export const Spotlight = () => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
query={query}
|
query={query}
|
||||||
groups={activeMode.groups}
|
groups={groups}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</MantineSpotlight.ActionsList>
|
</MantineSpotlight.ActionsList>
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import type { TranslationObject } from "@homarr/translation";
|
|||||||
|
|
||||||
import type { SearchGroup } from "./group";
|
import type { SearchGroup } from "./group";
|
||||||
|
|
||||||
export interface SearchMode {
|
export type SearchMode = {
|
||||||
modeKey: keyof TranslationObject["search"]["mode"];
|
modeKey: keyof TranslationObject["search"]["mode"];
|
||||||
character: string;
|
character: string;
|
||||||
groups: SearchGroup[];
|
} & (
|
||||||
}
|
| {
|
||||||
|
groups: SearchGroup[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
useGroups: () => SearchGroup[];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { useSession } from "@homarr/auth/client";
|
||||||
|
|
||||||
|
import type { SearchGroup } from "../../lib/group";
|
||||||
import type { SearchMode } from "../../lib/mode";
|
import type { SearchMode } from "../../lib/mode";
|
||||||
import { appsSearchGroup } from "./apps-search-group";
|
import { appsSearchGroup } from "./apps-search-group";
|
||||||
import { boardsSearchGroup } from "./boards-search-group";
|
import { boardsSearchGroup } from "./boards-search-group";
|
||||||
@@ -6,5 +9,14 @@ import { integrationsSearchGroup } from "./integrations-search-group";
|
|||||||
export const appIntegrationBoardMode = {
|
export const appIntegrationBoardMode = {
|
||||||
modeKey: "appIntegrationBoard",
|
modeKey: "appIntegrationBoard",
|
||||||
character: "#",
|
character: "#",
|
||||||
groups: [appsSearchGroup, integrationsSearchGroup, boardsSearchGroup],
|
useGroups() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const groups: SearchGroup[] = [boardsSearchGroup];
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.concat([appsSearchGroup, integrationsSearchGroup]);
|
||||||
|
},
|
||||||
} satisfies SearchMode;
|
} satisfies SearchMode;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Group, Text, useMantineColorScheme } from "@mantine/core";
|
import { Group, Text, useMantineColorScheme } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
IconBox,
|
||||||
IconCategoryPlus,
|
IconCategoryPlus,
|
||||||
IconFileImport,
|
IconFileImport,
|
||||||
IconLanguage,
|
IconLanguage,
|
||||||
IconMailForward,
|
IconMailForward,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
IconPackage,
|
|
||||||
IconPlug,
|
IconPlug,
|
||||||
IconSun,
|
IconSun,
|
||||||
IconUserPlus,
|
IconUserPlus,
|
||||||
@@ -113,9 +113,10 @@ export const commandMode = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
commandKey: "newApp",
|
commandKey: "newApp",
|
||||||
icon: IconPackage,
|
icon: IconBox,
|
||||||
name: tOption("newApp.label"),
|
name: tOption("newApp.label"),
|
||||||
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
|
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
|
||||||
|
hidden: !session?.user.permissions.includes("app-create"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
commandKey: "newIntegration",
|
commandKey: "newIntegration",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Group, Kbd, Text } from "@mantine/core";
|
import { Group, Kbd, Text } from "@mantine/core";
|
||||||
import { IconBook2, IconBrandDiscord, IconBrandGithub } from "@tabler/icons-react";
|
import { IconBook2, IconBrandDiscord, IconBrandGithub } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { createDocumentationLink } from "@homarr/definitions";
|
import { createDocumentationLink } from "@homarr/definitions";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
@@ -18,58 +19,67 @@ const searchModesWithoutHelp = [userGroupMode, appIntegrationBoardMode, external
|
|||||||
const helpMode = {
|
const helpMode = {
|
||||||
modeKey: "help",
|
modeKey: "help",
|
||||||
character: "?",
|
character: "?",
|
||||||
groups: [
|
useGroups() {
|
||||||
createGroup({
|
const { data: session } = useSession();
|
||||||
keyPath: "character",
|
const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, commandMode, pageMode];
|
||||||
title: (t) => t("search.mode.help.group.mode.title"),
|
|
||||||
options: searchModesWithoutHelp.map(({ character, modeKey }) => ({ character, modeKey })),
|
|
||||||
Component: ({ modeKey, character }) => {
|
|
||||||
const t = useScopedI18n(`search.mode.${modeKey}`);
|
|
||||||
|
|
||||||
return (
|
if (session?.user.permissions.includes("admin")) {
|
||||||
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between">
|
visibleSearchModes.unshift(userGroupMode);
|
||||||
<Text>{t("help")}</Text>
|
}
|
||||||
<Kbd size="sm">{character}</Kbd>
|
|
||||||
|
return [
|
||||||
|
createGroup({
|
||||||
|
keyPath: "character",
|
||||||
|
title: (t) => t("search.mode.help.group.mode.title"),
|
||||||
|
options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })),
|
||||||
|
Component: ({ modeKey, character }) => {
|
||||||
|
const t = useScopedI18n(`search.mode.${modeKey}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between">
|
||||||
|
<Text>{t("help")}</Text>
|
||||||
|
<Kbd size="sm">{character}</Kbd>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
filter: () => true,
|
||||||
|
useInteraction: interaction.mode(({ modeKey }) => ({ mode: modeKey })),
|
||||||
|
}),
|
||||||
|
createGroup({
|
||||||
|
keyPath: "href",
|
||||||
|
title: (t) => t("search.mode.help.group.help.title"),
|
||||||
|
useOptions() {
|
||||||
|
const t = useScopedI18n("search.mode.help.group.help.option");
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t("documentation.label"),
|
||||||
|
icon: IconBook2,
|
||||||
|
href: createDocumentationLink("/docs/getting-started"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("submitIssue.label"),
|
||||||
|
icon: IconBrandGithub,
|
||||||
|
href: "https://github.com/ajnart/homarr/issues/new/choose",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("discord.label"),
|
||||||
|
icon: IconBrandDiscord,
|
||||||
|
href: "https://discord.com/invite/aCsmEV5RgA",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
Component: (props) => (
|
||||||
|
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
|
||||||
|
<props.icon />
|
||||||
|
<Text>{props.label}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
),
|
||||||
},
|
filter: () => true,
|
||||||
filter: () => true,
|
useInteraction: interaction.link(({ href }) => ({ href, newTab: true })),
|
||||||
useInteraction: interaction.mode(({ modeKey }) => ({ mode: modeKey })),
|
}),
|
||||||
}),
|
];
|
||||||
createGroup({
|
},
|
||||||
keyPath: "href",
|
|
||||||
title: (t) => t("search.mode.help.group.help.title"),
|
|
||||||
useOptions() {
|
|
||||||
const t = useScopedI18n("search.mode.help.group.help.option");
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: t("documentation.label"),
|
|
||||||
icon: IconBook2,
|
|
||||||
href: createDocumentationLink("/docs/getting-started"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("submitIssue.label"),
|
|
||||||
icon: IconBrandGithub,
|
|
||||||
href: "https://github.com/ajnart/homarr/issues/new/choose",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("discord.label"),
|
|
||||||
icon: IconBrandDiscord,
|
|
||||||
href: "https://discord.com/invite/aCsmEV5RgA",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
Component: (props) => (
|
|
||||||
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
|
|
||||||
<props.icon />
|
|
||||||
<Text>{props.label}</Text>
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
filter: () => true,
|
|
||||||
useInteraction: interaction.link(({ href }) => ({ href, newTab: true })),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
} satisfies SearchMode;
|
} satisfies SearchMode;
|
||||||
|
|
||||||
export const searchModes = [...searchModesWithoutHelp, helpMode] as const;
|
export const searchModes = [...searchModesWithoutHelp, helpMode] as const;
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export const pagesSearchGroup = createGroup<{
|
|||||||
icon: IconLogs,
|
icon: IconLogs,
|
||||||
path: "/manage/tools/logs",
|
path: "/manage/tools/logs",
|
||||||
name: t("manageLog.label"),
|
name: t("manageLog.label"),
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
hidden: !session?.user.permissions.includes("other-view-logs"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconReport,
|
icon: IconReport,
|
||||||
|
|||||||
@@ -196,6 +196,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"app": {
|
||||||
|
"title": "Apps",
|
||||||
|
"item": {
|
||||||
|
"create": {
|
||||||
|
"label": "Create apps",
|
||||||
|
"description": "Allow members to create apps"
|
||||||
|
},
|
||||||
|
"use-all": {
|
||||||
|
"label": "Use all apps",
|
||||||
|
"description": "Allow members to add any apps to their boards"
|
||||||
|
},
|
||||||
|
"modify-all": {
|
||||||
|
"label": "Modify all apps",
|
||||||
|
"description": "Allow members to modify all apps"
|
||||||
|
},
|
||||||
|
"full-all": {
|
||||||
|
"label": "Full app access",
|
||||||
|
"description": "Allow members to manage, use and delete any app"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"board": {
|
"board": {
|
||||||
"title": "Boards",
|
"title": "Boards",
|
||||||
"item": {
|
"item": {
|
||||||
@@ -237,6 +258,49 @@
|
|||||||
"description": "Allow members to manage, use and interact with any integration"
|
"description": "Allow members to manage, use and interact with any integration"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"media": {
|
||||||
|
"title": "Medias",
|
||||||
|
"item": {
|
||||||
|
"upload": {
|
||||||
|
"label": "Upload medias",
|
||||||
|
"description": "Allow members to upload medias"
|
||||||
|
},
|
||||||
|
"view-all": {
|
||||||
|
"label": "View all medias",
|
||||||
|
"description": "Allow members to view all medias"
|
||||||
|
},
|
||||||
|
"full-all": {
|
||||||
|
"label": "Full media access",
|
||||||
|
"description": "Allow members to manage and delete any media"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "Other",
|
||||||
|
"item": {
|
||||||
|
"view-logs": {
|
||||||
|
"label": "View logs",
|
||||||
|
"description": "Allow members to view logs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"search-engine": {
|
||||||
|
"title": "Search engines",
|
||||||
|
"item": {
|
||||||
|
"create": {
|
||||||
|
"label": "Create search engines",
|
||||||
|
"description": "Allow members to create search engines"
|
||||||
|
},
|
||||||
|
"modify-all": {
|
||||||
|
"label": "Modify all search engines",
|
||||||
|
"description": "Allow members to modify all search engines"
|
||||||
|
},
|
||||||
|
"full-all": {
|
||||||
|
"label": "Full search engine access",
|
||||||
|
"description": "Allow members to manage and delete any search engine"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"memberNotice": {
|
"memberNotice": {
|
||||||
|
|||||||
Reference in New Issue
Block a user