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:
189
apps/nextjs/src/components/access/access-settings.tsx
Normal file
189
apps/nextjs/src/components/access/access-settings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
101
apps/nextjs/src/components/access/access-table-rows.tsx
Normal file
101
apps/nextjs/src/components/access/access-table-rows.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
53
apps/nextjs/src/components/access/context.tsx
Normal file
53
apps/nextjs/src/components/access/context.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
12
apps/nextjs/src/components/access/form.ts
Normal file
12
apps/nextjs/src/components/access/form.ts
Normal 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;
|
||||
101
apps/nextjs/src/components/access/group-access-form.tsx
Normal file
101
apps/nextjs/src/components/access/group-access-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
67
apps/nextjs/src/components/access/group-select-modal.tsx
Normal file
67
apps/nextjs/src/components/access/group-select-modal.tsx
Normal 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"),
|
||||
});
|
||||
57
apps/nextjs/src/components/access/inherit-access-table.tsx
Normal file
57
apps/nextjs/src/components/access/inherit-access-table.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
136
apps/nextjs/src/components/access/user-access-form.tsx
Normal file
136
apps/nextjs/src/components/access/user-access-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
97
apps/nextjs/src/components/access/user-select-modal.tsx
Normal file
97
apps/nextjs/src/components/access/user-select-modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user