diff --git a/src/components/forms/RFFComponents.jsx b/src/components/forms/RFFComponents.jsx index 737333c7dfc9..01b4ed8f083b 100644 --- a/src/components/forms/RFFComponents.jsx +++ b/src/components/forms/RFFComponents.jsx @@ -293,7 +293,6 @@ export const RFFCFormRadioList = ({ name, options, className = 'mb-3', - disabled = false, onClick, inline = false, }) => { @@ -312,7 +311,6 @@ export const RFFCFormRadioList = ({ onChange={input.onChange} type="radio" {...option} - disabled={disabled} onClick={onClick} inline={inline} /> diff --git a/src/components/utilities/CippListOffcanvas.jsx b/src/components/utilities/CippListOffcanvas.jsx index 4fbde6931ce3..e3c3229ae394 100644 --- a/src/components/utilities/CippListOffcanvas.jsx +++ b/src/components/utilities/CippListOffcanvas.jsx @@ -38,7 +38,7 @@ CippListOffcanvas.propTypes = { hideFunction: PropTypes.func.isRequired, } -export function OffcanvasListSection({ title, items }) { +export function OffcanvasListSection({ title, items, showCardTitle = true }) { //console.log(items) const mappedItems = items.map((item, key) => ({ value: item.content, label: item.heading })) return ( @@ -48,7 +48,11 @@ export function OffcanvasListSection({ title, items }) { - Extended Information + {showCardTitle && ( + <> + Extended Information + + )} @@ -62,4 +66,5 @@ export function OffcanvasListSection({ title, items }) { OffcanvasListSection.propTypes = { title: PropTypes.string, items: PropTypes.array, + showCardTitle: PropTypes.bool, } diff --git a/src/components/utilities/CippScheduleOffcanvas.jsx b/src/components/utilities/CippScheduleOffcanvas.jsx new file mode 100644 index 000000000000..45d5b5a62e92 --- /dev/null +++ b/src/components/utilities/CippScheduleOffcanvas.jsx @@ -0,0 +1,276 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { + CButton, + CCallout, + CCard, + CCardBody, + CCardHeader, + CCol, + CForm, + CRow, + CSpinner, + CTooltip, +} from '@coreui/react' +import { CippOffcanvas, TenantSelector } from '.' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { Field, Form, FormSpy } from 'react-final-form' +import arrayMutators from 'final-form-arrays' +import { + RFFCFormInput, + RFFCFormInputArray, + RFFCFormSwitch, + RFFSelectSearch, +} from 'src/components/forms' +import { useSelector } from 'react-redux' +import { useGenericGetRequestQuery, useLazyGenericPostRequestQuery } from 'src/store/api/app' +import DatePicker from 'react-datepicker' +import 'react-datepicker/dist/react-datepicker.css' + +export default function CippScheduleOffcanvas({ + state: visible, + hideFunction, + title, + placement, + ...props +}) { + const currentDate = new Date() + const [startDate, setStartDate] = useState(currentDate) + const tenantDomain = useSelector((state) => state.app.currentTenant.defaultDomainName) + const [refreshState, setRefreshState] = useState(false) + const taskName = `Scheduled Task ${currentDate.toLocaleString()}` + const { data: availableCommands = [], isLoading: isLoadingcmd } = useGenericGetRequestQuery({ + path: 'api/ListFunctionParameters?Module=CIPPCore', + }) + + const recurrenceOptions = [ + { value: '0', name: 'Only once' }, + { value: '1', name: 'Every 1 day' }, + { value: '7', name: 'Every 7 days' }, + { value: '30', name: 'Every 30 days' }, + { value: '365', name: 'Every 365 days' }, + ] + + const [genericPostRequest, postResults] = useLazyGenericPostRequestQuery() + const onSubmit = (values) => { + const unixTime = Math.floor(startDate.getTime() / 1000) + const shippedValues = { + TenantFilter: tenantDomain, + Name: values.taskName, + Command: values.command, + Parameters: values.parameters, + ScheduledTime: unixTime, + Recurrence: values.Recurrence, + AdditionalProperties: values.additional, + PostExecution: { + Webhook: values.webhook, + Email: values.email, + PSA: values.psa, + }, + } + genericPostRequest({ path: '/api/AddScheduledItem', values: shippedValues }).then((res) => { + setRefreshState(res.requestId) + if (props.submitFunction) { + props.submitFunction() + } + }) + } + + return ( + + + + +
{ + return ( + + + + + {(props) => } + + + + + + + + + + + setStartDate(date)} + /> + + + + + + + + + + ({ + value: cmd.Function, + name: cmd.Function, + }))} + name="command" + placeholder={ + isLoadingcmd ? ( + + ) : ( + 'Select a command or report to execute.' + ) + } + label="Command to execute" + /> + + + + {/* eslint-disable react/prop-types */} + {(props) => { + const selectedCommand = availableCommands.find( + (cmd) => cmd.Function === props.values.command?.value, + ) + return ( + + {selectedCommand?.Synopsis} + + ) + }} + + + + {/* eslint-disable react/prop-types */} + {(props) => { + const selectedCommand = availableCommands.find( + (cmd) => cmd.Function === props.values.command?.value, + ) + let paramblock = null + if (selectedCommand) { + //if the command parameter type is boolean we use else . + const parameters = selectedCommand.Parameters + if (parameters.length > 0) { + paramblock = parameters.map((param, idx) => ( + + + + {param.Type === 'System.Boolean' || + param.Type === + 'System.Management.Automation.SwitchParameter' ? ( + <> + + + + ) : ( + <> + {param.Type === 'System.Collections.Hashtable' ? ( + + ) : ( + + )} + + )} + + + + )) + } + } + return paramblock + }} + + + + + + + + + + + + + + + + + + + Add Schedule + {postResults.isFetching && ( + + )} + + + + {postResults.isSuccess && ( + +
  • {postResults.data.Results}
  • +
    + )} +
    + ) + }} + /> + + + + ) +} + +CippScheduleOffcanvas.propTypes = { + groups: PropTypes.array, + placement: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + state: PropTypes.bool, + hideFunction: PropTypes.func.isRequired, +} diff --git a/src/views/cipp/Scheduler.jsx b/src/views/cipp/Scheduler.jsx index aa07fb3722b6..7ffed5837fe0 100644 --- a/src/views/cipp/Scheduler.jsx +++ b/src/views/cipp/Scheduler.jsx @@ -157,7 +157,6 @@ const Scheduler = () => { if (typeof row?.Parameters[key] === 'object') { var nestedParamList = [] Object.keys(row?.Parameters[key]).forEach((nestedKey) => { - console.log(nestedKey) nestedParamList.push({ Key: nestedKey, Value: row?.Parameters[key][nestedKey], diff --git a/src/views/cipp/app-settings/SettingsExtensionMappings.jsx b/src/views/cipp/app-settings/SettingsExtensionMappings.jsx index de3246422499..33d7491795bf 100644 --- a/src/views/cipp/app-settings/SettingsExtensionMappings.jsx +++ b/src/views/cipp/app-settings/SettingsExtensionMappings.jsx @@ -337,6 +337,7 @@ export function SettingsExtensionMappings() { onClick={() => { if ( mappingValue.value !== undefined && + mappingValue.value !== '-1' && Object.values(haloMappingsArray) .map((item) => item.haloId) .includes(mappingValue.value) === false @@ -481,6 +482,7 @@ export function SettingsExtensionMappings() { //set the new mapping in the array if ( mappingValue.value !== undefined && + mappingValue.value !== '-1' && Object.values(ninjaMappingsArray) .map((item) => item.ninjaId) .includes(mappingValue.value) === false diff --git a/src/views/cipp/app-settings/SettingsSuperAdmin.jsx b/src/views/cipp/app-settings/SettingsSuperAdmin.jsx index 7650089b5a23..bec964fa3386 100644 --- a/src/views/cipp/app-settings/SettingsSuperAdmin.jsx +++ b/src/views/cipp/app-settings/SettingsSuperAdmin.jsx @@ -1,10 +1,11 @@ import { useGenericGetRequestQuery, useLazyGenericPostRequestQuery } from 'src/store/api/app.js' -import { CButton, CCol, CForm, CLink, CRow, CSpinner } from '@coreui/react' +import { CAccordion, CButton, CCol, CForm, CLink, CRow, CSpinner } from '@coreui/react' import { Form } from 'react-final-form' import { RFFCFormRadio } from 'src/components/forms/index.js' import React from 'react' import { CippCallout } from 'src/components/layout/index.js' -import CippButtonCard from 'src/components/contentcards/CippButtonCard' +import CippAccordionItem from 'src/components/contentcards/CippAccordionItem' +import SettingsCustomRoles from 'src/views/cipp/app-settings/components/SettingsCustomRoles' export function SettingsSuperAdmin() { const partnerConfig = useGenericGetRequestQuery({ @@ -38,68 +39,73 @@ export function SettingsSuperAdmin() { ) return ( - - <> + + <> - - -

    - The configuration settings below should only be modified by a super admin. Super - admins can configure what tenant mode CIPP operates in. See - - our documentation - - for more information on how to configure these modes and what they mean. -

    -
    -
    - - -

    Tenant Mode

    - ( - <> - {partnerConfig.isFetching && } - - - - - - + <> + + +

    + The configuration settings below should only be modified by a super admin. Super + admins can configure what tenant mode CIPP operates in. See + + our documentation + + for more information on how to configure these modes and what they mean. +

    +
    +
    + + +

    Tenant Mode

    + ( + <> + {partnerConfig.isFetching && } + + + + + + + )} + /> + {webhookCreateResult.isSuccess && ( + + {webhookCreateResult?.data?.results} + )} - /> - {webhookCreateResult.isSuccess && ( - - {webhookCreateResult?.data?.results} - - )} -
    -
    +
    +
    + - -
    + + + + + ) } diff --git a/src/views/cipp/app-settings/components/SettingsCustomRoles.jsx b/src/views/cipp/app-settings/components/SettingsCustomRoles.jsx new file mode 100644 index 000000000000..6f439a01c832 --- /dev/null +++ b/src/views/cipp/app-settings/components/SettingsCustomRoles.jsx @@ -0,0 +1,408 @@ +import React, { useRef, useState } from 'react' +import { + CButton, + CCallout, + CCol, + CForm, + CRow, + CAccordion, + CAccordionHeader, + CAccordionBody, + CAccordionItem, +} from '@coreui/react' +import { Field, Form, FormSpy } from 'react-final-form' +import { RFFCFormRadioList, RFFSelectSearch } from 'src/components/forms' +import { useGenericGetRequestQuery, useLazyGenericPostRequestQuery } from 'src/store/api/app' +import { CippPage } from 'src/components/layout' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import Skeleton from 'react-loading-skeleton' +import { TenantSelectorMultiple, ModalService, CippOffcanvas } from 'src/components/utilities' +import PropTypes from 'prop-types' +import { OnChange } from 'react-final-form-listeners' +import { useListTenantsQuery } from 'src/store/api/tenants' +import CippListOffcanvas, { OffcanvasListSection } from 'src/components/utilities/CippListOffcanvas' + +const SettingsCustomRoles = () => { + const [genericPostRequest, postResults] = useLazyGenericPostRequestQuery() + const [selectedTenant, setSelectedTenant] = useState([]) + const tenantSelectorRef = useRef() + const { data: tenants = [], tenantsFetching } = useListTenantsQuery({ + showAllTenantSelector: true, + }) + + const { + data: apiPermissions = [], + isFetching, + isSuccess, + } = useGenericGetRequestQuery({ + path: 'api/ExecAPIPermissionList', + }) + + const { + data: customRoleList = [], + isFetching: customRoleListFetching, + isSuccess: customRoleListSuccess, + refetch: refetchCustomRoleList, + } = useGenericGetRequestQuery({ + path: 'api/ExecCustomRole', + }) + + const handleSubmit = async (values) => { + //filter on only objects that are 'true' + genericPostRequest({ + path: '/api/ExecCustomRole?Action=AddUpdate', + values: { + RoleName: values.RoleName.value, + Permissions: values.Permissions, + AllowedTenants: selectedTenant.map((tenant) => tenant.value), + }, + }).then(() => { + refetchCustomRoleList() + }) + } + const handleDelete = async (values) => { + ModalService.confirm({ + title: 'Delete Custom Role', + body: 'Are you sure you want to delete this custom role? Any users with this role will have their permissions reset to the default for their base role.', + onConfirm: () => { + genericPostRequest({ + path: '/api/ExecCustomRole?Action=Delete', + values: { + RoleName: values.RoleName.value, + }, + }).then(() => { + refetchCustomRoleList() + }) + }, + }) + } + + const WhenFieldChanges = ({ field, set }) => ( + + {( + // No subscription. We only use Field to get to the change function + { input: { onChange } }, + ) => ( + + {({ form }) => ( + + {(value) => { + if (field === 'RoleName' && value?.value) { + let customRole = customRoleList.filter(function (obj) { + return obj.RowKey === value.value + }) + if (customRole === undefined || customRole === null || customRole.length === 0) { + return false + } else { + if (set === 'AllowedTenants') { + setSelectedTenant(customRole[0][set]) + var selectedTenants = [] + tenants.map((tenant) => { + if (customRole[0][set].includes(tenant.customerId)) { + selectedTenants.push({ + label: tenant.displayName, + value: tenant.customerId, + }) + } + }) + + tenantSelectorRef.current.setValue(selectedTenants) + } else { + onChange(customRole[0][set]) + } + } + } + if (field === 'Defaults') { + let newPermissions = {} + Object.keys(apiPermissions).forEach((cat) => { + Object.keys(apiPermissions[cat]).forEach((obj) => { + var newval = '' + if (cat == 'CIPP' && obj == 'Core' && value == 'None') { + newval = 'Read' + } else { + newval = value + } + newPermissions[`${cat}${obj}`] = `${cat}.${obj}.${newval}` + }) + }) + onChange(newPermissions) + } + }} + + )} + + )} + + ) + WhenFieldChanges.propTypes = { + field: PropTypes.node, + set: PropTypes.string, + } + + const ApiPermissionRow = ({ obj, cat }) => { + const [offcanvasVisible, setOffcanvasVisible] = useState(false) + + var items = [] + for (var key in apiPermissions[cat][obj]) + for (var key2 in apiPermissions[cat][obj][key]) { + items.push({ heading: '', content: apiPermissions[cat][obj][key][key2] }) + } + var group = [{ items: items }] + + return ( + <> + +
    +
    {obj}
    +
    +
    + + setOffcanvasVisible(true)} variant="ghost" size="sm" color="info"> + + + + + + + setOffcanvasVisible(false)} + title="Permission Info" + placement="end" + size="lg" + > +

    {`${cat}.${obj}`}

    +

    + Listed below are the available API endpoints based on permission level, ReadWrite level + includes endpoints under Read. +

    + {[apiPermissions[cat][obj]].map((permissions, key) => { + var sections = Object.keys(permissions).map((type) => { + var items = [] + for (var api in permissions[type]) { + items.push({ heading: '', content: permissions[type][api] }) + } + return ( + + ) + }) + return sections + })} +
    + + ) + } + ApiPermissionRow.propTypes = { + obj: PropTypes.node, + cat: PropTypes.node, + } + + return ( + + <> +

    + Custom roles can be used to restrict permissions for users with the 'editor' or 'readonly' + roles in CIPP. They can be limited to a subset of tenants and API permissions. To restrict + direct API access, create a role with the name 'CIPP-API'. +

    +

    + NOTE: The custom role must be added to the user in SWA in conjunction with the base role. + (e.g. editor,mycustomrole) +

    + {(isFetching || tenantsFetching) && } + {isSuccess && !isFetching && !tenantsFetching && ( + { + return ( + + + +
    + ({ + name: role.RowKey, + value: role.RowKey, + }))} + isLoading={customRoleListFetching} + refreshFunction={() => refetchCustomRoleList()} + allowCreate={true} + placeholder="Select an existing role or enter a custom role name" + /> + + +
    +
    +
    Allowed Tenants
    + setSelectedTenant(e)} + /> +
    +
    API Permissions
    + + +
    +
    Set All Permissions
    +
    +
    + + + + + +
    + + <> + {Object.keys(apiPermissions) + .sort() + .map((cat, catIndex) => ( + + {cat} + + {Object.keys(apiPermissions[cat]) + .sort() + .map((obj, index) => { + return ( + + + + ) + })} + + + ))} + + +
    + + + {({ values }) => { + return ( + <> + {values['RoleName'] && selectedTenant.length > 0 && ( + <> +
    Selected Tenants
    +
      + {selectedTenant.map((tenant, idx) => ( +
    • {tenant.label}
    • + ))} +
    + + )} + {values['RoleName'] && values['Permissions'] && ( + <> +
    Selected Permissions
    +
      + {values['Permissions'] && + Object.keys(values['Permissions'])?.map((cat, idx) => ( + <> + {!values['Permissions'][cat].includes('None') && ( +
    • {values['Permissions'][cat]}
    • + )} + + ))} +
    + + )} + + ) + }} +
    +
    +
    + + {postResults.isSuccess && ( + {postResults.data.Results} + )} + + + + + Save + + + {({ values }) => { + return ( + handleDelete(values)} + disabled={!values['RoleName']} + > + + Delete + + ) + }} + + + + +
    + ) + }} + /> + )} + +
    + ) +} + +export default SettingsCustomRoles diff --git a/src/views/tenant/administration/GraphExplorer.jsx b/src/views/tenant/administration/GraphExplorer.jsx index 3d6950c598d4..ebc3abd8b39d 100644 --- a/src/views/tenant/administration/GraphExplorer.jsx +++ b/src/views/tenant/administration/GraphExplorer.jsx @@ -32,6 +32,7 @@ import { cellGenericFormatter } from 'src/components/tables/CellGenericFormat' import PropTypes from 'prop-types' import { CippCodeOffCanvas, ModalService } from 'src/components/utilities' import { debounce } from 'lodash-es' +import CippScheduleOffcanvas from 'src/components/utilities/CippScheduleOffcanvas' const GraphExplorer = () => { const tenant = useSelector((state) => state.app.currentTenant) @@ -57,6 +58,8 @@ const GraphExplorer = () => { error: presetsError, } = useGenericGetRequestQuery({ path: '/api/ListGraphExplorerPresets', params: { random2 } }) const QueryColumns = { set: false, data: [] } + const [scheduleVisible, setScheduleVisible] = useState(false) + const [scheduleValues, setScheduleValues] = useState({}) const debounceEndpointChange = useMemo(() => { function endpointChange(value) { @@ -148,6 +151,36 @@ const GraphExplorer = () => { }) } + function handleSchedule(values) { + var graphParameters = [] + const paramNames = ['$filter', '$format', '$search', '$select', '$top'] + paramNames.map((param) => { + if (values[param]) { + if (Array.isArray(values[param])) { + graphParameters.push({ Key: param, Value: values[param].map((p) => p.value).join(',') }) + } else { + graphParameters.push({ Key: param, Value: values[param] }) + } + } + }) + + const reportName = values.name ?? 'Graph Explorer' + const shippedValues = { + taskName: reportName + ' - ' + tenant.displayName, + command: { label: 'Get-GraphRequestList', value: 'Get-GraphRequestList' }, + parameters: { + Parameters: graphParameters, + NoPagination: values.NoPagination, + ReverseTenantLookup: values.ReverseTenantLookup, + ReverseTenantLookupProperty: values.ReverseTenantLookupProperty, + Endpoint: values.endpoint, + SkipCache: true, + }, + } + setScheduleValues(shippedValues) + setScheduleVisible(true) + } + const presets = [ { name: 'All users with email addresses', @@ -617,6 +650,19 @@ const GraphExplorer = () => { Query + + {(props) => { + return ( + handleSchedule(props.values)} + className="ms-2" + > + + Schedule Report + + ) + }} + @@ -628,6 +674,13 @@ const GraphExplorer = () => { + setScheduleVisible(false)} + initialValues={scheduleValues} + />
    {!searchNow && Execute a search to get started.}