diff --git a/src/App.tsx b/src/App.tsx index 86b6b4b23..3d541f768 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import { MdOutlineIntegrationInstructions, MdOutlineSupportAgent } from "react-icons/md"; +import { RiShieldUserFill } from "react-icons/ri"; import { VscJson } from "react-icons/vsc"; import { BrowserRouter, @@ -53,6 +54,7 @@ import { import { ConnectionsPage } from "./pages/Settings/ConnectionsPage"; import { EventQueueStatusPage } from "./pages/Settings/EventQueueStatus"; import { FeatureFlagsPage } from "./pages/Settings/FeatureFlagsPage"; +import { PermissionsPage } from "./pages/Settings/PermissionsPage"; import NotificationSilencedAddPage from "./pages/Settings/notifications/NotificationSilencedAddPage"; import NotificationsPage from "./pages/Settings/notifications/NotificationsPage"; import NotificationRulesPage from "./pages/Settings/notifications/NotificationsRulesPage"; @@ -164,6 +166,13 @@ const settingsNav: SettingsNavigationItems = { featureName: features["settings.connections"], resourceName: tables.connections }, + { + name: "Permissions", + href: "/settings/permissions", + icon: RiShieldUserFill, + featureName: features["settings.permissions"], + resourceName: tables.permissions + }, ...(process.env.NEXT_PUBLIC_AUTH_IS_CLERK === "true" ? [] : [ @@ -397,6 +406,14 @@ export function IncidentManagerRoutes({ sidebar }: { sidebar: ReactNode }) { true )} /> + , + tables.permissions, + "read" + )} + /> ( `/connection/test/${id}` ); } + +export async function getConnectionByID(id: string) { + const res = await IncidentCommander.get< + Pick[] + >(`/connections?id=eq.${id}&select=id,name,type`); + return res?.data?.[0] ?? undefined; +} diff --git a/src/api/services/permissions.ts b/src/api/services/permissions.ts new file mode 100644 index 000000000..60beb42d4 --- /dev/null +++ b/src/api/services/permissions.ts @@ -0,0 +1,100 @@ +import { AVATAR_INFO } from "@flanksource-ui/constants"; +import { IncidentCommander } from "../axios"; +import { resolvePostGrestRequestWithPagination } from "../resolve"; +import { PermissionAPIResponse, PermissionTable } from "../types/permissions"; + +export type FetchPermissionsInput = { + componentId?: string; + personId?: string; + teamId?: string; + configId?: string; + checkId?: string; + canaryId?: string; + playbookId?: string; + connectionId?: string; +}; + +function composeQueryParamForFetchPermissions({ + componentId, + personId, + teamId, + configId, + checkId, + canaryId, + playbookId, + connectionId +}: FetchPermissionsInput) { + if (componentId) { + return `component_id=eq.${componentId}`; + } + if (personId) { + return `person_id=eq.${personId}`; + } + if (teamId) { + return `team_id=eq.${teamId}`; + } + if (configId) { + return `config_id=eq.${configId}`; + } + if (checkId) { + return `check_id=eq.${checkId}`; + } + if (canaryId) { + return `canary_id=eq.${canaryId}`; + } + if (playbookId) { + return `playbook_id=eq.${playbookId}`; + } + if (connectionId) { + return `connection_id=eq.${connectionId}`; + } + return ""; +} + +export function fetchPermissions( + input: FetchPermissionsInput, + pagination: { + pageSize: number; + pageIndex: number; + } +) { + const queryParam = composeQueryParamForFetchPermissions(input); + const selectFields = [ + "*", + // "checks:check_id(id, name, status, type)", + "catalog:config_id(id, name, type, config_class)", + "component:component_id(id, name, icon)", + "canary:canary_id(id, name)", + "playbook:playbook_id(id, title, name, icon)", + "team:team_id(id, name, icon)", + `person:person_id(${AVATAR_INFO})`, + `createdBy:created_by(${AVATAR_INFO})`, + `connection:connection_id(id,name,type)` + ]; + + const { pageSize, pageIndex } = pagination; + + const url = `/permissions?${queryParam}&select=${selectFields.join(",")}&limit=${pageSize}&offset=${pageIndex * pageSize}`; + return resolvePostGrestRequestWithPagination( + IncidentCommander.get(url, { + headers: { + Prefer: "count=exact" + } + }) + ); +} + +export function addPermission(permission: PermissionTable) { + return IncidentCommander.post("/permissions", permission); +} + +export function updatePermission(permission: PermissionTable) { + return IncidentCommander.patch( + `/permissions?id=eq.${permission.id}`, + permission + ); +} + +export function deletePermission(id: string) { + return IncidentCommander.delete(`/permissions?id=eq.${id}`); +} diff --git a/src/api/types/permissions.ts b/src/api/types/permissions.ts new file mode 100644 index 000000000..a4b32f4f0 --- /dev/null +++ b/src/api/types/permissions.ts @@ -0,0 +1,41 @@ +import { Connection } from "@flanksource-ui/components/Connections/ConnectionFormModal"; +import { ConfigItem } from "./configs"; +import { PlaybookSpec } from "./playbooks"; +import { Topology } from "./topology"; +import { Team, User } from "./users"; + +export type PermissionTable = { + id: string; + description: string; + action: string; + deny?: boolean; + object?: string; + component_id?: string; + config_id?: string; + canary_id?: string; + playbook_id?: string; + created_by: string; + connection_id?: string; + person_id?: string; + team_id?: string; + updated_by: string; + created_at: string; + updated_at: string; + until?: string; + source?: string; +}; + +export type PermissionAPIResponse = PermissionTable & { + // checks: Pick; + catalog: Pick; + component: Pick; + canary: { + id: string; + name: string; + }; + playbook: Pick; + team: Pick; + connection: Pick; + person: User; + createdBy: User; +}; diff --git a/src/components/Canary/CanaryLink.tsx b/src/components/Canary/CanaryLink.tsx new file mode 100644 index 000000000..6b2f11e84 --- /dev/null +++ b/src/components/Canary/CanaryLink.tsx @@ -0,0 +1,24 @@ +import { Icon } from "@flanksource-ui/ui/Icons/Icon"; +import { Link } from "react-router-dom"; + +type CanaryLinkProps = { + canary: { + id: string; + name: string; + icon?: string; + }; +}; + +export default function CanaryLink({ canary }: CanaryLinkProps) { + return ( + + {canary.icon && } + {canary.name} + + ); +} diff --git a/src/components/Configs/ConfigLink/ConfigLink.tsx b/src/components/Configs/ConfigLink/ConfigLink.tsx index 1b54f7bc3..c27468184 100644 --- a/src/components/Configs/ConfigLink/ConfigLink.tsx +++ b/src/components/Configs/ConfigLink/ConfigLink.tsx @@ -34,6 +34,10 @@ export default function ConfigLink({ const data = config || configFromRequest; + // if (isLoading) { + // return ; + // } + if (!data) { return null; } diff --git a/src/components/Configs/ConfigList/Cells/ConfigListTagsCell.tsx b/src/components/Configs/ConfigList/Cells/ConfigListTagsCell.tsx index f38ae35f4..d43024ca3 100644 --- a/src/components/Configs/ConfigList/Cells/ConfigListTagsCell.tsx +++ b/src/components/Configs/ConfigList/Cells/ConfigListTagsCell.tsx @@ -4,10 +4,11 @@ import { useCallback } from "react"; import { useSearchParams } from "react-router-dom"; import { ConfigItem } from "../../../../api/types/configs"; -type ConfigListTagsCellProps> = Pick< - CellContext, any>, - "getValue" | "row" -> & { +type ConfigListTagsCellProps< + T extends { + tags?: Record; + } +> = Pick, "getValue" | "row"> & { hideGroupByView?: boolean; label?: string; enableFilterByTag?: boolean; diff --git a/src/components/Connections/ConnectionFormModal.tsx b/src/components/Connections/ConnectionFormModal.tsx index 8db2a6dd4..edf303444 100644 --- a/src/components/Connections/ConnectionFormModal.tsx +++ b/src/components/Connections/ConnectionFormModal.tsx @@ -1,6 +1,8 @@ +import FlatTabs from "@flanksource-ui/ui/Tabs/FlatTabs"; import React, { useEffect, useState } from "react"; import { Icon } from "../../ui/Icons/Icon"; import { Modal } from "../../ui/Modal"; +import PermissionsView from "../Permissions/PermissionsView"; import ConnectionForm from "./ConnectionForm"; import ConnectionListView from "./ConnectionListView"; import ConnectionSpecEditor from "./ConnectionSpecEditor"; @@ -81,6 +83,8 @@ export default function ConnectionFormModal({ ConnectionType | undefined >(() => connectionTypes.find((item) => item.title === formValue?.type)); + const [activeTab, setActiveTab] = useState<"form" | "permissions">("form"); + useEffect(() => { let connection = connectionTypes.find( (item) => item.value === formValue?.type @@ -93,35 +97,78 @@ export default function ConnectionFormModal({ connectionType; return ( -
- - {typeof connectionType?.icon === "string" ? ( - - ) : ( - connectionType.icon - )} -
- {connectionType.title} Connection Details -
+ + {typeof connectionType?.icon === "string" ? ( + + ) : ( + connectionType.icon + )} +
+ {connectionType.title} Connection Details
- ) : ( -
Select Connection Type
- ) - } - onClose={() => { - setIsOpen(false); - }} - open={isOpen} - bodyClass="flex flex-col w-full flex-1 h-full overflow-y-auto" - helpLink="reference/connections/" - > - {type ? ( +
+ ) : ( +
Select Connection Type
+ ) + } + onClose={() => { + setIsOpen(false); + }} + open={isOpen} + size="full" + bodyClass="flex flex-col w-full flex-1 h-full overflow-y-auto" + helpLink="reference/connections/" + containerClassName="h-full overflow-auto" + > + {type ? ( + formValue?.id ? ( + setActiveTab(label)} + tabs={[ + { + label: "Edit", + key: "form", + current: activeTab === "form", + content: ( + setConnectionType(undefined)} + connectionType={type} + onConnectionSubmit={onConnectionSubmit} + onConnectionDelete={onConnectionDelete} + formValue={formValue} + className={className} + isSubmitting={isSubmitting} + isDeleting={isDeleting} + /> + ) + }, + { + label: "Permissions", + key: "permissions", + current: activeTab === "permissions", + content: ( + + ) + } + ]} + /> + ) : ( setConnectionType(undefined)} connectionType={type} @@ -132,20 +179,20 @@ export default function ConnectionFormModal({ isSubmitting={isSubmitting} isDeleting={isDeleting} /> - ) : formValue?.id ? ( - setConnectionType(undefined)} - onConnectionSubmit={onConnectionSubmit} - onConnectionDelete={onConnectionDelete} - formValue={formValue} - className={className} - isSubmitting={isSubmitting} - isDeleting={isDeleting} - /> - ) : ( - - )} - - + ) + ) : formValue?.id ? ( + setConnectionType(undefined)} + onConnectionSubmit={onConnectionSubmit} + onConnectionDelete={onConnectionDelete} + formValue={formValue} + className={className} + isSubmitting={isSubmitting} + isDeleting={isDeleting} + /> + ) : ( + + )} + ); } diff --git a/src/components/Connections/ConnectionIcon.tsx b/src/components/Connections/ConnectionIcon.tsx new file mode 100644 index 000000000..a4f7d9f9e --- /dev/null +++ b/src/components/Connections/ConnectionIcon.tsx @@ -0,0 +1,30 @@ +import { Icon } from "@flanksource-ui/ui/Icons/Icon"; +import { useMemo } from "react"; +import { Connection } from "./ConnectionFormModal"; +import { connectionTypes } from "./connectionTypes"; + +type ConnectionIconProps = { + connection: Pick; + showLabel?: boolean; +}; +export default function ConnectionIcon({ + connection, + showLabel = false +}: ConnectionIconProps) { + const icon = useMemo(() => { + return connectionTypes.find((item) => item.value === connection.type)?.icon; + }, [connection.type]); + + return ( +
+ {typeof icon === "string" ? ( + + ) : ( + // if not a string, it's a react component + // eslint-disable-next-line react/jsx-no-useless-fragment + icon && <>{icon} + )} + {showLabel && {connection.name}} +
+ ); +} diff --git a/src/components/Connections/ConnectionLink.tsx b/src/components/Connections/ConnectionLink.tsx new file mode 100644 index 000000000..06985cb83 --- /dev/null +++ b/src/components/Connections/ConnectionLink.tsx @@ -0,0 +1,33 @@ +import { getConnectionByID } from "@flanksource-ui/api/services/connections"; +import TextSkeletonLoader from "@flanksource-ui/ui/SkeletonLoader/TextSkeletonLoader"; +import { useQuery } from "@tanstack/react-query"; +import { Connection } from "./ConnectionFormModal"; +import ConnectionIcon from "./ConnectionIcon"; + +type ConnectionLinkProps = { + connection?: Pick; + connectionId: string; +}; + +export default function ConnectionLink({ + connection, + connectionId +}: ConnectionLinkProps) { + const { isLoading, data } = useQuery({ + queryKey: ["connections", connectionId], + queryFn: () => getConnectionByID(connectionId), + enabled: connection === undefined && !!connectionId + }); + + if (isLoading) { + return ; + } + + const connectionData = connection || data; + + if (!connectionData) { + return null; + } + + return ; +} diff --git a/src/components/Connections/connectionTypes.tsx b/src/components/Connections/connectionTypes.tsx index 891eaa1a4..3e0a8df14 100644 --- a/src/components/Connections/connectionTypes.tsx +++ b/src/components/Connections/connectionTypes.tsx @@ -96,7 +96,7 @@ export const enum ConnectionValueType { export type ConnectionType = { title: string; value: ConnectionValueType; - icon?: React.ReactNode | string | null; + icon?: JSX.Element | string | null; fields: ConnectionFormFields[]; convertToFormSpecificValue?: (data: Record) => Connection; preSubmitConverter?: (data: Record) => object; diff --git a/src/components/Forms/Formik/FormikConnectionField.tsx b/src/components/Forms/Formik/FormikConnectionField.tsx index ce6919c01..afe46e895 100644 --- a/src/components/Forms/Formik/FormikConnectionField.tsx +++ b/src/components/Forms/Formik/FormikConnectionField.tsx @@ -45,7 +45,7 @@ export default function FormikConnectionField({ return ( + checks?.map((playbook) => ({ + label: playbook.title || playbook.name, + value: playbook.id, + icon: + })), + [checks] + ); + + return ( + + ); +} diff --git a/src/components/Permissions/ManagePermissions/Forms/AddPermissionButton.tsx b/src/components/Permissions/ManagePermissions/Forms/AddPermissionButton.tsx new file mode 100644 index 000000000..17e3343b3 --- /dev/null +++ b/src/components/Permissions/ManagePermissions/Forms/AddPermissionButton.tsx @@ -0,0 +1,16 @@ +import { useState } from "react"; +import { AiFillPlusCircle } from "react-icons/ai"; +import PermissionForm from "./PermissionForm"; + +export default function AddPermissionButton() { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + setIsOpen(false)} /> + + ); +} diff --git a/src/components/Permissions/ManagePermissions/Forms/DeletePermission.tsx b/src/components/Permissions/ManagePermissions/Forms/DeletePermission.tsx new file mode 100644 index 000000000..fd4b19051 --- /dev/null +++ b/src/components/Permissions/ManagePermissions/Forms/DeletePermission.tsx @@ -0,0 +1,65 @@ +import { deletePermission } from "@flanksource-ui/api/services/permissions"; +import { + toastError, + toastSuccess +} from "@flanksource-ui/components/Toast/toast"; +import { ConfirmationPromptDialog } from "@flanksource-ui/ui/AlertDialog/ConfirmationPromptDialog"; +import { Button } from "@flanksource-ui/ui/Buttons/Button"; +import { useMutation } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { useCallback, useState } from "react"; +import { FaCircleNotch, FaTrash } from "react-icons/fa"; + +export default function DeletePermission({ + permissionId, + onDeleted = () => {} +}: { + permissionId: string; + onDeleted: () => void; +}) { + const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); + + const { mutate: deleteResource, isLoading } = useMutation({ + mutationFn: async (id: string) => { + const res = await deletePermission(id); + return res.data; + }, + onSuccess: (_) => { + toastSuccess("Permission deleted"); + onDeleted(); + }, + onError: (error: AxiosError) => { + toastError(error.message); + } + }); + + const onDeleteResource = useCallback(() => { + setIsConfirmDialogOpen(false); + deleteResource(permissionId); + }, [deleteResource, permissionId]); + + return ( + <> + + + )} + setSelectedPermission(row)} + hideResourceColumn={hideResourceColumn} + /> + {selectedPermission && ( + { + setSelectedPermission(undefined); + refetch(); + }} + data={selectedPermission} + /> + )} + {showAddPermission && ( + { + setIsPermissionModalOpen(false); + refetch(); + }} + /> + )} + + ); +} diff --git a/src/components/Playbooks/Settings/PlaybookSpecCard.tsx b/src/components/Playbooks/Settings/PlaybookSpecCard.tsx index 23ccc619a..3daa218b9 100644 --- a/src/components/Playbooks/Settings/PlaybookSpecCard.tsx +++ b/src/components/Playbooks/Settings/PlaybookSpecCard.tsx @@ -11,7 +11,7 @@ import { Icon } from "../../../ui/Icons/Icon"; import { toastError, toastSuccess } from "../../Toast/toast"; import SubmitPlaybookRunForm from "../Runs/Submit/SubmitPlaybookRunForm"; import PlaybookCardMenu from "./PlaybookCardMenu"; -import PlaybookSpecsForm from "./PlaybookSpecsForm"; +import PlaybookSpecFormModal from "./PlaybookSpecFormModal"; type PlaybookSpecCardProps = { playbook: PlaybookNames; @@ -91,7 +91,7 @@ export default function PlaybookSpecCard({ {playbookSpec && ( - { setIsEditPlaybookFormOpen(false); diff --git a/src/components/Playbooks/Settings/PlaybookSpecFormModal.tsx b/src/components/Playbooks/Settings/PlaybookSpecFormModal.tsx new file mode 100644 index 000000000..c139b9b03 --- /dev/null +++ b/src/components/Playbooks/Settings/PlaybookSpecFormModal.tsx @@ -0,0 +1,80 @@ +import { PlaybookSpec } from "@flanksource-ui/api/types/playbooks"; +import PermissionsView from "@flanksource-ui/components/Permissions/PermissionsView"; +import { Modal } from "@flanksource-ui/ui/Modal"; +import FlatTabs from "@flanksource-ui/ui/Tabs/FlatTabs"; +import { useState } from "react"; +import PlaybookSpecModalTitle from "../PlaybookSpecModalTitle"; +import PlaybookSpecsForm from "./PlaybookSpecsForm"; + +type PlaybookSpecFormModalProps = { + playbook?: PlaybookSpec; + isOpen: boolean; + onClose: () => void; + refresh?: () => void; +}; + +export default function PlaybookSpecFormModal({ + playbook, + isOpen, + onClose, + ...props +}: PlaybookSpecFormModalProps) { + const [activeTab, setActiveTab] = useState<"form" | "permissions">("form"); + + return ( + + } + onClose={onClose} + open={isOpen} + size="full" + containerClassName="h-full overflow-auto" + bodyClass="flex flex-col w-full flex-1 h-full overflow-y-auto" + helpLink="playbooks" + > + {playbook?.id ? ( + setActiveTab(label)} + tabs={[ + { + label: "Edit Playbook Spec", + key: "form", + current: activeTab === "form", + content: ( + + ) + }, + { + label: "Permissions", + key: "permissions", + current: activeTab === "permissions", + content: ( + + ) + } + ]} + /> + ) : ( + + )} + + ); +} diff --git a/src/components/Playbooks/Settings/PlaybookSpecIcon.tsx b/src/components/Playbooks/Settings/PlaybookSpecIcon.tsx index 0dc2024dd..8d467ed38 100644 --- a/src/components/Playbooks/Settings/PlaybookSpecIcon.tsx +++ b/src/components/Playbooks/Settings/PlaybookSpecIcon.tsx @@ -1,6 +1,9 @@ +import { getPlaybookSpec } from "@flanksource-ui/api/services/playbooks"; import { PlaybookSpec } from "@flanksource-ui/api/types/playbooks"; import { Icon } from "@flanksource-ui/ui/Icons/Icon"; +import TextSkeletonLoader from "@flanksource-ui/ui/SkeletonLoader/TextSkeletonLoader"; import { Tag } from "@flanksource-ui/ui/Tags/Tag"; +import { useQuery } from "@tanstack/react-query"; type PlaybookIconProps = { playbook: Pick; @@ -26,3 +29,18 @@ export default function PlaybookSpecIcon({ ); } + +export function PlaybookSpecLabel({ playbookId }: { playbookId: string }) { + const { data: playbook, isLoading } = useQuery({ + queryKey: ["playbook", playbookId], + queryFn: () => getPlaybookSpec(playbookId), + enabled: !!playbookId + }); + + if (isLoading) { + // show skeleton + return ; + } + + return ; +} diff --git a/src/components/Playbooks/Settings/PlaybookSpecsForm.tsx b/src/components/Playbooks/Settings/PlaybookSpecsForm.tsx index 57ea7f0e1..643978041 100644 --- a/src/components/Playbooks/Settings/PlaybookSpecsForm.tsx +++ b/src/components/Playbooks/Settings/PlaybookSpecsForm.tsx @@ -10,7 +10,6 @@ import { } from "@flanksource-ui/api/types/playbooks"; import { useUser } from "@flanksource-ui/context"; import { Button } from "@flanksource-ui/ui/Buttons/Button"; -import { Modal } from "@flanksource-ui/ui/Modal"; import { useMutation } from "@tanstack/react-query"; import clsx from "clsx"; import { Form, Formik } from "formik"; @@ -18,18 +17,15 @@ import { FaTrash } from "react-icons/fa"; import { FormikCodeEditor } from "../../Forms/Formik/FormikCodeEditor"; import FormikTextInput from "../../Forms/Formik/FormikTextInput"; import { toastError, toastSuccess } from "../../Toast/toast"; -import PlaybookSpecModalTitle from "../PlaybookSpecModalTitle"; type PlaybookSpecsFormProps = { playbook?: PlaybookSpec; - isOpen: boolean; onClose: () => void; refresh?: () => void; }; export default function PlaybookSpecsForm({ playbook, - isOpen, onClose, refresh = () => {} }: PlaybookSpecsFormProps) { @@ -100,96 +96,79 @@ export default function PlaybookSpecsForm({ }); return ( - + + initialValues={ + playbook || + ({ + title: "", + name: "", + source: "UI", + category: undefined, + spec: undefined, + created_by: user?.id + } satisfies NewPlaybookSpec) } - onClose={onClose} - open={isOpen} - size="full" - containerClassName="h-full overflow-auto" - bodyClass="flex flex-col w-full flex-1 h-full overflow-y-auto" - helpLink="playbooks" - > - - initialValues={ - playbook || - ({ - title: "", - name: "", - source: "UI", - category: undefined, - spec: undefined, - created_by: user?.id - } satisfies NewPlaybookSpec) + onSubmit={(value) => { + if (playbook?.id) { + updatePlaybook({ + ...value, + // // we sync the title with the name field + // name: playbook.title, + id: playbook.id + }); + } else { + createPlaybook({ + ...value, + // we sync the title with the name field + name: value.title + }); } - onSubmit={(value) => { - if (playbook?.id) { - updatePlaybook({ - ...value, - // // we sync the title with the name field - // name: playbook.title, - id: playbook.id - }); - } else { - createPlaybook({ - ...value, - // we sync the title with the name field - name: value.title - }); - } - }} - > - {({ handleSubmit }) => ( -
-
-
-
- - -
+ }} + > + {({ handleSubmit }) => ( + +
+
+
+ +
-
- {playbook?.id && ( -
+
+ {playbook?.id && (
- - )} - - + )} + +
+ + )} + ); } diff --git a/src/components/SchemaResourcePage/resourceTypes.tsx b/src/components/SchemaResourcePage/resourceTypes.tsx index faa8d88b2..b9461a134 100644 --- a/src/components/SchemaResourcePage/resourceTypes.tsx +++ b/src/components/SchemaResourcePage/resourceTypes.tsx @@ -19,7 +19,8 @@ export type SchemaResourceType = { | "Connections" | "Log Backends" | "Notifications" - | "Feature Flags"; + | "Feature Flags" + | "Permissions"; table: | "teams" | "incident_rules" @@ -29,7 +30,8 @@ export type SchemaResourceType = { | "connections" | "logging_backends" | "notifications" - | "properties"; + | "properties" + | "permissions"; api: "incident-commander" | "canary-checker" | "config-db"; featureName: string; resourceName: string; diff --git a/src/components/Settings/ResourceTable.tsx b/src/components/Settings/ResourceTable.tsx index a21aaecbb..ba2cd7bd0 100644 --- a/src/components/Settings/ResourceTable.tsx +++ b/src/components/Settings/ResourceTable.tsx @@ -266,7 +266,8 @@ const permanentlyHiddenColumnsForTableMap: Record< canaries: ["namespace"], config_scrapers: ["schedule", "namespace"], incident_rules: ["schedule", "namespace"], - teams: ["schedule", "namespace"] + teams: ["schedule", "namespace"], + permissions: ["schedule", "namespace"] }; type ResourceTableProps = { diff --git a/src/context/UserAccessContext/permissions.ts b/src/context/UserAccessContext/permissions.ts index 9ddb45728..5dfbd5c93 100644 --- a/src/context/UserAccessContext/permissions.ts +++ b/src/context/UserAccessContext/permissions.ts @@ -18,7 +18,8 @@ export const tables = { integrations: "integrations", notifications: "notifications", playbooks: "playbooks", - playbook_runs: "playbook_runs" + playbook_runs: "playbook_runs", + permissions: "permissions" }; export const permDefs = { diff --git a/src/pages/Settings/ConnectionsPage.tsx b/src/pages/Settings/ConnectionsPage.tsx index d943b4e21..edba37bb7 100644 --- a/src/pages/Settings/ConnectionsPage.tsx +++ b/src/pages/Settings/ConnectionsPage.tsx @@ -158,18 +158,17 @@ export function ConnectionsPage() { setEditedRow(val); }} /> - - deleteConnection(data)} - isSubmitting={isSubmitting} - isDeleting={isDeleting} - formValue={editedRow} - key={editedRow?.id || "connection-form"} - />
+ deleteConnection(data)} + isSubmitting={isSubmitting} + isDeleting={isDeleting} + formValue={editedRow} + key={editedRow?.id || "connection-form"} + /> ); diff --git a/src/pages/Settings/PermissionsPage.tsx b/src/pages/Settings/PermissionsPage.tsx new file mode 100644 index 000000000..c601e9e95 --- /dev/null +++ b/src/pages/Settings/PermissionsPage.tsx @@ -0,0 +1,60 @@ +import { AuthorizationAccessCheck } from "@flanksource-ui/components/Permissions/AuthorizationAccessCheck"; +import AddPermissionButton from "@flanksource-ui/components/Permissions/ManagePermissions/Forms/AddPermissionButton"; +import PermissionsView from "@flanksource-ui/components/Permissions/PermissionsView"; +import { tables } from "@flanksource-ui/context/UserAccessContext/permissions"; +import { + BreadcrumbNav, + BreadcrumbRoot +} from "@flanksource-ui/ui/BreadcrumbNav"; +import { Head } from "@flanksource-ui/ui/Head"; +import { SearchLayout } from "@flanksource-ui/ui/Layout/SearchLayout"; +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +export function PermissionsPage() { + const [isLoading, setIsLoading] = useState(false); + const client = useQueryClient(); + + return ( + <> + + + Permissions + , + + + + ]} + /> + } + onRefresh={() => + client.refetchQueries({ + queryKey: ["permissions"] + }) + } + contentClass="p-0 h-full" + loading={isLoading} + > +
+
+ +
+
+
+ + ); +} diff --git a/src/pages/playbooks/PlaybookRunsPage.tsx b/src/pages/playbooks/PlaybookRunsPage.tsx index a1116ca4c..5ee8ab750 100644 --- a/src/pages/playbooks/PlaybookRunsPage.tsx +++ b/src/pages/playbooks/PlaybookRunsPage.tsx @@ -4,8 +4,8 @@ import PlaybookRunsFilterBar, { } from "@flanksource-ui/components/Playbooks/Runs/Filter/PlaybookRunsFilterBar"; import PlaybookRunsTable from "@flanksource-ui/components/Playbooks/Runs/PlaybookRunsList"; import { playbookRunsPageTabs } from "@flanksource-ui/components/Playbooks/Runs/PlaybookRunsPageTabs"; +import PlaybookSpecFormModal from "@flanksource-ui/components/Playbooks/Settings/PlaybookSpecFormModal"; import PlaybookSpecIcon from "@flanksource-ui/components/Playbooks/Settings/PlaybookSpecIcon"; -import PlaybookSpecsForm from "@flanksource-ui/components/Playbooks/Settings/PlaybookSpecsForm"; import { BreadcrumbChild, BreadcrumbNav, @@ -133,7 +133,7 @@ export default function PlaybookRunsPage() {
{playbook && ( - { setIsEditPlaybookFormOpen(false); diff --git a/src/pages/playbooks/PlaybooksList.tsx b/src/pages/playbooks/PlaybooksList.tsx index 3254df04c..1c07aab28 100644 --- a/src/pages/playbooks/PlaybooksList.tsx +++ b/src/pages/playbooks/PlaybooksList.tsx @@ -1,4 +1,5 @@ import { AuthorizationAccessCheck } from "@flanksource-ui/components/Permissions/AuthorizationAccessCheck"; +import PlaybookSpecFormModal from "@flanksource-ui/components/Playbooks/Settings/PlaybookSpecFormModal"; import { tables } from "@flanksource-ui/context/UserAccessContext/permissions"; import { useState } from "react"; import { AiFillPlusCircle } from "react-icons/ai"; @@ -6,7 +7,6 @@ import { FaHome } from "react-icons/fa"; import { useGetAllPlaybookSpecs } from "../../api/query-hooks/playbooks"; import ErrorPage from "../../components/Errors/ErrorPage"; import { playbookRunsPageTabs } from "../../components/Playbooks/Runs/PlaybookRunsPageTabs"; -import PlaybookSpecsForm from "../../components/Playbooks/Settings/PlaybookSpecsForm"; import PlaybookSpecsList from "../../components/Playbooks/Settings/PlaybookSpecsList"; import { BreadcrumbNav, BreadcrumbRoot } from "../../ui/BreadcrumbNav"; import { Head } from "../../ui/Head"; @@ -65,7 +65,7 @@ export function PlaybooksListPage() { )} - { setIsOpen(false); diff --git a/src/services/permissions/features.ts b/src/services/permissions/features.ts index 1e157f161..b3a2272e7 100644 --- a/src/services/permissions/features.ts +++ b/src/services/permissions/features.ts @@ -20,7 +20,8 @@ export const features = { "settings.organization_profile": "settings.organization_profile", "settings.notifications": "settings.notifications", "settings.playbooks": "settings.playbooks", - "settings.integrations": "settings.integrations" + "settings.integrations": "settings.integrations", + "settings.permissions": "settings.permissions" } as const; export const featureToParentMap = { diff --git a/src/ui/Badge/Badge.tsx b/src/ui/Badge/Badge.tsx index 05d06805e..366a01c20 100644 --- a/src/ui/Badge/Badge.tsx +++ b/src/ui/Badge/Badge.tsx @@ -4,7 +4,7 @@ type BadgeProps = { text: React.ReactNode; value?: string; size?: "xs" | "sm" | "md"; - color?: "blue" | "gray"; + color?: "blue" | "gray" | "yellow"; dot?: string; title?: string; className?: string; @@ -29,7 +29,9 @@ export function Badge({ const colorClass = color === "blue" ? "bg-blue-100 text-blue-800" - : "bg-gray-100 text-gray-700"; + : color === "yellow" + ? "bg-yellow-100 text-yellow-800" + : "bg-gray-100 text-gray-700"; const spanClassName = size === "sm" ? "text-sm px-1 py-0.5" : "text-xs px-1 py-0.5"; const svgClassName = diff --git a/src/ui/MRTDataTable/MRTDataTable.tsx b/src/ui/MRTDataTable/MRTDataTable.tsx index 221afae78..f9575c5d8 100644 --- a/src/ui/MRTDataTable/MRTDataTable.tsx +++ b/src/ui/MRTDataTable/MRTDataTable.tsx @@ -16,7 +16,7 @@ import useReactTableSortState from "../DataTable/Hooks/useReactTableSortState"; type MRTDataTableProps = {}> = { data: T[]; columns: MRT_ColumnDef[]; - onRowClick: (row: T) => void; + onRowClick?: (row: T) => void; isLoading?: boolean; disablePagination?: boolean; enableServerSideSorting?: boolean; diff --git a/src/ui/Tabs/FlatTabs.stories.tsx b/src/ui/Tabs/FlatTabs.stories.tsx new file mode 100644 index 000000000..bffa058d0 --- /dev/null +++ b/src/ui/Tabs/FlatTabs.stories.tsx @@ -0,0 +1,36 @@ +import { Meta, StoryFn } from "@storybook/react"; +import { useState } from "react"; +import FlatTabs from "./FlatTabs"; + +export default { + title: "ui/FlatTabs", + component: FlatTabs +} satisfies Meta; + +const Template: StoryFn = () => { + const [activeTab, setActiveTab] = useState<"tab1" | "tab2">("tab1"); + + return ( + Tab 1 content + }, + { + label: "Tab 2", + key: "tab2", + current: activeTab === "tab2", + content:
Tab 2 content
+ } + ]} + activeTab={activeTab} + setActiveTab={(t) => setActiveTab(t)} + /> + ); +}; + +export const Default = Template.bind({}); +Default.args = {}; diff --git a/src/ui/Tabs/FlatTabs.tsx b/src/ui/Tabs/FlatTabs.tsx new file mode 100644 index 000000000..1e5c7f433 --- /dev/null +++ b/src/ui/Tabs/FlatTabs.tsx @@ -0,0 +1,61 @@ +import clsx from "clsx"; + +type FlatTabsProps = { + tabs: { + label: string; + key: T; + current: boolean; + content: React.ReactNode; + }[]; + activeTab: string; + setActiveTab: (tab: T) => void; +}; + +export default function FlatTabs({ + tabs, + activeTab, + setActiveTab +}: FlatTabsProps) { + return ( +
+
+ + {/* Use an "onChange" listener to redirect the user to the selected tab URL. */} + +
+
+
+ +
+ {tabs.find((tab) => tab.current)?.content} +
+
+ ); +}