mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-01-25 02:08:11 +00:00
chore: add manage group actions
This commit is contained in:
@@ -68,7 +68,7 @@ export const sendInvite = async (formData: FormData) => {
|
||||
? [
|
||||
{
|
||||
id: role,
|
||||
type: "role",
|
||||
type: "roles",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -131,7 +131,7 @@ export const updateInvite = async (formData: FormData) => {
|
||||
data: [
|
||||
{
|
||||
id: roleId,
|
||||
type: "role",
|
||||
type: "roles",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { auth } from "@/auth.config";
|
||||
import { getErrorMessage, parseStringify } from "@/lib";
|
||||
|
||||
export const createProviderGroup = async (formData: FormData) => {
|
||||
const session = await auth();
|
||||
const keyServer = process.env.API_BASE_URL;
|
||||
|
||||
const name = formData.get("name") as string;
|
||||
const providersJson = formData.get("providers") as string;
|
||||
const rolesJson = formData.get("roles") as string;
|
||||
|
||||
// Parse JSON strings and handle empty cases
|
||||
const providers = providersJson ? JSON.parse(providersJson) : [];
|
||||
const roles = rolesJson ? JSON.parse(rolesJson) : [];
|
||||
|
||||
// Prepare base payload
|
||||
const payload: any = {
|
||||
data: {
|
||||
type: "provider-groups",
|
||||
attributes: {
|
||||
name,
|
||||
},
|
||||
relationships: {},
|
||||
},
|
||||
};
|
||||
|
||||
// Add relationships only if there are items
|
||||
if (providers.length > 0) {
|
||||
payload.data.relationships.providers = {
|
||||
data: providers,
|
||||
};
|
||||
}
|
||||
|
||||
if (roles.length > 0) {
|
||||
payload.data.relationships.roles = {
|
||||
data: roles,
|
||||
};
|
||||
}
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
try {
|
||||
const url = new URL(`${keyServer}/provider-groups`);
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
Accept: "application/vnd.api+json",
|
||||
Authorization: `Bearer ${session?.accessToken}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
const data = await response.json();
|
||||
revalidatePath("/providers/manage-groups");
|
||||
return parseStringify(data);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: getErrorMessage(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,7 +82,7 @@ export const addRole = async (formData: FormData) => {
|
||||
const url = new URL(`${keyServer}/roles`);
|
||||
const body = JSON.stringify({
|
||||
data: {
|
||||
type: "role",
|
||||
type: "roles",
|
||||
attributes: {
|
||||
name: formData.get("name"),
|
||||
manage_users: formData.get("manage_users") === "true",
|
||||
@@ -128,7 +128,7 @@ export const updateRole = async (formData: FormData, roleId: string) => {
|
||||
const url = new URL(`${keyServer}/roles/${roleId}`);
|
||||
const body = JSON.stringify({
|
||||
data: {
|
||||
type: "role",
|
||||
type: "roles",
|
||||
id: roleId,
|
||||
attributes: {
|
||||
name: formData.get("name"),
|
||||
|
||||
@@ -119,7 +119,7 @@ export const updateUserRole = async (formData: FormData) => {
|
||||
const requestBody = {
|
||||
data: [
|
||||
{
|
||||
type: "role",
|
||||
type: "roles",
|
||||
id: roleId,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function ProviderLayout({ children }: ProviderLayoutProps) {
|
||||
href="/providers"
|
||||
/>
|
||||
<Spacer y={16} />
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-12">{children}</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,57 @@
|
||||
import React from "react";
|
||||
import { Divider } from "@nextui-org/react";
|
||||
import React, { Suspense } from "react";
|
||||
|
||||
import { Header } from "@/components/ui";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { getRoles } from "@/actions/roles";
|
||||
import { AddGroupForm } from "@/components/manage-groups/forms";
|
||||
import { SkeletonManageGroups } from "@/components/manage-groups/skeleton-manage-groups";
|
||||
import { ProviderProps, SearchParamsProps } from "@/types";
|
||||
|
||||
export default function ManageGroupsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
}) {
|
||||
const searchParamsKey = JSON.stringify(searchParams);
|
||||
|
||||
export default function ManageGroupsPage() {
|
||||
return (
|
||||
<>
|
||||
<Header title="Manage Groups" icon="fluent:people-team-24-regular" />
|
||||
</>
|
||||
<div className="grid min-h-[70vh] grid-cols-1 items-center justify-center gap-4 md:grid-cols-12">
|
||||
<div className="col-span-1 flex justify-end md:col-span-4">
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonManageGroups />}>
|
||||
<SSRAddGroupForm searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<Divider orientation="vertical" className="mx-auto h-full" />
|
||||
|
||||
<div className="col-span-1 flex justify-start md:col-span-6">
|
||||
{/* Space to add the table */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SSRAddGroupForm = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
}) => {
|
||||
const providersResponse = await getProviders({});
|
||||
const rolesResponse = await getRoles({});
|
||||
|
||||
const providersData = providersResponse?.data.map(
|
||||
(provider: ProviderProps) => ({
|
||||
id: provider.id,
|
||||
name: provider.attributes.alias,
|
||||
}),
|
||||
);
|
||||
|
||||
const rolesData = rolesResponse?.data.map((role: any) => ({
|
||||
id: role.id,
|
||||
name: role.attributes.name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<AddGroupForm providers={providersData || []} roles={rolesData || []} />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -58,7 +58,7 @@ const SSRDataTable = async ({
|
||||
// Create a dictionary for roles by user ID
|
||||
const roleDict = (usersData?.included || []).reduce(
|
||||
(acc: Record<string, any>, item: Role) => {
|
||||
if (item.type === "role") {
|
||||
if (item.type === "roles") {
|
||||
acc[item.id] = item.attributes;
|
||||
}
|
||||
return acc;
|
||||
|
||||
@@ -1,67 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Select, SelectItem } from "@nextui-org/react";
|
||||
import { Divider } from "@nextui-org/react";
|
||||
import { SaveIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { sendInvite } from "@/actions/invitations/invitation";
|
||||
import { createProviderGroup } from "@/actions/manage-groups";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomButton, CustomInput } from "@/components/ui/custom";
|
||||
import {
|
||||
CustomButton,
|
||||
CustomDropdownSelection,
|
||||
CustomInput,
|
||||
} from "@/components/ui/custom";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { ApiError } from "@/types";
|
||||
|
||||
const sendInvitationFormSchema = z.object({
|
||||
email: z.string().email("Please enter a valid email"),
|
||||
roleId: z.string().nonempty("Role is required"),
|
||||
const addGroupSchema = z.object({
|
||||
name: z.string().nonempty("Provider group name is required"),
|
||||
providers: z.array(z.string()).optional(),
|
||||
roles: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type FormValues = z.infer<typeof sendInvitationFormSchema>;
|
||||
export type FormValues = z.infer<typeof addGroupSchema>;
|
||||
|
||||
export const AddGroupForm = ({
|
||||
roles = [],
|
||||
defaultRole = "admin",
|
||||
isSelectorDisabled = false,
|
||||
providers = [],
|
||||
}: {
|
||||
roles: Array<{ id: string; name: string }>;
|
||||
defaultRole?: string;
|
||||
isSelectorDisabled: boolean;
|
||||
providers: Array<{ id: string; name: string }>;
|
||||
}) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(sendInvitationFormSchema),
|
||||
resolver: zodResolver(addGroupSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
roleId: isSelectorDisabled ? defaultRole : "",
|
||||
name: "",
|
||||
providers: [],
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
const isLoading = form.formState.isSubmitting;
|
||||
|
||||
const onSubmitClient = async (values: FormValues) => {
|
||||
const formData = new FormData();
|
||||
formData.append("email", values.email);
|
||||
formData.append("role", values.roleId);
|
||||
|
||||
console.log(values);
|
||||
try {
|
||||
const data = await sendInvite(formData);
|
||||
const formData = new FormData();
|
||||
formData.append("name", values.name);
|
||||
|
||||
if (values.providers?.length) {
|
||||
const providersData = values.providers.map((id) => ({
|
||||
id,
|
||||
type: "providers",
|
||||
}));
|
||||
formData.append("providers", JSON.stringify(providersData));
|
||||
}
|
||||
|
||||
if (values.roles?.length) {
|
||||
const rolesData = values.roles.map((id) => ({
|
||||
id,
|
||||
type: "roles",
|
||||
}));
|
||||
formData.append("roles", JSON.stringify(rolesData));
|
||||
}
|
||||
|
||||
const data = await createProviderGroup(formData);
|
||||
|
||||
if (data?.errors && data.errors.length > 0) {
|
||||
data.errors.forEach((error: ApiError) => {
|
||||
const errorMessage = error.detail;
|
||||
switch (error.source.pointer) {
|
||||
case "/data/attributes/email":
|
||||
form.setError("email", {
|
||||
case "/data/attributes/name":
|
||||
form.setError("name", {
|
||||
type: "server",
|
||||
message: errorMessage,
|
||||
});
|
||||
break;
|
||||
case "/data/relationships/roles":
|
||||
form.setError("roleId", {
|
||||
form.setError("roles", {
|
||||
type: "server",
|
||||
message: errorMessage,
|
||||
});
|
||||
@@ -75,8 +92,11 @@ export const AddGroupForm = ({
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const invitationId = data?.data?.id || "";
|
||||
router.push(`/invitations/check-details/?id=${invitationId}`);
|
||||
form.reset();
|
||||
toast({
|
||||
title: "Success!",
|
||||
description: "The group was created successfully.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
@@ -93,65 +113,84 @@ export const AddGroupForm = ({
|
||||
onSubmit={form.handleSubmit(onSubmitClient)}
|
||||
className="flex flex-col space-y-4"
|
||||
>
|
||||
{/* Email Field */}
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
name="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the email address"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
isInvalid={!!form.formState.errors.email}
|
||||
/>
|
||||
{/* Name Field */}
|
||||
<p className="text-small font-medium text-default-700">
|
||||
Please provide a name for the group. You can also select providers and
|
||||
roles to associate with the group, this step is optional and can be
|
||||
done later if needed.
|
||||
</p>
|
||||
<Divider orientation="horizontal" className="mb-2" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
name="name"
|
||||
type="text"
|
||||
label="Provider group name"
|
||||
labelPlacement="outside"
|
||||
placeholder="Enter the provider group name"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
isInvalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Providers Field */}
|
||||
<Controller
|
||||
name="roleId"
|
||||
name="providers"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<Select
|
||||
{...field}
|
||||
label="Role"
|
||||
placeholder="Select a role"
|
||||
variant="bordered"
|
||||
isDisabled={isSelectorDisabled}
|
||||
selectedKeys={[field.value]}
|
||||
onSelectionChange={(selected) =>
|
||||
field.onChange(selected?.currentKey || "")
|
||||
}
|
||||
>
|
||||
{isSelectorDisabled ? (
|
||||
<SelectItem key={defaultRole}>{defaultRole}</SelectItem>
|
||||
) : (
|
||||
roles.map((role) => (
|
||||
<SelectItem key={role.id}>{role.name}</SelectItem>
|
||||
))
|
||||
)}
|
||||
</Select>
|
||||
{form.formState.errors.roleId && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
{form.formState.errors.roleId.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
<CustomDropdownSelection
|
||||
label="Select Providers"
|
||||
name="providers"
|
||||
values={providers}
|
||||
selectedKeys={field.value || []}
|
||||
onChange={(name, selectedValues) =>
|
||||
field.onChange(selectedValues)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.providers && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
{form.formState.errors.providers.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Roles Field */}
|
||||
<Controller
|
||||
name="roles"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<CustomDropdownSelection
|
||||
label="Select Roles"
|
||||
name="roles"
|
||||
values={roles}
|
||||
selectedKeys={field.value || []}
|
||||
onChange={(name, selectedValues) =>
|
||||
field.onChange(selectedValues)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.roles && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
{form.formState.errors.roles.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex w-full justify-end sm:space-x-6">
|
||||
<CustomButton
|
||||
type="submit"
|
||||
ariaLabel="Send Invitation"
|
||||
ariaLabel="Create Group"
|
||||
className="w-1/2"
|
||||
variant="solid"
|
||||
color="action"
|
||||
size="lg"
|
||||
size="md"
|
||||
isLoading={isLoading}
|
||||
startContent={!isLoading && <SaveIcon size={24} />}
|
||||
>
|
||||
{isLoading ? <>Loading</> : <span>Send Invitation</span>}
|
||||
{isLoading ? <>Loading</> : <span>Create Group</span>}
|
||||
</CustomButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
156
ui/components/ui/custom/custom-dropdown-selection.tsx
Normal file
156
ui/components/ui/custom/custom-dropdown-selection.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
Divider,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
ScrollShadow,
|
||||
} from "@nextui-org/react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { PlusCircleIcon } from "@/components/icons";
|
||||
|
||||
interface CustomDropdownSelectionProps {
|
||||
label: string;
|
||||
name: string;
|
||||
values: { id: string; name: string }[];
|
||||
onChange: (name: string, selectedValues: string[]) => void;
|
||||
selectedKeys?: string[];
|
||||
}
|
||||
|
||||
const selectedTagClass =
|
||||
"inline-flex items-center border py-1 text-xs transition-colors border-transparent bg-default-500 text-secondary-foreground hover:bg-default-500/80 rounded-md px-2 font-normal";
|
||||
|
||||
export const CustomDropdownSelection: React.FC<
|
||||
CustomDropdownSelectionProps
|
||||
> = ({ label, name, values, onChange, selectedKeys = [] }) => {
|
||||
const [selectedValues, setSelectedValues] = useState<Set<string>>(
|
||||
new Set(selectedKeys),
|
||||
);
|
||||
const allValues = values.map((item) => item.id);
|
||||
|
||||
const memoizedValues = useMemo(() => values, [values]);
|
||||
|
||||
// Update the internal state when selectedKeys changes from props
|
||||
useEffect(() => {
|
||||
const newSelection = new Set(selectedKeys);
|
||||
if (
|
||||
JSON.stringify(Array.from(selectedValues)) !==
|
||||
JSON.stringify(Array.from(newSelection))
|
||||
) {
|
||||
if (selectedKeys.length === allValues.length) {
|
||||
newSelection.add("all");
|
||||
}
|
||||
setSelectedValues(newSelection);
|
||||
}
|
||||
}, [selectedKeys]);
|
||||
|
||||
const onSelectionChange = useCallback(
|
||||
(keys: string[]) => {
|
||||
setSelectedValues((prevSelected) => {
|
||||
const newSelection = new Set(keys);
|
||||
|
||||
// If all values are selected and "all" is not included,
|
||||
// add "all" automatically
|
||||
if (
|
||||
newSelection.size === allValues.length &&
|
||||
!newSelection.has("all")
|
||||
) {
|
||||
return new Set(["all", ...allValues]);
|
||||
} else if (prevSelected.has("all")) {
|
||||
// If "all" was previously selected, remove it
|
||||
newSelection.delete("all");
|
||||
return new Set(allValues.filter((key) => newSelection.has(key)));
|
||||
}
|
||||
return newSelection;
|
||||
});
|
||||
|
||||
// Notify the change without including "all"
|
||||
const selectedValues = keys.filter((key) => key !== "all");
|
||||
onChange(name, selectedValues);
|
||||
},
|
||||
[allValues, name, onChange],
|
||||
);
|
||||
|
||||
const handleSelectAllClick = useCallback(() => {
|
||||
setSelectedValues((prevSelected: Set<string>) => {
|
||||
const newSelection: Set<string> = prevSelected.has("all")
|
||||
? new Set()
|
||||
: new Set(["all", ...allValues]);
|
||||
|
||||
// Notify the change without including "all"
|
||||
const selectedValues = Array.from(newSelection).filter(
|
||||
(key) => key !== "all",
|
||||
);
|
||||
onChange(name, selectedValues);
|
||||
|
||||
return newSelection;
|
||||
});
|
||||
}, [allValues, name, onChange]);
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full flex-col gap-2">
|
||||
<Popover backdrop="transparent" placement="bottom-start">
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
className="border-input hover:bg-accent hover:text-accent-foreground inline-flex h-10 items-center justify-center whitespace-nowrap rounded-md border border-dashed bg-background px-3 text-xs font-medium shadow-sm transition-colors focus-visible:outline-none disabled:opacity-50 dark:bg-prowler-blue-800"
|
||||
startContent={<PlusCircleIcon size={16} />}
|
||||
size="md"
|
||||
>
|
||||
<h3 className="text-small">{label}</h3>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 dark:bg-prowler-blue-800">
|
||||
<div className="flex w-full flex-col gap-6 p-2">
|
||||
<CheckboxGroup
|
||||
color="default"
|
||||
label={label}
|
||||
value={Array.from(selectedValues)}
|
||||
onValueChange={onSelectionChange}
|
||||
className="font-bold"
|
||||
>
|
||||
<Checkbox
|
||||
className="font-normal"
|
||||
value="all"
|
||||
onClick={handleSelectAllClick}
|
||||
>
|
||||
Select All
|
||||
</Checkbox>
|
||||
<Divider orientation="horizontal" className="mt-2" />
|
||||
<ScrollShadow
|
||||
hideScrollBar
|
||||
className="flex max-h-96 max-w-56 flex-col gap-y-2 py-2"
|
||||
>
|
||||
{memoizedValues.map(({ id, name }) => (
|
||||
<Checkbox className="font-normal" key={id} value={id}>
|
||||
{name}
|
||||
</Checkbox>
|
||||
))}
|
||||
</ScrollShadow>
|
||||
</CheckboxGroup>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Selected Values Display */}
|
||||
{selectedValues.size > 0 && selectedValues.size !== 1 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{Array.from(selectedValues)
|
||||
.filter((value) => value !== "all")
|
||||
.map((value) => {
|
||||
const selectedItem = values.find((item) => item.id === value);
|
||||
return (
|
||||
<span key={value} className={selectedTagClass}>
|
||||
{selectedItem?.name || value}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ export * from "./custom-alert-modal";
|
||||
export * from "./custom-box";
|
||||
export * from "./custom-button";
|
||||
export * from "./custom-dropdown-filter";
|
||||
export * from "./custom-dropdown-selection";
|
||||
export * from "./custom-input";
|
||||
export * from "./custom-loader";
|
||||
export * from "./custom-radio";
|
||||
|
||||
@@ -249,7 +249,7 @@ export interface InvitationProps {
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
type: "role";
|
||||
type: "roles";
|
||||
id: string;
|
||||
attributes: {
|
||||
name: string;
|
||||
@@ -373,7 +373,7 @@ export interface UserProps {
|
||||
count: number;
|
||||
};
|
||||
data: Array<{
|
||||
type: "role";
|
||||
type: "roles";
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user