Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b218910476 | ||
|
|
cf0e469465 |
No files matched your search
@@ -1,29 +0,0 @@
|
|||||||
import type { QueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
|
|
||||||
|
|
||||||
export const createQueryCacheErrorCleanup = (queryClient: QueryClient): (() => void) => {
|
|
||||||
const wasSuccessful = new Set<string>();
|
|
||||||
|
|
||||||
return queryClient.getQueryCache().subscribe((event) => {
|
|
||||||
const query = event.query;
|
|
||||||
const key = query.queryHash;
|
|
||||||
|
|
||||||
if (event.type === "removed") {
|
|
||||||
wasSuccessful.delete(key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type !== "updated") return;
|
|
||||||
|
|
||||||
if (query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey)) {
|
|
||||||
wasSuccessful.add(key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.state.status === "error" && wasSuccessful.has(key)) {
|
|
||||||
queryClient.resetQueries({ queryKey: query.queryKey, exact: true });
|
|
||||||
wasSuccessful.delete(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import type { QueryKey } from "@tanstack/react-query";
|
import type { QueryKey } from "@tanstack/react-query";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
@@ -36,7 +36,6 @@ import { env } from "@homarr/common/env";
|
|||||||
import { showWarningNotification } from "@homarr/notifications";
|
import { showWarningNotification } from "@homarr/notifications";
|
||||||
import { widgetImports } from "@homarr/widgets";
|
import { widgetImports } from "@homarr/widgets";
|
||||||
|
|
||||||
import { createQueryCacheErrorCleanup } from "./query-cache-error-cleanup";
|
|
||||||
import { createWidgetQueryPersister } from "./query-cache-persister";
|
import { createWidgetQueryPersister } from "./query-cache-persister";
|
||||||
|
|
||||||
const getWebSocketProtocol = () => {
|
const getWebSocketProtocol = () => {
|
||||||
@@ -103,10 +102,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
return client;
|
return client;
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return createQueryCacheErrorCleanup(queryClient);
|
|
||||||
}, [queryClient]);
|
|
||||||
|
|
||||||
const [trpcClient] = useState(() => {
|
const [trpcClient] = useState(() => {
|
||||||
return clientApi.createClient({
|
return clientApi.createClient({
|
||||||
links: [
|
links: [
|
||||||
@@ -167,9 +162,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
|
query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
onSuccess={() => {
|
|
||||||
queryClient.invalidateQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
|
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ export default async function AppEditPage(props: AppEditPageProps) {
|
|||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user.permissions.includes("app-modify-all")) {
|
if (
|
||||||
|
!session?.user.permissions.includes("board-modify-all") ||
|
||||||
|
!session?.user.permissions.includes("app-modify-all")
|
||||||
|
) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
const app = await api.app.byId({ id: params.id });
|
const app = await api.app.byId({ id: params.id });
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
|||||||
export default async function AppNewPage() {
|
export default async function AppNewPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user.permissions.includes("app-create")) {
|
if (!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create")) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
|
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
|
||||||
import { IconBox, IconPencil } from "@tabler/icons-react";
|
import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
@@ -33,6 +33,9 @@ export default async function AppsPage(props: AppsPageProps) {
|
|||||||
if (!session) {
|
if (!session) {
|
||||||
redirect("/auth/login");
|
redirect("/auth/login");
|
||||||
}
|
}
|
||||||
|
if (!session.user.permissions.includes("board-modify-all")) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ 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,
|
hidden: !session?.user.permissions.includes("board-modify-all"),
|
||||||
iconProps: {
|
iconProps: {
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { useItemActions } from "./item-actions";
|
|||||||
import itemContentClasses from "./item-content.module.css";
|
import itemContentClasses from "./item-content.module.css";
|
||||||
import { BoardItemMenu } from "./item-menu";
|
import { BoardItemMenu } from "./item-menu";
|
||||||
import { WidgetContextMenu } from "./widget-context-menu";
|
import { WidgetContextMenu } from "./widget-context-menu";
|
||||||
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
|
||||||
|
|
||||||
interface BoardItemContentProps {
|
interface BoardItemContentProps {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
@@ -113,7 +112,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
|||||||
{({ reset }) => (
|
{({ reset }) => (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
onReset={() => {
|
onReset={() => {
|
||||||
removePersistedWidgetQueries(queryClient);
|
void queryClient.invalidateQueries({ queryKey: [["widget"]] });
|
||||||
reset();
|
reset();
|
||||||
}}
|
}}
|
||||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
import { QueryClient } from "@tanstack/react-query";
|
|
||||||
import { describe, expect, test } from "vitest";
|
|
||||||
|
|
||||||
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
|
|
||||||
|
|
||||||
import { createQueryCacheErrorCleanup } from "~/app/[locale]/_client-providers/query-cache-error-cleanup";
|
|
||||||
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
|
||||||
|
|
||||||
describe("removePersistedWidgetQueries", () => {
|
|
||||||
test("removes stale widget queries and allows fresh data to be fetched", async () => {
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
|
|
||||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
|
||||||
|
|
||||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
|
||||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
|
||||||
|
|
||||||
removePersistedWidgetQueries(queryClient);
|
|
||||||
|
|
||||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
|
||||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
|
||||||
await expect(
|
|
||||||
queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }),
|
|
||||||
).resolves.toBe("fresh");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("removes persisted queries outside the widget router", () => {
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
const dockerQueryKey = [["docker", "getContainers"], { input: {} }];
|
|
||||||
const appQueryKey = [["app", "byId"], { input: {} }];
|
|
||||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
|
||||||
|
|
||||||
queryClient.setQueryData(dockerQueryKey, "stale");
|
|
||||||
queryClient.setQueryData(appQueryKey, "stale");
|
|
||||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
|
||||||
|
|
||||||
removePersistedWidgetQueries(queryClient);
|
|
||||||
|
|
||||||
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
|
|
||||||
expect(queryClient.getQueryData(appQueryKey)).toBeUndefined();
|
|
||||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("removes persisted widget queries when the widget kind and query path differ", () => {
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }];
|
|
||||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
|
||||||
|
|
||||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
|
||||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
|
||||||
|
|
||||||
removePersistedWidgetQueries(queryClient);
|
|
||||||
|
|
||||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
|
||||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("query cache error cleanup", () => {
|
|
||||||
test("resets data when a persistable query transitions from success to error", async () => {
|
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
||||||
const unsubscribe = createQueryCacheErrorCleanup(queryClient);
|
|
||||||
|
|
||||||
const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
|
|
||||||
const userKey = [["user", "me"], { input: {} }];
|
|
||||||
|
|
||||||
queryClient.setQueryData(widgetKey, { old: true });
|
|
||||||
queryClient.setQueryData(userKey, { user: true });
|
|
||||||
|
|
||||||
await queryClient
|
|
||||||
.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("API down")) })
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
expect(queryClient.getQueryData(widgetKey)).toBeUndefined();
|
|
||||||
expect(queryClient.getQueryData(userKey)).toEqual({ user: true });
|
|
||||||
|
|
||||||
unsubscribe();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("preserves stale data for non-persistable query errors", async () => {
|
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
||||||
const unsubscribe = createQueryCacheErrorCleanup(queryClient);
|
|
||||||
|
|
||||||
const boardKey = [["board", "getBoardByName"], { input: {} }];
|
|
||||||
|
|
||||||
queryClient.setQueryData(boardKey, { boards: [] });
|
|
||||||
|
|
||||||
await queryClient
|
|
||||||
.fetchQuery({ queryKey: boardKey, queryFn: () => Promise.reject(new Error("API down")) })
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
expect(queryClient.getQueryData(boardKey)).toEqual({ boards: [] });
|
|
||||||
|
|
||||||
unsubscribe();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles success→error→success→error cycling without getting stuck", async () => {
|
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
||||||
const unsubscribe = createQueryCacheErrorCleanup(queryClient);
|
|
||||||
|
|
||||||
const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
|
|
||||||
|
|
||||||
queryClient.setQueryData(widgetKey, "first-success");
|
|
||||||
await queryClient.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.resolve("second-success") });
|
|
||||||
expect(queryClient.getQueryData(widgetKey)).toBe("second-success");
|
|
||||||
|
|
||||||
await queryClient
|
|
||||||
.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("boom")) })
|
|
||||||
.catch(() => {});
|
|
||||||
expect(queryClient.getQueryData(widgetKey)).toBeUndefined();
|
|
||||||
|
|
||||||
await queryClient.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.resolve("third-success") });
|
|
||||||
expect(queryClient.getQueryData(widgetKey)).toBe("third-success");
|
|
||||||
|
|
||||||
await queryClient
|
|
||||||
.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("boom-2")) })
|
|
||||||
.catch(() => {});
|
|
||||||
expect(queryClient.getQueryData(widgetKey)).toBeUndefined();
|
|
||||||
|
|
||||||
unsubscribe();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("unsubscribe stops processing further events", async () => {
|
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
||||||
const unsubscribe = createQueryCacheErrorCleanup(queryClient);
|
|
||||||
|
|
||||||
const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
|
|
||||||
queryClient.setQueryData(widgetKey, "stale");
|
|
||||||
|
|
||||||
unsubscribe();
|
|
||||||
|
|
||||||
await queryClient
|
|
||||||
.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("API down")) })
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
expect(queryClient.getQueryData(widgetKey)).toBe("stale");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("rehydration marks persistable data as stale", () => {
|
|
||||||
test("rehydrated queries are invalidated so they refetch on mount", async () => {
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: { queries: { retry: false, staleTime: 0 } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
|
|
||||||
const userKey = [["user", "me"], { input: {} }];
|
|
||||||
|
|
||||||
queryClient.setQueryData(widgetKey, { rehydrated: true });
|
|
||||||
queryClient.setQueryData(userKey, { rehydrated: true });
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
|
|
||||||
|
|
||||||
expect(queryClient.getQueryState(widgetKey)?.isInvalidated).toBe(true);
|
|
||||||
expect(queryClient.getQueryState(userKey)?.isInvalidated).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { QueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
|
|
||||||
|
|
||||||
export const removePersistedWidgetQueries = (queryClient: QueryClient) => {
|
|
||||||
queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
|
|
||||||
};
|
|
||||||
@@ -18,7 +18,8 @@ import { AppAccessControl } from "./app/app-access-control";
|
|||||||
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
|
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
getPaginated: protectedProcedure
|
getPaginated: permissionRequiredProcedure
|
||||||
|
.requiresPermission("board-modify-all")
|
||||||
.input(paginatedSchema)
|
.input(paginatedSchema)
|
||||||
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
|
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
|
||||||
.meta({
|
.meta({
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export const homeRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.session?.user) {
|
if (ctx.session?.user.permissions.includes("board-modify-all")) {
|
||||||
statistics.push({
|
statistics.push({
|
||||||
titleKey: "app",
|
titleKey: "app",
|
||||||
subtitleKey: "resources",
|
subtitleKey: "resources",
|
||||||
|
|||||||
@@ -76,6 +76,33 @@ describe("all should return all apps", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getPaginated should require board modification access", () => {
|
||||||
|
test("should throw FORBIDDEN without board-modify-all", async () => {
|
||||||
|
const caller = appRouter.createCaller({
|
||||||
|
db: createDb(),
|
||||||
|
deviceType: undefined,
|
||||||
|
session: createDefaultSession(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("Permission denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return apps with board-modify-all", async () => {
|
||||||
|
const db = createDb();
|
||||||
|
const caller = appRouter.createCaller({
|
||||||
|
db,
|
||||||
|
deviceType: undefined,
|
||||||
|
session: createDefaultSession(["board-modify-all"]),
|
||||||
|
});
|
||||||
|
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
|
||||||
|
|
||||||
|
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).resolves.toMatchObject({
|
||||||
|
totalCount: 1,
|
||||||
|
items: [{ id: "1" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("byId should return an app by id", () => {
|
describe("byId should return an app by id", () => {
|
||||||
test("should return an app by id when canUserSeeAppAsync returns true", async () => {
|
test("should return an app by id when canUserSeeAppAsync returns true", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -16,4 +16,6 @@ export const mediaRequestStatsRequestHandler = createIntegrationRequestHandler<
|
|||||||
users: await integrationInstance.getUsersAsync(),
|
users: await integrationInstance.getUsersAsync(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
cacheTtlMs: 60_000,
|
||||||
|
fallbackToStaleOnError: true,
|
||||||
});
|
});
|
||||||
@@ -17,6 +17,10 @@ export const appIntegrationBoardMode = {
|
|||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
return groups.concat([appsSearchGroup, integrationsSearchGroup]);
|
return groups.concat(
|
||||||
|
session.user.permissions.includes("board-modify-all")
|
||||||
|
? [appsSearchGroup, integrationsSearchGroup]
|
||||||
|
: [integrationsSearchGroup],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
} satisfies SearchMode;
|
} satisfies SearchMode;
|
||||||
@@ -87,7 +87,8 @@ export const globalCommandGroup = createGroup<Command>({
|
|||||||
icon: IconBox,
|
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"),
|
hidden:
|
||||||
|
!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
commandKey: "newIntegration",
|
commandKey: "newIntegration",
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const pagesSearchGroup = createGroup<{
|
|||||||
icon: IconBox,
|
icon: IconBox,
|
||||||
path: "/manage/apps",
|
path: "/manage/apps",
|
||||||
name: t("manageApp.label"),
|
name: t("manageApp.label"),
|
||||||
hidden: !session,
|
hidden: !session?.user.permissions.includes("board-modify-all"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconPlug,
|
icon: IconPlug,
|
||||||
|
|||||||
Reference in new issue
Block a user