feat: recalculate permission scope & permission matrix UI (#6612)
This commit is contained in:
1 parent
78b3dc1922
commit
ff14613f95
24 files changed
+1635
-314
No files matched your search
@@ -0,0 +1,35 @@
|
||||
import type { Session } from "@homarr/auth";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getAppManagementAccess, getIntegrationManagementAccess } from "@homarr/definitions";
|
||||
|
||||
/**
|
||||
* Session-aware wrappers around the shared management access rules in `@homarr/definitions`, so the
|
||||
* navigation and the pages resolve access exactly the same way the API does.
|
||||
*/
|
||||
|
||||
export const getAppsSectionAccess = (session: Session | null) =>
|
||||
getAppManagementAccess(session?.user.permissions ?? []);
|
||||
|
||||
/**
|
||||
* Pure variant for callers that already fetched the integrations the user can see, so the list is
|
||||
* only queried once per request.
|
||||
*/
|
||||
export const getIntegrationsSectionAccess = (
|
||||
session: Session | null,
|
||||
visibleIntegrations: { permissions: { hasFullAccess: boolean } }[],
|
||||
) =>
|
||||
getIntegrationManagementAccess(
|
||||
session?.user.permissions ?? [],
|
||||
visibleIntegrations.some((integration) => integration.permissions.hasFullAccess),
|
||||
);
|
||||
|
||||
export const getIntegrationsSectionAccessAsync = async (session: Session | null) => {
|
||||
const permissions = session?.user.permissions ?? [];
|
||||
|
||||
// Only pay for the lookup when the answer is not already known from the global permissions.
|
||||
const needsDelegatedLookup =
|
||||
session !== null && !permissions.includes("integration-full-all") && !permissions.includes("integration-create");
|
||||
|
||||
return getIntegrationsSectionAccess(session, needsDelegatedLookup ? await api.integration.all() : []);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Fragment } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Text } from "@mantine/core";
|
||||
import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
@@ -16,6 +16,7 @@ import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { getAppsSectionAccess } from "../_access";
|
||||
import { AppDeleteButton } from "./_app-delete-button";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
@@ -41,16 +42,27 @@ interface AppsPageProps {
|
||||
export default async function AppsPage(props: AppsPageProps) {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/auth/login");
|
||||
const { canManageAll, canCreate, canAccess } = getAppsSectionAccess(session);
|
||||
if (!canAccess) notFound();
|
||||
const canDelete = session.user.permissions.includes("app-full-all");
|
||||
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
|
||||
// Without app-modify-all the user may add apps but not browse the ones they cannot manage,
|
||||
// so the list stays empty instead of leaking every app name and URL.
|
||||
const { items: apps, totalCount } = canManageAll
|
||||
? await api.app.getPaginated(searchParams)
|
||||
: { items: [], totalCount: 0 };
|
||||
const t = await getScopedI18n("app");
|
||||
const canCreate = session.user.permissions.includes("app-create");
|
||||
const canModify = session.user.permissions.includes("app-modify-all");
|
||||
const canDelete = session.user.permissions.includes("app-full-all");
|
||||
const hasSearch = Boolean(searchParams.search?.trim());
|
||||
const hasSearch = canManageAll && Boolean(searchParams.search?.trim());
|
||||
|
||||
const emptyState = hasSearch ? (
|
||||
const emptyState = !canManageAll ? (
|
||||
<NoResults
|
||||
icon={IconBox}
|
||||
title={t("page.list.noResults.createOnlyTitle")}
|
||||
description={t("page.list.noResults.createOnlyDescription")}
|
||||
action={{ label: t("page.list.noResults.action"), href: "/manage/apps/new" }}
|
||||
/>
|
||||
) : hasSearch ? (
|
||||
<NoResults
|
||||
icon={IconBox}
|
||||
title={t("page.list.noResults.filteredTitle")}
|
||||
@@ -82,12 +94,14 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
) : undefined
|
||||
}
|
||||
toolbar={
|
||||
<SearchInput
|
||||
placeholder={`${t("search")}...`}
|
||||
ariaLabel={t("search")}
|
||||
defaultValue={searchParams.search}
|
||||
flexExpand
|
||||
/>
|
||||
canManageAll ? (
|
||||
<SearchInput
|
||||
placeholder={`${t("search")}...`}
|
||||
ariaLabel={t("search")}
|
||||
defaultValue={searchParams.search}
|
||||
flexExpand
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
footer={
|
||||
totalCount > searchParams.pageSize ? (
|
||||
@@ -100,7 +114,6 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
<AppItem
|
||||
key={app.id}
|
||||
app={app}
|
||||
canModify={canModify}
|
||||
canDelete={canDelete}
|
||||
editLabel={t("page.list.action.edit", { name: app.name })}
|
||||
/>
|
||||
@@ -113,12 +126,11 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
|
||||
interface AppItemProps {
|
||||
app: RouterOutputs["app"]["getPaginated"]["items"][number];
|
||||
canModify: boolean;
|
||||
canDelete: boolean;
|
||||
editLabel: string;
|
||||
}
|
||||
|
||||
const AppItem = ({ app, canModify, canDelete, editLabel }: AppItemProps) => {
|
||||
const AppItem = ({ app, canDelete, editLabel }: AppItemProps) => {
|
||||
const descriptionLines = app.description?.split("\n");
|
||||
const safeHref = getSafeAppHref(app.href);
|
||||
|
||||
@@ -157,23 +169,19 @@ const AppItem = ({ app, canModify, canDelete, editLabel }: AppItemProps) => {
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
canModify || canDelete ? (
|
||||
<ActionIconGroup>
|
||||
{canModify && (
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/apps/edit/${app.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={editLabel}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{canDelete && <AppDeleteButton app={app} />}
|
||||
</ActionIconGroup>
|
||||
) : undefined
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/apps/edit/${app.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={editLabel}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
{canDelete && <AppDeleteButton app={app} />}
|
||||
</ActionIconGroup>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Badge, Text } from "@mantine/core";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Badge, Stack, Text } from "@mantine/core";
|
||||
import { IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -15,6 +15,7 @@ import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { getIntegrationsSectionAccess } from "../_access";
|
||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||
|
||||
const searchParamsSchema = z.object({ search: z.string().optional() });
|
||||
@@ -29,11 +30,22 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const integrations = await api.integration.all();
|
||||
const {
|
||||
canManageAll: hasGlobalFullAccess,
|
||||
canCreate,
|
||||
canAccess,
|
||||
} = getIntegrationsSectionAccess(session, integrations);
|
||||
if (!canAccess) notFound();
|
||||
|
||||
const t = await getScopedI18n("integration");
|
||||
const canCreate = session.user.permissions.includes("integration-create");
|
||||
const hasGlobalFullAccess = session.user.permissions.includes("integration-full-all");
|
||||
// Without integration-full-all only the integrations that were explicitly delegated to the user
|
||||
// are manageable, so the list never shows an integration they cannot open.
|
||||
const manageableIntegrations = integrations.filter(
|
||||
(integration) => hasGlobalFullAccess || integration.permissions.hasFullAccess,
|
||||
);
|
||||
|
||||
const query = searchParams.search?.trim().toLocaleLowerCase() ?? "";
|
||||
const filteredIntegrations = integrations
|
||||
const filteredIntegrations = manageableIntegrations
|
||||
.filter((integration) => {
|
||||
if (!query) return true;
|
||||
return [integration.name, getIntegrationName(integration.kind), integration.url].some((value) =>
|
||||
@@ -53,7 +65,14 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
ariaLabel={t("page.list.ariaLabel")}
|
||||
itemCount={filteredIntegrations.length}
|
||||
emptyState={
|
||||
hasSearch ? (
|
||||
manageableIntegrations.length === 0 && !hasGlobalFullAccess ? (
|
||||
<NoResults
|
||||
icon={IconPlugX}
|
||||
title={t("page.list.noResults.createOnlyTitle")}
|
||||
description={t("page.list.noResults.createOnlyDescription")}
|
||||
action={{ label: t("page.list.noResults.action"), href: "/manage/integrations/new" }}
|
||||
/>
|
||||
) : hasSearch ? (
|
||||
<NoResults
|
||||
icon={IconPlugX}
|
||||
title={t("page.list.noResults.filteredTitle")}
|
||||
@@ -93,24 +112,33 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
floatingPrimaryAction={canCreate}
|
||||
>
|
||||
{filteredIntegrations.map((integration) => (
|
||||
<IntegrationItem
|
||||
key={integration.id}
|
||||
integration={integration}
|
||||
canManage={hasGlobalFullAccess || integration.permissions.hasFullAccess}
|
||||
/>
|
||||
<IntegrationItem key={integration.id} integration={integration} />
|
||||
))}
|
||||
</ManageCollectionPage>
|
||||
);
|
||||
|
||||
return filteredIntegrations.length > 0 ? <TourTarget id="manage-integrations-list">{page}</TourTarget> : page;
|
||||
const pageContent =
|
||||
filteredIntegrations.length > 0 ? <TourTarget id="manage-integrations-list">{page}</TourTarget> : page;
|
||||
|
||||
if (hasGlobalFullAccess) {
|
||||
return pageContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("page.list.scopedNote")}
|
||||
</Text>
|
||||
{pageContent}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface IntegrationItemProps {
|
||||
integration: RouterOutputs["integration"]["all"][number];
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
const IntegrationItem = async ({ integration, canManage }: IntegrationItemProps) => {
|
||||
const IntegrationItem = async ({ integration }: IntegrationItemProps) => {
|
||||
const t = await getScopedI18n("integration");
|
||||
const kindName = getIntegrationName(integration.kind);
|
||||
const safeUrl = getSafeApplicationUrl(integration.url);
|
||||
@@ -143,21 +171,19 @@ const IntegrationItem = async ({ integration, canManage }: IntegrationItemProps)
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
canManage ? (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={t("page.list.action.edit", { name: integration.name })}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} />
|
||||
</ActionIconGroup>
|
||||
) : undefined
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={44}
|
||||
aria-label={t("page.list.action.edit", { name: integration.name })}
|
||||
>
|
||||
<IconPencil size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} />
|
||||
</ActionIconGroup>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -44,6 +44,7 @@ import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { ManageTourGate } from "~/components/onboarding/manage-tour-gate";
|
||||
import { env as nextEnv } from "~/env";
|
||||
import { getAppsSectionAccess, getIntegrationsSectionAccessAsync } from "./_access";
|
||||
|
||||
const logger = createLogger({ module: "manageLayout" });
|
||||
|
||||
@@ -65,6 +66,8 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
sessionPromise,
|
||||
shouldRunManageTourPromise,
|
||||
]);
|
||||
const appsAccess = getAppsSectionAccess(session);
|
||||
const integrationsAccess = await getIntegrationsSectionAccessAsync(session);
|
||||
const navigationLinks: NavigationLink[] = [
|
||||
{
|
||||
label: t("items.home"),
|
||||
@@ -82,7 +85,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconBox,
|
||||
href: "/manage/apps",
|
||||
label: t("items.apps"),
|
||||
hidden: !session?.user.permissions.includes("app-create"),
|
||||
hidden: !appsAccess.canAccess,
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
@@ -92,7 +95,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconAffiliateFilled,
|
||||
href: "/manage/integrations",
|
||||
label: t("items.integrations"),
|
||||
hidden: !session?.user.permissions.includes("integration-create"),
|
||||
hidden: !integrationsAccess.canAccess,
|
||||
"data-onboarding-tour-id": "manage-integrations",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Center, Stack, Text, Title } from "@mantine/core";
|
||||
import { Anchor, Center, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
export default async function NotFound() {
|
||||
const t = await getScopedI18n("management.notFound");
|
||||
@@ -9,6 +10,9 @@ export default async function NotFound() {
|
||||
<Stack align="center">
|
||||
<Title order={1}>{t("title")}</Title>
|
||||
<Text>{t("text")}</Text>
|
||||
<Anchor component={Link} href="/manage">
|
||||
{t("backToHome")}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,12 @@ export default async function ManagementPage() {
|
||||
<Space h="md" />
|
||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||
{statistics.map((statistic) => (
|
||||
<Card component={Link} href={statistic.path} key={statistic.path}>
|
||||
<Card
|
||||
component={Link}
|
||||
href={statistic.path}
|
||||
key={statistic.path}
|
||||
aria-label={`${statistic.count} ${t(`statistic.${statistic.titleKey}`)}`}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Text size="xl" fw={700}>
|
||||
@@ -42,7 +47,7 @@ export default async function ManagementPage() {
|
||||
<Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />
|
||||
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" aria-hidden="true" />
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
+357
-25
@@ -1,17 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { Button, Card, Group, Switch, Text, Transition } from "@mantine/core";
|
||||
import { useCallback, useId } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
Divider,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
Transition,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconShieldExclamation } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { groupPermissionKeys } from "@homarr/definitions";
|
||||
import { objectEntries, objectKeys } from "@homarr/common";
|
||||
import type { GroupPermissionKey, PermissionMatrixCategory, PermissionMatrixState } from "@homarr/definitions";
|
||||
import {
|
||||
getPermissionsWithChildren,
|
||||
groupPermissionKeys,
|
||||
isCapabilityImpliedByLevel,
|
||||
matrixStateToPermissions,
|
||||
permissionMatrix,
|
||||
permissionsToMatrixState,
|
||||
} from "@homarr/definitions";
|
||||
import { createFormContext } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export type PermissionLabels = Partial<Record<GroupPermissionKey, string>>;
|
||||
|
||||
const [FormProvider, useFormContext, useForm] = createFormContext<FormType>();
|
||||
|
||||
interface PermissionFormProps {
|
||||
@@ -19,27 +45,17 @@ interface PermissionFormProps {
|
||||
}
|
||||
|
||||
export const PermissionForm = ({ children, initialPermissions }: PropsWithChildren<PermissionFormProps>) => {
|
||||
// Mantine compares each field against the values snapshot, so toggling a control back to its
|
||||
// saved value clears the dirty state on its own - no manual bookkeeping needed here.
|
||||
const form = useForm({
|
||||
initialValues: groupPermissionKeys.reduce((acc, key) => {
|
||||
acc[key] = initialPermissions.includes(key);
|
||||
return acc;
|
||||
}, {} as FormType),
|
||||
onValuesChange(values) {
|
||||
const currentKeys = objectEntries(values)
|
||||
.filter(([_key, value]) => Boolean(value))
|
||||
.map(([key]) => key);
|
||||
|
||||
if (
|
||||
currentKeys.every((key) => initialPermissions.includes(key)) &&
|
||||
initialPermissions.every((key) => currentKeys.includes(key))
|
||||
) {
|
||||
form.resetDirty(); // Reset dirty state if all keys are the same as initial
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form>
|
||||
<form onSubmit={(event) => event.preventDefault()}>
|
||||
<FormProvider form={form}>{children}</FormProvider>
|
||||
</form>
|
||||
);
|
||||
@@ -47,15 +63,329 @@ export const PermissionForm = ({ children, initialPermissions }: PropsWithChildr
|
||||
|
||||
type FormType = Record<GroupPermissionKey, boolean>;
|
||||
|
||||
export const PermissionSwitch = ({ name }: { name: GroupPermissionKey }) => {
|
||||
const form = useFormContext();
|
||||
type MatrixForm = ReturnType<typeof useFormContext>;
|
||||
|
||||
const props = form.getInputProps(name, {
|
||||
withError: false,
|
||||
type: "checkbox",
|
||||
const emptyMatrixState = (): PermissionMatrixState =>
|
||||
objectKeys(permissionMatrix).reduce((acc, category) => {
|
||||
acc[category] = { level: 0, capabilities: [] };
|
||||
return acc;
|
||||
}, {} as PermissionMatrixState);
|
||||
|
||||
const matrixStateOf = (values: FormType) => permissionsToMatrixState(groupPermissionKeys.filter((key) => values[key]));
|
||||
|
||||
export const getMatrixLevel = (values: FormType, category: PermissionMatrixCategory): number =>
|
||||
matrixStateOf(values)[category].level;
|
||||
|
||||
export const hasMatrixCapability = (
|
||||
values: FormType,
|
||||
category: PermissionMatrixCategory,
|
||||
capability: GroupPermissionKey,
|
||||
): boolean => matrixStateOf(values)[category].capabilities.includes(capability);
|
||||
|
||||
export const applyMatrixLevel = (form: MatrixForm, category: PermissionMatrixCategory, level: number) => {
|
||||
const targetKeys = new Set(
|
||||
matrixStateToPermissions({ ...emptyMatrixState(), [category]: { level, capabilities: [] } }),
|
||||
);
|
||||
permissionMatrix[category].levels.forEach((key) => {
|
||||
form.setFieldValue(key, targetKeys.has(key));
|
||||
});
|
||||
// Capability keys are deliberately left untouched: a level that implies one already covers it,
|
||||
// and an explicitly granted capability must survive lowering the level again.
|
||||
};
|
||||
|
||||
return <Switch {...props} />;
|
||||
export const applyMatrixCapability = (form: MatrixForm, capability: GroupPermissionKey, granted: boolean) => {
|
||||
form.setFieldValue(capability, granted);
|
||||
};
|
||||
|
||||
const presets = {
|
||||
viewer: {
|
||||
board: { level: 1, capabilities: [] },
|
||||
app: { level: 0, capabilities: ["app-use-all"] },
|
||||
integration: { level: 1, capabilities: [] },
|
||||
media: { level: 1, capabilities: [] },
|
||||
},
|
||||
editor: {
|
||||
board: { level: 2, capabilities: ["board-create"] },
|
||||
app: { level: 1, capabilities: ["app-use-all"] },
|
||||
integration: { level: 2, capabilities: ["integration-create"] },
|
||||
"search-engine": { level: 1, capabilities: [] },
|
||||
media: { level: 1, capabilities: ["media-upload"] },
|
||||
},
|
||||
admin: { admin: { level: 1, capabilities: [] } },
|
||||
} satisfies Record<string, Partial<PermissionMatrixState>>;
|
||||
|
||||
export type PermissionPreset = keyof typeof presets;
|
||||
|
||||
const presetToFormState = (preset: PermissionPreset): FormType => {
|
||||
const presetState: Partial<PermissionMatrixState> = presets[preset];
|
||||
const state = objectKeys(permissionMatrix).reduce((acc, category) => {
|
||||
acc[category] = presetState[category] ?? { level: 0, capabilities: [] };
|
||||
return acc;
|
||||
}, {} as PermissionMatrixState);
|
||||
const enabledKeys = new Set(matrixStateToPermissions(state));
|
||||
|
||||
return groupPermissionKeys.reduce((acc, key) => {
|
||||
acc[key] = enabledKeys.has(key);
|
||||
return acc;
|
||||
}, {} as FormType);
|
||||
};
|
||||
|
||||
export const applyPreset = (form: MatrixForm, preset: PermissionPreset) => {
|
||||
form.setValues(presetToFormState(preset));
|
||||
};
|
||||
|
||||
export const getActivePreset = (values: FormType): PermissionPreset | null => {
|
||||
return (
|
||||
objectKeys(presets).find((preset) => {
|
||||
const presetValues = presetToFormState(preset);
|
||||
return groupPermissionKeys.every((key) => presetValues[key] === values[key]);
|
||||
}) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Short, scannable label for a level. Categories with a single level use their permission label
|
||||
* instead, so the control reads "No access | View logs" rather than "No access | Enabled".
|
||||
*/
|
||||
const levelLabelKey = (key: GroupPermissionKey) => {
|
||||
if (key.endsWith("-view-all")) return "view" as const;
|
||||
if (key.endsWith("-use-all")) return "use" as const;
|
||||
if (key.endsWith("-interact-all")) return "interact" as const;
|
||||
if (key.endsWith("-modify-all")) return "modify" as const;
|
||||
if (key.endsWith("-full-all")) return "full" as const;
|
||||
return null;
|
||||
};
|
||||
|
||||
interface MatrixImpliedHintProps {
|
||||
category: PermissionMatrixCategory;
|
||||
permissionLabels: PermissionLabels;
|
||||
}
|
||||
|
||||
export const MatrixImpliedHint = ({ category, permissionLabels }: MatrixImpliedHintProps) => {
|
||||
const form = useFormContext();
|
||||
const tPermissions = useScopedI18n("group.permission");
|
||||
|
||||
const level = getMatrixLevel(form.getValues(), category);
|
||||
if (level === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const checkedKeys: GroupPermissionKey[] = permissionMatrix[category].levels.slice(0, level);
|
||||
const implied = getPermissionsWithChildren(checkedKeys).filter((key) => !checkedKeys.includes(key));
|
||||
if (implied.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labels = implied.map((key) => permissionLabels[key] ?? key);
|
||||
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
{tPermissions("matrix.impliedHint", { permissions: labels.join(", ") })}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
interface MatrixRowProps {
|
||||
category: PermissionMatrixCategory;
|
||||
permissionLabels: PermissionLabels;
|
||||
permissionDescriptions: PermissionLabels;
|
||||
}
|
||||
|
||||
export const MatrixRow = ({ category, permissionLabels, permissionDescriptions }: MatrixRowProps) => {
|
||||
const form = useFormContext();
|
||||
const tPermissions = useScopedI18n("group.permission");
|
||||
|
||||
const values = form.getValues();
|
||||
const level = getMatrixLevel(values, category);
|
||||
const isDanger = category === "admin";
|
||||
const disabledByAdmin = !isDanger && getMatrixLevel(values, "admin") > 0;
|
||||
const { levels, capabilities } = permissionMatrix[category];
|
||||
const hasSingleLevel = levels.length === 1;
|
||||
|
||||
const data = [
|
||||
{ value: "0", label: tPermissions("matrix.none") },
|
||||
...levels.map((key, index) => {
|
||||
const shortKey = hasSingleLevel ? null : levelLabelKey(key);
|
||||
return {
|
||||
value: String(index + 1),
|
||||
label: shortKey === null ? (permissionLabels[key] ?? key) : tPermissions(`matrix.level.${shortKey}`),
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
const capabilityStates = capabilities.map((capability) => ({
|
||||
capability,
|
||||
checked: hasMatrixCapability(values, category, capability),
|
||||
implied: isCapabilityImpliedByLevel(category, capability, level),
|
||||
}));
|
||||
|
||||
const selectedKey = level === 0 ? undefined : levels[level - 1];
|
||||
// With no access level but a capability granted, describing the row as "no access" would
|
||||
// contradict the checkbox next to it, so the granted capability describes the row instead.
|
||||
const describedKey = selectedKey ?? capabilityStates.find(({ checked }) => checked)?.capability;
|
||||
const description =
|
||||
describedKey === undefined ? tPermissions("matrix.noneDescription") : (permissionDescriptions[describedKey] ?? "");
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Group gap="sm" wrap="wrap" align="center" justify="space-between">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{isDanger && <IconShieldExclamation size={18} color="var(--mantine-color-red-8)" />}
|
||||
<Text fw={500} c={isDanger ? "red.8" : undefined}>
|
||||
{tPermissions(`${category}.title`)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="md" wrap="wrap" align="center">
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
data={data}
|
||||
value={String(level)}
|
||||
disabled={disabledByAdmin}
|
||||
aria-label={tPermissions(`${category}.title`)}
|
||||
onChange={(value) => applyMatrixLevel(form, category, Number(value))}
|
||||
/>
|
||||
{/* Fixed-width column so every level control lines up, whatever capabilities a row has. */}
|
||||
<Box w={{ base: "auto", sm: 230 }}>
|
||||
<Stack gap={4}>
|
||||
{capabilityStates.map(({ capability, checked, implied }) => (
|
||||
<Tooltip
|
||||
key={capability}
|
||||
label={tPermissions("matrix.capabilityIncluded", { level: data[level]?.label ?? "" })}
|
||||
disabled={!implied}
|
||||
>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
label={permissionLabels[capability] ?? capability}
|
||||
checked={checked}
|
||||
disabled={disabledByAdmin || implied}
|
||||
onChange={(event) => applyMatrixCapability(form, capability, event.currentTarget.checked)}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Group>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
{disabledByAdmin && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{tPermissions("matrix.grantedViaAdmin")}
|
||||
</Text>
|
||||
)}
|
||||
<MatrixImpliedHint category={category} permissionLabels={permissionLabels} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface PermissionMatrixProps {
|
||||
permissionLabels: PermissionLabels;
|
||||
permissionDescriptions: PermissionLabels;
|
||||
}
|
||||
|
||||
export const PermissionMatrix = ({ permissionLabels, permissionDescriptions }: PermissionMatrixProps) => (
|
||||
<Card withBorder p="md">
|
||||
<Stack gap="md">
|
||||
{objectKeys(permissionMatrix).map((category, index) => (
|
||||
<Stack key={category} gap="md">
|
||||
{index > 0 && <Divider />}
|
||||
<MatrixRow
|
||||
category={category}
|
||||
permissionLabels={permissionLabels}
|
||||
permissionDescriptions={permissionDescriptions}
|
||||
/>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export const PresetButtons = () => {
|
||||
const form = useFormContext();
|
||||
const tPermissions = useScopedI18n("group.permission");
|
||||
const activePreset = getActivePreset(form.getValues());
|
||||
|
||||
return (
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Text size="sm" fw={500}>
|
||||
{tPermissions("matrix.preset.label")}
|
||||
</Text>
|
||||
{(["viewer", "editor", "admin"] as const).map((preset) => (
|
||||
<Tooltip key={preset} label={tPermissions(`matrix.preset.${preset}Description`)}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={activePreset === preset ? "filled" : "default"}
|
||||
onClick={() => applyPreset(form, preset)}
|
||||
>
|
||||
{tPermissions(`matrix.preset.${preset}`)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
{activePreset === null && <Badge variant="light">{tPermissions("matrix.preset.custom")}</Badge>}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface EffectivePermissionPreviewProps {
|
||||
permissionLabels: PermissionLabels;
|
||||
}
|
||||
|
||||
export const EffectivePermissionPreview = ({ permissionLabels }: EffectivePermissionPreviewProps) => {
|
||||
const form = useFormContext();
|
||||
const tPermissions = useScopedI18n("group.permission");
|
||||
const [opened, { toggle }] = useDisclosure(false);
|
||||
const panelId = useId();
|
||||
|
||||
const values = form.getValues();
|
||||
const checkedKeys = groupPermissionKeys.filter((key) => values[key]);
|
||||
const effective = getPermissionsWithChildren(checkedKeys);
|
||||
|
||||
const grouped = objectKeys(permissionMatrix)
|
||||
.map((category) => ({
|
||||
category,
|
||||
keys: effective.filter((key) => key === category || key.startsWith(`${category}-`)),
|
||||
}))
|
||||
.filter(({ keys }) => keys.length > 0);
|
||||
|
||||
return (
|
||||
<Card withBorder p="md">
|
||||
<UnstyledButton onClick={toggle} w="100%" aria-expanded={opened} aria-controls={panelId}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={0}>
|
||||
<Text fw={500}>{tPermissions("matrix.effective.title")}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{tPermissions("matrix.effective.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge variant="light">{effective.length}</Badge>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<Collapse id={panelId} expanded={opened}>
|
||||
<Stack gap="xs" mt="sm">
|
||||
{grouped.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{tPermissions("matrix.effective.empty")}
|
||||
</Text>
|
||||
) : (
|
||||
grouped.map(({ category, keys }) => (
|
||||
<Group key={category} gap="xs" wrap="wrap" align="flex-start">
|
||||
<Text size="sm" fw={500}>
|
||||
{tPermissions(`${category}.title`)}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light">
|
||||
{keys.length}
|
||||
</Badge>
|
||||
<Text size="sm" c="dimmed">
|
||||
{keys.map((key) => permissionLabels[key] ?? key).join(", ")}
|
||||
</Text>
|
||||
</Group>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface SaveAffixProps {
|
||||
@@ -80,8 +410,10 @@ export const SaveAffix = ({ groupId }: SaveAffixProps) => {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Set new initial values for discard and reset dirty state
|
||||
// setInitialValues moves the snapshot so Discard returns here, but only resetDirty clears
|
||||
// the dirty flags that keep the unsaved-changes bar on screen.
|
||||
form.setInitialValues(values);
|
||||
form.resetDirty(values);
|
||||
showSuccessNotification({
|
||||
title: tNotification("success.title"),
|
||||
message: tNotification("success.message"),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Card, CardSection, Divider, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
@@ -9,7 +8,14 @@ import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { groupPermissions } from "@homarr/definitions";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { PermissionForm, PermissionSwitch, SaveAffix } from "./_group-permission-form";
|
||||
import type { PermissionLabels } from "./_group-permission-form";
|
||||
import {
|
||||
EffectivePermissionPreview,
|
||||
PermissionForm,
|
||||
PermissionMatrix,
|
||||
PresetButtons,
|
||||
SaveAffix,
|
||||
} from "./_group-permission-form";
|
||||
|
||||
interface GroupPermissionsPageProps {
|
||||
params: Promise<{
|
||||
@@ -26,27 +32,21 @@ export default async function GroupPermissionsPage(props: GroupPermissionsPagePr
|
||||
}
|
||||
|
||||
const group = await api.group.getById({ id: params.id });
|
||||
const tPermissions = await getScopedI18n("group.permission");
|
||||
const t = await getI18n();
|
||||
|
||||
const { permissionLabels, permissionDescriptions } = await buildPermissionTextsAsync();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title>{t("management.page.group.setting.permissions.title")}</Title>
|
||||
|
||||
<PermissionForm initialPermissions={group.permissions}>
|
||||
<Stack pos="relative">
|
||||
{objectKeys(groupPermissions).map((group) => {
|
||||
const isDanger = group === "admin";
|
||||
<PresetButtons />
|
||||
|
||||
return (
|
||||
<Stack key={group} gap="sm">
|
||||
<Title order={2} c={isDanger ? "red.8" : undefined}>
|
||||
{tPermissions(`${group}.title`)}
|
||||
</Title>
|
||||
<PermissionCard isDanger={isDanger} group={group} />
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
<PermissionMatrix permissionLabels={permissionLabels} permissionDescriptions={permissionDescriptions} />
|
||||
|
||||
<EffectivePermissionPreview permissionLabels={permissionLabels} />
|
||||
|
||||
<SaveAffix groupId={group.id} />
|
||||
</Stack>
|
||||
@@ -55,66 +55,21 @@ export default async function GroupPermissionsPage(props: GroupPermissionsPagePr
|
||||
);
|
||||
}
|
||||
|
||||
interface PermissionCardProps {
|
||||
group: keyof typeof groupPermissions;
|
||||
isDanger: boolean;
|
||||
}
|
||||
const buildPermissionTextsAsync = async () => {
|
||||
const permissionLabels: PermissionLabels = {};
|
||||
const permissionDescriptions: PermissionLabels = {};
|
||||
|
||||
const PermissionCard = async ({ group, isDanger }: PermissionCardProps) => {
|
||||
const t = await getScopedI18n(`group.permission.${group}.item`);
|
||||
const item = groupPermissions[group];
|
||||
const permissions = typeof item !== "boolean" ? item : ([group] as "admin"[]);
|
||||
for (const category of objectKeys(groupPermissions)) {
|
||||
const tItem = await getScopedI18n(`group.permission.${category}.item`);
|
||||
const item = groupPermissions[category];
|
||||
const suffixes = typeof item !== "boolean" ? item : (["admin"] as const);
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
style={{
|
||||
borderColor: isDanger ? "var(--mantine-color-red-8)" : undefined,
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{permissions.map((permission, index) => (
|
||||
<React.Fragment key={permission}>
|
||||
<PermissionRow
|
||||
name={createGroupPermissionKey(group, permission)}
|
||||
label={t(`${permission}.label`)}
|
||||
description={t(`${permission}.description`)}
|
||||
/>
|
||||
|
||||
{index < permissions.length - 1 && (
|
||||
<CardSection>
|
||||
<Divider />
|
||||
</CardSection>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const createGroupPermissionKey = (group: keyof typeof groupPermissions, permission: string): GroupPermissionKey => {
|
||||
if (typeof groupPermissions[group] === "boolean") {
|
||||
return group as GroupPermissionKey;
|
||||
suffixes.forEach((suffix) => {
|
||||
const key = (typeof item !== "boolean" ? `${category}-${suffix}` : "admin") as GroupPermissionKey;
|
||||
permissionLabels[key] = tItem(`${suffix}.label`);
|
||||
permissionDescriptions[key] = tItem(`${suffix}.description`);
|
||||
});
|
||||
}
|
||||
|
||||
return `${group}-${permission}` as GroupPermissionKey;
|
||||
};
|
||||
|
||||
interface PermissionRowProps {
|
||||
name: GroupPermissionKey;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PermissionRow = ({ name, label, description }: PermissionRowProps) => {
|
||||
return (
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Stack gap={0}>
|
||||
<Text fw={500}>{label}</Text>
|
||||
<Text c="gray.5">{description}</Text>
|
||||
</Stack>
|
||||
<PermissionSwitch name={name} />
|
||||
</Group>
|
||||
);
|
||||
return { permissionLabels, permissionDescriptions };
|
||||
};
|
||||
Reference in new issue
Block a user