fix(permissions): keep create permissions grantable and stop silent escalation
The matrix could not represent board-create, app-create, integration-create, search-engine-create or media-upload, and savePermissions force-bundled every create key with its modify/full parent. Together that meant a group with board-create was silently upgraded to board-full-all on the next save, and the common "members may create their own boards" setup became unreachable. - permissionMatrix models create/upload as an orthogonal capability next to the ordered access levels, so all 20 permission keys round-trip losslessly - savePermissions stores exactly what was selected - the matrix UI gets a per-category create checkbox, auto-checked and disabled when the chosen level already implies it - a management section is reachable when the user can do at least one thing in it; nav, pages and stat cards now share one access helper
This commit is contained in:
12 files changed
+534
-177
No files matched your search
@@ -0,0 +1,78 @@
|
||||
import type { Session } from "@homarr/auth";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
/**
|
||||
* Access rules for the management sections, shared by the navigation and the pages themselves so
|
||||
* the two can never drift apart. The rule is always the same: a section is reachable when the user
|
||||
* can do at least one thing in it, and the section only ever lists what the user is allowed to see.
|
||||
*/
|
||||
|
||||
export interface AppsSectionAccess {
|
||||
/** May see and edit every app */
|
||||
canManageAll: boolean;
|
||||
/** May add new apps */
|
||||
canCreate: boolean;
|
||||
/** May delete apps */
|
||||
canDelete: boolean;
|
||||
/** May open /manage/apps at all */
|
||||
canAccess: boolean;
|
||||
}
|
||||
|
||||
export const getAppsSectionAccess = (session: Session | null): AppsSectionAccess => {
|
||||
const permissions = session?.user.permissions ?? [];
|
||||
const canManageAll = permissions.includes("app-modify-all");
|
||||
const canCreate = permissions.includes("app-create");
|
||||
|
||||
return {
|
||||
canManageAll,
|
||||
canCreate,
|
||||
canDelete: permissions.includes("app-full-all"),
|
||||
canAccess: canManageAll || canCreate,
|
||||
};
|
||||
};
|
||||
|
||||
export interface IntegrationsSectionAccess {
|
||||
/** May manage every integration */
|
||||
canManageAll: boolean;
|
||||
/** May add new integrations */
|
||||
canCreate: boolean;
|
||||
/** May manage at least one specific integration through a delegated grant */
|
||||
hasDelegatedAccess: boolean;
|
||||
/** May open /manage/integrations at all */
|
||||
canAccess: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure variant for callers that already fetched the integrations the user can see, so the page and
|
||||
* the navigation never disagree and the list is only queried once per request.
|
||||
*/
|
||||
export const getIntegrationsSectionAccess = (
|
||||
session: Session | null,
|
||||
visibleIntegrations: { permissions: { hasFullAccess: boolean } }[],
|
||||
): IntegrationsSectionAccess => {
|
||||
const permissions = session?.user.permissions ?? [];
|
||||
const canManageAll = permissions.includes("integration-full-all");
|
||||
const canCreate = permissions.includes("integration-create");
|
||||
const hasDelegatedAccess =
|
||||
!canManageAll && visibleIntegrations.some((integration) => integration.permissions.hasFullAccess);
|
||||
|
||||
return {
|
||||
canManageAll,
|
||||
canCreate,
|
||||
hasDelegatedAccess,
|
||||
canAccess: canManageAll || canCreate || hasDelegatedAccess,
|
||||
};
|
||||
};
|
||||
|
||||
export const getIntegrationsSectionAccessAsync = async (
|
||||
session: Session | null,
|
||||
): Promise<IntegrationsSectionAccess> => {
|
||||
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() : []);
|
||||
};
|
||||
@@ -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,26 @@ interface AppsPageProps {
|
||||
export default async function AppsPage(props: AppsPageProps) {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/auth/login");
|
||||
if (!session.user.permissions.includes("app-modify-all")) notFound();
|
||||
const { canManageAll, canCreate, canDelete, canAccess } = getAppsSectionAccess(session);
|
||||
if (!canAccess) notFound();
|
||||
|
||||
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 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 +93,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 ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { redirect } from "next/navigation";
|
||||
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,14 +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");
|
||||
const canSee = hasGlobalFullAccess || integrations.some((integration) => integration.permissions.hasFullAccess);
|
||||
// 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
|
||||
.filter((integration) => hasGlobalFullAccess || integration.permissions.hasFullAccess)
|
||||
const filteredIntegrations = manageableIntegrations
|
||||
.filter((integration) => {
|
||||
if (!query) return true;
|
||||
return [integration.name, getIntegrationName(integration.kind), integration.url].some((value) =>
|
||||
@@ -56,11 +65,12 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
ariaLabel={t("page.list.ariaLabel")}
|
||||
itemCount={filteredIntegrations.length}
|
||||
emptyState={
|
||||
!canSee ? (
|
||||
manageableIntegrations.length === 0 && !hasGlobalFullAccess ? (
|
||||
<NoResults
|
||||
icon={IconPlugX}
|
||||
title={t("page.list.noResults.noPermissionTitle")}
|
||||
description={t("page.list.noResults.noPermissionDescription")}
|
||||
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
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
IconUsersGroup,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
@@ -45,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" });
|
||||
|
||||
@@ -66,12 +66,8 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
sessionPromise,
|
||||
shouldRunManageTourPromise,
|
||||
]);
|
||||
const hasGlobalIntegrationFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
|
||||
const hasDelegatedIntegrationFullAccess =
|
||||
!hasGlobalIntegrationFullAccess && session
|
||||
? (await api.integration.all()).some((integration) => integration.permissions.hasFullAccess)
|
||||
: false;
|
||||
const canManageIntegrations = hasGlobalIntegrationFullAccess || hasDelegatedIntegrationFullAccess;
|
||||
const appsAccess = getAppsSectionAccess(session);
|
||||
const integrationsAccess = await getIntegrationsSectionAccessAsync(session);
|
||||
const navigationLinks: NavigationLink[] = [
|
||||
{
|
||||
label: t("items.home"),
|
||||
@@ -89,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-modify-all"),
|
||||
hidden: !appsAccess.canAccess,
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
@@ -99,7 +95,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconAffiliateFilled,
|
||||
href: "/manage/integrations",
|
||||
label: t("items.integrations"),
|
||||
hidden: !canManageIntegrations,
|
||||
hidden: !integrationsAccess.canAccess,
|
||||
"data-onboarding-tour-id": "manage-integrations",
|
||||
},
|
||||
{
|
||||
|
||||
+141
-44
@@ -1,16 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useId } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
Divider,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
Transition,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
@@ -23,6 +26,7 @@ import type { GroupPermissionKey, PermissionMatrixCategory, PermissionMatrixStat
|
||||
import {
|
||||
getPermissionsWithChildren,
|
||||
groupPermissionKeys,
|
||||
isCreateImpliedByLevel,
|
||||
matrixStateToPermissions,
|
||||
permissionMatrix,
|
||||
permissionsToMatrixState,
|
||||
@@ -72,34 +76,57 @@ type MatrixForm = ReturnType<typeof useFormContext>;
|
||||
|
||||
const emptyMatrixState = (): PermissionMatrixState =>
|
||||
objectKeys(permissionMatrix).reduce((acc, category) => {
|
||||
acc[category] = { level: 0 };
|
||||
acc[category] = { level: 0, create: false };
|
||||
return acc;
|
||||
}, {} as PermissionMatrixState);
|
||||
|
||||
export const getMatrixLevel = (values: FormType, category: PermissionMatrixCategory): number => {
|
||||
const checkedKeys = groupPermissionKeys.filter((key) => values[key]);
|
||||
return permissionsToMatrixState(checkedKeys)[category].level;
|
||||
};
|
||||
const matrixStateOf = (values: FormType) => permissionsToMatrixState(groupPermissionKeys.filter((key) => values[key]));
|
||||
|
||||
export const getMatrixLevel = (values: FormType, category: PermissionMatrixCategory): number =>
|
||||
matrixStateOf(values)[category].level;
|
||||
|
||||
export const hasMatrixCreate = (values: FormType, category: PermissionMatrixCategory): boolean =>
|
||||
matrixStateOf(values)[category].create;
|
||||
|
||||
export const applyMatrixLevel = (form: MatrixForm, category: PermissionMatrixCategory, level: number) => {
|
||||
const targetKeys = new Set(matrixStateToPermissions({ ...emptyMatrixState(), [category]: { level } }));
|
||||
const targetKeys = new Set(matrixStateToPermissions({ ...emptyMatrixState(), [category]: { level, create: false } }));
|
||||
permissionMatrix[category].levels.forEach((key) => {
|
||||
form.setFieldValue(key, targetKeys.has(key));
|
||||
});
|
||||
// The create key is deliberately left untouched: a level that implies creating already covers it,
|
||||
// and an explicitly granted create must survive lowering the level again.
|
||||
};
|
||||
|
||||
export const applyMatrixCreate = (form: MatrixForm, category: PermissionMatrixCategory, create: boolean) => {
|
||||
const createKey = permissionMatrix[category].create;
|
||||
if (createKey !== null) {
|
||||
form.setFieldValue(createKey, create);
|
||||
}
|
||||
};
|
||||
|
||||
const presets = {
|
||||
viewer: { board: 1, app: 1, integration: 1, media: 1 },
|
||||
editor: { board: 2, app: 2, integration: 2, "search-engine": 1, media: 1 },
|
||||
admin: { admin: 1 },
|
||||
} satisfies Record<string, Partial<Record<PermissionMatrixCategory, number>>>;
|
||||
viewer: {
|
||||
board: { level: 1, create: false },
|
||||
app: { level: 1, create: false },
|
||||
integration: { level: 1, create: false },
|
||||
media: { level: 1, create: false },
|
||||
},
|
||||
editor: {
|
||||
board: { level: 2, create: true },
|
||||
app: { level: 2, create: true },
|
||||
integration: { level: 2, create: true },
|
||||
"search-engine": { level: 1, create: true },
|
||||
media: { level: 1, create: true },
|
||||
},
|
||||
admin: { admin: { level: 1, create: false } },
|
||||
} satisfies Record<string, Partial<PermissionMatrixState>>;
|
||||
|
||||
export type PermissionPreset = keyof typeof presets;
|
||||
|
||||
const presetToFormState = (preset: PermissionPreset): FormType => {
|
||||
const levels: Partial<Record<PermissionMatrixCategory, number>> = presets[preset];
|
||||
const presetState: Partial<PermissionMatrixState> = presets[preset];
|
||||
const state = objectKeys(permissionMatrix).reduce((acc, category) => {
|
||||
acc[category] = { level: levels[category] ?? 0 };
|
||||
acc[category] = presetState[category] ?? { level: 0, create: false };
|
||||
return acc;
|
||||
}, {} as PermissionMatrixState);
|
||||
const enabledKeys = new Set(matrixStateToPermissions(state));
|
||||
@@ -123,6 +150,19 @@ export const getActivePreset = (values: FormType): PermissionPreset | 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;
|
||||
@@ -155,9 +195,10 @@ export const MatrixImpliedHint = ({ category, permissionLabels }: MatrixImpliedH
|
||||
interface MatrixRowProps {
|
||||
category: PermissionMatrixCategory;
|
||||
permissionLabels: PermissionLabels;
|
||||
permissionDescriptions: PermissionLabels;
|
||||
}
|
||||
|
||||
export const MatrixRow = ({ category, permissionLabels }: MatrixRowProps) => {
|
||||
export const MatrixRow = ({ category, permissionLabels, permissionDescriptions }: MatrixRowProps) => {
|
||||
const form = useFormContext();
|
||||
const tPermissions = useScopedI18n("group.permission");
|
||||
|
||||
@@ -165,61 +206,116 @@ export const MatrixRow = ({ category, permissionLabels }: MatrixRowProps) => {
|
||||
const level = getMatrixLevel(values, category);
|
||||
const isDanger = category === "admin";
|
||||
const disabledByAdmin = !isDanger && getMatrixLevel(values, "admin") > 0;
|
||||
const { levels, create: createKey } = permissionMatrix[category];
|
||||
const hasSingleLevel = levels.length === 1;
|
||||
|
||||
const data = [
|
||||
{ value: "0", label: tPermissions("matrix.none") },
|
||||
...permissionMatrix[category].levels.map((key, index) => ({
|
||||
value: String(index + 1),
|
||||
label: permissionLabels[key] ?? key,
|
||||
})),
|
||||
...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 selectedKey = level === 0 ? undefined : levels[level - 1];
|
||||
const description =
|
||||
selectedKey === undefined ? tPermissions("matrix.noneDescription") : (permissionDescriptions[selectedKey] ?? "");
|
||||
|
||||
const createImplied = isCreateImpliedByLevel(category, level);
|
||||
const createChecked = hasMatrixCreate(values, category);
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
<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>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
data={data}
|
||||
value={String(level)}
|
||||
disabled={disabledByAdmin}
|
||||
onChange={(value) => applyMatrixLevel(form, category, Number(value))}
|
||||
/>
|
||||
{disabledByAdmin && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{tPermissions("matrix.grantedViaAdmin")}
|
||||
</Text>
|
||||
)}
|
||||
<Group gap="sm" 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))}
|
||||
/>
|
||||
{createKey !== null && (
|
||||
<Tooltip
|
||||
label={tPermissions("matrix.createIncluded", { level: data[level]?.label ?? "" })}
|
||||
disabled={!createImplied}
|
||||
>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
label={permissionLabels[createKey] ?? createKey}
|
||||
checked={createChecked}
|
||||
disabled={disabledByAdmin || createImplied}
|
||||
onChange={(event) => applyMatrixCreate(form, category, event.currentTarget.checked)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</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">
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Text size="sm" fw={500}>
|
||||
{tPermissions("matrix.preset.label")}
|
||||
</Text>
|
||||
{(["viewer", "editor", "admin"] as const).map((preset) => (
|
||||
<Button
|
||||
key={preset}
|
||||
size="xs"
|
||||
variant={activePreset === preset ? "filled" : "default"}
|
||||
onClick={() => applyPreset(form, preset)}
|
||||
>
|
||||
{tPermissions(`matrix.preset.${preset}`)}
|
||||
</Button>
|
||||
<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>
|
||||
@@ -234,6 +330,7 @@ export const EffectivePermissionPreview = ({ permissionLabels }: EffectivePermis
|
||||
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]);
|
||||
@@ -248,7 +345,7 @@ export const EffectivePermissionPreview = ({ permissionLabels }: EffectivePermis
|
||||
|
||||
return (
|
||||
<Card withBorder p="md">
|
||||
<UnstyledButton onClick={toggle} w="100%" aria-expanded={opened}>
|
||||
<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>
|
||||
@@ -259,7 +356,7 @@ export const EffectivePermissionPreview = ({ permissionLabels }: EffectivePermis
|
||||
<Badge variant="light">{effective.length}</Badge>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<Collapse expanded={opened}>
|
||||
<Collapse id={panelId} expanded={opened}>
|
||||
<Stack gap="xs" mt="sm">
|
||||
{grouped.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -267,7 +364,7 @@ export const EffectivePermissionPreview = ({ permissionLabels }: EffectivePermis
|
||||
</Text>
|
||||
) : (
|
||||
grouped.map(({ category, keys }) => (
|
||||
<Group key={category} gap="xs" wrap="nowrap" align="flex-start">
|
||||
<Group key={category} gap="xs" wrap="wrap" align="flex-start">
|
||||
<Text size="sm" fw={500}>
|
||||
{tPermissions(`${category}.title`)}
|
||||
</Text>
|
||||
|
||||
@@ -5,14 +5,14 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { objectKeys } from "@homarr/common";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { groupPermissions, permissionMatrix } from "@homarr/definitions";
|
||||
import { groupPermissions } from "@homarr/definitions";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import type { PermissionLabels } from "./_group-permission-form";
|
||||
import {
|
||||
EffectivePermissionPreview,
|
||||
MatrixRow,
|
||||
PermissionForm,
|
||||
PermissionMatrix,
|
||||
PresetButtons,
|
||||
SaveAffix,
|
||||
} from "./_group-permission-form";
|
||||
@@ -34,7 +34,7 @@ export default async function GroupPermissionsPage(props: GroupPermissionsPagePr
|
||||
const group = await api.group.getById({ id: params.id });
|
||||
const t = await getI18n();
|
||||
|
||||
const permissionLabels = await buildPermissionLabels();
|
||||
const { permissionLabels, permissionDescriptions } = await buildPermissionTextsAsync();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
@@ -44,11 +44,7 @@ export default async function GroupPermissionsPage(props: GroupPermissionsPagePr
|
||||
<Stack pos="relative">
|
||||
<PresetButtons />
|
||||
|
||||
<Stack gap="md">
|
||||
{objectKeys(permissionMatrix).map((category) => (
|
||||
<MatrixRow key={category} category={category} permissionLabels={permissionLabels} />
|
||||
))}
|
||||
</Stack>
|
||||
<PermissionMatrix permissionLabels={permissionLabels} permissionDescriptions={permissionDescriptions} />
|
||||
|
||||
<EffectivePermissionPreview permissionLabels={permissionLabels} />
|
||||
|
||||
@@ -59,8 +55,9 @@ export default async function GroupPermissionsPage(props: GroupPermissionsPagePr
|
||||
);
|
||||
}
|
||||
|
||||
const buildPermissionLabels = async (): Promise<PermissionLabels> => {
|
||||
const buildPermissionTextsAsync = async () => {
|
||||
const permissionLabels: PermissionLabels = {};
|
||||
const permissionDescriptions: PermissionLabels = {};
|
||||
|
||||
for (const category of objectKeys(groupPermissions)) {
|
||||
const tItem = await getScopedI18n(`group.permission.${category}.item`);
|
||||
@@ -70,8 +67,9 @@ const buildPermissionLabels = async (): Promise<PermissionLabels> => {
|
||||
suffixes.forEach((suffix) => {
|
||||
const key = (typeof item !== "boolean" ? `${category}-${suffix}` : "admin") as GroupPermissionKey;
|
||||
permissionLabels[key] = tItem(`${suffix}.label`);
|
||||
permissionDescriptions[key] = tItem(`${suffix}.description`);
|
||||
});
|
||||
}
|
||||
|
||||
return permissionLabels;
|
||||
return { permissionLabels, permissionDescriptions };
|
||||
};
|
||||
@@ -9,7 +9,6 @@ import type { Database } from "@homarr/db";
|
||||
import { and, eq, handleTransactionsAsync, like, not } from "@homarr/db";
|
||||
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||
import { groupMembers, groupPermissions, groups, onboarding, users } from "@homarr/db/schema";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
|
||||
import {
|
||||
@@ -23,13 +22,6 @@ import {
|
||||
|
||||
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
|
||||
const createPermissionParents = new Map<GroupPermissionKey, GroupPermissionKey>([
|
||||
["app-create", "app-modify-all"],
|
||||
["integration-create", "integration-full-all"],
|
||||
["board-create", "board-full-all"],
|
||||
["search-engine-create", "search-engine-modify-all"],
|
||||
]);
|
||||
|
||||
export const groupRouter = createTRPCRouter({
|
||||
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
|
||||
const dbGroups = await ctx.db.query.groups.findMany({
|
||||
@@ -310,14 +302,9 @@ export const groupRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
|
||||
const permissions = [
|
||||
...new Set(
|
||||
input.permissions.flatMap((permission) => {
|
||||
const parent = createPermissionParents.get(permission);
|
||||
return parent ? [permission, parent] : [permission];
|
||||
}),
|
||||
),
|
||||
];
|
||||
// Stored exactly as selected - implied permissions are resolved at read time through
|
||||
// getPermissionsWithChildren, so nothing is silently escalated here.
|
||||
const permissions = [...new Set(input.permissions)];
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
|
||||
@@ -123,12 +123,15 @@ export const homeRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// These mirror the access rules of /manage/integrations and /manage/apps exactly: a card is only
|
||||
// shown when the page behind it is reachable, and it counts only what the user is allowed to see.
|
||||
const hasGlobalFullIntegrationAccess = ctx.session?.user.permissions.includes("integration-full-all") ?? false;
|
||||
const canCreateIntegrations = ctx.session?.user.permissions.includes("integration-create") ?? false;
|
||||
const fullAccessIntegrationIds =
|
||||
hasGlobalFullIntegrationAccess || !ctx.session?.user
|
||||
? []
|
||||
: await getFullAccessIntegrationIdsAsync(ctx.db, ctx.session.user.id);
|
||||
if (hasGlobalFullIntegrationAccess || fullAccessIntegrationIds.length > 0) {
|
||||
if (hasGlobalFullIntegrationAccess || canCreateIntegrations || fullAccessIntegrationIds.length > 0) {
|
||||
statistics.push({
|
||||
titleKey: "integration",
|
||||
subtitleKey: "resources",
|
||||
@@ -137,11 +140,12 @@ export const homeRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.session?.user.permissions.includes("app-modify-all")) {
|
||||
const canManageAllApps = ctx.session?.user.permissions.includes("app-modify-all") ?? false;
|
||||
if (canManageAllApps || (ctx.session?.user.permissions.includes("app-create") ?? false)) {
|
||||
statistics.push({
|
||||
titleKey: "app",
|
||||
subtitleKey: "resources",
|
||||
count: await db.$count(apps),
|
||||
count: canManageAllApps ? await db.$count(apps) : 0,
|
||||
path: "/manage/apps",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -449,7 +449,7 @@ describe("savePermissions should save permissions for group", () => {
|
||||
expect(permissions.map(({ permission }) => permission)).toEqual(["integration-use-all", "board-full-all"]);
|
||||
});
|
||||
|
||||
test("with a create permission it should also persist its parent permission", async () => {
|
||||
test("with a standalone create permission it should not escalate to its parent permission", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
@@ -464,7 +464,7 @@ describe("savePermissions should save permissions for group", () => {
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: ["app-create"],
|
||||
permissions: ["board-create"],
|
||||
});
|
||||
|
||||
// Assert
|
||||
@@ -472,37 +472,34 @@ describe("savePermissions should save permissions for group", () => {
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["app-create", "app-modify-all"]);
|
||||
expect(permissions.map(({ permission }) => permission)).toEqual(["board-create"]);
|
||||
});
|
||||
|
||||
test("with an integration create permission it should persist its parent permission", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
test.each([["app-create"], ["integration-create"], ["search-engine-create"], ["media-upload"]] as const)(
|
||||
"with %s alone it should persist exactly that permission",
|
||||
async (permission) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: ["integration-create"],
|
||||
});
|
||||
// Act
|
||||
await caller.savePermissions({ groupId, permissions: [permission] });
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual([
|
||||
"integration-create",
|
||||
"integration-full-all",
|
||||
]);
|
||||
});
|
||||
expect(permissions.map(({ permission: key }) => key)).toEqual([permission]);
|
||||
},
|
||||
);
|
||||
|
||||
test("with a create permission and its parent it should persist exactly two rows", async () => {
|
||||
// Arrange
|
||||
@@ -531,6 +528,56 @@ describe("savePermissions should save permissions for group", () => {
|
||||
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["app-create", "app-modify-all"]);
|
||||
});
|
||||
|
||||
test("with duplicate permissions it should persist each permission only once", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: ["board-view-all", "board-view-all", "board-modify-all"],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["board-modify-all", "board-view-all"]);
|
||||
});
|
||||
|
||||
test("saving the same permissions twice should be idempotent", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({ groupId, permissions: ["board-create", "media-upload"] });
|
||||
await caller.savePermissions({ groupId, permissions: ["board-create", "media-upload"] });
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["board-create", "media-upload"]);
|
||||
});
|
||||
|
||||
test("with an empty permission list it should clear all existing permissions", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
|
||||
@@ -119,23 +119,53 @@ export const groupPermissionKeys = objectKeys(groupPermissions).reduce((acc, key
|
||||
}, [] as GroupPermissionKey[]);
|
||||
|
||||
/**
|
||||
* Ordered access levels per category used by the group permission matrix UI.
|
||||
* Each level entry is a full {@link GroupPermissionKey}; higher levels imply the lower ones
|
||||
* through {@link groupPermissionParents}.
|
||||
* Describes how each permission category is presented in the group permission matrix UI.
|
||||
*
|
||||
* `levels` is an ordered escalation of access: picking level N grants every key up to N.
|
||||
* `create` is orthogonal to `levels` - it is the "may add new resources" capability and can be
|
||||
* granted on its own (e.g. "members may create their own boards but not see anyone else's").
|
||||
* Some levels already imply the create key through {@link groupPermissionParents}; use
|
||||
* {@link isCreateImpliedByLevel} to detect that instead of hard-coding it.
|
||||
*
|
||||
* Together, `levels` and `create` cover every key in {@link groupPermissionKeys}, so the matrix
|
||||
* is a lossless representation of a group's permissions.
|
||||
*/
|
||||
export const permissionMatrix = {
|
||||
board: { levels: ["board-view-all", "board-modify-all", "board-full-all"] },
|
||||
app: { levels: ["app-use-all", "app-modify-all", "app-full-all"] },
|
||||
integration: { levels: ["integration-use-all", "integration-interact-all", "integration-full-all"] },
|
||||
"search-engine": { levels: ["search-engine-modify-all", "search-engine-full-all"] },
|
||||
media: { levels: ["media-view-all", "media-full-all"] },
|
||||
other: { levels: ["other-view-logs"] },
|
||||
admin: { levels: ["admin"] },
|
||||
} as const satisfies Record<string, { levels: readonly GroupPermissionKey[] }>;
|
||||
board: { levels: ["board-view-all", "board-modify-all", "board-full-all"], create: "board-create" },
|
||||
app: { levels: ["app-use-all", "app-modify-all", "app-full-all"], create: "app-create" },
|
||||
integration: {
|
||||
levels: ["integration-use-all", "integration-interact-all", "integration-full-all"],
|
||||
create: "integration-create",
|
||||
},
|
||||
"search-engine": { levels: ["search-engine-modify-all", "search-engine-full-all"], create: "search-engine-create" },
|
||||
media: { levels: ["media-view-all", "media-full-all"], create: "media-upload" },
|
||||
other: { levels: ["other-view-logs"], create: null },
|
||||
admin: { levels: ["admin"], create: null },
|
||||
} as const satisfies Record<string, { levels: readonly GroupPermissionKey[]; create: GroupPermissionKey | null }>;
|
||||
|
||||
export type PermissionMatrixCategory = keyof typeof permissionMatrix;
|
||||
|
||||
export type PermissionMatrixState = Record<PermissionMatrixCategory, { level: number }>;
|
||||
export interface PermissionMatrixCategoryState {
|
||||
/** 0 = no access, N = every key up to `permissionMatrix[category].levels[N - 1]` */
|
||||
level: number;
|
||||
/** Whether the category's create/upload key is granted */
|
||||
create: boolean;
|
||||
}
|
||||
|
||||
export type PermissionMatrixState = Record<PermissionMatrixCategory, PermissionMatrixCategoryState>;
|
||||
|
||||
/**
|
||||
* Whether the given level already grants the category's create key through permission inheritance.
|
||||
* For example board level 3 (`board-full-all`) implies `board-create`.
|
||||
*/
|
||||
export const isCreateImpliedByLevel = (category: PermissionMatrixCategory, level: number) => {
|
||||
const createKey = permissionMatrix[category].create;
|
||||
if (createKey === null || level <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getPermissionsWithChildren([...permissionMatrix[category].levels.slice(0, level)]).includes(createKey);
|
||||
};
|
||||
|
||||
export const permissionsToMatrixState = (permissions: GroupPermissionKey[]): PermissionMatrixState => {
|
||||
const permissionSet = new Set(permissions);
|
||||
@@ -147,7 +177,11 @@ export const permissionsToMatrixState = (permissions: GroupPermissionKey[]): Per
|
||||
level = index + 1;
|
||||
}
|
||||
});
|
||||
acc[category] = { level };
|
||||
const createKey = permissionMatrix[category].create;
|
||||
acc[category] = {
|
||||
level,
|
||||
create: createKey !== null && (permissionSet.has(createKey) || isCreateImpliedByLevel(category, level)),
|
||||
};
|
||||
return acc;
|
||||
}, {} as PermissionMatrixState);
|
||||
};
|
||||
@@ -155,6 +189,12 @@ export const permissionsToMatrixState = (permissions: GroupPermissionKey[]): Per
|
||||
export const matrixStateToPermissions = (state: PermissionMatrixState): GroupPermissionKey[] => {
|
||||
return objectKeys(permissionMatrix).reduce((acc, category) => {
|
||||
acc.push(...permissionMatrix[category].levels.slice(0, state[category].level));
|
||||
// Skip the create key when the chosen level already implies it, so the stored permissions
|
||||
// stay minimal and a state -> permissions -> state round-trip is exact.
|
||||
const createKey = permissionMatrix[category].create;
|
||||
if (createKey !== null && state[category].create && !isCreateImpliedByLevel(category, state[category].level)) {
|
||||
acc.push(createKey);
|
||||
}
|
||||
return acc;
|
||||
}, [] as GroupPermissionKey[]);
|
||||
};
|
||||
@@ -1,13 +1,27 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { GroupPermissionKey } from "../permissions";
|
||||
import type { GroupPermissionKey, PermissionMatrixState } from "../permissions";
|
||||
import {
|
||||
getPermissionsWithChildren,
|
||||
getPermissionsWithParents,
|
||||
groupPermissionKeys,
|
||||
isCreateImpliedByLevel,
|
||||
matrixStateToPermissions,
|
||||
permissionMatrix,
|
||||
permissionsToMatrixState,
|
||||
} from "../permissions";
|
||||
|
||||
const emptyState = (overrides: Partial<PermissionMatrixState> = {}): PermissionMatrixState => ({
|
||||
board: { level: 0, create: false },
|
||||
app: { level: 0, create: false },
|
||||
integration: { level: 0, create: false },
|
||||
"search-engine": { level: 0, create: false },
|
||||
media: { level: 0, create: false },
|
||||
other: { level: 0, create: false },
|
||||
admin: { level: 0, create: false },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("getPermissionsWithParents should return the correct permissions", () => {
|
||||
test.each([
|
||||
[["board-view-all"], ["board-view-all", "board-modify-all", "board-full-all", "admin"]],
|
||||
@@ -51,10 +65,23 @@ describe("getPermissionsWithChildren should return the correct permissions", ()
|
||||
});
|
||||
});
|
||||
|
||||
describe("the matrix must be able to represent every permission", () => {
|
||||
test("every group permission key is covered by exactly one category", () => {
|
||||
const covered = Object.values(permissionMatrix).flatMap(({ levels, create }) => [
|
||||
...levels,
|
||||
...(create === null ? [] : [create]),
|
||||
]);
|
||||
expect(covered.toSorted()).toEqual(groupPermissionKeys.toSorted());
|
||||
});
|
||||
|
||||
test.each(groupPermissionKeys.map((key) => [key]))("expect %s to survive a round-trip on its own", (key) => {
|
||||
expect(matrixStateToPermissions(permissionsToMatrixState([key]))).toContain(key);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissionsToMatrixState should resolve the highest present level per category", () => {
|
||||
test("resolves the highest present level key", () => {
|
||||
const state = permissionsToMatrixState(["board-view-all", "board-modify-all"]);
|
||||
expect(state.board.level).toBe(2);
|
||||
expect(permissionsToMatrixState(["board-view-all", "board-modify-all"]).board.level).toBe(2);
|
||||
});
|
||||
|
||||
test("resolves the admin category", () => {
|
||||
@@ -63,35 +90,74 @@ describe("permissionsToMatrixState should resolve the highest present level per
|
||||
|
||||
test("resolves an empty selection to all zero", () => {
|
||||
const state = permissionsToMatrixState([]);
|
||||
expect(Object.values(state).every(({ level }) => level === 0)).toBe(true);
|
||||
expect(Object.values(state).every(({ level, create }) => level === 0 && !create)).toBe(true);
|
||||
});
|
||||
|
||||
test("resolves a standalone create key without granting any level", () => {
|
||||
expect(permissionsToMatrixState(["board-create"]).board).toEqual({ level: 0, create: true });
|
||||
});
|
||||
|
||||
test("marks create as granted when the level implies it", () => {
|
||||
expect(permissionsToMatrixState(["board-full-all"]).board.create).toBe(true);
|
||||
expect(permissionsToMatrixState(["app-modify-all"]).app.create).toBe(true);
|
||||
expect(permissionsToMatrixState(["media-full-all"]).media.create).toBe(true);
|
||||
});
|
||||
|
||||
test("does not mark create as granted for a level that does not imply it", () => {
|
||||
expect(permissionsToMatrixState(["board-modify-all"]).board.create).toBe(false);
|
||||
expect(permissionsToMatrixState(["integration-interact-all"]).integration.create).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCreateImpliedByLevel", () => {
|
||||
test.each([
|
||||
["board", 0, false],
|
||||
["board", 1, false],
|
||||
["board", 2, false],
|
||||
["board", 3, true],
|
||||
["app", 1, false],
|
||||
["app", 2, true],
|
||||
["integration", 2, false],
|
||||
["integration", 3, true],
|
||||
["search-engine", 1, true],
|
||||
["media", 1, false],
|
||||
["media", 2, true],
|
||||
["other", 1, false],
|
||||
["admin", 1, false],
|
||||
] satisfies [keyof typeof permissionMatrix, number, boolean][])(
|
||||
"expect %s at level %i to imply create: %s",
|
||||
(category, level, expected) => {
|
||||
expect(isCreateImpliedByLevel(category, level)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("matrixStateToPermissions should expand levels into permission keys", () => {
|
||||
test("expands a level into the ordered level keys", () => {
|
||||
const permissions = matrixStateToPermissions({
|
||||
board: { level: 2 },
|
||||
app: { level: 0 },
|
||||
integration: { level: 0 },
|
||||
"search-engine": { level: 0 },
|
||||
media: { level: 0 },
|
||||
other: { level: 0 },
|
||||
admin: { level: 0 },
|
||||
});
|
||||
expect(permissions).toEqual(["board-view-all", "board-modify-all"]);
|
||||
expect(matrixStateToPermissions(emptyState({ board: { level: 2, create: false } }))).toEqual([
|
||||
"board-view-all",
|
||||
"board-modify-all",
|
||||
]);
|
||||
});
|
||||
|
||||
test("expands the admin category into the admin key", () => {
|
||||
const permissions = matrixStateToPermissions({
|
||||
board: { level: 0 },
|
||||
app: { level: 0 },
|
||||
integration: { level: 0 },
|
||||
"search-engine": { level: 0 },
|
||||
media: { level: 0 },
|
||||
other: { level: 0 },
|
||||
admin: { level: 1 },
|
||||
});
|
||||
expect(permissions).toEqual(["admin"]);
|
||||
expect(matrixStateToPermissions(emptyState({ admin: { level: 1, create: false } }))).toEqual(["admin"]);
|
||||
});
|
||||
|
||||
test("emits a standalone create key without any level", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ board: { level: 0, create: true } }))).toEqual(["board-create"]);
|
||||
});
|
||||
|
||||
test("emits media-upload as the create key of the media category", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ media: { level: 0, create: true } }))).toEqual(["media-upload"]);
|
||||
});
|
||||
|
||||
test("omits a create key that the chosen level already implies", () => {
|
||||
expect(matrixStateToPermissions(emptyState({ board: { level: 3, create: true } }))).toEqual([
|
||||
"board-view-all",
|
||||
"board-modify-all",
|
||||
"board-full-all",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,6 +177,10 @@ describe("matrix helpers should round-trip stably", () => {
|
||||
"other-view-logs",
|
||||
],
|
||||
],
|
||||
[["board-create"]],
|
||||
[["media-upload"]],
|
||||
[["board-create", "app-create", "integration-create", "search-engine-create", "media-upload"]],
|
||||
[["board-create", "board-view-all"]],
|
||||
] satisfies [GroupPermissionKey[]][])("expect %s to round-trip", (permissions) => {
|
||||
const roundTripped = matrixStateToPermissions(permissionsToMatrixState(permissions));
|
||||
expect(roundTripped).toEqual(expect.arrayContaining(permissions));
|
||||
@@ -118,7 +188,10 @@ describe("matrix helpers should round-trip stably", () => {
|
||||
});
|
||||
|
||||
test("expect a single higher-level key to expand to its full set on round-trip", () => {
|
||||
const roundTripped = matrixStateToPermissions(permissionsToMatrixState(["board-full-all"]));
|
||||
expect(roundTripped).toEqual(["board-view-all", "board-modify-all", "board-full-all"]);
|
||||
expect(matrixStateToPermissions(permissionsToMatrixState(["board-full-all"]))).toEqual([
|
||||
"board-view-all",
|
||||
"board-modify-all",
|
||||
"board-full-all",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -655,20 +655,32 @@
|
||||
}
|
||||
},
|
||||
"matrix": {
|
||||
"none": "None",
|
||||
"impliedHint": "also grants: {permissions}",
|
||||
"grantedViaAdmin": "Granted via Administrator",
|
||||
"none": "No access",
|
||||
"impliedHint": "Also grants: {permissions}",
|
||||
"grantedViaAdmin": "Administrators already have this. Set Admin to “No access” to configure it separately.",
|
||||
"preset": {
|
||||
"label": "Presets",
|
||||
"viewer": "Viewer",
|
||||
"viewerDescription": "Read-only: view boards and medias, use apps and integrations that already exist.",
|
||||
"editor": "Editor",
|
||||
"editorDescription": "Day-to-day management: create and edit boards, apps, integrations and search engines.",
|
||||
"admin": "Admin",
|
||||
"adminDescription": "Unrestricted access to every feature and setting, including user management.",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"effective": {
|
||||
"title": "Effective permissions",
|
||||
"description": "Everything members of this group will be able to do, including implied permissions",
|
||||
"description": "The full, de-duplicated set of actions members of this group can perform, including permissions granted indirectly.",
|
||||
"empty": "No permissions selected"
|
||||
},
|
||||
"noneDescription": "Members get no access to this area.",
|
||||
"createIncluded": "Already included in “{level}”",
|
||||
"level": {
|
||||
"view": "View",
|
||||
"use": "Use",
|
||||
"interact": "Interact",
|
||||
"modify": "Modify",
|
||||
"full": "Full"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -796,7 +808,9 @@
|
||||
"action": "Create your first app",
|
||||
"filteredTitle": "No apps match your search",
|
||||
"filteredDescription": "No apps match \"{search}\". Try another name or URL.",
|
||||
"clearSearch": "Clear search"
|
||||
"clearSearch": "Clear search",
|
||||
"createOnlyTitle": "You can only add apps",
|
||||
"createOnlyDescription": "Your permissions let you create apps but not browse existing ones. Ask an administrator for app management access to see the full list."
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
@@ -1598,7 +1612,7 @@
|
||||
"title": "Integrations",
|
||||
"ariaLabel": "Configured integrations",
|
||||
"search": "Search integrations",
|
||||
"scopedNote": "Showing only integrations you have full access to",
|
||||
"scopedNote": "Showing only the integrations you are allowed to manage.",
|
||||
"action": {
|
||||
"edit": "Edit {name}",
|
||||
"delete": "Delete {name}"
|
||||
@@ -1610,8 +1624,8 @@
|
||||
"filteredTitle": "No integrations match your search",
|
||||
"filteredDescription": "No integrations match \"{search}\". Try another name, type, or URL.",
|
||||
"clearSearch": "Clear search",
|
||||
"noPermissionTitle": "No manageable integrations",
|
||||
"noPermissionDescription": "You don't have full management access to any integrations. Ask your administrator."
|
||||
"createOnlyTitle": "You can only add integrations",
|
||||
"createOnlyDescription": "Your permissions let you create integrations but not manage existing ones. Ask an administrator for access to the ones you need."
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
|
||||
Reference in new issue
Block a user