Compare commits

...
Author SHA1 Message Date
Thomas Camlong 4789fb680c feat: cli demo no read only 2026-08-25 17:22:05 +02:00
Thomas Camlong 2e99b424fd Revert "fix: uncommitted review fixes"
This reverts commit 3603d0393c.
2026-08-17 17:20:54 +02:00
Thomas Camlong 3603d0393c fix: uncommitted review fixes
- Document app-modify-all requirement on API endpoints (docs/api)
- Skip implied hint rendering for admin category row
- Fix spotlight apps search group visibility for app-modify-all users
2026-08-17 17:18:24 +02:00
Thomas Camlong 447968d2b4 fix: use typed Map for create-permission parents
The 'in' operator guard did not narrow the GroupPermissionKey union for
indexing, failing the CI typecheck (TS7053). A typed Map with .get()
needs no narrowing and preserves the bundling behavior.
2026-08-17 13:00:23 +02:00
Thomas Camlong 79b8066a36 fix: fall back to raw segment in breadcrumbs for missing translations
Brings the DynamicBreadcrumb fallback from release/v2: dynamic segments
(group ids, etc.) without a translation key previously raised
MISSING_MESSAGE; they now render the raw path segment via t.has() guard.
2026-08-17 12:55:28 +02:00
Thomas Camlong c369df0133 fix: address CodeRabbit review findings
- Apply app search permission filter after limit (access filtering before truncation)
- Allow board-modify-all users to search apps (spotlight regression)
- Skip integration permission queries for anonymous sessions
- Add deterministic sort tie-breakers (kind, name, id)
- Extract shared integration access relations helper
- Replace double type assertion with typed lookup in savePermissions
- Add regression tests: board-modify-all app search, integration-create
  bundling, global full-access branch, matrix round-trip expansion
2026-08-16 21:25:14 +02:00
Thomas Camlong 5f5d71c3fd docs(pr): add permission UI screenshots 2026-08-16 16:59:43 +02:00
Thomas Camlong 978f117a88 feat: recalculate permission scope and add permission matrix UI
- Gate app list procedures (getPaginated/all/search) behind app-modify-all
- Filter integration list procedures by per-integration access
- Bundle create permissions with their modify/full parents on group save
- Align manage nav, home stat cards and page gates with the new scope
- Add permission matrix UI with presets and effective-permission preview
- Show scoped empty states and delegated-admin notices on the integrations page
2026-08-16 16:38:34 +02:00
36 changed files with 1075 additions and 308 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+1 -1
View File
@@ -53,4 +53,4 @@ Note that the ping URL only accepts `http://` and `https://` URLs — custom URI
On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page. On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page.
Any signed-in user can view this list. Creating, editing, and deleting apps requires the corresponding app permission; board permissions are not required. Viewing this list requires the `app-modify-all` permission. Creating an app requires `app-create`, editing requires `app-modify-all`, and deleting requires `app-full-all`; board permissions are not required.
@@ -17,6 +17,8 @@ To create an integration, choose **New integration** and select the supported ap
The integrations management page uses a searchable list. Each row shows the integration type and URL, with the actions you have permission to use. The integrations management page uses a searchable list. Each row shows the integration type and URL, with the actions you have permission to use.
This page is only visible to users with the global `integration-full-all` permission or with full access on at least one integration. Without the global permission, only the integrations you have full access to are shown.
Next, you'll need to fill out the required fields to configure the integration: Next, you'll need to fill out the required fields to configure the integration:
![](./img/form-create-integration.png) ![](./img/form-create-integration.png)
+49 -9
View File
@@ -163,19 +163,59 @@ Members can be removed from the group using the remove button at the end:
#### Permissions tab #### Permissions tab
In this tab, you manage what permissions the members of this group will have. In this tab, you manage what permissions the members of this group will have.
Currently, the following permissions are available: Permissions are organized by category (boards, apps, integrations, and so on). Each category is a row with a
segmented control that lets you pick a single access level. Selecting a level grants that level and every lower
level in the same category. Choosing **None** removes all permissions for that category.
- Boards Selecting **Administrator** grants full access to everything. While the administrator level is selected, every
- **Create boards**: Allow members to create boards other category is disabled and shows a **Granted via Administrator** caption, because administrators already have
all permissions. Deselecting it (choosing **None** on the administrator row) re-enables the other categories.
##### Presets
Above the rows you can apply a preset to fill in a common configuration in one click:
- **Viewer**: view access to boards, use access to apps and integrations, and viewing media.
- **Editor**: modify access to boards and apps, interact with integrations, modify search engines (includes creating), and viewing media.
- **Admin**: grants the administrator permission (full access to everything).
When the current selection does not match any preset, a **Custom** badge is shown.
##### Implied permissions
Some levels include additional permissions automatically. When a level implies extra permissions (for example,
full board access also allows creating boards), a dimmed `also grants: …` hint is shown under the category row.
##### Effective permissions
Below the rows, the **Effective permissions** panel can be expanded to review everything members of this group
will be able to do, including implied permissions, grouped by category with a count for each.
Each category below is a segmented control that starts with **None**; the remaining segments are the access levels
in ascending order (higher levels include the lower ones and may imply extra permissions shown in the hint):
- Boards — None
- **View all boards**: Allow members to view all boards - **View all boards**: Allow members to view all boards
- **Modify all boards**: Allow members to modify all boards (Does not include access control and danger zone) - **Modify all boards**: Allow members to modify all boards (Does not include access control and danger zone)
- **Full board access**: Allow members to view, modify, and delete all boards (Including access control and danger zone) - **Full board access**: Allow members to view, modify, and delete all boards (Including access control and danger zone; includes creating boards)
- Integrations - Apps — None
- **Create integrations**: Allow members to create integrations - **Use all apps**: Allow members to add any apps to their boards
- **Use all integrations**: Allows members to add any integrations to their boards - **Modify all apps**: Allow members to modify all apps (includes creating apps)
- **Full app access**: Allow members to manage, use and delete any app
- Integrations — None
- **Use all integrations**: Allow members to add any integrations to their boards
- **Interact with any integration**: Allow members to interact with any integration - **Interact with any integration**: Allow members to interact with any integration
- **Full integration access**: Allow members to manage, use and interact with any integration - **Full integration access**: Allow members to manage, use and interact with any integration (includes creating integrations)
- **Administrator**: Members with this permission have full access to all features and settings - Search engines — None
- **Modify all search engines**: Allow members to modify all search engines (includes creating search engines)
- **Full search engine access**: Allow members to manage and delete any search engine
- Medias — None
- **View all medias**: Allow members to view all medias
- **Full media access**: Allow members to manage and delete any media (includes uploading medias)
- Other — None
- **View logs**: Allow members to view logs
- Administrator — None
- **Administrator**: Members with this permission have full access to all features and settings
Custom Widget authoring, import, migration, preview, secrets, Workshop access, and placement configuration are reserved Custom Widget authoring, import, migration, preview, secrets, Workshop access, and placement configuration are reserved
for administrators and are not separate assignable group permissions. Board modifiers can still move, resize, change for administrators and are not separate assignable group permissions. Board modifiers can still move, resize, change
@@ -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, Text } from "@mantine/core"; import { ActionIcon, ActionIconGroup, Anchor, Avatar, 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";
@@ -41,12 +41,12 @@ interface AppsPageProps {
export default async function AppsPage(props: AppsPageProps) { export default async function AppsPage(props: AppsPageProps) {
const session = await auth(); const session = await auth();
if (!session) redirect("/auth/login"); if (!session) redirect("/auth/login");
if (!session.user.permissions.includes("app-modify-all")) notFound();
const searchParams = searchParamsSchema.parse(await props.searchParams); const searchParams = searchParamsSchema.parse(await props.searchParams);
const { items: apps, totalCount } = await api.app.getPaginated(searchParams); const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
const t = await getScopedI18n("app"); const t = await getScopedI18n("app");
const canCreate = session.user.permissions.includes("app-create"); 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 canDelete = session.user.permissions.includes("app-full-all");
const hasSearch = Boolean(searchParams.search?.trim()); const hasSearch = Boolean(searchParams.search?.trim());
@@ -100,7 +100,6 @@ export default async function AppsPage(props: AppsPageProps) {
<AppItem <AppItem
key={app.id} key={app.id}
app={app} app={app}
canModify={canModify}
canDelete={canDelete} canDelete={canDelete}
editLabel={t("page.list.action.edit", { name: app.name })} editLabel={t("page.list.action.edit", { name: app.name })}
/> />
@@ -113,12 +112,11 @@ export default async function AppsPage(props: AppsPageProps) {
interface AppItemProps { interface AppItemProps {
app: RouterOutputs["app"]["getPaginated"]["items"][number]; app: RouterOutputs["app"]["getPaginated"]["items"][number];
canModify: boolean;
canDelete: boolean; canDelete: boolean;
editLabel: string; editLabel: string;
} }
const AppItem = ({ app, canModify, canDelete, editLabel }: AppItemProps) => { const AppItem = ({ app, canDelete, editLabel }: AppItemProps) => {
const descriptionLines = app.description?.split("\n"); const descriptionLines = app.description?.split("\n");
const safeHref = getSafeAppHref(app.href); const safeHref = getSafeAppHref(app.href);
@@ -157,23 +155,19 @@ const AppItem = ({ app, canModify, canDelete, editLabel }: AppItemProps) => {
) : undefined ) : undefined
} }
actions={ actions={
canModify || canDelete ? ( <ActionIconGroup>
<ActionIconGroup> <ActionIcon
{canModify && ( component={Link}
<ActionIcon href={`/manage/apps/edit/${app.id}`}
component={Link} variant="subtle"
href={`/manage/apps/edit/${app.id}`} color="gray"
variant="subtle" size={44}
color="gray" aria-label={editLabel}
size={44} >
aria-label={editLabel} <IconPencil size={18} stroke={1.5} />
> </ActionIcon>
<IconPencil size={18} stroke={1.5} /> {canDelete && <AppDeleteButton app={app} />}
</ActionIcon> </ActionIconGroup>
)}
{canDelete && <AppDeleteButton app={app} />}
</ActionIconGroup>
) : undefined
} }
/> />
); );
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Badge, Text } from "@mantine/core"; import { ActionIcon, ActionIconGroup, Anchor, Badge, Stack, Text } from "@mantine/core";
import { IconPencil, IconPlugX } from "@tabler/icons-react"; import { IconPencil, IconPlugX } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod/v4";
@@ -32,8 +32,11 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
const t = await getScopedI18n("integration"); const t = await getScopedI18n("integration");
const canCreate = session.user.permissions.includes("integration-create"); const canCreate = session.user.permissions.includes("integration-create");
const hasGlobalFullAccess = session.user.permissions.includes("integration-full-all"); const hasGlobalFullAccess = session.user.permissions.includes("integration-full-all");
const canSee = hasGlobalFullAccess || integrations.some((integration) => integration.permissions.hasFullAccess);
const query = searchParams.search?.trim().toLocaleLowerCase() ?? ""; const query = searchParams.search?.trim().toLocaleLowerCase() ?? "";
const filteredIntegrations = integrations const filteredIntegrations = integrations
.filter((integration) => hasGlobalFullAccess || integration.permissions.hasFullAccess)
.filter((integration) => { .filter((integration) => {
if (!query) return true; if (!query) return true;
return [integration.name, getIntegrationName(integration.kind), integration.url].some((value) => return [integration.name, getIntegrationName(integration.kind), integration.url].some((value) =>
@@ -53,7 +56,13 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
ariaLabel={t("page.list.ariaLabel")} ariaLabel={t("page.list.ariaLabel")}
itemCount={filteredIntegrations.length} itemCount={filteredIntegrations.length}
emptyState={ emptyState={
hasSearch ? ( !canSee ? (
<NoResults
icon={IconPlugX}
title={t("page.list.noResults.noPermissionTitle")}
description={t("page.list.noResults.noPermissionDescription")}
/>
) : hasSearch ? (
<NoResults <NoResults
icon={IconPlugX} icon={IconPlugX}
title={t("page.list.noResults.filteredTitle")} title={t("page.list.noResults.filteredTitle")}
@@ -93,24 +102,33 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
floatingPrimaryAction={canCreate} floatingPrimaryAction={canCreate}
> >
{filteredIntegrations.map((integration) => ( {filteredIntegrations.map((integration) => (
<IntegrationItem <IntegrationItem key={integration.id} integration={integration} />
key={integration.id}
integration={integration}
canManage={hasGlobalFullAccess || integration.permissions.hasFullAccess}
/>
))} ))}
</ManageCollectionPage> </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 { interface IntegrationItemProps {
integration: RouterOutputs["integration"]["all"][number]; integration: RouterOutputs["integration"]["all"][number];
canManage: boolean;
} }
const IntegrationItem = async ({ integration, canManage }: IntegrationItemProps) => { const IntegrationItem = async ({ integration }: IntegrationItemProps) => {
const t = await getScopedI18n("integration"); const t = await getScopedI18n("integration");
const kindName = getIntegrationName(integration.kind); const kindName = getIntegrationName(integration.kind);
const safeUrl = getSafeApplicationUrl(integration.url); const safeUrl = getSafeApplicationUrl(integration.url);
@@ -143,21 +161,19 @@ const IntegrationItem = async ({ integration, canManage }: IntegrationItemProps)
) : undefined ) : undefined
} }
actions={ actions={
canManage ? ( <ActionIconGroup>
<ActionIconGroup> <ActionIcon
<ActionIcon component={Link}
component={Link} href={`/manage/integrations/edit/${integration.id}`}
href={`/manage/integrations/edit/${integration.id}`} variant="subtle"
variant="subtle" color="gray"
color="gray" size={44}
size={44} aria-label={t("page.list.action.edit", { name: integration.name })}
aria-label={t("page.list.action.edit", { name: integration.name })} >
> <IconPencil size={18} stroke={1.5} />
<IconPencil size={18} stroke={1.5} /> </ActionIcon>
</ActionIcon> <DeleteIntegrationActionButton integration={integration} />
<DeleteIntegrationActionButton integration={integration} /> </ActionIconGroup>
</ActionIconGroup>
) : undefined
} }
/> />
); );
@@ -29,6 +29,7 @@ import {
IconUsersGroup, IconUsersGroup,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { getRscUserSettingsAsync } from "@homarr/api/user-server"; import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server"; import { isProviderEnabled } from "@homarr/auth/server";
@@ -66,6 +67,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
sessionPromise, sessionPromise,
shouldRunManageTourPromise, 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 navigationLinks: NavigationLink[] = [ const navigationLinks: NavigationLink[] = [
{ {
label: t("items.home"), label: t("items.home"),
@@ -83,7 +90,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?.user.permissions.includes("app-create"), hidden: !session?.user.permissions.includes("app-modify-all"),
iconProps: { iconProps: {
strokeWidth: 2.5, strokeWidth: 2.5,
}, },
@@ -93,7 +100,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconAffiliateFilled, icon: IconAffiliateFilled,
href: "/manage/integrations", href: "/manage/integrations",
label: t("items.integrations"), label: t("items.integrations"),
hidden: !session?.user.permissions.includes("integration-create"), hidden: !canManageIntegrations,
"data-onboarding-tour-id": "manage-integrations", "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 { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
export default async function NotFound() { export default async function NotFound() {
const t = await getScopedI18n("management.notFound"); const t = await getScopedI18n("management.notFound");
@@ -9,6 +10,9 @@ export default async function NotFound() {
<Stack align="center"> <Stack align="center">
<Title order={1}>{t("title")}</Title> <Title order={1}>{t("title")}</Title>
<Text>{t("text")}</Text> <Text>{t("text")}</Text>
<Anchor component={Link} href="/manage">
{t("backToHome")}
</Anchor>
</Stack> </Stack>
</Center> </Center>
); );
+7 -2
View File
@@ -29,7 +29,12 @@ export default async function ManagementPage() {
<Space h="md" /> <Space h="md" />
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}> <SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{statistics.map((statistic) => ( {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 justify="space-between" wrap="nowrap">
<Group wrap="nowrap"> <Group wrap="nowrap">
<Text size="xl" fw={700}> <Text size="xl" fw={700}>
@@ -42,7 +47,7 @@ export default async function ManagementPage() {
<Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text> <Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text>
</Stack> </Stack>
</Group> </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> </Group>
</Card> </Card>
))} ))}
@@ -2,16 +2,37 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { useCallback } from "react"; import { useCallback } from "react";
import { Button, Card, Group, Switch, Text, Transition } from "@mantine/core"; import {
Badge,
Button,
Card,
Collapse,
Group,
SegmentedControl,
Stack,
Text,
Transition,
UnstyledButton,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconShieldExclamation } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { objectEntries } from "@homarr/common"; import { objectEntries, objectKeys } from "@homarr/common";
import type { GroupPermissionKey } from "@homarr/definitions"; import type { GroupPermissionKey, PermissionMatrixCategory, PermissionMatrixState } from "@homarr/definitions";
import { groupPermissionKeys } from "@homarr/definitions"; import {
getPermissionsWithChildren,
groupPermissionKeys,
matrixStateToPermissions,
permissionMatrix,
permissionsToMatrixState,
} from "@homarr/definitions";
import { createFormContext } from "@homarr/form"; import { createFormContext } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
export type PermissionLabels = Partial<Record<GroupPermissionKey, string>>;
const [FormProvider, useFormContext, useForm] = createFormContext<FormType>(); const [FormProvider, useFormContext, useForm] = createFormContext<FormType>();
interface PermissionFormProps { interface PermissionFormProps {
@@ -39,7 +60,7 @@ export const PermissionForm = ({ children, initialPermissions }: PropsWithChildr
}); });
return ( return (
<form> <form onSubmit={(event) => event.preventDefault()}>
<FormProvider form={form}>{children}</FormProvider> <FormProvider form={form}>{children}</FormProvider>
</form> </form>
); );
@@ -47,15 +68,222 @@ export const PermissionForm = ({ children, initialPermissions }: PropsWithChildr
type FormType = Record<GroupPermissionKey, boolean>; type FormType = Record<GroupPermissionKey, boolean>;
export const PermissionSwitch = ({ name }: { name: GroupPermissionKey }) => { type MatrixForm = ReturnType<typeof useFormContext>;
const form = useFormContext();
const props = form.getInputProps(name, { const emptyMatrixState = (): PermissionMatrixState =>
withError: false, objectKeys(permissionMatrix).reduce((acc, category) => {
type: "checkbox", acc[category] = { level: 0 };
return acc;
}, {} as PermissionMatrixState);
export const getMatrixLevel = (values: FormType, category: PermissionMatrixCategory): number => {
const checkedKeys = groupPermissionKeys.filter((key) => values[key]);
return permissionsToMatrixState(checkedKeys)[category].level;
};
export const applyMatrixLevel = (form: MatrixForm, category: PermissionMatrixCategory, level: number) => {
const targetKeys = new Set(matrixStateToPermissions({ ...emptyMatrixState(), [category]: { level } }));
permissionMatrix[category].levels.forEach((key) => {
form.setFieldValue(key, targetKeys.has(key));
}); });
};
return <Switch {...props} />; 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>>>;
export type PermissionPreset = keyof typeof presets;
const presetToFormState = (preset: PermissionPreset): FormType => {
const levels: Partial<Record<PermissionMatrixCategory, number>> = presets[preset];
const state = objectKeys(permissionMatrix).reduce((acc, category) => {
acc[category] = { level: levels[category] ?? 0 };
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
);
};
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;
}
export const MatrixRow = ({ category, permissionLabels }: 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 data = [
{ value: "0", label: tPermissions("matrix.none") },
...permissionMatrix[category].levels.map((key, index) => ({
value: String(index + 1),
label: permissionLabels[key] ?? key,
})),
];
return (
<Stack gap={4}>
<Group gap="sm" wrap="wrap" align="center">
<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>
<MatrixImpliedHint category={category} permissionLabels={permissionLabels} />
</Stack>
);
};
export const PresetButtons = () => {
const form = useFormContext();
const tPermissions = useScopedI18n("group.permission");
const activePreset = getActivePreset(form.getValues());
return (
<Group gap="xs">
<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>
))}
{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 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}>
<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 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="nowrap" 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 { interface SaveAffixProps {
@@ -1,15 +1,21 @@
import React from "react";
import { notFound } from "next/navigation"; 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 { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { objectKeys } from "@homarr/common"; import { objectKeys } from "@homarr/common";
import type { GroupPermissionKey } from "@homarr/definitions"; import type { GroupPermissionKey } from "@homarr/definitions";
import { groupPermissions } from "@homarr/definitions"; import { groupPermissions, permissionMatrix } from "@homarr/definitions";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { PermissionForm, PermissionSwitch, SaveAffix } from "./_group-permission-form"; import type { PermissionLabels } from "./_group-permission-form";
import {
EffectivePermissionPreview,
MatrixRow,
PermissionForm,
PresetButtons,
SaveAffix,
} from "./_group-permission-form";
interface GroupPermissionsPageProps { interface GroupPermissionsPageProps {
params: Promise<{ params: Promise<{
@@ -26,27 +32,25 @@ export default async function GroupPermissionsPage(props: GroupPermissionsPagePr
} }
const group = await api.group.getById({ id: params.id }); const group = await api.group.getById({ id: params.id });
const tPermissions = await getScopedI18n("group.permission");
const t = await getI18n(); const t = await getI18n();
const permissionLabels = await buildPermissionLabels();
return ( return (
<Stack> <Stack>
<Title>{t("management.page.group.setting.permissions.title")}</Title> <Title>{t("management.page.group.setting.permissions.title")}</Title>
<PermissionForm initialPermissions={group.permissions}> <PermissionForm initialPermissions={group.permissions}>
<Stack pos="relative"> <Stack pos="relative">
{objectKeys(groupPermissions).map((group) => { <PresetButtons />
const isDanger = group === "admin";
return ( <Stack gap="md">
<Stack key={group} gap="sm"> {objectKeys(permissionMatrix).map((category) => (
<Title order={2} c={isDanger ? "red.8" : undefined}> <MatrixRow key={category} category={category} permissionLabels={permissionLabels} />
{tPermissions(`${group}.title`)} ))}
</Title> </Stack>
<PermissionCard isDanger={isDanger} group={group} />
</Stack> <EffectivePermissionPreview permissionLabels={permissionLabels} />
);
})}
<SaveAffix groupId={group.id} /> <SaveAffix groupId={group.id} />
</Stack> </Stack>
@@ -55,66 +59,19 @@ export default async function GroupPermissionsPage(props: GroupPermissionsPagePr
); );
} }
interface PermissionCardProps { const buildPermissionLabels = async (): Promise<PermissionLabels> => {
group: keyof typeof groupPermissions; const permissionLabels: PermissionLabels = {};
isDanger: boolean;
}
const PermissionCard = async ({ group, isDanger }: PermissionCardProps) => { for (const category of objectKeys(groupPermissions)) {
const t = await getScopedI18n(`group.permission.${group}.item`); const tItem = await getScopedI18n(`group.permission.${category}.item`);
const item = groupPermissions[group]; const item = groupPermissions[category];
const permissions = typeof item !== "boolean" ? item : ([group] as "admin"[]); const suffixes = typeof item !== "boolean" ? item : (["admin"] as const);
return ( suffixes.forEach((suffix) => {
<Card const key = (typeof item !== "boolean" ? `${category}-${suffix}` : "admin") as GroupPermissionKey;
p="md" permissionLabels[key] = tItem(`${suffix}.label`);
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;
} }
return `${group}-${permission}` as GroupPermissionKey; return permissionLabels;
};
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>
);
}; };
@@ -65,24 +65,19 @@ export const DynamicBreadcrumb = ({
const isNonInteractable = const isNonInteractable =
nonInteractable?.includes(pathnamePart) === true || categorySegments.has(pathnamePart); nonInteractable?.includes(pathnamePart) === true || categorySegments.has(pathnamePart);
if (mappedValue) { // Dynamic segments (ids) have no translation and may not be mapped yet while
if (isNonInteractable) { // their page is still loading. Fall back to the raw segment instead of
return <Text key={href}>{mappedValue}</Text>; // raising MISSING_MESSAGE.
} const labelKey = `${translationKey}.label` as TranslationKeys;
return ( const label = mappedValue ?? (t.has(labelKey) ? t(labelKey) : pathnamePart);
<Anchor key={href} href={href}>
{mappedValue}
</Anchor>
);
}
if (isNonInteractable) { if (isNonInteractable) {
return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>; return <Text key={href}>{label}</Text>;
} }
return ( return (
<Anchor key={href} href={href}> <Anchor key={href} href={href}>
{t(`${translationKey}.label` as TranslationKeys)} {label}
</Anchor> </Anchor>
); );
})} })}
+14 -2
View File
@@ -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("app-modify-all")
.meta({ .meta({
openapi: { openapi: {
method: "GET", method: "GET",
@@ -50,7 +51,8 @@ export const appRouter = createTRPCRouter({
totalCount, totalCount,
}; };
}), }),
all: protectedProcedure all: permissionRequiredProcedure
.requiresPermission("app-modify-all")
.input(z.void()) .input(z.void())
.output(z.array(selectAppSchema)) .output(z.array(selectAppSchema))
.meta({ .meta({
@@ -88,6 +90,16 @@ export const appRouter = createTRPCRouter({
}, },
}) })
.query(({ ctx, input }) => { .query(({ ctx, input }) => {
if (
!ctx.session.user.permissions.includes("app-modify-all") &&
!ctx.session.user.permissions.includes("board-modify-all")
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Permission denied",
});
}
return ctx.db.query.apps.findMany({ return ctx.db.query.apps.findMany({
where: like(apps.name, `%${input.query}%`), where: like(apps.name, `%${input.query}%`),
orderBy: asc(apps.name), orderBy: asc(apps.name),
+49 -9
View File
@@ -9,6 +9,7 @@ import type { Database } from "@homarr/db";
import { and, eq, handleTransactionsAsync, like, not } from "@homarr/db"; import { and, eq, handleTransactionsAsync, like, not } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries"; import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { groupMembers, groupPermissions, groups, onboarding, users } from "@homarr/db/schema"; import { groupMembers, groupPermissions, groups, onboarding, users } from "@homarr/db/schema";
import type { GroupPermissionKey } from "@homarr/definitions";
import { everyoneGroup } from "@homarr/definitions"; import { everyoneGroup } from "@homarr/definitions";
import { byIdSchema, paginatedSchema } from "@homarr/validation/common"; import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
import { import {
@@ -22,6 +23,13 @@ import {
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, protectedProcedure } from "../trpc"; 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({ export const groupRouter = createTRPCRouter({
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => { getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
const dbGroups = await ctx.db.query.groups.findMany({ const dbGroups = await ctx.db.query.groups.findMany({
@@ -302,16 +310,48 @@ export const groupRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId); await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await ctx.db.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId)); const permissions = [
...new Set(
input.permissions.flatMap((permission) => {
const parent = createPermissionParents.get(permission);
return parent ? [permission, parent] : [permission];
}),
),
];
if (input.permissions.length > 0) { await handleTransactionsAsync(ctx.db, {
await ctx.db.insert(groupPermissions).values( async handleAsync(db, schema) {
input.permissions.map((permission) => ({ await db.transaction(async (transaction) => {
groupId: input.groupId, await transaction.delete(schema.groupPermissions).where(eq(schema.groupPermissions.groupId, input.groupId));
permission, if (permissions.length === 0) {
})), return;
); }
} await transaction.insert(schema.groupPermissions).values(
permissions.map((permission) => ({
groupId: input.groupId,
permission,
})),
);
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId)).run();
if (permissions.length === 0) {
return;
}
transaction
.insert(groupPermissions)
.values(
permissions.map((permission) => ({
groupId: input.groupId,
permission,
})),
)
.run();
});
},
});
}), }),
transferOwnership: permissionRequiredProcedure transferOwnership: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
+39 -3
View File
@@ -1,4 +1,6 @@
import { isProviderEnabled } from "@homarr/auth/server"; import { isProviderEnabled } from "@homarr/auth/server";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import type { Database } from "@homarr/db";
import { db, eq, inArray, or } from "@homarr/db"; import { db, eq, inArray, or } from "@homarr/db";
import { import {
apps, apps,
@@ -6,6 +8,8 @@ import {
boardUserPermissions, boardUserPermissions,
groupMembers, groupMembers,
groups, groups,
integrationGroupPermissions,
integrationUserPermissions,
integrations, integrations,
invites, invites,
medias, medias,
@@ -16,6 +20,33 @@ import type { TranslationObject } from "@homarr/translation";
import { createTRPCRouter, publicProcedure } from "../trpc"; import { createTRPCRouter, publicProcedure } from "../trpc";
const getFullAccessIntegrationIdsAsync = async (db: Database, userId: string) => {
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId),
});
const accessibleIntegrations = await db.query.integrations.findMany({
columns: {
id: true,
},
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, userId),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
});
return accessibleIntegrations
.filter((integration) => constructIntegrationPermissions(integration, null).hasFullAccess)
.map((integration) => integration.id);
};
interface HomeStatistic { interface HomeStatistic {
titleKey: keyof TranslationObject["management"]["page"]["home"]["statistic"]; titleKey: keyof TranslationObject["management"]["page"]["home"]["statistic"];
subtitleKey: keyof TranslationObject["management"]["page"]["home"]["statisticLabel"]; subtitleKey: keyof TranslationObject["management"]["page"]["home"]["statisticLabel"];
@@ -92,16 +123,21 @@ export const homeRouter = createTRPCRouter({
}); });
} }
if (ctx.session?.user.permissions.includes("integration-create")) { const hasGlobalFullIntegrationAccess = ctx.session?.user.permissions.includes("integration-full-all") ?? false;
const fullAccessIntegrationIds =
hasGlobalFullIntegrationAccess || !ctx.session?.user
? []
: await getFullAccessIntegrationIdsAsync(ctx.db, ctx.session.user.id);
if (hasGlobalFullIntegrationAccess || fullAccessIntegrationIds.length > 0) {
statistics.push({ statistics.push({
titleKey: "integration", titleKey: "integration",
subtitleKey: "resources", subtitleKey: "resources",
count: await db.$count(integrations), count: hasGlobalFullIntegrationAccess ? await db.$count(integrations) : fullAccessIntegrationIds.length,
path: "/manage/integrations", path: "/manage/integrations",
}); });
} }
if (ctx.session?.user.permissions.includes("app-create")) { if (ctx.session?.user.permissions.includes("app-modify-all")) {
statistics.push({ statistics.push({
titleKey: "app", titleKey: "app",
subtitleKey: "resources", subtitleKey: "resources",
@@ -1,12 +1,13 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { Session } from "@homarr/auth";
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server"; import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
import { constructIntegrationPermissions } from "@homarr/auth/shared"; import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { createId, objectEntries } from "@homarr/common"; import { createId, objectEntries } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Database } from "@homarr/db"; import type { Database, SQL } from "@homarr/db";
import { and, asc, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db"; import { and, asc, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
import { import {
apps, apps,
@@ -17,7 +18,7 @@ import {
integrationSecrets, integrationSecrets,
integrationUserPermissions, integrationUserPermissions,
} from "@homarr/db/schema"; } from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions"; import type { GroupPermissionKey, IntegrationPermission, IntegrationSecretKind } from "@homarr/definitions";
import { import {
getIntegrationKindsByCategory, getIntegrationKindsByCategory,
getPermissionsWithParents, getPermissionsWithParents,
@@ -71,94 +72,18 @@ export const integrationRouter = createTRPCRouter({
}, },
}) })
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({ return await getAccessibleIntegrationsAsync(ctx);
where: eq(groupMembers.userId, ctx.session.user.id),
});
const integrations = await ctx.db.query.integrations.findMany({
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
});
return integrations
.map((integration) => {
const permissions = integration.userPermissions
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
},
};
})
.toSorted(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}), }),
allThatSupportSearch: protectedProcedure.query(async ({ ctx }) => { allThatSupportSearch: protectedProcedure.query(async ({ ctx }) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({ return await getAccessibleIntegrationsAsync(
where: eq(groupMembers.userId, ctx.session.user.id), ctx,
}); inArray(
const integrationsFromDb = await ctx.db.query.integrations.findMany({
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
where: inArray(
integrations.kind, integrations.kind,
objectEntries(integrationDefs) objectEntries(integrationDefs)
.filter(([_, integration]) => [...integration.category].includes("search")) .filter(([_, integration]) => [...integration.category].includes("search"))
.map(([kind, _]) => kind), .map(([kind, _]) => kind),
), ),
}); );
return integrationsFromDb
.map((integration) => {
const permissions = integration.userPermissions
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
},
};
})
.toSorted(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}), }),
allOfGivenCategory: protectedProcedure allOfGivenCategory: protectedProcedure
.input( .input(
@@ -167,49 +92,10 @@ export const integrationRouter = createTRPCRouter({
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({ return await getAccessibleIntegrationsAsync(
where: eq(groupMembers.userId, ctx.session.user.id), ctx,
}); inArray(integrations.kind, getIntegrationKindsByCategory(input.category)),
);
const intergrationKinds = getIntegrationKindsByCategory(input.category);
const integrationsFromDb = await ctx.db.query.integrations.findMany({
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
where: inArray(integrations.kind, intergrationKinds),
});
return integrationsFromDb
.map((integration) => {
const permissions = integration.userPermissions
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
},
};
})
.toSorted(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}), }),
search: protectedProcedure search: protectedProcedure
.meta({ .meta({
@@ -226,11 +112,29 @@ export const integrationRouter = createTRPCRouter({
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
return await ctx.db.query.integrations.findMany({ const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session.user.id),
});
const integrationsFromDb = await ctx.db.query.integrations.findMany({
where: like(integrations.name, `%${input.query}%`), where: like(integrations.name, `%${input.query}%`),
orderBy: asc(integrations.name), orderBy: asc(integrations.name),
limit: input.limit, with: getIntegrationAccessRelationsAsync(
ctx.session.user.id,
groupsOfCurrentUser.map((group) => group.groupId),
),
}); });
return integrationsFromDb
.filter((integration) => hasIntegrationQueryAccess(ctx.session, integration))
.toSorted(
(integrationA, integrationB) =>
integrationA.name.localeCompare(integrationB.name) || integrationA.id.localeCompare(integrationB.id),
)
.slice(0, input.limit)
.map(
({ userPermissions: _userPermissions, groupPermissions: _groupPermissions, ...integration }) => integration,
);
}), }),
// This is used to get the integrations by their ids it's public because it's needed to get integrations data in the boards // This is used to get the integrations by their ids it's public because it's needed to get integrations data in the boards
byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => { byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => {
@@ -785,6 +689,66 @@ export const integrationRouter = createTRPCRouter({
}), }),
}); });
const globalIntegrationPermissions = [
"integration-use-all",
"integration-interact-all",
"integration-full-all",
] satisfies GroupPermissionKey[];
const hasGlobalIntegrationAccess = (session: Session | null) =>
globalIntegrationPermissions.some((permission) => session?.user.permissions.includes(permission) ?? false);
const hasIntegrationQueryAccess = (
session: Session | null,
integration: {
userPermissions: { permission: IntegrationPermission }[];
groupPermissions: { permission: IntegrationPermission }[];
},
) => hasGlobalIntegrationAccess(session) || constructIntegrationPermissions(integration, session).hasUseAccess;
const getIntegrationAccessRelationsAsync = (userId: string, groupIds: string[]) => ({
userPermissions: {
where: eq(integrationUserPermissions.userId, userId),
},
groupPermissions: {
where: inArray(integrationGroupPermissions.groupId, groupIds),
},
});
const getAccessibleIntegrationsAsync = async (ctx: { db: Database; session: Session }, where?: SQL) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session.user.id),
});
const integrationsFromDb = await ctx.db.query.integrations.findMany({
where,
with: getIntegrationAccessRelationsAsync(
ctx.session.user.id,
groupsOfCurrentUser.map((group) => group.groupId),
),
});
return integrationsFromDb
.filter((integration) => hasIntegrationQueryAccess(ctx.session, integration))
.map((integration) => {
const permissions = constructIntegrationPermissions(integration, ctx.session);
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions,
};
})
.toSorted(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind) ||
integrationA.name.localeCompare(integrationB.name) ||
integrationA.id.localeCompare(integrationB.id),
);
};
interface IntegrationRouterContext { interface IntegrationRouterContext {
db: Database; db: Database;
session: Parameters<typeof hasQueryAccessToIntegrationsAsync>[2]; session: Parameters<typeof hasQueryAccessToIntegrationsAsync>[2];
+54 -3
View File
@@ -33,7 +33,7 @@ describe("all should return all apps", () => {
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
deviceType: undefined, deviceType: undefined,
session: createDefaultSession(), session: createDefaultSession(["app-modify-all"]),
}); });
await db.insert(apps).values([ await db.insert(apps).values([
@@ -74,6 +74,20 @@ describe("all should return all apps", () => {
// Assert // Assert
await expect(actAsync()).rejects.toThrow("UNAUTHORIZED"); await expect(actAsync()).rejects.toThrow("UNAUTHORIZED");
}); });
test("should throw FORBIDDEN without app-modify-all permission", async () => {
// Arrange
const caller = appRouter.createCaller({
db: createDb(),
deviceType: undefined,
session: createDefaultSession(),
});
// Act
const actAsync = async () => await caller.all();
// Assert
await expect(actAsync()).rejects.toThrow("Permission denied");
});
}); });
describe("getPaginated should return apps to authenticated users", () => { describe("getPaginated should return apps to authenticated users", () => {
@@ -87,12 +101,12 @@ describe("getPaginated should return apps to authenticated users", () => {
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("UNAUTHORIZED"); await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("UNAUTHORIZED");
}); });
test("should return apps without board permissions", async () => { test("should return apps with app-modify-all permission", async () => {
const db = createDb(); const db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
deviceType: undefined, deviceType: undefined,
session: createDefaultSession(), session: createDefaultSession(["app-modify-all"]),
}); });
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" }); await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
@@ -101,6 +115,43 @@ describe("getPaginated should return apps to authenticated users", () => {
items: [{ id: "1" }], items: [{ id: "1" }],
}); });
}); });
test("should throw FORBIDDEN without app-modify-all permission", async () => {
const caller = appRouter.createCaller({
db: createDb(),
deviceType: undefined,
session: createDefaultSession(),
});
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("Permission denied");
});
});
describe("search should require app-modify-all or board-modify-all permission", () => {
test("should throw FORBIDDEN without app-modify-all or board-modify-all permission", async () => {
const caller = appRouter.createCaller({
db: createDb(),
deviceType: undefined,
session: createDefaultSession(),
});
await expect(caller.search({ query: "Homarr" })).rejects.toThrow("Permission denied");
});
test("should return apps with board-modify-all permission", 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" });
const result = await caller.search({ query: "Homarr" });
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("1");
});
}); });
describe("byId should return an app by id", () => { describe("byId should return an app by id", () => {
+112
View File
@@ -449,6 +449,118 @@ describe("savePermissions should save permissions for group", () => {
expect(permissions.map(({ permission }) => permission)).toEqual(["integration-use-all", "board-full-all"]); 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 () => {
// 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: ["app-create"],
});
// Assert
const permissions = await db.query.groupPermissions.findMany({
where: eq(groupPermissions.groupId, groupId),
});
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["app-create", "app-modify-all"]);
});
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 });
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "Group",
position: 1,
});
// Act
await caller.savePermissions({
groupId,
permissions: ["integration-create"],
});
// 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",
]);
});
test("with a create permission and its parent it should persist exactly two rows", 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: ["app-create", "app-modify-all"],
});
// Assert
const permissions = await db.query.groupPermissions.findMany({
where: eq(groupPermissions.groupId, groupId),
});
expect(permissions.length).toBe(2);
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["app-create", "app-modify-all"]);
});
test("with an empty permission list it should clear all existing permissions", 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,
});
await db.insert(groupPermissions).values([
{ groupId, permission: "admin" },
{ groupId, permission: "board-full-all" },
]);
// Act
await caller.savePermissions({
groupId,
permissions: [],
});
// Assert
const permissions = await db.query.groupPermissions.findMany({
where: eq(groupPermissions.groupId, groupId),
});
expect(permissions.length).toBe(0);
});
test("with non existing group it should throw not found error", async () => { test("with non existing group it should throw not found error", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
@@ -57,6 +57,171 @@ describe("all should return all integrations", () => {
expect(result[0]!.kind).toBe("plex"); expect(result[0]!.kind).toBe("plex");
expect(result[1]!.kind).toBe("homeAssistant"); expect(result[1]!.kind).toBe("homeAssistant");
}); });
test("with integration full access and no per-integration rows should return all integrations with full access", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-full-all"]),
});
await db.insert(integrations).values([
{
id: "1",
name: "Home assistant",
kind: "homeAssistant",
url: "http://homeassist.local",
},
{
id: "2",
name: "Home plex server",
kind: "plex",
url: "http://plex.local",
},
]);
const result = await caller.all();
expect(result).toHaveLength(2);
for (const integration of result) {
expect(integration.permissions.hasFullAccess).toBe(true);
}
});
test("with integration use access should return all integrations", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-use-all"]),
});
await db.insert(integrations).values([
{
id: "1",
name: "Home assistant",
kind: "homeAssistant",
url: "http://homeassist.local",
},
{
id: "2",
name: "Home plex server",
kind: "plex",
url: "http://plex.local",
},
]);
const result = await caller.all();
expect(result.length).toBe(2);
});
test("without global integration access should only return integrations with use access", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(),
});
await db.insert(integrations).values([
{
id: "1",
name: "Home assistant",
kind: "homeAssistant",
url: "http://homeassist.local",
},
{
id: "2",
name: "Home plex server",
kind: "plex",
url: "http://plex.local",
},
]);
await db.insert(users).values({ id: defaultUserId });
await db.insert(integrationUserPermissions).values({
integrationId: "1",
userId: defaultUserId,
permission: "use",
});
const result = await caller.all();
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("1");
expect(result[0]!.permissions.hasUseAccess).toBe(true);
});
test("with a per-integration interact grant should return the integration with use access", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(),
});
await db.insert(integrations).values([
{
id: "1",
name: "Home assistant",
kind: "homeAssistant",
url: "http://homeassist.local",
},
{
id: "2",
name: "Home plex server",
kind: "plex",
url: "http://plex.local",
},
]);
await db.insert(users).values({ id: defaultUserId });
await db.insert(integrationUserPermissions).values({
integrationId: "1",
userId: defaultUserId,
permission: "interact",
});
const result = await caller.all();
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("1");
expect(result[0]!.permissions.hasUseAccess).toBe(true);
});
});
describe("search should return accessible integrations", () => {
test("without global integration access should only return integrations with use access as plain rows", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(),
});
await db.insert(integrations).values([
{
id: "1",
name: "Home assistant",
kind: "homeAssistant",
url: "http://homeassist.local",
},
{
id: "2",
name: "Home plex server",
kind: "plex",
url: "http://plex.local",
},
]);
await db.insert(users).values({ id: defaultUserId });
await db.insert(integrationUserPermissions).values({
integrationId: "1",
userId: defaultUserId,
permission: "use",
});
const result = await caller.search({ query: "Home" });
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("1");
expect(result[0]).not.toHaveProperty("userPermissions");
expect(result[0]).not.toHaveProperty("groupPermissions");
});
}); });
describe("mediaRequestSearchTargets should return accessible media search integrations", () => { describe("mediaRequestSearchTargets should return accessible media search integrations", () => {
+41
View File
@@ -117,3 +117,44 @@ export const groupPermissionKeys = objectKeys(groupPermissions).reduce((acc, key
} }
return acc; return acc;
}, [] as GroupPermissionKey[]); }, [] 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}.
*/
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[] }>;
export type PermissionMatrixCategory = keyof typeof permissionMatrix;
export type PermissionMatrixState = Record<PermissionMatrixCategory, { level: number }>;
export const permissionsToMatrixState = (permissions: GroupPermissionKey[]): PermissionMatrixState => {
const permissionSet = new Set(permissions);
return objectKeys(permissionMatrix).reduce((acc, category) => {
let level = 0;
permissionMatrix[category].levels.forEach((key, index) => {
if (permissionSet.has(key)) {
level = index + 1;
}
});
acc[category] = { level };
return acc;
}, {} as PermissionMatrixState);
};
export const matrixStateToPermissions = (state: PermissionMatrixState): GroupPermissionKey[] => {
return objectKeys(permissionMatrix).reduce((acc, category) => {
acc.push(...permissionMatrix[category].levels.slice(0, state[category].level));
return acc;
}, [] as GroupPermissionKey[]);
};
@@ -1,7 +1,12 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import type { GroupPermissionKey } from "../permissions"; import type { GroupPermissionKey } from "../permissions";
import { getPermissionsWithChildren, getPermissionsWithParents } from "../permissions"; import {
getPermissionsWithChildren,
getPermissionsWithParents,
matrixStateToPermissions,
permissionsToMatrixState,
} from "../permissions";
describe("getPermissionsWithParents should return the correct permissions", () => { describe("getPermissionsWithParents should return the correct permissions", () => {
test.each([ test.each([
@@ -45,3 +50,75 @@ describe("getPermissionsWithChildren should return the correct permissions", ()
expect(getPermissionsWithChildren(input)).toEqual(expect.arrayContaining(expectedOutput)); expect(getPermissionsWithChildren(input)).toEqual(expect.arrayContaining(expectedOutput));
}); });
}); });
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);
});
test("resolves the admin category", () => {
expect(permissionsToMatrixState(["admin"]).admin.level).toBe(1);
});
test("resolves an empty selection to all zero", () => {
const state = permissionsToMatrixState([]);
expect(Object.values(state).every(({ level }) => level === 0)).toBe(true);
});
});
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"]);
});
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"]);
});
});
describe("matrix helpers should round-trip stably", () => {
test.each([
[["board-view-all", "board-modify-all"]],
[["integration-use-all", "integration-interact-all", "integration-full-all"]],
[["admin"]],
[[]],
[
[
"app-use-all",
"app-modify-all",
"search-engine-modify-all",
"search-engine-full-all",
"media-view-all",
"other-view-logs",
],
],
] satisfies [GroupPermissionKey[]][])("expect %s to round-trip", (permissions) => {
const roundTripped = matrixStateToPermissions(permissionsToMatrixState(permissions));
expect(roundTripped).toEqual(expect.arrayContaining(permissions));
expect(roundTripped).toHaveLength(permissions.length);
});
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"]);
});
});
+23 -2
View File
@@ -598,6 +598,23 @@
"description": "Allow members to manage and delete any search engine" "description": "Allow members to manage and delete any search engine"
} }
} }
},
"matrix": {
"none": "None",
"impliedHint": "also grants: {permissions}",
"grantedViaAdmin": "Granted via Administrator",
"preset": {
"label": "Presets",
"viewer": "Viewer",
"editor": "Editor",
"admin": "Admin",
"custom": "Custom"
},
"effective": {
"title": "Effective permissions",
"description": "Everything members of this group will be able to do, including implied permissions",
"empty": "No permissions selected"
}
} }
}, },
"memberNotice": { "memberNotice": {
@@ -1483,6 +1500,7 @@
"title": "Integrations", "title": "Integrations",
"ariaLabel": "Configured integrations", "ariaLabel": "Configured integrations",
"search": "Search integrations", "search": "Search integrations",
"scopedNote": "Showing only integrations you have full access to",
"action": { "action": {
"edit": "Edit {name}", "edit": "Edit {name}",
"delete": "Delete {name}" "delete": "Delete {name}"
@@ -1493,7 +1511,9 @@
"action": "Create your first integration", "action": "Create your first integration",
"filteredTitle": "No integrations match your search", "filteredTitle": "No integrations match your search",
"filteredDescription": "No integrations match \"{search}\". Try another name, type, or URL.", "filteredDescription": "No integrations match \"{search}\". Try another name, type, or URL.",
"clearSearch": "Clear search" "clearSearch": "Clear search",
"noPermissionTitle": "No manageable integrations",
"noPermissionDescription": "You don't have full management access to any integrations. Ask your administrator."
} }
}, },
"create": { "create": {
@@ -5947,7 +5967,8 @@
}, },
"notFound": { "notFound": {
"title": "Not Found", "title": "Not Found",
"text": "Could not find requested resource" "text": "Could not find requested resource",
"backToHome": "Back to management home"
}, },
"error": { "error": {
"title": "This page couldn't load", "title": "This page couldn't load",
+1 -1
View File
@@ -70,7 +70,7 @@ func BuildPlan(opts Options) (*Plan, error) {
} }
p.Env = append(p.Env, opts.Env...) p.Env = append(p.Env, opts.Env...)
if opts.Demo { if opts.Demo {
p.Env = append(p.Env, "DEMO_MODE=true", "UNSAFE_ENABLE_MOCK_INTEGRATION=true") p.Env = append(p.Env, "DEMO_MODE=true", "DEMO_READ_ONLY=false", "UNSAFE_ENABLE_MOCK_INTEGRATION=true")
p.Label += " (demo)" p.Label += " (demo)"
} }
return p, nil return p, nil