feat: add integration access settings (#725)

* feat: add integration access settings

* fix: typecheck and test issues

* fix: test timeout

* chore: address pull request feedback

* chore: add throw if action forbidden for integration permissions

* fix: unable to create new migrations because of duplicate prevId in sqlite snapshots

* chore: add sqlite migration for integration permissions

* test: add unit tests for integration access

* test: add permission checks to integration router tests

* test: add unit test for integration permissions

* chore: add mysql migration

* fix: format issues
This commit is contained in:
Meier Lukas
2024-07-08 00:00:37 +02:00
committed by GitHub
parent be711149f7
commit 408cdeb5c3
50 changed files with 4392 additions and 615 deletions

View File

@@ -0,0 +1,189 @@
import { useState } from "react";
import { Group, Stack, Tabs } from "@mantine/core";
import { IconUser, IconUserDown, IconUsersGroup } from "@tabler/icons-react";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { CountBadge } from "@homarr/ui";
import { AccessProvider } from "./context";
import type { AccessFormType } from "./form";
import { GroupAccessForm } from "./group-access-form";
import { InheritAccessTable } from "./inherit-access-table";
import { UsersAccessForm } from "./user-access-form";
interface GroupAccessPermission<TPermission extends string> {
permission: TPermission;
group: {
id: string;
name: string;
};
}
interface UserAccessPermission<TPermission extends string> {
permission: TPermission;
user: {
name: string | null;
image: string | null;
id: string;
};
}
interface SimpleMutation<TPermission extends string> {
mutate: (
props: { entityId: string; permissions: { principalId: string; permission: TPermission }[] },
options: { onSuccess: () => void },
) => void;
isPending: boolean;
}
export interface AccessQueryData<TPermission extends string> {
inherited: GroupAccessPermission<GroupPermissionKey>[];
groups: GroupAccessPermission<TPermission>[];
users: UserAccessPermission<TPermission>[];
}
interface Props<TPermission extends string> {
permission: {
items: readonly TPermission[];
default: TPermission;
icons: Record<TPermission, TablerIcon>;
groupPermissionMapping: Record<TPermission, GroupPermissionKey>;
fullAccessGroupPermission: GroupPermissionKey;
};
query: {
data: AccessQueryData<TPermission>;
invalidate: () => Promise<void>;
};
groupsMutation: SimpleMutation<TPermission>;
usersMutation: SimpleMutation<TPermission>;
entity: {
id: string;
ownerId: string | null;
owner: {
id: string;
name: string | null;
image: string | null;
} | null;
};
translate: (key: TPermission) => string;
}
export const AccessSettings = <TPermission extends string>({
permission,
query,
groupsMutation,
usersMutation,
entity,
translate,
}: Props<TPermission>) => {
const [counts, setCounts] = useState({
user: query.data.users.length + (entity.owner ? 1 : 0),
group: query.data.groups.length,
});
const handleGroupSubmit = (values: AccessFormType<TPermission>) => {
groupsMutation.mutate(
{
entityId: entity.id,
permissions: values.items,
},
{
onSuccess() {
void query.invalidate();
},
},
);
};
const handleUserSubmit = (values: AccessFormType<TPermission>) => {
usersMutation.mutate(
{
entityId: entity.id,
permissions: values.items,
},
{
onSuccess() {
void query.invalidate();
},
},
);
};
return (
<AccessProvider<TPermission>
defaultPermission={permission.default}
icons={permission.icons}
permissions={permission.items}
translate={translate}
>
<Stack>
<Tabs color="red" defaultValue="user">
<Tabs.List grow>
<TabItem value="user" count={counts.user} icon={IconUser} />
<TabItem value="group" count={counts.group} icon={IconUsersGroup} />
<TabItem value="inherited" count={query.data.inherited.length} icon={IconUserDown} />
</Tabs.List>
<Tabs.Panel value="user">
<UsersAccessForm<TPermission>
entity={entity}
accessQueryData={query.data}
handleCountChange={(callback) =>
setCounts(({ user, ...others }) => ({
user: callback(user),
...others,
}))
}
handleSubmit={handleUserSubmit}
isPending={usersMutation.isPending}
/>
</Tabs.Panel>
<Tabs.Panel value="group">
<GroupAccessForm<TPermission>
accessQueryData={query.data}
handleCountChange={(callback) =>
setCounts(({ group, ...others }) => ({
group: callback(group),
...others,
}))
}
handleSubmit={handleGroupSubmit}
isPending={groupsMutation.isPending}
/>
</Tabs.Panel>
<Tabs.Panel value="inherited">
<InheritAccessTable<TPermission>
accessQueryData={query.data}
fullAccessGroupPermission={permission.fullAccessGroupPermission}
mapPermissions={permission.groupPermissionMapping}
/>
</Tabs.Panel>
</Tabs>
</Stack>
</AccessProvider>
);
};
interface TabItemProps {
value: "user" | "group" | "inherited";
count: number;
icon: TablerIcon;
}
const TabItem = ({ value, icon: Icon, count }: TabItemProps) => {
const t = useScopedI18n("permission");
return (
<Tabs.Tab value={value} leftSection={<Icon stroke={1.5} size={16} />}>
<Group gap="sm">
{t(`tab.${value}`)}
<CountBadge count={count} />
</Group>
</Tabs.Tab>
);
};

View File

@@ -0,0 +1,101 @@
import type { ReactNode } from "react";
import { useCallback } from "react";
import type { SelectProps } from "@mantine/core";
import { Button, Flex, Group, Select, TableTd, TableTr, Text } from "@mantine/core";
import { Icon123, IconCheck } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { useAccessContext } from "./context";
import type { HandleCountChange } from "./form";
import { useFormContext } from "./form";
interface AccessSelectRowProps {
itemContent: ReactNode;
permission: string;
index: number;
handleCountChange: HandleCountChange;
}
export const AccessSelectRow = ({ itemContent, permission, index, handleCountChange }: AccessSelectRowProps) => {
const tRoot = useI18n();
const { icons, getSelectData } = useAccessContext();
const form = useFormContext();
const handleRemove = useCallback(() => {
form.setFieldValue(
"items",
form.values.items.filter((_, i) => i !== index),
);
handleCountChange((prev) => prev - 1);
}, [form, index, handleCountChange]);
const Icon = icons[permission] ?? Icon123;
return (
<TableTr>
<TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd>
<TableTd>
<Flex direction={{ base: "column", xs: "row" }} align={{ base: "end", xs: "center" }} wrap="nowrap">
<Select
allowDeselect={false}
flex="1"
leftSection={<Icon size="1rem" />}
renderOption={RenderOption}
variant="unstyled"
data={getSelectData()}
{...form.getInputProps(`items.${index}.permission`)}
/>
<Button size="xs" variant="subtle" onClick={handleRemove}>
{tRoot("common.action.remove")}
</Button>
</Flex>
</TableTd>
</TableTr>
);
};
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: "1rem",
};
const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
const { icons } = useAccessContext();
const Icon = icons[option.value] ?? Icon123;
return (
<Group flex="1" gap="xs" wrap="nowrap">
<Icon {...iconProps} />
{option.label}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
</Group>
);
};
interface AccessDisplayRowProps {
itemContent: ReactNode;
permission: string;
}
export const AccessDisplayRow = ({ itemContent, permission }: AccessDisplayRowProps) => {
const { icons, translate } = useAccessContext();
const Icon = icons[permission] ?? Icon123;
return (
<TableTr>
<TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd>
<TableTd>
<Group gap={0}>
<Flex w={34} h={34} align="center" justify="center">
<Icon size="1rem" color="var(--input-section-color, var(--mantine-color-dimmed))" />
</Flex>
<Text size="sm">{translate(permission)}</Text>
</Group>
</TableTd>
</TableTr>
);
};

View File

@@ -0,0 +1,53 @@
import { createContext, useContext } from "react";
import type { TablerIcon } from "@tabler/icons-react";
const AccessContext = createContext<{
permissions: readonly string[];
icons: Record<string, TablerIcon>;
translate: (key: string) => string;
defaultPermission: string;
} | null>(null);
export const useAccessContext = <TPermission extends string>() => {
const context = useContext(AccessContext);
if (!context) {
throw new Error("useAccessContext must be used within a AccessProvider");
}
return {
icons: context.icons as Record<TPermission, TablerIcon>,
getSelectData: () =>
context.permissions.map((permission) => ({ value: permission, label: context.translate(permission) })),
permissions: context.permissions as readonly TPermission[],
translate: context.translate as (key: TPermission) => string,
defaultPermission: context.defaultPermission as TPermission,
};
};
export const AccessProvider = <TPermission extends string>({
defaultPermission,
permissions,
icons,
translate,
children,
}: {
defaultPermission: TPermission;
permissions: readonly TPermission[];
icons: Record<TPermission, TablerIcon>;
translate: (key: TPermission) => string;
children: React.ReactNode;
}) => {
return (
<AccessContext.Provider
value={{
defaultPermission,
permissions,
icons,
translate: translate as (key: string) => string,
}}
>
{children}
</AccessContext.Provider>
);
};

View File

@@ -0,0 +1,12 @@
import { createFormContext } from "@homarr/form";
export interface AccessFormType<TPermission extends string> {
items: {
principalId: string;
permission: TPermission;
}[];
}
export const [FormProvider, useFormContext, useForm] = createFormContext<AccessFormType<string>>();
export type HandleCountChange = (callback: (prev: number) => number) => void;

View File

@@ -0,0 +1,101 @@
import { useState } from "react";
import Link from "next/link";
import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { AccessQueryData } from "./access-settings";
import { AccessSelectRow } from "./access-table-rows";
import { useAccessContext } from "./context";
import type { AccessFormType } from "./form";
import { FormProvider, useForm } from "./form";
import { GroupSelectModal } from "./group-select-modal";
import type { FormProps } from "./user-access-form";
export const GroupAccessForm = <TPermission extends string>({
accessQueryData,
handleCountChange,
handleSubmit,
isPending,
}: Omit<FormProps<TPermission>, "entity">) => {
const { defaultPermission } = useAccessContext();
const [groups, setGroups] = useState<Map<string, AccessQueryData<string>["groups"][number]["group"]>>(
new Map(accessQueryData.groups.map(({ group }) => [group.id, group])),
);
const { openModal } = useModalAction(GroupSelectModal);
const t = useI18n();
const tPermissions = useScopedI18n("permission");
const form = useForm({
initialValues: {
items: accessQueryData.groups.map(({ group, permission }) => ({
principalId: group.id,
permission,
})),
},
});
const handleAddUser = () => {
openModal({
presentGroupIds: form.values.items.map(({ principalId: id }) => id),
onSelect: (group) => {
setGroups((prev) => new Map(prev).set(group.id, group));
form.setFieldValue("items", [
{
principalId: group.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
},
});
};
return (
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
<FormProvider form={form}>
<Stack pt="sm">
<Table>
<TableThead>
<TableTr>
<TableTh style={{ whiteSpace: "nowrap" }}>{tPermissions("field.group.label")}</TableTh>
<TableTh>{tPermissions("field.permission.label")}</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{form.values.items.map((row, index) => (
<AccessSelectRow
key={row.principalId}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemContent={<GroupItemContent group={groups.get(row.principalId)!} />}
permission={row.permission}
index={index}
handleCountChange={handleCountChange}
/>
))}
</TableTbody>
</Table>
<Group justify="space-between">
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
{t("common.action.add")}
</Button>
<Button type="submit" loading={isPending} color="teal">
{t("permission.action.saveGroup")}
</Button>
</Group>
</Stack>
</FormProvider>
</form>
);
};
export const GroupItemContent = ({ group }: { group: AccessQueryData<string>["groups"][number]["group"] }) => {
return (
<Anchor component={Link} href={`/manage/users/groups/${group.id}`} size="sm" style={{ whiteSpace: "nowrap" }}>
{group.name}
</Anchor>
);
};

View File

@@ -0,0 +1,67 @@
import { useState } from "react";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface InnerProps {
presentGroupIds: string[];
onSelect: (props: { id: string; name: string }) => void | Promise<void>;
confirmLabel?: string;
}
interface GroupSelectFormType {
groupId: string;
}
export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const { data: groups, isPending } = clientApi.group.selectable.useQuery();
const [loading, setLoading] = useState(false);
const form = useForm<GroupSelectFormType>();
const handleSubmitAsync = async (values: GroupSelectFormType) => {
const currentGroup = groups?.find((group) => group.id === values.groupId);
if (!currentGroup) return;
setLoading(true);
await innerProps.onSelect({
id: currentGroup.id,
name: currentGroup.name,
});
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<Select
{...form.getInputProps("groupId")}
label={t("group.action.select.label")}
clearable
searchable
leftSection={isPending ? <Loader size="xs" /> : undefined}
nothingFoundMessage={t("group.action.select.notFound")}
limit={5}
data={groups
?.filter((group) => !innerProps.presentGroupIds.includes(group.id))
.map((group) => ({ value: group.id, label: group.name }))}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("permission.groupSelect.title"),
});

View File

@@ -0,0 +1,57 @@
import { Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
import type { GroupPermissionKey } from "@homarr/definitions";
import { getPermissionsWithChildren } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import type { AccessQueryData } from "./access-settings";
import { AccessDisplayRow } from "./access-table-rows";
import { GroupItemContent } from "./group-access-form";
export interface InheritTableProps<TPermission extends string> {
accessQueryData: AccessQueryData<TPermission>;
mapPermissions: Partial<Record<GroupPermissionKey, TPermission>>;
fullAccessGroupPermission: GroupPermissionKey;
}
export const InheritAccessTable = <TPermission extends string>({
accessQueryData,
mapPermissions,
fullAccessGroupPermission,
}: InheritTableProps<TPermission>) => {
const tPermissions = useScopedI18n("permission");
return (
<Stack pt="sm">
<Table>
<TableThead>
<TableTr>
<TableTh>{tPermissions("field.user.label")}</TableTh>
<TableTh>{tPermissions("field.permission.label")}</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{accessQueryData.inherited.map(({ group, permission }) => {
const entityPermission =
permission in mapPermissions
? mapPermissions[permission]
: getPermissionsWithChildren([permission]).includes(fullAccessGroupPermission)
? "full"
: null;
if (!entityPermission) {
return null;
}
return (
<AccessDisplayRow
key={group.id}
itemContent={<GroupItemContent group={group} />}
permission={entityPermission}
/>
);
})}
</TableTbody>
</Table>
</Stack>
);
};

View File

@@ -0,0 +1,136 @@
import { useState } from "react";
import Link from "next/link";
import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import type { AccessQueryData } from "./access-settings";
import { AccessDisplayRow, AccessSelectRow } from "./access-table-rows";
import { useAccessContext } from "./context";
import type { AccessFormType, HandleCountChange } from "./form";
import { FormProvider, useForm } from "./form";
import { UserSelectModal } from "./user-select-modal";
export interface FormProps<TPermission extends string> {
entity: {
id: string;
ownerId: string | null;
owner: {
id: string;
name: string | null;
image: string | null;
} | null;
};
accessQueryData: AccessQueryData<TPermission>;
handleCountChange: HandleCountChange;
handleSubmit: (values: AccessFormType<TPermission>) => void;
isPending: boolean;
}
export const UsersAccessForm = <TPermission extends string>({
entity,
accessQueryData,
handleCountChange,
handleSubmit,
isPending,
}: FormProps<TPermission>) => {
const { defaultPermission } = useAccessContext();
const [users, setUsers] = useState<Map<string, UserItemContentProps["user"]>>(
new Map(accessQueryData.users.map(({ user }) => [user.id, user])),
);
const { openModal } = useModalAction(UserSelectModal);
const t = useI18n();
const tPermissions = useScopedI18n("permission");
const form = useForm({
initialValues: {
items: accessQueryData.users.map(({ user, permission }) => ({
principalId: user.id,
permission,
})),
},
});
const handleAddUser = () => {
const presentUserIds = form.values.items.map(({ principalId: id }) => id);
openModal({
presentUserIds: entity.ownerId ? presentUserIds.concat(entity.ownerId) : presentUserIds,
onSelect: (user) => {
setUsers((prev) => new Map(prev).set(user.id, user));
form.setFieldValue("items", [
{
principalId: user.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
},
});
};
return (
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
<FormProvider form={form}>
<Stack pt="sm">
<Table>
<TableThead>
<TableTr>
<TableTh>{tPermissions("field.user.label")}</TableTh>
<TableTh>{tPermissions("field.permission.label")}</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{entity.owner && (
<AccessDisplayRow itemContent={<UserItemContent user={entity.owner} />} permission="full" />
)}
{form.values.items.map((row, index) => (
<AccessSelectRow
key={row.principalId}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemContent={<UserItemContent user={users.get(row.principalId)!} />}
permission={row.permission}
index={index}
handleCountChange={handleCountChange}
/>
))}
</TableTbody>
</Table>
<Group justify="space-between">
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
{t("common.action.add")}
</Button>
<Button type="submit" loading={isPending} color="teal">
{t("permission.action.saveUser")}
</Button>
</Group>
</Stack>
</FormProvider>
</form>
);
};
interface UserItemContentProps {
user: {
id: string;
name: string | null;
image: string | null;
};
}
const UserItemContent = ({ user }: UserItemContentProps) => {
return (
<Group wrap="nowrap">
<Box visibleFrom="xs">
<UserAvatar user={user} size="sm" />
</Box>
<Anchor component={Link} href={`/manage/users/${user.id}`} size="sm" style={{ whiteSpace: "nowrap" }}>
{user.name}
</Anchor>
</Group>
);
};

View File

@@ -0,0 +1,97 @@
import { useState } from "react";
import type { SelectProps } from "@mantine/core";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
interface InnerProps {
presentUserIds: string[];
onSelect: (props: { id: string; name: string; image: string }) => void | Promise<void>;
confirmLabel?: string;
}
interface UserSelectFormType {
userId: string;
}
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const { data: users, isPending } = clientApi.user.selectable.useQuery();
const [loading, setLoading] = useState(false);
const form = useForm<UserSelectFormType>();
const handleSubmitAsync = async (values: UserSelectFormType) => {
const currentUser = users?.find((user) => user.id === values.userId);
if (!currentUser) return;
setLoading(true);
await innerProps.onSelect({
id: currentUser.id,
name: currentUser.name ?? "",
image: currentUser.image ?? "",
});
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
const currentUser = users?.find((user) => user.id === form.values.userId);
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<Select
{...form.getInputProps("userId")}
label={t("user.action.select.label")}
searchable
clearable
leftSection={
isPending ? <Loader size="xs" /> : currentUser ? <UserAvatar user={currentUser} size="xs" /> : undefined
}
nothingFoundMessage={t("user.action.select.notFound")}
renderOption={createRenderOption(users ?? [])}
limit={5}
data={users
?.filter((user) => !innerProps.presentUserIds.includes(user.id))
.map((user) => ({ value: user.id, label: user.name ?? "" }))}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("permission.userSelect.title"),
});
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: "1rem",
};
const createRenderOption = (users: RouterOutputs["user"]["selectable"]): SelectProps["renderOption"] =>
function InnerRenderRoot({ option, checked }) {
const user = users.find((user) => user.id === option.value);
if (!user) return null;
return (
<Group flex="1" gap="xs">
<UserAvatar user={user} size="xs" />
{option.label}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
</Group>
);
};