diff --git a/.gitignore b/.gitignore index e8cef5c36c55..97735ad0415c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,8 @@ yarn-error.log* # vscode debug logs debug.log -app.log \ No newline at end of file +app.log + +# AI rules +.*/rules +AGENTS.md diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 1bcdb9744954..91a6a5c671f2 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -10,6 +10,12 @@ "type": "shell", "command": "azurite --location ../", "isBackground": true, + "options": { + "env": { + "LC_ALL": "en-US.UTF-8", + "LANG": "en-US" + } + }, "problemMatcher": { "pattern": [ { diff --git a/CLA.md b/CLA.md new file mode 100644 index 000000000000..1f94b337f7bf --- /dev/null +++ b/CLA.md @@ -0,0 +1,83 @@ +# Contributor License Agreement (CLA) + +This Contributor License Agreement ("Agreement") is entered into by the individual or entity ("You") submitting a Contribution to this project. By submitting a Contribution, You agree to the following terms and conditions: + +--- + +## 1. Definitions + +1. **"Contribution"** means any original work of authorship, including modifications or additions to existing works, submitted in any form (including source code, object code, documentation, or other materials) to this repository. +2. **"CyberDrain"** means the maintainers, owners, or legal rights holders of this repository, including successors and assigns. +3. **"Project License"** refers to the **GNU Affero General Public License, version 3 (AGPL-3.0)** under which this project is distributed, unless CyberDrain elects to relicense under a custom license. + +--- + +## 2. Copyright Assignment + +You hereby assign to CyberDrain, effective on submission of any Contribution, **all right, title, and interest worldwide in and to the copyright** of Your Contributions. + +This assignment includes, without limitation, the exclusive rights to: + +* Reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute the Contributions in any medium, and +* Relicense the Contributions under the AGPL-3.0 license, any future versions of that license, or under custom/commercial licenses as CyberDrain deems appropriate. + +To the extent that applicable law prohibits the assignment of certain moral rights or similar rights, You hereby irrevocably waive those rights to the maximum extent permitted by law. + +--- + +## 3. Patent Grant + +You hereby grant to CyberDrain, its successors, assigns, and licensees a **perpetual, worldwide, non-exclusive, transferable, irrevocable, royalty-free, fully paid-up license** under any patents that You own or control, to make, have made, use, offer to sell, sell, import, and otherwise transfer Your Contributions. + +This patent license extends only to the combination of Your Contributions with the Project to which they were submitted. + +--- + +## 4. License Grant Back to You + +CyberDrain hereby grants You a non-exclusive, worldwide, royalty-free, irrevocable license to use, reproduce, and prepare derivative works of Your Contributions for any purpose, **provided such use does not conflict with the licensing terms applied by CyberDrain** (including AGPL-3.0 or custom licenses). + +--- + +## 5. Representations and Warranties + +By submitting a Contribution, You represent and warrant that: + +1. The Contribution is Your original creation, or You have sufficient rights to submit it. +2. The Contribution does not knowingly violate or infringe any third-party intellectual property rights. +3. You are legally entitled to assign copyright and grant the licenses described herein. +4. The Contribution is submitted free of any encumbrances, liens, or claims by any third party. + +--- + +## 6. Custom Licensing + +CyberDrain reserves the right to distribute the Project, including Your Contributions, under: + +* The **AGPL-3.0 license**, and/or +* **Custom or commercial licenses**, including licenses granted to sponsors via GitHub Sponsorships. + +Contributors acknowledge and agree that: + +* Their Contributions may be included under such custom licenses. +* No royalties, fees, or other compensation shall be due to Contributors in connection with such relicensing. + +--- + +## 7. Disclaimer of Warranty + +Except as expressly stated in this Agreement, You provide Contributions **β€œAS IS”**, without warranties or conditions of any kind, express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, or non-infringement. + +--- + +## 8. Limitation of Liability + +In no event shall You be liable for any direct, indirect, incidental, special, exemplary, or consequential damages arising out of or in connection with Your Contributions, even if advised of the possibility of such damages. + +--- + +## 9. Acceptance + +By submitting a Contribution to this repository, You acknowledge that You have read and understood this Agreement, and that You agree to be legally bound by its terms. + +No signature is required β€” **submission of a Contribution constitutes acceptance**. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000000..219f861658e2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,47 @@ +# Contributing to This Project + +First of all – thank you for considering contributing! πŸŽ‰ Contributions help improve this project for everyone, and we welcome issues, discussions, and pull requests. + +Please read through this document before contributing. + +--- + +## Contributor License Agreement (CLA) + +By contributing to this repository, you agree to the terms of our **Contributor License Agreement (CLA):** + +* **Copyright Transfer**: All contributions (commits, pull requests, issues, or code reviews) are automatically assigned to **CyberDrain**. +* Contributors give up ownership rights of their contributions and transfer them fully to CyberDrain. +* CyberDrain may use, modify, distribute, sublicense, or relicense the contributions under any terms it deems fit, including custom or commercial licenses. +* **You do not need to sign anything** – the act of contributing implies agreement with this CLA. + +--- + +## Custom Licenses + +This project is generally open source, but we also provide **custom licensing options**: + +* Custom licenses are available **upon agreement**. +* Sponsors who arrange a custom license are **not required** to publish their license terms in this repository. +* Since copyright of contributions is transferred to CyberDrain, CyberDrain has full authority to include contributions under such custom licensing terms. + + +--- + +## How to Contribute + +As this project is ever evolving, we recommend checking out the contributions docs on our doc page here: + +- https://docs.cipp.app/dev-documentation/contributing-to-the-code +- https://docs.cipp.app/dev-documentation/cipp-dev-guide +- https://docs.cipp.app/dev-documentation/contributing-to-the-documentation + +--- + +## Code of Conduct + +We expect all contributors to follow respectful, inclusive, and collaborative practices. +Please help keep this project a safe and welcoming place for everyone. + +πŸ‘‰ By contributing to this repository, you acknowledge that your contributions are automatically and irrevocably transferred in copyright to **CyberDrain**, and that they are covered by the CLA described above. + diff --git a/LICENSE.CustomLicenses.md b/LICENSE.CustomLicenses.md new file mode 100644 index 000000000000..2c8ebfbe3dea --- /dev/null +++ b/LICENSE.CustomLicenses.md @@ -0,0 +1,14 @@ +1. Availability of Custom Licenses +Custom licenses are available to sponsors via GitHub Sponsorships. Upon mutual agreement between the project maintainers and the sponsor, such licenses shall apply to the sponsored party. + +2. Publication Exemption +Custom licenses granted through GitHub Sponsorships are exempt from publication in this repository. Sponsors and maintainers may keep such agreements private. + +3. Contributor License Agreement (CLA) +By contributing to this repository in any form (including but not limited to commits, pull requests, and code reviews), contributors explicitly agree to the terms of this Contributor License Agreement. + +4. Coverage of Contributions +Any and all commits made to this repository are automatically considered covered under this CLA. Contributors retain copyright to their individual contributions, while granting the maintainers the necessary rights to use, modify, distribute, and sublicense such contributions in accordance with the terms of the project. + +5. Automatic Acceptance +All contributors to this repository, by the act of contribution, automatically and irrevocably agree to the provisions of this CLA and the terms herein. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 000000000000..29ebfa545f55 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. \ No newline at end of file diff --git a/context7.json b/context7.json new file mode 100644 index 000000000000..3a0b7c321b31 --- /dev/null +++ b/context7.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://context7.com/schema/context7.json", + "projectTitle": "CIPP - Cyberdrain Improved Partner Portal", + "description": "The CyberDrain Improved Partner Portal is a portal to help manage administration for Microsoft Partners.", + "folders": [], + "excludeFolders": [], + "excludeFiles": [], + "rules": [], + "previousVersions": [], + "branch": "docs" +} diff --git a/cspell.json b/cspell.json index 1ff07bf80063..9ffb9e0d6e27 100644 --- a/cspell.json +++ b/cspell.json @@ -1,64 +1,81 @@ { - "version": "0.2", - "ignorePaths": [], - "dictionaryDefinitions": [], - "dictionaries": [], - "words": [ - "ADMS", - "AITM", - "Augmentt", - "Autotask", - "Choco", - "CIPP", - "CIPP-API", - "Datto", - "Entra", - "ESET", - "GDAP", - "HIBP", - "Hudu", - "ImmyBot", - "Intune", - "LCID", - "OBEE", - "Passwordless", - "pwpush", - "Rewst", - "Sherweb", - "Syncro", - "Yubikey" - ], - "ignoreWords": [ - "Addins", - "CIPPAPI", - "PSTN", - "TNEF", - "exo_individualsharing", - "exo_mailboxaudit", - "exo_mailtipsenabled", - "exo_outlookaddins", - "exo_storageproviderrestricted", - "locationcipp", - "mdo_antiphishingpolicies", - "mdo_autoforwardingmode", - "mdo_blockmailforward", - "mdo_commonattachmentsfilter", - "mdo_highconfidencephishaction", - "mdo_highconfidencespamaction", - "mdo_phishthresholdlevel", - "mdo_phisspamacation", - "mdo_safeattachmentpolicy", - "mdo_safeattachments", - "mdo_safedocuments", - "mdo_safelinksforOfficeApps", - "mdo_safelinksforemail", - "mdo_spam_notifications_only_for_admins", - "mdo_zapmalware", - "mdo_zapphish", - "mdo_zapspam", - "microsoftonline", - "mip_search_auditlog", - "winmail" - ], - "import": [] + "version": "0.2", + "ignorePaths": [], + "dictionaryDefinitions": [], + "dictionaries": [], + "words": [ + "ADMS", + "AITM", + "AOSP", + "Augmentt", + "Automapping", + "Autotask", + "Choco", + "cipp", + "CIPP", + "CIPP-API", + "Datto", + "DMARC", + "Entra", + "ESET", + "GDAP", + "HIBP", + "Hudu", + "ImmyBot", + "Intune", + "LCID", + "OBEE", + "passwordless", + "Passwordless", + "pwpush", + "Reshare", + "Rewst", + "Sherweb", + "superadmin", + "Syncro", + "TERRL", + "unconfigured", + "Yubikey" + ], + "ignoreWords": [ + "Addins", + "Disablex", + "Displayname", + "CIPPAPI", + "PSTN", + "TNEF", + "Equivio", + "defaultvalues", + "Excludedfile", + "exo_individualsharing", + "exo_mailboxaudit", + "exo_mailtipsenabled", + "exo_outlookaddins", + "exo_storageproviderrestricted", + "donotchange", + "locationcipp", + "mdo_antiphishingpolicies", + "mdo_autoforwardingmode", + "mdo_blockmailforward", + "mdo_commonattachmentsfilter", + "mdo_highconfidencephishaction", + "mdo_highconfidencespamaction", + "mdo_phishthresholdlevel", + "mdo_phisspamacation", + "mdo_safeattachmentpolicy", + "mdo_safeattachments", + "mdo_safedocuments", + "mdo_safelinksforOfficeApps", + "mdo_safelinksforemail", + "mdo_spam_notifications_only_for_admins", + "mdo_zapmalware", + "mdo_zapphish", + "mdo_zapspam", + "microsoftonline", + "mip_search_auditlog", + "winmail", + "onmicrosoft.com", + "MOERA" + ], + "import": [] } diff --git a/generate-placeholders.js b/generate-placeholders.js index 2f6b614fe9a8..2b34888fca7a 100644 --- a/generate-placeholders.js +++ b/generate-placeholders.js @@ -70,6 +70,8 @@ const pages = [ { title: "Defender Deployment", path: "/security/defender/deployment" }, { title: "Vulnerabilities", path: "/security/defender/list-defender-tvm" }, { title: "Device Compliance", path: "/security/reports/list-device-compliance" }, + { title: "Safe Links", path: "/security/safelinks/safelinks" }, + { title: "Safe Links Templates", path: "/security/safelinks/safelinks-template" }, { title: "Applications", path: "/endpoint/applications/list" }, { title: "Application Queue", path: "/endpoint/applications/queue" }, { title: "Add Choco App", path: "/endpoint/applications/add-choco-app" }, @@ -81,7 +83,6 @@ const pages = [ { title: "Profiles", path: "/endpoint/autopilot/list-profiles" }, { title: "Add Profile", path: "/endpoint/autopilot/add-profile" }, { title: "Status Pages", path: "/endpoint/autopilot/list-status-pages" }, - { title: "Add Status Page", path: "/endpoint/autopilot/add-status-page" }, { title: "Devices", path: "/endpoint/MEM/devices" }, { title: "Configuration Policies", path: "/endpoint/MEM/list-policies" }, { title: "Compliance Policies", path: "/endpoint/MEM/list-compliance-policies" }, @@ -99,6 +100,7 @@ const pages = [ { title: "Deleted Mailboxes", path: "/email/administration/deleted-mailboxes" }, { title: "Mailbox Rules", path: "/email/administration/mailbox-rules" }, { title: "Contacts", path: "/email/administration/contacts" }, + { title: "Contact Templates", path: "/email/administration/contacts-template" }, { title: "Quarantine", path: "/email/administration/quarantine" }, { title: "Tenant Allow/Block Lists", path: "/email/administration/tenant-allow-block-lists" }, { title: "Mailbox Restore Wizard", path: "/email/tools/mailbox-restore-wizard" }, @@ -121,7 +123,6 @@ const pages = [ { title: "Message Trace", path: "/email/reports/message-trace" }, { title: "Anti-Phishing Filters", path: "/email/reports/antiphishing-filters" }, { title: "Malware Filters", path: "/email/reports/malware-filters" }, - { title: "Safe Links Filters", path: "/email/reports/safelinks-filters" }, { title: "Safe Attachments Filters", path: "/email/reports/safeattachments-filters" }, { title: "Shared Mailbox with Enabled Account", diff --git a/package.json b/package.json index 934681339eba..1444ad6102ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "7.1.3", + "version": "8.5.2", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { @@ -27,38 +27,38 @@ "@emotion/cache": "11.14.0", "@emotion/react": "11.14.0", "@emotion/server": "11.11.0", - "@emotion/styled": "11.14.0", + "@emotion/styled": "11.14.1", "@heroicons/react": "2.2.0", "@monaco-editor/react": "^4.6.0", - "@mui/icons-material": "6.4.7", - "@mui/lab": "6.0.0-beta.30", - "@mui/material": "6.4.7", - "@mui/system": "6.4.7", - "@mui/x-date-pickers": "7.27.3", + "@mui/icons-material": "7.3.2", + "@mui/lab": "7.0.0-beta.17", + "@mui/material": "7.3.2", + "@mui/system": "7.3.2", + "@mui/x-date-pickers": "^8.11.1", "@musement/iso-duration": "^1.0.0", - "@react-pdf/renderer": "4.3.0", - "@reduxjs/toolkit": "2.6.1", + "@react-pdf/renderer": "^4.3.0", + "@reduxjs/toolkit": "2.9.0", "@tanstack/query-sync-storage-persister": "^5.76.0", "@tanstack/react-query": "^5.51.11", "@tanstack/react-query-devtools": "^5.51.11", "@tanstack/react-query-persist-client": "^5.76.0", "@tanstack/react-table": "^8.19.2", - "@tiptap/core": "^2.9.1", - "@tiptap/extension-heading": "^2.9.1", - "@tiptap/extension-image": "^2.9.1", - "@tiptap/extension-table": "^2.9.1", - "@tiptap/pm": "^2.9.1", - "@tiptap/react": "^2.9.1", - "@tiptap/starter-kit": "^2.9.1", + "@tiptap/core": "^3.4.1", + "@tiptap/extension-heading": "^3.4.1", + "@tiptap/extension-image": "^3.4.1", + "@tiptap/extension-table": "^3.4.1", + "@tiptap/pm": "^3.4.1", + "@tiptap/react": "^3.4.1", + "@tiptap/starter-kit": "^3.4.1", "@uiw/react-json-view": "^2.0.0-alpha.30", - "apexcharts": "4.5.0", + "apexcharts": "5.3.5", "axios": "^1.7.2", "date-fns": "4.1.0", "eml-parse-js": "^1.2.0-beta.0", "export-to-csv": "^1.3.0", "formik": "2.4.6", "gray-matter": "4.0.3", - "i18next": "24.2.3", + "i18next": "25.5.2", "javascript-time-ago": "^2.5.11", "jspdf": "^3.0.0", "jspdf-autotable": "^5.0.2", @@ -67,48 +67,51 @@ "leaflet.markercluster": "^1.5.3", "lodash.isequal": "4.5.0", "material-react-table": "^3.0.1", - "monaco-editor": "^0.52.0", + "monaco-editor": "^0.53.0", "mui-tiptap": "^1.14.0", "next": "^15.2.2", "nprogress": "0.2.0", "numeral": "2.0.6", "prop-types": "15.8.1", "punycode": "^2.3.1", - "react": "19.0.0", + "react": "19.1.1", "react-apexcharts": "1.7.0", "react-beautiful-dnd": "13.1.1", "react-copy-to-clipboard": "^5.1.0", - "react-dom": "19.0.0", + "react-dom": "19.1.1", "react-dropzone": "14.3.8", - "react-error-boundary": "^5.0.0", + "react-error-boundary": "^6.0.0", "react-grid-layout": "^1.5.0", "react-hook-form": "^7.53.0", - "react-hot-toast": "2.5.2", + "react-hot-toast": "2.6.0", "react-html-parser": "^2.0.2", - "react-i18next": "15.4.1", + "react-i18next": "15.7.3", "react-leaflet": "5.0.0", "react-leaflet-markercluster": "^5.0.0-rc.0", "react-markdown": "10.1.0", + "rehype-raw": "^7.0.0", + "remark-gfm": "^3.0.1", "react-media-hook": "^0.5.0", "react-papaparse": "^4.4.0", "react-quill": "^2.0.0", "react-redux": "9.2.0", "react-syntax-highlighter": "^15.6.1", "react-time-ago": "^7.3.3", - "react-window": "^1.8.10", + "react-virtuoso": "^4.12.8", + "react-window": "^2.1.0", "redux": "5.0.1", "redux-devtools-extension": "2.13.9", "redux-persist": "^6.0.0", "redux-thunk": "3.1.0", - "simplebar": "6.3.0", - "simplebar-react": "3.3.0", + "simplebar": "6.3.2", + "simplebar-react": "3.3.2", "stylis-plugin-rtl": "2.1.1", - "typescript": "5.8.2", - "yup": "1.6.1" + "typescript": "5.9.2", + "yup": "1.7.0" }, "devDependencies": { "@svgr/webpack": "8.1.0", - "eslint": "9.22.0", - "eslint-config-next": "15.2.2" + "eslint": "9.35.0", + "eslint-config-next": "15.5.2" } } diff --git a/public/assets/illustrations/undraw-into-the-night-nd84.svg b/public/assets/illustrations/undraw-into-the-night-nd84.svg new file mode 100644 index 000000000000..a7d05162299b --- /dev/null +++ b/public/assets/illustrations/undraw-into-the-night-nd84.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/languageList.json b/public/languageList.json index 3bd0ec3d8e82..769bf0b60f6b 100644 --- a/public/languageList.json +++ b/public/languageList.json @@ -3,240 +3,595 @@ "language": "Arabic", "Geographic area": "Saudi Arabia", "tag": "ar-SA", + "languageTag": "Arabic (ar-SA)", "LCID": "1025" }, + { + "language": "Arabic", + "Geographic area": "Algeria", + "tag": "ar-DZ", + "languageTag": "Arabic (ar-DZ)", + "LCID": "5121" + }, + { + "language": "Arabic", + "Geographic area": "Egypt", + "tag": "ar-EG", + "languageTag": "Arabic (ar-EG)", + "LCID": "3073" + }, + { + "language": "Arabic", + "Geographic area": "Bahrain", + "tag": "ar-BH", + "languageTag": "Arabic (ar-BH)", + "LCID": "15361" + }, + { + "language": "Arabic", + "Geographic area": "Iraq", + "tag": "ar-IQ", + "languageTag": "Arabic (ar-IQ)", + "LCID": "2049" + }, + { + "language": "Arabic", + "Geographic area": "Jordan", + "tag": "ar-JO", + "languageTag": "Arabic (ar-JO)", + "LCID": "11265" + }, + { + "language": "Arabic", + "Geographic area": "Kuwait", + "tag": "ar-KW", + "languageTag": "Arabic (ar-KW)", + "LCID": "13313" + }, + { + "language": "Arabic", + "Geographic area": "Lebanon", + "tag": "ar-LB", + "languageTag": "Arabic (ar-LB)", + "LCID": "12289" + }, + { + "language": "Arabic", + "Geographic area": "Libya", + "tag": "ar-LY", + "languageTag": "Arabic (ar-LY)", + "LCID": "4097" + }, + { + "language": "Arabic", + "Geographic area": "Morocco", + "tag": "ar-MA", + "languageTag": "Arabic (ar-MA)", + "LCID": "6145" + }, + { + "language": "Arabic", + "Geographic area": "Oman", + "tag": "ar-OM", + "languageTag": "Arabic (ar-OM)", + "LCID": "8193" + }, + { + "language": "Arabic", + "Geographic area": "Qatar", + "tag": "ar-QA", + "languageTag": "Arabic (ar-QA)", + "LCID": "16385" + }, + { + "language": "Arabic", + "Geographic area": "Syria", + "tag": "ar-SY", + "languageTag": "Arabic (ar-SY)", + "LCID": "10241" + }, + { + "language": "Arabic", + "Geographic area": "Tunisia", + "tag": "ar-TN", + "languageTag": "Arabic (ar-TN)", + "LCID": "7169" + }, + { + "language": "Arabic", + "Geographic area": "UAE", + "tag": "ar-AE", + "languageTag": "Arabic (ar-AE)", + "LCID": "14337" + }, + { + "language": "Arabic", + "Geographic area": "Yemen", + "tag": "ar-YE", + "languageTag": "Arabic (ar-YE)", + "LCID": "9217" + }, { "language": "Bulgarian", "Geographic area": "Bulgaria", "tag": "bg-BG", + "languageTag": "Bulgarian (bg-BG)", "LCID": "1026" }, { "language": "Chinese (Simplified)", "Geographic area": "People's Republic of China", "tag": "zh-CN", + "languageTag": "Chinese (Simplified) (zh-CN)", "LCID": "2052" }, { "language": "Chinese", "Geographic area": "Taiwan", "tag": "zh-TW", + "languageTag": "Chinese (zh-TW)", "LCID": "1028" }, + { + "language": "Chinese", + "Geographic area": "Hong Kong SAR", + "tag": "zh-HK", + "languageTag": "Chinese (zh-HK)", + "LCID": "3076" + }, { "language": "Croatian", "Geographic area": "Croatia", "tag": "hr-HR", + "languageTag": "Croatian (hr-HR)", "LCID": "1050" }, { "language": "Czech", "Geographic area": "Czech Republic", "tag": "cs-CZ", + "languageTag": "Czech (cs-CZ)", "LCID": "1029" }, { "language": "Danish", "Geographic area": "Denmark", "tag": "da-DK", + "languageTag": "Danish (da-DK)", "LCID": "1030" }, { "language": "Dutch", "Geographic area": "Netherlands", "tag": "nl-NL", + "languageTag": "Dutch (nl-NL)", "LCID": "1043" }, { "language": "English", "Geographic area": "United States", "tag": "en-US", + "languageTag": "English (en-US)", "LCID": "1033" }, + { + "language": "English", + "Geographic area": "Australia", + "tag": "en-AU", + "languageTag": "English (en-AU)", + "LCID": "3081" + }, + { + "language": "English", + "Geographic area": "United Kingdom", + "tag": "en-GB", + "languageTag": "English (en-GB)", + "LCID": "2057" + }, + { + "language": "English", + "Geographic area": "New Zealand", + "tag": "en-NZ", + "languageTag": "English (en-NZ)", + "LCID": "5129" + }, + { + "language": "English", + "Geographic area": "Canada", + "tag": "en-CA", + "languageTag": "English (en-CA)", + "LCID": "4105" + }, + { + "language": "English", + "Geographic area": "South Africa", + "tag": "en-ZA", + "languageTag": "English (en-ZA)", + "LCID": "7177" + }, + { + "language": "English", + "Geographic area": "Singapore", + "tag": "en-SG", + "languageTag": "English (en-SG)", + "LCID": "4100" + }, { "language": "Estonian", "Geographic area": "Estonia", "tag": "et-EE", + "languageTag": "Estonian (et-EE)", "LCID": "1061" }, { "language": "Finnish", "Geographic area": "Finland", "tag": "fi-FI", + "languageTag": "Finnish (fi-FI)", "LCID": "1035" }, { "language": "French", "Geographic area": "France", "tag": "fr-FR", + "languageTag": "French (fr-FR)", "LCID": "1036" }, + { + "language": "French", + "Geographic area": "Canada", + "tag": "fr-CA", + "languageTag": "French (fr-CA)", + "LCID": "3084" + }, + { + "language": "French", + "Geographic area": "Switzerland", + "tag": "fr-CH", + "languageTag": "French (fr-CH)", + "LCID": "4108" + }, { "language": "German", "Geographic area": "Germany", "tag": "de-DE", + "languageTag": "German (de-DE)", "LCID": "1031" }, + { + "language": "German", + "Geographic area": "Switzerland", + "tag": "de-CH", + "languageTag": "German (de-CH)", + "LCID": "2055" + }, { "language": "Greek", "Geographic area": "Greece", "tag": "el-GR", + "languageTag": "Greek (el-GR)", "LCID": "1032" }, { "language": "Hebrew", "Geographic area": "Israel", "tag": "he-IL", + "languageTag": "Hebrew (he-IL)", "LCID": "1037" }, { "language": "Hindi", "Geographic area": "India", "tag": "hi-IN", + "languageTag": "Hindi (hi-IN)", "LCID": "1081" }, { "language": "Hungarian", "Geographic area": "Hungary", "tag": "hu-HU", + "languageTag": "Hungarian (hu-HU)", "LCID": "1038" }, { "language": "Indonesian", "Geographic area": "Indonesia", "tag": "id-ID", + "languageTag": "Indonesian (id-ID)", "LCID": "1057" }, { "language": "Italian", "Geographic area": "Italy", "tag": "it-IT", + "languageTag": "Italian (it-IT)", "LCID": "1040" }, { "language": "Japanese", "Geographic area": "Japan", "tag": "ja-JP", + "languageTag": "Japanese (ja-JP)", "LCID": "1041" }, { "language": "Kazakh", "Geographic area": "Kazakhstan", "tag": "kk-KZ", + "languageTag": "Kazakh (kk-KZ)", "LCID": "1087" }, { "language": "Korean", "Geographic area": "Korea", "tag": "ko-KR", + "languageTag": "Korean (ko-KR)", "LCID": "1042" }, { "language": "Latvian", "Geographic area": "Latvia", "tag": "lv-LV", + "languageTag": "Latvian (lv-LV)", "LCID": "1062" }, { "language": "Lithuanian", "Geographic area": "Lithuania", "tag": "lt-LT", + "languageTag": "Lithuanian (lt-LT)", "LCID": "1063" }, { "language": "Malay", "Geographic area": "Malaysia", "tag": "ms-MY", + "languageTag": "Malay (ms-MY)", "LCID": "1086" }, { "language": "Norwegian (BokmΓ₯l)", "Geographic area": "Norway", "tag": "nb-NO", + "languageTag": "Norwegian (BokmΓ₯l) (nb-NO)", "LCID": "1044" }, + { + "language": "Persian", + "Geographic area": "Iran", + "tag": "fa-IR", + "languageTag": "Persian (fa-IR)", + "LCID": "1065" + }, { "language": "Polish", "Geographic area": "Poland", "tag": "pl-PL", + "languageTag": "Polish (pl-PL)", "LCID": "1045" }, { "language": "Portuguese", "Geographic area": "Brazil", "tag": "pt-BR", + "languageTag": "Portuguese (pt-BR)", "LCID": "1046" }, { "language": "Portuguese", "Geographic area": "Portugal", "tag": "pt-PT", + "languageTag": "Portuguese (pt-PT)", "LCID": "2070" }, { "language": "Romanian", "Geographic area": "Romania", "tag": "ro-RO", + "languageTag": "Romanian (ro-RO)", "LCID": "1048" }, { "language": "Russian", "Geographic area": "Russia", "tag": "ru-RU", + "languageTag": "Russian (ru-RU)", "LCID": "1049" }, { "language": "Serbian (Latin)", "Geographic area": "Serbia", "tag": "sr-latn-RS", + "languageTag": "Serbian (Latin) (sr-latn-RS)", "LCID": "2074" }, { "language": "Slovak", "Geographic area": "Slovakia", "tag": "sk-SK", + "languageTag": "Slovak (sk-SK)", "LCID": "1051" }, { "language": "Slovenian", "Geographic area": "Slovenia", "tag": "sl-SI", + "languageTag": "Slovenian (sl-SI)", "LCID": "1060" }, { "language": "Spanish", "Geographic area": "Spain", "tag": "es-ES", + "languageTag": "Spanish (es-ES)", "LCID": "3082" }, + { + "language": "Spanish", + "Geographic area": "Argentina", + "tag": "es-AR", + "languageTag": "Spanish (es-AR)", + "LCID": "11274" + }, + { + "language": "Spanish", + "Geographic area": "Bolivia", + "tag": "es-BO", + "languageTag": "Spanish (es-BO)", + "LCID": "16394" + }, + { + "language": "Spanish", + "Geographic area": "Chile", + "tag": "es-CL", + "languageTag": "Spanish (es-CL)", + "LCID": "13322" + }, + { + "language": "Spanish", + "Geographic area": "Colombia", + "tag": "es-CO", + "languageTag": "Spanish (es-CO)", + "LCID": "9226" + }, + { + "language": "Spanish", + "Geographic area": "Costa Rica", + "tag": "es-CR", + "languageTag": "Spanish (es-CR)", + "LCID": "5130" + }, + { + "language": "Spanish", + "Geographic area": "Dominican Republic", + "tag": "es-DO", + "languageTag": "Spanish (es-DO)", + "LCID": "7178" + }, + { + "language": "Spanish", + "Geographic area": "Ecuador", + "tag": "es-EC", + "languageTag": "Spanish (es-EC)", + "LCID": "12298" + }, + { + "language": "Spanish", + "Geographic area": "El Salvador", + "tag": "es-SV", + "languageTag": "Spanish (es-SV)", + "LCID": "17418" + }, + { + "language": "Spanish", + "Geographic area": "Guatemala", + "tag": "es-GT", + "languageTag": "Spanish (es-GT)", + "LCID": "4106" + }, + { + "language": "Spanish", + "Geographic area": "Honduras", + "tag": "es-HN", + "languageTag": "Spanish (es-HN)", + "LCID": "18442" + }, + { + "language": "Spanish", + "Geographic area": "Mexico", + "tag": "es-MX", + "languageTag": "Spanish (es-MX)", + "LCID": "2058" + }, + { + "language": "Spanish", + "Geographic area": "Nicaragua", + "tag": "es-NI", + "languageTag": "Spanish (es-NI)", + "LCID": "19466" + }, + { + "language": "Spanish", + "Geographic area": "Panama", + "tag": "es-PA", + "languageTag": "Spanish (es-PA)", + "LCID": "6154" + }, + { + "language": "Spanish", + "Geographic area": "Paraguay", + "tag": "es-PY", + "languageTag": "Spanish (es-PY)", + "LCID": "15370" + }, + { + "language": "Spanish", + "Geographic area": "Peru", + "tag": "es-PE", + "languageTag": "Spanish (es-PE)", + "LCID": "10250" + }, + { + "language": "Spanish", + "Geographic area": "Uruguay", + "tag": "es-UY", + "languageTag": "Spanish (es-UY)", + "LCID": "14346" + }, + { + "language": "Spanish", + "Geographic area": "Venezuela", + "tag": "es-VE", + "languageTag": "Spanish (es-VE)", + "LCID": "8202" + }, { "language": "Swedish", "Geographic area": "Sweden", "tag": "sv-SE", + "languageTag": "Swedish (sv-SE)", "LCID": "1053" }, { "language": "Thai", "Geographic area": "Thailand", "tag": "th-TH", + "languageTag": "Thai (th-TH)", "LCID": "1054" }, { "language": "Turkish", "Geographic area": "Turkey", "tag": "tr-TR", + "languageTag": "Turkish (tr-TR)", "LCID": "1055" }, { "language": "Ukrainian", - "Geographic area": "Ukrainian", + "Geographic area": "Ukraine", "tag": "uk-UA", + "languageTag": "Ukrainian (uk-UA)", "LCID": "1058" }, + { + "language": "Urdu", + "Geographic area": "Pakistan", + "tag": "ur-PK", + "languageTag": "Urdu (ur-PK)", + "LCID": "1056" + }, { "language": "Vietnamese", "Geographic area": "Vietnam", "tag": "vi-VN", + "languageTag": "Vietnamese (vi-VN)", "LCID": "1066" + }, + { + "language": "Welsh", + "Geographic area": "Wales", + "tag": "cy-GB", + "languageTag": "Welsh (cy-GB)", + "LCID": "1106" } -] +] \ No newline at end of file diff --git a/public/permissionsList.json b/public/permissionsList.json index c91c29126036..8ec55a707b7e 100644 --- a/public/permissionsList.json +++ b/public/permissionsList.json @@ -7969,5 +7969,25 @@ "userConsentDescription": "Allows the app to manage workforce integrations, to synchronize data from Microsoft Teams Shifts, on your behalf.", "userConsentDisplayName": "Read and write workforce integrations", "value": "WorkforceIntegration.ReadWrite.All" + }, + { + "description": "Read and Modify Tenant-Acquired Telephone Number Details", + "displayName": "Read and Modify Tenant-Acquired Telephone Number Details", + "id": "424b07a8-1209-4d17-9fe4-9018a93a1024", + "isEnabled": true, + "Origin": "Delegated", + "userConsentDescription": "Allows the app to read and modify your tenant's acquired telephone number details on behalf of the signed-in admin user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.", + "userConsentDisplayName": "Allows the app to read and modify your tenant's acquired telephone number details on behalf of the signed-in admin user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.", + "value": "TeamsTelephoneNumber.ReadWrite.All" + }, + { + "description": "Read and Modify Tenant-Acquired Telephone Number Details", + "displayName": "Read and Modify Tenant-Acquired Telephone Number Details", + "id": "0a42382f-155c-4eb1-9bdc-21548ccaa387", + "isEnabled": true, + "Origin": "Application", + "userConsentDescription": "Allows the app to read your tenant's acquired telephone number details, without a signed-in user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.", + "userConsentDisplayName": "Allows the app to read your tenant's acquired telephone number details, without a signed-in user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.", + "value": "TeamsTelephoneNumber.ReadWrite.All" } ] diff --git a/public/reportImages/board.jpg b/public/reportImages/board.jpg new file mode 100644 index 000000000000..05c3e51ddc34 Binary files /dev/null and b/public/reportImages/board.jpg differ diff --git a/public/reportImages/city.jpg b/public/reportImages/city.jpg new file mode 100644 index 000000000000..155439cf30f2 Binary files /dev/null and b/public/reportImages/city.jpg differ diff --git a/public/reportImages/glasses.jpg b/public/reportImages/glasses.jpg new file mode 100644 index 000000000000..b41d200ea650 Binary files /dev/null and b/public/reportImages/glasses.jpg differ diff --git a/public/reportImages/laptop.jpg b/public/reportImages/laptop.jpg new file mode 100644 index 000000000000..31a14fc4383e Binary files /dev/null and b/public/reportImages/laptop.jpg differ diff --git a/public/reportImages/soc.jpg b/public/reportImages/soc.jpg new file mode 100644 index 000000000000..f8da4eba5139 Binary files /dev/null and b/public/reportImages/soc.jpg differ diff --git a/public/reportImages/working.jpg b/public/reportImages/working.jpg new file mode 100644 index 000000000000..c979c21b1ea0 Binary files /dev/null and b/public/reportImages/working.jpg differ diff --git a/public/sponsors/domotz-dark.png b/public/sponsors/domotz-dark.png new file mode 100644 index 000000000000..ac26f6f0f6ad Binary files /dev/null and b/public/sponsors/domotz-dark.png differ diff --git a/public/sponsors/domotz-light.png b/public/sponsors/domotz-light.png new file mode 100644 index 000000000000..dab40b067807 Binary files /dev/null and b/public/sponsors/domotz-light.png differ diff --git a/public/version.json b/public/version.json index fea50d299123..571782873e0e 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "7.5.3" -} \ No newline at end of file + "version": "8.6.1" +} diff --git a/src/api/ApiCall.jsx b/src/api/ApiCall.jsx index 35554764168e..67f602114c3c 100644 --- a/src/api/ApiCall.jsx +++ b/src/api/ApiCall.jsx @@ -1,10 +1,4 @@ -import { - keepPreviousData, - useInfiniteQuery, - useMutation, - useQuery, - useQueryClient, -} from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import axios, { isAxiosError } from "axios"; import { useDispatch } from "react-redux"; import { showToast } from "../store/toasts"; @@ -26,6 +20,9 @@ export function ApiGetCall(props) { refetchOnMount = true, refetchOnReconnect = true, keepPreviousData = false, + refetchInterval = false, + responseType = "json", + convertToDataUrl = false, } = props; const queryClient = useQueryClient(); const dispatch = useDispatch(); @@ -81,7 +78,25 @@ export function ApiGetCall(props) { if (relatedQueryKeys) { const clearKeys = Array.isArray(relatedQueryKeys) ? relatedQueryKeys : [relatedQueryKeys]; setTimeout(() => { - clearKeys.forEach((key) => { + // Separate wildcard patterns from exact keys + const wildcardPatterns = clearKeys + .filter((key) => key.endsWith("*")) + .map((key) => key.slice(0, -1)); + const exactKeys = clearKeys.filter((key) => !key.endsWith("*")); + + // Use single predicate call for all wildcard patterns + if (wildcardPatterns.length > 0) { + queryClient.invalidateQueries({ + predicate: (query) => { + if (!query.queryKey || !query.queryKey[0]) return false; + const queryKeyStr = String(query.queryKey[0]); + return wildcardPatterns.some((pattern) => queryKeyStr.startsWith(pattern)); + }, + }); + } + + // Handle exact keys + exactKeys.forEach((key) => { queryClient.invalidateQueries({ queryKey: [key] }); }); }, 1000); @@ -94,19 +109,50 @@ export function ApiGetCall(props) { headers: { "Content-Type": "application/json", }, + responseType: responseType, }); + + let responseData = response.data; + + // Convert blob to data URL if requested + if (convertToDataUrl && responseType === "blob" && response.data) { + responseData = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.readAsDataURL(response.data); + }); + } + if (onResult) { - onResult(response.data); // Emit each result as it arrives + onResult(responseData); // Emit each result as it arrives } if (relatedQueryKeys) { const clearKeys = Array.isArray(relatedQueryKeys) ? relatedQueryKeys : [relatedQueryKeys]; setTimeout(() => { - clearKeys.forEach((key) => { + // Separate wildcard patterns from exact keys + const wildcardPatterns = clearKeys + .filter((key) => key.endsWith("*")) + .map((key) => key.slice(0, -1)); + const exactKeys = clearKeys.filter((key) => !key.endsWith("*")); + + // Use single predicate call for all wildcard patterns + if (wildcardPatterns.length > 0) { + queryClient.invalidateQueries({ + predicate: (query) => { + if (!query.queryKey || !query.queryKey[0]) return false; + const queryKeyStr = String(query.queryKey[0]); + return wildcardPatterns.some((pattern) => queryKeyStr.startsWith(pattern)); + }, + }); + } + + // Handle exact keys + exactKeys.forEach((key) => { queryClient.invalidateQueries({ queryKey: [key] }); }); }, 1000); } - return response.data; + return responseData; } }, staleTime: staleTime, @@ -114,6 +160,7 @@ export function ApiGetCall(props) { refetchOnMount: refetchOnMount, refetchOnReconnect: refetchOnReconnect, keepPreviousData: keepPreviousData, + refetchInterval: refetchInterval, retry: retryFn, }); return queryInfo; @@ -121,6 +168,7 @@ export function ApiGetCall(props) { export function ApiPostCall({ relatedQueryKeys, onResult }) { const queryClient = useQueryClient(); + const mutation = useMutation({ mutationFn: async (props) => { const { url, data, bulkRequest } = props; @@ -148,9 +196,43 @@ export function ApiPostCall({ relatedQueryKeys, onResult }) { const clearKeys = Array.isArray(relatedQueryKeys) ? relatedQueryKeys : [relatedQueryKeys]; setTimeout(() => { if (relatedQueryKeys === "*") { + console.log("Invalidating all queries"); queryClient.invalidateQueries(); } else { - clearKeys.forEach((key) => { + // Separate wildcard patterns from exact keys + const wildcardPatterns = clearKeys + .filter((key) => key.endsWith("*")) + .map((key) => key.slice(0, -1)); + const exactKeys = clearKeys.filter((key) => !key.endsWith("*")); + + // Use single predicate call for all wildcard patterns + if (wildcardPatterns.length > 0) { + queryClient.invalidateQueries({ + predicate: (query) => { + if (!query.queryKey || !query.queryKey[0]) return false; + const queryKeyStr = String(query.queryKey[0]); + const matches = wildcardPatterns.some((pattern) => + queryKeyStr.startsWith(pattern) + ); + + // Debug logging for each query check + if (matches) { + console.log("Invalidating query:", { + queryKey: query.queryKey, + queryKeyStr, + matchedPattern: wildcardPatterns.find((pattern) => + queryKeyStr.startsWith(pattern) + ), + }); + } + + return matches; + }, + }); + } + + // Handle exact keys + exactKeys.forEach((key) => { queryClient.invalidateQueries({ queryKey: [key] }); }); } diff --git a/src/components/CSVReader.jsx b/src/components/CSVReader.jsx index d34d9ee79007..be3f6f67f02a 100644 --- a/src/components/CSVReader.jsx +++ b/src/components/CSVReader.jsx @@ -1,86 +1,67 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { useCSVReader, lightenDarkenColor, formatFileSize } from "react-papaparse"; +import { Box, Typography, useTheme } from "@mui/material"; +import { CloudUpload } from "@mui/icons-material"; -const GREY = "#CCC"; -const GREY_LIGHT = "rgba(255, 255, 255, 0.4)"; +/* + * These colors define our remove button states. The light version is + * calculated rather than hardcoded - a small touch that ensures + * consistent color relationships no matter what base color we use. + * + * Sometimes it's these little details that make a component feel polished. + */ const DEFAULT_REMOVE_HOVER_COLOR = "#A01919"; const REMOVE_HOVER_COLOR_LIGHT = lightenDarkenColor(DEFAULT_REMOVE_HOVER_COLOR, 40); -const GREY_DIM = "#686868"; -const styles = { - zone: { - alignItems: "center", - border: `2px dashed`, - borderRadius: 20, - display: "flex", - flexDirection: "column", - height: "100%", - justifyContent: "center", - padding: 20, - }, - file: { - background: "linear-gradient(to bottom, #aaa, #aaa)", - borderRadius: 20, - display: "flex", - height: 60, - width: 120, - position: "relative", - zIndex: 10, - flexDirection: "column", - justifyContent: "center", - }, - info: { - alignItems: "center", - display: "flex", - flexDirection: "column", - paddingLeft: 10, - paddingRight: 10, - }, - size: { - borderRadius: 3, - marginBottom: "0.5em", - justifyContent: "center", - display: "flex", - }, - name: { - borderRadius: 3, - fontSize: 12, - marginBottom: "0.5em", - }, - progressBar: { - bottom: 14, - position: "absolute", - width: "100%", - paddingLeft: 10, - paddingRight: 10, - }, - zoneHover: { - borderColor: GREY_DIM, - }, - default: { - borderColor: GREY, - }, - remove: { - height: 23, - position: "absolute", - right: 6, - top: 6, - width: 23, - }, -}; - -export default function CSVReader(props) { +/* + * This component has evolved from a simple file input to a polished + * upload zone that maintains state between wizard steps. It's a good + * example of how components grow with requirements while trying to + * keep their core purpose clear. + * + * The journey to this version taught us about: + * - Proper event handling with third-party libraries + * - State persistence in multi-step forms + * - The value of simple solutions (sessionStorage vs complex state) + */ +export default function CSVReader({ config, onDrop, onRemove }) { const { CSVReader } = useCSVReader(); const [zoneHover, setZoneHover] = useState(false); const [removeHoverColor, setRemoveHoverColor] = useState(DEFAULT_REMOVE_HOVER_COLOR); + const [storedFile, setStoredFile] = useState(null); + const theme = useTheme(); + + /* + * On mount, we check sessionStorage for file details. This lets us + * restore the preview when users navigate back to this step. + * + * It's a simple solution that works well - sometimes the best + * approaches don't need complex state management. The fact that + * it "just works" is a feature, not a bug. + */ + useEffect(() => { + const fileName = sessionStorage.getItem('csvFileName'); + const fileSize = sessionStorage.getItem('csvFileSize'); + if (fileName && fileSize) { + console.log('Restoring file preview:', fileName); + setStoredFile({ + name: fileName, + size: parseInt(fileSize, 10) + }); + } + }, []); return ( { - //call the ondrop function from the props, passing the results. - props.onDrop(results.data); + config={config} + onUploadAccepted={(results, file) => { + console.log('File accepted:', file.name); + onDrop(results.data); setZoneHover(false); + setStoredFile(file); + // Store file details for persistence between steps + sessionStorage.setItem('csvFileName', file.name); + sessionStorage.setItem('csvFileSize', file.size.toString()); }} onDragOver={(event) => { event.preventDefault(); @@ -92,46 +73,91 @@ export default function CSVReader(props) { }} > {({ getRootProps, acceptedFile, ProgressBar, getRemoveFileProps, Remove }) => ( - <> -
- {acceptedFile ? ( - <> -
-
- {formatFileSize(acceptedFile.size)} - {acceptedFile.name} -
-
- -
-
{ - event.preventDefault(); - setRemoveHoverColor(REMOVE_HOVER_COLOR_LIGHT); - }} - onMouseOut={(event) => { - event.preventDefault(); - setRemoveHoverColor(DEFAULT_REMOVE_HOVER_COLOR); - }} - > - -
-
- - ) : ( - "Drop CSV file here or click to upload" - )} -
- + + {(acceptedFile || storedFile) ? ( + + + + + + {acceptedFile?.name || storedFile?.name} + + + {formatFileSize(acceptedFile?.size || storedFile?.size)} + + + + + {acceptedFile && } + + {/* + * The remove button's event handling taught us about working with + * third-party libraries. Instead of fighting the library's patterns, + * we adapted to work with them. A good reminder that sometimes + * the best solution is to follow the path of least resistance. + */} + { + console.log('Removing file'); + setStoredFile(null); + sessionStorage.removeItem('csvFileName'); + sessionStorage.removeItem('csvFileSize'); + // Notify parent that file was removed + onRemove?.(); + } + })} + > + + + + ) : ( + + + + Drop CSV file here + + + or click to browse + + + )} + )}
); -} +} \ No newline at end of file diff --git a/src/components/CippCards/CippBannerListCard.jsx b/src/components/CippCards/CippBannerListCard.jsx index 32e8aee05e8d..7c96e641df6e 100644 --- a/src/components/CippCards/CippBannerListCard.jsx +++ b/src/components/CippCards/CippBannerListCard.jsx @@ -39,8 +39,8 @@ export const CippBannerListCard = (props) => { - + @@ -72,17 +72,31 @@ export const CippBannerListCard = (props) => {
  • handleExpand(item.id) : undefined} > {/* Left Side: cardLabelBox */} - + {typeof item.cardLabelBox === "object" ? ( @@ -102,8 +116,16 @@ export const CippBannerListCard = (props) => { {/* Main Text and Subtext */} - - + + {item.text} @@ -113,7 +135,7 @@ export const CippBannerListCard = (props) => { {/* Right Side: Status and Expand Icon */} - + {item?.statusText && ( { {item.statusText} )} + {item?.cardLabelBoxActions && ( + e.stopPropagation()}>{item.cardLabelBoxActions} + )} {isCollapsible && ( - handleExpand(item.id)}> + { + e.stopPropagation(); + handleExpand(item.id); + }} + > { {item?.propertyItems?.length > 0 && ( )} @@ -187,9 +217,9 @@ CippBannerListCard.propTypes = { actionButton: PropTypes.element, propertyItems: PropTypes.array, table: PropTypes.object, - actionButton: PropTypes.element, isFetching: PropTypes.bool, children: PropTypes.node, + cardLabelBoxActions: PropTypes.element, }) ).isRequired, isCollapsible: PropTypes.bool, diff --git a/src/components/CippCards/CippChartCard.jsx b/src/components/CippCards/CippChartCard.jsx index 2bd455d87761..577a3f2bbaf1 100644 --- a/src/components/CippCards/CippChartCard.jsx +++ b/src/components/CippCards/CippChartCard.jsx @@ -92,12 +92,16 @@ export const CippChartCard = ({ chartType = "donut", title, actions, + onClick, + totalLabel = "Total", + customTotal, }) => { const [range, setRange] = useState("Last 7 days"); const [barSeries, setBarSeries] = useState([]); const chartOptions = useChartOptions(labels, chartType); chartSeries = chartSeries.filter((item) => item !== null); - const total = chartSeries.reduce((acc, value) => acc + value, 0); + const calculatedTotal = chartSeries.reduce((acc, value) => acc + value, 0); + const total = customTotal !== undefined ? customTotal : calculatedTotal; useEffect(() => { if (chartType === "bar") { setBarSeries( @@ -109,7 +113,18 @@ export const CippChartCard = ({ }, [chartType, chartSeries.length, labels]); return ( - + theme.shadows[8], + transform: "translateY(-2px)", + } : {}, + }} + > {labels.length > 0 && ( <> - Total + {totalLabel} {isFetching ? "0" : total} )} diff --git a/src/components/CippCards/CippDomainCards.jsx b/src/components/CippCards/CippDomainCards.jsx index 03fd08306afc..b6d8adb58bf4 100644 --- a/src/components/CippCards/CippDomainCards.jsx +++ b/src/components/CippCards/CippDomainCards.jsx @@ -1,6 +1,5 @@ -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { - Grid, Button, Collapse, Switch, @@ -13,6 +12,7 @@ import { Divider, FormControlLabel, } from "@mui/material"; +import { Grid } from "@mui/system"; import SearchIcon from "@mui/icons-material/Search"; import ClearIcon from "@mui/icons-material/Clear"; import SettingsIcon from "@mui/icons-material/Settings"; @@ -152,7 +152,7 @@ function DomainResultCard({ title, data, isFetching, info, type }) { ? { children: ( - + {info} @@ -187,7 +187,6 @@ function DomainResultCard({ title, data, isFetching, info, type }) { Record: - - + {info} setVisible(false)} {...offCanvasData} /> @@ -478,13 +477,13 @@ export const CippDomainCards = ({ domain: propDomain = "", fullwidth = false }) waiting: !!domain && enableHttps, }); - // Adjust grid item size based on fullwidth prop + // Adjust Grid size based on fullwidth prop const gridItemSize = fullwidth ? 12 : 4; return (
    - + - + - + @@ -563,7 +562,7 @@ export const CippDomainCards = ({ domain: propDomain = "", fullwidth = false }) {domain && ( <> - + - + - + - + - + - + - + - + {enableHttps && ( - + { const { exchangeData, isLoading = false, isFetching = false, handleRefresh, ...other } = props; @@ -59,16 +60,56 @@ export const CippExchangeInfoCard = (props) => { } /> + {exchangeData?.BlockedForSpam === true ? ( + + This mailbox is currently blocked for spam. + + ) : null} + ) : ( - exchangeData?.RecipientTypeDetails || "N/A" + + + + Mailbox Type: + + + {exchangeData?.RecipientTypeDetails || "N/A"} + + + + + Hidden from GAL: + + + {getCippFormatting( + exchangeData?.HiddenFromAddressLists, + "HiddenFromAddressLists" + )} + + + + + Blocked For Spam: + + + {getCippFormatting(exchangeData?.BlockedForSpam, "BlockedForSpam")} + + + + + Retention Policy: + + + {getCippFormatting(exchangeData?.RetentionPolicy, "RetentionPolicy")} + + + ) } /> @@ -100,82 +141,121 @@ export const CippExchangeInfoCard = (props) => { /> - ) : ( - getCippFormatting(exchangeData?.HiddenFromAddressLists, "HiddenFromAddressLists") - ) - } - /> - - ) : ( - getCippFormatting(exchangeData?.ForwardAndDeliver, "ForwardAndDeliver") - ) - } - /> - - ) : ( - exchangeData?.ForwardingAddress || "N/A" - ) - } - /> - - ) : ( - getCippFormatting(exchangeData?.ArchiveMailBox, "ArchiveMailBox") - ) - } - /> - - ) : ( - getCippFormatting(exchangeData?.AutoExpandingArchive, "AutoExpandingArchive") - ) - } - /> - - ) : exchangeData?.TotalArchiveItemSize != null ? ( - `${exchangeData.TotalArchiveItemSize} GB` + ) : ( - "N/A" + (() => { + const forwardingAddress = exchangeData?.ForwardingAddress; + const forwardAndDeliver = exchangeData?.ForwardAndDeliver; + + // Determine forwarding type and clean address + let forwardingType = "None"; + let cleanAddress = ""; + + if (forwardingAddress) { + if (forwardingAddress.startsWith("smtp:")) { + forwardingType = "External"; + cleanAddress = forwardingAddress.replace("smtp:", ""); + } else { + forwardingType = "Internal"; + cleanAddress = forwardingAddress; + } + } + + return ( + + + + Forwarding Status: + + + {forwardingType === "None" + ? getCippFormatting(false, "ForwardingStatus") + : `${forwardingType} Forwarding`} + + + {forwardingType !== "None" && ( + <> + + + Keep Copy in Mailbox: + + + {getCippFormatting(forwardAndDeliver, "ForwardAndDeliver")} + + + + + Forwarding Address: + + {cleanAddress} + + + )} + + ); + })() ) } /> + + {/* Archive section - always show status */} - ) : exchangeData?.TotalArchiveItemCount != null ? ( - exchangeData.TotalArchiveItemCount + ) : ( - "N/A" + + + + Archive Mailbox Enabled: + + + {getCippFormatting(exchangeData?.ArchiveMailBox, "ArchiveMailBox")} + + + {exchangeData?.ArchiveMailBox && ( + <> + + + Auto Expanding Archive: + + + {getCippFormatting( + exchangeData?.AutoExpandingArchive, + "AutoExpandingArchive" + )} + + + + + Total Archive Item Size: + + + {exchangeData?.TotalArchiveItemSize != null + ? `${exchangeData.TotalArchiveItemSize} GB` + : "N/A"} + + + + + Total Archive Item Count: + + + {exchangeData?.TotalArchiveItemCount != null + ? exchangeData.TotalArchiveItemCount + : "N/A"} + + + + )} + ) } /> - {/* Combine all mailbox hold types into a single PropertyListItem */} + { ) } /> - {/* Combine protocols into a single PropertyListItem */} { ) } /> - - ) : ( - getCippFormatting(exchangeData?.BlockedForSpam, "BlockedForSpam") - ) - } - /> ); diff --git a/src/components/CippCards/CippInfoBar.jsx b/src/components/CippCards/CippInfoBar.jsx index 28ca740199f7..0bfb8c5e62e1 100644 --- a/src/components/CippCards/CippInfoBar.jsx +++ b/src/components/CippCards/CippInfoBar.jsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; -import { Box, Card, Stack, SvgIcon, Typography, Skeleton } from "@mui/material"; -import Grid from "@mui/material/Grid"; +import { Box, Card, Stack, SvgIcon, Typography, Skeleton, Tooltip } from "@mui/material"; +import { Grid } from "@mui/system"; import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; import { CippPropertyListCard } from "./CippPropertyListCard"; @@ -13,9 +13,7 @@ export const CippInfoBar = ({ data, isFetching }) => { {data.map((item, index) => ( <> setVisibleIndex(index) : undefined} sx={{ @@ -45,20 +43,39 @@ export const CippInfoBar = ({ data, isFetching }) => { {item.icon} )} - { - if (!item?.icon) { - return { pl: 2 }; - } - }} - > - - {item.name} - - - {isFetching ? : item.data} - - + {item?.toolTip ? ( + + { + if (!item?.icon) { + return { pl: 2 }; + } + }} + > + + {item.name} + + + {isFetching ? : item.data} + + + + ) : ( + { + if (!item?.icon) { + return { pl: 2 }; + } + }} + > + + {item.name} + + + {isFetching ? : item.data} + + + )} {item.offcanvas && ( @@ -78,7 +95,7 @@ export const CippInfoBar = ({ data, isFetching }) => { }} > - + {item?.offcanvas?.propertyItems?.length > 0 && ( { const { diff --git a/src/components/CippCards/CippPropertyListCard.jsx b/src/components/CippCards/CippPropertyListCard.jsx index f6bd1a78b7a3..4e7bb2d81f0f 100644 --- a/src/components/CippCards/CippPropertyListCard.jsx +++ b/src/components/CippCards/CippPropertyListCard.jsx @@ -62,11 +62,11 @@ export const CippPropertyListCard = (props) => { {isFetching ? ( <> - {propertyItems.map((item, index) => ( + {Array.from({ length: propertyItems?.length || 3 }).map((_, index) => ( } sx={setPadding} /> @@ -102,13 +102,17 @@ export const CippPropertyListCard = (props) => { > {isFetching ? ( - } - /> + <> + {Array.from({ length: Math.max(1, firstHalf?.length || 1) }).map((_, index) => ( + } + /> + ))} + ) : ( firstHalf.map((item, index) => ( { {isFetching ? ( - } - /> + <> + {Array.from({ length: Math.max(1, secondHalf?.length || 1) }).map( + (_, index) => ( + } + /> + ) + )} + ) : ( secondHalf.map((item, index) => ( { + switch (category) { + case "Global Standards": + return ; + case "Entra (AAD) Standards": + return ; + case "Exchange Standards": + return ; + case "Defender Standards": + return ; + case "Intune Standards": + return ; + case "Templates": + return ; + default: + return ; + } +}; + +const getActionIcon = (action) => { + switch (action?.toLowerCase()) { + case "report": + return ; + case "alert": + case "warn": + return ; + case "remediate": + return ; + default: + return ; + } +}; + +const getImpactColor = (impact) => { + switch (impact?.toLowerCase()) { + case "low impact": + return "info"; + case "medium impact": + return "warning"; + case "high impact": + return "error"; + default: + return "default"; + } +}; + +export const CippStandardsDialog = ({ open, onClose, standardsData, currentTenant }) => { + const [expanded, setExpanded] = useState(false); + if (!standardsData) return null; + + // Get applicable templates for the current tenant + const applicableTemplates = standardsData.filter((template) => { + const tenantFilterArr = Array.isArray(template?.tenantFilter) ? template.tenantFilter : []; + const excludedTenantsArr = Array.isArray(template?.excludedTenants) + ? template.excludedTenants + : []; + + const tenantInFilter = + tenantFilterArr.length > 0 && tenantFilterArr.some((tf) => tf.value === currentTenant); + + const allTenantsTemplate = + tenantFilterArr.some((tf) => tf.value === "AllTenants") && + (excludedTenantsArr.length === 0 || + !excludedTenantsArr.some((et) => et.value === currentTenant)); + + const isApplicable = tenantInFilter || allTenantsTemplate; + + return isApplicable; + }); + + // Combine standards from all applicable templates + const combinedStandards = {}; + for (const template of applicableTemplates) { + for (const [standardKey, standardValue] of Object.entries(template.standards)) { + if (combinedStandards[standardKey]) { + // If the standard already exists, we need to merge it + const existing = combinedStandards[standardKey]; + const incoming = standardValue; + + // If both are arrays (like IntuneTemplate, ConditionalAccessTemplate), concatenate them + if (Array.isArray(existing) && Array.isArray(incoming)) { + combinedStandards[standardKey] = [...existing, ...incoming]; + } + // If one is array and other is not, or both are objects, keep the last one (existing behavior) + else { + combinedStandards[standardKey] = standardValue; + } + } else { + combinedStandards[standardKey] = standardValue; + } + } + } + + // Group standards by category + const standardsByCategory = {}; + let totalStandardsCount = 0; + + Object.entries(combinedStandards).forEach(([standardKey, standardConfig]) => { + const standardInfo = standards.find((s) => s.name === `standards.${standardKey}`); + if (standardInfo) { + const category = standardInfo.cat; + if (!standardsByCategory[category]) { + standardsByCategory[category] = []; + } + standardsByCategory[category].push({ + key: standardKey, + config: standardConfig, + info: standardInfo, + }); + + // Count template instances separately + if (Array.isArray(standardConfig) && standardConfig.length > 0) { + totalStandardsCount += standardConfig.length; + } else { + totalStandardsCount += 1; + } + } + }); + + const handleAccordionChange = (panel) => (event, isExpanded) => { + setExpanded(isExpanded ? panel : false); + }; + + return ( + + + Standards Configuration + theme.palette.grey[500], + }} + > + + + + + + + + Showing standards configuration for tenant: {currentTenant} + + + Total templates applied: {applicableTemplates.length} | Total + standards: {totalStandardsCount} + + + + {Object.entries(standardsByCategory).map(([category, categoryStandards], idx) => { + // Calculate the actual count of standards in this category (counting template instances) + const categoryCount = categoryStandards.reduce((count, { config }) => { + if (Array.isArray(config) && config.length > 0) { + return count + config.length; + } + return count + 1; + }, 0); + + return ( + `1px solid ${theme.palette.divider}`, + "&:before": { display: "none" }, + }} + > + } + aria-controls={`${category}-content`} + id={`${category}-header`} + sx={{ + minHeight: 48, + "& .MuiAccordionSummary-content": { alignItems: "center", m: 0 }, + }} + > + + {getCategoryIcon(category)} + + {category} + + + + + + + {categoryStandards.map(({ key, config, info }) => { + // Handle template arrays by rendering each template as a separate card + if (Array.isArray(config) && config.length > 0) { + return config.map((templateItem, templateIndex) => ( + + + + + + + {info.label} {config.length > 1 && `(${templateIndex + 1})`} + + + {info.helpText} + + + + + {info.tag && info.tag.length > 0 && ( + + )} + + + + Actions: + + + {templateItem.action && Array.isArray(templateItem.action) ? ( + templateItem.action.map((action, actionIndex) => ( + + )) + ) : ( + + No actions configured + + )} + + + + {info.addedComponent && info.addedComponent.length > 0 && ( + + + Fields: + + + {info.addedComponent.map((component, componentIndex) => { + const value = _.get(templateItem, component.name); + let displayValue = "N/A"; + + if (value) { + if (typeof value === "object" && value !== null) { + displayValue = + value.label || value.value || JSON.stringify(value); + } else { + displayValue = String(value); + } + } + + return ( + + + {component.label || component.name}: + + + + ); + })} + + + )} + + + + + )); + } + + // Handle regular standards (non-template arrays) + return ( + + + + + + + {info.label} + + + {info.helpText} + + + + + {info.tag && info.tag.length > 0 && ( + + )} + + + + Actions: + + + {config.action && Array.isArray(config.action) ? ( + config.action.map((action, index) => ( + + )) + ) : ( + + No actions configured + + )} + + + + {info.addedComponent && info.addedComponent.length > 0 && ( + + + Fields: + + + {info.addedComponent.map((component, index) => { + let componentValue; + let displayValue = "N/A"; + + // Handle regular standards and nested standards structures + let extractedValue = null; + + // Try direct access first + componentValue = _.get(config, component.name); + + // If direct access fails and component name contains dots (nested structure) + if ( + (componentValue === undefined || + componentValue === null) && + component.name.includes(".") + ) { + const pathParts = component.name.split("."); + + // Handle structures like: standards.AuthMethodsSettings.ReportSuspiciousActivity + if (pathParts[0] === "standards" && config.standards) { + // Remove 'standards.' prefix and try to find the value in config.standards + const nestedPath = pathParts.slice(1).join("."); + extractedValue = _.get(config.standards, nestedPath); + + // If still not found, try alternative nested structures + // Some standards have double nesting like: config.standards.StandardName.fieldName + if ( + (extractedValue === undefined || + extractedValue === null) && + pathParts.length >= 3 + ) { + const standardName = pathParts[1]; + const fieldPath = pathParts.slice(2).join("."); + extractedValue = _.get( + config.standards, + `${standardName}.${fieldPath}` + ); + } + } + } else { + extractedValue = componentValue; + } + + if (extractedValue) { + if (Array.isArray(extractedValue)) { + // Handle array of objects + const arrayValues = extractedValue.map((item) => { + if (typeof item === "object" && item !== null) { + return ( + item.label || item.value || JSON.stringify(item) + ); + } + return String(item); + }); + displayValue = arrayValues.join(", "); + } else if ( + typeof extractedValue === "object" && + extractedValue !== null + ) { + if (extractedValue.label) { + displayValue = extractedValue.label; + } else if (extractedValue.value) { + displayValue = extractedValue.value; + } else { + displayValue = JSON.stringify(extractedValue); + } + } else { + displayValue = String(extractedValue); + } + } + + return ( + + + {component.label || component.name}: + + + + ); + })} + + + )} + + + + + ); + })} + + + + ); + })} + + {Object.keys(standardsByCategory).length === 0 && ( + + + No standards configured for this tenant + + + Standards templates may not be applied to this tenant or no standards are currently + active. + + + )} + + + + + + + ); +}; diff --git a/src/components/CippCards/CippUniversalSearch.jsx b/src/components/CippCards/CippUniversalSearch.jsx index c4b5ddbaf294..589ac2c117bf 100644 --- a/src/components/CippCards/CippUniversalSearch.jsx +++ b/src/components/CippCards/CippUniversalSearch.jsx @@ -3,7 +3,6 @@ import { TextField, Box, Typography, - Grid, Card, CardContent, CardHeader, @@ -11,6 +10,7 @@ import { Button, Link, } from "@mui/material"; +import { Grid } from "@mui/system"; import { ApiGetCall } from "../../api/ApiCall"; export const CippUniversalSearch = React.forwardRef( @@ -34,33 +34,28 @@ export const CippUniversalSearch = React.forwardRef( }; return ( - - - - - + + - {search.isFetching && ( - - - - )} - {search.isSuccess && search?.data?.length > 0 ? ( - - ) : ( - search.isSuccess && "No results found." - )} + {search.isFetching && ( + + - - + )} + {search.isSuccess && search?.data?.length > 0 ? ( + + ) : ( + search.isSuccess && "No results found." + )} + ); } ); @@ -97,7 +92,7 @@ const Results = ({ items = [], searchValue }) => { {displayedResults.map((item, key) => ( - + ))} diff --git a/src/components/CippCards/CippUserInfoCard.jsx b/src/components/CippCards/CippUserInfoCard.jsx index 97e6917e9126..19585794ac4b 100644 --- a/src/components/CippCards/CippUserInfoCard.jsx +++ b/src/components/CippCards/CippUserInfoCard.jsx @@ -1,163 +1,271 @@ import PropTypes from "prop-types"; -import { Avatar, Card, CardHeader, Divider, Skeleton, Stack } from "@mui/material"; +import { Avatar, Card, CardHeader, Divider, Skeleton, Typography, Alert } from "@mui/material"; +import { AccountCircle } from "@mui/icons-material"; import { PropertyList } from "/src/components/property-list"; import { PropertyListItem } from "/src/components/property-list-item"; import { getCippFormatting } from "../../utils/get-cipp-formatting"; +import { Stack, Grid } from "@mui/system"; export const CippUserInfoCard = (props) => { const { user, tenant, isFetching = false, ...other } = props; + // Helper function to check if a section has any data + const hasWorkInfo = user?.jobTitle || user?.department || user?.manager?.displayName || user?.companyName; + const hasAddressInfo = + user?.streetAddress || user?.postalCode || user?.city || user?.country || user?.officeLocation; + const hasContactInfo = + user?.mobilePhone || (user?.businessPhones && user?.businessPhones.length > 0); + + // Handle image URL - only set if user and tenant exist, otherwise let Avatar fall back to children + const imageUrl = + user?.id && tenant ? `/api/ListUserPhoto?TenantFilter=${tenant}&UserId=${user.id}` : undefined; + return ( - - {isFetching ? ( - - ) : ( - - {user?.displayName?.[0] + user?.surname?.[0] || ""} - - )} - - - ) : ( - getCippFormatting(user?.accountEnabled, "accountEnabled") - ) - } - /> - - ) : ( - getCippFormatting(user?.onPremisesSyncEnabled, "onPremisesSyncEnabled") - ) - } - /> + ) : ( - getCippFormatting(user?.displayName, "displayName") + + {/* Avatar section */} + + + + + + + + + {/* Status information section */} + + + + + Account Enabled: + + + {getCippFormatting(user?.accountEnabled, "accountEnabled")} + + + + + + Synced from AD: + + + {getCippFormatting(user?.onPremisesSyncEnabled, "onPremisesSyncEnabled")} + + + + + ) } /> + + {/* Basic Identity Information */} + ) : ( - getCippFormatting(user?.userPrincipalName, "userPrincipalName") + + + + Display Name: + + + {getCippFormatting(user?.displayName, "displayName") || "N/A"} + + + + + Email Address: + + + {getCippFormatting(user?.proxyAddresses, "proxyAddresses") || "N/A"} + + + + + User Principal Name: + + + {getCippFormatting(user?.userPrincipalName, "userPrincipalName") || "N/A"} + + + ) } /> + + {/* Licenses */} + ) : !user?.assignedLicenses || user?.assignedLicenses.length === 0 ? ( + + No licenses assigned to this user + ) : ( getCippFormatting(user?.assignedLicenses, "assignedLicenses") ) } /> + + {/* Work Information Section */} : user?.id || "N/A"} - /> - + + ) : !hasWorkInfo ? ( + + No work information available + ) : ( - getCippFormatting(user?.proxyAddresses, "proxyAddresses") + + {user?.jobTitle && ( + + + Job Title: + + {user.jobTitle} + + )} + {user?.companyName && ( + + + Company Name: + + {user.companyName} + + )} + {user?.department && ( + + + Department: + + {user.department} + + )} + {user?.manager?.displayName && ( + + + Manager: + + {user.manager.displayName} + + )} + ) } /> - : user?.jobTitle || "N/A"} - /> - : user?.department || "N/A"} - /> - : user?.manager?.displayName || "N/A"} - /> - : user?.streetAddress || "N/A" - } - /> - : user?.postalCode || "N/A"} - /> - : user?.city || "N/A"} - /> - : user?.country || "N/A"} - /> + + {/* Contact Information Section */} : user?.officeLocation || "N/A" + isFetching ? ( + + ) : !hasContactInfo ? ( + + No contact information available + + ) : ( + + {user?.mobilePhone && ( + + + Mobile Phone: + + {user.mobilePhone} + + )} + {user?.businessPhones && user.businessPhones.length > 0 && ( + + + Business Phones: + + {user.businessPhones.join(", ")} + + )} + + ) } /> + + {/* Address Information Section */} : user?.mobilePhone || "N/A"} - /> - + + ) : !hasAddressInfo ? ( + + No address information available + ) : ( - user?.businessPhones?.join(", ") || "N/A" + + {user?.streetAddress && ( + + + Street Address: + + {user.streetAddress} + + )} + {user?.city && ( + + + City: + + {user.city} + + )} + {user?.postalCode && ( + + + Postal Code: + + {user.postalCode} + + )} + {user?.country && ( + + + Country: + + {user.country} + + )} + {user?.officeLocation && ( + + + Office Location: + + {user.officeLocation} + + )} + ) } /> @@ -168,5 +276,6 @@ export const CippUserInfoCard = (props) => { CippUserInfoCard.propTypes = { user: PropTypes.object, + tenant: PropTypes.string, isFetching: PropTypes.bool, }; diff --git a/src/components/CippComponents/AppApprovalTemplateForm.jsx b/src/components/CippComponents/AppApprovalTemplateForm.jsx new file mode 100644 index 000000000000..984fd342a0c1 --- /dev/null +++ b/src/components/CippComponents/AppApprovalTemplateForm.jsx @@ -0,0 +1,592 @@ +import { useState, useEffect, use } from "react"; +import { Alert, Skeleton, Stack, Typography, Button, Box, Link } from "@mui/material"; +import { CippFormComponent } from "./CippFormComponent"; +import { CippFormCondition } from "./CippFormCondition"; +import { CippApiResults } from "./CippApiResults"; +import { Grid } from "@mui/system"; +import CippPermissionPreview from "./CippPermissionPreview"; +import { useWatch } from "react-hook-form"; +import { CippPermissionSetDrawer } from "./CippPermissionSetDrawer"; + +const AppApprovalTemplateForm = ({ + formControl, + templateData, + templateLoading, + isEditing, + isCopy, + updatePermissions, + onSubmit, + refetchKey, +}) => { + const [selectedPermissionSet, setSelectedPermissionSet] = useState(null); + const [permissionsLoaded, setPermissionsLoaded] = useState(false); + const [permissionSetDrawerVisible, setPermissionSetDrawerVisible] = useState(false); + + // Watch for app type selection changes + const selectedAppType = useWatch({ + control: formControl?.control, + name: "appType", + defaultValue: "EnterpriseApp", + }); + const selectedGalleryTemplate = useWatch({ + control: formControl?.control, + name: "galleryTemplateId", + }); + + // Watch for application manifest changes + const selectedApplicationManifest = useWatch({ + control: formControl?.control, + name: "applicationManifest", + }); + + // Watch for app selection changes to update template name + const selectedApp = useWatch({ + control: formControl?.control, + name: "appId", + }); + + // When templateData changes, update the form + useEffect(() => { + if (!formControl) return; // Early return if formControl is not available + + if (!isEditing && !isCopy) { + formControl.setValue("templateName", "New App Deployment Template"); + formControl.setValue("appType", "EnterpriseApp"); + setPermissionsLoaded(false); + } else if (templateData && isCopy) { + // When copying, we want to load the template data but not the ID + if (templateData[0]) { + const copyName = `Copy of ${templateData[0].TemplateName}`; + formControl.setValue("templateName", copyName); + + // Set app type based on whether it's a gallery template, defaulting to EnterpriseApp for backward compatibility + const appType = + templateData[0].AppType || + (templateData[0].GalleryTemplateId + ? "GalleryTemplate" + : templateData[0].ApplicationManifest + ? "ApplicationManifest" + : "EnterpriseApp"); + formControl.setValue("appType", appType); + + if (appType === "GalleryTemplate") { + formControl.setValue("galleryTemplateId", { + label: templateData[0].AppName || "Unknown", + value: templateData[0].GalleryTemplateId, + addedFields: { + displayName: templateData[0].AppName, + applicationId: templateData[0].AppId, + // Include saved gallery information for proper display + ...(templateData[0].GalleryInformation || {}), + }, + }); + } else if (appType === "ApplicationManifest") { + // For Application Manifest, load the manifest JSON + if (templateData[0].ApplicationManifest) { + formControl.setValue( + "applicationManifest", + JSON.stringify(templateData[0].ApplicationManifest, null, 2) + ); + } + } else { + formControl.setValue("appId", { + label: `${templateData[0].AppName || "Unknown"} (${templateData[0].AppId})`, + value: templateData[0].AppId, + addedFields: { + displayName: templateData[0].AppName, + }, + }); + } + + // Set permission set and trigger loading of permissions (only for Enterprise Apps) + if (appType === "EnterpriseApp") { + const permissionSetValue = { + label: templateData[0].PermissionSetName || "Custom Permissions", + value: templateData[0].PermissionSetId, + addedFields: { + Permissions: templateData[0].Permissions || {}, + }, + }; + + formControl.setValue("permissionSetId", permissionSetValue); + setSelectedPermissionSet(permissionSetValue); + setPermissionsLoaded(true); + } else { + // For Gallery Templates, no permission set needed + setSelectedPermissionSet(null); + setPermissionsLoaded(false); + } + } + } else if (templateData) { + // For editing, load all template data + if (templateData[0]) { + formControl.setValue("templateName", templateData[0].TemplateName); + + // Set app type based on whether it's a gallery template, defaulting to EnterpriseApp for backward compatibility + const appType = + templateData[0].AppType || + (templateData[0].GalleryTemplateId + ? "GalleryTemplate" + : templateData[0].ApplicationManifest + ? "ApplicationManifest" + : "EnterpriseApp"); + formControl.setValue("appType", appType); + + if (appType === "GalleryTemplate") { + formControl.setValue("galleryTemplateId", { + label: templateData[0].AppName || "Unknown", + value: templateData[0].GalleryTemplateId, + addedFields: { + displayName: templateData[0].AppName, + applicationId: templateData[0].AppId, + // Include saved gallery information for proper display + ...(templateData[0].GalleryInformation || {}), + }, + }); + } else if (appType === "ApplicationManifest") { + // For Application Manifest, load the manifest JSON + if (templateData[0].ApplicationManifest) { + formControl.setValue( + "applicationManifest", + JSON.stringify(templateData[0].ApplicationManifest, null, 2) + ); + } + } else { + formControl.setValue("appId", { + label: `${templateData[0].AppName || "Unknown"} (${templateData[0].AppId})`, + value: templateData[0].AppId, + addedFields: { + displayName: templateData[0].AppName, + }, + }); + } + + // Set permission set and trigger loading of permissions (only for Enterprise Apps) + if (appType === "EnterpriseApp") { + const permissionSetValue = { + label: templateData[0].PermissionSetName || "Custom Permissions", + value: templateData[0].PermissionSetId, + addedFields: { + Permissions: templateData[0].Permissions || {}, + }, + }; + + formControl.setValue("permissionSetId", permissionSetValue); + setSelectedPermissionSet(permissionSetValue); + setPermissionsLoaded(true); + } else { + // For Gallery Templates and Application Manifests, no permission set needed + setSelectedPermissionSet(null); + setPermissionsLoaded(false); + } + } + } + }, [templateData, isCopy, isEditing, formControl]); + + useEffect(() => { + if (!formControl) return; // Early return if formControl is not available + + // Update template name when app is selected if we're in add mode and name hasn't been manually changed + if (!isEditing && !isCopy) { + const currentName = formControl.getValues("templateName"); + // Only update if it's still the default or empty + if (currentName === "New App Deployment Template" || !currentName) { + let appName = null; + + if (selectedAppType === "GalleryTemplate" && selectedGalleryTemplate) { + appName = + selectedGalleryTemplate.addedFields?.displayName || selectedGalleryTemplate.label; + } else if (selectedAppType === "EnterpriseApp" && selectedApp) { + // Extract app name from the label (format is usually "AppName (AppId)") + appName = selectedApp.label.split(" (")[0]; + } + + if (appName) { + formControl.setValue("templateName", `${appName} Template`); + } + } + } + }, [selectedApp, selectedGalleryTemplate, selectedAppType, isEditing, isCopy, formControl]); + + // Watch for permission set selection changes + const selectedPermissionSetValue = useWatch({ + control: formControl?.control, + name: "permissionSetId", + }); + + useEffect(() => { + if (selectedPermissionSetValue?.value) { + setSelectedPermissionSet(selectedPermissionSetValue); + setPermissionsLoaded(true); + } else { + setSelectedPermissionSet(null); + setPermissionsLoaded(false); + } + }, [selectedPermissionSetValue]); + + // Handle initial data loading for editing and copying + useEffect(() => { + // When editing or copying, ensure permission data is properly loaded + if (isEditing || isCopy) { + if (templateData?.[0]?.Permissions) { + // Ensure permissions are immediately available for the preview + setPermissionsLoaded(true); + } + } + }, [isEditing, isCopy, templateData]); + + // Handle form submission + const handleSubmit = (data) => { + let appDisplayName, appId, galleryTemplateId, applicationManifest; + + if (data.appType === "GalleryTemplate") { + appDisplayName = + data.galleryTemplateId?.addedFields?.displayName || data.galleryTemplateId?.label; + appId = data.galleryTemplateId?.addedFields?.applicationId; + galleryTemplateId = data.galleryTemplateId?.value; + } else if (data.appType === "ApplicationManifest") { + try { + applicationManifest = JSON.parse(data.applicationManifest); + + // Validate signInAudience - only allow null/undefined or "AzureADMyOrg" + if ( + applicationManifest.signInAudience && + applicationManifest.signInAudience !== "AzureADMyOrg" + ) { + return; // Don't submit if validation fails + } + + // Extract app name from manifest + appDisplayName = + applicationManifest.displayName || + applicationManifest.appDisplayName || + "Custom Application"; + // Application ID will be generated during deployment for manifests + appId = null; + } catch (error) { + console.error("Failed to parse application manifest:", error); + return; // Don't submit if manifest is invalid + } + } else { + appDisplayName = + data.appId?.addedFields?.displayName || + (data.appId?.label ? data.appId.label.split(" (")[0] : undefined); + appId = data.appId?.value; + } + + const payload = { + TemplateName: data.templateName, + AppType: data.appType, + AppId: appId, + AppName: appDisplayName, + }; + + // Only include permission set data for Enterprise Apps + if (data.appType === "EnterpriseApp") { + payload.PermissionSetId = data.permissionSetId?.value; + payload.PermissionSetName = data.permissionSetId?.label; + payload.Permissions = data.permissionSetId?.addedFields?.Permissions; + } + // For Gallery Templates, permissions will be auto-handled from the template's app registration + if (data.appType === "GalleryTemplate") { + payload.Permissions = null; // No permissions needed for Gallery Templates + payload.GalleryTemplateId = galleryTemplateId; + payload.GalleryInformation = selectedGalleryTemplate?.addedFields || {}; + } + + // For Application Manifests, store the manifest data + if (data.appType === "ApplicationManifest") { + payload.Permissions = null; // Permissions defined in manifest + payload.ApplicationManifest = applicationManifest; + } + + if (isEditing && !isCopy && templateData?.[0]?.TemplateId) { + payload.TemplateId = templateData[0].TemplateId; + } + + // Store values before submission to set them back afterward + const currentValues = { + templateName: data.templateName, + appType: data.appType, + appId: data.appId, + galleryTemplateId: data.galleryTemplateId, + permissionSetId: data.permissionSetId, + applicationManifest: data.applicationManifest, + }; + + onSubmit(payload); + + // After submission, set the values back to what they were but mark as clean + // This will only apply to add page, as edit will get refreshed data + if (!isEditing) { + setTimeout(() => { + formControl.setValue("templateName", currentValues.templateName, { shouldDirty: false }); + formControl.setValue("appType", currentValues.appType, { shouldDirty: false }); + formControl.setValue("appId", currentValues.appId, { shouldDirty: false }); + formControl.setValue("galleryTemplateId", currentValues.galleryTemplateId, { + shouldDirty: false, + }); + formControl.setValue("permissionSetId", currentValues.permissionSetId, { + shouldDirty: false, + }); + formControl.setValue("applicationManifest", currentValues.applicationManifest, { + shouldDirty: false, + }); + }, 100); + } + }; + + return ( + + + + App Approval Template Details + {templateLoading && } + {(!templateLoading || !isEditing) && ( + <> + + App approval templates allow you to define an application with its permissions that + can be deployed to multiple tenants. Choose from three template types: +
    +
    + β€’ Enterprise Application: Deploy existing multi-tenant apps from + your tenant. Requires "Multiple organizations" or "Personal Microsoft accounts" in + App Registration settings. +
    + β€’ Gallery Template: Deploy pre-configured applications from + Microsoft's Enterprise Application Gallery with standard permissions. +
    + β€’ Application Manifest: Deploy custom applications using JSON + manifests. For security, only single-tenant apps (AzureADMyOrg) are supported. +
    + + + + `${item.displayName} (${item.appId})`, + valueField: "appId", + addedField: { + displayName: "displayName", + signInAudience: "signInAudience", + }, + dataFilter: (data) => { + return data.filter( + (item) => + item.addedFields?.signInAudience === "AzureADMultipleOrgs" || + item.addedFields?.signInAudience === "AzureADandPersonalMicrosoftAccount" + ); + }, + showRefresh: true, + }} + multiple={false} + creatable={false} + required={true} + validators={{ required: "Application is required" }} + helperText="Select a multi-tenant application to deploy in this template." + /> + + + item.displayName, + valueField: "id", + addedField: { + displayName: "displayName", + applicationId: "applicationId", + description: "description", + categories: "categories", + publisher: "publisher", + logoUrl: "logoUrl", + homePageUrl: "homePageUrl", + supportedSingleSignOnModes: "supportedSingleSignOnModes", + supportedProvisioningTypes: "supportedProvisioningTypes", + }, + showRefresh: true, + }} + multiple={false} + creatable={false} + required={true} + sortOptions={true} + validators={{ required: "Gallery template is required" }} + /> + + + { + try { + const manifest = JSON.parse(value); + + // Check for minimum required property + if (!manifest.displayName) { + return "Application manifest must include a 'displayName' property"; + } + + // Validate signInAudience if present + if (manifest.signInAudience && manifest.signInAudience !== "AzureADMyOrg") { + return "signInAudience must be null, undefined, or 'AzureADMyOrg' for security reasons"; + } + + return true; + } catch (e) { + return "Invalid JSON format"; + } + }, + }} + /> + + + + item.TemplateName, + valueField: "TemplateId", + addedField: { + Permissions: "Permissions", + }, + showRefresh: true, + }} + multiple={false} + creatable={false} + required={true} + validators={{ required: "Permission Set is required" }} + helperText={ + <> + Select a permission set to apply to this application.{" "} + + + } + /> + + + + + + + + + + )} +
    +
    + + { + try { + return JSON.parse(selectedApplicationManifest); + } catch (e) { + return null; // Return null if JSON is invalid + } + })() + : null + } + /> + +
    + ); +}; + +export default AppApprovalTemplateForm; diff --git a/src/components/CippComponents/BPASyncDialog.jsx b/src/components/CippComponents/BPASyncDialog.jsx index 10ae3cecd724..43a16450416a 100644 --- a/src/components/CippComponents/BPASyncDialog.jsx +++ b/src/components/CippComponents/BPASyncDialog.jsx @@ -1,14 +1,12 @@ -import React, { useState } from "react"; +import { useState } from "react"; import { Dialog, DialogContent, DialogTitle, Button, DialogActions, - Alert, - CircularProgress, } from "@mui/material"; -import { CheckCircle, Error, Sync } from "@mui/icons-material"; +import { Sync } from "@mui/icons-material"; import { useForm, FormProvider } from "react-hook-form"; import { CippFormTenantSelector } from "./CippFormTenantSelector"; import { ApiPostCall } from "/src/api/ApiCall"; @@ -29,7 +27,7 @@ export const BPASyncDialog = ({ createDialog }) => { const [isSyncing, setIsSyncing] = useState(false); const bpaSyncResults = ApiPostCall({ - urlfromdata: true, + urlFromData: true, }); const handleForm = (values) => { diff --git a/src/components/CippComponents/BreachSearchDialog.jsx b/src/components/CippComponents/BreachSearchDialog.jsx new file mode 100644 index 000000000000..e089908ffb43 --- /dev/null +++ b/src/components/CippComponents/BreachSearchDialog.jsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { Dialog, DialogContent, DialogTitle, Button, DialogActions } from "@mui/material"; +import { Search } from "@mui/icons-material"; +import { useForm, FormProvider } from "react-hook-form"; +import { ApiPostCall } from "/src/api/ApiCall"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; + +export const BreachSearchDialog = ({ createDialog }) => { + const tenantFilter = useSettings()?.currentTenant; + const methods = useForm({ + defaultValues: {}, + }); + + // Use methods for form handling and control + const { handleSubmit } = methods; + + const [isRunning, setIsRunning] = useState(false); + const breachSearchResults = ApiPostCall({ + urlFromData: true, + }); + + const handleForm = () => { + setIsRunning(true); + breachSearchResults.mutate({ + url: "/api/ExecBreachSearch", + queryKey: `breach-search-${tenantFilter}`, + data: { tenantFilter: tenantFilter }, + }); + }; + + // Reset running state when dialog is closed + const handleClose = () => { + setIsRunning(false); + createDialog.handleClose(); + }; + + return ( + + + + Run Breach Search + +
    +

    + This will run a breach search to check for potentially compromised passwords and information + for the current tenant: {tenantFilter?.displayName || tenantFilter} +

    +
    + +
    + + + + + +
    +
    + ); +}; \ No newline at end of file diff --git a/src/components/CippComponents/CIPPDeviceCodeButton.js b/src/components/CippComponents/CIPPDeviceCodeButton.js new file mode 100644 index 000000000000..16711f8d1332 --- /dev/null +++ b/src/components/CippComponents/CIPPDeviceCodeButton.js @@ -0,0 +1,252 @@ +import { useState, useEffect } from "react"; +import { + Alert, + Button, + Typography, + CircularProgress, + Box, +} from "@mui/material"; +import { ApiGetCall } from "../../api/ApiCall"; + +/** + * CIPPDeviceCodeButton - A button component for Microsoft 365 OAuth authentication using device code flow + * + * @param {Object} props - Component props + * @param {Function} props.onAuthSuccess - Callback function called when authentication is successful with token data + * @param {Function} props.onAuthError - Callback function called when authentication fails with error data + * @param {string} props.buttonText - Text to display on the button (default: "Login with Device Code") + * @param {boolean} props.showResults - Whether to show authentication results in the component (default: true) + * @returns {JSX.Element} The CIPPDeviceCodeButton component + */ +export const CIPPDeviceCodeButton = ({ + onAuthSuccess, + onAuthError, + buttonText = "Login with Device Code", + showResults = true, +}) => { + const [authInProgress, setAuthInProgress] = useState(false); + const [authError, setAuthError] = useState(null); + const [deviceCodeInfo, setDeviceCodeInfo] = useState(null); + const [currentStep, setCurrentStep] = useState(0); + const [pollInterval, setPollInterval] = useState(null); + const [tokens, setTokens] = useState({ + accessToken: null, + refreshToken: null, + accessTokenExpiresOn: null, + refreshTokenExpiresOn: null, + username: null, + tenantId: null, + onmicrosoftDomain: null, + }); + + // Get application ID information from API + const appIdInfo = ApiGetCall({ + url: `/api/ExecListAppId`, + queryKey: `ExecListAppId`, + waiting: true, + }); + + // Handle closing the error + const handleCloseError = () => { + setAuthError(null); + }; + + // Clear polling interval when component unmounts + useEffect(() => { + return () => { + if (pollInterval) { + clearInterval(pollInterval); + } + }; + }, [pollInterval]); + + // Start device code authentication + const startDeviceCodeAuth = async () => { + try { + setAuthInProgress(true); + setAuthError(null); + setDeviceCodeInfo(null); + setCurrentStep(1); + + // Call the API to start device code flow + const response = await fetch(`/api/ExecSAMSetup?CreateSAM=true`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const data = await response.json(); + + if (response.ok && data.code) { + // Store device code info + setDeviceCodeInfo({ + user_code: data.code, + verification_uri: data.url, + expires_in: 900, // Default to 15 minutes if not provided + }); + + // Start polling for token + const interval = setInterval(checkAuthStatus, 5000); + setPollInterval(interval); + } else { + // Error getting device code + setAuthError({ + errorCode: "device_code_error", + errorMessage: data.message || "Failed to get device code", + timestamp: new Date().toISOString(), + }); + setAuthInProgress(false); + if (onAuthError) onAuthError(error); + } + } catch (error) { + console.error("Error starting device code authentication:", error); + setAuthError({ + errorCode: "device_code_error", + errorMessage: error.message || "An error occurred during device code authentication", + timestamp: new Date().toISOString(), + }); + setAuthInProgress(false); + if (onAuthError) onAuthError(error); + } + }; + + // Check authentication status + const checkAuthStatus = async () => { + try { + // Call the API to check auth status + const response = await fetch(`/api/ExecSAMSetup?CheckSetupProcess=true&step=1`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const data = await response.json(); + + if (response.ok) { + if (data.step === 2) { + // Authentication successful + clearInterval(pollInterval); + setPollInterval(null); + + // Process token data + const tokenData = { + accessToken: "Successfully authenticated", + refreshToken: "Token stored on server", + accessTokenExpiresOn: new Date(Date.now() + 3600 * 1000), // 1 hour from now + refreshTokenExpiresOn: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // 90 days from now + username: "authenticated user", + tenantId: data.tenantId || "unknown", + onmicrosoftDomain: null, + }; + + // Store tokens in component state + setTokens(tokenData); + setDeviceCodeInfo(null); + setCurrentStep(2); + + // Call the onAuthSuccess callback if provided + if (onAuthSuccess) onAuthSuccess(tokenData); + + // Update UI state + setAuthInProgress(false); + } + } else { + // Error checking auth status + clearInterval(pollInterval); + setPollInterval(null); + + setAuthError({ + errorCode: "auth_status_error", + errorMessage: data.message || "Failed to check authentication status", + timestamp: new Date().toISOString(), + }); + setAuthInProgress(false); + if (onAuthError) onAuthError({ + errorCode: "auth_status_error", + errorMessage: data.message || "Failed to check authentication status", + timestamp: new Date().toISOString(), + }); + } + } catch (error) { + console.error("Error checking auth status:", error); + // Don't stop polling on transient errors + } + }; + + return ( +
    + + + {!appIdInfo.isLoading && + !appIdInfo?.data?.applicationId && ( + + The Application ID is not valid. Please check your configuration. + + ) + } + + {showResults && ( + + {deviceCodeInfo && authInProgress ? ( + + Device Code Authentication + + To sign in, use a web browser to open the page {deviceCodeInfo.verification_uri} and enter the code {deviceCodeInfo.user_code} to authenticate. + + + Code expires in {Math.round(deviceCodeInfo.expires_in / 60)} minutes + + + ) : tokens.accessToken ? ( + + Authentication Successful + + You've successfully refreshed your token using device code flow. + + {tokens.tenantId && ( + + Tenant ID: {tokens.tenantId} + + )} + + ) : authError ? ( + + Authentication Error: {authError.errorCode} + {authError.errorMessage} + + Time: {authError.timestamp} + + + + + + ) : null} + + )} +
    + ); +}; + +export default CIPPDeviceCodeButton; \ No newline at end of file diff --git a/src/components/CippComponents/CIPPM365OAuthButton.jsx b/src/components/CippComponents/CIPPM365OAuthButton.jsx new file mode 100644 index 000000000000..88e517a2139d --- /dev/null +++ b/src/components/CippComponents/CIPPM365OAuthButton.jsx @@ -0,0 +1,683 @@ +import { useState, useEffect } from "react"; +import { Alert, Button, Typography, CircularProgress, Box } from "@mui/material"; +import { ApiGetCall } from "../../api/ApiCall"; +import { CippCopyToClipBoard } from "./CippCopyToClipboard"; + +export const CIPPM365OAuthButton = ({ + onAuthSuccess, + onAuthError, + buttonText = "Login with Microsoft", + showResults = true, + showSuccessAlert = true, + scope = "https://graph.microsoft.com/.default offline_access profile openid", + useDeviceCode = false, + applicationId = null, + autoStartDeviceLogon = false, + validateServiceAccount = true, +}) => { + const [authInProgress, setAuthInProgress] = useState(false); + const [authError, setAuthError] = useState(null); + const [deviceCodeInfo, setDeviceCodeInfo] = useState(null); + const [codeRetrievalInProgress, setCodeRetrievalInProgress] = useState(false); + const [isServiceAccount, setIsServiceAccount] = useState(true); + const [tokens, setTokens] = useState({ + accessToken: null, + refreshToken: null, + accessTokenExpiresOn: null, + refreshTokenExpiresOn: null, + username: null, + tenantId: null, + onmicrosoftDomain: null, + }); + + const appIdInfo = ApiGetCall({ + url: `/api/ExecListAppId`, + waiting: true, + }); + + useEffect(() => { + appIdInfo.refetch(); + }, []); + + const handleCloseError = () => { + setAuthError(null); + }; + + const checkIsServiceAccount = (username) => { + if (!username || !validateServiceAccount) return true; // If no username or validation disabled, don't show warning + + const lowerUsername = username.toLowerCase(); + return lowerUsername.includes("service") || lowerUsername.includes("cipp"); + }; + + // Function to retrieve device code + const retrieveDeviceCode = async () => { + setCodeRetrievalInProgress(true); + setAuthError(null); + + // Refetch appId to ensure we have the latest + await appIdInfo.refetch(); + + try { + // Get the application ID to use + const appId = + applicationId || appIdInfo?.data?.applicationId || "1b730954-1685-4b74-9bfd-dac224a7b894"; // Default to MS Graph Explorer app ID + + // Request device code from our API endpoint + const deviceCodeResponse = await fetch( + `/api/ExecDeviceCodeLogon?operation=getDeviceCode&clientId=${appId}&scope=${encodeURIComponent( + scope + )}` + ); + const deviceCodeData = await deviceCodeResponse.json(); + + if (deviceCodeResponse.ok && deviceCodeData.user_code) { + // Store device code info + setDeviceCodeInfo(deviceCodeData); + } else { + // Error getting device code + setAuthError({ + errorCode: deviceCodeData.error || "device_code_error", + errorMessage: deviceCodeData.error_description || "Failed to get device code", + timestamp: new Date().toISOString(), + }); + } + } catch (error) { + setAuthError({ + errorCode: "device_code_error", + errorMessage: error.message || "An error occurred retrieving device code", + timestamp: new Date().toISOString(), + }); + } finally { + setCodeRetrievalInProgress(false); + } + }; + + // Device code authentication function - opens popup and starts polling + const handleDeviceCodeAuthentication = async () => { + // Refetch appId to ensure we have the latest + await appIdInfo.refetch(); + + if (!deviceCodeInfo) { + // If we don't have a device code yet, retrieve it first + await retrieveDeviceCode(); + return; + } + + setAuthInProgress(true); + setTokens({ + accessToken: null, + refreshToken: null, + accessTokenExpiresOn: null, + refreshTokenExpiresOn: null, + username: null, + tenantId: null, + onmicrosoftDomain: null, + }); + + try { + // Get the application ID to use - refetch already happened at the start of this function + const appId = + applicationId || appIdInfo?.data?.applicationId || "1b730954-1685-4b74-9bfd-dac224a7b894"; // Default to MS Graph Explorer app ID + + // Open popup to device login page + const width = 500; + const height = 600; + const left = window.screen.width / 2 - width / 2; + const top = window.screen.height / 2 - height / 2; + + const popup = window.open( + "https://microsoft.com/devicelogin", + "deviceLoginPopup", + `width=${width},height=${height},left=${left},top=${top}` + ); + + // Start polling for token + const pollInterval = deviceCodeInfo.interval || 5; + const expiresIn = deviceCodeInfo.expires_in || 900; + const startTime = Date.now(); + + const pollForToken = async () => { + // Check if we've exceeded the expiration time + if (Date.now() - startTime >= expiresIn * 1000) { + if (popup && !popup.closed) { + popup.close(); + } + setAuthError({ + errorCode: "timeout", + errorMessage: "Device code authentication timed out", + timestamp: new Date().toISOString(), + }); + setAuthInProgress(false); + return; + } + + try { + // Poll for token using our API endpoint + const tokenResponse = await fetch( + `/api/ExecDeviceCodeLogon?operation=checkToken&clientId=${appId}&deviceCode=${deviceCodeInfo.device_code}` + ); + const tokenData = await tokenResponse.json(); + + if (tokenResponse.ok && tokenData.status === "success") { + // Successfully got token + if (popup && !popup.closed) { + popup.close(); + } + handleTokenResponse(tokenData); + } else if ( + tokenData.error === "authorization_pending" || + tokenData.status === "pending" + ) { + // User hasn't completed authentication yet, continue polling + setTimeout(pollForToken, pollInterval * 1000); + } else if (tokenData.error === "slow_down") { + // Server asking us to slow down polling + setTimeout(pollForToken, (pollInterval + 5) * 1000); + } else { + // Other error + if (popup && !popup.closed) { + popup.close(); + } + setAuthError({ + errorCode: tokenData.error || "token_error", + errorMessage: tokenData.error_description || "Failed to get token", + timestamp: new Date().toISOString(), + }); + setAuthInProgress(false); + } + } catch (error) { + setTimeout(pollForToken, pollInterval * 1000); + } + }; + + // Start polling + setTimeout(pollForToken, pollInterval * 1000); + } catch (error) { + setAuthError({ + errorCode: "device_code_error", + errorMessage: error.message || "An error occurred during device code authentication", + timestamp: new Date().toISOString(), + }); + setAuthInProgress(false); + } + }; + + // Process token response (common for both auth methods) + const handleTokenResponse = (tokenData) => { + // Extract token information + const accessTokenExpiresOn = new Date(Date.now() + tokenData.expires_in * 1000); + // Refresh tokens typically last for 90 days, but this can vary + const refreshTokenExpiresOn = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); + + // Extract information from ID token if available + let username = "unknown user"; + let tenantId = "unknown tenant"; + let onmicrosoftDomain = null; + + if (tokenData.id_token) { + try { + const idTokenPayload = JSON.parse(atob(tokenData.id_token.split(".")[1])); + + username = + idTokenPayload.preferred_username || + idTokenPayload.email || + idTokenPayload.upn || + idTokenPayload.name || + "unknown user"; + + if (idTokenPayload.tid) { + tenantId = idTokenPayload.tid; + } + + if (username && username.includes("@") && username.includes(".onmicrosoft.com")) { + onmicrosoftDomain = username.split("@")[1]; + } else if (idTokenPayload.iss) { + const issuerMatch = idTokenPayload.iss.match(/https:\/\/sts\.windows\.net\/([^/]+)\//); + if (issuerMatch && issuerMatch[1]) { + } + } + setIsServiceAccount(checkIsServiceAccount(username)); + } catch (error) {} + } + + // Create token result object + const tokenResult = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + accessTokenExpiresOn: accessTokenExpiresOn, + refreshTokenExpiresOn: refreshTokenExpiresOn, + username: username, + tenantId: tenantId, + onmicrosoftDomain: onmicrosoftDomain, + }; + + setTokens(tokenResult); + setDeviceCodeInfo(null); + + if (onAuthSuccess) onAuthSuccess(tokenResult); + + // Update UI state + setAuthInProgress(false); + setIsServiceAccount(checkIsServiceAccount(username)); + }; + + // MSAL-like authentication function + const handleMsalAuthentication = async () => { + // Clear previous authentication state when starting a new authentication + setAuthInProgress(true); + setAuthError(null); + setTokens({ + accessToken: null, + refreshToken: null, + accessTokenExpiresOn: null, + refreshTokenExpiresOn: null, + username: null, + tenantId: null, + onmicrosoftDomain: null, + }); + + // Refetch app ID info to ensure we have the latest + await appIdInfo.refetch(); + + // Get the application ID to use - now we're sure to have the latest after the await + const appId = applicationId || appIdInfo?.data?.applicationId; + + // Generate MSAL-like authentication parameters + const msalConfig = { + auth: { + clientId: appId, + authority: `https://login.microsoftonline.com/common`, + redirectUri: `${window.location.origin}/authredirect`, + }, + }; + + // Define the request object similar to MSAL + const loginRequest = { + scopes: [scope], + }; + + // Generate PKCE code verifier and challenge + const generateCodeVerifier = () => { + const array = new Uint8Array(32); + window.crypto.getRandomValues(array); + return Array.from(array, (byte) => ("0" + (byte & 0xff).toString(16)).slice(-2)).join(""); + }; + + const codeVerifier = generateCodeVerifier(); + const codeChallenge = codeVerifier; + const state = Math.random().toString(36).substring(2, 15); + const authUrl = + `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?` + + `client_id=${appId}` + + `&response_type=code` + + `&redirect_uri=${encodeURIComponent(window.location.origin)}/authredirect` + + `&scope=${encodeURIComponent(scope)}` + + `&code_challenge=${codeChallenge}` + + `&code_challenge_method=plain` + + `&state=${state}` + + `&prompt=select_account`; + + // Open popup for authentication + const width = 500; + const height = 600; + const left = window.screen.width / 2 - width / 2; + const top = window.screen.height / 2 - height / 2; + + const popup = window.open( + authUrl, + "msalAuthPopup", + `width=${width},height=${height},left=${left},top=${top}` + ); + + // Function to actually exchange the authorization code for tokens + const handleAuthorizationCode = async (code, receivedState) => { + // Verify the state parameter matches what we sent (security check) + if (receivedState !== state) { + const errorMessage = "State mismatch in auth response - possible CSRF attack"; + const error = { + errorCode: "state_mismatch", + errorMessage: errorMessage, + timestamp: new Date().toISOString(), + }; + setAuthError(error); + if (onAuthError) onAuthError(error); + setAuthInProgress(false); + return; + } + try { + // Prepare the token request + const tokenRequest = { + grant_type: "authorization_code", + client_id: appId, + code: code, + redirect_uri: `${window.location.origin}/authredirect`, + code_verifier: codeVerifier, + }; + + // Make the token request through our API proxy to avoid origin header issues + const tokenResponse = await fetch(`/api/ExecTokenExchange`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + tokenRequest, + tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token", + tenantId: appId, // Pass the tenant ID to retrieve the correct client secret + }), + }); + + // Parse the token response + const tokenData = await tokenResponse.json(); + + // Check if the response contains an error + if (tokenData.error) { + const error = { + errorCode: tokenData.error || "token_error", + errorMessage: + tokenData.error_description || "Failed to exchange authorization code for tokens", + timestamp: new Date().toISOString(), + }; + setAuthError(error); + if (onAuthError) onAuthError(error); + setAuthInProgress(false); + return; + } + + if (tokenResponse.ok) { + // If we have a refresh token, store it + if (tokenData.refresh_token) { + try { + // Extract tid from access_token jwt base64 + const accessTokenParts = tokenData.access_token.split("."); + const accessTokenPayload = JSON.parse(atob(accessTokenParts[1] || "")); + tokenData.tid = accessTokenPayload.tid; + const refreshResponse = await fetch(`/api/ExecUpdateRefreshToken`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + tenantId: tokenData.tid, + refreshtoken: tokenData.refresh_token, + tenantMode: tokenData.tenantMode, + allowPartnerTenantManagement: tokenData.allowPartnerTenantManagement, + }), + }); + + if (!refreshResponse.ok) { + console.warn("Failed to store refresh token, but continuing with authentication"); + } + } catch (error) { + console.error("Failed to store refresh token:", error); + } + } + + handleTokenResponse(tokenData); + } else { + // Handle token error - display in error box instead of throwing + const error = { + errorCode: tokenData.error || "token_error", + errorMessage: + tokenData.error_description || "Failed to exchange authorization code for tokens", + timestamp: new Date().toISOString(), + }; + setAuthError(error); + if (onAuthError) onAuthError(error); + } + } catch (error) { + const errorObj = { + errorCode: "token_exchange_error", + errorMessage: error.message || "Failed to exchange authorization code for tokens", + timestamp: new Date().toISOString(), + }; + setAuthError(errorObj); + if (onAuthError) onAuthError(errorObj); + } finally { + // Close the popup window if it's still open + if (popup && !popup.closed) { + popup.close(); + } + + // Update UI state + setAuthInProgress(false); + } + }; + + // Monitor for the redirect with the authorization code + // This is what MSAL does internally + const checkPopupLocation = setInterval(() => { + if (!popup || popup.closed) { + clearInterval(checkPopupLocation); + + // If authentication is still in progress when popup closes, it's an error + if (authInProgress) { + const errorMessage = "Authentication was cancelled. Please try again."; + const error = { + errorCode: "user_cancelled", + errorMessage: errorMessage, + timestamp: new Date().toISOString(), + }; + setAuthError(error); + if (onAuthError) onAuthError(error); + + // Ensure we're not showing any previous success state + setTokens({ + accessToken: null, + refreshToken: null, + accessTokenExpiresOn: null, + refreshTokenExpiresOn: null, + username: null, + tenantId: null, + onmicrosoftDomain: null, + }); + } + + setAuthInProgress(false); + return; + } + + try { + // Try to access the popup location to check for the authorization code + const currentUrl = popup.location.href; + + // Check if the URL contains a code parameter (authorization code) + if (currentUrl.includes("code=") && currentUrl.includes("state=")) { + clearInterval(checkPopupLocation); + // Parse the URL to extract the code and state + const urlParams = new URLSearchParams(popup.location.search); + const code = urlParams.get("code"); + const receivedState = urlParams.get("state"); + + // Process the authorization code + handleAuthorizationCode(code, receivedState); + } + + // Check for error in the URL + if (currentUrl.includes("error=")) { + clearInterval(checkPopupLocation); + // Parse the URL to extract the error details + const urlParams = new URLSearchParams(popup.location.search); + const errorCode = urlParams.get("error"); + const errorDescription = urlParams.get("error_description"); + + // Set the error state + const error = { + errorCode: errorCode, + errorMessage: errorDescription || "Unknown authentication error", + timestamp: new Date().toISOString(), + }; + setAuthError(error); + if (onAuthError) onAuthError(error); + + // Close the popup + popup.close(); + setAuthInProgress(false); + } + } catch (error) { + // This will throw an error when the popup is on a different domain + // due to cross-origin restrictions, which is normal during auth flow + // Just continue monitoring + } + }, 500); + + // Also monitor for popup closing as a fallback + }; + + // Auto-start device code retrieval if requested + useEffect(() => { + if ( + useDeviceCode && + autoStartDeviceLogon && + !codeRetrievalInProgress && + !deviceCodeInfo && + !tokens.accessToken && + appIdInfo?.data + ) { + retrieveDeviceCode(); + } + }, [ + useDeviceCode, + autoStartDeviceLogon, + codeRetrievalInProgress, + deviceCodeInfo, + tokens.accessToken, + appIdInfo?.data, + ]); + + return ( +
    + {!applicationId && + !appIdInfo.isLoading && + appIdInfo?.data && // Only check if data is available + !/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test( + appIdInfo?.data?.applicationId + ) && ( + + The Application ID is not valid. Please check your configuration. + + )} + + {showResults && ( + + {deviceCodeInfo ? ( + + Application Creation + + {authInProgress ? ( + <> + When asked to log onto an account, please use a{" "} + CIPP Service Account. Enter this code to authenticate:{" "} + + ) : ( + <> + Click the button below to authenticate. When asked to log onto an account, + please use a CIPP Service Account. You will need to enter this + code:{" "} + + )} + + + + {authInProgress ? ( + <> + If the popup was blocked or you closed it, you can also go to{" "} + microsoft.com/devicelogin manually and enter the code shown + above. + + ) : ( + <> + When you click the button below, a popup will open to{" "} + microsoft.com/devicelogin where you'll enter this code. + + )} + + + Code expires in {Math.round(deviceCodeInfo.expires_in / 60)} minutes + + + ) : tokens.accessToken ? ( + <> + {showSuccessAlert ? ( + + Authentication Successful + + You've successfully refreshed your token. The account you're using for + authentication is: {tokens.username} + + + Tenant ID: {tokens.tenantId} + {tokens.onmicrosoftDomain && ( + <> + {" "} + | Domain: {tokens.onmicrosoftDomain} + + )} + + + Refresh token expires: {tokens.refreshTokenExpiresOn?.toLocaleString()} + + + ) : null} + + {!isServiceAccount && ( + + Service Account Required + + CIPP requires a service account for authentication. The account you're using ( + {tokens.username}) does not appear to be a service account. + + + Please redo authentication using an account with "service" or "cipp" in the + username. + + + )} + + ) : authError ? ( + + + Authentication Error: {authError.errorCode} + + {authError.errorMessage} + + Time: {authError.timestamp} + + + + + + ) : null} + + )} + +
    + ); +}; diff --git a/src/components/CippComponents/CertificateCredentialRemovalForm.jsx b/src/components/CippComponents/CertificateCredentialRemovalForm.jsx new file mode 100644 index 000000000000..9b499297ed59 --- /dev/null +++ b/src/components/CippComponents/CertificateCredentialRemovalForm.jsx @@ -0,0 +1,23 @@ +import { CippFormComponent } from "./CippFormComponent.jsx"; + +export const CertificateCredentialRemovalForm = ({ formHook, row }) => { + return ( + ({ + label: `${cred.displayName || "Unnamed"} (Expiration: ${new Date( + cred.endDateTime + ).toLocaleDateString()})`, + value: cred.keyId, + })) || [] + } + /> + ); +}; diff --git a/src/components/CippComponents/CippAddConnectorDrawer.jsx b/src/components/CippComponents/CippAddConnectorDrawer.jsx new file mode 100644 index 000000000000..e08654239b14 --- /dev/null +++ b/src/components/CippComponents/CippAddConnectorDrawer.jsx @@ -0,0 +1,166 @@ +ο»Ώimport { useState, useEffect } from "react"; +import { Button, Divider } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useFormState, useWatch } from "react-hook-form"; +import { RocketLaunch } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormTenantSelector } from "./CippFormTenantSelector"; +import { CippApiResults } from "./CippApiResults"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippAddConnectorDrawer = ({ + buttonText = "Deploy Connector", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + selectedTenants: [], + TemplateList: null, + PowerShellCommand: "", + }, + }); + + const { isValid } = useFormState({ control: formControl.control }); + const templateListVal = useWatch({ control: formControl.control, name: "TemplateList" }); + + const addConnector = ApiPostCall({ + urlFromData: true, + }); + + // Update PowerShellCommand when template is selected + useEffect(() => { + if (templateListVal?.value) { + formControl.setValue("PowerShellCommand", JSON.stringify(templateListVal?.value)); + } + }, [templateListVal, formControl]); + + // Reset form fields on successful creation + useEffect(() => { + if (addConnector.isSuccess) { + const currentTenants = formControl.getValues("selectedTenants"); + formControl.reset({ + selectedTenants: currentTenants, + TemplateList: null, + PowerShellCommand: "", + }); + } + }, [addConnector.isSuccess, formControl]); + + const handleSubmit = () => { + formControl.trigger(); + // Check if the form is valid before proceeding + if (!isValid) { + return; + } + + const formData = formControl.getValues(); + + addConnector.mutate({ + url: "/api/AddExConnector", + data: formData, + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + selectedTenants: [], + TemplateList: null, + PowerShellCommand: "", + }); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + {/* Tenant Selector */} + + + + + + + {/* Template List */} + + option, + url: "/api/ListExconnectorTemplates", + }} + placeholder="Select a template or enter PowerShell JSON manually" + /> + + + + + {/* PowerShell Command */} + + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippAddContactDrawer.jsx b/src/components/CippComponents/CippAddContactDrawer.jsx new file mode 100644 index 000000000000..f46b5b54f80e --- /dev/null +++ b/src/components/CippComponents/CippAddContactDrawer.jsx @@ -0,0 +1,321 @@ +ο»Ώimport React, { useState, useEffect } from "react"; +import { Button, Divider } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useFormState } from "react-hook-form"; +import { PersonAdd } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippAddContactDrawer = ({ + buttonText = "Add Contact", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const tenantDomain = useSettings().currentTenant; + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + displayName: "", + firstName: "", + lastName: "", + email: "", + hidefromGAL: false, + streetAddress: "", + postalCode: "", + city: "", + state: "", + country: "", + companyName: "", + mobilePhone: "", + businessPhone: "", + jobTitle: "", + website: "", + mailTip: "", + }, + }); + + const { isValid } = useFormState({ control: formControl.control }); + + const addContact = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: [`Contacts-${tenantDomain}`], + }); + + // Reset form fields on successful creation + useEffect(() => { + if (addContact.isSuccess) { + formControl.reset({ + displayName: "", + firstName: "", + lastName: "", + email: "", + hidefromGAL: false, + streetAddress: "", + postalCode: "", + city: "", + state: "", + country: "", + companyName: "", + mobilePhone: "", + businessPhone: "", + jobTitle: "", + website: "", + mailTip: "", + }); + } + }, [addContact.isSuccess, formControl]); + + const handleSubmit = () => { + formControl.trigger(); + // Check if the form is valid before proceeding + if (!isValid) { + return; + } + + const formData = formControl.getValues(); + const shippedValues = { + tenantID: tenantDomain, + DisplayName: formData.displayName, + hidefromGAL: formData.hidefromGAL, + email: formData.email, + FirstName: formData.firstName, + LastName: formData.lastName, + Title: formData.jobTitle, + StreetAddress: formData.streetAddress, + PostalCode: formData.postalCode, + City: formData.city, + State: formData.state, + CountryOrRegion: formData.country?.value || formData.country, + Company: formData.companyName, + mobilePhone: formData.mobilePhone, + phone: formData.businessPhone, + website: formData.website, + mailTip: formData.mailTip, + }; + + addContact.mutate({ + url: "/api/AddContact", + data: shippedValues, + relatedQueryKeys: [`Contacts-${tenantDomain}`], + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + displayName: "", + firstName: "", + lastName: "", + email: "", + hidefromGAL: false, + streetAddress: "", + postalCode: "", + city: "", + state: "", + country: "", + companyName: "", + mobilePhone: "", + businessPhone: "", + jobTitle: "", + website: "", + mailTip: "", + }); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + {/* Display Name */} + + + + + {/* First Name and Last Name */} + + + + + + + + + + {/* Email */} + + + + + {/* Hide from GAL */} + + + + + + + {/* Additional Contact Information */} + + + + + + + + {/* Phone Numbers */} + + + + + + + + {/* Address Information */} + + + + + + + + + + + + + + {/* Website and Mail Tip */} + + + + {/* Website and Mail Tip */} + + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippAddEditTenantGroups.jsx b/src/components/CippComponents/CippAddEditTenantGroups.jsx index b07999089947..9ba637d003c8 100644 --- a/src/components/CippComponents/CippAddEditTenantGroups.jsx +++ b/src/components/CippComponents/CippAddEditTenantGroups.jsx @@ -1,54 +1,96 @@ -import { useForm } from "react-hook-form"; import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; -import { Stack, Typography, Grid } from "@mui/material"; -import CippFormSection from "/src/components/CippFormPages/CippFormSection"; +import { Typography } from "@mui/material"; +import { Grid } from "@mui/system"; import { CippFormTenantSelector } from "./CippFormTenantSelector"; +import { CippFormCondition } from "./CippFormCondition"; +import CippTenantGroupRuleBuilder from "./CippTenantGroupRuleBuilder"; -const CippAddEditTenantGroups = ({ formControl, initialValues, title, backButtonTitle }) => { +const CippAddEditTenantGroups = ({ formControl, initialValues, title, backButtonTitle, hideSubmitButton = false }) => { return ( - { - return { - ...values, - Action: "AddEdit", - }; - }} - initialValues={initialValues} - > + <> Properties - - - - - - + + + + + + + + + {/* Group Type Selection */} + + + + + {/* Static Group Members - Show only when Static is selected */} + + + + + + + {/* Dynamic Group Rules - Show only when Dynamic is selected */} + + + + + + + ); }; diff --git a/src/components/CippComponents/CippAddEquipmentDrawer.jsx b/src/components/CippComponents/CippAddEquipmentDrawer.jsx new file mode 100644 index 000000000000..3d9a397765c6 --- /dev/null +++ b/src/components/CippComponents/CippAddEquipmentDrawer.jsx @@ -0,0 +1,151 @@ +ο»Ώimport React, { useState, useEffect } from "react"; +import { Button, Divider } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useFormState } from "react-hook-form"; +import { AddBusiness } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormDomainSelector } from "./CippFormDomainSelector"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippAddEquipmentDrawer = ({ + buttonText = "Add Equipment Mailbox", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const tenantDomain = useSettings().currentTenant; + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + displayName: "", + username: "", + domain: null, + }, + }); + + const { isValid } = useFormState({ control: formControl.control }); + + const addEquipment = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: [`EquipmentMailbox-${tenantDomain}`], + }); + + // Reset form fields on successful creation + useEffect(() => { + if (addEquipment.isSuccess) { + formControl.reset(); + } + }, [addEquipment.isSuccess, formControl]); + + const handleSubmit = () => { + formControl.trigger(); + // Check if the form is valid before proceeding + if (!isValid) { + return; + } + + const formData = formControl.getValues(); + const shippedValues = { + tenantID: tenantDomain, + domain: formData.domain?.value, + displayName: formData.displayName.trim(), + username: formData.username.trim(), + userPrincipalName: formData.username.trim() + "@" + (formData.domain?.value || "").trim(), + }; + + addEquipment.mutate({ + url: "/api/AddEquipmentMailbox", + data: shippedValues, + relatedQueryKeys: [`EquipmentMailbox-${tenantDomain}`], + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + displayName: "", + username: "", + domain: null, + location: "", + department: "", + company: "", + }); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + {/* Display Name */} + + + + + + + {/* Username and Domain */} + + + + + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippAddRoomDrawer.jsx b/src/components/CippComponents/CippAddRoomDrawer.jsx new file mode 100644 index 000000000000..8dc5060908aa --- /dev/null +++ b/src/components/CippComponents/CippAddRoomDrawer.jsx @@ -0,0 +1,171 @@ +ο»Ώimport React, { useState, useEffect } from "react"; +import { Button, Divider } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useFormState } from "react-hook-form"; +import { AddHomeWork } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormDomainSelector } from "./CippFormDomainSelector"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippAddRoomDrawer = ({ + buttonText = "Add Room Mailbox", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const tenantDomain = useSettings().currentTenant; + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + displayName: "", + username: "", + domain: null, + resourceCapacity: "", + }, + }); + + const { isValid } = useFormState({ control: formControl.control }); + + const addRoom = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: [`RoomMailbox-${tenantDomain}`], + }); + + // Reset form fields on successful creation + useEffect(() => { + if (addRoom.isSuccess) { + formControl.reset({ + displayName: "", + username: "", + domain: null, + resourceCapacity: "", + }); + } + }, [addRoom.isSuccess, formControl]); + + const handleSubmit = () => { + formControl.trigger(); + // Check if the form is valid before proceeding + if (!isValid) { + return; + } + + const formData = formControl.getValues(); + const shippedValues = { + tenantID: tenantDomain, + domain: formData.domain?.value, + displayName: formData.displayName.trim(), + username: formData.username.trim(), + userPrincipalName: formData.username.trim() + "@" + (formData.domain?.value || "").trim(), + }; + + if (formData.resourceCapacity && formData.resourceCapacity.trim() !== "") { + shippedValues.resourceCapacity = formData.resourceCapacity.trim(); + } + + addRoom.mutate({ + url: "/api/AddRoomMailbox", + data: shippedValues, + relatedQueryKeys: [`RoomMailbox-${tenantDomain}`], + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + displayName: "", + username: "", + domain: null, + resourceCapacity: "", + }); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + {/* Display Name */} + + + + + + + {/* Username and Domain */} + + + + + + + + + + {/* Resource Capacity (Optional) */} + + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippAddRoomListDrawer.jsx b/src/components/CippComponents/CippAddRoomListDrawer.jsx new file mode 100644 index 000000000000..6ced8947993b --- /dev/null +++ b/src/components/CippComponents/CippAddRoomListDrawer.jsx @@ -0,0 +1,159 @@ +ο»Ώimport React, { useState, useEffect } from "react"; +import { Button, InputAdornment, Divider } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useFormState } from "react-hook-form"; +import { ListAlt } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormDomainSelector } from "./CippFormDomainSelector"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippAddRoomListDrawer = ({ + buttonText = "Add Room List", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const userSettingsDefaults = useSettings(); + const tenantDomain = userSettingsDefaults.currentTenant; + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + displayName: "", + username: "", + primDomain: null, + }, + }); + + const { isValid } = useFormState({ control: formControl.control }); + + const addRoomList = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: [`RoomLists-${tenantDomain}`], + }); + + // Reset form fields on successful creation + useEffect(() => { + if (addRoomList.isSuccess) { + formControl.reset({ + displayName: "", + username: "", + primDomain: null, + }); + } + }, [addRoomList.isSuccess, formControl]); + + const handleSubmit = () => { + formControl.trigger(); + // Check if the form is valid before proceeding + if (!isValid) { + return; + } + + const formData = formControl.getValues(); + const shippedValues = { + tenantFilter: tenantDomain, + displayName: formData.displayName?.trim(), + username: formData.username?.trim(), + primDomain: formData.primDomain, + }; + + addRoomList.mutate({ + url: "/api/AddRoomList", + data: shippedValues, + relatedQueryKeys: [`RoomLists-${tenantDomain}`], + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + displayName: "", + username: "", + primDomain: null, + }); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + + + + + + @, + }} + /> + + + + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippAddTenantAllowBlockListDrawer.jsx b/src/components/CippComponents/CippAddTenantAllowBlockListDrawer.jsx new file mode 100644 index 000000000000..23fbe252ae55 --- /dev/null +++ b/src/components/CippComponents/CippAddTenantAllowBlockListDrawer.jsx @@ -0,0 +1,378 @@ +ο»Ώimport { useEffect, useState } from "react"; +import { Button, Divider } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useFormState, useWatch } from "react-hook-form"; +import { PlaylistAdd } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormTenantSelector } from "./CippFormTenantSelector"; +import { CippApiResults } from "./CippApiResults"; +import { ApiPostCall } from "../../api/ApiCall"; +import { getCippValidator } from "../../utils/get-cipp-validator"; + +const defaultValues = { + tenantID: [], + entries: "", + notes: "", + listType: null, + listMethod: null, + NoExpiration: false, + RemoveAfter: false, +}; + +export const CippAddTenantAllowBlockListDrawer = ({ + buttonText = "Add Entry", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const formControl = useForm({ + mode: "onChange", + defaultValues, + }); + + const { isValid } = useFormState({ control: formControl.control }); + + const noExpiration = useWatch({ control: formControl.control, name: "NoExpiration" }); + const removeAfter = useWatch({ control: formControl.control, name: "RemoveAfter" }); + const listMethod = useWatch({ control: formControl.control, name: "listMethod" }); + const listType = useWatch({ control: formControl.control, name: "listType" }); + + const isListMethodBlock = listMethod?.value === "Block"; + const isListTypeFileHash = listType?.value === "FileHash"; + const isListTypeSenderUrlOrFileHash = ["Sender", "Url", "FileHash"].includes(listType?.value); + const isNoExpirationCompatible = + isListMethodBlock || (listMethod?.value === "Allow" && ["Url", "IP"].includes(listType?.value)); + + const addEntry = ApiPostCall({}); + + useEffect(() => { + if (noExpiration && formControl.getValues("RemoveAfter")) { + formControl.setValue("RemoveAfter", false, { shouldValidate: true }); + } + + if (removeAfter && formControl.getValues("NoExpiration")) { + formControl.setValue("NoExpiration", false, { shouldValidate: true }); + } + + if (isListMethodBlock && formControl.getValues("RemoveAfter")) { + formControl.setValue("RemoveAfter", false, { shouldValidate: true }); + } + + if (listType && !isListTypeSenderUrlOrFileHash && formControl.getValues("RemoveAfter")) { + formControl.setValue("RemoveAfter", false, { shouldValidate: true }); + } + + if (isListTypeFileHash && listMethod?.value !== "Block") { + formControl.setValue( + "listMethod", + { label: "Block", value: "Block" }, + { shouldValidate: true } + ); + } + + if ((listMethod || listType) && noExpiration && !isNoExpirationCompatible) { + formControl.setValue("NoExpiration", false, { shouldValidate: true }); + } + }, [ + noExpiration, + removeAfter, + isListMethodBlock, + listType, + isListTypeSenderUrlOrFileHash, + isListTypeFileHash, + isNoExpirationCompatible, + listMethod, + formControl, + ]); + + useEffect(() => { + if (addEntry.isSuccess) { + const currentTenants = formControl.getValues("tenantID"); + formControl.reset({ + ...defaultValues, + tenantID: currentTenants, + }); + } + }, [addEntry.isSuccess, formControl]); + + const validateEntries = (value) => { + if (!value) return true; + + const entries = value + .split(/[,;]/) + .map((entry) => entry.trim()) + .filter(Boolean); + const currentListType = listType?.value; + + if (currentListType === "FileHash") { + for (const entry of entries) { + if (entry.length !== 64) return "File hash entries must be exactly 64 characters"; + + const hashResult = getCippValidator(entry, "sha256"); + if (hashResult !== true) return hashResult; + } + return true; + } + + if (currentListType === "IP") { + for (const entry of entries) { + const ipv6Result = getCippValidator(entry, "ipv6"); + const ipv6CidrResult = getCippValidator(entry, "ipv6cidr"); + + if (ipv6Result !== true && ipv6CidrResult !== true) { + return "Invalid IPv6 address format. Use colon-hexadecimal or CIDR notation"; + } + } + return true; + } + + if (currentListType === "Url") { + for (const entry of entries) { + if (entry.length > 250) { + return "URL entries must be 250 characters or less"; + } + + if (entry.includes("*") || entry.includes("~")) { + const wildcardUrlResult = getCippValidator(entry, "wildcardUrl"); + const wildcardDomainResult = getCippValidator(entry, "wildcardDomain"); + + if (wildcardUrlResult === true || wildcardDomainResult === true) { + continue; + } + + if (!/^[a-zA-Z0-9.\-*~\/]+$/.test(entry)) { + return "Invalid wildcard pattern. Use only letters, numbers, dots, hyphens, slashes, and wildcards (* or ~)"; + } + + return "Invalid wildcard format. Common formats are *.domain.com or domain.*"; + } + + const ipv4Result = getCippValidator(entry, "ip"); + const ipv4CidrResult = getCippValidator(entry, "ipv4cidr"); + const ipv6Result = getCippValidator(entry, "ipv6"); + const ipv6CidrResult = getCippValidator(entry, "ipv6cidr"); + const hostnameResult = getCippValidator(entry, "hostname"); + const urlResult = getCippValidator(entry, "url"); + + if ( + ipv4Result !== true && + ipv4CidrResult !== true && + ipv6Result !== true && + ipv6CidrResult !== true && + hostnameResult !== true && + urlResult !== true + ) { + return "Invalid URL format. Enter hostnames, IPv4, or IPv6 addresses"; + } + } + return true; + } + + if (currentListType === "Sender") { + for (const entry of entries) { + if (entry.includes("*") || entry.includes("~")) { + const wildcardDomainResult = getCippValidator(entry, "wildcardDomain"); + + if (wildcardDomainResult !== true) { + return "Invalid sender wildcard pattern. Common format is *.domain.com"; + } + continue; + } + + const senderResult = getCippValidator(entry, "senderEntry"); + if (senderResult !== true) { + return senderResult; + } + } + return true; + } + + return true; + }; + + const handleSubmit = formControl.handleSubmit((values) => { + const payload = { + tenantID: values.tenantID, + entries: values.entries, + listType: values.listType?.value, + notes: values.notes, + listMethod: values.listMethod?.value, + NoExpiration: values.NoExpiration, + RemoveAfter: values.RemoveAfter, + }; + + addEntry.mutate({ + url: "/api/AddTenantAllowBlockList", + data: payload, + }); + }); + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset(defaultValues); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + + } + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default CippAddTenantAllowBlockListDrawer; diff --git a/src/components/CippComponents/CippAddTenantGroupDrawer.jsx b/src/components/CippComponents/CippAddTenantGroupDrawer.jsx new file mode 100644 index 000000000000..75804c15f89c --- /dev/null +++ b/src/components/CippComponents/CippAddTenantGroupDrawer.jsx @@ -0,0 +1,121 @@ +import React, { useState, useEffect } from "react"; +import { Button, Box } from "@mui/material"; +import { useForm, useFormState } from "react-hook-form"; +import { GroupAdd } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import { CippApiResults } from "./CippApiResults"; +import { ApiPostCall } from "../../api/ApiCall"; +import CippAddEditTenantGroups from "./CippAddEditTenantGroups"; +import { getCippValidator } from "../../utils/get-cipp-validator"; + +export const CippAddTenantGroupDrawer = ({ + buttonText = "Add Tenant Group", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + groupType: "static", + ruleLogic: "and", + dynamicRules: [{}] + }, + }); + + const createTenantGroup = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: ["TenantGroupListPage"], + }); + + const { isValid, isDirty } = useFormState({ control: formControl.control }); + + useEffect(() => { + if (createTenantGroup.isSuccess) { + formControl.reset({ + groupType: "static", + ruleLogic: "and", + dynamicRules: [{}] + }); + } + }, [createTenantGroup.isSuccess]); + + const handleSubmit = (data) => { + const formattedData = { + ...data, + Action: "AddEdit", + }; + + // If it's a dynamic group, format the rules for the backend + if (data.groupType === "dynamic" && data.dynamicRules) { + formattedData.dynamicRules = data.dynamicRules.map(rule => ({ + property: rule.property?.value || rule.property, + operator: rule.operator?.value || rule.operator, + value: rule.value, + })); + formattedData.ruleLogic = data.ruleLogic || "and"; + } + + createTenantGroup.mutate({ + url: "/api/ExecTenantGroup", + data: formattedData, + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + groupType: "static", + ruleLogic: "and", + dynamicRules: [{}] + }); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + + + + + + ); +}; \ No newline at end of file diff --git a/src/components/CippComponents/CippAddTransportRuleDrawer.jsx b/src/components/CippComponents/CippAddTransportRuleDrawer.jsx new file mode 100644 index 000000000000..b9ee583100a8 --- /dev/null +++ b/src/components/CippComponents/CippAddTransportRuleDrawer.jsx @@ -0,0 +1,166 @@ +ο»Ώimport { useState, useEffect } from "react"; +import { Button, Divider } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useFormState, useWatch } from "react-hook-form"; +import { RocketLaunch } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormTenantSelector } from "./CippFormTenantSelector"; +import { CippApiResults } from "./CippApiResults"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippAddTransportRuleDrawer = ({ + buttonText = "Deploy Template", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + selectedTenants: [], + TemplateList: null, + PowerShellCommand: "", + }, + }); + + const { isValid } = useFormState({ control: formControl.control }); + const templateListVal = useWatch({ control: formControl.control, name: "TemplateList" }); + + const addTransportRule = ApiPostCall({ + urlFromData: true, + }); + + // Update PowerShellCommand when template is selected + useEffect(() => { + if (templateListVal?.value) { + formControl.setValue("PowerShellCommand", JSON.stringify(templateListVal?.value)); + } + }, [templateListVal, formControl]); + + // Reset form fields on successful creation + useEffect(() => { + if (addTransportRule.isSuccess) { + const currentTenants = formControl.getValues("selectedTenants"); + formControl.reset({ + selectedTenants: currentTenants, + TemplateList: null, + PowerShellCommand: "", + }); + } + }, [addTransportRule.isSuccess, formControl]); + + const handleSubmit = () => { + formControl.trigger(); + // Check if the form is valid before proceeding + if (!isValid) { + return; + } + + const formData = formControl.getValues(); + + addTransportRule.mutate({ + url: "/api/AddTransportRule", + data: formData, + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + selectedTenants: [], + TemplateList: null, + PowerShellCommand: "", + }); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + {/* Tenant Selector */} + + + + + + + {/* Template List */} + + option, + url: "/api/ListTransportRulesTemplates", + }} + placeholder="Select a template or enter PowerShell JSON manually" + /> + + + + + {/* PowerShell Command */} + + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippAddUserDrawer.jsx b/src/components/CippComponents/CippAddUserDrawer.jsx new file mode 100644 index 000000000000..f6a650ce1b11 --- /dev/null +++ b/src/components/CippComponents/CippAddUserDrawer.jsx @@ -0,0 +1,172 @@ +import React, { useState, useEffect } from "react"; +import { Button, Box } from "@mui/material"; +import { useForm, useWatch, useFormState } from "react-hook-form"; +import { PersonAdd } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import { CippFormUserSelector } from "./CippFormUserSelector"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiPostCall } from "../../api/ApiCall"; +import CippAddEditUser from "../CippFormPages/CippAddEditUser"; +import { Stack } from "@mui/system"; + +export const CippAddUserDrawer = ({ + buttonText = "Add User", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const userSettingsDefaults = useSettings(); + + const formControl = useForm({ + mode: "onBlur", + defaultValues: { + tenantFilter: userSettingsDefaults.currentTenant, + usageLocation: userSettingsDefaults.usageLocation, + }, + }); + + const createUser = ApiPostCall({ + datafromUrl: true, + relatedQueryKeys: [`Users-${userSettingsDefaults.currentTenant}`], + }); + + const { isValid, isDirty } = useFormState({ control: formControl.control }); + + const formValues = useWatch({ control: formControl.control, name: "userProperties" }); + + useEffect(() => { + if (formValues) { + const { userPrincipalName, usageLocation, ...restFields } = formValues.addedFields || {}; + let newFields = { ...restFields }; + if (userPrincipalName) { + const [mailNickname, domainNamePart] = userPrincipalName.split("@"); + if (mailNickname) { + newFields.mailNickname = mailNickname; + } + if (domainNamePart) { + newFields.primDomain = { label: domainNamePart, value: domainNamePart }; + } + } + if (usageLocation) { + newFields.usageLocation = { label: usageLocation, value: usageLocation }; + } + newFields.tenantFilter = userSettingsDefaults.currentTenant; + + formControl.reset(newFields); + } + }, [formValues]); + + useEffect(() => { + if (createUser.isSuccess) { + formControl.reset({ + tenantFilter: userSettingsDefaults.currentTenant, + usageLocation: userSettingsDefaults.usageLocation, + }); + } + }, [createUser.isSuccess]); + + const handleSubmit = () => { + formControl.trigger(); + if (!isValid) { + return; + } + const values = formControl.getValues(); + Object.keys(values).forEach((key) => { + if (values[key] === "" || values[key] === null) { + delete values[key]; + } + }); + createUser.mutate({ + url: "/api/AddUser", + data: values, + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + tenantFilter: userSettingsDefaults.currentTenant, + usageLocation: userSettingsDefaults.usageLocation, + }); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + +
    + + +
    +
    + } + > + + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippAliasDialog.jsx b/src/components/CippComponents/CippAliasDialog.jsx new file mode 100644 index 000000000000..7f6696054843 --- /dev/null +++ b/src/components/CippComponents/CippAliasDialog.jsx @@ -0,0 +1,174 @@ +import { useState, useEffect, useMemo } from "react"; +import { Typography, Box, Button, TextField, Chip, Stack } from "@mui/material"; +import { Add } from "@mui/icons-material"; +import { useWatch } from "react-hook-form"; +import { CippFormDomainSelector } from "./CippFormDomainSelector"; + +const CippAliasDialog = ({ formHook }) => { + const [aliasPrefix, setAliasPrefix] = useState(""); + + // Initialize the form field if it doesn't exist + useEffect(() => { + // Set default empty array if AddedAliases doesn't exist in the form + if (!formHook.getValues("AddedAliases")) { + formHook.setValue("AddedAliases", []); + } + }, [formHook]); + + // Use useWatch to subscribe to form field changes + const aliasList = useWatch({ + control: formHook.control, + name: "AddedAliases", + defaultValue: [], + }); + + const selectedDomain = useWatch({ + control: formHook.control, + name: "AliasDomain", + }); + + const isPending = formHook.formState.isSubmitting; + + const selectedDomainValue = useMemo(() => { + if (!selectedDomain) return ""; + if (Array.isArray(selectedDomain)) { + return selectedDomain[0]?.value || selectedDomain[0] || ""; + } + if (typeof selectedDomain === "object") { + return selectedDomain?.value || ""; + } + return selectedDomain; + }, [selectedDomain]); + + const handleAddAlias = () => { + const prefix = aliasPrefix.trim(); + const domain = selectedDomainValue; + + if (!prefix || !domain) { + return; + } + + const formattedAlias = `${prefix}@${domain}`; + const currentAliases = formHook.getValues("AddedAliases") || []; + + if (currentAliases.some((alias) => alias.toLowerCase() === formattedAlias.toLowerCase())) { + setAliasPrefix(""); + return; + } + + const newList = [...currentAliases, formattedAlias]; + formHook.setValue("AddedAliases", newList, { shouldValidate: true }); + setAliasPrefix(""); + }; + + const handleDeleteAlias = (aliasToDelete) => { + const currentAliases = formHook.getValues("AddedAliases") || []; + const updatedList = currentAliases.filter((alias) => alias !== aliasToDelete); + formHook.setValue("AddedAliases", updatedList, { shouldValidate: true }); + }; + + const handleKeyPress = (event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleAddAlias(); + } + }; + + return ( + <> + + + Add proxy addresses (aliases) for this user. Enter a prefix, choose a verified tenant + domain, and click Add or press Enter. + + + setAliasPrefix(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Enter alias prefix" + variant="outlined" + disabled={isPending} + size="small" + sx={{ + "& .MuiOutlinedInput-root": { + fontFamily: "monospace", + "& .MuiOutlinedInput-input": { + px: 2, + }, + }, + }} + /> + + + + + + + {aliasList.length === 0 ? ( + + No aliases added yet + + ) : ( + aliasList.map((alias) => ( + handleDeleteAlias(alias)} + color="primary" + variant="outlined" + /> + )) + )} + + + + ); +}; + +export default CippAliasDialog; diff --git a/src/components/CippComponents/CippApiDialog.jsx b/src/components/CippComponents/CippApiDialog.jsx index 87a9fe56b689..e45fe1a22365 100644 --- a/src/components/CippComponents/CippApiDialog.jsx +++ b/src/components/CippComponents/CippApiDialog.jsx @@ -1,13 +1,20 @@ -import { useRouter } from "next/router"; // Import Next.js router -import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Grid } from "@mui/material"; +import { useRouter } from "next/router"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + useMediaQuery, +} from "@mui/material"; import { Stack } from "@mui/system"; import { CippApiResults } from "./CippApiResults"; import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import React, { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useForm, useFormState } from "react-hook-form"; import { useSettings } from "../../hooks/use-settings"; import CippFormComponent from "./CippFormComponent"; -import { useMediaQuery } from "@mui/material"; export const CippApiDialog = (props) => { const { @@ -19,187 +26,177 @@ export const CippApiDialog = (props) => { relatedQueryKeys, dialogAfterEffect, allowResubmit = false, + children, + defaultvalues, ...other } = props; const router = useRouter(); const [addedFieldData, setAddedFieldData] = useState({}); const [partialResults, setPartialResults] = useState([]); const [isFormSubmitted, setIsFormSubmitted] = useState(false); - const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); if (mdDown) { other.fullScreen = true; } + const formHook = useForm({ + defaultValues: defaultvalues || {}, + mode: "onChange", // Enable real-time validation + }); + + // Get form state for validation + const { isValid } = useFormState({ control: formHook.control }); + useEffect(() => { if (createDialog.open) { setIsFormSubmitted(false); - formHook.reset(); + formHook.reset(defaultvalues || {}); } - }, [createDialog.open]); + }, [createDialog.open, defaultvalues]); const [getRequestInfo, setGetRequestInfo] = useState({ url: "", waiting: false, queryKey: "", - relatedQueryKeys: relatedQueryKeys - ? relatedQueryKeys - : api.relatedQueryKeys - ? api.relatedQueryKeys - : title, + relatedQueryKeys: relatedQueryKeys ?? api.relatedQueryKeys ?? title, bulkRequest: api.multiPost === false, - onResult: (result) => { - setPartialResults((prevResults) => [...prevResults, result]); - }, + onResult: (result) => setPartialResults((prev) => [...prev, result]), }); const actionPostRequest = ApiPostCall({ urlFromData: true, - relatedQueryKeys: relatedQueryKeys - ? relatedQueryKeys - : api.relatedQueryKeys - ? api.relatedQueryKeys - : title, + relatedQueryKeys: relatedQueryKeys ?? api.relatedQueryKeys ?? title, bulkRequest: api.multiPost === false, onResult: (result) => { - setPartialResults((prevResults) => [...prevResults, result]); - if (api?.onSuccess) { - api.onSuccess(result); - } + setPartialResults((prev) => [...prev, result]); + api?.onSuccess?.(result); }, }); + const actionGetRequest = ApiGetCall({ ...getRequestInfo, - relatedQueryKeys: relatedQueryKeys - ? relatedQueryKeys - : api.relatedQueryKeys - ? api.relatedQueryKeys - : title, + relatedQueryKeys: relatedQueryKeys ?? api.relatedQueryKeys ?? title, bulkRequest: api.multiPost === false, onResult: (result) => { - setPartialResults((prevResults) => [...prevResults, result]); - if (api?.onSuccess) { - api.onSuccess(result); - } + setPartialResults((prev) => [...prev, result]); + api?.onSuccess?.(result); }, }); const processActionData = (dataObject, row, replacementBehaviour) => { - if (typeof api?.dataFunction === "function") { - return api.dataFunction(row); - } - var newData = {}; + if (typeof api?.dataFunction === "function") return api.dataFunction(row, dataObject); + let newData = {}; if (api?.postEntireRow) { - newData = row; - } else { - Object.keys(dataObject).forEach((key) => { - const value = dataObject[key]; - if (typeof value === "string" && value.startsWith("!")) { - newData[key] = value.slice(1); - } else if (typeof value === "string") { - if (row[value] !== undefined) { - newData[key] = row[value]; - } else { - newData[key] = value; - } - } else if (typeof value === "boolean") { - newData[key] = value; - } else if (typeof value === "object" && value !== null) { - const processedValue = processActionData(value, row, replacementBehaviour); - if (replacementBehaviour !== "removeNulls" || Object.keys(processedValue).length > 0) { - newData[key] = processedValue; - } - } else if (replacementBehaviour !== "removeNulls") { - newData[key] = value; - } else if (row[value] !== undefined) { - newData[key] = row[value]; - } - }); + return row; } + + if (!dataObject) { + return dataObject; + } + + Object.keys(dataObject).forEach((key) => { + const value = dataObject[key]; + + if (typeof value === "string" && value.startsWith("!")) { + newData[key] = value.slice(1); + } else if (typeof value === "string") { + newData[key] = row[value] ?? value; + } else if (typeof value === "boolean") { + newData[key] = value; + } else if (typeof value === "object" && value !== null) { + const processedValue = processActionData(value, row, replacementBehaviour); + if (replacementBehaviour !== "removeNulls" || Object.keys(processedValue).length > 0) { + newData[key] = processedValue; + } + } else if (replacementBehaviour !== "removeNulls") { + newData[key] = value; + } + }); + return newData; }; + const tenantFilter = useSettings().currentTenant; const handleActionClick = (row, action, formData) => { setIsFormSubmitted(true); - if (action.multiPost === undefined) { - action.multiPost = false; - } - if (api.customFunction) { - action.customFunction(row, action, formData); - createDialog.handleClose(); - return; - } + let finalData = {}; + if (typeof api?.customDataformatter === "function") { + finalData = api.customDataformatter(row, action, formData); + } else { + if (action.multiPost === undefined) action.multiPost = false; - const commonData = { - tenantFilter: tenantFilter, - ...formData, - ...addedFieldData, - }; - const processedActionData = processActionData(action.data, row, action.replacementBehaviour); - - if (Array.isArray(row) && action.multiPost === false) { - const arrayOfObjects = row.map((singleRow) => { - const itemData = { ...commonData }; - Object.keys(processedActionData).forEach((key) => { - const rowValue = singleRow[processedActionData[key]]; - itemData[key] = rowValue !== undefined ? rowValue : processedActionData[key]; - }); - return itemData; - }); - if (action.type === "POST") { - actionPostRequest.mutate({ - url: action.url, - bulkRequest: true, - data: arrayOfObjects, - }); - } else if (action.type === "GET") { - setGetRequestInfo({ - url: action.url, - waiting: true, - queryKey: Date.now(), - bulkRequest: true, - data: arrayOfObjects, - }); + if (api.customFunction) { + action.customFunction(row, action, formData); + createDialog.handleClose(); + return; } - return; - } + // Helper function to get the correct tenant filter for a row + const getRowTenantFilter = (rowData) => { + // If we're in AllTenants mode and the row has a Tenant property, use that + if (tenantFilter === "AllTenants" && rowData?.Tenant) { + return rowData.Tenant; + } + // Otherwise use the current tenant filter + return tenantFilter; + }; - if (Array.isArray(row) && action.multiPost === true) { - const singleArrayData = row.map((singleRow) => { - const itemData = { ...commonData }; - Object.keys(processedActionData).forEach((key) => { - const rowValue = singleRow[processedActionData[key]]; - itemData[key] = rowValue !== undefined ? rowValue : processedActionData[key]; - }); - return itemData; - }); + const processedActionData = processActionData(action.data, row, action.replacementBehaviour); + + if (!processedActionData || Object.keys(processedActionData).length === 0) { + console.warn("No data to process for action:", action); + } else { + // MULTI ROW CASES + if (Array.isArray(row)) { + const arrayData = row.map((singleRow) => { + const commonData = { + tenantFilter: getRowTenantFilter(singleRow), + ...formData, + ...addedFieldData, + }; + const itemData = { ...commonData }; + Object.keys(processedActionData).forEach((key) => { + const rowValue = singleRow[processedActionData[key]]; + itemData[key] = rowValue !== undefined ? rowValue : processedActionData[key]; + }); + return itemData; + }); - if (action.type === "POST") { - actionPostRequest.mutate({ - url: action.url, - bulkRequest: false, - data: singleArrayData, - }); - } else if (action.type === "GET") { - setGetRequestInfo({ - url: action.url, - waiting: true, - queryKey: Date.now(), - bulkRequest: false, - data: singleArrayData, - }); + const payload = { + url: action.url, + bulkRequest: !action.multiPost, + data: arrayData, + }; + + if (action.type === "POST") { + actionPostRequest.mutate(payload); + } else if (action.type === "GET") { + setGetRequestInfo({ + ...payload, + waiting: true, + queryKey: Date.now(), + }); + } + + return; + } } - return; - } - const finalData = { ...commonData }; - Object.keys(processedActionData).forEach((key) => { - const rowValue = row[processedActionData[key]]; - finalData[key] = rowValue !== undefined ? rowValue : processedActionData[key]; - }); + // SINGLE ROW CASE + const commonData = { + tenantFilter: getRowTenantFilter(row), + ...formData, + ...addedFieldData, + }; + + // βœ… FIXED: DIRECT MERGE INSTEAD OF CORRUPT TRANSFORMATION + finalData = { + ...commonData, + ...processedActionData, + }; + } if (action.type === "POST") { actionPostRequest.mutate({ @@ -217,94 +214,70 @@ export const CippApiDialog = (props) => { }); } }; - //add a useEffect, when dialogAfterEffect exists, and the post or get request is successful, run the dialogAfterEffect function + useEffect(() => { if (dialogAfterEffect && (actionPostRequest.isSuccess || actionGetRequest.isSuccess)) { - dialogAfterEffect(actionPostRequest.data.data || actionGetRequest.data); + dialogAfterEffect(actionPostRequest.data?.data || actionGetRequest.data); } }, [actionPostRequest.isSuccess, actionGetRequest.isSuccess]); - const formHook = useForm(); + const onSubmit = (data) => handleActionClick(row, api, data); const selectedType = api.type === "POST" ? actionPostRequest : actionGetRequest; useEffect(() => { if (api?.setDefaultValues && createDialog.open) { - fields.map((field) => { + fields.forEach((field) => { + const val = row[field.name]; if ( - ((typeof row[field.name] === "string" && field.type === "textField") || - (typeof row[field.name] === "boolean" && field.type === "switch")) && - row[field.name] !== undefined && - row[field.name] !== null && - row[field.name] !== "" + (typeof val === "string" && field.type === "textField") || + (typeof val === "boolean" && field.type === "switch") ) { - formHook.setValue(field.name, row[field.name]); - } else if (Array.isArray(row[field.name]) && field.type === "autoComplete") { - var values = []; - row[field.name].map((element) => { - if (element.label && element.value) { - values.push(element); - } else if (typeof element === "string" || typeof element === "number") { - values.push({ - label: element, - value: element, - }); - } - }); + formHook.setValue(field.name, val); + } else if (Array.isArray(val) && field.type === "autoComplete") { + const values = val + .map((el) => + el?.label && el?.value + ? el + : typeof el === "string" || typeof el === "number" + ? { label: el, value: el } + : null + ) + .filter(Boolean); formHook.setValue(field.name, values); - } else if ( - field.type === "autoComplete" && - row[field.name] !== "" && - (typeof row[field.name] === "string" || - (typeof row[field.name] === "object" && - row[field.name] !== undefined && - row[field.name] !== null)) - ) { - if (typeof row[field.name] === "string") { - formHook.setValue(field.name, { - label: row[field.name], - value: row[field.name], - }); - } else if ( - typeof row[field.name] === "object" && - row[field.name]?.label && - row[field.name]?.value - ) { - formHook.setValue(field.name, row[field.name]); - } + } else if (field.type === "autoComplete" && val) { + formHook.setValue( + field.name, + typeof val === "string" + ? { label: val, value: val } + : val.label && val.value + ? val + : undefined + ); } }); } }, [createDialog.open, api?.setDefaultValues]); - const getNestedValue = (obj, path) => { - return path + const getNestedValue = (obj, path) => + path .split(".") .reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : undefined), obj); - }; const [linkClicked, setLinkClicked] = useState(false); - - useEffect(() => { - setLinkClicked(false); - }, [api.link]); + useEffect(() => setLinkClicked(false), [api.link]); useEffect(() => { if (api.link && !linkClicked && row && Object.keys(row).length > 0) { const timeoutId = setTimeout(() => { - const linkWithRowData = api.link.replace(/\[([^\]]+)\]/g, (_, key) => { - return getNestedValue(row, key) || `[${key}]`; - }); - - if (linkWithRowData.startsWith("/") && !api?.external) { - // Internal link navigation - setLinkClicked(true); - router.push(linkWithRowData, undefined, { shallow: true }); - } else { - // External link navigation - setLinkClicked(true); - window.open(linkWithRowData, api.target || "_blank"); - } - }, 0); // Delay execution to the next event loop cycle + const linkWithData = api.link.replace( + /\[([^\]]+)\]/g, + (_, key) => getNestedValue(row, key) || `[${key}]` + ); + setLinkClicked(true); + if (linkWithData.startsWith("/") && !api?.external) + router.push(linkWithData, undefined, { shallow: true }); + else window.open(linkWithData, api.target || "_blank"); + }, 0); return () => clearTimeout(timeoutId); } @@ -312,41 +285,42 @@ export const CippApiDialog = (props) => { useEffect(() => { if (api.noConfirm && !api.link) { - formHook.handleSubmit(onSubmit)(); // Submits the form on mount - createDialog.handleClose(); // Closes the dialog after submitting + formHook.handleSubmit(onSubmit)(); + createDialog.handleClose(); } - }, [api.noConfirm, api.link]); // Run effect when noConfirm or link changes + }, [api.noConfirm, api.link]); const handleClose = () => { createDialog.handleClose(); setPartialResults([]); }; - var confirmText; + let confirmText; if (typeof api?.confirmText === "string") { if (!Array.isArray(row)) { - confirmText = api.confirmText.replace(/\[([^\]]+)\]/g, (_, key) => { - return getNestedValue(row, key) || `[${key}]`; - }); + confirmText = api.confirmText.replace( + /\[([^\]]+)\]/g, + (_, key) => getNestedValue(row, key) || `[${key}]` + ); } else if (row.length > 1) { confirmText = api.confirmText.replace(/\[([^\]]+)\]/g, "the selected rows"); } else if (row.length === 1) { - confirmText = api.confirmText.replace(/\[([^\]]+)\]/g, (_, key) => { - return getNestedValue(row[0], key) || `[${key}]`; - }); + confirmText = api.confirmText.replace( + /\[([^\]]+)\]/g, + (_, key) => getNestedValue(row[0], key) || `[${key}]` + ); } } else { - // Handle JSX/Component confirmText const replaceTextInElement = (element) => { if (!element) return element; if (typeof element === "string") { - if (Array.isArray(row) && row.length > 1) { - return element.replace(/\[([^\]]+)\]/g, "the selected rows"); - } else if (Array.isArray(row) && row.length === 1) { - return element.replace( - /\[([^\]]+)\]/g, - (_, key) => getNestedValue(row[0], key) || `[${key}]` - ); + if (Array.isArray(row)) { + return row.length > 1 + ? element.replace(/\[([^\]]+)\]/g, "the selected rows") + : element.replace( + /\[([^\]]+)\]/g, + (_, key) => getNestedValue(row[0], key) || `[${key}]` + ); } return element.replace(/\[([^\]]+)\]/g, (_, key) => getNestedValue(row, key) || `[${key}]`); } @@ -369,36 +343,43 @@ export const CippApiDialog = (props) => { {confirmText} - - {fields && - fields.map((fieldProps, index) => { - if (fieldProps?.api?.processFieldData) { - fieldProps.api.data = processActionData(fieldProps.api.data, row); - } - return ( - + + {children ? ( + typeof children === "function" ? ( + children({ + formHook, + row, + }) + ) : ( + children + ) + ) : ( + <> + {fields?.map((fieldProps, i) => ( + - - ); - })} - + + ))} + + )} +
    - diff --git a/src/components/CippComponents/CippApiLogsDrawer.jsx b/src/components/CippComponents/CippApiLogsDrawer.jsx new file mode 100644 index 000000000000..3349e891a9f5 --- /dev/null +++ b/src/components/CippComponents/CippApiLogsDrawer.jsx @@ -0,0 +1,95 @@ +import { useState } from "react"; +import { Button, Box } from "@mui/material"; +import { History } from "@mui/icons-material"; +import { EyeIcon } from "@heroicons/react/24/outline"; +import { CippOffCanvas } from "./CippOffCanvas"; +import { CippDataTable } from "../CippTable/CippDataTable"; + +export const CippApiLogsDrawer = ({ + buttonText = "View API Logs", + apiFilter = null, + tenantFilter = null, + requiredPermissions = [], + PermissionButton = Button, + title = "API Logs", + ...props +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + + const handleCloseDrawer = () => { + setDrawerVisible(false); + }; + + const handleOpenDrawer = () => { + setDrawerVisible(true); + }; + + // Build the API URL with the filter + const apiUrl = `/api/ListLogs?Filter=true${apiFilter ? `&API=${apiFilter}` : ""}${ + tenantFilter ? `&Tenant=${tenantFilter}` : "" + }`; + + // Define the columns for the logs table + const simpleColumns = ["DateTime", "Severity", "Message", "User", "Tenant", "API"]; + + const actions = [ + { + label: "View Log Entry", + link: "/cipp/logs/logentry?logentry=[RowKey]", + icon: , + color: "primary", + }, + ]; + + return ( + <> + } + {...props} + > + {buttonText} + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippApiResults.jsx b/src/components/CippComponents/CippApiResults.jsx index 21917507c396..388b27984ced 100644 --- a/src/components/CippComponents/CippApiResults.jsx +++ b/src/components/CippComponents/CippApiResults.jsx @@ -1,4 +1,4 @@ -import { Close, Download } from "@mui/icons-material"; +import { Close, Download, Help, ExpandMore, ExpandLess } from "@mui/icons-material"; import { Alert, CircularProgress, @@ -9,15 +9,18 @@ import { Box, SvgIcon, Tooltip, + Button, + keyframes, } from "@mui/material"; import { useEffect, useState, useMemo, useCallback } from "react"; import { getCippError } from "../../utils/get-cipp-error"; import { CippCopyToClipBoard } from "./CippCopyToClipboard"; +import { CippDocsLookup } from "./CippDocsLookup"; +import { CippCodeBlock } from "./CippCodeBlock"; import React from "react"; import { CippTableDialog } from "./CippTableDialog"; import { EyeIcon } from "@heroicons/react/24/outline"; import { useDialog } from "../../hooks/use-dialog"; -import { useRouter } from "next/router"; const extractAllResults = (data) => { const results = []; @@ -41,12 +44,14 @@ const extractAllResults = (data) => { const copyField = item.copyField || ""; const severity = typeof item.state === "string" ? item.state : getSeverity(item) ? "error" : "success"; + const details = item.details || null; if (text) { return { text, copyField, severity, + details, ...item, }; } @@ -73,7 +78,7 @@ const extractAllResults = (data) => { results.push(processed); } } else { - const ignoreKeys = ["metadata", "Metadata"]; + const ignoreKeys = ["metadata", "Metadata", "severity"]; if (typeof obj === "object") { Object.keys(obj).forEach((key) => { @@ -121,8 +126,8 @@ export const CippApiResults = (props) => { const [errorVisible, setErrorVisible] = useState(false); const [fetchingVisible, setFetchingVisible] = useState(false); const [finalResults, setFinalResults] = useState([]); + const [showDetails, setShowDetails] = useState({}); const tableDialog = useDialog(); - const router = useRouter(); const pageTitle = `${document.title} - Results`; const correctResultObj = useMemo(() => { if (!apiObject.isSuccess) return; @@ -159,12 +164,12 @@ export const CippApiResults = (props) => { useEffect(() => { setErrorVisible(!!apiObject.isError); + if (apiObject.isFetching || (apiObject.isIdle === false && apiObject.isPending === true)) { + setFetchingVisible(true); + } else { + setFetchingVisible(false); + } if (!errorsOnly) { - if (apiObject.isFetching || (apiObject.isIdle === false && apiObject.isPending === true)) { - setFetchingVisible(true); - } else { - setFetchingVisible(false); - } if (allResults.length > 0) { setFinalResults( allResults.map((res, index) => ({ @@ -193,6 +198,10 @@ export const CippApiResults = (props) => { setFinalResults((prev) => prev.map((r) => (r.id === id ? { ...r, visible: false } : r))); }, []); + const toggleDetails = useCallback((id) => { + setShowDetails((prev) => ({ ...prev, [id]: !prev[id] })); + }, []); + const handleDownloadCsv = useCallback(() => { if (!finalResults?.length) return; @@ -241,7 +250,6 @@ export const CippApiResults = (props) => { )} - {/* Error alert */} {apiObject.isError && ( @@ -272,12 +280,79 @@ export const CippApiResults = (props) => { - + {resultObj.severity === "error" && ( + + )} + + + {resultObj.details && ( + + toggleDetails(resultObj.id)} + aria-label={showDetails[resultObj.id] ? "Hide Details" : "Show Details"} + > + {showDetails[resultObj.id] ? ( + + ) : ( + + )} + + + )} + { } > - {resultObj.text} + + {resultObj.text} + {resultObj.details && ( + + + + + + )} + ))} )} - {(apiObject.isSuccess || apiObject.isError) && finalResults?.length > 0 ? ( + {(apiObject.isSuccess || apiObject.isError) && + finalResults?.length > 0 && + hasVisibleResults ? ( tableDialog.handleOpen()}> diff --git a/src/components/CippComponents/CippAppPermissionBuilder.jsx b/src/components/CippComponents/CippAppPermissionBuilder.jsx index 7fa7099fb4c3..46adfc153008 100644 --- a/src/components/CippComponents/CippAppPermissionBuilder.jsx +++ b/src/components/CippComponents/CippAppPermissionBuilder.jsx @@ -70,7 +70,18 @@ const CippAppPermissionBuilder = ({ setExpanded(newExpanded ? panel : false); }; + const deprecatedServicePrincipals = [ + "00000002-0000-0000-c000-000000000000", // Windows Azure Active Directory + "a0c73c16-a7e3-4564-9a95-2bdf47383716", // Microsoft Exchange Online Remote PowerShell + "1b730954-1685-4b74-9bfd-dac224a7b894", // Azure Active Directory PowerShell + ]; + const currentSelectedSp = useWatch({ control: formControl.control, name: "servicePrincipal" }); + + // Check if selected service principal is in the deprecated list + const isDeprecatedSp = + currentSelectedSp && deprecatedServicePrincipals.includes(currentSelectedSp.value); + const { data: servicePrincipals = [], isSuccess: spSuccess, @@ -79,7 +90,7 @@ const CippAppPermissionBuilder = ({ refetch: refetchServicePrincipals, } = ApiGetCall({ url: "/api/ExecServicePrincipals", - queryKey: "execServicePrincipals", + queryKey: "execServicePrincipalList", waiting: true, }); @@ -544,115 +555,50 @@ const CippAppPermissionBuilder = ({ return ( <> - {spInfoFetching && } - {servicePrincipal && spInfoSuccess && ( - <> - - Manage the permissions for the {servicePrincipal.displayName}. - + + Manage the permissions for the {servicePrincipal.displayName}. + - - - - - - - - - {servicePrincipal?.appRoles?.length > 0 ? ( - <> - - - - !appTable?.find((perm) => perm.id === role.id)) - .map((role) => ({ - label: role.value, - value: role.id, - }))} - formControl={formControl} - multiple={false} - /> - - - -
    - handleAddRow("applicationPermissions", currentAppPermission) - } - > - -
    -
    -
    -
    - , - noConfirm: true, - customFunction: (row) => handleRemoveRow("applicationPermissions", row), - }, - ]} - /> -
    - - ) : ( - } sx={{ mb: 3 }}> - No Application Permissions found. - - )} -
    - + + + + + + + + + {servicePrincipal?.appRoles?.length > 0 ? ( + <> - {spInfo?.Results?.publishedPermissionScopes?.length === 0 && ( - }> - No Published Delegated Permissions found. - - )} - + !delegatedTable?.find((perm) => perm.id === scope.id)) - .map((scope) => ({ - label: scope.value, - value: scope.id, + options={(spInfo?.Results?.appRoles || []) + .filter((role) => !appTable?.find((perm) => perm.id === role.id)) + .map((role) => ({ + label: role.value, + value: role.id, }))} formControl={formControl} multiple={false} /> - +
    - handleAddRow("delegatedPermissions", currentDelegatedPermission) + handleAddRow("applicationPermissions", currentAppPermission) } > - +
    +
    +
    +
    - -
    - - )} + , + noConfirm: true, + customFunction: (row) => handleRemoveRow("delegatedPermissions", row), + }, + ]} + isFetching={spInfoFetching} + /> +
    + + + + ); }; @@ -715,7 +723,8 @@ const CippAppPermissionBuilder = ({
    - + - +
    { - setSelectedApp([ - ...selectedApp, - servicePrincipals?.Results?.find( - (sp) => sp.appId === currentSelectedSp.value - ), - ]); - formControl.setValue("servicePrincipal", null); + // Only add if not deprecated + if (!isDeprecatedSp) { + setSelectedApp([ + ...selectedApp, + servicePrincipals?.Results?.find( + (sp) => sp.appId === currentSelectedSp.value + ), + ]); + formControl.setValue("servicePrincipal", null); + } }} > + +
    + } + > + + + + + {/* Tenant Selector */} + + + + + + + + + + + + + This is a community contribution and is not covered under a vendor sponsorship. + Please join our Discord community for assistance with this MSP App. + + + + + + + + + + + {selectedTenants?.map((tenant, index) => ( + + + + ))} + + + {/* For "syncro" */} + + {selectedTenants?.map((tenant, index) => ( + + + + ))} + + + {/* For "huntress" */} + + + + + {selectedTenants?.map((tenant, index) => ( + + + + ))} + + + {/* For "automate" */} + + + + + {selectedTenants?.map((tenant, index) => ( + + + + ))} + {selectedTenants?.map((tenant, index) => ( + + + + ))} + + + {/* For "cwcommand" */} + + {selectedTenants?.map((tenant, index) => ( + + + + ))} + + + {/* Assign To Options */} + + + + + + + + + + + {/* WinGet App Section */} + + + + + + + + + + ({ + value: item, + label: `${item.applicationName} - ${item.packagename}`, + })) + : [] + } + multiple={false} + formControl={formControl} + disabled={winGetSearchResults.isLoading} + isFetching={winGetSearchResults.isLoading} + /> + + + + + + + + + + + + {/* Install Options */} + + + + + {/* Assign To Options */} + + + + + + + + + + + {/* Chocolatey App Section */} + + + + + + + + + + ({ + value: item, + label: `${item.applicationName} - ${item.packagename}`, + })) + : [] + } + multiple={false} + formControl={formControl} + isFetching={ChocosearchResults.isLoading} + /> + + + + + + + + + + + + + + + + + + + {/* Install Options */} + + + + + + + {/* Assign To Options */} + + + + + + + + + + + {/* Office App Section */} + + + + + + + + + ({ + value: tag, + label: `${language} (${tag})`, + }))} + multiple={true} + formControl={formControl} + validators={{ required: "Please select at least one language" }} + /> + + + + + + + + + + + + + + + + + + + + + Provide a custom Office Configuration XML. When using custom XML, all other + Office configuration options above will be ignored. See{" "} + + Office Customization Tool + {" "} + to generate XML. + + + + + {/* Assign To Options */} + + + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippAuditLogDetails.jsx b/src/components/CippComponents/CippAuditLogDetails.jsx new file mode 100644 index 000000000000..b5a3077a0da6 --- /dev/null +++ b/src/components/CippComponents/CippAuditLogDetails.jsx @@ -0,0 +1,359 @@ +import { useEffect } from "react"; +import { getCippTranslation } from "/src/utils/get-cipp-translation"; +import { getCippFormatting } from "/src/utils/get-cipp-formatting"; +import CippGeoLocation from "/src/components/CippComponents/CippGeoLocation"; +import { Tooltip, CircularProgress, Stack } from "@mui/material"; +import { useGuidResolver } from "/src/hooks/use-guid-resolver"; +import { CippPropertyListCard } from "/src/components/CippCards/CippPropertyListCard"; + +const CippAuditLogDetails = ({ row }) => { + const { + guidMapping, + upnMapping, + isLoadingGuids, + resolveGuids, + isGuid, + replaceGuidsAndUpnsInString, + } = useGuidResolver(); + + // Use effect for initial scan to resolve GUIDs and special UPNs + useEffect(() => { + if (row) { + // Scan the main row data + resolveGuids(row); + + // Scan audit data if present + if (row.auditData) { + resolveGuids(row.auditData); + } + } + }, [row?.id, resolveGuids]); // Dependencies for when to resolve GUIDs + + // Function to replace GUIDs and special UPNs in strings with resolved names + const replaceGuidsInString = (str) => { + if (typeof str !== "string") return str; + + // Use the hook's helper function to replace both GUIDs and special UPNs + const { result, hasResolvedNames } = replaceGuidsAndUpnsInString(str); + + // If we have resolved names, return a tooltip showing original and resolved + if (hasResolvedNames) { + return ( + + {result} + + ); + } + + // Check for GUIDs and special UPNs to see if we should show loading state + const guidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi; + const partnerUpnRegex = /user_([0-9a-f]{32})@([^@]+\.onmicrosoft\.com)/gi; + + let hasGuids = guidRegex.test(str); + + // Reset regex state and check for partner UPNs + partnerUpnRegex.lastIndex = 0; + let hasUpns = false; + let match; + + // Need to extract and check if the GUIDs from UPNs are in the pending state + while ((match = partnerUpnRegex.exec(str)) !== null) { + const hexId = match[1]; + if (hexId && hexId.length === 32) { + hasUpns = true; + break; // At least one UPN pattern found + } + } + + // If we have unresolved GUIDs or UPNs and are currently loading + if ((hasGuids || hasUpns) && isLoadingGuids) { + return ( +
    + + {str} +
    + ); + } + + return str; + }; + + // Convert data to property items format for CippPropertyListCard + const convertToPropertyItems = (data, excludeAuditData = false) => { + if (!data) return []; + + return Object.entries(data) + .map(([key, value]) => { + // Skip certain blacklisted fields + const blacklist = ["selectedOption", "GUID", "ID", "id", "noSubmitButton"]; + if (blacklist.includes(key)) return null; + + // Exclude auditData from main log items if specified + if (excludeAuditData && key === "auditData") return null; + + let displayValue; + // Handle different value types + if (typeof value === "string" && isGuid(value)) { + // Handle pure GUID strings + displayValue = renderGuidValue(value); + } else if ( + typeof value === "string" && + value.match(/^user_[0-9a-f]{32}@[^@]+\.onmicrosoft\.com$/i) + ) { + // Handle special partner UPN format as direct values + displayValue = renderGuidValue(value); + } else if ( + key.toLowerCase().includes("clientip") && + value && + value !== null && + isValidIpAddress(value) + ) { + // Handle IP addresses (with optional ports) using CippGeoLocation + // Check for various IP field names: clientIp, ClientIP, IP, etc. + const cleanIp = extractIpForGeolocation(value); + displayValue = ( +
    + +
    + ); + } else if (typeof value === "string") { + // Handle strings that might contain embedded GUIDs + // First apply GUID replacement to get the processed string + const guidProcessedValue = replaceGuidsInString(value); + + // If GUID replacement returned a React element (with tooltips), use it directly + if (typeof guidProcessedValue === "object" && guidProcessedValue?.type) { + displayValue = guidProcessedValue; + } else { + // Otherwise, apply getCippFormatting to the GUID-processed string + // This preserves key-based formatting while including GUID replacements + displayValue = getCippFormatting(guidProcessedValue, key); + } + } else if (typeof value === "object" && value !== null) { + // Handle nested objects and arrays - expand GUIDs within them + displayValue = renderNestedValue(value); + } else { + // Handle regular values + displayValue = getCippFormatting(value, key); + } + + return { + label: getCippTranslation(key), + value: displayValue, + }; + }) + .filter(Boolean); + }; + + // Render GUID values with proper resolution states + const renderGuidValue = (guidValue) => { + // Handle standard GUIDs directly + if (guidMapping[guidValue]) { + return ( + + {guidMapping[guidValue]} + + ); + } + + // Special handling for partner UPN format (user_@partnertenant.onmicrosoft.com) + const partnerUpnRegex = /^user_([0-9a-f]{32})@([^@]+\.onmicrosoft\.com)$/i; + const upnMatch = typeof guidValue === "string" ? guidValue.match(partnerUpnRegex) : null; + + if (upnMatch) { + const hexId = upnMatch[1]; + if (hexId && hexId.length === 32) { + const guid = [ + hexId.slice(0, 8), + hexId.slice(8, 12), + hexId.slice(12, 16), + hexId.slice(16, 20), + hexId.slice(20, 32), + ].join("-"); + + // For partner UPN format, use the actual UPN if available, otherwise fall back to display name + if (upnMapping && upnMapping[guid]) { + return ( + + {upnMapping[guid]} + + ); + } else if (guidMapping[guid]) { + return ( + + {guidMapping[guid]} + + ); + } + } + } + + // Loading state + if (isLoadingGuids) { + return ( +
    + + {guidValue} +
    + ); + } + + // Fallback for unresolved values + return ( + + {guidValue} + + ); + }; + + // Recursively render nested objects and arrays with GUID expansion + const renderNestedValue = (value) => { + if (Array.isArray(value)) { + // Handle arrays + return renderArrayValue(value); + } else if (typeof value === "object" && value !== null) { + // Handle objects + return renderObjectValue(value); + } + return getCippFormatting(value, "nested"); + }; + + // Render array values with GUID expansion + const renderArrayValue = (arrayValue) => { + if (arrayValue.length === 0) return "[]"; + + // If it's a simple array, show it formatted + if (arrayValue.length <= 5 && arrayValue.every((item) => typeof item !== "object")) { + return ( +
    + {arrayValue.map((item, index) => ( +
    + {typeof item === "string" && isGuid(item) + ? renderGuidValue(item) + : typeof item === "string" + ? replaceGuidsInString(item) + : getCippFormatting(item, `item-${index}`)} +
    + ))} +
    + ); + } + + // For complex arrays, use the formatted version which might include table buttons + return getCippFormatting(arrayValue, "array"); + }; + + // Render object values with GUID expansion + const renderObjectValue = (objectValue) => { + const entries = Object.entries(objectValue); + + // If it's a simple object with few properties, show them inline + if (entries.length <= 3 && entries.every(([, val]) => typeof val !== "object")) { + return ( +
    + {entries.map(([objKey, objVal]) => ( +
    + {getCippTranslation(objKey)}:{" "} + {typeof objVal === "string" && isGuid(objVal) + ? renderGuidValue(objVal) + : typeof objVal === "string" + ? replaceGuidsInString(objVal) + : getCippFormatting(objVal, objKey)} +
    + ))} +
    + ); + } + + // For complex objects, use the formatted version which might include table buttons + return getCippFormatting(objectValue, "object"); + }; + + // Helper function to validate IP addresses (with optional ports) + const isValidIpAddress = (ip) => { + if (typeof ip !== "string") return false; + + // Extract IP part if there's a port (split by last colon for IPv6 compatibility) + let ipPart = ip; + let portPart = null; + + // Check for IPv4:port format + const ipv4PortMatch = ip.match(/^(.+):(\d+)$/); + if (ipv4PortMatch) { + ipPart = ipv4PortMatch[1]; + portPart = ipv4PortMatch[2]; + } + + // IPv4 regex + const ipv4Regex = + /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + + // IPv6 regex (simplified) - note: IPv6 with ports use [::]:port format, handled separately + const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$/; + + // Check for IPv6 with port [::]:port format + const ipv6PortMatch = ip.match(/^\[(.+)\]:(\d+)$/); + if (ipv6PortMatch) { + ipPart = ipv6PortMatch[1]; + portPart = ipv6PortMatch[2]; + } + + // Validate port number if present + if (portPart !== null) { + const port = parseInt(portPart, 10); + if (port < 1 || port > 65535) return false; + } + + return ipv4Regex.test(ipPart) || ipv6Regex.test(ipPart); + }; + + // Extract clean IP address from IP:port combinations for geolocation + const extractIpForGeolocation = (ipWithPort) => { + if (typeof ipWithPort !== "string") return ipWithPort; + + // IPv4:port format + const ipv4PortMatch = ipWithPort.match(/^(.+):(\d+)$/); + if (ipv4PortMatch) { + return ipv4PortMatch[1]; + } + + // IPv6 with port [::]:port format + const ipv6PortMatch = ipWithPort.match(/^\[(.+)\]:(\d+)$/); + if (ipv6PortMatch) { + return ipv6PortMatch[1]; + } + + // Return as-is if no port detected + return ipWithPort; + }; + + const mainLogItems = convertToPropertyItems(row, true); // Exclude auditData from main items + const auditDataItems = row?.auditData ? convertToPropertyItems(row.auditData) : []; + + return ( + + + + {auditDataItems.length > 0 && ( + + )} + + ); +}; + +export default CippAuditLogDetails; diff --git a/src/components/CippComponents/CippAuditLogSearchDrawer.jsx b/src/components/CippComponents/CippAuditLogSearchDrawer.jsx new file mode 100644 index 000000000000..93628842b386 --- /dev/null +++ b/src/components/CippComponents/CippAuditLogSearchDrawer.jsx @@ -0,0 +1,471 @@ +import { useState, useEffect } from "react"; +import { Button, Stack, Box } from "@mui/material"; +import { Add } from "@mui/icons-material"; +import { useForm } from "react-hook-form"; +import { CippOffCanvas } from "./CippOffCanvas"; +import { ApiPostCall, ApiGetCallWithPagination } from "../../api/ApiCall"; +import CippFormComponent from "./CippFormComponent"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "/src/hooks/use-settings"; + +export const CippAuditLogSearchDrawer = ({ + buttonText = "New Search", + relatedQueryKeys = ["AuditLogSearches"], +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const currentTenantDomain = useSettings().currentTenant; + + // Fetch tenant list to get full tenant details + const tenantList = ApiGetCallWithPagination({ + url: "/api/ListTenants", + queryKey: "ListTenants-FormnotAllTenants", + data: { AllTenantSelector: false }, + }); + + // Find the current tenant from the list using the domain name - handle pagination data structure + const allTenants = tenantList.data?.pages?.flatMap((page) => page.Results || page) || []; + const currentTenant = allTenants.find( + (tenant) => tenant.defaultDomainName === currentTenantDomain + ); + + // Create default values with current tenant prefilled + const defaultValues = { + TenantFilter: currentTenant + ? { + label: `${currentTenant.displayName} (${currentTenant.defaultDomainName})`, + value: currentTenant.defaultDomainName, + } + : null, + }; + + const formControl = useForm({ + defaultValues, + }); + + // Update form defaults when tenant data is loaded + useEffect(() => { + if (currentTenant) { + const newDefaultValues = { + TenantFilter: { + label: `${currentTenant.displayName} (${currentTenant.defaultDomainName})`, + value: currentTenant.defaultDomainName, + }, + }; + formControl.reset(newDefaultValues); + } + }, [currentTenant, formControl]); + + const createSearchApi = ApiPostCall({ + datafromUrl: false, + relatedQueryKeys, + }); + + const handleCloseDrawer = () => { + setDrawerVisible(false); + if (currentTenant) { + const resetValues = { + TenantFilter: { + label: `${currentTenant.displayName} (${currentTenant.defaultDomainName})`, + value: currentTenant.defaultDomainName, + }, + }; + formControl.reset(resetValues); + } else { + formControl.reset(); + } + }; + + const handleCreateSearch = async (data) => { + const formattedData = { ...data }; + + // Extract value from TenantFilter autocomplete object + if (formattedData.TenantFilter?.value) { + formattedData.TenantFilter = formattedData.TenantFilter.value; + } + + // Handle KeywordFilter - extract values from array and join with spaces + if (Array.isArray(formattedData.KeywordFilter)) { + const keywords = formattedData.KeywordFilter.map((item) => + typeof item === "object" ? item.value : item + ).filter(Boolean); + formattedData.KeywordFilter = keywords.join(" "); + } + + // Extract values from RecordTypeFilters array + if (Array.isArray(formattedData.RecordTypeFilters)) { + formattedData.RecordTypeFilters = formattedData.RecordTypeFilters.map((item) => + typeof item === "object" ? item.value : item + ); + } + + // Extract values from ServiceFilters array + if (Array.isArray(formattedData.ServiceFilters)) { + formattedData.ServiceFilters = formattedData.ServiceFilters.map((item) => + typeof item === "object" ? item.value : item + ); + } + + // Extract values from OperationsFilters array + if (Array.isArray(formattedData.OperationsFilters)) { + formattedData.OperationsFilters = formattedData.OperationsFilters.map((item) => + typeof item === "object" ? item.value : item + ); + } + + // Extract values from UserPrincipalNameFilters array + if (Array.isArray(formattedData.UserPrincipalNameFilters)) { + formattedData.UserPrincipalNameFilters = formattedData.UserPrincipalNameFilters.map((item) => + typeof item === "object" ? item.value : item + ); + } + + // Extract values from IPAddressFilters array + if (Array.isArray(formattedData.IPAddressFilters)) { + formattedData.IPAddressFilters = formattedData.IPAddressFilters.map((item) => + typeof item === "object" ? item.value : item + ); + } + + // Extract values from ObjectIdFilters array + if (Array.isArray(formattedData.ObjectIdFilters)) { + formattedData.ObjectIdFilters = formattedData.ObjectIdFilters.map((item) => + typeof item === "object" ? item.value : item + ); + } + + // Extract values from AdministrativeUnitFilters array + if (Array.isArray(formattedData.AdministrativeUnitFilters)) { + formattedData.AdministrativeUnitFilters = formattedData.AdministrativeUnitFilters.map( + (item) => (typeof item === "object" ? item.value : item) + ); + } + + // Remove empty arrays to avoid sending unnecessary data + Object.keys(formattedData).forEach((key) => { + if (Array.isArray(formattedData[key]) && formattedData[key].length === 0) { + delete formattedData[key]; + } + if ( + formattedData[key] === "" || + formattedData[key] === null || + formattedData[key] === undefined + ) { + delete formattedData[key]; + } + }); + + try { + await createSearchApi.mutateAsync({ + url: "/api/ExecAuditLogSearch", + data: formattedData, + }); + } catch (error) { + console.error("Error creating search:", error); + } + }; + + // Create Search Form Fields + const createSearchFields = [ + { + type: "textField", + name: "DisplayName", + label: "Search Name", + required: true, + validators: { required: "Search name is required" }, + disableVariables: true, + }, + { + type: "autoComplete", + name: "TenantFilter", + label: "Tenant", + multiple: false, + creatable: false, + api: { + url: "/api/ListTenants?AllTenantSelector=false", + labelField: (option) => `${option.displayName} (${option.defaultDomainName})`, + valueField: "defaultDomainName", + queryKey: "ListTenants-FormnotAllTenants", + excludeTenantFilter: true, + }, + validators: { validate: (value) => !!value?.value || "Please select a tenant" }, + required: true, + }, + { + type: "datePicker", + name: "StartTime", + label: "Start Date & Time", + dateTimeType: "datetime-local", + validators: { required: "Start time is required" }, + required: true, + }, + { + type: "datePicker", + name: "EndTime", + label: "End Date & Time", + dateTimeType: "datetime-local", + validators: { required: "End time is required" }, + required: true, + }, + { + type: "autoComplete", + name: "ServiceFilters", + label: "Services", + multiple: true, + creatable: false, + options: [ + { label: "Azure Active Directory", value: "AzureActiveDirectory" }, + { label: "Dynamics 365", value: "CRM" }, + { label: "Exchange Online", value: "Exchange" }, + { label: "Microsoft Flow", value: "MicrosoftFlow" }, + { label: "Microsoft Teams", value: "MicrosoftTeams" }, + { label: "OneDrive for Business", value: "OneDrive" }, + { label: "Power BI", value: "PowerBI" }, + { label: "Security & Compliance", value: "ThreatIntelligence" }, + { label: "SharePoint Online", value: "SharePoint" }, + { label: "Yammer", value: "Yammer" }, + ], + validators: { + validate: (values) => values?.length > 0 || "Please select at least one service", + }, + }, + { + type: "autoComplete", + name: "RecordTypeFilters", + label: "Record Types", + multiple: true, + creatable: false, + options: [ + { label: "Azure Active Directory", value: "azureActiveDirectory" }, + { label: "Azure AD Account Logon", value: "azureActiveDirectoryAccountLogon" }, + { label: "Azure AD STS Logon", value: "azureActiveDirectoryStsLogon" }, + { label: "Compliance DLP Endpoint", value: "complianceDLPEndpoint" }, + { label: "Compliance DLP Exchange", value: "complianceDLPExchange" }, + { label: "Compliance DLP SharePoint", value: "complianceDLPSharePoint" }, + { label: "Data Governance", value: "dataGovernance" }, + { label: "Exchange Admin", value: "exchangeAdmin" }, + { label: "Exchange Item", value: "exchangeItem" }, + { label: "Exchange Item Group", value: "exchangeItemGroup" }, + { label: "Information Worker Protection", value: "informationWorkerProtection" }, + { label: "Label Content Explorer", value: "labelContentExplorer" }, + { label: "Microsoft Flow", value: "microsoftFlow" }, + { label: "Microsoft Forms", value: "microsoftForms" }, + { label: "Microsoft Stream", value: "microsoftStream" }, + { label: "Microsoft Teams", value: "microsoftTeams" }, + { label: "Microsoft Teams Admin", value: "microsoftTeamsAdmin" }, + { label: "Microsoft Teams Analytics", value: "microsoftTeamsAnalytics" }, + { label: "Microsoft Teams Device", value: "microsoftTeamsDevice" }, + { label: "Microsoft Teams Shifts", value: "microsoftTeamsShifts" }, + { label: "MIP Label", value: "mipLabel" }, + { label: "OneDrive", value: "oneDrive" }, + { label: "Power Apps App", value: "powerAppsApp" }, + { label: "Power Apps Plan", value: "powerAppsPlan" }, + { label: "Power BI Audit", value: "powerBIAudit" }, + { label: "Power BI DLP", value: "powerBIDlp" }, + { label: "Security & Compliance Alerts", value: "securityComplianceAlerts" }, + { label: "Security & Compliance Insights", value: "securityComplianceInsights" }, + { label: "Security & Compliance RBAC", value: "securityComplianceRBAC" }, + { label: "SharePoint", value: "sharePoint" }, + { label: "SharePoint File Operation", value: "sharePointFileOperation" }, + { label: "SharePoint List Operation", value: "sharePointListOperation" }, + { label: "SharePoint Sharing Operation", value: "sharePointSharingOperation" }, + { label: "Threat Intelligence", value: "threatIntelligence" }, + { label: "Threat Intelligence ATP Content", value: "threatIntelligenceAtpContent" }, + { label: "Threat Intelligence URL", value: "threatIntelligenceUrl" }, + { label: "Workplace Analytics", value: "workplaceAnalytics" }, + ], + }, + { + type: "autoComplete", + name: "KeywordFilter", + label: "Keywords", + multiple: true, + creatable: true, + freeSolo: true, + placeholder: "Enter keywords to search for", + options: [], + }, + { + type: "autoComplete", + name: "OperationsFilters", + label: "Operations", + multiple: true, + creatable: true, + placeholder: "Enter or select operations", + options: [ + // Authentication & User Operations + { label: "User Logged In", value: "UserLoggedIn" }, + { label: "Mailbox Login", value: "mailboxlogin" }, + + // User Management Operations + { label: "Add User", value: "add user." }, + { label: "Update User", value: "update user." }, + { label: "Delete User", value: "delete user." }, + { label: "Reset User Password", value: "reset user password." }, + { label: "Change User Password", value: "change user password." }, + { label: "Change User License", value: "change user license." }, + + // Group Management Operations + { label: "Add Group", value: "add group." }, + { label: "Update Group", value: "update group." }, + { label: "Delete Group", value: "delete group." }, + { label: "Add Member to Group", value: "add member to group." }, + { label: "Remove Member from Group", value: "remove member from group." }, + + // Mailbox Operations + { label: "New Mailbox", value: "New-Mailbox" }, + { label: "Set Mailbox", value: "Set-Mailbox" }, + { label: "Add Mailbox Permission", value: "add-mailboxpermission" }, + { label: "Remove Mailbox Permission", value: "remove-mailboxpermission" }, + { label: "Mail Items Accessed", value: "mailitemsaccessed" }, + + // Email Operations + { label: "Send Message", value: "send" }, + { label: "Send As", value: "sendas" }, + { label: "Send On Behalf", value: "sendonbehalf" }, + { label: "Create Item", value: "create" }, + { label: "Update Message", value: "update" }, + { label: "Copy Messages", value: "copy" }, + { label: "Move Messages", value: "move" }, + { label: "Move to Deleted Items", value: "movetodeleteditems" }, + { label: "Soft Delete", value: "softdelete" }, + { label: "Hard Delete", value: "harddelete" }, + + // Inbox Rules + { label: "New Inbox Rule", value: "new-inboxrule" }, + { label: "Set Inbox Rule", value: "set-inboxrule" }, + { label: "Update Inbox Rules", value: "updateinboxrules" }, + + // Folder Operations + { label: "Add Folder Permissions", value: "addfolderpermissions" }, + { label: "Remove Folder Permissions", value: "removefolderpermissions" }, + { label: "Update Folder Permissions", value: "updatefolderpermissions" }, + { label: "Update Calendar Delegation", value: "updatecalendardelegation" }, + + // SharePoint/OneDrive Operations (Common ones) + { label: "File Accessed", value: "FileAccessed" }, + { label: "File Modified", value: "FileModified" }, + { label: "File Deleted", value: "FileDeleted" }, + { label: "File Downloaded", value: "FileDownloaded" }, + { label: "File Uploaded", value: "FileUploaded" }, + { label: "Sharing Set", value: "SharingSet" }, + { label: "Anonymous Link Created", value: "AnonymousLinkCreated" }, + + // Role and Permission Operations + { label: "Add Member to Role", value: "add member to role." }, + { label: "Remove Member from Role", value: "remove member from role." }, + { label: "Add Service Principal", value: "add service principal." }, + { label: "Remove Service Principal", value: "remove service principal." }, + + // Company and Domain Operations + { label: "Add Domain to Company", value: "add domain to company." }, + { label: "Remove Domain from Company", value: "remove domain from company." }, + { label: "Verify Domain", value: "verify domain." }, + { label: "Set Company Information", value: "set company information." }, + + // Security Operations + { label: "Disable Strong Authentication", value: "Disable Strong Authentication." }, + { label: "Apply Record Label", value: "applyrecordlabel" }, + { label: "Update STS Refresh Token", value: "Update StsRefreshTokenValidFrom Timestamp." }, + ], + }, + { + type: "autoComplete", + name: "UserPrincipalNameFilters", + label: "User Principal Names", + multiple: true, + creatable: true, + freeSolo: true, + placeholder: "Enter user principal names", + options: [], + }, + { + type: "autoComplete", + name: "IPAddressFilters", + label: "IP Addresses", + multiple: true, + creatable: true, + freeSolo: true, + placeholder: "Enter IP addresses", + options: [], + }, + { + type: "autoComplete", + name: "ObjectIdFilters", + label: "Object IDs", + multiple: true, + creatable: true, + freeSolo: true, + placeholder: "Enter object IDs", + options: [], + }, + { + type: "autoComplete", + name: "AdministrativeUnitFilters", + label: "Administrative Units", + multiple: true, + creatable: true, + placeholder: "Enter administrative units", + api: { + url: "/api/ListGraphRequest", + queryKey: "AdministrativeUnits", + data: { + Endpoint: "directoryObjects/microsoft.graph.administrativeUnit", + $select: "id,displayName", + }, + dataKey: "Results", + labelField: "displayName", + valueField: "id", + addedField: { + id: "id", + displayName: "displayName", + }, + showRefresh: true, + }, + }, + { + type: "switch", + name: "ProcessLogs", + label: "Process Logs for Alerts", + helperText: "Enable to store this search for alert processing", + }, + ]; + + return ( + <> + + + + +
    + } + > + + + {createSearchFields.map((field, index) => ( + + + + ))} + + + + + + ); +}; diff --git a/src/components/CippComponents/CippAutocomplete.jsx b/src/components/CippComponents/CippAutocomplete.jsx index 4edb340549ba..9ed0e358d594 100644 --- a/src/components/CippComponents/CippAutocomplete.jsx +++ b/src/components/CippComponents/CippAutocomplete.jsx @@ -6,7 +6,7 @@ import { TextField, IconButton, } from "@mui/material"; -import { useEffect, useState, useMemo, useCallback } from "react"; +import { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { useSettings } from "../../hooks/use-settings"; import { getCippError } from "../../utils/get-cipp-error"; import { ApiGetCallWithPagination } from "../../api/ApiCall"; @@ -71,11 +71,15 @@ export const CippAutoComplete = (props) => { removeOptions = [], sortOptions = false, preselectedValue, + groupBy, + renderGroup, ...other } = props; const [usedOptions, setUsedOptions] = useState(options); const [getRequestInfo, setGetRequestInfo] = useState({ url: "", waiting: false, queryKey: "" }); + const hasPreselectedRef = useRef(false); + const autocompleteRef = useRef(null); // Ref for focusing input after selection const filter = createFilterOptions({ stringify: (option) => JSON.stringify(option), }); @@ -158,7 +162,11 @@ export const CippAutoComplete = (props) => { label: typeof api?.labelField === "function" ? api.labelField(option) - : option[api?.labelField], + : option[api?.labelField] + ? option[api?.labelField] + : option[api?.altLabelField] || + option[api?.valueField] || + "No label found - Are you missing a labelField?", value: typeof api?.valueField === "function" ? api.valueField(option) @@ -201,19 +209,51 @@ export const CippAutoComplete = (props) => { return finalOptions; }, [api, usedOptions, options, removeOptions, sortOptions]); - // Dedicated effect for handling preselected value + // Dedicated effect for handling preselected value or auto-select first item - only runs once useEffect(() => { - if (preselectedValue && !defaultValue && !value && memoizedOptions.length > 0) { - const preselectedOption = memoizedOptions.find((option) => option.value === preselectedValue); + if (memoizedOptions.length > 0 && !hasPreselectedRef.current) { + // Check if we should skip preselection due to existing defaultValue + const hasDefaultValue = + defaultValue && (Array.isArray(defaultValue) ? defaultValue.length > 0 : true); - if (preselectedOption) { - const newValue = multiple ? [preselectedOption] : preselectedOption; - if (onChange) { - onChange(newValue, newValue?.addedFields); + if (!hasDefaultValue) { + // For multiple mode, check if value is empty array or null/undefined + // For single mode, check if value is null/undefined + const shouldPreselect = multiple + ? !value || (Array.isArray(value) && value.length === 0) + : !value; + + if (shouldPreselect) { + let preselectedOption; + + // Handle explicit preselected value + if (preselectedValue) { + preselectedOption = memoizedOptions.find((option) => option.value === preselectedValue); + } + // Handle auto-select first item from API + else if (api?.autoSelectFirstItem && memoizedOptions.length > 0) { + preselectedOption = memoizedOptions[0]; + } + + if (preselectedOption) { + const newValue = multiple ? [preselectedOption] : preselectedOption; + hasPreselectedRef.current = true; // Mark that we've preselected + if (onChange) { + onChange(newValue, newValue?.addedFields); + } + } } } } - }, [preselectedValue, defaultValue, value, memoizedOptions, multiple, onChange]); + }, [ + preselectedValue, + defaultValue, + value, + memoizedOptions, + multiple, + onChange, + api?.autoSelectFirstItem, + ]); // Create a stable key that only changes when necessary inputs change const stableKey = useMemo(() => { @@ -237,6 +277,7 @@ export const CippAutoComplete = (props) => { return ( { value: item?.label ? item.value : item, }; if (onCreateOption) { - onCreateOption(item, item?.addedFields); + item = onCreateOption(item, item?.addedFields); } } return item; @@ -311,7 +352,7 @@ export const CippAutoComplete = (props) => { value: newValue?.label ? newValue.value : newValue, }; if (onCreateOption) { - onCreateOption(newValue, newValue?.addedFields); + newValue = onCreateOption(newValue, newValue?.addedFields); } } if (!newValue?.value || newValue.value === "error") { @@ -321,17 +362,51 @@ export const CippAutoComplete = (props) => { if (onChange) { onChange(newValue, newValue?.addedFields); } + + // In multiple mode, refocus the input after selection to allow continuous adding + if (multiple && newValue && autocompleteRef.current) { + // Use setTimeout to ensure the selection is processed first + setTimeout(() => { + const input = autocompleteRef.current?.querySelector("input"); + if (input) { + input.focus(); + } + }, 0); + } }} options={memoizedOptions} getOptionLabel={useCallback( - (option) => - option - ? option.label === null + (option) => { + if (!option) return ""; + // For static options (non-API), the option should already have a label + if (!api && option.label !== undefined) { + return option.label === null ? "" : String(option.label); + } + // For API options, use the existing logic + if (api) { + return option.label === null ? "" - : option.label || "Label not found - Are you missing a labelField?" - : "", - [] + : option.label || "Label not found - Are you missing a labelField?"; + } + // Fallback for any edge cases + return option.label || option.value || ""; + }, + [api] )} + onKeyDown={(event) => { + // Handle Tab key to select highlighted option + if (event.key === "Tab" && !event.shiftKey) { + // Check if there's a highlighted option + const listbox = document.querySelector('[role="listbox"]'); + const highlightedOption = listbox?.querySelector('[data-focus="true"], .Mui-focused'); + + if (highlightedOption && listbox?.style.display !== "none") { + event.preventDefault(); + // Trigger a click on the highlighted option + highlightedOption.click(); + } + } + }} sx={sx} renderInput={(params) => ( @@ -354,6 +429,8 @@ export const CippAutoComplete = (props) => { )} )} + groupBy={groupBy} + renderGroup={renderGroup} {...other} /> ); diff --git a/src/components/CippComponents/CippAutopilotProfileDrawer.jsx b/src/components/CippComponents/CippAutopilotProfileDrawer.jsx new file mode 100644 index 000000000000..f5b48ae54040 --- /dev/null +++ b/src/components/CippComponents/CippAutopilotProfileDrawer.jsx @@ -0,0 +1,255 @@ +import React, { useState, useEffect } from "react"; +import { Divider, Button } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useWatch, useFormState } from "react-hook-form"; +import { AccountCircle } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormTenantSelector } from "./CippFormTenantSelector"; +import { CippApiResults } from "./CippApiResults"; +import languageList from "/src/data/languageList.json"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippAutopilotProfileDrawer = ({ + buttonText = "Add Profile", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const formControl = useForm({ + mode: "onChange", + defaultValues: { + DisplayName: "", + Description: "", + DeviceNameTemplate: "", + languages: null, + CollectHash: false, + Assignto: true, + DeploymentMode: false, + HideTerms: true, + HidePrivacy: true, + HideChangeAccount: true, + NotLocalAdmin: true, + allowWhiteglove: true, + Autokeyboard: true, + }, + }); + + const createProfile = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: ["Autopilot Profiles*"], + }); + + // Watch the deployment mode to conditionally disable white glove + const deploymentMode = useWatch({ + control: formControl.control, + name: "DeploymentMode", + }); + + // Watch form state for validation + const { isValid, isDirty } = useFormState({ + control: formControl.control, + }); + + // Automatically disable white glove when self-deploying mode (shared) is enabled + useEffect(() => { + if (deploymentMode === true) { + // Self-deploying mode is enabled (shared mode), disable white glove + formControl.setValue("allowWhiteglove", false); + } + }, [deploymentMode, formControl]); + + const handleSubmit = () => { + const formData = formControl.getValues(); + // Always set HideChangeAccount to true regardless of form state + formData.HideChangeAccount = true; + createProfile.mutate({ + url: "/api/AddAutopilotConfig", + data: formData, + relatedQueryKeys: ["Autopilot Profiles*"], + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset(); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + +
    + + +
    + + } + > + + {/* Tenant Selector */} + + + + + + + + + {/* Form Fields */} + + + + + + ({ + value: tag, + label: `${language} - ${geographicArea}`, // Format as "language - geographic area" for display + }))} + formControl={formControl} + multiple={false} + /> + + + + + + + + + + + {/* Switches */} + + + + + + + + + + + + +
    + + ); +}; diff --git a/src/components/CippComponents/CippAutopilotStatusPageDrawer.jsx b/src/components/CippComponents/CippAutopilotStatusPageDrawer.jsx new file mode 100644 index 000000000000..3f0739ec4874 --- /dev/null +++ b/src/components/CippComponents/CippAutopilotStatusPageDrawer.jsx @@ -0,0 +1,180 @@ +import React, { useState } from "react"; +import { Divider, Button } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useFormState } from "react-hook-form"; +import { PostAdd } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormTenantSelector } from "./CippFormTenantSelector"; +import { CippApiResults } from "./CippApiResults"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippAutopilotStatusPageDrawer = ({ + buttonText = "Add Status Page", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const formControl = useForm({ + mode: "onChange", + defaultValues: { + TimeOutInMinutes: "", + ErrorMessage: "", + ShowProgress: true, + EnableLog: true, + OBEEOnly: false, + blockDevice: true, + AllowReset: true, + AllowFail: false, + InstallWindowsUpdates: true, + }, + }); + + // Get form state for validation + const { isValid } = useFormState({ control: formControl.control }); + + const createStatusPage = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: ["Autopilot Status Pages"], + }); + + const handleSubmit = () => { + const formData = formControl.getValues(); + createStatusPage.mutate({ + url: "/api/AddEnrollment", + data: formData, + relatedQueryKeys: ["Autopilot Status Pages"], + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset(); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + {/* Tenant Selector */} + + + + + + + + + {/* Form Fields */} + + + + + + + + + {/* Switches */} + + + + + + + + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippBackupScheduleDrawer.jsx b/src/components/CippComponents/CippBackupScheduleDrawer.jsx new file mode 100644 index 000000000000..36f064d06110 --- /dev/null +++ b/src/components/CippComponents/CippBackupScheduleDrawer.jsx @@ -0,0 +1,287 @@ +import { useState, useEffect } from "react"; +import { Button, Box, Typography, Alert, AlertTitle } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useFormState } from "react-hook-form"; +import { Backup } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormTenantSelector } from "./CippFormTenantSelector"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiPostCall } from "../../api/ApiCall"; +import { omit } from "lodash"; + +export const CippBackupScheduleDrawer = ({ + buttonText = "Add Backup Schedule", + requiredPermissions = [], + PermissionButton = Button, + onSuccess, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const userSettingsDefaults = useSettings(); + + const formControl = useForm({ + mode: "onBlur", + defaultValues: { + tenantFilter: userSettingsDefaults.currentTenant, + users: true, + groups: true, + ca: true, + intuneconfig: true, + intunecompliance: true, + intuneprotection: true, + antispam: true, + antiphishing: true, + CippWebhookAlerts: true, + CippScriptedAlerts: true, + CippCustomVariables: true, + }, + }); + + const createBackup = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: [`BackupTasks-${userSettingsDefaults.currentTenant}`], + }); + + const { isValid, isDirty } = useFormState({ control: formControl.control }); + + useEffect(() => { + if (createBackup.isSuccess) { + formControl.reset({ + tenantFilter: userSettingsDefaults.currentTenant, + users: true, + groups: true, + ca: true, + intuneconfig: true, + intunecompliance: true, + intuneprotection: true, + antispam: true, + antiphishing: true, + CippWebhookAlerts: true, + CippScriptedAlerts: true, + CippCustomVariables: true, + }); + // Call onSuccess callback if provided + if (onSuccess) { + onSuccess(); + } + } + }, [createBackup.isSuccess, onSuccess]); + + const handleSubmit = () => { + formControl.trigger(); + if (!isValid) { + return; + } + const values = formControl.getValues(); + const startDate = new Date(); + startDate.setHours(0, 0, 0, 0); + const unixTime = Math.floor(startDate.getTime() / 1000) - 45; + const tenantFilter = values.tenantFilter || userSettingsDefaults.currentTenant; + + const shippedValues = { + TenantFilter: tenantFilter, + Name: `CIPP Backup - ${tenantFilter}`, + Command: { value: `New-CIPPBackup` }, + Parameters: { + backupType: "Scheduled", + ScheduledBackupValues: { ...omit(values, ["tenantFilter"]) }, + }, + ScheduledTime: unixTime, + Recurrence: { value: "1d" }, + }; + + createBackup.mutate({ + url: "/api/AddScheduledItem?hidden=true&DisallowDuplicateName=true", + data: shippedValues, + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + tenantFilter: userSettingsDefaults.currentTenant, + users: true, + groups: true, + ca: true, + intuneconfig: true, + intunecompliance: true, + intuneprotection: true, + antispam: true, + antiphishing: true, + CippWebhookAlerts: true, + CippScriptedAlerts: true, + CippCustomVariables: true, + }); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + + Backup Schedule Information + Create a scheduled backup task that will automatically backup your tenant configuration. + Backups are stored securely and can be restored using the restore functionality. + + + + + Tenant Selection + + + + + Identity + + + + + + + + + + Conditional Access + + + + + + + + Intune + + + + + + + + + + + + + + Email Security + + + + + + + + + + CIPP + + + + + + + + + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippBulkUserDrawer.jsx b/src/components/CippComponents/CippBulkUserDrawer.jsx new file mode 100644 index 000000000000..7718ae957138 --- /dev/null +++ b/src/components/CippComponents/CippBulkUserDrawer.jsx @@ -0,0 +1,247 @@ +import { useState } from "react"; +import { Button, Link, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useWatch } from "react-hook-form"; +import { GroupAdd, Delete } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormLicenseSelector } from "./CippFormLicenseSelector"; +import { CippDataTable } from "../CippTable/CippDataTable"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiPostCall } from "../../api/ApiCall"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; +import countryList from "/src/data/countryList.json"; + +export const CippBulkUserDrawer = ({ + buttonText = "Bulk Add Users", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const [addRowDialogOpen, setAddRowDialogOpen] = useState(false); + const initialState = useSettings(); + + const addedFields = initialState?.defaultAttributes + ? initialState.userAttributes.map((item) => item.label) + : []; + + const fields = [ + "givenName", + "surName", + "displayName", + "mailNickName", + "domain", + "JobTitle", + "streetAddress", + "PostalCode", + "City", + "State", + "Department", + "MobilePhone", + "businessPhones", + ...addedFields, + ]; + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + tenantFilter: initialState.currentTenant, + usageLocation: initialState.usageLocation || "US", + bulkUser: [], + licenses: [], + }, + }); + + const bulkUserData = useWatch({ control: formControl.control, name: "bulkUser" }); + + const createBulkUsers = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: ["Users"], + }); + + // Register the bulkUser field with validation + formControl.register("bulkUser", { + validate: (value) => Array.isArray(value) && value.length > 0, + }); + + const handleRemoveItem = (row) => { + if (row === undefined) return false; + const currentData = formControl.getValues("bulkUser") || []; + const index = currentData.findIndex((item) => item === row); + const newData = [...currentData]; + newData.splice(index, 1); + formControl.setValue("bulkUser", newData, { shouldValidate: true }); + }; + + const handleAddItem = () => { + const newRowData = formControl.getValues("addrow"); + if (newRowData === undefined) return false; + const currentData = formControl.getValues("bulkUser") || []; + const newData = [...currentData, newRowData]; + formControl.setValue("bulkUser", newData, { shouldValidate: true }); + setAddRowDialogOpen(false); + formControl.reset({ + ...formControl.getValues(), + addrow: {}, + }); + }; + + const handleSubmit = () => { + const formData = formControl.getValues(); + createBulkUsers.mutate({ + url: "/api/AddUserBulk", + data: formData, + relatedQueryKeys: ["Users"], + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + tenantFilter: initialState.currentTenant, + usageLocation: initialState.usageLocation || "US", + bulkUser: [], + licenses: [], + }); + }; + + const actions = [ + { + icon: , + label: "Delete Row", + confirmText: "Are you sure you want to delete this row?", + customFunction: handleRemoveItem, + noConfirm: true, + }, + ]; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + + ({ + label: Name, + value: Code, + }))} + formControl={formControl} + /> + + + + + + + + + Download Example CSV + + + + + + + + + + + + + + + + + + + setAddRowDialogOpen(false)} + maxWidth="md" + fullWidth + > + Add a new user + + + {fields.map((field) => ( + + + + ))} + + + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippCADeployDrawer.jsx b/src/components/CippComponents/CippCADeployDrawer.jsx new file mode 100644 index 000000000000..77e604672f9e --- /dev/null +++ b/src/components/CippComponents/CippCADeployDrawer.jsx @@ -0,0 +1,205 @@ +import { useEffect, useState, useCallback } from "react"; +import { Button, Stack } from "@mui/material"; +import { RocketLaunch } from "@mui/icons-material"; +import { useForm, useWatch } from "react-hook-form"; +import { CippOffCanvas } from "./CippOffCanvas"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; +import CippFormComponent from "./CippFormComponent"; +import CippJsonView from "../CippFormPages/CippJSONView"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { CippFormTenantSelector } from "./CippFormTenantSelector"; + +export const CippCADeployDrawer = ({ + buttonText = "Deploy CA Policy", + requiredPermissions = [], + PermissionButton = Button, + templateId = null, // New prop for pre-supplying template ID + open = null, // External control for drawer visibility + onClose = null, // External close handler +}) => { + const [internalDrawerVisible, setInternalDrawerVisible] = useState(false); + const formControl = useForm(); + const tenantFilter = useSettings()?.tenantFilter; + const CATemplates = ApiGetCall({ url: "/api/ListCATemplates", queryKey: "CATemplates" }); + const [JSONData, setJSONData] = useState(); + const watcher = useWatch({ control: formControl.control, name: "TemplateList" }); + + // Use external open state if provided, otherwise use internal state + const drawerVisible = open !== null ? open : internalDrawerVisible; + const isExternallyControlled = open !== null && onClose !== null; + + const updateTemplate = useCallback( + (templateGuid) => { + if (CATemplates.isSuccess && templateGuid) { + const template = CATemplates.data.find((template) => template.GUID === templateGuid); + if (template) { + setJSONData(template); + formControl.setValue("rawjson", JSON.stringify(template, null)); + } + } + }, + [CATemplates.isSuccess, CATemplates.data, formControl.setValue] + ); + + // Effect to set template when templateId prop is provided + useEffect(() => { + if (templateId && CATemplates.isSuccess) { + // Find the template to get the display name + const template = CATemplates.data.find((template) => template.GUID === templateId); + if (template) { + // Pre-select the template when drawer opens + formControl.setValue("TemplateList", { value: templateId, label: template.displayName }); + updateTemplate(templateId); + } + } + }, [templateId, CATemplates.isSuccess, formControl, updateTemplate]); + + useEffect(() => { + updateTemplate(watcher?.value); + }, [updateTemplate, watcher?.value]); + + const deployPolicy = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: ["CATemplates", `Conditional Access Policies - ${tenantFilter}`], + }); + + const handleSubmit = () => { + const formData = formControl.getValues(); + console.log("Submitting CA form data:", formData); + deployPolicy.mutate({ + url: "/api/AddCAPolicy", + relatedQueryKeys: ["CATemplates", "Conditional Access Policies"], + data: { ...formData }, + }); + }; + + const handleCloseDrawer = () => { + if (isExternallyControlled) { + onClose(); + } else { + setInternalDrawerVisible(false); + } + formControl.reset(); + }; + + return ( + <> + {!isExternallyControlled && ( + setInternalDrawerVisible(true)} + startIcon={} + > + {buttonText} + + )} + + + + + } + > + + + + ({ + label: template.displayName, + value: template.GUID, + })) + : [] + } + /> + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippCalendarPermissionsDialog.jsx b/src/components/CippComponents/CippCalendarPermissionsDialog.jsx new file mode 100644 index 000000000000..33f31e21196f --- /dev/null +++ b/src/components/CippComponents/CippCalendarPermissionsDialog.jsx @@ -0,0 +1,114 @@ +import { useEffect } from "react"; +import { Box, Stack, Tooltip } from "@mui/material"; +import CippFormComponent from "./CippFormComponent"; +import { useWatch } from "react-hook-form"; + +const CippCalendarPermissionsDialog = ({ formHook, combinedOptions, isUserGroupLoading }) => { + const permissionLevel = useWatch({ + control: formHook.control, + name: "Permissions", + }); + + const isEditor = permissionLevel?.value === "Editor"; + + useEffect(() => { + if (!isEditor) { + formHook.setValue("CanViewPrivateItems", false); + } + }, [isEditor, formHook]); + + // default SendNotificationToUser to false on mount + useEffect(() => { + formHook.setValue("SendNotificationToUser", false); + }, [formHook]); + + // Only certain permission levels support sending a notification when calendar permissions are added + const notifyAllowed = ["AvailabilityOnly", "LimitedDetails", "Reviewer", "Editor"]; + const isNotifyAllowed = notifyAllowed.includes(permissionLevel?.value ?? permissionLevel); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default CippCalendarPermissionsDialog; diff --git a/src/components/CippComponents/CippCentralSearch.jsx b/src/components/CippComponents/CippCentralSearch.jsx index a3a8c214b5af..ee1c80e2b426 100644 --- a/src/components/CippComponents/CippCentralSearch.jsx +++ b/src/components/CippComponents/CippCentralSearch.jsx @@ -6,13 +6,13 @@ import { DialogContent, DialogTitle, TextField, - Grid, Card, CardContent, CardActionArea, Typography, Box, } from "@mui/material"; +import { Grid } from "@mui/system"; import { useRouter } from "next/router"; import { nativeMenuItems } from "/src/layouts/config"; @@ -111,7 +111,7 @@ export const CippCentralSearch = ({ handleClose, open }) => { filteredItems.length > 0 ? ( {filteredItems.map((item, index) => ( - + handleCardClick(item.path)} diff --git a/src/components/CippComponents/CippCodeBlock.jsx b/src/components/CippComponents/CippCodeBlock.jsx index d1836e9aa66d..507a26667bbd 100644 --- a/src/components/CippComponents/CippCodeBlock.jsx +++ b/src/components/CippComponents/CippCodeBlock.jsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import { useState } from "react"; import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism"; import SyntaxHighlighter from "react-syntax-highlighter"; import { CippCopyToClipBoard } from "./CippCopyToClipboard"; @@ -16,7 +16,7 @@ const CodeContainer = styled("div")` padding-bottom: 1rem; .cipp-code-copy-button { position: absolute; - right: 0.5rem; + right: 1rem; /* Moved further left to avoid Monaco scrollbar */ top: 0.5rem; z-index: 1; /* Ensure the button is above the code block */ } @@ -54,7 +54,7 @@ export const CippCodeBlock = (props) => { options={{ wordWrap: true, lineNumbers: showLineNumbers ? "on" : "off", - minimap: { enabled: showLineNumbers}, + minimap: { enabled: showLineNumbers }, }} {...other} /> diff --git a/src/components/CippComponents/CippComponentDialog.jsx b/src/components/CippComponents/CippComponentDialog.jsx index 9f8c618ad179..03d4d64dcdaa 100644 --- a/src/components/CippComponents/CippComponentDialog.jsx +++ b/src/components/CippComponents/CippComponentDialog.jsx @@ -7,7 +7,7 @@ export const CippComponentDialog = (props) => {
    {title} - {...children} + {children} + + + } + > + + + + + + + + {/* TemplateList */} + + option, + url: "/api/ListContactTemplates", + }} + placeholder="Select a template or enter PowerShell JSON manually" + /> + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippDevOptions.jsx b/src/components/CippComponents/CippDevOptions.jsx index dab86d6c454a..7bddbbbc126a 100644 --- a/src/components/CippComponents/CippDevOptions.jsx +++ b/src/components/CippComponents/CippDevOptions.jsx @@ -1,6 +1,6 @@ import { useSettings } from "../../hooks/use-settings"; import { Button, Card, CardHeader, Divider, CardContent, SvgIcon } from "@mui/material"; -import { CodeBracketIcon, CogIcon } from "@heroicons/react/24/outline"; +import { CodeBracketIcon } from "@heroicons/react/24/outline"; export const CippDevOptions = () => { const settings = useSettings(); diff --git a/src/components/CippComponents/CippDocsLookup.jsx b/src/components/CippComponents/CippDocsLookup.jsx new file mode 100644 index 000000000000..987809b24f2c --- /dev/null +++ b/src/components/CippComponents/CippDocsLookup.jsx @@ -0,0 +1,71 @@ +import { Search } from "@mui/icons-material"; +import { Chip, IconButton, SvgIcon, Tooltip } from "@mui/material"; +import { useState } from "react"; + +export const CippDocsLookup = (props) => { + const { text, type = "button", visible = true, ...other } = props; + const [showPassword, setShowPassword] = useState(false); + + const handleTogglePassword = () => { + setShowPassword((prev) => !prev); + }; + + const handleDocsLookup = () => { + const searchUrl = `https://docs.cipp.app/?q=Help+with:+${encodeURIComponent(text)}&ask=true`; + window.open(searchUrl, '_blank'); + }; + + if (!visible) return null; + + if (type === "button") { + return ( + + + + + + + + ); + } + + if (type === "chip") { + return ( + + + + ); + } + + if (type === "password") { + return ( + <> + + + {showPassword ? : } + + + + + + + ); + } + + return null; +}; \ No newline at end of file diff --git a/src/components/CippComponents/CippDropzone.jsx b/src/components/CippComponents/CippDropzone.jsx index 6e400ef31e2c..72a1e2a6b70c 100644 --- a/src/components/CippComponents/CippDropzone.jsx +++ b/src/components/CippComponents/CippDropzone.jsx @@ -1,4 +1,3 @@ -import React, { useCallback, useMemo, useState } from "react"; import PropTypes from "prop-types"; //import { CippContentCard } from 'src/components/layout' import { useDropzone } from "react-dropzone"; diff --git a/src/components/CippComponents/CippExchangeActions.jsx b/src/components/CippComponents/CippExchangeActions.jsx index cbe8f656e633..1a4be9744d55 100644 --- a/src/components/CippComponents/CippExchangeActions.jsx +++ b/src/components/CippComponents/CippExchangeActions.jsx @@ -2,10 +2,7 @@ import { Archive, MailOutline, - Person, - Room, Visibility, - VisibilityOff, PhonelinkLock, Key, PostAdd, @@ -16,11 +13,136 @@ import { DataUsage, MailLock, SettingsEthernet, + CalendarMonth, + PersonAdd, + Email, } from "@mui/icons-material"; +import { useSettings } from "/src/hooks/use-settings.js"; +import { useMemo } from "react"; export const CippExchangeActions = () => { - // const tenant = useSettings().currentTenant; + const tenant = useSettings().currentTenant; + + // API configuration for all user selection fields + const userApiConfig = useMemo( + () => ({ + url: "/api/ListGraphRequest", + dataKey: "Results", + labelField: (option) => `${option.displayName} (${option.userPrincipalName})`, + valueField: "userPrincipalName", + queryKey: `users-${tenant}`, + data: { + Endpoint: "users", + tenantFilter: tenant, + $select: "id,displayName,userPrincipalName,mail", + $top: 999, + }, + }), + [tenant] + ); + return [ + { + label: "Bulk Add Mailbox Permissions", + type: "POST", + url: "/api/ExecModifyMBPerms", + icon: , + data: { + userID: "UPN", + }, + confirmText: "Add the specified permissions to selected mailboxes?", + multiPost: false, + data: {}, + fields: [ + { + type: "autoComplete", + name: "fullAccessUser", + label: "Add Full Access User", + multiple: true, + creatable: false, + api: userApiConfig, + }, + { + type: "switch", + name: "autoMap", + label: "Enable Automapping", + defaultValue: true, + labelLocation: "behind", + }, + { + type: "autoComplete", + name: "sendAsUser", + label: "Add Send As User", + multiple: true, + creatable: false, + api: userApiConfig, + }, + { + type: "autoComplete", + name: "sendOnBehalfUser", + label: "Add Send On Behalf User", + multiple: true, + creatable: false, + api: userApiConfig, + }, + ], + customDataformatter: (rows, action, formData) => { + const mailboxArray = Array.isArray(rows) ? rows : [rows]; + + // Create bulk request array - one object per mailbox + const bulkRequestData = mailboxArray.map((mailbox) => { + const permissions = []; + const autoMap = formData.autoMap === undefined ? true : formData.autoMap; + + // Add type: "user" to match format + const addTypeToUsers = (users) => { + return users.map((user) => ({ + ...user, + type: "user", + })); + }; + + // Handle FullAccess - formData.fullAccessUser is an array since multiple: true + if (formData.fullAccessUser && formData.fullAccessUser.length > 0) { + permissions.push({ + UserID: addTypeToUsers(formData.fullAccessUser), + PermissionLevel: "FullAccess", + Modification: "Add", + AutoMap: autoMap, + }); + } + + // Handle SendAs - formData.sendAsUser is an array since multiple: true + if (formData.sendAsUser && formData.sendAsUser.length > 0) { + permissions.push({ + UserID: addTypeToUsers(formData.sendAsUser), + PermissionLevel: "SendAs", + Modification: "Add", + }); + } + + // Handle SendOnBehalf - formData.sendOnBehalfUser is an array since multiple: true + if (formData.sendOnBehalfUser && formData.sendOnBehalfUser.length > 0) { + permissions.push({ + UserID: addTypeToUsers(formData.sendOnBehalfUser), + PermissionLevel: "SendOnBehalf", + Modification: "Add", + }); + } + + return { + userID: mailbox.UPN, + permissions: permissions, + }; + }); + + return { + mailboxRequests: bulkRequestData, + tenantFilter: tenant, + }; + }, + color: "primary", + }, { label: "Edit permissions", link: "/identity/administration/users/user/exchange?userId=[ExternalDirectoryObjectId]", @@ -40,56 +162,87 @@ export const CippExchangeActions = () => { data: { UserEmail: "UPN", }, - confirmText: "Are you sure you want to send an MFA request?", + confirmText: "Are you sure you want to send an MFA request to [UPN]?", icon: , }, { - label: "Convert to User Mailbox", - type: "POST", - url: "/api/ExecConvertMailbox", - icon: , - data: { - ID: "UPN", - MailboxType: "!Regular", - }, - confirmText: "Are you sure you want to convert this mailbox to a user mailbox?", - condition: (row) => row.recipientTypeDetails !== "UserMailbox", - }, - { - label: "Convert to Shared Mailbox", - type: "POST", - icon: , - url: "/api/ExecConvertMailbox", - data: { - ID: "UPN", - MailboxType: "!Shared", - }, - confirmText: "Are you sure you want to convert this mailbox to a shared mailbox?", - condition: (row) => row.recipientTypeDetails !== "SharedMailbox", - }, - { - label: "Convert to Room Mailbox", + label: "Convert Mailbox", type: "POST", + icon: , url: "/api/ExecConvertMailbox", - icon: , - data: { - ID: "UPN", - MailboxType: "!Room", - }, - confirmText: "Are you sure you want to convert this mailbox to a room mailbox?", - condition: (row) => row.recipientTypeDetails !== "RoomMailbox", + data: { ID: "UPN" }, + fields: [ + { + type: "radio", + name: "MailboxType", + label: "Mailbox Type", + options: [ + { label: "User Mailbox", value: "Regular" }, + { label: "Shared Mailbox", value: "Shared" }, + { label: "Room Mailbox", value: "Room" }, + { label: "Equipment Mailbox", value: "Equipment" }, + ], + validators: { required: "Please select a mailbox type" }, + }, + ], + confirmText: + "Pick the type of mailbox you want to convert [UPN] of mailbox type [recipientTypeDetails] to:", + multiPost: false, }, { - //tested label: "Enable Online Archive", type: "POST", icon: , url: "/api/ExecEnableArchive", data: { ID: "Id", username: "UPN" }, - confirmText: "Are you sure you want to enable the online archive for this user?", + confirmText: "Are you sure you want to enable the online archive for [UPN]?", multiPost: false, condition: (row) => row.ArchiveGuid === "00000000-0000-0000-0000-000000000000", }, + { + label: "Set Retention Policy", + type: "POST", + url: "/api/ExecSetMailboxRetentionPolicies", + icon: , + confirmText: "Set the specified retention policy for selected mailboxes?", + multiPost: false, + fields: [ + { + type: "autoComplete", + name: "policyName", + label: "Retention Policy", + multiple: false, + creatable: false, + validators: { required: "Please select a retention policy" }, + api: { + url: "/api/ExecManageRetentionPolicies", + labelField: "Name", + valueField: "Name", + queryKey: `RetentionPolicies-${tenant}`, + data: { + tenantFilter: tenant, + }, + }, + }, + ], + customDataformatter: (rows, action, formData) => { + const mailboxArray = Array.isArray(rows) ? rows : [rows]; + + // Extract mailbox identities - using UPN as the identifier + const mailboxes = mailboxArray.map((mailbox) => mailbox.UPN); + + // Handle autocomplete selection - could be string or object + const policyName = + typeof formData.policyName === "object" ? formData.policyName.value : formData.policyName; + + return { + PolicyName: policyName, + Mailboxes: mailboxes, + tenantFilter: tenant, + }; + }, + color: "primary", + }, { label: "Enable Auto-Expanding Archive", type: "POST", @@ -97,35 +250,32 @@ export const CippExchangeActions = () => { url: "/api/ExecEnableAutoExpandingArchive", data: { ID: "Id", username: "UPN" }, confirmText: - "Are you sure you want to enable auto-expanding archive for this user? The archive must already be enabled.", + "Are you sure you want to enable auto-expanding archive for [UPN]? The archive must already be enabled.", multiPost: false, condition: (row) => row.ArchiveGuid !== "00000000-0000-0000-0000-000000000000", }, { - label: "Hide from Global Address List", - type: "POST", - url: "/api/ExecHideFromGAL", - icon: , - data: { - ID: "UPN", - HidefromGAL: true, - }, - confirmText: - "Are you sure you want to hide this mailbox from the global address list? This will not work if the user is AD Synced.", - condition: (row) => row.HiddenFromAddressListsEnabled === false, - }, - { - label: "Unhide from Global Address List", + label: "Set Global Address List visibility", type: "POST", url: "/api/ExecHideFromGAL", icon: , data: { ID: "UPN", - HidefromGAL: false, }, + fields: [ + { + type: "radio", + name: "HidefromGAL", + label: "Global Address List visibility", + options: [ + { label: "Hidden", value: true }, + { label: "Shown", value: false }, + ], + validators: { required: "Please select a global address list state" }, + }, + ], confirmText: - "Are you sure you want to unhide this mailbox from the global address list? This will not work if the user is AD Synced.", - condition: (row) => row.HiddenFromAddressListsEnabled === true, + "Are you sure you want to set the global address list state for [UPN]? Changes can take up to 72 hours to take effect.", }, { label: "Start Managed Folder Assistant", @@ -136,7 +286,7 @@ export const CippExchangeActions = () => { ID: "ExchangeGuid", UserPrincipalName: "UPN", }, - confirmText: "Are you sure you want to start the managed folder assistant for this user?", + confirmText: "Are you sure you want to start the managed folder assistant for [UPN]?", }, { label: "Delete Mailbox", @@ -144,28 +294,28 @@ export const CippExchangeActions = () => { icon: , url: "/api/RemoveUser", data: { ID: "UPN" }, - confirmText: "Are you sure you want to delete this mailbox?", + confirmText: "Are you sure you want to delete [UPN]?", multiPost: false, }, { - label: "Copy Sent Items to Shared Mailbox", + label: "Set Copy Sent Items for Delegated Mailboxes", type: "POST", - url: "/api/ExecCopyForSent", - data: { ID: "UPN", MessageCopyForSentAsEnabled: true }, - confirmText: "Are you sure you want to enable Copy Sent Items to Shared Mailbox?", icon: , - condition: (row) => - row.MessageCopyForSentAsEnabled === false && row.recipientTypeDetails === "SharedMailbox", - }, - { - label: "Disable Copy Sent Items to Shared Mailbox", - type: "POST", url: "/api/ExecCopyForSent", - data: { ID: "UPN", MessageCopyForSentAsEnabled: false }, - confirmText: "Are you sure you want to disable Copy Sent Items to Shared Mailbox?", - icon: , - condition: (row) => - row.MessageCopyForSentAsEnabled === true && row.recipientTypeDetails === "SharedMailbox", + data: { ID: "UPN" }, + fields: [ + { + type: "radio", + name: "MessageCopyForSentAsEnabled", + label: "Copy Sent Items", + options: [ + { label: "Enabled", value: true }, + { label: "Disabled", value: false }, + ], + validators: { required: "Please select a copy sent items state" }, + }, + ], + confirmText: "Are you sure you want to set Copy Sent Items for [UPN]?", }, { label: "Set Litigation Hold", @@ -217,6 +367,7 @@ export const CippExchangeActions = () => { name: "locale", type: "textField", placeholder: "e.g. en-US", + validators: { required: "Please enter a locale" }, }, ], }, @@ -255,6 +406,7 @@ export const CippExchangeActions = () => { name: "quota", type: "textField", placeholder: "e.g. 1000MB, 10GB,1TB", + validators: { required: "Please enter a quota" }, }, ], }, @@ -274,6 +426,7 @@ export const CippExchangeActions = () => { name: "quota", type: "textField", placeholder: "e.g. 1000MB, 10GB,1TB", + validators: { required: "Please enter a quota" }, }, ], }, @@ -290,6 +443,110 @@ export const CippExchangeActions = () => { name: "quota", type: "textField", placeholder: "e.g. 1000MB, 10GB,1TB", + validators: { required: "Please enter a quota" }, + }, + ], + }, + { + label: "Set Calendar Processing", + type: "POST", + url: "/api/ExecSetCalendarProcessing", + data: { UPN: "UPN" }, + confirmText: "Configure calendar processing settings for [UPN]", + icon: , + condition: (row) => + row.recipientTypeDetails === "RoomMailbox" || + row.recipientTypeDetails === "EquipmentMailbox", + fields: [ + { + label: "Automatically Process Meeting Requests", + name: "automaticallyProcess", + type: "switch", + }, + { + label: "Automatically Accept Meeting Requests", + name: "automaticallyAccept", + type: "switch", + }, + { + label: "Allow Conflicts", + name: "allowConflicts", + type: "switch", + }, + { + label: "Maximum Number of Conflicts", + name: "maxConflicts", + type: "number", + placeholder: "e.g. 2", + }, + { + label: "Allow Recurring Meetings", + name: "allowRecurringMeetings", + type: "switch", + }, + { + label: "Schedule Only During Work Hours", + name: "scheduleOnlyDuringWorkHours", + type: "switch", + }, + { + label: "Maximum Duration (Minutes)", + name: "maximumDurationInMinutes", + type: "number", + placeholder: "e.g. 240", + }, + { + label: "Minimum Duration (Minutes)", + name: "minimumDurationInMinutes", + type: "number", + placeholder: "e.g. 30", + }, + { + label: "Booking Window (Days)", + name: "bookingWindowInDays", + type: "number", + placeholder: "e.g. 30", + }, + { + label: "Add Organizer to Subject", + name: "addOrganizerToSubject", + type: "switch", + }, + { + label: "Delete Comments", + name: "deleteComments", + type: "switch", + }, + { + label: "Delete Subject", + name: "deleteSubject", + type: "switch", + }, + { + label: "Remove Private Property", + name: "removePrivateProperty", + type: "switch", + }, + { + label: "Remove Canceled Meetings", + name: "removeCanceledMeetings", + type: "switch", + }, + { + label: "Remove Old Meeting Messages", + name: "removeOldMeetingMessages", + type: "switch", + }, + { + label: "Process External Meeting Messages", + name: "processExternalMeetingMessages", + type: "switch", + }, + { + label: "Additional Response", + name: "additionalResponse", + type: "textField", + placeholder: "Additional text to add to responses", }, ], }, diff --git a/src/components/CippComponents/CippFolderNavigation.jsx b/src/components/CippComponents/CippFolderNavigation.jsx new file mode 100644 index 000000000000..5904a70b06b1 --- /dev/null +++ b/src/components/CippComponents/CippFolderNavigation.jsx @@ -0,0 +1,428 @@ +import { useState, useMemo } from "react"; +import { + Box, + Typography, + List, + ListItem, + ListItemIcon, + ListItemText, + ListItemButton, + Breadcrumbs, + Link, + Stack, + TextField, + InputAdornment, + IconButton, + Chip, + Slide, + Button, +} from "@mui/material"; +import { + Folder, + InsertDriveFile, + Search, + Clear, + NavigateNext, + Home, + Visibility, + SubdirectoryArrowLeft, +} from "@mui/icons-material"; +import { alpha, styled } from "@mui/material/styles"; + +const StyledListItem = styled(ListItemButton)(({ theme }) => ({ + borderRadius: theme.shape.borderRadius, + margin: theme.spacing(0.25, 0), + padding: theme.spacing(1, 2), + "&:hover": { + backgroundColor: alpha(theme.palette.primary.main, 0.08), + }, + "&.Mui-selected": { + backgroundColor: alpha(theme.palette.primary.main, 0.12), + "&:hover": { + backgroundColor: alpha(theme.palette.primary.main, 0.16), + }, + }, +})); + +const FileListItem = styled(Box)(({ theme }) => ({ + padding: theme.spacing(1, 2), + border: `1px solid ${theme.palette.divider}`, +})); + +const NavigationContainer = styled(Box)(({ theme }) => ({ + position: "relative", + overflow: "hidden", + height: "100%", + minHeight: 400, + backgroundColor: theme.palette.background.paper, + borderRadius: theme.shape.borderRadius, + border: `1px solid ${theme.palette.divider}`, + display: "flex", + flexDirection: "column", +})); + +const SlideView = styled(Box)(({ theme }) => ({ + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: theme.palette.background.paper, + display: "flex", + flexDirection: "column", +})); + +export const CippFolderNavigation = ({ + data = [], + onFileSelect, + selectedFile = null, + searchable = true, + showFileInfo = true, + onImportFile, + onViewFile, + isImporting = false, +}) => { + const [currentPath, setCurrentPath] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + const [slideDirection, setSlideDirection] = useState("left"); + + // Build folder structure from flat file list + const folderStructure = useMemo(() => { + const structure = { folders: {}, files: [] }; + + data.forEach((file) => { + const pathParts = file.path.split("/"); + let current = structure; + + // Build folder hierarchy + for (let i = 0; i < pathParts.length - 1; i++) { + const folderName = pathParts[i]; + if (!current.folders[folderName]) { + current.folders[folderName] = { + folders: {}, + files: [], + name: folderName, + path: pathParts.slice(0, i + 1).join("/"), + }; + } + current = current.folders[folderName]; + } + + // Add file to the final folder + current.files.push({ + ...file, + name: pathParts[pathParts.length - 1], + }); + }); + + return structure; + }, [data]); + + // Get current folder based on currentPath + const getCurrentFolder = () => { + let current = folderStructure; + for (const pathPart of currentPath) { + current = current.folders[pathPart]; + if (!current) break; + } + return current || { folders: {}, files: [] }; + }; + + // Filter files based on search term (only when searching) + const getFilteredContent = () => { + if (!searchTerm) { + return getCurrentFolder(); + } + + // When searching, show all matching files across all folders + const allFiles = data.filter((file) => + file.path.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return { + folders: {}, + files: allFiles.map((file) => ({ + ...file, + name: file.path.split("/").pop(), + })), + }; + }; + + const currentFolder = getFilteredContent(); + + const navigateToFolder = (folderName) => { + setSlideDirection("left"); + setCurrentPath((prev) => [...prev, folderName]); + }; + + const navigateBack = () => { + if (currentPath.length > 0) { + setSlideDirection("right"); + setCurrentPath((prev) => prev.slice(0, -1)); + } + }; + + const navigateTo = (index) => { + if (index < currentPath.length) { + const direction = index < currentPath.length - 1 ? "right" : "left"; + setSlideDirection(direction); + setCurrentPath((prev) => prev.slice(0, index + 1)); + } else if (index === -1) { + setSlideDirection("right"); + setCurrentPath([]); + } + }; + + const handleFileClick = (file) => { + if (onFileSelect) { + onFileSelect(file); + } + }; + + const formatFileSize = (bytes) => { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; + }; + + const getFileIcon = (fileName) => { + return ; + }; + + const clearSearch = () => { + setSearchTerm(""); + }; + + const folders = Object.values(currentFolder.folders || {}); + const files = currentFolder.files || []; + + return ( + + {searchable && ( + + setSearchTerm(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchTerm && ( + + + + + + ), + }} + /> + + )} + + + + + {/* Header with navigation */} + + {searchTerm ? ( + Search Results ({files.length}) + ) : ( + } + sx={{ fontSize: "0.875rem" }} + > + navigateTo(-1)} + sx={{ + display: "flex", + alignItems: "center", + textDecoration: "none", + "&:hover": { textDecoration: "underline" }, + }} + > + + + {currentPath.map((folder, index) => ( + navigateTo(index)} + sx={{ + textDecoration: "none", + "&:hover": { textDecoration: "underline" }, + }} + > + {folder} + + ))} + + )} + + + {/* Content */} + + + {/* Show ".." folder for navigation back when not at root and not searching */} + {!searchTerm && currentPath.length > 0 && ( + + + + + + + + Parent folder + + + } + /> + + + )} + + {/* Show folders first (only when not searching) */} + {!searchTerm && + folders.map((folder) => ( + navigateToFolder(folder.name)}> + + + + + {folder.name} + {folder.files.length > 0 && ( + + )} + + } + /> + + + ))} + + {/* Show files */} + {files.map((file) => ( + + + {/* File Icon and Info */} + handleFileClick(file)} + > + + {getFileIcon(file.name)} + + + + {file.name} + + + {searchTerm && ( + <> + + {file.path.substring(0, file.path.lastIndexOf("/")) || "root"} + + + β€’ + + + )} + {showFileInfo && ( + + {formatFileSize(file.size)} + + )} + + + + + {/* Action Buttons */} + + + + + + + ))} + + {/* Empty state */} + {folders.length === 0 && files.length === 0 && ( + + + {searchTerm + ? `No files found matching "${searchTerm}"` + : "This folder is empty"} + + + )} + + + + + + + {!searchTerm && data.length === 0 && ( + + + No files available + + + )} + + ); +}; diff --git a/src/components/CippComponents/CippFormComponent.jsx b/src/components/CippComponents/CippFormComponent.jsx index 02f6d9a50cbc..5d613bc10c74 100644 --- a/src/components/CippComponents/CippFormComponent.jsx +++ b/src/components/CippComponents/CippFormComponent.jsx @@ -8,8 +8,12 @@ import { FormControl, FormLabel, RadioGroup, + Button, + Box, + Input, } from "@mui/material"; import { CippAutoComplete } from "./CippAutocomplete"; +import { CippTextFieldWithVariables } from "./CippTextFieldWithVariables"; import { Controller, useFormState } from "react-hook-form"; import { DateTimePicker } from "@mui/x-date-pickers"; // Make sure to install @mui/x-date-pickers import CSVReader from "../CSVReader"; @@ -25,9 +29,13 @@ import { import StarterKit from "@tiptap/starter-kit"; import { CippDataTable } from "../CippTable/CippDataTable"; import React from "react"; +import { CloudUpload } from "@mui/icons-material"; +import { Stack } from "@mui/system"; // Helper function to convert bracket notation to dot notation +// Improved to correctly handle nested bracket notations const convertBracketsToDots = (name) => { + if (!name) return ""; return name.replace(/\[(\d+)\]/g, ".$1"); // Replace [0] with .0 }; @@ -44,6 +52,9 @@ export const CippFormComponent = (props) => { label, labelLocation = "behind", // Default location for switches defaultValue, + helperText, + disableVariables = false, + includeSystemVariables = false, ...other } = props; const { errors } = useFormState({ control: formControl.control }); @@ -113,21 +124,85 @@ export const CippFormComponent = (props) => { return ( <>
    - + !disableVariables ? ( + + ) : ( + + ) + } />
    {get(errors, convertedName, {})?.message} + {helperText && ( + + {helperText} + + )} + + ); + case "textFieldWithVariables": + return ( + <> +
    + ( + + )} + /> +
    + + {get(errors, convertedName, {})?.message} + + {helperText && ( + + {helperText} + + )} ); case "password": @@ -138,14 +213,23 @@ export const CippFormComponent = (props) => { type="password" variant="filled" fullWidth + InputLabelProps={{ + shrink: true, + }} {...other} {...formControl.register(convertedName, { ...validators })} label={label} + defaultValue={defaultValue} /> {get(errors, convertedName, {})?.message} + {helperText && ( + + {helperText} + + )} ); case "number": @@ -167,6 +251,11 @@ export const CippFormComponent = (props) => { {get(errors, convertedName, {})?.message} + {helperText && ( + + {helperText} + + )} ); @@ -177,11 +266,11 @@ export const CippFormComponent = (props) => { renderSwitchWithLabel( @@ -192,9 +281,9 @@ export const CippFormComponent = (props) => { {get(errors, convertedName, {})?.message} - {other.helperText && ( + {helperText && ( - {other.helperText} + {helperText} )} @@ -217,23 +306,39 @@ export const CippFormComponent = (props) => { return ( <> - {label} + + + {label} + {helperText && ( + + {helperText} + + )} + + ( - - {props.options.map((option, idx) => ( - } - label={option.label} - /> - ))} - - )} + render={({ field }) => { + return ( + field.onChange(e.target.value)} + {...other} + > + {props.options.map((option, idx) => ( + } + label={option.label} + /> + ))} + + ); + }} /> @@ -263,39 +368,50 @@ export const CippFormComponent = (props) => { )} /> - - {get(errors, convertedName, {}).message} - + {get(errors, convertedName, {})?.message && ( + + {get(errors, convertedName, {})?.message} + + )} ); case "autoComplete": return ( - <> -
    - ( - field.onChange(value)} - /> - )} - /> -
    - - {get(errors, convertedName, {}).message} - - +
    + ( + field.onChange(value)} + /> + )} + /> + + {get(errors, convertedName, {})?.message && ( + + {get(errors, convertedName, {})?.message} + + )} + {helperText && ( + + {helperText} + + )} +
    ); - case "richText": + case "richText": { + const editorInstanceRef = React.useRef(null); + const lastSetValue = React.useRef(null); + return ( <>
    @@ -303,30 +419,56 @@ export const CippFormComponent = (props) => { name={convertedName} control={formControl.control} rules={validators} - render={({ field }) => ( - <> - {label} - { - field.onChange(editor.getHTML()); - }} - label={label} - renderControls={() => ( - - - - - - - )} - /> - - )} + render={({ field }) => { + const { value, onChange, ref } = field; + + // Update content when value changes externally + React.useEffect(() => { + if ( + editorInstanceRef.current && + typeof value === "string" && + value !== lastSetValue.current + ) { + editorInstanceRef.current.commands.setContent(value || "", false); + lastSetValue.current = value; + } + }, [value]); + + return ( + <> + {label} + { + editorInstanceRef.current = editor; + // Set initial content when editor is created + if (typeof value === "string") { + editor.commands.setContent(value || "", false); + lastSetValue.current = value; + } + }} + onUpdate={({ editor }) => { + const newValue = editor.getHTML(); + lastSetValue.current = newValue; + onChange(newValue); + }} + label={label} + renderControls={() => ( + + + + + + + )} + /> + + ); + }} />
    @@ -334,7 +476,7 @@ export const CippFormComponent = (props) => { ); - + } case "CSVReader": const remapData = (data, nameToCSVMapping) => { if (nameToCSVMapping && data) { @@ -398,47 +540,152 @@ export const CippFormComponent = (props) => { control={formControl.control} rules={validators} render={({ field }) => ( - { - if (date) { - const unixTimestamp = Math.floor(date.getTime() / 1000); // Convert to Unix timestamp - field.onChange(unixTimestamp); // Pass the Unix timestamp to the form - } else { - field.onChange(null); // Handle the case where no date is selected - } - }} - ampm={false} - minutesStep={15} - inputFormat="yyyy/MM/dd HH:mm" // Display format - renderInput={(inputProps) => ( - + + { + if (date) { + const unixTimestamp = Math.floor(date.getTime() / 1000); // Convert to Unix timestamp + field.onChange(unixTimestamp); // Pass the Unix timestamp to the form + } else { + field.onChange(null); // Handle the case where no date is selected + } + }} + ampm={false} + minutesStep={15} + inputFormat="yyyy/MM/dd HH:mm" // Display format + renderInput={(inputProps) => ( + + )} {...other} - fullWidth - error={!!errors[convertedName]} - helperText={get(errors, convertedName, {})?.message} - variant="filled" /> - )} - {...other} - /> + + + )} /> + {helperText && ( + + {helperText} + + )} {get(errors, convertedName, {})?.message} ); + case "file": + return ( + <> +
    + ( + + + {label} + + document.getElementById(`file-input-${convertedName}`).click()} + > + + + {field.value ? field.value.name : "Click to upload file or drag and drop"} + + {field.value && ( + + Size: {(field.value.size / 1024).toFixed(2)} KB + + )} + + { + const file = e.target.files[0]; + field.onChange(file); + if (other.onChange) { + other.onChange(file); + } + }} + /> + + )} + /> +
    + + {get(errors, convertedName, {})?.message} + + {helperText && ( + + {helperText} + + )} + + ); + default: return null; } diff --git a/src/components/CippComponents/CippFormCondition.jsx b/src/components/CippComponents/CippFormCondition.jsx index dd2f87997c8c..cd310be40d99 100644 --- a/src/components/CippComponents/CippFormCondition.jsx +++ b/src/components/CippComponents/CippFormCondition.jsx @@ -1,6 +1,7 @@ import { useWatch } from "react-hook-form"; import isEqual from "lodash/isEqual"; // lodash for deep comparison -import React from "react"; +import get from "lodash/get"; // Add lodash get for safer property access +import React, { useEffect } from "react"; // Added useEffect export const CippFormCondition = (props) => { let { @@ -13,35 +14,213 @@ export const CippFormCondition = (props) => { formControl, disabled = false, } = props; + if ( field === undefined || compareValue === undefined || children === undefined || formControl === undefined ) { + console.warn("CippFormCondition: Missing required props", { + field, + compareValue, + children, + formControl, + }); return null; } - let watcher = useWatch({ control: formControl.control, name: field }); + // Convert bracket notation to dot notation for array fields if needed + const normalizedField = field.replace(/\[(\d+)\]/g, ".$1"); + + // Watch the form field value + const watcher = useWatch({ + control: formControl.control, + name: normalizedField, + }); + + // Safer property access with get for nested paths + let watchedValue = watcher; + let compareTargetValue = compareValue; + + if (propertyName && propertyName !== "value") { + watchedValue = get(watcher, propertyName); + // Only extract from compareValue if it's an object, otherwise use as-is + if (typeof compareValue === "object" && compareValue !== null) { + compareTargetValue = get(compareValue, propertyName); + } else { + compareTargetValue = compareValue; + } + } + + /*console.log("CippFormCondition: ", { + watcher, + watchedValue, + compareTargetValue, + compareType, + compareValue, + action, + field, + propertyName, + });*/ + + // Function to recursively extract field names from child components + const extractFieldNames = (children) => { + const fieldNames = []; + + React.Children.forEach(children, (child) => { + if (!React.isValidElement(child)) return; - if (propertyName.includes(".")) { - propertyName.split(".").forEach((prop) => { - if (watcher?.[prop] !== undefined) { - watcher = watcher?.[prop]; + // Check if the child is a CippFormComponent with a name prop + if (child.props?.name && child.type?.name === "CippFormComponent") { + fieldNames.push(child.props.name); } - if (compareValue?.[prop] !== undefined) { - compareValue = compareValue?.[prop]; + + // Check if child has nested children + if (child.props?.children) { + fieldNames.push(...extractFieldNames(child.props.children)); } }); - } else { - if (watcher?.[propertyName] !== undefined) { - watcher = watcher?.[propertyName]; + + return fieldNames; + }; + + // Function to check if the condition is met + const isConditionMet = () => { + switch (compareType) { + case "regex": + return watcher?.match?.(new RegExp(compareValue)); + case "is": + return isEqual(watchedValue, compareTargetValue); + case "isNot": + return !isEqual(watchedValue, compareTargetValue); + case "contains": + if (Array.isArray(watcher)) { + return watcher.some((item) => isEqual(item, compareValue)); + } else if (typeof watcher === "string") { + return watcher.includes(compareValue); + } else if (typeof watcher === "object" && watcher !== null) { + // Handle checking if object contains value or key + if (typeof compareValue === "string") { + // Check for "value" property containing the string + if (watcher.value && typeof watcher.value === "string") { + return watcher.value.includes(compareValue); + } + // Check for "label" property containing the string + if (watcher.label && typeof watcher.label === "string") { + return watcher.label.includes(compareValue); + } + // Check if object has the compareValue as a key + return compareValue in watcher; + } else { + return Object.values(watcher).some((val) => isEqual(val, compareValue)); + } + } + return false; + case "doesNotContain": + if (watcher === undefined || watcher === null) { + return true; + } else if (Array.isArray(watcher)) { + return !watcher.some((item) => isEqual(item, compareValue)); + } else if (typeof watcher === "string") { + return !watcher.includes(compareValue); + } else if (typeof watcher === "object") { + if (typeof compareValue === "string") { + return !(compareValue in watcher); + } else { + return !Object.values(watcher).some((val) => isEqual(val, compareValue)); + } + } + return true; + case "greaterThan": + return ( + typeof watcher === "number" && typeof compareValue === "number" && watcher > compareValue + ); + case "lessThan": + return ( + typeof watcher === "number" && typeof compareValue === "number" && watcher < compareValue + ); + case "arrayLength": + return ( + Array.isArray(watcher) && + typeof compareValue === "number" && + watcher.length >= compareValue + ); + case "hasValue": + return ( + (watcher !== undefined && watcher !== null && watcher !== "") || + (watcher?.value !== undefined && watcher?.value !== null && watcher?.value !== "") + ); + case "labelEq": + return Array.isArray(watcher) && watcher.some((item) => item?.label === compareValue); + case "labelContains": + return ( + Array.isArray(watcher) && + watcher.some( + (item) => typeof item?.label === "string" && item.label.includes(compareValue) + ) + ); + case "valueEq": + if (Array.isArray(watcher)) { + return watcher.some((item) => item?.value === compareValue); + } else if (typeof watcher === "object" && watcher !== null) { + return watcher?.value === compareValue; + } + return false; + case "valueNotEq": + if (Array.isArray(watcher)) { + return watcher.some((item) => item?.value !== compareValue); + } else if (typeof watcher === "object" && watcher !== null) { + return watcher?.value !== compareValue; + } + return false; + case "valueContains": + return ( + Array.isArray(watcher) && + watcher.some( + (item) => typeof item?.value === "string" && item.value.includes(compareValue) + ) + ); + case "isOneOf": + // Check if the watched value is one of the values in the compareValue array + if (!Array.isArray(compareValue)) { + console.warn( + "CippFormCondition: isOneOf compareType requires compareValue to be an array" + ); + return false; + } + return compareValue.some((value) => isEqual(watchedValue, value)); + case "isNotOneOf": + // Check if the watched value is NOT one of the values in the compareValue array + if (!Array.isArray(compareValue)) { + console.warn( + "CippFormCondition: isNotOneOf compareType requires compareValue to be an array" + ); + return false; + } + return !compareValue.some((value) => isEqual(watchedValue, value)); + default: + return false; } + }; - if (compareValue?.[propertyName] !== undefined) { - compareValue = compareValue?.[propertyName]; + // Reset field values when condition is not met and action is "hide" + useEffect(() => { + if (action === "hide" && !isConditionMet()) { + const fieldNames = extractFieldNames(children); + + // Reset each field + fieldNames.forEach((fieldName) => { + // Don't reset if the field doesn't exist in the form + if (formControl.getValues(fieldName) !== undefined) { + formControl.setValue(fieldName, null, { + shouldValidate: false, + shouldDirty: false, + }); + } + }); } - } + }, [watcher, action]); const disableChildren = (children) => { return React.Children.map(children, (child) => { @@ -63,185 +242,14 @@ export const CippFormCondition = (props) => { return disableChildren(children); } - switch (compareType) { - case "regex": - if (watcher?.match(new RegExp(compareValue))) { - return children; - } - if (action === "disable") { - return disableChildren(children); - } - return null; - case "is": - // Deep comparison for objects and arrays - if (isEqual(watcher, compareValue)) { - return children; - } - if (action === "disable") { - return disableChildren(children); - } - return null; - - case "isNot": - // Deep comparison for objects and arrays (negation) - if (!isEqual(watcher, compareValue)) { - return children; - } - if (action === "disable") { - return disableChildren(children); - } - return null; - - case "contains": - if (Array.isArray(watcher)) { - if (watcher.includes(compareValue)) { - return children; - } - } else if (typeof watcher === "string") { - if (watcher.includes(compareValue)) { - return children; - } - } else if (typeof watcher === "object" && watcher !== null && compareValue in watcher) { - // Check if object contains the key - return children; - } - if (action === "disable") { - return disableChildren(children); - } - return null; - - case "doesNotContain": - if (Array.isArray(watcher)) { - if (!watcher.includes(compareValue)) { - return children; - } - } else if (typeof watcher === "string") { - if (!watcher.includes(compareValue)) { - return children; - } - //extra elsseif; if the value is undefined or null, return children because it does not contain the compareValue - } else if (watcher === undefined || watcher === null) { - return children; - } else if (typeof watcher === "object" && !(compareValue in watcher)) { - // Check if object does not contain the key - return children; - } - if (action === "disable") { - return disableChildren(children); - } - return null; - - case "greaterThan": - if ( - typeof watcher === "number" && - typeof compareValue === "number" && - watcher > compareValue - ) { - return children; - } - if (action === "disable") { - return disableChildren(children); - } - return null; - - case "lessThan": - if ( - typeof watcher === "number" && - typeof compareValue === "number" && - watcher < compareValue - ) { - return children; - } - if (action === "disable") { - return disableChildren(children); - } - return null; - - case "arrayLength": - if ( - Array.isArray(watcher) && - typeof compareValue === "number" && - watcher.length >= compareValue - ) { - return children; - } - if (action === "disable") { - return disableChildren(children); - } - return null; + // Return based on condition check + const conditionMet = isConditionMet(); - case "hasValue": - if (watcher !== undefined && watcher !== null && watcher !== "") { - return children; - } - if (action === "disable") { - return disableChildren(children); - } - return null; - - /* - * NEW CASES - */ - case "labelEq": - // Checks if any object in array has .label exactly equal to compareValue - if (Array.isArray(watcher) && watcher.some((item) => item?.label === compareValue)) { - return children; - } - if (action === "disable") { - return disableChildren(children); - } - return null; - - case "labelContains": - // Checks if any object in array has a .label that contains compareValue - if ( - Array.isArray(watcher) && - watcher.some((item) => typeof item?.label === "string" && item.label.includes(compareValue)) - ) { - return children; - } - if (action === "disable") { - return disableChildren(children); - } - return null; - - case "valueEq": - // Checks if any object in array has .value exactly equal to compareValue - if (Array.isArray(watcher) && watcher.some((item) => item?.value === compareValue)) { - return children; - } - if (action === "disable") { - return disableChildren(children); - } - return null; - - case "valueNotEq": - // Checks if any object in array has .value exactly equal to compareValue - if (Array.isArray(watcher) && watcher.some((item) => item?.value !== compareValue)) { - return children; - } - if (action === "disable") { - return disableChildren(children); - } - return null; - - case "valueContains": - // Checks if any object in array has a .value that contains compareValue - if ( - Array.isArray(watcher) && - watcher.some((item) => typeof item?.value === "string" && item.value.includes(compareValue)) - ) { - return children; - } - if (action === "disable") { - return disableChildren(children); - } - return null; - - default: - if (action === "disable") { - return disableChildren(children); - } - return null; + if (conditionMet) { + return children; + } else if (action === "disable") { + return disableChildren(children); + } else { + return null; } }; diff --git a/src/components/CippComponents/CippFormContactSelector.jsx b/src/components/CippComponents/CippFormContactSelector.jsx index d912bc526be5..ec79ea8424bd 100644 --- a/src/components/CippComponents/CippFormContactSelector.jsx +++ b/src/components/CippComponents/CippFormContactSelector.jsx @@ -1,4 +1,3 @@ -import React from "react"; import { CippFormComponent } from "./CippFormComponent"; import { useWatch } from "react-hook-form"; import { useSettings } from "../../hooks/use-settings"; @@ -13,6 +12,7 @@ export const CippFormContactSelector = ({ select, addedField, valueField, + dataFilter = null, ...other }) => { const currentTenant = useWatch({ control: formControl.control, name: "tenantFilter" }); @@ -28,9 +28,18 @@ export const CippFormContactSelector = ({ addedField: addedField, tenantFilter: currentTenant ? currentTenant.value : selectedTenant, url: "/api/ListContacts", - labelField: (option) => `${option.displayName} (${option.mail})`, - valueField: valueField ? valueField : "id", + labelField: (option) => + `${option.displayName || option.DisplayName} (${ + option.mail || option.WindowsEmailAddress + })`, + valueField: valueField ? valueField : "WindowsEmailAddress" || "mail", queryKey: `listcontacts-${currentTenant?.value ? currentTenant.value : selectedTenant}`, + dataFilter: (options) => { + if (dataFilter) { + return options.filter(dataFilter); + } + return options; + }, }} creatable={false} {...other} diff --git a/src/components/CippComponents/CippFormDomainSelector.jsx b/src/components/CippComponents/CippFormDomainSelector.jsx index f22a5f74e884..8d9cbea7dca2 100644 --- a/src/components/CippComponents/CippFormDomainSelector.jsx +++ b/src/components/CippComponents/CippFormDomainSelector.jsx @@ -1,7 +1,7 @@ -import React from "react"; import { CippFormComponent } from "./CippFormComponent"; import { useWatch } from "react-hook-form"; import { useSettings } from "../../hooks/use-settings"; +import { useMemo } from "react"; export const CippFormDomainSelector = ({ formControl, @@ -9,32 +9,54 @@ export const CippFormDomainSelector = ({ label, allTenants = false, type = "multiple", + multiple = false, + preselectDefaultDomain = true, ...other }) => { const currentTenant = useWatch({ control: formControl.control, name: "tenantFilter" }); const selectedTenant = useSettings().currentTenant; + + const apiConfig = useMemo( + () => ({ + autoSelectFirstItem: preselectDefaultDomain && !multiple, + tenantFilter: currentTenant ? currentTenant.value : selectedTenant, + queryKey: `listDomains-${currentTenant?.value ? currentTenant.value : selectedTenant}`, + url: "/api/ListGraphRequest", + dataKey: "Results", + labelField: (option) => `${option.id}`, + valueField: "id", + addedField: { + isDefault: "isDefault", + isInitial: "isInitial", + isVerified: "isVerified", + }, + data: { + Endpoint: "domains", + manualPagination: true, + $count: true, + $top: 99, + }, + dataFilter: (domains) => { + // Always sort domains so that the default domain appears first + return domains.sort((a, b) => { + if (a.addedFields?.isDefault === true) return -1; + if (b.addedFields?.isDefault === true) return 1; + return 0; + }); + }, + }), + [currentTenant, selectedTenant, preselectDefaultDomain, multiple] + ); + return ( `${option.id}`, - valueField: "id", - data: { - Endpoint: "domains", - manualPagination: true, - $count: true, - $top: 99, - }, - }} + multiple={multiple} + api={apiConfig} + {...other} /> ); }; diff --git a/src/components/CippComponents/CippFormGroupSelector.jsx b/src/components/CippComponents/CippFormGroupSelector.jsx new file mode 100644 index 000000000000..ff8eb4ac6dc5 --- /dev/null +++ b/src/components/CippComponents/CippFormGroupSelector.jsx @@ -0,0 +1,47 @@ +import { CippFormComponent } from "./CippFormComponent"; +import { useWatch } from "react-hook-form"; +import { useSettings } from "../../hooks/use-settings"; + +export const CippFormGroupSelector = ({ + formControl, + name, + label, + allTenants = false, + multiple = false, + type = "multiple", + select, + addedField, + creatable = false, + ...other +}) => { + const currentTenant = useWatch({ control: formControl.control, name: "tenantFilter" }); + const selectedTenant = useSettings().currentTenant; + return ( + option.displayName, + valueField: "id", + queryKey: `ListGroups-${currentTenant?.value ? currentTenant.value : selectedTenant}`, + data: { + Endpoint: "groups", + manualPagination: true, + $select: select ? select : "id,displayName,description", + $count: true, + $orderby: "displayName", + $top: 999, + }, + }} + creatable={creatable} + {...other} + /> + ); +}; diff --git a/src/components/CippComponents/CippFormInputArray.jsx b/src/components/CippComponents/CippFormInputArray.jsx index 1eabcbefcb0d..159b11b36119 100644 --- a/src/components/CippComponents/CippFormInputArray.jsx +++ b/src/components/CippComponents/CippFormInputArray.jsx @@ -1,4 +1,4 @@ -import { Button, TextField, IconButton, Typography, SvgIcon } from "@mui/material"; +import { TextField, IconButton, Typography, Box } from "@mui/material"; import { Controller, useFieldArray } from "react-hook-form"; import { Add, Remove } from "@mui/icons-material"; @@ -7,68 +7,111 @@ const convertBracketsToDots = (name) => { return name.replace(/\[(\d+)\]/g, ".$1"); // Replace [0] with .0 }; -export const CippFormInputArray = ({ formControl, name, label, validators, ...other }) => { +export const CippFormInputArray = ({ + formControl, + name, + label, + validators, + mode = "keyValue", // Default to keyValue for backward compatibility + placeholder, + keyPlaceholder = "Key", + valuePlaceholder = "Value", + ...other +}) => { // Convert the name from bracket notation to dot notation const convertedName = convertBracketsToDots(name); + // Determine initial value based on mode + const getInitialValue = () => { + if (mode === "simple") { + return ""; + } else { + return { Key: "", Value: "" }; + } + }; + // Use `useFieldArray` to manage dynamic field arrays const { fields, append, remove } = useFieldArray({ control: formControl.control, - name: convertedName, // Specify the converted name for useFieldArray + name: convertedName, }); + // Render simple mode (single input field) + const renderSimpleField = (field, index) => ( + + ( + + )} + /> + remove(index)} aria-label="remove item" size="small"> + + + + ); + + // Render key-value mode (two input fields) - original functionality + const renderKeyValueField = (field, index) => ( + + ( + + )} + /> + ( + + )} + /> + remove(index)} aria-label="remove item" size="small"> + + + + ); + return ( - <> -
    + + {label && {label}} - append({ Key: "", Value: "" })} variant="outlined"> + append(getInitialValue())} variant="outlined" size="small"> -
    + - {fields.map((field, index) => ( -
    - ( - - )} - /> - ( - - )} - /> - remove(index)} aria-label="remove item"> - - - - -
    - ))} - + {fields.map((field, index) => + mode === "simple" ? renderSimpleField(field, index) : renderKeyValueField(field, index) + )} + ); }; diff --git a/src/components/CippComponents/CippFormLicenseSelector.jsx b/src/components/CippComponents/CippFormLicenseSelector.jsx index 0f8e1a6b867b..1bd640850857 100644 --- a/src/components/CippComponents/CippFormLicenseSelector.jsx +++ b/src/components/CippComponents/CippFormLicenseSelector.jsx @@ -1,4 +1,3 @@ -import React from "react"; import { CippFormComponent } from "./CippFormComponent"; import { getCippLicenseTranslation } from "../../utils/get-cipp-license-translation"; import { useSettings } from "../../hooks/use-settings"; diff --git a/src/components/CippComponents/CippFormTenantSelector.jsx b/src/components/CippComponents/CippFormTenantSelector.jsx index dbde6a84b073..0ce271515d29 100644 --- a/src/components/CippComponents/CippFormTenantSelector.jsx +++ b/src/components/CippComponents/CippFormTenantSelector.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { CippFormComponent } from "./CippFormComponent"; import { useSettings } from "../../hooks/use-settings"; import { GroupHeader, GroupItems } from "../CippComponents/CippAutocompleteGrouping"; @@ -15,7 +15,8 @@ export const CippFormTenantSelector = ({ disableClearable = true, preselectedEnabled = false, removeOptions = [], - includeGroups = false, // New parameter + includeGroups = false, + includeOffboardingDefaults = false, ...other }) => { const validators = () => { @@ -28,10 +29,30 @@ export const CippFormTenantSelector = ({ }; const currentTenant = useSettings()?.currentTenant; + // Build the API URL with query parameters to support tenant specific offboarding config + const buildApiUrl = () => { + const baseUrl = allTenants ? "/api/ListTenants?AllTenantSelector=true" : "/api/ListTenants"; + const params = new URLSearchParams(); + + if (allTenants) { + params.append("AllTenantSelector", "true"); + } + + if (includeOffboardingDefaults) { + params.append("IncludeOffboardingDefaults", "true"); + } + + return params.toString() + ? `${baseUrl.split("?")[0]}?${params.toString()}` + : baseUrl.split("?")[0]; + }; + // Fetch tenant list const tenantList = ApiGetCall({ - url: allTenants ? "/api/ListTenants?AllTenantSelector=true" : "/api/ListTenants", - queryKey: allTenants ? "ListTenants-FormAllTenantSelector" : "ListTenants-FormnotAllTenants", + url: buildApiUrl(), + queryKey: allTenants + ? `ListTenants-FormAllTenantSelector${includeOffboardingDefaults ? "-WithOffboarding" : ""}` + : `ListTenants-FormnotAllTenants${includeOffboardingDefaults ? "-WithOffboarding" : ""}`, }); // Fetch tenant group list if includeGroups is true @@ -46,28 +67,34 @@ export const CippFormTenantSelector = ({ useEffect(() => { if (tenantList.isSuccess && (!includeGroups || tenantGroupList.isSuccess)) { - const tenantData = tenantList.data.map((tenant) => ({ - value: tenant[valueField], - label: `${tenant.displayName} (${tenant.defaultDomainName})`, - type: "Tenant", - addedFields: { - defaultDomainName: tenant.defaultDomainName, - displayName: tenant.displayName, - customerId: tenant.customerId, - }, - })); - - const groupData = includeGroups - ? tenantGroupList?.data?.Results?.map((group) => ({ - value: group.Id, - label: group.Name, - type: "Group", + const tenantData = Array.isArray(tenantList.data) + ? tenantList.data.map((tenant) => ({ + value: tenant[valueField], + label: `${tenant.displayName} (${tenant.defaultDomainName})`, + type: "Tenant", + addedFields: { + defaultDomainName: tenant.defaultDomainName, + displayName: tenant.displayName, + customerId: tenant.customerId, + ...(includeOffboardingDefaults && { + offboardingDefaults: tenant.offboardingDefaults, + }), + }, })) : []; + const groupData = + includeGroups && Array.isArray(tenantGroupList?.data?.Results) + ? tenantGroupList.data.Results.map((group) => ({ + value: group.Id, + label: group.Name, + type: "Group", + })) + : []; + setOptions([...tenantData, ...groupData]); } - }, [tenantList.isSuccess, tenantGroupList.isSuccess, includeGroups]); + }, [tenantList.isSuccess, tenantGroupList.isSuccess, includeGroups, includeOffboardingDefaults]); return ( {params.children} : params.children}
  • )} + isFetching={tenantList.isFetching || tenantGroupList.isFetching} {...other} /> ); diff --git a/src/components/CippComponents/CippFormUserSelector.jsx b/src/components/CippComponents/CippFormUserSelector.jsx index be303767317b..18a2e8d13fa9 100644 --- a/src/components/CippComponents/CippFormUserSelector.jsx +++ b/src/components/CippComponents/CippFormUserSelector.jsx @@ -1,4 +1,3 @@ -import React from "react"; import { CippFormComponent } from "./CippFormComponent"; import { useWatch } from "react-hook-form"; import { useSettings } from "../../hooks/use-settings"; @@ -13,6 +12,8 @@ export const CippFormUserSelector = ({ select, addedField, valueField, + dataFilter = null, + showRefresh = false, ...other }) => { const currentTenant = useWatch({ control: formControl.control, name: "tenantFilter" }); @@ -31,7 +32,9 @@ export const CippFormUserSelector = ({ dataKey: "Results", labelField: (option) => `${option.displayName} (${option.userPrincipalName})`, valueField: valueField ? valueField : "id", - queryKey: `ListUsers-${currentTenant?.value ? currentTenant.value : selectedTenant}`, + queryKey: `ListUsers-${currentTenant?.value ? currentTenant.value : selectedTenant}-${ + select ? select : "default" + }`, data: { Endpoint: "users", manualPagination: true, @@ -40,6 +43,13 @@ export const CippFormUserSelector = ({ $orderby: "displayName", $top: 999, }, + dataFilter: (options) => { + if (dataFilter) { + return options.filter(dataFilter); + } + return options; + }, + showRefresh: showRefresh, }} creatable={false} {...other} diff --git a/src/components/CippComponents/CippForwardingSection.jsx b/src/components/CippComponents/CippForwardingSection.jsx new file mode 100644 index 000000000000..df7fcba9b177 --- /dev/null +++ b/src/components/CippComponents/CippForwardingSection.jsx @@ -0,0 +1,98 @@ +import { Stack, Button } from "@mui/material"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormCondition } from "./CippFormCondition"; +import { Grid } from "@mui/system"; +import { CippApiResults } from "./CippApiResults"; +import { getCippValidator } from "/src/utils/get-cipp-validator"; + +const CippForwardingSection = ({ formControl, usersList, contactsList, postRequest, handleSubmit }) => { + + const internalAddressOptions = [ + // Add users + ...(usersList?.data?.Results?.map((user) => ({ + value: user.userPrincipalName, + label: `${user.displayName} (${user.userPrincipalName}) - User`, + })) || []), + // Add contacts + ...(contactsList?.data?.Results?.map((contact) => ({ + value: contact.mail || contact.emailAddress, + label: `${contact.displayName} (${contact.mail || contact.emailAddress}) - Contact`, + })) || []) + ]; + + return ( + + + + + + + + + getCippValidator(value, "email"), + }} + /> + + + + + + + + + + + + + ); +}; + +export default CippForwardingSection; diff --git a/src/components/CippComponents/CippGeoLocation.jsx b/src/components/CippComponents/CippGeoLocation.jsx index 633ce3ee60cd..e7a4be63ed66 100644 --- a/src/components/CippComponents/CippGeoLocation.jsx +++ b/src/components/CippComponents/CippGeoLocation.jsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState } from "react"; -import { Card, CardContent, CardHeader, Skeleton } from "@mui/material"; +import { useEffect, useState } from "react"; +import { Skeleton } from "@mui/material"; import { Grid } from "@mui/system"; import dynamic from "next/dynamic"; import { ApiPostCall } from "/src/api/ApiCall"; @@ -8,14 +8,27 @@ import { getCippTranslation } from "../../utils/get-cipp-translation"; import { getCippFormatting } from "../../utils/get-cipp-formatting"; const CippMap = dynamic(() => import("./CippMap"), { ssr: false }); -export default function CippGeoLocation({ ipAddress, cardProps }) { +export default function CippGeoLocation({ + ipAddress, + cardProps, + showIpAddress = false, + displayIpAddress = null, +}) { const [locationInfo, setLocationInfo] = useState(null); const markerProperties = ["timezone", "as", "proxy", "hosting", "mobile"]; const includeProperties = ["org", "city", "region", "country", "zip"]; - const initialPropertyList = includeProperties.map((key) => ({ - label: getCippTranslation(key), - value: "", + + // Use displayIpAddress if provided, otherwise use ipAddress + const ipToDisplay = displayIpAddress || ipAddress; + + // Add IP address to properties if showIpAddress is true + const initialIncludeProperties = showIpAddress + ? ["ipAddress", ...includeProperties] + : includeProperties; + const initialPropertyList = initialIncludeProperties.map((key) => ({ + label: getCippTranslation(key === "ipAddress" ? "IP Address" : key), + value: key === "ipAddress" ? ipToDisplay : "", })); const [properties, setProperties] = useState(initialPropertyList); @@ -28,6 +41,16 @@ export default function CippGeoLocation({ ipAddress, cardProps }) { onResult: (result) => { setLocationInfo(result); var propertyList = []; + + // Add IP address property if showIpAddress is true + if (showIpAddress) { + propertyList.push({ + label: getCippTranslation("IP Address"), + value: getCippFormatting(ipToDisplay, "ipAddress"), + }); + } + + // Add other properties includeProperties.map((key) => { propertyList.push({ label: getCippTranslation(key), @@ -61,7 +84,7 @@ export default function CippGeoLocation({ ipAddress, cardProps }) { return ( - + {geoLookup.isPending ? ( ) : ( @@ -78,7 +101,7 @@ export default function CippGeoLocation({ ipAddress, cardProps }) { )} - + { + // Watch for changes in the resource type field + const selectedResource = useWatch({ + control: formControl.control, + name: resourceFieldName, + }); + + // Extract the value whether selectedResource is an object or string + const resourceValue = selectedResource?.value || selectedResource; + + const getHelperText = () => { + if (helperText) return helperText; + + if (!resourceValue) { + return "Select a resource type above to view available attributes"; + } + + return "Select which attributes to monitor for changes"; + }; + + const api = resourceValue + ? { + url: "/api/ListGraphRequest", + queryKey: `graph-properties-${resourceValue}`, + data: { + Endpoint: resourceValue, + ListProperties: true, + IgnoreErrors: true, + }, + labelField: (item) => item, + valueField: (item) => item, + dataKey: "Results", + } + : null; + + return ( + + ); +}; + +export default CippGraphAttributeSelector; diff --git a/src/components/CippComponents/CippGraphResourceSelector.jsx b/src/components/CippComponents/CippGraphResourceSelector.jsx new file mode 100644 index 000000000000..f2c2015c61f8 --- /dev/null +++ b/src/components/CippComponents/CippGraphResourceSelector.jsx @@ -0,0 +1,132 @@ +import { useWatch } from "react-hook-form"; +import CippFormComponent from "./CippFormComponent"; + +/** + * A form component for selecting specific resources from a Graph API endpoint + * @param {Object} props - Component props + * @param {Object} props.formControl - React Hook Form control object + * @param {string} props.name - Field name for the form + * @param {string} props.resourceFieldName - Name of the field that contains the selected resource type + * @param {string} props.label - Label for the field + * @param {string} props.helperText - Helper text for the field + * @param {boolean} props.multiple - Whether to allow multiple selections + * @param {boolean} props.required - Whether the field is required + * @param {Object} props.gridProps - Grid props to pass to the wrapper + */ +const CippGraphResourceSelector = ({ + formControl, + name, + resourceFieldName = "DeltaResource", + tenantFilterFieldName = "tenantFilter", + label = "Filter Specific Resources (Optional)", + helperText, + multiple = true, + required = false, + ...otherProps +}) => { + // Watch for changes in the resource type field + const selectedResource = useWatch({ + control: formControl.control, + name: resourceFieldName, + }); + + // Watch for changes in the tenant filter field + const tenantFilter = useWatch({ + control: formControl.control, + name: tenantFilterFieldName, + }); + + // Extract the value whether selectedResource is an object or string + const resourceValue = selectedResource?.value || selectedResource; + + // Extract the tenant filter value - handle both object and string formats + const tenantFilterValue = tenantFilter?.value || tenantFilter; + + const getHelperText = () => { + if (helperText) return helperText; + + if (!resourceValue) { + return "Select a resource type above to filter specific resources"; + } + + if ( + !tenantFilterValue || + tenantFilterValue === "AllTenants" || + (tenantFilter && typeof tenantFilter === "object" && tenantFilter.type === "Group") + ) { + return "Resource filtering is not available for All Tenants or tenant groups"; + } + + if (multiple) { + return "Optionally select specific resources to monitor (will create filter with OR statements: id eq 'id1' or id eq 'id2')"; + } + + return "Optionally select a specific resource to monitor"; + }; + + // Check if we should make the API call + const shouldFetchResources = () => { + // Must have a resource type selected + if (!resourceValue) return false; + + // Must have a tenant filter + if (!tenantFilterValue) return false; + + // Cannot be null or undefined + if (tenantFilterValue === null || tenantFilterValue === undefined) return false; + + // Cannot be AllTenants + if (tenantFilterValue === "AllTenants") return false; + + // Cannot be a tenant group (check if tenantFilter object has type: "Group") + if (tenantFilter && typeof tenantFilter === "object" && tenantFilter.type === "Group") + return false; + + return true; + }; + + const isDisabled = !resourceValue || !shouldFetchResources(); + + const api = shouldFetchResources() + ? { + url: "/api/ListGraphRequest", + queryKey: `graph-resources-${resourceValue}-${tenantFilterValue}`, + data: { + Endpoint: resourceValue, + IgnoreErrors: true, + $select: "id,displayName", + $top: 100, + tenantFilter: tenantFilterValue, + }, + labelField: (item) => item.displayName || item.id, + valueField: "id", + dataKey: "Results", + waiting: true, + } + : null; + + return ( + + ); +}; + +export default CippGraphResourceSelector; diff --git a/src/components/CippComponents/CippHVEUserDrawer.jsx b/src/components/CippComponents/CippHVEUserDrawer.jsx new file mode 100644 index 000000000000..3c4ba53ca852 --- /dev/null +++ b/src/components/CippComponents/CippHVEUserDrawer.jsx @@ -0,0 +1,171 @@ +import React, { useState } from "react"; +import { Button, Alert, Box } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm } from "react-hook-form"; +import { PersonAdd } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippHVEUserDrawer = ({ + buttonText = "Add HVE User", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const userSettingsDefaults = useSettings(); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + tenantFilter: userSettingsDefaults.currentTenant, + displayName: "", + password: "", + primarySMTPAddress: "", + }, + }); + + const createHVEUser = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: ["Mailboxes"], + }); + + const handleSubmit = () => { + const formData = formControl.getValues(); + const postData = { + tenantFilter: formData.tenantFilter, + displayName: formData.displayName, + password: formData.password, + primarySMTPAddress: formData.primarySMTPAddress, + }; + createHVEUser.mutate({ + url: "/api/ExecHVEUser", + data: postData, + relatedQueryKeys: ["Mailboxes"], + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + tenantFilter: userSettingsDefaults.currentTenant, + displayName: "", + password: "", + primarySMTPAddress: "", + }); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + + + + HVE SMTP Configuration Settings: + +
  • + Server: smtp-hve.office365.com +
  • +
  • + Port: 587 +
  • +
  • + Encryption: STARTTLS +
  • +
  • + TLS Support: TLS 1.2 and TLS 1.3 +
  • +
    + + Use these settings to configure your email client for HVE access. + +
    +
    +
    + + + + + + + + + + + + + + +
    +
    + + ); +}; diff --git a/src/components/CippComponents/CippInviteGuestDrawer.jsx b/src/components/CippComponents/CippInviteGuestDrawer.jsx new file mode 100644 index 000000000000..5c3f851206b0 --- /dev/null +++ b/src/components/CippComponents/CippInviteGuestDrawer.jsx @@ -0,0 +1,149 @@ +import React, { useState } from "react"; +import { Button } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useFormState } from "react-hook-form"; +import { Send } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippInviteGuestDrawer = ({ + buttonText = "Invite Guest", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const userSettingsDefaults = useSettings(); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + tenantFilter: userSettingsDefaults.currentTenant, + displayName: "", + mail: "", + redirectUri: "", + sendInvite: false, + }, + }); + + const { isValid } = useFormState({ control: formControl.control }); + + const inviteGuest = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: [`Users-${userSettingsDefaults.currentTenant}`], + }); + + const handleSubmit = () => { + formControl.trigger(); + // Check if the form is valid before proceeding + if (!isValid) { + return; + } + const formData = formControl.getValues(); + inviteGuest.mutate({ + url: "/api/AddGuest", + data: formData, + relatedQueryKeys: [`Users-${userSettingsDefaults.currentTenant}`], + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + tenantFilter: userSettingsDefaults.currentTenant, + displayName: "", + mail: "", + redirectUri: "", + sendInvite: false, + }); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippMailboxPermissionsDialog.jsx b/src/components/CippComponents/CippMailboxPermissionsDialog.jsx new file mode 100644 index 000000000000..8306089a8008 --- /dev/null +++ b/src/components/CippComponents/CippMailboxPermissionsDialog.jsx @@ -0,0 +1,71 @@ +import { Box, Stack } from "@mui/material"; +import { useEffect } from "react"; +import CippFormComponent from "./CippFormComponent"; +import { useWatch } from "react-hook-form"; + +const CippMailboxPermissionsDialog = ({ + formHook, + combinedOptions, + isUserGroupLoading, + defaultAutoMap = false +}) => { + const fullAccess = useWatch({ + control: formHook.control, + name: "permissions.AddFullAccess", + }); + + // Set the default AutoMap value when component mounts + useEffect(() => { + formHook.setValue("permissions.AutoMap", defaultAutoMap); + }, [formHook, defaultAutoMap]); + + return ( + + + + + + + + + + + + + + + ); +}; + +export default CippMailboxPermissionsDialog; diff --git a/src/components/CippComponents/CippMailboxRestoreDrawer.jsx b/src/components/CippComponents/CippMailboxRestoreDrawer.jsx new file mode 100644 index 000000000000..81e03bb4c659 --- /dev/null +++ b/src/components/CippComponents/CippMailboxRestoreDrawer.jsx @@ -0,0 +1,538 @@ +import { useEffect, useState } from "react"; +import { useForm, useWatch, useFormState } from "react-hook-form"; +import { + Button, + Drawer, + Box, + Typography, + IconButton, + Alert, + Divider, + CircularProgress, + Card, + CardContent, + Chip, + Tooltip, +} from "@mui/material"; +import { Grid } from "@mui/system"; +import { + Close as CloseIcon, + RestoreFromTrash, + DeleteForever, + Archive, + Storage, + AccountBox, +} from "@mui/icons-material"; +import { useSettings } from "../../hooks/use-settings"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; +import CippFormComponent from "./CippFormComponent"; +import { CippApiResults } from "./CippApiResults"; +import { ApiPostCall } from "../../api/ApiCall"; + +const wellKnownFolders = [ + "Inbox", + "SentItems", + "DeletedItems", + "Calendar", + "Contacts", + "Drafts", + "Journal", + "Tasks", + "Notes", + "JunkEmail", + "CommunicationHistory", + "Voicemail", + "Fax", + "Conflicts", + "SyncIssues", + "LocalFailures", + "ServerFailures", +].map((folder) => ({ value: `#${folder}#`, label: getCippTranslation(folder) })); + +export const CippMailboxRestoreDrawer = ({ + buttonText = "New Restore Job", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const userSettingsDefaults = useSettings(); + const tenantDomain = userSettingsDefaults.currentTenant; + + const formControl = useForm({ + mode: "onBlur", + defaultValues: { + tenantFilter: tenantDomain, + }, + }); + + const createRestore = ApiPostCall({ + relatedQueryKeys: ["MailboxRestores*"], + datafromurl: true, + }); + + const { isValid, isDirty } = useFormState({ control: formControl.control }); + + const sourceMailbox = useWatch({ control: formControl.control, name: "SourceMailbox" }); + const targetMailbox = useWatch({ control: formControl.control, name: "TargetMailbox" }); + + // Helper function to check if archive is active (GUID exists and is not all zeros) + const hasActiveArchive = (mailbox) => { + const archiveGuid = mailbox?.addedFields?.ArchiveGuid; + return ( + archiveGuid && + archiveGuid !== "00000000-0000-0000-0000-000000000000" && + archiveGuid.replace(/0/g, "").replace(/-/g, "") !== "" + ); + }; + + useEffect(() => { + if (sourceMailbox && targetMailbox) { + const sourceUPN = sourceMailbox.value; + const targetUPN = targetMailbox.value; + const randomGUID = crypto.randomUUID(); + formControl.setValue("RequestName", `Restore ${sourceUPN} to ${targetUPN} (${randomGUID})`, { + shouldDirty: true, + shouldValidate: true, + }); + } + }, [sourceMailbox?.value, targetMailbox?.value]); + + useEffect(() => { + if (createRestore.isSuccess) { + formControl.reset(); + } + }, [createRestore.isSuccess]); + + const handleSubmit = () => { + const values = formControl.getValues(); + const shippedValues = { + TenantFilter: tenantDomain, + RequestName: values.RequestName, + SourceMailbox: values.SourceMailbox?.addedFields?.ExchangeGuid ?? values.SourceMailbox?.value, + TargetMailbox: values.TargetMailbox?.addedFields?.ExchangeGuid ?? values.TargetMailbox?.value, + BadItemLimit: values.BadItemLimit, + LargeItemLimit: values.LargeItemLimit, + AcceptLargeDataLoss: values.AcceptLargeDataLoss, + AssociatedMessagesCopyOption: values.AssociatedMessagesCopyOption, + ExcludeFolders: values.ExcludeFolders, + IncludeFolders: values.IncludeFolders, + BatchName: values.BatchName, + CompletedRequestAgeLimit: values.CompletedRequestAgeLimit, + ConflictResolutionOption: values.ConflictResolutionOption, + SourceRootFolder: values.SourceRootFolder, + TargetRootFolder: values.TargetRootFolder, + TargetType: values.TargetType, + ExcludeDumpster: values.ExcludeDumpster, + SourceIsArchive: values.SourceIsArchive, + TargetIsArchive: values.TargetIsArchive, + }; + + createRestore.mutate({ + url: "/api/ExecMailboxRestore", + data: shippedValues, + }); + }; + + const handleCloseDrawer = () => { + formControl.reset(); + setDrawerVisible(false); + }; + + return ( + <> + } + onClick={() => setDrawerVisible(true)} + requiredPermissions={requiredPermissions} + > + {buttonText} + + + + + + New Mailbox Restore + + + + + + + + + + Use this form to restore a mailbox from a soft-deleted state to the target + mailbox. Use the optional settings to tailor the restore request for your needs. + + + + + Restore Settings + + + + `${option.displayName} (${option.UPN})`, + valueField: "UPN", + addedField: { + displayName: "displayName", + ExchangeGuid: "ExchangeGuid", + recipientTypeDetails: "recipientTypeDetails", + ArchiveStatus: "ArchiveStatus", + ArchiveGuid: "ArchiveGuid", + ProhibitSendQuota: "ProhibitSendQuota", + TotalItemSize: "TotalItemSize", + ItemCount: "ItemCount", + WhenSoftDeleted: "WhenSoftDeleted", + }, + url: "/api/ListMailboxes?SoftDeletedMailbox=true", + queryKey: `ListMailboxes-${tenantDomain}-SoftDeleted`, + showRefresh: true, + }} + validators={{ + validate: (value) => (value ? true : "Please select a source mailbox."), + }} + /> + + + {sourceMailbox && ( + + + {sourceMailbox.addedFields?.recipientTypeDetails && ( + + } + label={sourceMailbox.addedFields.recipientTypeDetails} + size="small" + color="info" + variant="outlined" + /> + + )} + + } + label={ + hasActiveArchive(sourceMailbox) + ? "Archive Active" + : "Archive Not Available" + } + size="small" + color={hasActiveArchive(sourceMailbox) ? "success" : "warning"} + variant="outlined" + /> + + + + )} + + + `${option.displayName} (${option.UPN})`, + valueField: "UPN", + addedField: { + displayName: "displayName", + ExchangeGuid: "ExchangeGuid", + recipientTypeDetails: "recipientTypeDetails", + ArchiveStatus: "ArchiveStatus", + ArchiveGuid: "ArchiveGuid", + ProhibitSendQuota: "ProhibitSendQuota", + TotalItemSize: "TotalItemSize", + ItemCount: "ItemCount", + }, + url: "/api/ListMailboxes", + showRefresh: true, + }} + validators={{ + validate: (value) => (value ? true : "Please select a target mailbox."), + }} + /> + + + {targetMailbox && ( + + + {targetMailbox.addedFields?.recipientTypeDetails && ( + + } + label={targetMailbox.addedFields.recipientTypeDetails} + size="small" + color="info" + variant="outlined" + /> + + )} + + } + label={ + hasActiveArchive(targetMailbox) + ? "Archive Active" + : "Archive Not Available" + } + size="small" + color={hasActiveArchive(targetMailbox) ? "success" : "warning"} + variant="outlined" + /> + + {targetMailbox.addedFields?.TotalItemSize && ( + + } + label={targetMailbox.addedFields.TotalItemSize} + size="small" + color="info" + variant="outlined" + /> + + )} + + + )} + + + + + + + + + + + Optional Settings + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippMessageViewer.jsx b/src/components/CippComponents/CippMessageViewer.jsx index fb9d68c338f9..366c17dcc507 100644 --- a/src/components/CippComponents/CippMessageViewer.jsx +++ b/src/components/CippComponents/CippMessageViewer.jsx @@ -333,7 +333,7 @@ export const CippMessageViewer = ({ emailSource }) => { justifyContent: "space-between", }} > - + @@ -417,7 +417,7 @@ export const CippMessageViewer = ({ emailSource }) => { )} - + {emlContent.date && isValidDate(emlContent.date) @@ -436,7 +436,7 @@ export const CippMessageViewer = ({ emailSource }) => { {emlContent.attachments && emlContent.attachments.length > 0 && ( - + {emlContent?.attachments?.map((attachment, index) => ( @@ -484,7 +484,7 @@ export const CippMessageViewer = ({ emailSource }) => { {(emlContent?.text || emlContent?.html) && ( - + {messageHtml ? ( {emailStyle} diff --git a/src/components/CippComponents/CippNotificationForm.jsx b/src/components/CippComponents/CippNotificationForm.jsx new file mode 100644 index 000000000000..bd7a5ce3887c --- /dev/null +++ b/src/components/CippComponents/CippNotificationForm.jsx @@ -0,0 +1,188 @@ +import { useEffect } from "react"; +import { Button, Box } from "@mui/material"; +import { Grid } from "@mui/system"; +import CippFormComponent from "./CippFormComponent"; +import { ApiGetCall } from "../../api/ApiCall"; +import { useDialog } from "../../hooks/use-dialog"; +import { CippApiDialog } from "./CippApiDialog"; + +export const CippNotificationForm = ({ + formControl, + showTestButton = true, + hideButtons = false, +}) => { + const notificationDialog = useDialog(); + + // API call to get notification configuration + const listNotificationConfig = ApiGetCall({ + url: "/api/ListNotificationConfig", + queryKey: "ListNotificationConfig", + }); + + // Define log types and severity types + const logTypes = [ + { label: "Updates Status", value: "Updates" }, + { label: "All Standards", value: "Standards" }, + { label: "Token Events", value: "TokensUpdater" }, + { label: "Changing DNS Settings", value: "ExecDnsConfig" }, + { label: "Adding excluded licenses", value: "ExecExcludeLicenses" }, + { label: "Adding excluded tenants", value: "ExecExcludeTenant" }, + { label: "Editing a user", value: "EditUser" }, + { label: "Adding or deploying applications", value: "ChocoApp" }, + { label: "Adding autopilot devices", value: "AddAPDevice" }, + { label: "Editing a tenant", value: "EditTenant" }, + { label: "Adding an MSP app", value: "AddMSPApp" }, + { label: "Adding a user", value: "AddUser" }, + { label: "Adding a group", value: "AddGroup" }, + { label: "Adding a tenant", value: "NewTenant" }, + { label: "Executing the offboard wizard", value: "ExecOffboardUser" }, + ]; + + const severityTypes = [ + { label: "Alert", value: "Alert" }, + { label: "Error", value: "Error" }, + { label: "Info", value: "Info" }, + { label: "Warning", value: "Warning" }, + { label: "Critical", value: "Critical" }, + ]; + + // Load notification config data into form + useEffect(() => { + if (listNotificationConfig.isSuccess) { + const logsToInclude = listNotificationConfig.data?.logsToInclude + ?.map((log) => logTypes.find((logType) => logType.value === log)) + .filter(Boolean); + + const Severity = listNotificationConfig.data?.Severity?.map((sev) => + severityTypes.find((stype) => stype.value === sev) + ).filter(Boolean); + + formControl.reset({ + ...formControl.getValues(), + email: listNotificationConfig.data?.email, + webhook: listNotificationConfig.data?.webhook, + logsToInclude, + Severity, + onePerTenant: listNotificationConfig.data?.onePerTenant, + sendtoIntegration: listNotificationConfig.data?.sendtoIntegration, + includeTenantId: listNotificationConfig.data?.includeTenantId, + }); + } + }, [listNotificationConfig.isSuccess]); + + return ( + <> + + + + + + + + + + + + + + + + + + + {showTestButton && ( + + + + )} + + + {showTestButton && ( + ({ + ...row, + text: "This is a test from Notification Settings", + }), + }} + /> + )} + + ); +}; + +export default CippNotificationForm; diff --git a/src/components/CippComponents/CippOffCanvas.jsx b/src/components/CippComponents/CippOffCanvas.jsx index 266c998619f7..25b05ed69a28 100644 --- a/src/components/CippComponents/CippOffCanvas.jsx +++ b/src/components/CippComponents/CippOffCanvas.jsx @@ -1,8 +1,8 @@ -import { Drawer, Box, Grid, IconButton } from "@mui/material"; +import { Drawer, Box, IconButton, Typography, Divider } from "@mui/material"; import { CippPropertyListCard } from "../CippCards/CippPropertyListCard"; import { getCippTranslation } from "../../utils/get-cipp-translation"; import { getCippFormatting } from "../../utils/get-cipp-formatting"; -import { useMediaQuery } from "@mui/system"; +import { useMediaQuery, Grid } from "@mui/system"; import CloseIcon from "@mui/icons-material/Close"; export const CippOffCanvas = (props) => { @@ -16,6 +16,7 @@ export const CippOffCanvas = (props) => { isFetching, children, size = "sm", + footer, } = props; const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); @@ -79,41 +80,73 @@ export const CippOffCanvas = (props) => { open={visible} onClose={onClose} > - - - - {/* Force vertical stacking in a column layout */} + {title} + + + + + - - + + {extendedInfo.length > 0 && ( - + + + )} + + + {/* Render children if provided, otherwise render default content */} + {typeof children === "function" ? children(extendedData) : children} + + - - - {typeof children === "function" ? children(extendedData) : children} - - - + + + {/* Footer section */} + {footer && ( + + {footer} + + )} diff --git a/src/components/CippComponents/CippOffboardingDefaultSettings.jsx b/src/components/CippComponents/CippOffboardingDefaultSettings.jsx new file mode 100644 index 000000000000..9ac089dee39b --- /dev/null +++ b/src/components/CippComponents/CippOffboardingDefaultSettings.jsx @@ -0,0 +1,212 @@ +import { CippPropertyListCard } from "../../components/CippCards/CippPropertyListCard"; +import CippFormComponent from "../../components/CippComponents/CippFormComponent"; +import { Typography, Box } from "@mui/material"; + +export const CippOffboardingDefaultSettings = (props) => { + const { formControl, defaultsSource = null, title = "Offboarding Default Settings" } = props; + + const getSourceIndicator = () => { + // Only show the indicator if defaultsSource is explicitly provided (for wizard, not tenant config) + if (!defaultsSource || defaultsSource === null) return null; + + let sourceText = ""; + let color = "text.secondary"; + + switch (defaultsSource) { + case "tenant": + sourceText = "Using Tenant Defaults"; + color = "primary.main"; + break; + case "user": + sourceText = "Using User Defaults"; + color = "info.main"; + break; + case "none": + default: + sourceText = "Using Default Settings"; + color = "text.secondary"; + break; + } + + return ( + + + {sourceText} + + + ); + }; + + return ( + <> + {getSourceIndicator()} + + ), + }, + { + label: "Remove from all groups", + value: ( + + ), + }, + { + label: "Hide from Global Address List", + value: ( + + ), + }, + { + label: "Remove Licenses", + value: ( + + ), + }, + { + label: "Cancel all calendar invites", + value: ( + + ), + }, + { + label: "Revoke all sessions", + value: ( + + ), + }, + { + label: "Remove users mailbox permissions", + value: ( + + ), + }, + { + label: "Remove all Rules", + value: ( + + ), + }, + { + label: "Reset Password", + value: ( + + ), + }, + { + label: "Keep copy of forwarded mail in source mailbox", + value: ( + + ), + }, + { + label: "Delete user", + value: ( + + ), + }, + { + label: "Remove all Mobile Devices", + value: ( + + ), + }, + { + label: "Disable Sign in", + value: ( + + ), + }, + { + label: "Remove all MFA Devices", + value: ( + + ), + }, + { + label: "Remove Teams Phone DID", + value: ( + + ), + }, + { + label: "Clear Immutable ID", + value: ( + + ), + }, + ]} + /> + + ); +}; diff --git a/src/components/CippComponents/CippPermissionPreview.jsx b/src/components/CippComponents/CippPermissionPreview.jsx new file mode 100644 index 000000000000..ad1bb530020c --- /dev/null +++ b/src/components/CippComponents/CippPermissionPreview.jsx @@ -0,0 +1,833 @@ +import { useState, useEffect, useCallback } from "react"; +import { + Alert, + Skeleton, + Stack, + Typography, + Box, + Paper, + List, + ListItem, + ListItemText, + Tab, + Tabs, + Chip, + SvgIcon, + Accordion, + AccordionSummary, + AccordionDetails, +} from "@mui/material"; +import { ShieldCheckIcon } from "@heroicons/react/24/outline"; +import { ExpandMore } from "@mui/icons-material"; +import { CippCardTabPanel } from "./CippCardTabPanel"; +import { ApiGetCall } from "../../api/ApiCall"; + +const CippPermissionPreview = ({ + permissions, + title = "Permission Preview", + isLoading = false, + maxHeight = "100%", + showAppIds = true, + galleryTemplate = null, + applicationManifest = null, +}) => { + const [selectedPermissionTab, setSelectedPermissionTab] = useState(0); + const [servicePrincipalDetails, setServicePrincipalDetails] = useState({}); + const [resourceIds, setResourceIds] = useState([]); + const [loadingDetails, setLoadingDetails] = useState(false); + + // Extract resource IDs from permissions object + useEffect(() => { + if (permissions && typeof permissions === "object") { + const ids = Object.keys(permissions); + setResourceIds(ids); + } + }, [permissions]); + + // Function to fetch individual service principal details + const fetchServicePrincipalDetails = useCallback(async (resourceId) => { + try { + const response = await fetch(`/api/ExecServicePrincipals?AppId=${resourceId}`); + const data = await response.json(); + + if (data?.Results) { + setServicePrincipalDetails((prev) => ({ + ...prev, + [resourceId]: data.Results, + })); + } + } catch (error) { + console.error(`Error fetching details for ${resourceId}:`, error); + } + }, []); + + // Fetch details for each resource ID + useEffect(() => { + const fetchAllDetails = async () => { + if (resourceIds.length > 0) { + setLoadingDetails(true); + const promises = resourceIds.map((id) => fetchServicePrincipalDetails(id)); + await Promise.all(promises); + setLoadingDetails(false); + } + }; + + fetchAllDetails(); + }, [resourceIds, fetchServicePrincipalDetails]); + + const handlePermissionTabChange = (event, newValue) => { + setSelectedPermissionTab(newValue); + }; + + // Function to get permission counts + const getPermissionCounts = (permissions) => { + if (!permissions) return { app: 0, delegated: 0 }; + + let appCount = 0; + let delegatedCount = 0; + + Object.entries(permissions).forEach(([resourceName, perms]) => { + if (perms.applicationPermissions) { + appCount += perms?.applicationPermissions?.length ?? 0; + } + if (perms.delegatedPermissions) { + delegatedCount += perms?.delegatedPermissions?.length ?? 0; + } + }); + + return { app: appCount, delegated: delegatedCount }; + }; + + // Helper to get the display name for a resource ID + const getResourceDisplayName = (resourceId) => { + const spDetails = servicePrincipalDetails[resourceId]; + return spDetails?.displayName || resourceId; + }; + + // Helper to get the appropriate permission description + const getPermissionDescription = (resourceId, permissionId, permissionType) => { + const spDetails = servicePrincipalDetails[resourceId]; + if (!spDetails) return null; + + if (permissionType === "application") { + const foundRole = spDetails.appRoles?.find((role) => role.id === permissionId); + return foundRole?.description || null; + } else { + const foundScope = spDetails.publishedPermissionScopes?.find( + (scope) => scope.id === permissionId + ); + return foundScope?.userConsentDescription || foundScope?.description || null; + } + }; + + // Better checks for permissions object to prevent rendering errors + if (isLoading || loadingDetails) { + return ( + <> + {title} + + + ); + } + + if (!permissions && !galleryTemplate && !applicationManifest) { + return ( + + Select a template with permissions to see what will be consented. + + ); + } + + // If we have gallery template data, show that instead of permissions + if (galleryTemplate) { + return ( + + {title} + + + {/* App Logo and Name */} + + {galleryTemplate.addedFields?.logoUrl && ( + + {galleryTemplate.addedFields?.displayName { + e.target.style.display = "none"; + }} + /> + + )} + + + {galleryTemplate.addedFields?.displayName || galleryTemplate.label} + + {galleryTemplate.addedFields?.publisher && ( + + by {galleryTemplate.addedFields.publisher} + + )} + + + + {/* Description */} + {galleryTemplate.addedFields?.description && ( + + + {galleryTemplate.addedFields.description} + + + )} + + {/* Categories */} + {galleryTemplate.addedFields?.categories && + galleryTemplate.addedFields.categories.length > 0 && ( + + + Categories: + + + {galleryTemplate.addedFields.categories.map((category, idx) => ( + + ))} + + + )} + + {/* SSO Modes */} + {galleryTemplate.addedFields?.supportedSingleSignOnModes && + galleryTemplate.addedFields.supportedSingleSignOnModes.length > 0 && ( + + + Supported SSO Modes: + + + {galleryTemplate.addedFields.supportedSingleSignOnModes.map((mode, idx) => ( + + ))} + + + )} + + {/* Provisioning Types */} + {galleryTemplate.addedFields?.supportedProvisioningTypes && + galleryTemplate.addedFields.supportedProvisioningTypes.length > 0 && ( + + + Supported Provisioning: + + + {galleryTemplate.addedFields.supportedProvisioningTypes.map((type, idx) => ( + + ))} + + + )} + + {/* Home Page URL */} + {galleryTemplate.addedFields?.homePageUrl && ( + + + Home Page: + + + {galleryTemplate.addedFields.homePageUrl} + + + )} + + {/* Template ID */} + + + Template ID: {galleryTemplate.value} + + + + {/* Auto-consent note */} + + Gallery templates will automatically consent to the required permissions defined in + the template's app registration. No manual permission configuration needed. + + + + + ); + } + + // If we have application manifest data, show that instead of permissions + if (applicationManifest) { + return ( + + ); + } + + // Ensure permissions is an object and has entries + if ( + typeof permissions !== "object" || + permissions === null || + Object.keys(permissions).length === 0 + ) { + return No permissions data available in this template.; + } + + return ( + + + {title} + + + + } + title="Application/Delegated Permissions" + /> + + + + + + + + + + + + + + {Object.entries(permissions).map(([resourceId, resourcePerms]) => { + const resourceName = getResourceDisplayName(resourceId); + const hasAppPermissions = + resourcePerms.applicationPermissions && + resourcePerms.applicationPermissions.length > 0; + const hasDelegatedPermissions = + resourcePerms.delegatedPermissions && resourcePerms.delegatedPermissions.length > 0; + + return ( + + + + {resourceName} + {showAppIds && ( + + {resourceId} + + )} + + + {hasAppPermissions && ( + + + Application Permissions ({resourcePerms.applicationPermissions.length}) + + + {resourcePerms.applicationPermissions.map((perm, idx) => { + const description = + getPermissionDescription(resourceId, perm.id, "application") || + perm.description || + "No description available"; + return ( + + + + ); + })} + + + )} + + {hasDelegatedPermissions && ( + + + Delegated Permissions ({resourcePerms.delegatedPermissions.length}) + + + {resourcePerms.delegatedPermissions.map((perm, idx) => { + const description = + getPermissionDescription(resourceId, perm.id, "delegated") || + perm.description || + "No description available"; + return ( + + + + ); + })} + + + )} + + + ); + })} + + + + + + {Object.entries(permissions) + .filter( + ([_, perms]) => + perms.applicationPermissions && perms.applicationPermissions.length > 0 + ) + .map(([resourceId, resourcePerms]) => { + const resourceName = getResourceDisplayName(resourceId); + return ( + + + + {resourceName} + {showAppIds && ( + + {resourceId} + + )} + + + {resourcePerms.applicationPermissions.map((perm, idx) => { + const description = + getPermissionDescription(resourceId, perm.id, "application") || + perm.description || + "No description available"; + return ( + + + + ); + })} + + + + ); + })} + {!Object.values(permissions).some( + (perms) => perms.applicationPermissions && perms.applicationPermissions.length > 0 + ) && No application permissions in this template.} + + + + + + {Object.entries(permissions) + .filter( + ([_, perms]) => perms.delegatedPermissions && perms.delegatedPermissions.length > 0 + ) + .map(([resourceId, resourcePerms]) => { + const resourceName = getResourceDisplayName(resourceId); + return ( + + + + {resourceName} + {showAppIds && ( + + {resourceId} + + )} + + + {resourcePerms.delegatedPermissions.map((perm, idx) => { + const description = + getPermissionDescription(resourceId, perm.id, "delegated") || + perm.description || + "No description available"; + return ( + + + + ); + })} + + + + ); + })} + {!Object.values(permissions).some( + (perms) => perms.delegatedPermissions && perms.delegatedPermissions.length > 0 + ) && No delegated permissions in this template.} + + + + + ); +}; + +// Component to handle individual service principal resource details +const ServicePrincipalResourceDetails = ({ + resource, + servicePrincipalId, + expandedResource, + handleAccordionChange, +}) => { + // Fetch individual service principal details using ApiGetCall + const { + data: servicePrincipalData, + isSuccess: spDetailSuccess, + isFetching: spDetailFetching, + isLoading: spDetailLoading, + } = ApiGetCall({ + url: "/api/ExecServicePrincipals", + data: { Id: servicePrincipalId }, + queryKey: `execServicePrincipal-details-${servicePrincipalId}`, + waiting: !!servicePrincipalId, + }); + + const spDetails = servicePrincipalData?.Results; + + // Helper to get permission details + const getPermissionDetails = (permissionId, type) => { + if (!spDetails) return { name: permissionId, description: "Loading..." }; + + if (type === "Role") { + const foundRole = spDetails.appRoles?.find((role) => role.id === permissionId); + return { + name: foundRole?.value || permissionId, + description: foundRole?.description || "No description available", + }; + } else { + const foundScope = spDetails.publishedPermissionScopes?.find( + (scope) => scope.id === permissionId + ); + return { + name: foundScope?.value || permissionId, + description: + foundScope?.userConsentDescription || + foundScope?.description || + "No description available", + }; + } + }; + + const resourceName = spDetails?.displayName || resource.resourceAppId; + const appPermissions = resource.resourceAccess?.filter((access) => access.type === "Role") || []; + const delegatedPermissions = + resource.resourceAccess?.filter((access) => access.type === "Scope") || []; + + return ( + + }> + + + {spDetailLoading || spDetailFetching ? "Loading..." : resourceName} + + + + + + } + title="Application/Delegated Permissions" + /> + + + + + {(spDetailLoading || spDetailFetching) && ( + + )} + + {spDetailSuccess && spDetails && ( + <> + {appPermissions.length > 0 && ( + + + Application Permissions ({appPermissions.length}) + + + {appPermissions.map((permission, idx) => { + const permDetails = getPermissionDetails(permission.id, "Role"); + return ( + + + + ); + })} + + + )} + + {delegatedPermissions.length > 0 && ( + + + Delegated Permissions ({delegatedPermissions.length}) + + + {delegatedPermissions.map((permission, idx) => { + const permDetails = getPermissionDetails(permission.id, "Scope"); + return ( + + + + ); + })} + + + )} + + )} + + + ); +}; + +// Component to handle Application Manifest preview with detailed permission expansion +const ApplicationManifestPreview = ({ applicationManifest, title, maxHeight }) => { + const [expandedResource, setExpandedResource] = useState(false); + + // Get unique resource IDs from required resource access + const resourceIds = + applicationManifest.requiredResourceAccess?.map((resource) => resource.resourceAppId) || []; + + // Fetch the service principal list to get object IDs + const { + data: servicePrincipals = [], + isSuccess: spSuccess, + isFetching: spFetching, + isLoading: spLoading, + } = ApiGetCall({ + url: "/api/ExecServicePrincipals", + data: { Select: "appId,displayName,id" }, + queryKey: "execServicePrincipalList-cipp-permission-preview", + waiting: true, + }); + + // Helper to get service principal ID by appId + const getServicePrincipalId = (appId) => { + if (spSuccess && servicePrincipals?.Results) { + const sp = servicePrincipals.Results.find((sp) => sp.appId === appId); + return sp?.id || null; + } + return null; + }; + + const handleAccordionChange = (panel) => (event, newExpanded) => { + setExpandedResource(newExpanded ? panel : false); + }; + + return ( + + {title} + + + {/* App Basic Info */} + + + {applicationManifest.displayName || "Custom Application"} + + {applicationManifest.description && ( + + {applicationManifest.description} + + )} + + + {/* Application Properties */} + + + Application Properties: + + + {applicationManifest.signInAudience && ( + + + + )} + {applicationManifest.web?.redirectUris && + applicationManifest.web.redirectUris.length > 0 && ( + + + + )} + + + + {/* Required Resource Access with detailed permissions */} + {applicationManifest.requiredResourceAccess && + applicationManifest.requiredResourceAccess.length > 0 && ( + + + Required Permissions: + + {(spLoading || spFetching) && ( + + )} + {spSuccess && + servicePrincipals?.Results && + applicationManifest.requiredResourceAccess.map((resource, index) => { + const servicePrincipalId = getServicePrincipalId(resource.resourceAppId); + + return ( + + ); + })} + + )} + + {/* Custom application note */} + {/* Validation warning for signInAudience */} + {applicationManifest.signInAudience && + applicationManifest.signInAudience !== "AzureADMyOrg" && ( + + + Invalid signInAudience: "{applicationManifest.signInAudience}" + + + For security reasons, Application Manifests must have signInAudience set to + "AzureADMyOrg" or not defined in the JSON. This template cannot be deployed with + the current signInAudience value. + + + )} + + + This application will be created from a custom manifest. All permissions and + configuration are defined within the manifest JSON. + + + + + ); +}; + +export default CippPermissionPreview; diff --git a/src/components/CippComponents/CippPermissionSetDrawer.jsx b/src/components/CippComponents/CippPermissionSetDrawer.jsx new file mode 100644 index 000000000000..de9f45b6d58f --- /dev/null +++ b/src/components/CippComponents/CippPermissionSetDrawer.jsx @@ -0,0 +1,206 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { Button, Typography, Alert, Box, Stack } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm } from "react-hook-form"; +import { Edit, Add } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippApiResults } from "./CippApiResults"; +import { ApiGetCall, ApiPostCall } from "/src/api/ApiCall"; +import CippAppPermissionBuilder from "./CippAppPermissionBuilder"; + +export const CippPermissionSetDrawer = ({ + buttonText = "New Permission Set", + isEditMode = false, + templateId = null, + requiredPermissions = [], + PermissionButton = Button, + onSuccess = () => {}, + drawerVisible: controlledDrawerVisible, + setDrawerVisible: controlledSetDrawerVisible, + rowAction = false, +}) => { + const [internalDrawerVisible, internalSetDrawerVisible] = useState(false); + const drawerVisible = + controlledDrawerVisible !== undefined ? controlledDrawerVisible : internalDrawerVisible; + const setDrawerVisible = + controlledSetDrawerVisible !== undefined + ? controlledSetDrawerVisible + : internalSetDrawerVisible; + + const [initialPermissions, setInitialPermissions] = useState(null); + const [refetchKey, setRefetchKey] = useState(0); + + // Fetch existing template data in edit mode + const templateInfo = ApiGetCall({ + url: templateId ? `/api/ExecAppPermissionTemplate?TemplateId=${templateId}` : null, + queryKey: templateId ? ["execAppPermissionTemplate", templateId, refetchKey] : null, + waiting: !!drawerVisible && !!isEditMode && !!templateId, + }); + + // Default form values + const defaultFormValues = useMemo( + () => ({ + templateName: "", + }), + [] + ); + + const formControl = useForm({ + mode: "onChange", + defaultValues: defaultFormValues, + }); + + // API call for submit + const updatePermissions = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: ["ExecAppPermissionTemplate", "execAppPermissionTemplate"], + }); + + // Process template data for editing + useEffect(() => { + if (isEditMode && templateInfo.isSuccess && templateInfo.data) { + const template = Array.isArray(templateInfo.data) ? templateInfo.data[0] : templateInfo.data; + + if (template) { + setInitialPermissions({ + TemplateId: template.TemplateId, + Permissions: template.Permissions, + TemplateName: template.TemplateName, + }); + formControl.setValue("templateName", template.TemplateName, { + shouldValidate: true, + shouldDirty: false, + }); + } + } else if (!isEditMode && drawerVisible) { + // Initialize with empty structure for new templates + setInitialPermissions({ + Permissions: {}, + TemplateName: "New Permission Set", + }); + formControl.setValue("templateName", "New Permission Set"); + } + }, [templateInfo.isSuccess, templateInfo.data, isEditMode, drawerVisible]); + + const handleUpdatePermissions = (data) => { + let payload = { + ...data, + }; + + if (isEditMode && templateId) { + // For editing, include the template ID + payload.TemplateId = templateId; + } + + // Use the current value from the text field + payload.TemplateName = formControl.getValues("templateName"); + + updatePermissions.mutate( + { + url: "/api/ExecAppPermissionTemplate?Action=Save", + data: payload, + queryKey: "execAppPermissionTemplate", + }, + { + onSuccess: (data) => { + if (onSuccess) { + onSuccess(data); + } + // Refresh the data + setRefetchKey((prev) => prev + 1); + + // Close the drawer after successful save + setDrawerVisible(false); + + // Reset form for next use + if (!isEditMode) { + formControl.reset(defaultFormValues); + setInitialPermissions(null); + } + }, + } + ); + }; + + const handleDrawerClose = () => { + setDrawerVisible(false); + if (!isEditMode) { + formControl.reset(defaultFormValues); + setInitialPermissions(null); + } + }; + + return ( + <> + {!rowAction && ( + setDrawerVisible(true)} + startIcon={isEditMode ? : } + requiredPermissions={requiredPermissions} + > + {buttonText} + + )} + + + + + {isEditMode + ? "Modify the permissions in this permission set. Any changes will affect all applications using this permission set." + : "Create a new permission set to define a collection of application permissions."} + + + + Permission sets allow you to define collections of permissions that can be applied to + applications consistently. + + + + + {templateInfo.isFetching && isEditMode && ( + Loading permission set data... + )} + + {initialPermissions && !templateInfo.isFetching && ( + <> + + Choose the permissions you want to assign to this permission set. Microsoft Graph + is the default Service Principal added and you can choose to add additional + Service Principals as needed. Note that some Service Principals do not have any + published permissions to choose from. + + + + + )} + + + + + + + ); +}; + +export default CippPermissionSetDrawer; diff --git a/src/components/CippComponents/CippPolicyDeployDrawer.jsx b/src/components/CippComponents/CippPolicyDeployDrawer.jsx new file mode 100644 index 000000000000..619f516496cd --- /dev/null +++ b/src/components/CippComponents/CippPolicyDeployDrawer.jsx @@ -0,0 +1,223 @@ +import { useEffect, useState } from "react"; +import { Button, Stack, Box } from "@mui/material"; +import { RocketLaunch } from "@mui/icons-material"; +import { useForm, useWatch, useFormState } from "react-hook-form"; +import { CippOffCanvas } from "./CippOffCanvas"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; +import CippFormComponent from "./CippFormComponent"; +import CippJsonView from "../CippFormPages/CippJSONView"; +import { Grid } from "@mui/system"; +import { CippFormCondition } from "./CippFormCondition"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { CippFormTenantSelector } from "./CippFormTenantSelector"; + +export const CippPolicyDeployDrawer = ({ + buttonText = "Deploy Policy", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const formControl = useForm({ + mode: "onChange", + }); + const { isValid } = useFormState({ control: formControl.control }); + const tenantFilter = useSettings()?.tenantFilter; + const selectedTenants = useWatch({ control: formControl.control, name: "tenantFilter" }) || []; + const CATemplates = ApiGetCall({ url: "/api/ListIntuneTemplates", queryKey: "IntuneTemplates" }); + const [JSONData, setJSONData] = useState(); + const watcher = useWatch({ control: formControl.control, name: "TemplateList" }); + const jsonWatch = useWatch({ control: formControl.control, name: "RAWJson" }); + useEffect(() => { + if (CATemplates.isSuccess && watcher?.value) { + const template = CATemplates.data?.find((template) => template.GUID === watcher.value); + if (template) { + const jsonTemplate = template.RAWJson ? JSON.parse(template.RAWJson) : null; + setJSONData(jsonTemplate); + formControl.setValue("RAWJson", template.RAWJson); + formControl.setValue("displayName", template.Displayname); + formControl.setValue("description", template.Description); + formControl.setValue("TemplateType", template.Type); + } + } + }, [watcher]); + const deployPolicy = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: [ + "IntuneTemplates", + `Configuration Policies - ${tenantFilter}`, + `Compliance Policies - ${tenantFilter}`, + `Protection Policies - ${tenantFilter}`, + ], + }); + + const handleSubmit = () => { + formControl.trigger(); + // Check if the form is valid before proceeding + if (!isValid) { + return; + } + + const formData = formControl.getValues(); + console.log("Submitting form data:", formData); + deployPolicy.mutate({ + url: "/api/AddPolicy", + relatedQueryKeys: [ + "IntuneTemplates", + "Configuration Policies", + "Compliance Policies", + "Protection Policies", + ], + data: { ...formData }, + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset(); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + + ({ + label: template.Displayname, + value: template.GUID, + })) + : [] + } + /> + + + + + + + + + + + + + + {(() => { + const rawJson = jsonWatch ? jsonWatch : ""; + const placeholderMatches = [...rawJson.matchAll(/%(\w+)%/g)].map((m) => m[1]); + const uniquePlaceholders = Array.from(new Set(placeholderMatches)); + if (uniquePlaceholders.length === 0 || selectedTenants.length === 0) { + return null; + } + return uniquePlaceholders.map((placeholder) => ( + + {selectedTenants.map((tenant, idx) => ( + + ))} + + )); + })()} + + + + + + ); +}; diff --git a/src/components/CippComponents/CippPolicyImportDrawer.jsx b/src/components/CippComponents/CippPolicyImportDrawer.jsx new file mode 100644 index 000000000000..bbe5b3a03dc4 --- /dev/null +++ b/src/components/CippComponents/CippPolicyImportDrawer.jsx @@ -0,0 +1,516 @@ +import { useState } from "react"; +import { + Button, + Stack, + TextField, + Typography, + Box, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Skeleton, +} from "@mui/material"; +import { CloudUpload, Search, Visibility } from "@mui/icons-material"; +import { useForm, useWatch } from "react-hook-form"; +import { CippOffCanvas } from "./CippOffCanvas"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; +import CippFormComponent from "./CippFormComponent"; +import CippJsonView from "../CippFormPages/CippJSONView"; +import { CippApiResults } from "./CippApiResults"; +import { CippFormTenantSelector } from "./CippFormTenantSelector"; +import { CippFolderNavigation } from "./CippFolderNavigation"; + +export const CippPolicyImportDrawer = ({ + buttonText = "Browse Catalog", + requiredPermissions = [], + PermissionButton = Button, + mode = "Intune", +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [viewDialogOpen, setViewDialogOpen] = useState(false); + const [viewingPolicy, setViewingPolicy] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + const formControl = useForm(); + + const selectedSource = useWatch({ control: formControl.control, name: "policySource" }); + const tenantFilter = useWatch({ control: formControl.control, name: "tenantFilter" }); + + // API calls + const communityRepos = ApiGetCall({ + url: "/api/ListCommunityRepos", + queryKey: "CommunityRepos-List", + }); + + const tenantPolicies = ApiGetCall({ + url: + mode === "ConditionalAccess" + ? `/api/ListCATemplates?TenantFilter=${tenantFilter?.value || ""}` + : mode === "Standards" + ? `/api/listStandardTemplates?TenantFilter=${tenantFilter?.value || ""}` + : `/api/ListIntunePolicy?type=ESP&TenantFilter=${tenantFilter?.value || ""}`, + queryKey: `TenantPolicies-${mode}-${tenantFilter?.value || "none"}`, + }); + + const repoPolicies = ApiGetCall({ + url: `/api/ExecGitHubAction?Action=GetFileTree&FullName=${ + selectedSource?.value || "" + }&Branch=main`, + queryKey: `RepoPolicies-${mode}-${selectedSource?.value || "none"}`, + enabled: !!(selectedSource?.value && selectedSource?.value !== "tenant"), + }); + + const repositoryFiles = ApiGetCall({ + url: `/api/ExecGitHubAction?Action=GetFileTree&FullName=${ + selectedSource?.value || "" + }&Branch=main`, + queryKey: `RepositoryFiles-${selectedSource?.value || "none"}`, + enabled: !!(selectedSource?.value && selectedSource?.value !== "tenant"), + }); + + const importPolicy = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: + mode === "ConditionalAccess" + ? ["ListCATemplates-table"] + : mode === "Standards" + ? ["listStandardTemplates"] + : ["ListIntuneTemplates-table", "ListIntuneTemplates-autcomplete"], + }); + + const viewPolicyQuery = ApiPostCall({ + onResult: (resp) => { + let content = resp?.Results?.content?.trim() || "{}"; + content = content.replace( + /^[\u0000-\u001F\u007F-\u009F]+|[\u0000-\u001F\u007F-\u009F]+$/g, + "" + ); + try { + setViewingPolicy(JSON.parse(content)); + } catch (e) { + console.error("Invalid JSON content:", e); + setViewingPolicy({}); + } + }, + }); + + const handleImportPolicy = (policy) => { + if (!policy) return; + + try { + if (selectedSource?.value === "tenant") { + // For tenant policies, use appropriate API based on mode + if (mode === "ConditionalAccess") { + // For Conditional Access, convert RawJSON to object and send the contents + let policyData = policy; + + // If the policy has RawJSON, parse it and use that as the data + if (policy.RawJSON) { + try { + policyData = JSON.parse(policy.RawJSON); + } catch (e) { + console.error("Failed to parse RawJSON:", e); + policyData = policy; + } + } + + // Send the object contents directly with tenantFilter + const caTemplateData = { + tenantFilter: tenantFilter?.value, + ...policyData, + }; + + importPolicy.mutate({ + url: "/api/AddCATemplate", + data: caTemplateData, + }); + } else if (mode === "Standards") { + // For Standards templates, clone the template + importPolicy.mutate({ + url: "/api/AddStandardTemplate", + data: { + tenantFilter: tenantFilter?.value, + templateId: policy.GUID, + clone: true, + }, + }); + } else { + // For Intune policies, use existing format + importPolicy.mutate({ + url: "/api/AddIntuneTemplate", + data: { + tenantFilter: tenantFilter?.value, + ID: policy.id, + URLName: policy.URLName || "GroupPolicyConfigurations", + }, + }); + } + } else { + // For community repository files, use ExecCommunityRepo + importPolicy.mutate({ + url: "/api/ExecCommunityRepo", + data: { + tenantFilter: tenantFilter?.value || "AllTenants", + Action: "ImportTemplate", + FullName: selectedSource?.value, + Path: policy.path, + Branch: "main", + Type: mode, + }, + }); + } + } catch (error) { + console.error("Error importing policy:", error); + } + }; + + const handleViewPolicy = (policy) => { + if (!policy) return; + + try { + if (selectedSource?.value !== "tenant" && selectedSource?.value) { + // For community repository files, fetch the file content + viewPolicyQuery.mutate({ + url: "/api/ExecGitHubAction", + data: { + Action: "GetFileContents", + FullName: selectedSource.value, + Path: policy.path || "", + Branch: "main", + }, + }); + } else { + // For tenant policies, use the policy object directly + setViewingPolicy(policy || {}); + } + setViewDialogOpen(true); + } catch (error) { + console.error("Error viewing policy:", error); + } + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + setSearchQuery(""); + setViewingPolicy(null); + setSelectedFile(null); + // Don't reset form at all to avoid any potential issues + }; + + const handleFileSelect = (file) => { + setSelectedFile(file); + }; + + const handleCloseViewDialog = () => { + setViewDialogOpen(false); + setViewingPolicy(null); + }; + + const formatPolicyName = (policy) => { + // Safety check + if (!policy) return "Unnamed Policy"; + + // For tenant policies, use displayName or name + if (policy.displayName || policy.name) { + return policy.displayName || policy.name; + } + + // For repository files, format the path nicely + if (policy.path) { + try { + // Remove file extension + let name = policy.path.replace(/\.(json|yaml|yml)$/i, ""); + + // Remove directory path, keep only filename + name = name.split("/").pop(); + + // Replace underscores with spaces and clean up + name = name.replace(/_/g, " "); + + // Remove common prefixes like "CIPP_" + name = name.replace(/^CIPP\s*/i, ""); + + // Capitalize first letter of each word + name = name.replace(/\b\w/g, (l) => l.toUpperCase()); + + return name || "Unnamed Policy"; + } catch (error) { + console.warn("Error formatting policy name:", error); + return policy.path || "Unnamed Policy"; + } + } + + return "Unnamed Policy"; + }; + + // Get policies based on source + let availablePolicies = []; + if (selectedSource?.value === "tenant" && tenantPolicies.isSuccess && tenantFilter?.value) { + availablePolicies = Array.isArray(tenantPolicies.data) ? tenantPolicies.data : []; + } else if ( + selectedSource?.value && + selectedSource?.value !== "tenant" && + repoPolicies.isSuccess + ) { + const repoData = repoPolicies.data?.Results || repoPolicies.data || []; + availablePolicies = Array.isArray(repoData) ? repoData : []; + } + + const filteredPolicies = (() => { + if (!Array.isArray(availablePolicies)) return []; + + if (!searchQuery?.trim()) return availablePolicies; + + return availablePolicies.filter((policy) => { + if (!policy) return false; + const searchLower = searchQuery.toLowerCase(); + return ( + policy.displayName?.toLowerCase().includes(searchLower) || + policy.description?.toLowerCase().includes(searchLower) || + policy.name?.toLowerCase().includes(searchLower) || + policy.path?.toLowerCase().includes(searchLower) + ); + }); + })(); + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + } + > + + + ({ + label: `${repo?.Name || "Unknown"} (${repo?.URL || "Unknown"})`, + value: repo?.FullName || "", + })).filter((option) => option.value) + : []), + { label: "Get template from existing tenant", value: "tenant" }, + ]} + /> + + {selectedSource?.value === "tenant" && ( + + + + )} + + + {/* Content based on source */} + + {selectedSource?.value === "tenant" ? ( + // Tenant policies - show traditional list + <> + setSearchQuery(e.target.value)} + InputProps={{ + startAdornment: , + }} + placeholder="Search by policy name or description..." + /> + + Available Policies ({filteredPolicies.length}) + + {tenantPolicies.isLoading ? ( + <> + {[...Array(3)].map((_, index) => ( + + + + + + + + + ))} + + ) : Array.isArray(filteredPolicies) && filteredPolicies.length > 0 ? ( + filteredPolicies.map((policy, index) => { + if (!policy) return null; + return ( + + + + + + + {formatPolicyName(policy)} + + {policy?.description && ( + + {policy.description} + + )} + + + + ); + }) + ) : ( + + No policies available. + + )} + + + + ) : selectedSource?.value ? ( + // Repository source - show iOS-style folder navigation + <> + + Browse Repository Files + + {repositoryFiles.isLoading ? ( + + {/* Navigation skeleton */} + + + + + {/* File/folder list skeleton */} + + {[...Array(5)].map((_, index) => ( + + + + + + + + + ))} + + + ) : repositoryFiles.isSuccess ? ( + + + + ) : ( + + Unable to load repository files. + + )} + + + + + + ) : ( + + Please select a policy source to continue. + + )} + + + + + + + + + + Policy Details + + {viewPolicyQuery.isPending ? ( + + + + ) : ( + + )} + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippPropertyList.jsx b/src/components/CippComponents/CippPropertyList.jsx index 4c9fd3800820..e6b5fed8f1d0 100644 --- a/src/components/CippComponents/CippPropertyList.jsx +++ b/src/components/CippComponents/CippPropertyList.jsx @@ -53,7 +53,7 @@ export const CippPropertyList = (props) => { ) : ( // Two-column layout - { )) )} - + ) )} ); diff --git a/src/components/CippComponents/CippRestoreBackupDrawer.jsx b/src/components/CippComponents/CippRestoreBackupDrawer.jsx new file mode 100644 index 000000000000..f011b499820f --- /dev/null +++ b/src/components/CippComponents/CippRestoreBackupDrawer.jsx @@ -0,0 +1,373 @@ +import React, { useState, useEffect } from "react"; +import { Button, Box, Typography, Alert, AlertTitle, Divider, Chip, Stack } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useFormState } from "react-hook-form"; +import { SettingsBackupRestore } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormCondition } from "./CippFormCondition"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippRestoreBackupDrawer = ({ + buttonText = "Restore Backup", + backupName = null, + requiredPermissions = [], + PermissionButton = Button, + ...props +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const userSettingsDefaults = useSettings(); + const tenantFilter = userSettingsDefaults.currentTenant || ""; + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + tenantFilter: tenantFilter, + users: true, + groups: true, + ca: true, + intuneconfig: true, + intunecompliance: true, + intuneprotection: true, + antispam: true, + antiphishing: true, + CippWebhookAlerts: true, + CippScriptedAlerts: true, + CippCustomVariables: true, + CippStandards: true, + overwrite: false, + webhook: false, + email: false, + psa: false, + backup: backupName ? { value: backupName, label: backupName } : null, + }, + }); + + const restoreBackup = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: [`BackupTasks-${tenantFilter}`], + }); + + const { isValid, isDirty } = useFormState({ control: formControl.control }); + + useEffect(() => { + if (restoreBackup.isSuccess) { + formControl.reset({ + tenantFilter: tenantFilter, + users: true, + groups: true, + ca: true, + intuneconfig: true, + intunecompliance: true, + intuneprotection: true, + antispam: true, + antiphishing: true, + CippWebhookAlerts: true, + CippScriptedAlerts: true, + CippCustomVariables: true, + CippStandards: true, + overwrite: false, + webhook: false, + email: false, + psa: false, + backup: backupName ? { value: backupName, label: backupName } : null, + }); + } + }, [restoreBackup.isSuccess]); + + const handleSubmit = () => { + formControl.trigger(); + if (!isValid) { + return; + } + const values = formControl.getValues(); + const startDate = new Date(); + const unixTime = Math.floor(startDate.getTime() / 1000) - 45; + const tenantFilterValue = tenantFilter; + + const shippedValues = { + TenantFilter: tenantFilterValue, + Name: `CIPP Restore ${tenantFilterValue}`, + Command: { value: `New-CIPPRestore` }, + Parameters: { + Type: "Scheduled", + RestoreValues: { + backup: values.backup?.value || values.backup, + users: values.users, + groups: values.groups, + ca: values.ca, + intuneconfig: values.intuneconfig, + intunecompliance: values.intunecompliance, + intuneprotection: values.intuneprotection, + antispam: values.antispam, + antiphishing: values.antiphishing, + CippWebhookAlerts: values.CippWebhookAlerts, + CippScriptedAlerts: values.CippScriptedAlerts, + CippCustomVariables: values.CippCustomVariables, + overwrite: values.overwrite, + }, + }, + ScheduledTime: unixTime, + PostExecution: { + Webhook: values.webhook, + Email: values.email, + PSA: values.psa, + }, + DisallowDuplicateName: true, + }; + + restoreBackup.mutate({ + url: "/api/AddScheduledItem", + data: shippedValues, + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + tenantFilter: tenantFilter, + users: true, + groups: true, + ca: true, + intuneconfig: true, + intunecompliance: true, + intuneprotection: true, + antispam: true, + antiphishing: true, + CippWebhookAlerts: true, + CippScriptedAlerts: true, + CippCustomVariables: true, + CippStandards: true, + overwrite: false, + webhook: false, + email: false, + psa: false, + backup: backupName ? { value: backupName, label: backupName } : null, + }); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + {...props} + > + {buttonText} + + + + + + } + > + + + Use this form to restore a backup for a tenant. Please select the backup and restore + options. + + + + {/* Backup Selector */} + + { + const match = option.BackupName.match(/.*_(\d{4}-\d{2}-\d{2})-(\d{2})(\d{2})/); + return match ? `${match[1]} @ ${match[2]}:${match[3]}` : option.BackupName; + }, + valueField: "BackupName", + data: { + Type: "Scheduled", + NameOnly: true, + tenantFilter: tenantFilter, + }, + }} + formControl={formControl} + required={true} + validators={{ + validate: (value) => !!value || "Please select a backup", + }} + /> + + + {/* Restore Settings */} + + Restore Settings + + + {/* Identity */} + + Identity + + + + + {/* Conditional Access */} + + Conditional Access + + + + {/* Intune */} + + Intune + + + + + + {/* Email Security */} + + Email Security + + + + + {/* CIPP */} + + CIPP + + + + + + {/* Overwrite Existing Entries */} + + + + + + Warning: Overwriting existing entries will remove the current + settings and replace them with the backup settings. If you have selected to + restore users, all properties will be overwritten with the backup settings. To + prevent and skip already existing entries, deselect the setting from the list + above, or disable overwrite. + + + + + + {/* Send Results To */} + + Send Restore results to: + + + + + + + + + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippScheduledTaskActions.jsx b/src/components/CippComponents/CippScheduledTaskActions.jsx new file mode 100644 index 000000000000..2df628a81cdb --- /dev/null +++ b/src/components/CippComponents/CippScheduledTaskActions.jsx @@ -0,0 +1,70 @@ +import { EyeIcon, TrashIcon } from "@heroicons/react/24/outline"; +import { CopyAll, Edit, PlayArrow } from "@mui/icons-material"; +import { usePermissions } from "../../hooks/use-permissions"; + +export const CippScheduledTaskActions = (drawerHandlers = {}) => { + const { checkPermissions } = usePermissions(); + const canWriteScheduler = checkPermissions(["CIPP.Scheduler.ReadWrite"]); + const canReadScheduler = checkPermissions(["CIPP.Scheduler.Read", "CIPP.Scheduler.ReadWrite"]); + + return [ + { + label: "View Task Details", + link: "/cipp/scheduler/task?id=[RowKey]", + icon: , + condition: () => canReadScheduler, + }, + { + label: "Run Now", + type: "POST", + url: "/api/AddScheduledItem", + data: { RowKey: "RowKey", RunNow: true }, + icon: , + confirmText: "Are you sure you want to run [Name]?", + allowResubmit: true, + condition: () => canWriteScheduler, + }, + { + label: "Edit Job", + customFunction: + drawerHandlers.openEditDrawer || + ((row) => { + // Fallback to page navigation if no drawer handler provided + window.location.href = `/cipp/scheduler/job?id=${row.RowKey}`; + }), + multiPost: false, + icon: , + color: "success", + showInActionsMenu: true, + noConfirm: true, + condition: () => canWriteScheduler, + }, + { + label: "Clone Job", + customFunction: + drawerHandlers.openCloneDrawer || + ((row) => { + // Fallback to page navigation if no drawer handler provided + window.location.href = `/cipp/scheduler/job?id=${row.RowKey}&Clone=True`; + }), + multiPost: false, + icon: , + color: "success", + showInActionsMenu: true, + noConfirm: true, + condition: () => canWriteScheduler, + }, + { + label: "Delete Job", + icon: , + type: "POST", + url: "/api/RemoveScheduledItem", + data: { id: "RowKey" }, + confirmText: "Are you sure you want to delete this job?", + multiPost: false, + condition: () => canWriteScheduler, + }, + ]; +}; + +export default CippScheduledTaskActions; diff --git a/src/components/CippComponents/CippSchedulerDrawer.jsx b/src/components/CippComponents/CippSchedulerDrawer.jsx new file mode 100644 index 000000000000..98510e77ce53 --- /dev/null +++ b/src/components/CippComponents/CippSchedulerDrawer.jsx @@ -0,0 +1,97 @@ +import { useState, useEffect } from "react"; +import { Button, Box, Typography, Alert, AlertTitle } from "@mui/material"; +import { useForm, useFormState } from "react-hook-form"; +import { CalendarDaysIcon } from "@heroicons/react/24/outline"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippSchedulerForm from "../CippFormPages/CippSchedulerForm"; +import { useSettings } from "../../hooks/use-settings"; + +export const CippSchedulerDrawer = ({ + buttonText = "Add Task", + requiredPermissions = [], + PermissionButton = Button, + onSuccess, + onClose, + taskId = null, + cloneMode = false, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const [formKey, setFormKey] = useState(0); + const userSettingsDefaults = useSettings(); + + const formControl = useForm({ + mode: "onBlur", + defaultValues: { + tenantFilter: userSettingsDefaults.currentTenant, + Recurrence: { value: "0", label: "Once" }, + taskType: { value: "scheduled", label: "Scheduled Task" }, + }, + }); + + const handleCloseDrawer = () => { + setDrawerVisible(false); + // Increment form key to force complete remount when reopening + setFormKey((prev) => prev + 1); + // Call onClose callback if provided (to clear parent state) + if (onClose) { + onClose(); + } + // Add a small delay before resetting to ensure drawer is closed + setTimeout(() => { + // Reset form to default values + formControl.reset({ + tenantFilter: userSettingsDefaults.currentTenant, + Recurrence: { value: "0", label: "Once" }, + taskType: { value: "scheduled", label: "Scheduled Task" }, + }); + }, 100); + }; + + const handleOpenDrawer = () => { + setDrawerVisible(true); + }; + + // Auto-open drawer if taskId is provided (for edit mode) + useEffect(() => { + if (taskId) { + setDrawerVisible(true); + } + }, [taskId]); + + return ( + <> + } + > + {buttonText} + + + + + Task Configuration + {taskId && cloneMode + ? "Clone this task with the same configuration. Modify the settings as needed and save to create a new task." + : taskId + ? "Edit the task configuration. Changes will be applied when you save." + : "Create a scheduled task or event-triggered task. Scheduled tasks run PowerShell commands at specified times, while triggered tasks respond to events like Azure AD changes."} + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippSettingsSideBar.jsx b/src/components/CippComponents/CippSettingsSideBar.jsx index cb08993a8ea1..693c55673d77 100644 --- a/src/components/CippComponents/CippSettingsSideBar.jsx +++ b/src/components/CippComponents/CippSettingsSideBar.jsx @@ -14,29 +14,118 @@ import CippFormComponent from "./CippFormComponent"; import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import { getCippError } from "../../utils/get-cipp-error"; import { useFormState } from "react-hook-form"; +import { useEffect } from "react"; export const CippSettingsSideBar = (props) => { - const { formcontrol, ...others } = props; + const { formcontrol, initialUserType, ...others } = props; const { isDirty, isValid } = useFormState({ control: formcontrol.control }); const currentUser = ApiGetCall({ - url: "/.auth/me", + url: "/api/me", queryKey: "authmecipp", - staleTime: 120000, - refetchOnWindowFocus: true, }); const saveSettingsPost = ApiPostCall({ url: "/api/ExecUserSettings", + relatedQueryKeys: "userSettings", }); + + // Set the correct default value once we have the initial user type and current user data + useEffect(() => { + if (initialUserType && currentUser.data?.clientPrincipal?.userDetails) { + const defaultUserOption = + initialUserType === "currentUser" + ? { + label: "Current User", + value: currentUser.data.clientPrincipal.userDetails, + } + : { + label: "All Users", + value: "allUsers", + }; + + // Only set if not already set to avoid infinite loops + const currentUserValue = formcontrol.getValues("user"); + if (!currentUserValue || currentUserValue.value !== defaultUserOption.value) { + formcontrol.setValue("user", defaultUserOption); + } + } + }, [initialUserType, currentUser.data?.clientPrincipal?.userDetails, formcontrol]); + const handleSaveChanges = () => { + const formValues = formcontrol.getValues(); + + // Only include the specific form fields from preferences.js to avoid unmapped data + const currentSettings = { + // General Settings + usageLocation: formValues.usageLocation, + tablePageSize: formValues.tablePageSize, + userAttributes: formValues.userAttributes, + + // Table Filter Preferences + persistFilters: formValues.persistFilters, + + // Portal Links Configuration + portalLinks: { + M365_Portal: formValues.portalLinks?.M365_Portal, + Exchange_Portal: formValues.portalLinks?.Exchange_Portal, + Entra_Portal: formValues.portalLinks?.Entra_Portal, + Teams_Portal: formValues.portalLinks?.Teams_Portal, + Azure_Portal: formValues.portalLinks?.Azure_Portal, + Intune_Portal: formValues.portalLinks?.Intune_Portal, + SharePoint_Admin: formValues.portalLinks?.SharePoint_Admin, + Security_Portal: formValues.portalLinks?.Security_Portal, + Compliance_Portal: formValues.portalLinks?.Compliance_Portal, + Power_Platform_Portal: formValues.portalLinks?.Power_Platform_Portal, + Power_BI_Portal: formValues.portalLinks?.Power_BI_Portal, + }, + + // Offboarding Defaults + offboardingDefaults: { + ConvertToShared: formValues.offboardingDefaults?.ConvertToShared, + RemoveGroups: formValues.offboardingDefaults?.RemoveGroups, + HideFromGAL: formValues.offboardingDefaults?.HideFromGAL, + RemoveLicenses: formValues.offboardingDefaults?.RemoveLicenses, + removeCalendarInvites: formValues.offboardingDefaults?.removeCalendarInvites, + RevokeSessions: formValues.offboardingDefaults?.RevokeSessions, + removePermissions: formValues.offboardingDefaults?.removePermissions, + RemoveRules: formValues.offboardingDefaults?.RemoveRules, + ResetPass: formValues.offboardingDefaults?.ResetPass, + KeepCopy: formValues.offboardingDefaults?.KeepCopy, + DeleteUser: formValues.offboardingDefaults?.DeleteUser, + RemoveMobile: formValues.offboardingDefaults?.RemoveMobile, + DisableSignIn: formValues.offboardingDefaults?.DisableSignIn, + RemoveMFADevices: formValues.offboardingDefaults?.RemoveMFADevices, + RemoveTeamsPhoneDID: formValues.offboardingDefaults?.RemoveTeamsPhoneDID, + ClearImmutableId: formValues.offboardingDefaults?.ClearImmutableId, + }, + }; + const shippedValues = { user: formcontrol.getValues("user").value, - currentSettings: formcontrol.getValues(), + currentSettings: currentSettings, }; saveSettingsPost.mutate({ url: "/api/ExecUserSettings", data: shippedValues }); }; + // Create user options based on current user data + const getUserOptions = () => { + if (!currentUser.data?.clientPrincipal?.userDetails) { + return []; + } + + return [ + { + label: "Current User", + value: currentUser.data.clientPrincipal.userDetails, + }, + { + label: "All Users", + value: "allUsers", + }, + ]; + }; + return ( <> @@ -53,15 +142,8 @@ export const CippSettingsSideBar = (props) => { disableClearable={true} name="user" formControl={formcontrol} - defaultValue={{ - label: "Current User", - value: currentUser.data?.clientPrincipal?.userDetails, - }} multiple={false} - options={[ - { label: "Current User", value: currentUser.data?.clientPrincipal?.userDetails }, - { label: "All Users", value: "allUsers" }, - ]} + options={getUserOptions()} /> {saveSettingsPost.isError && ( diff --git a/src/components/CippComponents/CippSharedMailboxDrawer.jsx b/src/components/CippComponents/CippSharedMailboxDrawer.jsx new file mode 100644 index 000000000000..2efaed2a67f7 --- /dev/null +++ b/src/components/CippComponents/CippSharedMailboxDrawer.jsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from "react"; +import { Button } from "@mui/material"; +import { useForm } from "react-hook-form"; +import { Add } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormDomainSelector } from "./CippFormDomainSelector"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiPostCall } from "../../api/ApiCall"; +import { Grid } from "@mui/system"; + +export const CippSharedMailboxDrawer = ({ + buttonText = "Add Shared Mailbox", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const tenantDomain = useSettings().currentTenant; + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + displayName: "", + username: "", + domain: null, + }, + }); + + const createSharedMailbox = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: ["Mailboxes"], + }); + + const handleSubmit = () => { + const formData = formControl.getValues(); + const postData = { + tenantID: tenantDomain, + displayName: formData.displayName, + username: formData.username, + domain: formData.domain?.value, + }; + createSharedMailbox.mutate({ + url: "/api/AddSharedMailbox", + data: postData, + relatedQueryKeys: ["Mailboxes"], + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + displayName: "", + username: "", + domain: null, + }); + }; + + // Reset form on successful creation, preserving the selected domain + useEffect(() => { + if (createSharedMailbox.isSuccess) { + const domain = formControl.getValues("domain"); + formControl.reset({ + displayName: "", + username: "", + domain: domain, + }); + } + }, [createSharedMailbox.isSuccess, formControl]); + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippTableDialog.jsx b/src/components/CippComponents/CippTableDialog.jsx index 59ec73436efd..e31d4485263b 100644 --- a/src/components/CippComponents/CippTableDialog.jsx +++ b/src/components/CippComponents/CippTableDialog.jsx @@ -1,28 +1,30 @@ -import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; -import { Stack } from "@mui/system"; -import { CippDataTable } from "../CippTable/CippDataTable"; - -export const CippTableDialog = (props) => { - const { createDialog, title, fields, api, simpleColumns, ...other } = props; - return ( - - {title} - - - - - - - - - - ); -}; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; +import { Stack } from "@mui/system"; +import { CippDataTable } from "../CippTable/CippDataTable"; + +export const CippTableDialog = (props) => { + const { createDialog, title, fields, api, simpleColumns, ...other } = props; + + return ( + + {title} + + + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippTablePage.jsx b/src/components/CippComponents/CippTablePage.jsx index eb75f6cc1ad1..5ab4223b933d 100644 --- a/src/components/CippComponents/CippTablePage.jsx +++ b/src/components/CippComponents/CippTablePage.jsx @@ -1,6 +1,5 @@ import { Alert, Card, Divider } from "@mui/material"; import { Box, Container, Stack } from "@mui/system"; -import Head from "next/head"; import { CippDataTable } from "../CippTable/CippDataTable"; import { useSettings } from "../../hooks/use-settings"; import { CippHead } from "./CippHead"; diff --git a/src/components/CippComponents/CippTemplateEditor.jsx b/src/components/CippComponents/CippTemplateEditor.jsx new file mode 100644 index 000000000000..e223abf49962 --- /dev/null +++ b/src/components/CippComponents/CippTemplateEditor.jsx @@ -0,0 +1,317 @@ +import React, { useEffect, useState } from "react"; +import { Box, Typography, Divider } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm } from "react-hook-form"; +import CippFormPage from "/src/components/CippFormPages/CippFormPage"; +import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; +import CippFormSkeleton from "/src/components/CippFormPages/CippFormSkeleton"; +import { ApiGetCall } from "/src/api/ApiCall"; +import { getCippTranslation } from "/src/utils/get-cipp-translation"; + +const CippTemplateEditor = ({ + templateId, + templateType, + apiConfig, + schemaConfig, + blacklistConfig, + priorityFields = [], + title, + backButtonTitle, + customDataFormatter, +}) => { + const [templateData, setTemplateData] = useState(null); + + // Default blacklist patterns that apply to all template types + const defaultBlacklistPatterns = [ + "id", + "createdDateTime", + "modifiedDateTime", + "@odata.*", + "GUID", + "Type", + "times", + "tenantFilter", + "*Id", + "*DateTime", + ]; + + // Combine default and custom blacklist patterns + const blacklistedFields = [ + ...defaultBlacklistPatterns, + ...(blacklistConfig?.patterns || []), + ]; + + const formControl = useForm({ mode: "onChange" }); + + // Fetch the template data + const templateQuery = ApiGetCall({ + url: `${apiConfig.fetchUrl}?${apiConfig.idParam}=${templateId}`, + queryKey: `${templateType}-${templateId}`, + enabled: !!templateId, + }); + + // Function to check if a field matches any blacklisted pattern (including wildcards) + const isFieldBlacklisted = (fieldName) => { + return blacklistedFields.some(pattern => { + if (pattern.includes('*')) { + // Convert wildcard pattern to regex + const regexPattern = pattern + .replace(/\*/g, '.*') + .replace(/\./g, '\\.'); + const regex = new RegExp(`^${regexPattern}$`, 'i'); + return regex.test(fieldName); + } + return pattern === fieldName; + }); + }; + + useEffect(() => { + if (templateQuery.isSuccess && templateQuery.data) { + // Find the template with matching ID + const template = Array.isArray(templateQuery.data) + ? templateQuery.data.find((t) => t[apiConfig.idParam] === templateId) + : templateQuery.data; + + if (template) { + setTemplateData(template); + // Set form values excluding blacklisted fields + const formValues = {}; + Object.keys(template).forEach((key) => { + if (!isFieldBlacklisted(key)) { + formValues[key] = template[key]; + } + }); + formControl.reset(formValues); + } + } + }, [templateQuery.isSuccess, templateQuery.data, templateId]); + + const renderFormField = (key, value, path = "") => { + const fieldPath = path ? `${path}.${key}` : key; + + if (isFieldBlacklisted(key)) { + return null; + } + + // Check for custom schema handling + const schemaField = schemaConfig?.fields?.[key.toLowerCase()]; + if (schemaField) { + return ( + + + + ); + } + + // Special handling for complex array fields (like LocationInfo and GroupInfo) + if (schemaConfig?.complexArrayFields?.some(pattern => + key.toLowerCase().includes(pattern.toLowerCase()) + )) { + // Don't render if value is null, undefined, empty array, or contains only null/empty items + if ( + !value || + (Array.isArray(value) && value.length === 0) || + (Array.isArray(value) && + value.every( + (item) => + item === null || + item === undefined || + (typeof item === "string" && item.trim() === "") || + (typeof item === "object" && item !== null && Object.keys(item).length === 0) + )) + ) { + return null; + } + + return ( + + + {getCippTranslation(key)} + + + + {Array.isArray(value) ? ( + value + .filter( + (item) => + item !== null && + item !== undefined && + !(typeof item === "string" && item.trim() === "") && + !(typeof item === "object" && item !== null && Object.keys(item).length === 0) + ) + .map((item, index) => ( + + + {getCippTranslation(key)} {index + 1} + + + {typeof item === "object" && item !== null ? ( + Object.entries(item).map(([subKey, subValue]) => + renderFormField(subKey, subValue, `${fieldPath}.${index}`) + ) + ) : ( + + + + )} + + + )) + ) : ( + + + No {getCippTranslation(key)} data available + + + )} + + + ); + } + + // Generic field type handling + if (typeof value === "boolean") { + return ( + + + + ); + } + + if (typeof value === "string") { + return ( + + + + ); + } + + if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + return ( + + ({ label: item, value: item }))} + /> + + ); + } + + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return ( + + + {getCippTranslation(key)} + + + + {Object.entries(value).map(([subKey, subValue]) => + renderFormField(subKey, subValue, fieldPath) + )} + + + ); + } + + // For other types (numbers, complex arrays, etc.), render as text field + return ( + + + + ); + }; + + const defaultDataFormatter = (values) => { + return { + [apiConfig.idParam]: templateId, + ...values, + }; + }; + + if (templateQuery.isLoading) { + return ( + + + + ); + } + + if (templateQuery.isError || !templateData) { + return ( + + + Error loading template or template not found. + + + ); + } + + return ( + + + + Edit the properties of this template. Only editable properties are shown below. + + + + {templateData && ( + <> + {/* Render priority fields first */} + {priorityFields.map(fieldName => + templateData[fieldName] !== undefined && + renderFormField(fieldName, templateData[fieldName]) + )} + + {/* Render all other fields except priority fields */} + {Object.entries(templateData) + .filter(([key]) => !priorityFields.includes(key)) + .map(([key, value]) => renderFormField(key, value))} + + )} + + + + ); +}; + +export default CippTemplateEditor; \ No newline at end of file diff --git a/src/components/CippComponents/CippTemplateFieldRenderer.jsx b/src/components/CippComponents/CippTemplateFieldRenderer.jsx new file mode 100644 index 000000000000..7fae93ec413f --- /dev/null +++ b/src/components/CippComponents/CippTemplateFieldRenderer.jsx @@ -0,0 +1,705 @@ +import React from "react"; +import { Typography, Divider } from "@mui/material"; +import { Grid } from "@mui/system"; +import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; +import { getCippTranslation } from "/src/utils/get-cipp-translation"; +import intuneCollection from "/src/data/intuneCollection.json"; + +const CippTemplateFieldRenderer = ({ + templateData, + formControl, + templateType = "conditionalAccess", +}) => { + // Default blacklisted fields with wildcard support + const defaultBlacklistedFields = [ + "id", + "isAssigned", + "createdDateTime", + "modifiedDateTime", + "@odata.*", + "GUID", + "Type", + "times", + "tenantFilter", + "*Id", + "*DateTime", + ]; + + // Template-specific configurations + const templateConfigs = { + conditionalAccess: { + blacklistedFields: [ + ...defaultBlacklistedFields, + "membershipKind", + "countryLookupMethod", + "applicationFilter", + "includeAuthenticationContextClassReferences", + ], + priorityFields: ["displayName", "state", "DisplayName", "Name", "displayname"], + complexArrayFields: ["locationinfo", "groupinfo"], + schemaFields: { + operator: { + multiple: false, + options: [ + { label: "OR", value: "OR" }, + { label: "AND", value: "AND" }, + ], + }, + builtincontrols: { + multiple: true, + options: [ + { label: "Block", value: "block" }, + { label: "Multi-factor Authentication", value: "mfa" }, + { label: "Compliant Device", value: "compliantDevice" }, + { label: "Domain Joined Device", value: "domainJoinedDevice" }, + { label: "Approved Application", value: "approvedApplication" }, + { label: "Compliant Application", value: "compliantApplication" }, + { label: "Password Change", value: "passwordChange" }, + { label: "Unknown Future Value", value: "unknownFutureValue" }, + ], + }, + authenticationtype: { + multiple: false, + options: [ + { + label: "Primary and Secondary Authentication", + value: "primaryAndSecondaryAuthentication", + }, + { label: "Secondary Authentication", value: "secondaryAuthentication" }, + { label: "Unknown Future Value", value: "unknownFutureValue" }, + ], + }, + frequencyinterval: { + multiple: false, + options: [ + { label: "Time Based", value: "timeBased" }, + { label: "Every Time", value: "everyTime" }, + { label: "Unknown Future Value", value: "unknownFutureValue" }, + ], + }, + state: { + multiple: false, + options: [ + { label: "Enabled", value: "enabled" }, + { label: "Disabled", value: "disabled" }, + { label: "Enabled for Reporting", value: "enabledForReportingButNotEnforced" }, + ], + }, + }, + }, + intune: { + blacklistedFields: [ + ...defaultBlacklistedFields, + "deviceManagementApplicabilityRuleOsEdition", + "deviceManagementApplicabilityRuleOsVersion", + "deviceManagementApplicabilityRuleDeviceMode", + "roleScopeTagIds", + "supportsScopeTags", + "deviceSettingStateSummaries", + "RAWJson", // Handle RAWJson specially + ], + priorityFields: ["displayName", "description", "DisplayName", "Name", "displayname"], + complexArrayFields: ["assignments", "devicestatusoverview"], + schemaFields: { + devicecompliancepolicystate: { + multiple: false, + options: [ + { label: "Unknown", value: "unknown" }, + { label: "Compliant", value: "compliant" }, + { label: "Noncompliant", value: "noncompliant" }, + { label: "Conflict", value: "conflict" }, + { label: "Error", value: "error" }, + { label: "In Grace Period", value: "inGracePeriod" }, + { label: "Config Manager", value: "configManager" }, + ], + }, + // Common device policy enum values + applicationguardenabledoptions: { + multiple: false, + options: [ + { label: "Not Configured", value: "notConfigured" }, + { label: "Enabled for Edge", value: "enabledForEdge" }, + { label: "Enabled for Office", value: "enabledForOffice" }, + { label: "Enabled for Edge and Office", value: "enabledForEdgeAndOffice" }, + ], + }, + firewallcertificaterevocationlistcheckmethod: { + multiple: false, + options: [ + { label: "Device Default", value: "deviceDefault" }, + { label: "None", value: "none" }, + { label: "Attempt", value: "attempt" }, + { label: "Require", value: "require" }, + ], + }, + firewallpacketqueueingmethod: { + multiple: false, + options: [ + { label: "Device Default", value: "deviceDefault" }, + { label: "Disabled", value: "disabled" }, + { label: "Queue Inbound", value: "queueInbound" }, + { label: "Queue Outbound", value: "queueOutbound" }, + { label: "Queue Both", value: "queueBoth" }, + ], + }, + startupmode: { + multiple: false, + options: [ + { label: "Manual", value: "manual" }, + { label: "Automatic", value: "automatic" }, + { label: "Disabled", value: "disabled" }, + ], + }, + applicationguardblockclipboardsharing: { + multiple: false, + options: [ + { label: "Not Configured", value: "notConfigured" }, + { label: "Block Both", value: "blockBoth" }, + { label: "Block Host to Container", value: "blockHostToContainer" }, + { label: "Block Container to Host", value: "blockContainerToHost" }, + { label: "Block None", value: "blockNone" }, + ], + }, + bitlockerrecoverypasswordrotation: { + multiple: false, + options: [ + { label: "Not Configured", value: "notConfigured" }, + { label: "Disabled", value: "disabled" }, + { label: "Enabled for Azure AD Joined", value: "enabledForAzureAd" }, + { + label: "Enabled for Azure AD and Hybrid Joined", + value: "enabledForAzureAdAndHybrid", + }, + ], + }, + bitlockerprebootrecoverymsgurloption: { + multiple: false, + options: [ + { label: "Default", value: "default" }, + { label: "Use Custom", value: "useCustom" }, + { label: "No URL", value: "noUrl" }, + ], + }, + }, + }, + exchange: { + blacklistedFields: [ + ...defaultBlacklistedFields, + "ExchangeVersion", + "DistinguishedName", + "ObjectCategory", + "WhenChanged", + "WhenCreated", + ], + priorityFields: ["Name", "Identity"], + complexArrayFields: ["accepteddomains", "remotedomain"], + schemaFields: {}, + }, + }; + + // Get configuration for the current template type + const config = templateConfigs[templateType] || templateConfigs.conditionalAccess; + const { blacklistedFields, priorityFields, complexArrayFields, schemaFields } = config; + + // Function to check if a field matches any blacklisted pattern (including wildcards) + const isFieldBlacklisted = (fieldName) => { + return blacklistedFields.some((pattern) => { + if (pattern.includes("*")) { + // Convert wildcard pattern to regex + const regexPattern = pattern.replace(/\*/g, ".*").replace(/\./g, "\\."); + const regex = new RegExp(`^${regexPattern}$`, "i"); + return regex.test(fieldName); + } + return pattern === fieldName; + }); + }; + + // Parse RAWJson for Intune templates + const parseIntuneRawJson = (templateData) => { + if (templateType === "intune" && templateData.RAWJson) { + try { + const parsedJson = JSON.parse(templateData.RAWJson); + return { + ...templateData, + parsedRAWJson: parsedJson, + }; + } catch (error) { + console.warn("Failed to parse RAWJson:", error); + return templateData; + } + } + return templateData; + }; + + // Reset form with filtered values when templateData changes + React.useEffect(() => { + if (templateData && formControl) { + const processedData = parseIntuneRawJson(templateData); + const formValues = {}; + + Object.keys(processedData).forEach((key) => { + if (!isFieldBlacklisted(key)) { + formValues[key] = processedData[key]; + } + }); + formControl.reset(formValues); + } + }, [templateData]); + + const renderFormField = (key, value, path = "") => { + const fieldPath = path ? `${path}.${key}` : key; + + if (isFieldBlacklisted(key)) { + return null; + } + + // Check for custom schema handling + const schemaField = schemaFields[key.toLowerCase()]; + if (schemaField) { + return ( + + + + ); + } + + // Special handling for Intune RAWJson structure + if (templateType === "intune" && key === "parsedRAWJson" && value) { + // Check if this is a classic policy (has 'added' array) - these are not editable + if (value.added) { + return ( + + + This is a legacy policy and the settings cannot be edited through the form interface. + + + ); + } + + // Handle modern policies with settings array + if (value.settings && Array.isArray(value.settings)) { + return ( + + + Policy Settings + + + + {value.settings.map((setting, index) => { + const settingInstance = setting.settingInstance; + if (!settingInstance) return null; + + // Handle different setting types + if (settingInstance.choiceSettingValue) { + // Find the setting definition in the intune collection + const intuneObj = intuneCollection.find( + (item) => item.id === settingInstance.settingDefinitionId + ); + + const label = intuneObj?.displayName || `Setting ${index + 1}`; + const options = + intuneObj?.options?.map((option) => ({ + label: option.displayName || option.id, + value: option.id, + })) || []; + + return ( + + + + ); + } + + if (settingInstance.simpleSettingValue) { + // Find the setting definition in the intune collection + const intuneObj = intuneCollection.find( + (item) => item.id === settingInstance.settingDefinitionId + ); + + const label = intuneObj?.displayName || `Setting ${index + 1}`; + + return ( + + + + ); + } + + // Handle group setting collections + if (settingInstance.groupSettingCollectionValue) { + // Find the setting definition in the intune collection + const intuneObj = intuneCollection.find( + (item) => item.id === settingInstance.settingDefinitionId + ); + + const label = intuneObj?.displayName || `Group Setting Collection ${index + 1}`; + + return ( + + + {label} + + + Definition ID: {settingInstance.settingDefinitionId} + + {/* Group collections are complex - show as read-only for now */} + + Complex group setting collection - view in JSON mode for details + + + ); + } + + return null; + })} + + + ); + } + + // Handle OMA settings + if (value.omaSettings && Array.isArray(value.omaSettings)) { + return ( + + + OMA Settings + + + + {value.omaSettings.map((omaSetting, index) => ( + + + {omaSetting.displayName || `OMA Setting ${index + 1}`} + + + + + + + + + + + ))} + + + ); + } + + // Handle device policies (direct configuration properties) + if (!value.settings && !value.omaSettings && !value.added) { + return ( + + + Device Policy Configuration + + + + {Object.entries(value) + .filter(([deviceKey]) => !isFieldBlacklisted(deviceKey)) + .map(([deviceKey, deviceValue]) => + renderFormField(deviceKey, deviceValue, fieldPath) + )} + + + ); + } + + // Fallback for other RAWJson structures + return ( + + + Policy Configuration + + + + This policy structure is not supported for editing. + + + ); + } + + // Special handling for complex array fields + if (complexArrayFields.some((pattern) => key.toLowerCase().includes(pattern.toLowerCase()))) { + // Don't render if value is null, undefined, empty array, or contains only null/empty items + if ( + !value || + (Array.isArray(value) && value.length === 0) || + (Array.isArray(value) && + value.every( + (item) => + item === null || + item === undefined || + (typeof item === "string" && item.trim() === "") || + (typeof item === "object" && item !== null && Object.keys(item).length === 0) + )) + ) { + return null; + } + + return ( + + + {getCippTranslation(key)} + + + + {Array.isArray(value) ? ( + value + .filter( + (item) => + item !== null && + item !== undefined && + !(typeof item === "string" && item.trim() === "") && + !(typeof item === "object" && item !== null && Object.keys(item).length === 0) + ) + .map((item, index) => ( + + + {getCippTranslation(key)} {index + 1} + + + {typeof item === "object" && item !== null ? ( + Object.entries(item).map(([subKey, subValue]) => + renderFormField(subKey, subValue, `${fieldPath}.${index}`) + ) + ) : ( + + + + )} + + + )) + ) : ( + + + No {getCippTranslation(key)} data available + + + )} + + + ); + } + + // Generic field type handling + if (typeof value === "boolean") { + return ( + + + + ); + } + + if (typeof value === "string") { + const alwaysTextFields = [ + "displayname", + "displayName", + "name", + "description", + "identity", + "title", + ]; + + const isAlwaysTextField = alwaysTextFields.some( + (field) => key.toLowerCase() === field.toLowerCase() + ); + + // Check if this looks like an enum value (common patterns in device policies) + const enumPatterns = [ + "notConfigured", + "deviceDefault", + "manual", + "automatic", + "disabled", + "enabled", + "blocked", + "allowed", + "required", + "none", + "lockWorkstation", + ]; + + const looksLikeEnum = enumPatterns.some((pattern) => + value.toLowerCase().includes(pattern.toLowerCase()) + ); + + if (!isAlwaysTextField && looksLikeEnum) { + // Create basic options based on common patterns + const commonOptions = [ + { label: "Not Configured", value: "notConfigured" }, + { label: "Device Default", value: "deviceDefault" }, + { label: "Manual", value: "manual" }, + { label: "Automatic", value: "automatic" }, + { label: "Disabled", value: "disabled" }, + { label: "Enabled", value: "enabled" }, + { label: "Blocked", value: "blocked" }, + { label: "Allowed", value: "allowed" }, + { label: "Required", value: "required" }, + { label: "None", value: "none" }, + ].filter( + (option) => + // Only include options that make sense for this field + option.value === value || + key.toLowerCase().includes(option.value.toLowerCase()) || + option.value === "notConfigured" // Always include notConfigured + ); + + return ( + + + + ); + } + + return ( + + + + ); + } + + if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + return ( + + ({ label: item, value: item }))} + /> + + ); + } + + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return ( + + + {getCippTranslation(key)} + + + + {Object.entries(value).map(([subKey, subValue]) => + renderFormField(subKey, subValue, fieldPath) + )} + + + ); + } + + // For other types (numbers, complex arrays, etc.), render as text field + return ( + + + + ); + }; + + if (!templateData) { + return null; + } + + // Process template data (parse RAWJson for Intune templates) + const processedData = parseIntuneRawJson(templateData); + + return ( + + {/* Render priority fields first */} + {priorityFields.map( + (fieldName) => + processedData[fieldName] !== undefined && + renderFormField(fieldName, processedData[fieldName]) + )} + + {/* Render all other fields except priority fields */} + {Object.entries(processedData) + .filter(([key]) => !priorityFields.includes(key)) + .map(([key, value]) => renderFormField(key, value))} + + ); +}; + +export default CippTemplateFieldRenderer; diff --git a/src/components/CippComponents/CippTenantGroupOffCanvas.jsx b/src/components/CippComponents/CippTenantGroupOffCanvas.jsx new file mode 100644 index 000000000000..05ed8e18f836 --- /dev/null +++ b/src/components/CippComponents/CippTenantGroupOffCanvas.jsx @@ -0,0 +1,275 @@ +import React from "react"; +import { + Box, + Typography, + Card, + CardContent, + Chip, + Alert, + AlertTitle, + useTheme, + Stack, +} from "@mui/material"; +import { Groups, Business, Rule, Info } from "@mui/icons-material"; +import { CippDataTable } from "../CippTable/CippDataTable"; + +export const CippTenantGroupOffCanvas = ({ data }) => { + const theme = useTheme(); + + if (!data) { + return ( + + No group data available + + ); + } + + const isDynamic = data.GroupType === "dynamic"; + const hasMembers = data.Members && data.Members.length > 0; + const hasDynamicRules = + data.DynamicRules && + ((Array.isArray(data.DynamicRules) && data.DynamicRules.length > 0) || + (!Array.isArray(data.DynamicRules) && Object.keys(data.DynamicRules).length > 0)); + + const renderDynamicRules = () => { + if (!hasDynamicRules) { + return ( + + No Dynamic Rules + This dynamic group has no rules configured. + + ); + } + + const operatorDisplay = { + eq: "equals", + ne: "not equals", + in: "in", + notIn: "not in", + contains: "contains", + startsWith: "starts with", + endsWith: "ends with", + }; + + // Handle both single rule object and array of rules + const rules = Array.isArray(data.DynamicRules) ? data.DynamicRules : [data.DynamicRules]; + + const renderRule = (rule, index) => ( + + + Rule {rules.length > 1 ? `${index + 1}:` : "Configuration:"} + + + Property: {rule.property} + + + Operator: {operatorDisplay[rule.operator] || rule.operator} + + + Value(s): + + {Array.isArray(rule.value) ? ( + + {rule.value.map((item, valueIndex) => ( + + ))} + + ) : ( + + )} + + ); + + const renderRulesWithLogic = () => { + if (rules.length === 1) { + return renderRule(rules[0], 0); + } + + return rules.map((rule, index) => ( + + {renderRule(rule, index)} + {index < rules.length - 1 && ( + + + + )} + + )); + }; + + return ( + + + + + Dynamic Rules + {rules.length > 1 && ( + + )} + + {renderRulesWithLogic()} + + + ); + }; + + const renderMembers = () => { + if (!hasMembers) { + return ( + + No Members + {isDynamic + ? "This dynamic group has no members that match the current rules." + : "This static group has no members assigned."} + + ); + } + + const memberColumns = ["displayName", "defaultDomainName", "customerId"]; + + return ( + + , + }} + /> + + ); + }; + + return ( + + {/* Header Section */} + + + + + + {data.Name} + + + + + ID: {data.Id} + + + + + + {data.Description && ( + + Description + {data.Description} + + )} + + + {/* Content Sections */} + + {/* Dynamic Rules Section (only for dynamic groups) */} + {isDynamic && {renderDynamicRules()}} + + {/* Members Section */} + {renderMembers()} + + {/* Additional Info */} + + + + + Additional Information + + + + + Group Type + + {isDynamic ? "Dynamic" : "Static"} + + + + Member Count + + + {data.Members?.length || 0} tenant{(data.Members?.length || 0) !== 1 ? "s" : ""} + + + {isDynamic && ( + <> + + + Rule Logic + + + {data.RuleLogic?.toUpperCase() || "AND"} + + + + + Has Rules + + {hasDynamicRules ? "Yes" : "No"} + + + )} + + + + + + ); +}; diff --git a/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx b/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx new file mode 100644 index 000000000000..2964b81a0d83 --- /dev/null +++ b/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx @@ -0,0 +1,186 @@ + import React, { useState } from "react"; +import { Box, Button, IconButton, Typography, Alert, Paper } from "@mui/material"; +import { Grid } from "@mui/system"; +import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormCondition } from "./CippFormCondition"; +import { useWatch } from "react-hook-form"; +import { + getTenantGroupPropertyOptions, + getTenantGroupOperatorOptions, + getTenantGroupValueOptions, +} from "../../utils/get-cipp-tenant-group-options"; + +const CippTenantGroupRuleBuilder = ({ formControl, name = "dynamicRules" }) => { + const [ruleCount, setRuleCount] = useState(1); + + // Watch the rules array to get current values + const watchedRules = useWatch({ + control: formControl.control, + name: name, + defaultValue: [{}], + }); + + // Watch the logic operator + const ruleLogic = useWatch({ + control: formControl.control, + name: "ruleLogic", + defaultValue: "and" + }); + + const propertyOptions = getTenantGroupPropertyOptions(); + + const addRule = () => { + const currentRules = formControl.getValues(name) || []; + const newRules = [...currentRules, {}]; + formControl.setValue(name, newRules); + setRuleCount(ruleCount + 1); + }; + + const removeRule = (index) => { + const currentRules = formControl.getValues(name) || []; + const newRules = currentRules.filter((_, i) => i !== index); + formControl.setValue(name, newRules); + setRuleCount(Math.max(1, ruleCount - 1)); + }; + + const getValueOptions = (ruleIndex) => { + const rules = watchedRules || []; + const rule = rules[ruleIndex]; + const propertyType = rule?.property?.type; + return getTenantGroupValueOptions(propertyType); + }; + + const getOperatorOptions = (ruleIndex) => { + const rules = watchedRules || []; + const rule = rules[ruleIndex]; + const propertyType = rule?.property?.type; + return getTenantGroupOperatorOptions(propertyType); + }; + + const renderRule = (ruleIndex) => { + const isFirstRule = ruleIndex === 0; + const canRemove = (watchedRules?.length || 0) > 1; + + return ( + + {!isFirstRule && ( + + {(ruleLogic || 'and').toUpperCase()} + + )} + + + {/* Property Selection */} + + + + + {/* Operator Selection */} + + + + + + + {/* Value Selection - Conditional based on property type */} + + + + + + + {/* Remove Rule Button */} + + {canRemove && ( + removeRule(ruleIndex)} size="small"> + + + )} + + + + ); + }; + + return ( + + + Dynamic Rules + + + + Define rules to automatically include tenants in this group. Rules are combined with the selected logic operator. + Example: "Available License equals Microsoft 365 E3" {(ruleLogic || 'and').toUpperCase()} "Delegated Access Status equals Direct Tenant" + + + {/* Logic Operator Selection */} + + + + + {/* Render existing rules */} + {(watchedRules || [{}]).map((_, index) => renderRule(index))} + + {/* Add Rule Button */} + + + + + ); +}; + +export default CippTenantGroupRuleBuilder; diff --git a/src/components/CippComponents/CippTenantSelector.jsx b/src/components/CippComponents/CippTenantSelector.jsx index 61d0c32e37fd..aa78fc46d787 100644 --- a/src/components/CippComponents/CippTenantSelector.jsx +++ b/src/components/CippComponents/CippTenantSelector.jsx @@ -2,7 +2,7 @@ import PropTypes from "prop-types"; import { CippAutoComplete } from "../CippComponents/CippAutocomplete"; import { ApiGetCall } from "../../api/ApiCall"; import { IconButton, SvgIcon, Tooltip, Box } from "@mui/material"; -import { FilePresent, Laptop, Mail, Refresh, Share, Shield, ShieldMoon } from "@mui/icons-material"; +import { FilePresent, Laptop, Mail, Refresh, Share, Shield, ShieldMoon, PrecisionManufacturing, BarChart } from "@mui/icons-material"; import { BuildingOfficeIcon, GlobeAltIcon, @@ -43,6 +43,112 @@ export const CippTenantSelector = (props) => { toast: true, }); + // Filter portal actions based on user preferences + const getFilteredPortalActions = () => { + // Define all available portal actions with current tenant data + const allPortalActions = [ + { + key: "M365_Portal", + label: "M365 Admin Portal", + link: `https://admin.cloud.microsoft/?delegatedOrg=${currentTenant?.addedFields?.initialDomainName}`, + icon: , + }, + { + key: "Exchange_Portal", + label: "Exchange Portal", + link: `https://admin.cloud.microsoft/exchange?delegatedOrg=${currentTenant?.addedFields?.initialDomainName}`, + icon: , + }, + { + key: "Entra_Portal", + label: "Entra Portal", + link: `https://entra.microsoft.com/${currentTenant?.value}`, + icon: , + }, + { + key: "Teams_Portal", + label: "Teams Portal", + link: `https://admin.teams.microsoft.com/?delegatedOrg=${currentTenant?.addedFields?.initialDomainName}`, + icon: , + }, + { + key: "Azure_Portal", + label: "Azure Portal", + link: `https://portal.azure.com/${currentTenant?.value}`, + icon: , + }, + { + key: "Intune_Portal", + label: "Intune Portal", + link: `https://intune.microsoft.com/${currentTenant?.value}`, + icon: , + }, + { + key: "SharePoint_Admin", + label: "SharePoint Portal", + link: `/api/ListSharePointAdminUrl?tenantFilter=${currentTenant?.value}`, + icon: , + external: true, + }, + { + key: "Security_Portal", + label: "Security Portal", + link: `https://security.microsoft.com/?tid=${currentTenant?.addedFields?.customerId}`, + icon: , + }, + { + key: "Compliance_Portal", + label: "Compliance Portal", + link: `https://purview.microsoft.com/?tid=${currentTenant?.addedFields?.customerId}`, + icon: , + }, + { + key: "Power_Platform_Portal", + label: "Power Platform Portal", + link: `https://admin.powerplatform.microsoft.com/account/login/${currentTenant?.addedFields?.customerId}`, + icon: , + }, + { + key: "Power_BI_Portal", + label: "Power BI Portal", + link: `https://app.powerbi.com/admin-portal?ctid=${currentTenant?.addedFields?.customerId}`, + icon: , + }, + ]; + + // Default to all links enabled (final fallback) + const defaultLinks = { + M365_Portal: true, + Exchange_Portal: true, + Entra_Portal: true, + Teams_Portal: true, + Azure_Portal: true, + Intune_Portal: true, + SharePoint_Admin: true, + Security_Portal: true, + Compliance_Portal: true, + Power_Platform_Portal: true, + Power_BI_Portal: true, + }; + + let portalLinks; + if (settings.UserSpecificSettings?.portalLinks) { + portalLinks = { ...defaultLinks, ...settings.UserSpecificSettings.portalLinks }; + } else if (settings.portalLinks) { + portalLinks = { ...defaultLinks, ...settings.portalLinks }; + } else { + portalLinks = defaultLinks; + } + + const filteredActions = allPortalActions.filter(action => { + const isEnabled = portalLinks[action.key] === true; + return isEnabled; + }); + + return filteredActions; + }; + + // This effect handles updates when the tenant is changed via dropdown selection useEffect(() => { if (!router.isReady) return; if (currentTenant?.value) { @@ -65,14 +171,62 @@ export const CippTenantSelector = (props) => { } }, [currentTenant?.value]); + // This effect handles when the URL parameter changes externally + useEffect(() => { + if (!router.isReady || !tenantList.isSuccess) return; + + // Get the current tenant from URL or settings + const urlTenant = router.query.tenantFilter || settings.currentTenant; + + // Only update if there's a URL tenant and it's different from our current state + if (urlTenant && (!currentTenant || urlTenant !== currentTenant.value)) { + // Find the tenant in our list + const matchingTenant = tenantList.data.find( + ({ defaultDomainName }) => defaultDomainName === urlTenant + ); + + if (matchingTenant) { + setSelectedTenant({ + value: urlTenant, + label: `${matchingTenant.displayName} (${urlTenant})`, + addedFields: { + defaultDomainName: matchingTenant.defaultDomainName, + displayName: matchingTenant.displayName, + customerId: matchingTenant.customerId, + initialDomainName: matchingTenant.initialDomainName, + }, + }); + } + } + }, [router.isReady, router.query.tenantFilter, tenantList.isSuccess, settings.currentTenant]); + + // This effect ensures the tenant filter parameter is included in the URL when missing + useEffect(() => { + if (!router.isReady || !settings.currentTenant) return; + + // If the tenant parameter is missing from the URL but we have it in settings + if (!router.query.tenantFilter && settings.currentTenant) { + const query = { ...router.query, tenantFilter: settings.currentTenant }; + router.replace( + { + pathname: router.pathname, + query: query, + }, + undefined, + { shallow: true } + ); + } + }, [router.isReady, router.query, settings.currentTenant]); + useEffect(() => { if (tenant && currentTenant?.value && currentTenant?.value !== "AllTenants") { tenantDetails.refetch(); } }, [tenant, offcanvasVisible]); + // We can simplify this effect since we now have the new effect above to handle URL changes useEffect(() => { - if (tenant && tenantList.isSuccess) { + if (tenant && tenantList.isSuccess && !currentTenant) { const matchingTenant = tenantList.data.find( ({ defaultDomainName }) => defaultDomainName === tenant ); @@ -94,7 +248,8 @@ export const CippTenantSelector = (props) => { } ); } - }, [tenant, tenantList.isSuccess]); + }, [tenant, tenantList.isSuccess, currentTenant]); + return ( <> { "onPremisesLastSyncDateTime", "onPremisesLastPasswordSyncDateTime", ]} - actions={[ - { - label: "M365 Admin Portal", - link: `https://admin.cloud.microsoft/?delegatedOrg=${currentTenant?.addedFields?.initialDomainName}`, - icon: , - }, - { - label: "Exchange Portal", - link: `https://admin.cloud.microsoft/exchange?delegatedOrg=${currentTenant?.addedFields?.initialDomainName}`, - icon: , - }, - { - label: "Entra Portal", - link: `https://entra.microsoft.com/${currentTenant?.value}`, - icon: , - }, - { - label: "Teams Portal", - link: `https://admin.teams.microsoft.com/?delegatedOrg=${currentTenant?.addedFields?.initialDomainName}`, - icon: , - }, - { - label: "Azure Portal", - link: `https://portal.azure.com/${currentTenant?.value}`, - icon: , - }, - { - label: "Intune Portal", - link: `https://intune.microsoft.com/${currentTenant?.value}`, - icon: , - }, - { - label: "SharePoint Portal", - link: `/api/ListSharePointAdminUrl?tenantFilter=${currentTenant?.value}`, - icon: , - external: true, - }, - { - label: "Security Portal", - link: `https://security.microsoft.com/?tid=${currentTenant?.addedFields?.customerId}`, - icon: , - }, - { - label: "Compliance Portal", - link: `https://purview.microsoft.com/?tid=${currentTenant?.addedFields?.customerId}`, - icon: , - }, - ]} + actions={getFilteredPortalActions()} /> ); diff --git a/src/components/CippComponents/CippTextFieldWithVariables.jsx b/src/components/CippComponents/CippTextFieldWithVariables.jsx new file mode 100644 index 000000000000..0f6abd9fea99 --- /dev/null +++ b/src/components/CippComponents/CippTextFieldWithVariables.jsx @@ -0,0 +1,214 @@ +import React, { useState, useRef, useEffect, useMemo, useCallback } from "react"; +import { TextField } from "@mui/material"; +import { CippVariableAutocomplete } from "./CippVariableAutocomplete"; +import { useSettings } from "/src/hooks/use-settings.js"; + +/** + * Enhanced TextField that supports custom variable autocomplete + * Triggers when user types % character + */ +export const CippTextFieldWithVariables = ({ + value = "", + onChange, + includeSystemVariables = false, + ...textFieldProps +}) => { + const [showAutocomplete, setShowAutocomplete] = useState(false); + const [autocompleteAnchor, setAutocompleteAnchor] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [cursorPosition, setCursorPosition] = useState(0); + const textFieldRef = useRef(null); + + const settings = useSettings(); + // Memoize tenant filter to prevent unnecessary re-renders + const tenantFilter = useMemo(() => settings?.currentTenant || null, [settings?.currentTenant]); + + // Safely close autocomplete + const closeAutocomplete = useCallback(() => { + setShowAutocomplete(false); + setSearchQuery(""); + setAutocompleteAnchor(null); + }, []); + + // Track cursor position + const handleSelectionChange = () => { + if (textFieldRef.current) { + setCursorPosition(textFieldRef.current.selectionStart || 0); + } + }; + + // Get cursor position for floating autocomplete + const getCursorPosition = () => { + if (!textFieldRef.current) return { top: 0, left: 0 }; + + const rect = textFieldRef.current.getBoundingClientRect(); + return { + top: rect.bottom + window.scrollY, + left: rect.left + window.scrollX + cursorPosition * 8, // Approximate character width + }; + }; + + // Handle input changes and detect % trigger + const handleInputChange = (event) => { + const newValue = event.target.value; + const cursorPos = event.target.selectionStart; + + // Update cursor position state immediately + setCursorPosition(cursorPos); + + // Call parent onChange + if (onChange) { + onChange(event); + } + + // Check if % was just typed + if (newValue[cursorPos - 1] === "%") { + // Position autocomplete near cursor + setAutocompleteAnchor(textFieldRef.current); + setSearchQuery(""); + setShowAutocomplete(true); + } else if (showAutocomplete) { + // Update search query if autocomplete is open + const lastPercentIndex = newValue.lastIndexOf("%", cursorPos - 1); + if (lastPercentIndex !== -1) { + const query = newValue.substring(lastPercentIndex + 1, cursorPos); + setSearchQuery(query); + + // Close autocomplete if user typed space or special characters (except %) + if (query.includes(" ") || /[^a-zA-Z0-9_]/.test(query)) { + closeAutocomplete(); + } + } else { + closeAutocomplete(); + } + } + }; + + // Handle variable selection + const handleVariableSelect = useCallback( + (variableString) => { + if (!onChange) { + return; + } + + // Use the value prop instead of DOM value since we're in a controlled component + const currentValue = value || ""; + + // Get fresh cursor position from the DOM + let cursorPos = cursorPosition; + if (textFieldRef.current) { + const inputElement = textFieldRef.current.querySelector("input") || textFieldRef.current; + if (inputElement && typeof inputElement.selectionStart === "number") { + cursorPos = inputElement.selectionStart; + } + } + + // Find the % that triggered the autocomplete + const lastPercentIndex = currentValue.lastIndexOf("%", cursorPos - 1); + + if (lastPercentIndex !== -1) { + // Replace from % to cursor position with the selected variable + const beforePercent = currentValue.substring(0, lastPercentIndex); + const afterCursor = currentValue.substring(cursorPos); + const newValue = beforePercent + variableString + afterCursor; + + // Create synthetic event for onChange + const syntheticEvent = { + target: { + name: textFieldRef.current?.name || "", + value: newValue, + }, + }; + + onChange(syntheticEvent); + + // Set cursor position after the inserted variable + setTimeout(() => { + if (textFieldRef.current) { + const newCursorPos = lastPercentIndex + variableString.length; + + // Access the actual input element for Material-UI TextField + const inputElement = + textFieldRef.current.querySelector("input") || textFieldRef.current; + if (inputElement && inputElement.setSelectionRange) { + inputElement.setSelectionRange(newCursorPos, newCursorPos); + inputElement.focus(); + } + setCursorPosition(newCursorPos); + } + }, 0); + } + + closeAutocomplete(); + }, + [value, cursorPosition, onChange, closeAutocomplete] + ); + + // Handle key events + const handleKeyDown = (event) => { + if (showAutocomplete) { + // Let the autocomplete handle arrow keys and enter + if (["ArrowDown", "ArrowUp", "Enter", "Tab"].includes(event.key)) { + return; // Let autocomplete handle these + } + + // Close autocomplete on Escape + if (event.key === "Escape") { + closeAutocomplete(); + event.preventDefault(); + } + } + + // Call original onKeyDown if provided + if (textFieldProps.onKeyDown) { + textFieldProps.onKeyDown(event); + } + }; + + // Close autocomplete when clicking outside + useEffect(() => { + const handleClickOutside = (event) => { + if ( + showAutocomplete && + textFieldRef.current && + !textFieldRef.current.contains(event.target) + ) { + // Check if click is on autocomplete dropdown + const autocompleteElement = document.querySelector("[data-cipp-autocomplete]"); + if (autocompleteElement && autocompleteElement.contains(event.target)) { + return; // Don't close if clicking inside autocomplete + } + + closeAutocomplete(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showAutocomplete]); + + return ( + <> + + + + + ); +}; diff --git a/src/components/CippComponents/CippTranslations.jsx b/src/components/CippComponents/CippTranslations.jsx index fef71fa8ccb0..99d46a6e5182 100644 --- a/src/components/CippComponents/CippTranslations.jsx +++ b/src/components/CippComponents/CippTranslations.jsx @@ -36,6 +36,8 @@ export const CippTranslations = { portal_security: "Security Portal", portal_compliance: "Compliance Portal", portal_sharepoint: "SharePoint Portal", + portal_platform: "Power Platform Portal", + portal_bi: "Power BI Portal", "@odata.type": "Type", roleDefinitionId: "GDAP Role", FromIP: "From IP", @@ -46,4 +48,8 @@ export const CippTranslations = { prohibitSendReceiveQuotaInBytes: "Quota", ClientId: "Client ID", html_url: "URL", + sendtoIntegration: "Send Notifications to Integration", + includeTenantId: "Include Tenant ID in Notifications", + logsToInclude: "Logs to Include in notifications", + assignmentFilterManagementType: "Filter Type", }; diff --git a/src/components/CippComponents/CippTransportRuleDrawer.jsx b/src/components/CippComponents/CippTransportRuleDrawer.jsx new file mode 100644 index 000000000000..8cac4e3fd390 --- /dev/null +++ b/src/components/CippComponents/CippTransportRuleDrawer.jsx @@ -0,0 +1,1332 @@ +import React, { useState, useEffect, useMemo, useCallback, cloneElement } from "react"; +import { Button, Divider, Typography, Alert, Box } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useWatch } from "react-hook-form"; +import { RocketLaunch, Edit } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormDomainSelector } from "./CippFormDomainSelector"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "/src/hooks/use-settings"; +import { ApiGetCall, ApiPostCall } from "/src/api/ApiCall"; +import { useQueryClient } from "@tanstack/react-query"; + +export const CippTransportRuleDrawer = ({ + buttonText = "New Transport Rule", + isEditMode = false, + ruleId = null, + requiredPermissions = [], + PermissionButton = Button, + onSuccess = () => {}, + drawerVisible: controlledDrawerVisible, + setDrawerVisible: controlledSetDrawerVisible, + rowAction = false, +}) => { + const currentTenant = useSettings().currentTenant; + const [internalDrawerVisible, internalSetDrawerVisible] = useState(false); + const drawerVisible = controlledDrawerVisible !== undefined ? controlledDrawerVisible : internalDrawerVisible; + const setDrawerVisible = controlledSetDrawerVisible !== undefined ? controlledSetDrawerVisible : internalSetDrawerVisible; + + // Fetch existing rule data in edit mode + const ruleInfo = ApiGetCall({ + url: `/api/ListTransportRules?tenantFilter=${currentTenant}&id=${ruleId}`, + queryKey: `ListTransportRules-${ruleId}`, + waiting: !!drawerVisible || !!isEditMode || !!ruleId, + }); + + // Default form values + const defaultFormValues = useMemo( + () => ({ + Enabled: true, + Mode: { value: "Enforce", label: "Enforce" }, + StopRuleProcessing: false, + SenderAddressLocation: { value: "Header", label: "Header" }, + applyToAllMessages: false, + tenantFilter: currentTenant, + Name: "", + Priority: "", + Comments: "", + conditionType: [], + actionType: [], + exceptionType: [], + }), + [currentTenant] + ); + + const formControl = useForm({ + mode: "onChange", + defaultValues: defaultFormValues, + }); + + const [selectedConditions, setSelectedConditions] = useState([]); + const [selectedActions, setSelectedActions] = useState([]); + const [selectedExceptions, setSelectedExceptions] = useState([]); + + const conditionTypeWatch = useWatch({ control: formControl.control, name: "conditionType" }); + const actionTypeWatch = useWatch({ control: formControl.control, name: "actionType" }); + const exceptionTypeWatch = useWatch({ control: formControl.control, name: "exceptionType" }); + const applyToAllMessagesWatch = useWatch({ control: formControl.control, name: "applyToAllMessages" }); + + // API call for submit + const submitRule = ApiPostCall({ + urlFromData: true, + }); + + // Helper to convert ISO8601 date string to Unix timestamp (seconds) + const iso8601ToUnixTimestamp = (dateString) => { + if (!dateString) return ""; + const d = new Date(dateString); + if (isNaN(d.getTime())) return ""; + return Math.floor(d.getTime() / 1000); + }; + + // Helper to convert Enable/Disable into a usable bool for switches + const boolHelper = (boolValue) => { + if (boolValue === "Enabled") return true; + return false; + }; + + // Memoize processed rule data + const processedRuleData = useMemo(() => { + if (!ruleInfo.isSuccess || !ruleInfo.data || !isEditMode) { + return null; + } + + const rule = ruleInfo.data?.Results + ? (Array.isArray(ruleInfo.data.Results) ? ruleInfo.data.Results[0] : ruleInfo.data.Results) + : (Array.isArray(ruleInfo.data) ? ruleInfo.data[0] : ruleInfo.data); + + if (!rule) { + return null; + } + + // Map of condition field names to their display labels + const conditionFieldMap = { + From: "The sender is...", + FromScope: "The sender is located...", + SentTo: "The recipient is...", + SentToScope: "The recipient is located...", + SubjectContainsWords: "Subject contains words...", + SubjectMatchesPatterns: "Subject matches patterns...", + SubjectOrBodyContainsWords: "Subject or body contains words...", + SubjectOrBodyMatchesPatterns: "Subject or body matches patterns...", + FromAddressContainsWords: "Sender address contains words...", + FromAddressMatchesPatterns: "Sender address matches patterns...", + AttachmentContainsWords: "Attachment content contains words...", + AttachmentMatchesPatterns: "Attachment content matches patterns...", + AttachmentExtensionMatchesWords: "Attachment extension is...", + AttachmentSizeOver: "Attachment size is greater than...", + MessageSizeOver: "Message size is greater than...", + SCLOver: "SCL is greater than or equal to...", + WithImportance: "Message importance is...", + MessageTypeMatches: "Message type is...", + SenderDomainIs: "Sender domain is...", + RecipientDomainIs: "Recipient domain is...", + HeaderContainsWords: "Message header contains words...", + HeaderMatchesPatterns: "Message header matches patterns...", + }; + + const actionFieldMap = { + DeleteMessage: "Delete the message without notifying anyone", + Quarantine: "Quarantine the message", + RedirectMessageTo: "Redirect the message to...", + BlindCopyTo: "Add recipients to the Bcc box...", + CopyTo: "Add recipients to the Cc box...", + ModerateMessageByUser: "Forward the message for approval to...", + ModerateMessageByManager: "Forward the message for approval to the sender's manager", + RejectMessageReasonText: "Reject the message with explanation...", + PrependSubject: "Prepend the subject with...", + SetSCL: "Set spam confidence level (SCL) to...", + SetHeaderName: "Set message header...", + RemoveHeader: "Remove message header...", + ApplyClassification: "Apply message classification...", + ApplyHtmlDisclaimerText: "Apply HTML disclaimer...", + GenerateIncidentReport: "Generate incident report and send to...", + GenerateNotification: "Notify the sender with a message...", + ApplyOME: "Apply Office 365 Message Encryption", + }; + + // Detect active conditions + const activeConditions = []; + Object.keys(conditionFieldMap).forEach(field => { + const value = rule[field]; + if (value !== null && value !== undefined && value !== false && value !== "") { + activeConditions.push({ + value: field, + label: conditionFieldMap[field] + }); + } + }); + + // Detect active actions + const activeActions = []; + Object.keys(actionFieldMap).forEach(field => { + const value = rule[field]; + if (field === "RejectMessageReasonText" && (rule.RejectMessageReasonText || rule.RejectMessageEnhancedStatusCode)) { + activeActions.push({ value: "RejectMessage", label: actionFieldMap[field] }); + } else if (field === "SetHeaderName" && (rule.SetHeaderName || rule.SetHeaderValue)) { + activeActions.push({ value: "SetHeader", label: actionFieldMap[field] }); + } else if (field === "ApplyHtmlDisclaimerText" && rule.ApplyHtmlDisclaimerText) { + activeActions.push({ value: "ApplyHtmlDisclaimer", label: actionFieldMap[field] }); + } else if (field === "ModerateMessageByManager" && value === true) { + activeActions.push({ value: field, label: actionFieldMap[field] }); + } else if (value !== null && value !== undefined && value !== false && value !== "" && + field !== "RejectMessageReasonText" && field !== "SetHeaderName" && + field !== "ApplyHtmlDisclaimerText" && field !== "ModerateMessageByManager") { + activeActions.push({ value: field, label: actionFieldMap[field] }); + } + }); + + // Detect active exceptions + const activeExceptions = []; + Object.keys(conditionFieldMap).forEach(field => { + const exceptionField = `ExceptIf${field}`; + const value = rule[exceptionField]; + if (value !== null && value !== undefined && value !== false && value !== "") { + activeExceptions.push({ + value: exceptionField, + label: conditionFieldMap[field] + }); + } + }); + + // Build form data + const formData = { + Name: rule.Name || "", + Priority: rule.Priority || "", + Comments: rule.Comments || "", + Enabled: boolHelper(rule.State), + Mode: rule.Mode ? { value: rule.Mode, label: rule.Mode } : { value: "Enforce", label: "Enforce" }, + SetAuditSeverity: rule.SetAuditSeverity + ? { value: rule.SetAuditSeverity, label: rule.SetAuditSeverity } + : undefined, + SenderAddressLocation: rule.SenderAddressLocation + ? { value: rule.SenderAddressLocation, label: rule.SenderAddressLocation } + : { value: "Header", label: "Header" }, + StopRuleProcessing: rule.StopRuleProcessing || false, + ActivationDate: iso8601ToUnixTimestamp(rule.ActivationDate), + ExpiryDate: iso8601ToUnixTimestamp(rule.ExpiryDate), + applyToAllMessages: activeConditions.length === 0, + conditionType: activeConditions, + actionType: activeActions, + exceptionType: activeExceptions, + tenantFilter: currentTenant, + }; + + // Add all condition values + Object.keys(conditionFieldMap).forEach(field => { + if (rule[field] !== null && rule[field] !== undefined) { + if (field === "FromScope" || field === "SentToScope") { + formData[field] = rule[field] + ? { value: rule[field], label: rule[field] === "InOrganization" ? "Inside the organization" : "Outside the organization" } + : undefined; + } else if (field === "WithImportance") { + formData[field] = rule[field] + ? { value: rule[field], label: rule[field] } + : undefined; + } else if (field === "MessageTypeMatches") { + formData[field] = rule[field] + ? { value: rule[field], label: rule[field] } + : undefined; + } else if (field === "SCLOver") { + formData[field] = rule[field] !== null + ? { value: rule[field].toString(), label: rule[field].toString() } + : undefined; + } else { + formData[field] = rule[field]; + } + } + if (field === "HeaderContainsWords" && rule.HeaderContainsMessageHeader) { + formData.HeaderContainsWordsMessageHeader = rule.HeaderContainsMessageHeader; + } + if (field === "HeaderMatchesPatterns" && rule.HeaderMatchesMessageHeader) { + formData.HeaderMatchesPatternsMessageHeader = rule.HeaderMatchesMessageHeader; + } + }); + + // Add all action values + if (rule.RejectMessageReasonText) formData.RejectMessageReasonText = rule.RejectMessageReasonText; + if (rule.RejectMessageEnhancedStatusCode) formData.RejectMessageEnhancedStatusCode = rule.RejectMessageEnhancedStatusCode; + if (rule.SetHeaderName) formData.SetHeaderName = rule.SetHeaderName; + if (rule.SetHeaderValue) formData.SetHeaderValue = rule.SetHeaderValue; + if (rule.ApplyHtmlDisclaimerText) formData.ApplyHtmlDisclaimerText = rule.ApplyHtmlDisclaimerText; + if (rule.ApplyHtmlDisclaimerLocation) { + formData.ApplyHtmlDisclaimerLocation = { value: rule.ApplyHtmlDisclaimerLocation, label: rule.ApplyHtmlDisclaimerLocation }; + } + if (rule.ApplyHtmlDisclaimerFallbackAction) { + formData.ApplyHtmlDisclaimerFallbackAction = { value: rule.ApplyHtmlDisclaimerFallbackAction, label: rule.ApplyHtmlDisclaimerFallbackAction }; + } + + Object.keys(actionFieldMap).forEach(field => { + if (rule[field] !== null && rule[field] !== undefined && !formData[field]) { + if (field === "SetSCL" && rule[field] !== null) { + formData[field] = { value: rule[field].toString(), label: rule[field].toString() }; + } else { + formData[field] = rule[field]; + } + } + }); + + // Add all exception values + Object.keys(conditionFieldMap).forEach(field => { + const exceptionField = `ExceptIf${field}`; + if (rule[exceptionField] !== null && rule[exceptionField] !== undefined) { + if (field === "FromScope" || field === "SentToScope") { + formData[exceptionField] = rule[exceptionField] + ? { value: rule[exceptionField], label: rule[exceptionField] === "InOrganization" ? "Inside the organization" : "Outside the organization" } + : undefined; + } else if (field === "WithImportance") { + formData[exceptionField] = rule[exceptionField] + ? { value: rule[exceptionField], label: rule[exceptionField] } + : undefined; + } else if (field === "MessageTypeMatches") { + formData[exceptionField] = rule[exceptionField] + ? { value: rule[exceptionField], label: rule[exceptionField] } + : undefined; + } else if (field === "SCLOver") { + formData[exceptionField] = rule[exceptionField] !== null + ? { value: rule[exceptionField].toString(), label: rule[exceptionField].toString() } + : undefined; + } else { + formData[exceptionField] = rule[exceptionField]; + } + } + if (field === "HeaderContainsWords" && rule[`ExceptIfHeaderContainsMessageHeader`]) { + formData.ExceptIfHeaderContainsWordsMessageHeader = rule.ExceptIfHeaderContainsMessageHeader; + } + if (field === "HeaderMatchesPatterns" && rule[`ExceptIfHeaderMatchesMessageHeader`]) { + formData.ExceptIfHeaderMatchesPatternsMessageHeader = rule.ExceptIfHeaderMatchesMessageHeader; + } + }); + + return formData; + }, [ruleInfo.isSuccess, ruleInfo.data, currentTenant, isEditMode]); + + // Reset form with processed data + const resetForm = useCallback(() => { + if (processedRuleData) { + formControl.reset(processedRuleData); + } + }, [processedRuleData, formControl]); + + useEffect(() => { + if (drawerVisible && isEditMode) { + resetForm(); + } + }, [resetForm, drawerVisible, isEditMode]); + + // Custom data formatter for API submission + const customDataFormatter = useCallback( + (values) => { + const rule = ruleInfo.data?.Results + ? (Array.isArray(ruleInfo.data.Results) ? ruleInfo.data.Results[0] : ruleInfo.data.Results) + : (Array.isArray(ruleInfo.data) ? ruleInfo.data[0] : ruleInfo.data); + + const apiData = { + tenantFilter: currentTenant, + Name: values.Name, + Priority: values.Priority, + Comments: values.Comments, + State: values.Enabled ? "Enabled" : "Disabled", + Mode: values.Mode?.value || values.Mode, + SetAuditSeverity: values.SetAuditSeverity?.value || values.SetAuditSeverity, + SenderAddressLocation: values.SenderAddressLocation?.value || values.SenderAddressLocation, + StopRuleProcessing: values.StopRuleProcessing, + ActivationDate: values.ActivationDate, + ExpiryDate: values.ExpiryDate, + }; + + if (isEditMode && rule) { + apiData.ruleId = rule.Guid || rule.Identity || rule.Name; + } + + const conditionTypes = values.conditionType || []; + conditionTypes.forEach(condition => { + const conditionValue = condition.value || condition; + if (values[conditionValue] !== undefined) { + const fieldValue = values[conditionValue]; + if (fieldValue && typeof fieldValue === 'object' && fieldValue.value !== undefined) { + apiData[conditionValue] = fieldValue.value; + } else { + apiData[conditionValue] = fieldValue; + } + } + if (conditionValue === "HeaderContainsWords" && values.HeaderContainsWordsMessageHeader) { + apiData.HeaderContainsMessageHeader = values.HeaderContainsWordsMessageHeader; + apiData.HeaderContainsWords = values.HeaderContainsWords; + } + if (conditionValue === "HeaderMatchesPatterns" && values.HeaderMatchesPatternsMessageHeader) { + apiData.HeaderMatchesMessageHeader = values.HeaderMatchesPatternsMessageHeader; + apiData.HeaderMatchesPatterns = values.HeaderMatchesPatterns; + } + }); + + const actionTypes = values.actionType || []; + actionTypes.forEach(action => { + const actionValue = action.value || action; + + if (actionValue === "RejectMessage") { + if (values.RejectMessageReasonText) { + apiData.RejectMessageReasonText = values.RejectMessageReasonText; + } + if (values.RejectMessageEnhancedStatusCode) { + apiData.RejectMessageEnhancedStatusCode = values.RejectMessageEnhancedStatusCode; + } + } else if (actionValue === "SetHeader") { + if (values.SetHeaderName) apiData.SetHeaderName = values.SetHeaderName; + if (values.SetHeaderValue) apiData.SetHeaderValue = values.SetHeaderValue; + } else if (actionValue === "ApplyHtmlDisclaimer") { + if (values.ApplyHtmlDisclaimerText) { + apiData.ApplyHtmlDisclaimerText = values.ApplyHtmlDisclaimerText; + } + if (values.ApplyHtmlDisclaimerLocation) { + const location = values.ApplyHtmlDisclaimerLocation; + apiData.ApplyHtmlDisclaimerLocation = location?.value || location; + } + if (values.ApplyHtmlDisclaimerFallbackAction) { + const fallback = values.ApplyHtmlDisclaimerFallbackAction; + apiData.ApplyHtmlDisclaimerFallbackAction = fallback?.value || fallback; + } + } else if (values[actionValue] !== undefined) { + const fieldValue = values[actionValue]; + if (fieldValue && typeof fieldValue === 'object' && fieldValue.value !== undefined) { + apiData[actionValue] = fieldValue.value; + } else { + apiData[actionValue] = fieldValue; + } + } + }); + + const exceptionTypes = values.exceptionType || []; + exceptionTypes.forEach(exception => { + const exceptionValue = exception.value || exception; + if (values[exceptionValue] !== undefined) { + const fieldValue = values[exceptionValue]; + if (fieldValue && typeof fieldValue === 'object' && fieldValue.value !== undefined) { + apiData[exceptionValue] = fieldValue.value; + } else { + apiData[exceptionValue] = fieldValue; + } + } + if (exceptionValue === "ExceptIfHeaderContainsWords" && values.ExceptIfHeaderContainsWordsMessageHeader) { + apiData.ExceptIfHeaderContainsMessageHeader = values.ExceptIfHeaderContainsWordsMessageHeader; + apiData.ExceptIfHeaderContainsWords = values.ExceptIfHeaderContainsWords; + } + if (exceptionValue === "ExceptIfHeaderMatchesPatterns" && values.ExceptIfHeaderMatchesPatternsMessageHeader) { + apiData.ExceptIfHeaderMatchesMessageHeader = values.ExceptIfHeaderMatchesPatternsMessageHeader; + apiData.ExceptIfHeaderMatchesPatterns = values.ExceptIfHeaderMatchesPatterns; + } + }); + + return apiData; + }, + [currentTenant, ruleInfo.data, isEditMode] + ); + + // Helper function to get field names for a condition + const getConditionFieldNames = (conditionValue) => { + const fields = [conditionValue]; + if (conditionValue === "HeaderContainsWords") { + fields.push("HeaderContainsWordsMessageHeader"); + } else if (conditionValue === "HeaderMatchesPatterns") { + fields.push("HeaderMatchesPatternsMessageHeader"); + } + return fields; + }; + + // Helper function to get field names for an action + const getActionFieldNames = (actionValue) => { + const fields = []; + switch (actionValue) { + case "RejectMessage": + fields.push("RejectMessageReasonText", "RejectMessageEnhancedStatusCode"); + break; + case "SetHeader": + fields.push("SetHeaderName", "SetHeaderValue"); + break; + case "ApplyHtmlDisclaimer": + fields.push("ApplyHtmlDisclaimerText", "ApplyHtmlDisclaimerLocation", "ApplyHtmlDisclaimerFallbackAction"); + break; + default: + fields.push(actionValue); + } + return fields; + }; + + // Update selected conditions and clean up removed ones + useEffect(() => { + const newConditions = conditionTypeWatch || []; + const newConditionValues = newConditions.map(c => c.value || c); + const oldConditionValues = selectedConditions.map(c => c.value || c); + + const removedConditions = oldConditionValues.filter( + oldVal => !newConditionValues.includes(oldVal) + ); + + removedConditions.forEach(conditionValue => { + const fieldNames = getConditionFieldNames(conditionValue); + fieldNames.forEach(fieldName => { + formControl.setValue(fieldName, undefined); + }); + }); + + setSelectedConditions(newConditions); + }, [conditionTypeWatch]); + + // Update selected actions and clean up removed ones + useEffect(() => { + const newActions = actionTypeWatch || []; + const newActionValues = newActions.map(a => a.value || a); + const oldActionValues = selectedActions.map(a => a.value || a); + + const removedActions = oldActionValues.filter( + oldVal => !newActionValues.includes(oldVal) + ); + + removedActions.forEach(actionValue => { + const fieldNames = getActionFieldNames(actionValue); + fieldNames.forEach(fieldName => { + formControl.setValue(fieldName, undefined); + }); + }); + + setSelectedActions(newActions); + }, [actionTypeWatch]); + + // Update selected exceptions and clean up removed ones + useEffect(() => { + const newExceptions = exceptionTypeWatch || []; + const newExceptionValues = newExceptions.map(e => e.value || e); + const oldExceptionValues = selectedExceptions.map(e => e.value || e); + + const removedExceptions = oldExceptionValues.filter( + oldVal => !newExceptionValues.includes(oldVal) + ); + + removedExceptions.forEach(exceptionValue => { + const baseCondition = exceptionValue.replace("ExceptIf", ""); + const fieldNames = getConditionFieldNames(baseCondition).map( + field => field.includes("MessageHeader") ? `ExceptIf${field}` : exceptionValue + ); + fieldNames.forEach(fieldName => { + formControl.setValue(fieldName, undefined); + }); + }); + + setSelectedExceptions(newExceptions); + }, [exceptionTypeWatch]); + + // Handle "Apply to all messages" logic + useEffect(() => { + if (applyToAllMessagesWatch) { + formControl.setValue("conditionType", []); + setSelectedConditions([]); + } + }, [applyToAllMessagesWatch, formControl]); + + // Disable "Apply to all messages" when conditions are selected + useEffect(() => { + if (conditionTypeWatch && conditionTypeWatch.length > 0) { + formControl.setValue("applyToAllMessages", false); + } + }, [conditionTypeWatch, formControl]); + + // Condition options + const conditionOptions = [ + { value: "From", label: "The sender is..." }, + { value: "FromScope", label: "The sender is located..." }, + { value: "SentTo", label: "The recipient is..." }, + { value: "SentToScope", label: "The recipient is located..." }, + { value: "SubjectContainsWords", label: "Subject contains words..." }, + { value: "SubjectMatchesPatterns", label: "Subject matches patterns..." }, + { value: "SubjectOrBodyContainsWords", label: "Subject or body contains words..." }, + { value: "SubjectOrBodyMatchesPatterns", label: "Subject or body matches patterns..." }, + { value: "FromAddressContainsWords", label: "Sender address contains words..." }, + { value: "FromAddressMatchesPatterns", label: "Sender address matches patterns..." }, + { value: "AttachmentContainsWords", label: "Attachment content contains words..." }, + { value: "AttachmentMatchesPatterns", label: "Attachment content matches patterns..." }, + { value: "AttachmentExtensionMatchesWords", label: "Attachment extension is..." }, + { value: "AttachmentSizeOver", label: "Attachment size is greater than..." }, + { value: "MessageSizeOver", label: "Message size is greater than..." }, + { value: "SCLOver", label: "SCL is greater than or equal to..." }, + { value: "WithImportance", label: "Message importance is..." }, + { value: "MessageTypeMatches", label: "Message type is..." }, + { value: "SenderDomainIs", label: "Sender domain is..." }, + { value: "RecipientDomainIs", label: "Recipient domain is..." }, + { value: "HeaderContainsWords", label: "Message header contains words..." }, + { value: "HeaderMatchesPatterns", label: "Message header matches patterns..." }, + ]; + + // Action options + const actionOptions = [ + { value: "DeleteMessage", label: "Delete the message without notifying anyone" }, + { value: "Quarantine", label: "Quarantine the message" }, + { value: "RedirectMessageTo", label: "Redirect the message to..." }, + { value: "BlindCopyTo", label: "Add recipients to the Bcc box..." }, + { value: "CopyTo", label: "Add recipients to the Cc box..." }, + { value: "ModerateMessageByUser", label: "Forward the message for approval to..." }, + { value: "ModerateMessageByManager", label: "Forward the message for approval to the sender's manager" }, + { value: "RejectMessage", label: "Reject the message with explanation..." }, + { value: "PrependSubject", label: "Prepend the subject with..." }, + { value: "SetSCL", label: "Set spam confidence level (SCL) to..." }, + { value: "SetHeader", label: "Set message header..." }, + { value: "RemoveHeader", label: "Remove message header..." }, + { value: "ApplyClassification", label: "Apply message classification..." }, + { value: "ApplyHtmlDisclaimer", label: "Apply HTML disclaimer..." }, + { value: "GenerateIncidentReport", label: "Generate incident report and send to..." }, + { value: "GenerateNotification", label: "Notify the sender with a message..." }, + { value: "ApplyOME", label: "Apply Office 365 Message Encryption" }, + ]; + + const renderConditionField = (condition) => { + const conditionValue = condition.value || condition; + const conditionLabel = condition.label || condition; + + switch (conditionValue) { + case "From": + case "SentTo": + return ( + + `${option.displayName} (${option.userPrincipalName})`, + valueField: "userPrincipalName", + dataKey: "Results", + }} + /> + + ); + + case "FromScope": + case "SentToScope": + return ( + + + + ); + + case "WithImportance": + return ( + + + + ); + + case "MessageTypeMatches": + return ( + + + + ); + + case "SCLOver": + return ( + + ({ + value: i.toString(), + label: i.toString(), + }))} + /> + + ); + + case "AttachmentSizeOver": + case "MessageSizeOver": + return ( + + + + ); + + case "SenderDomainIs": + case "RecipientDomainIs": + return ( + + + + ); + + case "HeaderContainsWords": + case "HeaderMatchesPatterns": + return ( + + + + + + + + + + + ); + + default: + return ( + + + + ); + } + }; + + const renderActionField = (action) => { + const actionValue = action.value || action; + const actionLabel = action.label || action; + + switch (actionValue) { + case "DeleteMessage": + case "Quarantine": + case "ModerateMessageByManager": + case "ApplyOME": + return ( + + + + ); + + case "RedirectMessageTo": + case "BlindCopyTo": + case "CopyTo": + case "ModerateMessageByUser": + case "GenerateIncidentReport": + return ( + + `${option.displayName} (${option.userPrincipalName})`, + valueField: "userPrincipalName", + dataKey: "Results", + }} + /> + + ); + + case "SetSCL": + return ( + + ({ + value: i.toString(), + label: i.toString(), + })), + ]} + /> + + ); + + case "RejectMessage": + return ( + + + + + + + + + + + ); + + case "SetHeader": + return ( + + + + + + + + + + + ); + + case "RemoveHeader": + return ( + + + + ); + + case "ApplyHtmlDisclaimer": + return ( + + + + + + + + + + + + + + ); + + case "PrependSubject": + case "ApplyClassification": + case "GenerateNotification": + return ( + + + + ); + + default: + return ( + + + + ); + } + }; + + const renderExceptionField = (exception) => { + const exceptionValue = exception.value || exception; + const baseCondition = exceptionValue.replace("ExceptIf", ""); + const exceptionLabel = exception.label || exception; + + const mockCondition = { value: baseCondition, label: exceptionLabel }; + const field = renderConditionField(mockCondition); + + if (field) { + return cloneElement(field, { + key: exceptionValue, + children: React.Children.map(field.props.children, (child) => { + if (child?.type === CippFormComponent) { + return cloneElement(child, { + name: exceptionValue, + }); + } + if (child?.type === Grid && child.props.container) { + return cloneElement(child, { + children: React.Children.map(child.props.children, (gridChild) => { + if (gridChild?.props?.children?.type === CippFormComponent) { + const formComponent = gridChild.props.children; + const originalName = formComponent.props.name; + const newName = originalName.includes("MessageHeader") + ? `ExceptIf${originalName}` + : exceptionValue; + return cloneElement(gridChild, { + children: cloneElement(formComponent, { + name: newName, + }), + }); + } + return gridChild; + }), + }); + } + return child; + }), + }); + } + return null; + }; + + const handleSubmit = () => { + formControl.trigger(); + const formData = formControl.getValues(); + const apiData = customDataFormatter(formData); + + submitRule.mutate({ + url: "/api/AddEditTransportRule", + data: apiData, + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset(defaultFormValues); + setSelectedConditions([]); + setSelectedActions([]); + setSelectedExceptions([]); + }; + + const rule = ruleInfo.data?.Results + ? (Array.isArray(ruleInfo.data.Results) ? ruleInfo.data.Results[0] : ruleInfo.data.Results) + : (Array.isArray(ruleInfo.data) ? ruleInfo.data[0] : ruleInfo.data); + + const queryClient = useQueryClient(); + + useEffect(() => { + if (submitRule.isSuccess) { + queryClient.invalidateQueries({ queryKey: [`ListTransportRules-${ruleId}`]}); + queryClient.invalidateQueries({ queryKey: [`Transport Rules - ${currentTenant}`]}); + onSuccess(); + } + }, [submitRule.isSuccess, queryClient, ruleId, currentTenant, onSuccess]); + + return ( + <> + {rowAction === false && !drawerVisible && ( + setDrawerVisible(true)} + startIcon={isEditMode ? : } + > + {buttonText} + + )} + + + + + } + > + + {/* Basic Information */} + + + Basic Information + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Conditions */} + + + Apply this rule if... + + + + + + + + {applyToAllMessagesWatch && ( + + + This rule will apply to ALL inbound and outbound messages + for the entire organization. + + + )} + + {!applyToAllMessagesWatch && ( + <> + + + Select one or more conditions to target specific messages. If you want this rule to + apply to all messages, enable "Apply to all messages" above. + + + + + + + + {selectedConditions.map((condition) => renderConditionField(condition))} + + )} + + + + {/* Actions */} + + + Do the following... + + + + + + + + {selectedActions.map((action) => renderActionField(action))} + + + + {/* Exceptions */} + + + Except if... (optional) + + + + + ({ + value: `ExceptIf${opt.value}`, + label: opt.label, + }))} + /> + + + {selectedExceptions.map((exception) => renderExceptionField(exception))} + + + + {/* Advanced Settings */} + + + Advanced Settings + + + + + + + + + + + + + + + + + + + + + + + + ); +}; \ No newline at end of file diff --git a/src/components/CippComponents/CippUserActions.jsx b/src/components/CippComponents/CippUserActions.jsx index 1f7dea46433e..b795e036e9bc 100644 --- a/src/components/CippComponents/CippUserActions.jsx +++ b/src/components/CippComponents/CippUserActions.jsx @@ -1,14 +1,13 @@ import { EyeIcon, MagnifyingGlassIcon, TrashIcon } from "@heroicons/react/24/outline"; import { Archive, - Block, Clear, CloudDone, Edit, Email, ForwardToInbox, GroupAdd, - LockOpen, + LockClock, LockPerson, LockReset, MeetingRoom, @@ -18,11 +17,109 @@ import { PhonelinkLock, PhonelinkSetup, Shortcut, + EditAttributes, } from "@mui/icons-material"; +import { getCippLicenseTranslation } from "../../utils/get-cipp-license-translation"; import { useSettings } from "/src/hooks/use-settings.js"; +import { usePermissions } from "../../hooks/use-permissions"; +import { Tooltip, Box } from "@mui/material"; +import CippFormComponent from "./CippFormComponent"; +import { useWatch } from "react-hook-form"; -export const CippUserActions = () => { +// Separate component for Out of Office form to avoid hook issues +const OutOfOfficeForm = ({ formControl }) => { + // Watch the Auto Reply State value + const autoReplyState = useWatch({ + control: formControl.control, + name: "AutoReplyState", + }); + + // Calculate if date fields should be disabled + const areDateFieldsDisabled = autoReplyState?.value !== "Scheduled"; + + return ( + <> + + + + + + + + + + + + + + + + + + + ); +}; + +export const useCippUserActions = () => { const tenant = useSettings().currentTenant; + + const { checkPermissions } = usePermissions(); + const canWriteUser = checkPermissions(["Identity.User.ReadWrite"]); + const canWriteMailbox = checkPermissions(["Exchange.Mailbox.ReadWrite"]); + const canWriteGroup = checkPermissions(["Identity.Group.ReadWrite"]); + return [ { //tested @@ -39,6 +136,7 @@ export const CippUserActions = () => { icon: , color: "success", target: "_self", + condition: () => canWriteUser, }, { //tested @@ -51,14 +149,33 @@ export const CippUserActions = () => { }, { //tested - label: "Create Temporary Access Password", type: "POST", icon: , url: "/api/ExecCreateTAP", data: { ID: "userPrincipalName" }, + fields: [ + { + type: "number", + name: "lifetimeInMinutes", + label: "Lifetime (Minutes)", + placeholder: "Leave blank for default", + }, + { + type: "switch", + name: "isUsableOnce", + label: "One-time use only", + }, + { + type: "datePicker", + name: "startDateTime", + label: "Start Date/Time (leave blank for immediate)", + dateTimeType: "datetime", + }, + ], confirmText: "Are you sure you want to create a Temporary Access Password?", multiPost: false, + condition: () => canWriteUser, }, { //tested @@ -69,6 +186,7 @@ export const CippUserActions = () => { data: { ID: "userPrincipalName" }, confirmText: "Are you sure you want to reset MFA for this user?", multiPost: false, + condition: () => canWriteUser, }, { //tested @@ -86,7 +204,7 @@ export const CippUserActions = () => { type: "POST", icon: , url: "/api/ExecPerUserMFA", - data: { userId: "userPrincipalName" }, + data: { userId: "id", userPrincipalName: "userPrincipalName" }, fields: [ { type: "autoComplete", @@ -99,29 +217,37 @@ export const CippUserActions = () => { ], multiple: false, creatable: false, + validators: { required: "Please select an MFA state" }, }, ], confirmText: "Are you sure you want to set per-user MFA for these users?", multiPost: false, + condition: () => canWriteUser, }, { //tested - label: "Convert to Shared Mailbox", - type: "POST", - icon: , - url: "/api/ExecConvertMailbox", - data: { ID: "userPrincipalName", MailboxType: "!Shared" }, - confirmText: "Are you sure you want to convert this user to a shared mailbox?", - multiPost: false, - }, - { - label: "Convert to User Mailbox", + label: "Convert Mailbox", type: "POST", icon: , url: "/api/ExecConvertMailbox", - data: { ID: "userPrincipalName", MailboxType: "!Regular" }, - confirmText: "Are you sure you want to convert this user to a user mailbox?", + data: { ID: "userPrincipalName" }, + fields: [ + { + type: "radio", + name: "MailboxType", + label: "Mailbox Type", + options: [ + { label: "User Mailbox", value: "Regular" }, + { label: "Shared Mailbox", value: "Shared" }, + { label: "Room Mailbox", value: "Room" }, + { label: "Equipment Mailbox", value: "Equipment" }, + ], + validators: { required: "Please select a mailbox type" }, + }, + ], + confirmText: "Pick the type of mailbox you want to convert [userPrincipalName] to:", multiPost: false, + condition: () => canWriteMailbox, }, { //tested @@ -130,8 +256,9 @@ export const CippUserActions = () => { icon: , url: "/api/ExecEnableArchive", data: { ID: "userPrincipalName" }, - confirmText: "Are you sure you want to enable the online archive for this user?", + confirmText: "Are you sure you want to enable the online archive for [userPrincipalName]?", multiPost: false, + condition: (row) => canWriteMailbox, }, { //tested @@ -141,32 +268,49 @@ export const CippUserActions = () => { url: "/api/ExecSetOoO", data: { userId: "userPrincipalName", - AutoReplyState: { value: "Enabled" }, tenantFilter: "Tenant", }, - fields: [{ type: "richText", name: "input", label: "Out of Office Message" }], + children: ({ formHook: formControl }) => , confirmText: "Are you sure you want to set the out of office?", multiPost: false, - }, - - { - label: "Disable Out of Office", - type: "POST", - icon: , - url: "/api/ExecSetOoO", - data: { - userId: "userPrincipalName", - AutoReplyState: { value: "Disabled" }, - }, - confirmText: "Are you sure you want to disable the out of office?", - multiPost: false, + condition: () => canWriteMailbox, }, { label: "Add to Group", type: "POST", icon: , url: "/api/EditGroup", - data: { addMember: "userPrincipalName" }, + customDataformatter: (row, action, formData) => { + let addMember = []; + if (Array.isArray(row)) { + row + .map((r) => ({ + label: r.displayName, + value: r.id, + addedFields: { + id: r.id, + userPrincipalName: r.userPrincipalName, + displayName: r.displayName, + }, + })) + .forEach((r) => addMember.push(r)); + } else { + addMember.push({ + label: row.displayName, + value: row.id, + addedFields: { + id: row.id, + userPrincipalName: row.userPrincipalName, + displayName: row.displayName, + }, + }); + } + return { + addMember: addMember, + tenantFilter: tenant, + groupId: formData.groupId, + }; + }, fields: [ { type: "autoComplete", @@ -174,19 +318,70 @@ export const CippUserActions = () => { label: "Select a group to add the user to", multiple: false, creatable: false, + validators: { required: "Please select a group" }, api: { url: "/api/ListGroups", - labelField: "displayName", + labelField: (option) => + option?.calculatedGroupType + ? `${option.displayName} (${option.calculatedGroupType})` + : option?.displayName ?? "", valueField: "id", addedField: { groupType: "calculatedGroupType", groupName: "displayName", }, queryKey: `groups-${tenant}`, + showRefresh: true, }, }, ], - confirmText: "Are you sure you want to add the user to this group?", + confirmText: "Are you sure you want to add [userPrincipalName] to this group?", + multiPost: true, + allowResubmit: true, + condition: () => canWriteGroup, + }, + { + label: "Manage Licenses", + type: "POST", + url: "/api/ExecBulkLicense", + icon: , + data: { userIds: "id" }, + multiPost: true, + fields: [ + { + type: "radio", + name: "LicenseOperation", + label: "License Operation", + options: [ + { label: "Add Licenses", value: "Add" }, + { label: "Remove Licenses", value: "Remove" }, + { label: "Replace Licenses", value: "Replace" }, + ], + validators: { required: "Please select a license operation" }, + }, + { + type: "switch", + name: "RemoveAllLicenses", + label: "Remove All Existing Licenses", + }, + { + type: "autoComplete", + name: "Licenses", + label: "Select Licenses", + multiple: true, + creatable: false, + api: { + url: "/api/ListLicenses", + labelField: (option) => + `${getCippLicenseTranslation([option])} (${option?.availableUnits} available)`, + valueField: "skuId", + queryKey: `licenses-${tenant}`, + }, + }, + ], + confirmText: "Are you sure you want to manage licenses for the selected users?", + multiPost: true, + condition: () => canWriteUser, }, { label: "Disable Email Forwarding", @@ -198,8 +393,9 @@ export const CippUserActions = () => { userid: "userPrincipalName", ForwardOption: "!disabled", }, - confirmText: "Are you sure you want to disable forwarding of this user's emails?", + confirmText: "Are you sure you want to disable forwarding of [userPrincipalName]'s emails?", multiPost: false, + condition: () => canWriteMailbox, }, { label: "Pre-provision OneDrive", @@ -209,6 +405,7 @@ export const CippUserActions = () => { data: { UserPrincipalName: "userPrincipalName" }, confirmText: "Are you sure you want to pre-provision OneDrive for this user?", multiPost: false, + condition: () => canWriteUser, }, { label: "Add OneDrive Shortcut", @@ -225,7 +422,8 @@ export const CippUserActions = () => { name: "siteUrl", label: "Select a Site", multiple: false, - creatable: false, + creatable: true, + validators: { required: "Please select or enter a SharePoint site URL" }, api: { url: "/api/ListSites", data: { type: "SharePointSiteUsage", URLOnly: true }, @@ -237,53 +435,72 @@ export const CippUserActions = () => { ], confirmText: "Select a SharePoint site to create a shortcut for:", multiPost: false, + condition: () => canWriteUser, }, { - label: "Block Sign In", + label: "Set Sign In State", type: "POST", - icon: , + icon: , url: "/api/ExecDisableUser", data: { ID: "id" }, - confirmText: "Are you sure you want to block the sign-in for this user?", - multiPost: false, - condition: (row) => row.accountEnabled, - }, - { - label: "Unblock Sign In", - type: "POST", - icon: , - url: "/api/ExecDisableUser", - data: { ID: "id", Enable: true }, - confirmText: "Are you sure you want to unblock sign-in for this user?", + fields: [ + { + type: "radio", + name: "Enable", + label: "Sign In State", + options: [ + { label: "Enabled", value: true }, + { label: "Disabled", value: false }, + ], + validators: { required: "Please select a sign-in state" }, + }, + ], + confirmText: "Are you sure you want to set the sign-in state for [userPrincipalName]?", multiPost: false, - condition: (row) => !row.accountEnabled, + condition: () => canWriteUser, }, { - label: "Reset Password (Must Change)", + label: "Reset Password", type: "POST", icon: , url: "/api/ExecResetPass", data: { - MustChange: true, ID: "userPrincipalName", displayName: "displayName", }, - confirmText: - "Are you sure you want to reset the password for this user? The user must change their password at next logon.", + fields: [ + { + type: "switch", + name: "MustChange", + label: "Must Change Password at Next Logon", + }, + ], + confirmText: "Are you sure you want to reset the password for [userPrincipalName]?", multiPost: false, + condition: () => canWriteUser, }, { - label: "Reset Password", + label: "Set Password Expiration", type: "POST", - icon: , - url: "/api/ExecResetPass", - data: { - MustChange: false, - ID: "userPrincipalName", - displayName: "displayName", - }, - confirmText: "Are you sure you want to reset the password for this user?", + icon: , + url: "/api/ExecPasswordNeverExpires", + data: { userId: "id", userPrincipalName: "userPrincipalName" }, + fields: [ + { + type: "radio", + name: "PasswordPolicy", + label: "Password Policy", + options: [ + { label: "Disable Password Expiration", value: "DisablePasswordExpiration" }, + { label: "Enable Password Expiration", value: "None" }, + ], + validators: { required: "Please select a password policy" }, + }, + ], + confirmText: + "Set Password Never Expires state for [userPrincipalName]. If the password of the user is older than the set expiration date of the organization, the user will be prompted to change their password at their next login.", multiPost: false, + condition: () => canWriteUser, }, { label: "Clear Immutable ID", @@ -293,9 +510,9 @@ export const CippUserActions = () => { data: { ID: "id", }, - confirmText: "Are you sure you want to clear the Immutable ID for this user?", + confirmText: "Are you sure you want to clear the Immutable ID for [userPrincipalName]?", multiPost: false, - condition: (row) => !row.onPremisesSyncEnabled && row?.onPremisesImmutableId, + condition: (row) => !row?.onPremisesSyncEnabled && row?.onPremisesImmutableId && canWriteUser, }, { label: "Revoke all user sessions", @@ -303,8 +520,9 @@ export const CippUserActions = () => { icon: , url: "/api/ExecRevokeSessions", data: { ID: "id", Username: "userPrincipalName" }, - confirmText: "Are you sure you want to revoke all sessions for this user?", + confirmText: "Are you sure you want to revoke all sessions for [userPrincipalName]?", multiPost: false, + condition: () => canWriteUser, }, { label: "Delete User", @@ -312,10 +530,42 @@ export const CippUserActions = () => { icon: , url: "/api/RemoveUser", data: { ID: "id", userPrincipalName: "userPrincipalName" }, - confirmText: "Are you sure you want to delete this user?", + confirmText: "Are you sure you want to delete [userPrincipalName]?", multiPost: false, + condition: () => canWriteUser, + }, + { + label: "Edit Properties", + icon: , + multiPost: true, + noConfirm: true, + customFunction: (users, action, formData) => { + // Handle both single user and multiple users + const userData = Array.isArray(users) ? users : [users]; + + // Store users in session storage to avoid URL length limits + sessionStorage.setItem("patchWizardUsers", JSON.stringify(userData)); + + // Use Next.js router for internal navigation + import("next/router") + .then(({ default: router }) => { + router.push("/identity/administration/users/patch-wizard"); + }) + .catch(() => { + // Fallback to window.location if router is not available + window.location.href = "/identity/administration/users/patch-wizard"; + }); + }, + condition: () => canWriteUser, }, ]; }; +// Legacy wrapper function for backward compatibility - but this should not be used +// Instead, components should use the useCippUserActions hook +export const CippUserActions = () => { + console.warn("CippUserActions() function is deprecated. Use useCippUserActions() hook instead."); + return useCippUserActions(); +}; + export default CippUserActions; diff --git a/src/components/CippComponents/CippVariableAutocomplete.jsx b/src/components/CippComponents/CippVariableAutocomplete.jsx new file mode 100644 index 000000000000..9910e9771afd --- /dev/null +++ b/src/components/CippComponents/CippVariableAutocomplete.jsx @@ -0,0 +1,342 @@ +import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; +import { + Paper, + Typography, + Box, + Chip, + Popper, + ListItem, + useTheme, + CircularProgress, +} from "@mui/material"; +import { ApiGetCall } from "/src/api/ApiCall"; +import { useSettings } from "/src/hooks/use-settings.js"; +import { getCippError } from "/src/utils/get-cipp-error"; + +/** + * Autocomplete component specifically for custom variables + * Shows when user types % in a text field + */ +export const CippVariableAutocomplete = React.memo( + ({ + open, + anchorEl, + onClose, + onSelect, + searchQuery = "", + tenantFilter = null, + includeSystemVariables = false, + position = { top: 0, left: 0 }, // Cursor position for floating box + }) => { + const theme = useTheme(); + const settings = useSettings(); + + // State management similar to CippAutocomplete + const [variables, setVariables] = useState([]); + const [getRequestInfo, setGetRequestInfo] = useState({ url: "", waiting: false, queryKey: "" }); + const [filteredVariables, setFilteredVariables] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); // For keyboard navigation + + // Get current tenant like CippAutocomplete does + const currentTenant = tenantFilter || settings.currentTenant; + + // API call using the same pattern as CippAutocomplete + const actionGetRequest = ApiGetCall({ + ...getRequestInfo, + }); + + // Setup API request when component mounts or tenant changes + useEffect(() => { + if (open) { + // Normalize tenant filter + const normalizedTenantFilter = currentTenant === "AllTenants" ? null : currentTenant; + + // Build API URL + let apiUrl = "/api/ListCustomVariables"; + const params = new URLSearchParams(); + + if (normalizedTenantFilter) { + params.append("tenantFilter", normalizedTenantFilter); + } + + if (!includeSystemVariables) { + params.append("includeSystem", "false"); + } + + if (params.toString()) { + apiUrl += `?${params.toString()}`; + } + + // Generate query key + const queryKey = `CustomVariables-${normalizedTenantFilter || "global"}-${ + includeSystemVariables ? "withSystem" : "noSystem" + }`; + + setGetRequestInfo({ + url: apiUrl, + waiting: true, + queryKey: queryKey, + staleTime: Infinity, // Never goes stale like in the updated hook + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }); + } + }, [open, currentTenant, includeSystemVariables]); + + // Process API response like CippAutocomplete does + useEffect(() => { + if (actionGetRequest.isSuccess && actionGetRequest.data?.Results) { + const processedVariables = actionGetRequest.data.Results.map((variable) => ({ + // Core properties + name: variable.Name, + variable: variable.Variable, + label: variable.Variable, // What shows in autocomplete + value: variable.Variable, // What gets inserted + + // Metadata for display and filtering + description: variable.Description, + type: variable.Type, // 'reserved' or 'custom' + category: variable.Category, // 'system', 'tenant', 'partner', 'cipp', 'global', 'tenant-custom' + + // Custom variable specific + ...(variable.Type === "custom" && { + customValue: variable.Value, + scope: variable.Scope, + }), + + // For grouping in autocomplete + group: + variable.Type === "reserved" + ? `Reserved (${variable.Category})` + : variable.category === "global" + ? "Global Custom Variables" + : "Tenant Custom Variables", + })); + + setVariables(processedVariables); + } + + if (actionGetRequest.isError) { + setVariables([ + { + label: getCippError(actionGetRequest.error), + value: "error", + name: "error", + variable: "error", + description: "Error loading variables", + }, + ]); + } + }, [actionGetRequest.isSuccess, actionGetRequest.isError, actionGetRequest.data]); + + // Filter variables based on search query + useEffect(() => { + if (!searchQuery) { + setFilteredVariables(variables); + setSelectedIndex(0); // Reset selection when filtering + return; + } + + const lowerQuery = searchQuery.toLowerCase(); + const filtered = variables.filter( + (variable) => + variable.name?.toLowerCase().includes(lowerQuery) || + variable.description?.toLowerCase().includes(lowerQuery) + ); + setFilteredVariables(filtered); + setSelectedIndex(0); // Reset selection when filtering + }, [searchQuery, variables]); + + const handleSelect = (event, value) => { + if (value && onSelect) { + onSelect(value.variable); // Pass the full variable string like %tenantname% + } + onClose(); + }; + + // Keyboard navigation handlers + const handleKeyDown = useCallback( + (event) => { + if (!open || filteredVariables.length === 0) return; + + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + setSelectedIndex((prev) => (prev < filteredVariables.length - 1 ? prev + 1 : 0)); + break; + case "ArrowUp": + event.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : filteredVariables.length - 1)); + break; + case "Tab": + case "Enter": + event.preventDefault(); + if (filteredVariables[selectedIndex]) { + handleSelect(event, filteredVariables[selectedIndex]); + } + break; + case "Escape": + event.preventDefault(); + onClose(); + break; + } + }, + [open, filteredVariables, selectedIndex, onClose] + ); + + // Set up keyboard event listeners + useEffect(() => { + if (open) { + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + } + }, [open, handleKeyDown]); + + if (!open) { + return null; + } + + // Show loading state like CippAutocomplete + if (actionGetRequest.isLoading && (!variables || variables.length === 0)) { + return ( + + + + + + Loading variables... + + + + + ); + } + + if (!variables || variables.length === 0) { + return null; + } + + if (filteredVariables.length === 0) { + return null; + } + + return ( + + { + e.stopPropagation(); + }} + > + {filteredVariables.map((variable, index) => ( + { + // Scroll selected item into view + if (el) { + el.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + } + : null + } + onClick={(e) => { + e.stopPropagation(); + e.preventDefault(); + handleSelect(e, variable); + }} + sx={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + py: 1, + px: 2, + borderBottom: `1px solid ${theme.palette.divider}`, + backgroundColor: + index === selectedIndex ? theme.palette.action.selected : "transparent", + borderLeft: + index === selectedIndex + ? `3px solid ${theme.palette.primary.main}` + : "3px solid transparent", + "&:hover": { + backgroundColor: theme.palette.action.hover, + }, + cursor: "pointer", + }} + > + + + {variable.variable} + + + {variable.description} + + + + + + + + ))} + + + ); + } +); diff --git a/src/components/CippComponents/DomainAnalyserDialog.jsx b/src/components/CippComponents/DomainAnalyserDialog.jsx new file mode 100644 index 000000000000..803baf43f154 --- /dev/null +++ b/src/components/CippComponents/DomainAnalyserDialog.jsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { Dialog, DialogContent, DialogTitle, Button, DialogActions } from "@mui/material"; +import { Refresh } from "@mui/icons-material"; +import { useForm, FormProvider } from "react-hook-form"; +import { CippFormTenantSelector } from "./CippFormTenantSelector"; +import { ApiPostCall } from "/src/api/ApiCall"; +import { CippApiResults } from "./CippApiResults"; + +export const DomainAnalyserDialog = ({ createDialog }) => { + const methods = useForm({ + defaultValues: { + tenantFilter: { + value: "AllTenants", + label: "*All Tenants", + }, + }, + }); + + // Use methods for form handling and control + const { handleSubmit, control } = methods; + + const [isRunning, setIsRunning] = useState(false); + const domainAnalyserResults = ApiPostCall({ + urlFromData: true, + }); + + const handleForm = (values) => { + setIsRunning(true); + domainAnalyserResults.mutate({ + url: "/api/ExecDomainAnalyser", + queryKey: `domain-analyser-${values.tenantFilter}`, + data: values.tenantFilter ? { TenantFilter: values.tenantFilter } : {}, + }); + }; + + // Reset running state when dialog is closed + const handleClose = () => { + setIsRunning(false); + createDialog.handleClose(); + }; + + return ( + + + + Run Domain Analysis + +
    +

    + This will run a Domain Analysis to check for DNS configuration issues. Select a + tenant (or all tenants) below. +

    + +
    + +
    + + + + + +
    +
    + ); +}; diff --git a/src/components/CippComponents/ScheduledTaskDetails.jsx b/src/components/CippComponents/ScheduledTaskDetails.jsx index 88aabea0f512..554c72261c4b 100644 --- a/src/components/CippComponents/ScheduledTaskDetails.jsx +++ b/src/components/CippComponents/ScheduledTaskDetails.jsx @@ -20,8 +20,10 @@ import { ExpandMore, Sync, Search, Close } from "@mui/icons-material"; import { getCippFormatting } from "../../utils/get-cipp-formatting"; import { CippDataTable } from "../CippTable/CippDataTable"; import { CippTimeAgo } from "/src/components/CippComponents/CippTimeAgo"; +import { ActionsMenu } from "/src/components/actions-menu"; +import { CippScheduledTaskActions } from "./CippScheduledTaskActions"; -const ScheduledTaskDetails = ({ data }) => { +const ScheduledTaskDetails = ({ data, showActions = true }) => { const [taskDetails, setTaskDetails] = useState(null); const [expanded, setExpanded] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -79,7 +81,20 @@ const ScheduledTaskDetails = ({ data }) => { return ( <> - {taskDetails?.Task?.Name} + + + {taskDetailResults.isLoading ? : taskDetails?.Task?.Name} + + {showActions && ( + + + + )} + @@ -103,6 +118,31 @@ const ScheduledTaskDetails = ({ data }) => { isFetching={taskDetailResults.isFetching} /> + {taskDetails?.Task?.Trigger && ( + + }> + Trigger Configuration + + + { + return { + label: key, + value: getCippFormatting(value, key), + }; + })} + isFetching={taskDetailResults.isFetching} + /> + + + )} + {taskDetailResults.isFetching ? ( ) : ( @@ -206,7 +246,9 @@ const ScheduledTaskDetails = ({ data }) => { }, }} > - {result.TenantName || result.Tenant} + + {getCippFormatting(result.TenantName || result.Tenant, "Tenant")} + { /> - {result.Results === "null" ? ( + {result.Results === "null" || !result.Results ? ( No data available ) : Array.isArray(result.Results) ? ( { + const { formControl, isEdit = false } = props; + + const assignmentFilterManagementType = + useWatch({ + control: formControl?.control ?? formControl, + name: "assignmentFilterManagementType", + defaultValue: "devices", + }) ?? "devices"; + const platformOptions = + assignmentFilterManagementType === "apps" ? APP_PLATFORM_OPTIONS : DEVICE_PLATFORM_OPTIONS; + + return ( + + + + + + + + + + + + + + + + + + + Enter the filter rule using Intune filter syntax. See{" "} + + Microsoft documentation + {" "} + for supported properties and operators. + + } + multiline + rows={6} + fullWidth + /> + + + ); +}; + +export default CippAddAssignmentFilterForm; diff --git a/src/components/CippFormPages/CippAddAssignmentFilterTemplateForm.jsx b/src/components/CippFormPages/CippAddAssignmentFilterTemplateForm.jsx new file mode 100644 index 000000000000..2b43d8f4c6b6 --- /dev/null +++ b/src/components/CippFormPages/CippAddAssignmentFilterTemplateForm.jsx @@ -0,0 +1,117 @@ +import "@mui/material"; +import { Grid } from "@mui/system"; +import { useWatch } from "react-hook-form"; +import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; + +const DEVICE_PLATFORM_OPTIONS = [ + { label: "Windows 10 and later", value: "windows10AndLater" }, + { label: "iOS", value: "iOS" }, + { label: "macOS", value: "macOS" }, + { label: "Android Enterprise", value: "androidForWork" }, + { label: "Android device administrator", value: "android" }, + { label: "Android Work Profile", value: "androidWorkProfile" }, + { label: "Android (AOSP)", value: "androidAOSP" }, +]; + +const APP_PLATFORM_OPTIONS = [ + { label: "Windows", value: "windowsMobileApplicationManagement" }, + { label: "Android", value: "androidMobileApplicationManagement" }, + { label: "iOS/iPadOS", value: "iOSMobileApplicationManagement" }, +]; + +const CippAddAssignmentFilterTemplateForm = (props) => { + const { formControl } = props; + + const assignmentFilterManagementType = + useWatch({ + control: formControl?.control ?? formControl, + name: "assignmentFilterManagementType", + defaultValue: "devices", + }) ?? "devices"; + const platformOptions = + assignmentFilterManagementType === "apps" ? APP_PLATFORM_OPTIONS : DEVICE_PLATFORM_OPTIONS; + + return ( + + {/* Hidden field to store the template GUID when editing */} + + + + + + + + + + + + + + + + + + + + Enter the filter rule using Intune filter syntax. See{" "} + + Microsoft documentation + {" "} + for supported properties and operators. + + } + required + multiline + rows={6} + validators={{ required: "Filter Rule is required" }} + fullWidth + /> + + + ); +}; + +export default CippAddAssignmentFilterTemplateForm; diff --git a/src/components/CippFormPages/CippAddEditContact.jsx b/src/components/CippFormPages/CippAddEditContact.jsx new file mode 100644 index 000000000000..cbc96616d37c --- /dev/null +++ b/src/components/CippFormPages/CippAddEditContact.jsx @@ -0,0 +1,194 @@ +import { Divider } from "@mui/material"; +import { Grid } from "@mui/system"; +import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; +import { getCippValidator } from "/src/utils/get-cipp-validator"; +import countryList from "/src/data/countryList.json"; + +const countryOptions = countryList.map(({ Code, Name }) => ({ + label: Name, + value: Code, +})); + +const ContactFormLayout = ({ formControl, formType = "add" }) => { + return ( + + {/* Display Name */} + + + + + {/* First Name and Last Name */} + + + + + + + + + + {/* Email */} + + getCippValidator(value, "email"), + }} + /> + + + {/* Hide from GAL */} + + + + + + + {/* Company Information */} + + + + + + + + {/* Website */} + + !value || getCippValidator(value, "url"), + }} + /> + + + + + {/* Address Information */} + + + + + + + + + + + + + + + + + + + {/* Phone Numbers */} + + + + + + + + + + {/* Mail Tip */} + + + + + ); +}; + +export default ContactFormLayout; diff --git a/src/components/CippFormPages/CippAddEditUser.jsx b/src/components/CippFormPages/CippAddEditUser.jsx index 8912c6a4f807..fe5a49fb1581 100644 --- a/src/components/CippFormPages/CippAddEditUser.jsx +++ b/src/components/CippFormPages/CippAddEditUser.jsx @@ -1,19 +1,22 @@ -import { Alert, InputAdornment, Typography } from "@mui/material"; +import { Alert, Divider, InputAdornment, Typography } from "@mui/material"; import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; import { CippFormCondition } from "/src/components/CippComponents/CippFormCondition"; import { CippFormDomainSelector } from "/src/components/CippComponents/CippFormDomainSelector"; import { CippFormUserSelector } from "/src/components/CippComponents/CippFormUserSelector"; import countryList from "/src/data/countryList.json"; import { CippFormLicenseSelector } from "/src/components/CippComponents/CippFormLicenseSelector"; -import Grid from "@mui/material/Grid"; +import { Grid } from "@mui/system"; import { ApiGetCall } from "../../api/ApiCall"; import { useSettings } from "../../hooks/use-settings"; import { useWatch } from "react-hook-form"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; +import { useRouter } from "next/router"; const CippAddEditUser = (props) => { const { formControl, userSettingsDefaults, formType = "add" } = props; const tenantDomain = useSettings().currentTenant; + const router = useRouter(); + const { userId } = router.query; const integrationSettings = ApiGetCall({ url: "/api/ListExtensionsConfig", queryKey: "ListExtensionsConfig", @@ -21,6 +24,52 @@ const CippAddEditUser = (props) => { refetchOnReconnect: false, }); + // Get all groups the is the user is a member of + const userGroups = ApiGetCall({ + url: `/api/ListUserGroups?userId=${userId}&tenantFilter=${tenantDomain}`, + queryKey: `User-${userId}-Groups-${tenantDomain}`, + refetchOnMount: false, + refetchOnReconnect: false, + waiting: !!userId, + }); + + // Get all groups for the tenant + const tenantGroups = ApiGetCall({ + url: `/api/ListGroups?tenantFilter=${tenantDomain}`, + queryKey: `ListGroups-${tenantDomain}`, + refetchOnMount: false, + refetchOnReconnect: false, + waiting: !!userId, + }); + + // Get manual entry custom data mappings for current tenant + const manualEntryMappings = ApiGetCall({ + url: `/api/ListCustomDataMappings?sourceType=Manual Entry&directoryObject=User&tenantFilter=${tenantDomain}`, + queryKey: `ManualEntryMappings-${tenantDomain}`, + refetchOnMount: false, + refetchOnReconnect: false, + }); + + // Use mappings directly since they're already filtered by the API + const currentTenantManualMappings = useMemo(() => { + if (manualEntryMappings.isSuccess) { + return manualEntryMappings.data?.Results || []; + } + return []; + }, [manualEntryMappings.isSuccess, manualEntryMappings.data]); + + // Make new list of groups by removing userGroups from tenantGroups + const filteredTenantGroups = useMemo(() => { + if (tenantGroups.isSuccess && userGroups.isSuccess) { + const tenantGroupsList = tenantGroups?.data || []; + + return tenantGroupsList.filter( + (tenantGroup) => !userGroups?.data?.some((userGroup) => userGroup.id === tenantGroup.id) + ); + } + return []; + }, [tenantGroups.isSuccess, userGroups.isSuccess, tenantGroups.data, userGroups.data]); + const watcher = useWatch({ control: formControl.control }); useEffect(() => { //if watch.firstname changes, and watch.lastname changes, set displayname to firstname + lastname @@ -31,7 +80,7 @@ const CippAddEditUser = (props) => { return ( - + { formControl={formControl} /> - + { formControl={formControl} /> - + { formControl={formControl} /> - + { formControl={formControl} /> - + - + { /> - + Settings - + { compareType="is" compareValue={true} > - + { - + { formControl={formControl} /> - + ({ label: Name, value: Code, @@ -139,7 +188,7 @@ const CippAddEditUser = (props) => { formControl={formControl} /> - + {integrationSettings?.data?.Sherweb?.Enabled === true && ( @@ -151,7 +200,7 @@ const CippAddEditUser = (props) => { compareValue="(0 available)" labelCompare={true} > - + { compareType="is" compareValue={true} > - + This will Purchase a new Sherweb License for the user, according to the terms and conditions with Sherweb. When the license becomes available, CIPP will assign the license to this user. - + { )} - + { formControl={formControl} /> - + { formControl={formControl} /> - + { formControl={formControl} /> - + - + - + - + + + + { formControl={formControl} /> - + { formControl={formControl} /> - + { formControl={formControl} /> - + { formControl={formControl} /> - + { {userSettingsDefaults?.userAttributes ?.filter((attribute) => attribute.value !== "sponsor") .map((attribute, idx) => ( - + { ))} {/* Set Manager */} - + { /> {userSettingsDefaults?.userAttributes?.some((attribute) => attribute.value === "sponsor") && ( - + { /> )} - + { /> {formType === "edit" && ( - + ({ + label: tenantGroup.displayName, + value: tenantGroup.id, + addedFields: { + calculatedGroupType: tenantGroup.calculatedGroupType, }, - }} + }))} formControl={formControl} /> )} - {/* Schedule User Creation */} - {formType === "add" && ( - + {formType === "edit" && ( + ({ + label: userGroups.DisplayName, + value: userGroups.id, + addedFields: { + calculatedGroupType: userGroups.calculatedGroupType, + }, + }))} formControl={formControl} /> - - - - - - - - - - - )} + {/* Manual Entry Custom Data Fields */} + {currentTenantManualMappings.length > 0 && ( + <> + + Custom Data + + {currentTenantManualMappings.map((mapping, index) => { + const fieldName = `customData.${mapping.customDataAttribute.value}`; + const fieldLabel = mapping.manualEntryFieldLabel; + const dataType = mapping.customDataAttribute.addedFields.dataType; + + // Determine field type based on the custom data attribute type + const getFieldType = (dataType) => { + switch (dataType?.toLowerCase()) { + case "boolean": + return "switch"; + case "datetime": + case "date": + return "datePicker"; + case "string": + default: + return "textField"; + } + }; + + return ( + + + + ); + })} + + )} + {/* Schedule User Creation */} + {formType === "add" && ( + <> + + + + + + + + + + + + + + + + + + + )} ); }; diff --git a/src/components/CippFormPages/CippAddGroupForm.jsx b/src/components/CippFormPages/CippAddGroupForm.jsx index 30817d510b0c..83d64df292b2 100644 --- a/src/components/CippFormPages/CippAddGroupForm.jsx +++ b/src/components/CippFormPages/CippAddGroupForm.jsx @@ -1,16 +1,31 @@ -import React from "react"; -import { Grid, InputAdornment } from "@mui/material"; +import { InputAdornment } from "@mui/material"; +import { Grid } from "@mui/system"; import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; import { CippFormCondition } from "/src/components/CippComponents/CippFormCondition"; import { CippFormDomainSelector } from "../CippComponents/CippFormDomainSelector"; import { CippFormUserSelector } from "../CippComponents/CippFormUserSelector"; +const DynamicMembershipRules = ({ formControl }) => ( + + + +); + const CippAddGroupForm = (props) => { const { formControl } = props; return ( - + { fullWidth /> - + { fullWidth /> - + { }} /> - + { /> - + { /> - + { select={"id,userPrincipalName,displayName"} /> - + { { label: "Security Group", value: "generic" }, { label: "Microsoft 365 Group", value: "m365" }, { label: "Dynamic Group", value: "dynamic" }, - { label: "Dynamic Distribution Group", value: "dynamicDistribution" }, + { label: "Dynamic Distribution Group", value: "dynamicdistribution" }, { label: "Distribution List", value: "distribution" }, { label: "Mail Enabled Security Group", value: "security" }, ]} @@ -88,10 +103,10 @@ const CippAddGroupForm = (props) => { - + { compareType="contains" compareValue="dynamic" > - + { const { formControl } = props; + // Debug the current form values, especially groupType + useEffect(() => { + const subscription = formControl.watch((value, { name, type }) => {}); + return () => subscription.unsubscribe(); + }, [formControl]); + return ( - + {/* Hidden field to store the template GUID when editing */} + + + { fullWidth /> - + { fullWidth /> - + { /> - + { { label: "Security Group", value: "generic" }, { label: "Microsoft 365 Group", value: "m365" }, { label: "Dynamic Group", value: "dynamic" }, - { label: "Dynamic Distribution Group", value: "dynamicdistribution" }, + { label: "Dynamic Distribution Group", value: "dynamicDistribution" }, { label: "Distribution List", value: "distribution" }, { label: "Mail Enabled Security Group", value: "security" }, ]} /> + {/* Debug output */} +
    Current groupType: {formControl.watch("groupType")}
    - + { compareType="contains" compareValue="dynamic" > - + { + + return ( + + + + + + + + @, + }} + /> + + + + + + + + + ); +}; + +export default CippAddRoomListForm; \ No newline at end of file diff --git a/src/components/CippFormPages/CippCustomDataMappingForm.jsx b/src/components/CippFormPages/CippCustomDataMappingForm.jsx index 764bbe56c247..acb0c7134a56 100644 --- a/src/components/CippFormPages/CippCustomDataMappingForm.jsx +++ b/src/components/CippFormPages/CippCustomDataMappingForm.jsx @@ -11,6 +11,7 @@ import { getCippTranslation } from "/src/utils/get-cipp-translation"; const CippCustomDataMappingForm = ({ formControl }) => { const selectedAttribute = useWatch({ control: formControl.control, name: "customDataAttribute" }); + const selectedDirectoryObjectType = useWatch({ control: formControl.control, name: "directoryObjectType", @@ -19,19 +20,35 @@ const CippCustomDataMappingForm = ({ formControl }) => { control: formControl.control, name: "extensionSyncDataset", }); + const selectedSourceType = useWatch({ + control: formControl.control, + name: "sourceType", + }); + const selectedManualEntryFieldLabel = useWatch({ + control: formControl.control, + name: "manualEntryFieldLabel", + }); + + console.log("Selected directory object type: ", selectedDirectoryObjectType); const staticTargetTypes = [{ value: "user", label: "User" }]; + // Top-level source type selection + const sourceTypeField = { + name: "sourceType", + label: "Source Type", + type: "autoComplete", + required: true, + multiple: false, + placeholder: "Select a Source Type", + options: [ + { value: "extensionSync", label: "Extension Sync" }, + { value: "manualEntry", label: "Manual Entry" }, + ], + }; + + // Extension Sync specific fields const sourceFields = [ - { - name: "sourceType", - label: "Source Type", - type: "autoComplete", - required: true, - multiple: false, - placeholder: "Select a Source Type", - options: [{ value: "extensionSync", label: "Extension Sync" }], - }, { name: "extensionSyncDataset", label: "Extension Sync Dataset", @@ -47,7 +64,7 @@ const CippCustomDataMappingForm = ({ formControl }) => { creatable: false, condition: { field: "sourceType", - compareType: "is", + compareType: "valueEq", compareValue: "extensionSync", }, }, @@ -77,6 +94,59 @@ const CippCustomDataMappingForm = ({ formControl }) => { }, ]; + // Manual Entry specific fields + const manualEntryFields = [ + { + name: "manualEntryFieldLabel", + label: "Field Label", + type: "textField", + required: true, + placeholder: "Enter field label (e.g., Employee ID, Department)", + disableVariables: true, + }, + { + name: "directoryObjectType", + label: "Directory Object Type", + type: "autoComplete", + required: true, + placeholder: "Select an Object Type", + options: staticTargetTypes, + multiple: false, + creatable: false, + }, + { + name: "customDataAttribute", + label: "Attribute", + type: "autoComplete", + required: true, + placeholder: "Select an Attribute", + api: { + url: "/api/ExecCustomData?Action=ListAvailableAttributes", + queryKey: "CustomAttributes", + dataKey: "Results", + dataFilter: (options) => { + if (!selectedDirectoryObjectType?.value) return options; + return options.filter( + (option) => + option?.addedFields?.targetObject?.toLowerCase() === + selectedDirectoryObjectType?.value?.toLowerCase() + ); + }, + valueField: "name", + labelField: "name", + showRefresh: true, + addedField: { + type: "type", + targetObject: "targetObject", + dataType: "dataType", + isMultiValued: "isMultiValued", + }, + }, + multiple: false, + sortOptions: true, + }, + ]; + const destinationFields = [ { name: "directoryObjectType", @@ -125,7 +195,7 @@ const CippCustomDataMappingForm = ({ formControl }) => { return ( - + @@ -143,44 +213,76 @@ const CippCustomDataMappingForm = ({ formControl }) => { - Source Details + Source Type - {sourceFields.map((field, index) => ( - <> - {field?.condition ? ( - - - - ) : ( - - )} - - ))} - - - - - Destination Details - - {destinationFields.map((field, index) => ( - <> - {field?.condition ? ( - - - - ) : ( - - )} - - ))} + + + {selectedSourceType?.value === "extensionSync" && ( + <> + + + Source Details + + {sourceFields.map((field, index) => ( + <> + {field?.condition ? ( + + + + ) : ( + + )} + + ))} + + + + + Destination Details + + {destinationFields.map((field, index) => ( + <> + {field?.condition ? ( + + + + ) : ( + + )} + + ))} + + + + )} + + {selectedSourceType?.value === "manualEntry" && ( + + + Manual Entry Configuration + + {manualEntryFields.map((field, index) => ( + + ))} + + + )} - + - {selectedExtensionSyncDataset && ( + {selectedExtensionSyncDataset && selectedSourceType?.value === "extensionSync" && ( { /> )} + {selectedSourceType?.value === "manualEntry" && selectedManualEntryFieldLabel && ( + + )} + {selectedAttribute && ( { ); }; -export default CippCustomDataMappingForm; \ No newline at end of file +export default CippCustomDataMappingForm; diff --git a/src/components/CippFormPages/CippExchangeSettingsForm.jsx b/src/components/CippFormPages/CippExchangeSettingsForm.jsx index 1b293aefd7d1..db7c15da5af8 100644 --- a/src/components/CippFormPages/CippExchangeSettingsForm.jsx +++ b/src/components/CippFormPages/CippExchangeSettingsForm.jsx @@ -1,33 +1,43 @@ -import React, { useState, useEffect } from "react"; +import { useState, useEffect } from "react"; import { Box, Button, Card, Collapse, Divider, - IconButton, Stack, SvgIcon, Typography, Tooltip, + CircularProgress, + IconButton, } from "@mui/material"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import { Check, Error, Sync } from "@mui/icons-material"; import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; -import { CippFormCondition } from "/src/components/CippComponents/CippFormCondition"; -import { Forward } from "@mui/icons-material"; import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import { useSettings } from "../../hooks/use-settings"; import { Grid } from "@mui/system"; import { CippApiResults } from "../CippComponents/CippApiResults"; -import { useWatch, useFormContext } from "react-hook-form"; +import { useWatch } from "react-hook-form"; +import { ChevronDownIcon } from "@heroicons/react/24/outline"; +import CippForwardingSection from "../CippComponents/CippForwardingSection"; const CippExchangeSettingsForm = (props) => { const userSettingsDefaults = useSettings(); - const { formControl, currentSettings, userId, calPermissions, isFetching } = props; + const { formControl, currentSettings, userId, calPermissions, isFetching, oooRequest } = props; // State to manage the expanded panels const [expandedPanel, setExpandedPanel] = useState(null); const [relatedQueryKeys, setRelatedQueryKeys] = useState([]); + // Watch the Auto Reply State value + const autoReplyState = useWatch({ + control: formControl.control, + name: "ooo.AutoReplyState", + }); + + // Calculate if date fields should be disabled + const areDateFieldsDisabled = autoReplyState?.value !== "Scheduled"; + const handleExpand = (panel) => { setExpandedPanel((prev) => (prev === panel ? null : panel)); }; @@ -38,21 +48,60 @@ const CippExchangeSettingsForm = (props) => { Endpoint: `users`, tenantFilter: userSettingsDefaults.currentTenant, $select: "id,displayName,userPrincipalName,mail", - noPagination: true, $top: 999, }, queryKey: `UserNames-${userSettingsDefaults.currentTenant}`, }); + const contactsList = ApiGetCall({ + url: "/api/ListGraphRequest", + data: { + Endpoint: `contacts`, + tenantFilter: userSettingsDefaults.currentTenant, + $select: "displayName,mail,mailNickname", + $top: 999, + }, + queryKey: `TenantContacts-${userSettingsDefaults.currentTenant}`, + }); + const postRequest = ApiPostCall({ datafromUrl: true, relatedQueryKeys: relatedQueryKeys, }); + // Handle form reset and set dropdown state after successful API calls + useEffect(() => { + if (postRequest.isSuccess) { + // If this was an OOO submission, preserve the submitted values + if (relatedQueryKeys.includes(`ooo-${userId}`)) { + const submittedValues = formControl.getValues(); + const oooFields = [ + "AutoReplyState", + "InternalMessage", + "ExternalMessage", + "StartTime", + "EndTime", + ]; + + // Reset the form + formControl.reset(); + + // Restore the submitted OOO values + oooFields.forEach((field) => { + const value = submittedValues.ooo?.[field]; + if (value !== undefined) { + formControl.setValue(`ooo.${field}`, value); + } + }); + } else { + // For non-OOO submissions, just reset normally + formControl.reset(); + } + } + }, [postRequest.isSuccess, relatedQueryKeys, userId, formControl]); + const handleSubmit = (type) => { - if (type === "permissions") { - setRelatedQueryKeys([`Mailbox-${userId}`]); - } else if (type === "calendar") { + if (type === "calendar") { setRelatedQueryKeys([`CalendarPermissions-${userId}`]); } else if (type === "forwarding") { setRelatedQueryKeys([`Mailbox-${userId}`]); @@ -83,7 +132,6 @@ const CippExchangeSettingsForm = (props) => { } }); const url = { - permissions: "/api/ExecEditMailboxPermissions", calendar: "/api/ExecEditCalendarPermissions", forwarding: "/api/ExecEmailForward", ooo: "/api/ExecSetOoO", @@ -94,407 +142,63 @@ const CippExchangeSettingsForm = (props) => { data: data, queryKey: "MailboxPermissions", }); - - // Reset the form - formControl.reset(); }; // Data for each section const sections = [ - { - id: "mailboxPermissions", - cardLabelBox: "-", - text: "Mailbox Permissions", - subtext: "Manage mailbox permissions for users", - formContent: ( - - {/* Full Access Section */} - - Full Access - - Manage who has full access to this mailbox - - - - currentSettings?.Permissions?.some( - (perm) => - perm.AccessRights === "FullAccess" && perm.User === user.userPrincipalName - ) - ).map((user) => ({ - value: user.userPrincipalName, - label: `${user.displayName} (${user.userPrincipalName})`, - })) || [] - } - formControl={formControl} - /> - ({ - value: user.userPrincipalName, - label: `${user.displayName} (${user.userPrincipalName})`, - })) || [] - } - formControl={formControl} - /> - ({ - value: user.userPrincipalName, - label: `${user.displayName} (${user.userPrincipalName})`, - })) || [] - } - formControl={formControl} - /> - - - - {/* Send As Section */} - - Send As - - Manage who can send emails as this user - - - - currentSettings?.Permissions?.some( - (perm) => perm.AccessRights === "SendAs" && perm.User === user.userPrincipalName - ) - ).map((user) => ({ - value: user.userPrincipalName, - label: `${user.displayName} (${user.userPrincipalName})`, - })) || [] - } - formControl={formControl} - /> - ({ - value: user.userPrincipalName, - label: `${user.displayName} (${user.userPrincipalName})`, - })) || [] - } - formControl={formControl} - /> - - - - {/* Send On Behalf Section */} - - Send On Behalf - - Manage who can send emails on behalf of this user - - - - currentSettings?.Permissions?.some( - (perm) => - perm.AccessRights === "SendOnBehalf" && perm.User === user.userPrincipalName - ) - ).map((user) => ({ - value: user.userPrincipalName, - label: `${user.displayName} (${user.userPrincipalName})`, - })) || [] - } - formControl={formControl} - /> - ({ - value: user.userPrincipalName, - label: `${user.displayName} (${user.userPrincipalName})`, - })) || [] - } - formControl={formControl} - /> - - - - - - - - - - - ), - }, - { - id: "calendarPermissions", - cardLabelBox: "-", - text: "Calendar Permissions", - subtext: "Adjust calendar sharing settings", - formContent: ( - - - calPermissions?.some((perm) => perm.User === user.displayName) - ).map((user) => ({ - value: user.userPrincipalName, - label: `${user.displayName} (${user.userPrincipalName})`, - })) || [] - } - formControl={formControl} - /> - ({ - value: user.userPrincipalName, - label: `${user.displayName} (${user.userPrincipalName})`, - })) || []), - ]} - multiple={false} - formControl={formControl} - /> - - - - value ? true : "Select the permission level for the calendar", - }} - isFetching={isFetching || usersList.isFetching} - options={[ - { value: "Author", label: "Author" }, - { value: "Contributor", label: "Contributor" }, - { value: "Editor", label: "Editor" }, - { value: "Owner", label: "Owner" }, - { value: "NonEditingAuthor", label: "Non Editing Author" }, - { value: "PublishingAuthor", label: "Publishing Author" }, - { value: "PublishingEditor", label: "Publishing Editor" }, - { value: "Reviewer", label: "Reviewer" }, - { value: "LimitedDetails", label: "Limited Details" }, - { value: "AvailabilityOnly", label: "Availability Only" }, - ]} - multiple={false} - formControl={formControl} - /> - - {(() => { - const permissionLevel = useWatch({ - control: formControl.control, - name: "calendar.Permissions" - }); - const isEditor = permissionLevel?.value === "Editor"; - - // Use useEffect to handle the switch value reset - useEffect(() => { - if (!isEditor) { - formControl.setValue("calendar.CanViewPrivateItems", false); - } - }, [isEditor, formControl]); - - return ( - - - - - - ); - })()} - - - - - - - - - - - - ), - }, { id: "mailboxForwarding", - cardLabelBox: currentSettings?.ForwardAndDeliver ? : "-", + cardLabelBox: { + cardLabelBoxHeader: isFetching ? ( + + ) : currentSettings?.ForwardingAddress ? ( + + ) : ( + + ), + }, text: "Mailbox Forwarding", - subtext: "Configure email forwarding options", + subtext: currentSettings?.ForwardingAddress + ? "Email forwarding is configured for this mailbox" + : "No email forwarding configured for this mailbox", formContent: ( - - - - - ({ - value: user.userPrincipalName, - label: `${user.displayName} (${user.userPrincipalName})`, - })) || [] - } - formControl={formControl} - /> - - - - - - - - - - - - - - + ), }, { id: "outOfOffice", - cardLabelBox: "OOO", + cardLabelBox: { + cardLabelBoxHeader: OOO, + }, text: "Out Of Office", subtext: "Set automatic replies for when you are away", + action: oooRequest + ? { + tooltip: oooRequest.isFetching + ? "Refreshing Out Of Office data" + : "Refresh Out Of Office data", + onClick: () => oooRequest.refetch(), + disabled: oooRequest.isFetching, + isLoading: oooRequest.isFetching, + } + : null, formContent: ( - + { ]} /> - - + + + + + + - - + + + + + + - + { rows={4} /> - + { rows={4} /> - + @@ -556,22 +284,29 @@ const CippExchangeSettingsForm = (props) => { }, { id: "recipientLimits", - cardLabelBox: "RL", + cardLabelBox: { + cardLabelBoxHeader: RL, + }, text: "Recipient Limits", subtext: "Set the maximum number of recipients per message", formContent: ( - + - + @@ -600,8 +335,15 @@ const CippExchangeSettingsForm = (props) => { alignItems: "center", display: "flex", justifyContent: "space-between", - p: 2, + py: 3, + pl: 2, + pr: 4, + cursor: "pointer", + "&:hover": { + bgcolor: "action.hover", + }, }} + onClick={() => handleExpand(section.id)} > {/* Left Side: cardLabelBox, text, subtext */} @@ -610,14 +352,14 @@ const CippExchangeSettingsForm = (props) => { sx={{ alignItems: "center", borderRadius: 1, - color: "primary.contrastText", + color: "text.secondary", display: "flex", height: 40, justifyContent: "center", width: 40, }} > - {section.cardLabelBox} + {section.cardLabelBox.cardLabelBoxHeader}
    {/* Main Text and Subtext */} @@ -631,8 +373,39 @@ const CippExchangeSettingsForm = (props) => { - {/* Expand Icon */} - handleExpand(section.id)}> + + {section.action && ( + + + { + event.stopPropagation(); + section.action.onClick?.(); + }} + disabled={section.action.disabled} + sx={{ + color: "text.secondary", + }} + > + + + + + + + )} { transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)", }} > - + - + diff --git a/src/components/CippFormPages/CippFormPage.jsx b/src/components/CippFormPages/CippFormPage.jsx index d22877dfb139..2a85e6362ee9 100644 --- a/src/components/CippFormPages/CippFormPage.jsx +++ b/src/components/CippFormPages/CippFormPage.jsx @@ -11,7 +11,6 @@ import { CardActions, } from "@mui/material"; import ArrowLeftIcon from "@mui/icons-material/ArrowLeft"; -import Head from "next/head"; import { ApiPostCall } from "../../api/ApiCall"; import { CippApiResults } from "../CippComponents/CippApiResults"; import { useEffect } from "react"; @@ -22,6 +21,7 @@ const CippFormPage = (props) => { const { title, backButtonTitle, + titleButton, formPageType = "Add", children, queryKey, @@ -71,6 +71,11 @@ const CippFormPage = (props) => { }, [postCall.isSuccess]); const handleSubmit = () => { + formControl.trigger(); + // Check if the form is valid before proceeding + if (!isValid) { + return; + } const values = customDataformatter ? customDataformatter(formControl.getValues()) : formControl.getValues(); @@ -114,11 +119,14 @@ const CippFormPage = (props) => { )} -
    +
    {!hidePageType && <>{formPageType} - } {title} + {titleButton && titleButton}
    )} diff --git a/src/components/CippFormPages/CippFormSkeleton.jsx b/src/components/CippFormPages/CippFormSkeleton.jsx index 086b6b09c378..5fba2ef46370 100644 --- a/src/components/CippFormPages/CippFormSkeleton.jsx +++ b/src/components/CippFormPages/CippFormSkeleton.jsx @@ -1,4 +1,6 @@ -import { Box, Grid, Skeleton } from "@mui/material"; +import { Box, Skeleton } from "@mui/material"; + +import { Grid } from "@mui/system"; const CippFormSkeleton = ({ layout }) => { return ( @@ -6,7 +8,7 @@ const CippFormSkeleton = ({ layout }) => { {layout.map((columns, rowIndex) => ( {Array.from({ length: columns }).map((_, columnIndex) => ( - + ))} diff --git a/src/components/CippFormPages/CippInviteGuest.jsx b/src/components/CippFormPages/CippInviteGuest.jsx index 4c687c811083..7d62d071da0b 100644 --- a/src/components/CippFormPages/CippInviteGuest.jsx +++ b/src/components/CippFormPages/CippInviteGuest.jsx @@ -1,11 +1,12 @@ -import { Grid } from "@mui/material"; +import "@mui/material"; +import { Grid } from "@mui/system"; import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; const CippInviteUser = (props) => { const { formControl, userSettingsDefaults } = props; return ( - + { formControl={formControl} /> - + { formControl={formControl} /> - + { formControl={formControl} /> - + { if (Array.isArray(obj)) { @@ -37,7 +42,29 @@ const cleanObject = (obj) => { } }; -const renderListItems = (data, onItemClick) => { +// Function to check if a string is a GUID +const isGuid = (str) => { + if (typeof str !== "string") return false; + const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return guidRegex.test(str); +}; + +// Function to recursively scan an object for GUIDs +const findGuids = (obj, guidsSet = new Set()) => { + if (!obj) return guidsSet; + + if (typeof obj === "string" && isGuid(obj)) { + guidsSet.add(obj); + } else if (Array.isArray(obj)) { + obj.forEach((item) => findGuids(item, guidsSet)); + } else if (typeof obj === "object") { + Object.values(obj).forEach((value) => findGuids(value, guidsSet)); + } + + return guidsSet; +}; + +const renderListItems = (data, onItemClick, guidMapping = {}, isLoadingGuids = false) => { return Object.entries(data).map(([key, value]) => { if (Array.isArray(value)) { return ( @@ -64,6 +91,31 @@ const renderListItems = (data, onItemClick) => { } /> ); + } else if (typeof value === "string" && isGuid(value) && guidMapping[value]) { + return ( + +
    {guidMapping[value]}
    + + } + /> + ); + } else if (typeof value === "string" && isGuid(value) && isLoadingGuids) { + return ( + + + {getCippFormatting(value, key)} +
    + } + /> + ); } else { return ( { + if (data && Array.isArray(data.value)) { + const newMapping = {}; + + // Process the returned results + data.value.forEach((item) => { + if (item.id && (item.displayName || item.userPrincipalName || item.mail)) { + // Prefer displayName, fallback to UPN or mail if available + newMapping[item.id] = item.displayName || item.userPrincipalName || item.mail; + } + }); + + // Find GUIDs that were sent but not returned in the response + const processedGuids = new Set(pendingGuids); + const returnedGuids = new Set(data.value.map((item) => item.id)); + const notReturned = [...processedGuids].filter((guid) => !returnedGuids.has(guid)); + + // Add them to the notFoundGuids set + if (notReturned.length > 0) { + setNotFoundGuids((prev) => { + const newSet = new Set(prev); + notReturned.forEach((guid) => newSet.add(guid)); + return newSet; + }); + } + + setGuidMapping((prevMapping) => ({ ...prevMapping, ...newMapping })); + setPendingGuids([]); + setIsLoadingGuids(false); + } + }, + }); + + // Function to handle resolving GUIDs - used in both useEffect and handleItemClick + const resolveGuids = (objectToScan) => { + const guidsSet = findGuids(objectToScan); + + if (guidsSet.size === 0) return; + + const guidsArray = Array.from(guidsSet); + // Filter out GUIDs that are already resolved or known to not be resolvable + const notResolvedGuids = guidsArray.filter( + (guid) => !guidMapping[guid] && !notFoundGuids.has(guid) + ); + + if (notResolvedGuids.length === 0) return; + + // Merge with any pending GUIDs to avoid duplicate requests + const allPendingGuids = [...new Set([...pendingGuids, ...notResolvedGuids])]; + setPendingGuids(allPendingGuids); + setIsLoadingGuids(true); + + // Implement throttling - only send a new request every 2 seconds + const now = Date.now(); + if (now - lastRequestTime < 2000) { + return; + } + + setLastRequestTime(now); + + // Only send a maximum of 1000 GUIDs per request + const batchSize = 1000; + const guidsToSend = allPendingGuids.slice(0, batchSize); + + if (guidsToSend.length > 0) { + directoryObjectsMutation.mutate({ + url: "/api/ListDirectoryObjects", + data: { + tenantFilter: tenantFilter, + ids: guidsToSend, + $select: "id,displayName,userPrincipalName,mail", + }, + }); + } else { + setIsLoadingGuids(false); + } + }; const renderIntuneItems = (data) => { const items = []; @@ -96,11 +235,31 @@ function CippJsonView({ if (data.omaSettings) { data.omaSettings.forEach((omaSetting, index) => { + // Check if value is a GUID that we've resolved + const value = + typeof omaSetting.value === "string" && + isGuid(omaSetting.value) && + guidMapping[omaSetting.value] ? ( + +
    + {guidMapping[omaSetting.value]} + + (GUID) + +
    +
    + ) : ( + omaSetting.value + ); + items.push( ); }); @@ -127,9 +286,11 @@ function CippJsonView({ let value; if (child.choiceSettingValue && child.choiceSettingValue.value) { value = - childIntuneObj?.options?.find( - (option) => option.id === child.choiceSettingValue.value - )?.displayName || child.choiceSettingValue.value; + (Array.isArray(childIntuneObj?.options) && + childIntuneObj.options.find( + (option) => option.id === child.choiceSettingValue.value + )?.displayName) || + child.choiceSettingValue.value; } items.push( ); + // Check if value is a GUID that we've resolved + const displayValue = + typeof value === "string" && isGuid(value) && guidMapping[value] ? ( + +
    + {guidMapping[value]} + + (GUID) + +
    +
    + ) : ( + value + ); + + items.push( + + ); } else if (settingInstance?.choiceSettingValue?.value) { const label = intuneObj?.displayName || settingInstance.settingDefinitionId; - const optionValue = - intuneObj?.options?.find( - (option) => option.id === settingInstance.choiceSettingValue.value - )?.displayName || settingInstance.choiceSettingValue.value; + const rawValue = settingInstance.choiceSettingValue.value; + let optionValue = + (Array.isArray(intuneObj?.options) && + intuneObj.options.find((option) => option.id === rawValue)?.displayName) || + rawValue; + + // Check if optionValue is a GUID that we've resolved + if (typeof optionValue === "string" && isGuid(optionValue) && guidMapping[optionValue]) { + optionValue = ( + +
    + {guidMapping[optionValue]} + + (GUID) + +
    +
    + ); + } + items.push( ); @@ -175,13 +375,49 @@ function CippJsonView({ ); } else { Object.entries(data).forEach(([key, value]) => { - items.push( - - ); + // Check if value is a GUID that we've resolved + if (typeof value === "string" && isGuid(value) && guidMapping[value]) { + items.push( + +
    + {guidMapping[value]} + + (GUID) + +
    + + } + /> + ); + } else if (typeof value === "string" && isGuid(value) && isLoadingGuids) { + items.push( + + + {getCippFormatting(value, key)} + + } + /> + ); + } else { + items.push( + + ); + } }); } @@ -201,12 +437,47 @@ function CippJsonView({ "createdDateTime", "modifiedDateTime", ]; - const cleanedObj = cleanObject(object); + const cleanedObj = cleanObject(object) || {}; const filteredObj = Object.fromEntries( Object.entries(cleanedObj).filter(([key]) => !blacklist.includes(key)) ); setDrilldownData([filteredObj]); - }, [object]); + + // Using the centralized resolveGuids function to handle GUID resolution + resolveGuids(cleanedObj); + }, [object, tenantFilter]); + + // Effect to reprocess any pending GUIDs when the guidMapping changes or throttling window passes + useEffect(() => { + if (pendingGuids.length > 0 && !isLoadingGuids) { + const now = Date.now(); + if (now - lastRequestTime >= 2000) { + // Only send a maximum of 1000 GUIDs per request + const batchSize = 1000; + const guidsToSend = pendingGuids.slice(0, batchSize); + + setLastRequestTime(now); + setIsLoadingGuids(true); + + directoryObjectsMutation.mutate({ + url: "/api/ListDirectoryObjects", + data: { + tenantFilter: tenantFilter, + ids: guidsToSend, + $select: "id,displayName,userPrincipalName,mail", + }, + }); + } + } + }, [ + guidMapping, + notFoundGuids, + pendingGuids, + lastRequestTime, + isLoadingGuids, + directoryObjectsMutation, + tenantFilter, + ]); const toggleView = () => setViewJson(!viewJson); @@ -214,6 +485,9 @@ function CippJsonView({ const updatedData = drilldownData.slice(0, level + 1); updatedData[level + 1] = itemData; setDrilldownData(updatedData); + + // Use the centralized resolveGuids function to handle GUID resolution for drill-down data + resolveGuids(itemData); }; return ( @@ -226,9 +500,16 @@ function CippJsonView({ expandIcon={} sx={{ display: "flex", alignItems: "center" }} > - - Policy Details - + + + Policy Details + + {isLoadingGuids && ( + + Resolving object identifiers... + + )} + @@ -238,30 +519,35 @@ function CippJsonView({ ) : ( - {drilldownData?.map((data, index) => ( - 4, and add spacing between the top and bottom items - paddingTop: index === 0 ? 0 : 2, - borderTop: index >= 4 && type !== "intune" ? "1px solid lightgrey" : "none", - borderRight: index < drilldownData.length - 1 ? "1px solid lightgrey" : "none", - overflowWrap: "anywhere", - whiteSpace: "pre-line", - paddingRight: 2, - }} - > - {type !== "intune" && ( - - {renderListItems(data, (itemData) => handleItemClick(itemData, index))} - - )} - {type === "intune" && {renderIntuneItems(data)}} - - ))} + {drilldownData + ?.filter((data) => data !== null && data !== undefined) + .map((data, index) => ( + 4, and add spacing between the top and bottom items + paddingTop: index === 0 ? 0 : 2, + borderTop: index >= 4 && type !== "intune" ? "1px solid lightgrey" : "none", + borderRight: index < drilldownData.length - 1 ? "1px solid lightgrey" : "none", + overflowWrap: "anywhere", + whiteSpace: "pre-line", + paddingRight: 2, + }} + > + {type !== "intune" && ( + + {renderListItems( + data, + (itemData) => handleItemClick(itemData, index), + guidMapping, + isLoadingGuids + )} + + )} + {type === "intune" && {renderIntuneItems(data)}} + + ))} )} diff --git a/src/components/CippFormPages/CippSafeLinksPolicyRuleForm.jsx b/src/components/CippFormPages/CippSafeLinksPolicyRuleForm.jsx new file mode 100644 index 000000000000..ce25cc269028 --- /dev/null +++ b/src/components/CippFormPages/CippSafeLinksPolicyRuleForm.jsx @@ -0,0 +1,652 @@ +import { useEffect, useState } from "react"; +import { Grid } from "@mui/system"; +import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; +import { Typography } from "@mui/material"; +import { CippFormUserSelector } from "/src/components/CippComponents/CippFormUserSelector"; +import { CippFormGroupSelector } from "/src/components/CippComponents/CippFormGroupSelector"; +import { CippFormDomainSelector } from "/src/components/CippComponents/CippFormDomainSelector"; +import { CippInfoCard } from "/src/components/CippCards/CippInfoCard"; +import { InformationCircleIcon } from "@heroicons/react/24/outline"; +import { getCippValidator } from "/src/utils/get-cipp-validator"; +import { ApiGetCall } from "/src/api/ApiCall"; +import { useSettings } from "/src/hooks/use-settings"; + +// Utility functions for data processing +export const safeLinksDataUtils = { + // Process arrays for string inputs + formatStringToArray: (value) => { + if (!value || value === '') return []; + if (typeof value === 'string') { + return value.split(',').map(item => item.trim()).filter(item => item !== ''); + } + return value; + }, + + // Process domain fields - handle both string and object values + processDomainField: (field) => { + if (!field) return []; + + if (typeof field === 'string') { + // Handle comma-separated string + return safeLinksDataUtils.formatStringToArray(field); + } else if (Array.isArray(field)) { + // If already an array of strings, return it + if (field.length > 0 && typeof field[0] === 'string') { + return field; + } + // If an array of objects from the domain selector, extract the ids + return field.map(item => item.id || item); + } + return []; + }, + + // Process group fields if they're returned as objects + processGroupField: (field) => { + if (Array.isArray(field)) { + // If the field is already an array of IDs, return it + if (field.length > 0 && typeof field[0] === 'string') { + return field; + } + // If the field is an array of objects, extract the IDs + return field.map(item => item.id || item); + } + return []; + }, + + // Create custom data formatter for different form types + createDataFormatter: (formControl, formType = 'add', additionalFields = {}) => { + return (values) => { + const ruleValues = formControl.getValues(); + + // Base data structure + const baseData = { + // Common fields + PolicyName: values.PolicyName, + tenantFilter: values.tenantFilter, + + // Policy fields + EnableSafeLinksForEmail: values.EnableSafeLinksForEmail, + EnableSafeLinksForTeams: values.EnableSafeLinksForTeams, + EnableSafeLinksForOffice: values.EnableSafeLinksForOffice, + TrackClicks: values.TrackClicks, + AllowClickThrough: values.AllowClickThrough, + ScanUrls: values.ScanUrls, + EnableForInternalSenders: values.EnableForInternalSenders, + DeliverMessageAfterScan: values.DeliverMessageAfterScan, + DisableUrlRewrite: values.DisableUrlRewrite, + DoNotRewriteUrls: Array.isArray(values.DoNotRewriteUrls) ? values.DoNotRewriteUrls : [], + AdminDisplayName: values.AdminDisplayName, + CustomNotificationText: values.CustomNotificationText, + EnableOrganizationBranding: values.EnableOrganizationBranding, + + // Rule fields + RuleName: ruleValues.RuleName, + Priority: ruleValues.Priority, + Comments: ruleValues.Comments, + + // Process user, group and domain fields + SentTo: ruleValues.SentTo, + ExceptIfSentTo: ruleValues.ExceptIfSentTo, + SentToMemberOf: safeLinksDataUtils.processGroupField(ruleValues.SentToMemberOf), + ExceptIfSentToMemberOf: safeLinksDataUtils.processGroupField(ruleValues.ExceptIfSentToMemberOf), + RecipientDomainIs: safeLinksDataUtils.processDomainField(ruleValues.RecipientDomainIs), + ExceptIfRecipientDomainIs: safeLinksDataUtils.processDomainField(ruleValues.ExceptIfRecipientDomainIs), + }; + + // Add form-specific fields + switch (formType) { + case 'add': + return { + ...baseData, + State: ruleValues.State, + }; + + case 'edit': + return { + ...baseData, + State: ruleValues.State, + }; + + case 'template': + return { + ...baseData, + ID: additionalFields.ID, + TemplateName: values.TemplateName, + TemplateDescription: values.TemplateDescription, + State: ruleValues.State ? "Enabled" : "Disabled", + }; + + case 'createTemplate': + return { + ...baseData, + TemplateName: values.TemplateName, + TemplateDescription: values.TemplateDescription, + // If no policy description provided, use template description as fallback + AdminDisplayName: values.AdminDisplayName || values.Description, + State: ruleValues.State, + }; + + default: + return baseData; + } + }; + }, + + // Helper to populate form with existing data + populateFormData: (formControl, data, userSettingsDefaults, formType = 'edit' ) => { + const baseData = { + tenantFilter: userSettingsDefaults.currentTenant, + PolicyName: data.PolicyName, + EnableSafeLinksForEmail: data.EnableSafeLinksForEmail, + EnableSafeLinksForTeams: data.EnableSafeLinksForTeams, + EnableSafeLinksForOffice: data.EnableSafeLinksForOffice, + TrackClicks: data.TrackClicks, + AllowClickThrough: data.AllowClickThrough, + ScanUrls: data.ScanUrls, + EnableForInternalSenders: data.EnableForInternalSenders, + DeliverMessageAfterScan: data.DeliverMessageAfterScan, + DisableUrlRewrite: data.DisableUrlRewrite, + DoNotRewriteUrls: data.DoNotRewriteUrls, + AdminDisplayName: data.AdminDisplayName, + CustomNotificationText: data.CustomNotificationText, + EnableOrganizationBranding: data.EnableOrganizationBranding, + RuleName: data.RuleName, + Priority: data.Priority, + Comments: data.Comments, + State: data.State, + SentTo: data.SentTo || [], + ExceptIfSentTo: data.ExceptIfSentTo || [], + SentToMemberOf: data.SentToMemberOf || [], + ExceptIfSentToMemberOf: data.ExceptIfSentToMemberOf || [], + RecipientDomainIs: data.RecipientDomainIs || [], + ExceptIfRecipientDomainIs: data.ExceptIfRecipientDomainIs || [], + }; + + // Add template-specific fields + if (formType === 'template') { + baseData.TemplateName = data.TemplateName; + baseData.TemplateDescription = data.TemplateDescription; + } + + formControl.reset(baseData); + }, +}; + +export const SafeLinksForm = ({ formControl, formType = "add" }) => { + const { watch, setError, clearErrors } = formControl; + const doNotRewriteUrls = watch("DoNotRewriteUrls"); + const policyName = watch("PolicyName"); + const [isUrlsValid, setIsUrlsValid] = useState(true); + const userSettingsDefaults = useSettings(); + + // Fetch existing policies for name validation (only for add/createTemplate forms) + const shouldFetchPolicies = formType === "add" || formType === "createTemplate"; + const existingPolicies = ApiGetCall({ + url: `/api/ListSafeLinksPolicy?tenantFilter=${userSettingsDefaults.currentTenant}`, + queryKey: `SafeLinksPolicy-List-${userSettingsDefaults.currentTenant}`, + enabled: shouldFetchPolicies, + }); + + // Fetch existing templates for name validation (only for createTemplate forms) + const shouldFetchTemplates = formType === "createTemplate"; + const existingTemplates = ApiGetCall({ + url: `/api/ListSafeLinksPolicyTemplates`, + queryKey: `SafeLinksTemplates-List`, + enabled: shouldFetchTemplates, + }); + + // Create validator for checking duplicate policy names + const validatePolicyName = (value) => { + if (!shouldFetchPolicies || !value) return true; + + // If still loading, allow validation to pass (it will re-validate when data loads) + if (existingPolicies.isFetching) return true; + + // If API call failed, allow validation to pass (don't block user due to API issues) + if (existingPolicies.error) return true; + + if (existingPolicies.isSuccess && existingPolicies.data) { + const existingNames = existingPolicies.data.map(policy => policy.PolicyName?.toLowerCase()).filter(Boolean); + if (existingNames.includes(value.toLowerCase())) { + return "A policy with this name already exists"; + } + + const lowerValue = value.toLowerCase(); + if (lowerValue.startsWith("built-in protection policy") || + lowerValue.startsWith("standard preset security policy") || + lowerValue.startsWith("strict preset security policy")) { + return "This name is reserved for built-in policies"; + } + } + return true; + }; + + // Create validator for checking duplicate template names + const validateTemplateName = (value) => { + if (!shouldFetchTemplates || !value) return true; + + // If still loading, allow validation to pass (it will re-validate when data loads) + if (existingTemplates.isFetching) return true; + + // If API call failed, allow validation to pass (don't block user due to API issues) + if (existingTemplates.error) return true; + + if (existingTemplates.isSuccess && existingTemplates.data) { + const existingNames = existingTemplates.data.map(template => template.name?.toLowerCase()).filter(Boolean); + if (existingNames.includes(value.toLowerCase())) { + return "A template with this name already exists"; + } + } + return true; + }; + + // Helper function to validate a URL/domain entry + const validateDoNotRewriteUrl = (entry) => { + if (!entry) return true; + + // For entries with wildcards, use wildcard validators + if (entry.includes('*') || entry.includes('~')) { + const wildcardUrlResult = getCippValidator(entry, "wildcardUrl"); + const wildcardDomainResult = getCippValidator(entry, "wildcardDomain"); + + if (wildcardUrlResult !== true && wildcardDomainResult !== true) { + return false; + } + return true; + } + + // For standard entries, check normal validators + const hostnameResult = getCippValidator(entry, "hostname"); + const urlResult = getCippValidator(entry, "url"); + const domainResult = getCippValidator(entry, "domain"); + + if (hostnameResult !== true && urlResult !== true && domainResult !== true) { + return false; + } + + return true; + }; + + // Re-validate policy name when existing policies data changes + useEffect(() => { + if (shouldFetchPolicies && (existingPolicies.isSuccess || existingPolicies.error)) { + formControl.trigger('PolicyName'); + } + }, [existingPolicies.isSuccess, existingPolicies.error, existingPolicies.data, shouldFetchPolicies, formControl]); + + // Re-validate template name when existing templates data changes + useEffect(() => { + if (shouldFetchTemplates && (existingTemplates.isSuccess || existingTemplates.error)) { + formControl.trigger('TemplateName'); + } + }, [existingTemplates.isSuccess, existingTemplates.error, existingTemplates.data, shouldFetchTemplates, formControl]); + + // Validate URLs in useEffect and update the validation Enabled + useEffect(() => { + if (!doNotRewriteUrls || doNotRewriteUrls.length === 0) { + clearErrors("DoNotRewriteUrls"); + setIsUrlsValid(true); + return; + } + + let hasInvalidEntry = false; + + for (const item of doNotRewriteUrls) { + const entry = typeof item === 'string' ? item : (item?.value || item?.label || ''); + if (!entry) continue; + + const isValid = validateDoNotRewriteUrl(entry); + if (!isValid) { + hasInvalidEntry = true; + break; + } + } + + if (hasInvalidEntry) { + setError("DoNotRewriteUrls", { + type: "validate", + message: "Not a valid URL, domain, or pattern" + }); + setIsUrlsValid(false); + } else { + clearErrors("DoNotRewriteUrls"); + setIsUrlsValid(true); + } + }, [doNotRewriteUrls, setError, clearErrors]); + + // Set the rule-related values whenever the policy name changes + useEffect(() => { + if (policyName) { + // Always set SafeLinksPolicy to match the policy name + formControl.setValue('SafeLinksPolicy', policyName); + + // Only auto-generate the rule name for new policies + if (formType === "add" || formType === "createTemplate") { + const ruleName = `${policyName}_Rule`; + formControl.setValue('RuleName', ruleName); + } + } + }, [policyName, formType, formControl]); + + // Show template-specific fields + const showTemplateFields = formType === "template" || formType === "createTemplate"; + + return ( + + {/* Template Fields (if applicable) */} + {showTemplateFields && ( + <> + + Template Information + + + + + + + + + )} + + {/* Policy Settings Section */} + + Safe Links Policy Configuration + + + Policy Settings + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + isUrlsValid || "Not a valid URL, domain, or pattern" + } + }} + /> + + + {/* Rule Settings Section */} + + Safe Links Rule Configuration + + + Rule Information + + + + + + + + + + + + + + + Applies To: + + + + + + + + + + + + Exceptions: + + + + + + + + + + + + {/* Information Cards */} + + } + label="Propagation Time" + value="Changes to Safe Links policies and rules may take up to 6 hours to propagate throughout your organization." + isFetching={false} + /> + + + ); +}; + +export default SafeLinksForm; \ No newline at end of file diff --git a/src/components/CippFormPages/CippSchedulerForm.jsx b/src/components/CippFormPages/CippSchedulerForm.jsx index d49b2f069254..50be6839a2e9 100644 --- a/src/components/CippFormPages/CippSchedulerForm.jsx +++ b/src/components/CippFormPages/CippSchedulerForm.jsx @@ -1,20 +1,39 @@ -import React from "react"; -import { Box, Button, Divider, Grid, Skeleton, SvgIcon, Typography } from "@mui/material"; +import { + Box, + Button, + Divider, + Skeleton, + SvgIcon, + Typography, + ButtonGroup, + Accordion, + AccordionSummary, + AccordionDetails, + IconButton, + Alert, +} from "@mui/material"; +import { Grid } from "@mui/system"; import { useWatch } from "react-hook-form"; import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; import { CippFormTenantSelector } from "/src/components/CippComponents/CippFormTenantSelector"; import { CippFormCondition } from "/src/components/CippComponents/CippFormCondition"; +import CippGraphResourceSelector from "/src/components/CippComponents/CippGraphResourceSelector"; +import CippGraphAttributeSelector from "/src/components/CippComponents/CippGraphAttributeSelector"; import { getCippValidator } from "/src/utils/get-cipp-validator"; import { useRouter } from "next/router"; +import Link from "next/link"; import { ApiGetCall, ApiPostCall } from "/src/api/ApiCall"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import CippFormInputArray from "../CippComponents/CippFormInputArray"; import { CippApiResults } from "../CippComponents/CippApiResults"; import { CalendarDaysIcon } from "@heroicons/react/24/outline"; +import { ExpandMoreOutlined, Delete, Add } from "@mui/icons-material"; const CippSchedulerForm = (props) => { - const { formControl, fullWidth = false } = props; // Added fullWidth prop + const { formControl, fullWidth = false, taskId = null, cloneMode = false } = props; const selectedCommand = useWatch({ control: formControl.control, name: "command" }); + const [addedConditions, setAddedConditions] = useState([{ id: 0 }]); + const [isResourcePickerDisabled, setIsResourcePickerDisabled] = useState(false); const fieldRequired = (field) => { if (field?.Required) { @@ -26,6 +45,17 @@ const CippSchedulerForm = (props) => { } }; + const handleAddCondition = () => { + setAddedConditions([...addedConditions, { id: addedConditions.length }]); + }; + + const handleRemoveCondition = (id) => { + const currentConditions = formControl.getValues("Trigger.DeltaConditions") || []; + const updatedConditions = currentConditions.filter((_, index) => index !== id); + formControl.setValue("Trigger.DeltaConditions", updatedConditions); + setAddedConditions(addedConditions.filter((condition, index) => index !== id)); + }; + const postCall = ApiPostCall({ datafromUrl: true, relatedQueryKeys: [ @@ -57,39 +87,267 @@ const CippSchedulerForm = (props) => { { value: "30d", label: "Every 30 days" }, { value: "365d", label: "Every 365 days" }, ]; + + const triggerRecurrenceOptions = [ + { value: "15m", label: "Every 15 minutes" }, + { value: "30m", label: "Every 30 minutes" }, + { value: "1h", label: "Every 1 hour" }, + { value: "4h", label: "Every 4 hours" }, + { value: "12h", label: "Every 12 hours" }, + { value: "1d", label: "Every 1 day" }, + ]; + + const taskTypeOptions = [ + { value: "scheduled", label: "Scheduled Task" }, + { value: "triggered", label: "Triggered Task" }, + ]; + + const triggerTypeOptions = [{ value: "DeltaQuery", label: "Delta Query" }]; + + const deltaResourceOptions = [ + { value: "users", label: "Users" }, + { value: "groups", label: "Groups" }, + { value: "contacts", label: "Contacts" }, + { value: "orgContact", label: "Organizational Contacts" }, + { value: "devices", label: "Devices" }, + { value: "applications", label: "Applications" }, + { value: "servicePrincipals", label: "Service Principals" }, + { value: "directoryObjects", label: "Directory Objects" }, + { value: "directoryRole", label: "Directory Roles" }, + { value: "administrativeUnits", label: "Administrative Units" }, + { value: "oAuth2PermissionGrant", label: "OAuth2 Permission Grants" }, + ]; + + const simpleEventOptions = [ + { value: "created", label: "Resource Created" }, + { value: "updated", label: "Resource Updated" }, + { value: "deleted", label: "Resource Deleted" }, + ]; + + const operatorOptions = [ + { value: "eq", label: "Equals to" }, + { value: "ne", label: "Not Equals to" }, + { value: "like", label: "Like" }, + { value: "notlike", label: "Not like" }, + { value: "notmatch", label: "Does not match" }, + { value: "gt", label: "Greater than" }, + { value: "lt", label: "Less than" }, + { value: "in", label: "In" }, + { value: "notIn", label: "Not In" }, + ]; + + // Watch for trigger-related fields + const selectedTaskType = useWatch({ control: formControl.control, name: "taskType" }); + const selectedTriggerType = useWatch({ control: formControl.control, name: "Trigger.Type" }); + const selectedDeltaResource = useWatch({ + control: formControl.control, + name: "Trigger.DeltaResource", + }); + const selectedTenant = useWatch({ control: formControl.control, name: "tenantFilter" }); + + // Watch for summary display + const selectedSimpleEvent = useWatch({ control: formControl.control, name: "Trigger.EventType" }); + const selectedRecurrence = useWatch({ control: formControl.control, name: "Recurrence" }); + const selectedScheduledTime = useWatch({ control: formControl.control, name: "ScheduledTime" }); + const selectedExecutePerResource = useWatch({ + control: formControl.control, + name: "Trigger.ExecutePerResource", + }); + const selectedDeltaExecutionMode = useWatch({ + control: formControl.control, + name: "Trigger.ExecutionMode", + }); + const selectedUseConditions = useWatch({ + control: formControl.control, + name: "Trigger.UseConditions", + }); + const selectedDeltaConditions = useWatch({ + control: formControl.control, + name: "Trigger.DeltaConditions", + }); const commands = ApiGetCall({ url: "/api/ListFunctionParameters?Module=CIPPCore", queryKey: "ListCommands", }); const router = useRouter(); + const scheduledTaskList = ApiGetCall({ url: "/api/ListScheduledItems", - queryKey: "ListScheduledItems-Edit", + queryKey: "ListScheduledItems-Edit-" + (taskId || router.query.id), + waiting: !!(taskId || router.query.id), + data: { + Id: taskId || router.query.id, + }, }); const tenantList = ApiGetCall({ url: "/api/ListTenants?AllTenantSelector=true", queryKey: "ListTenants-AllTenants", }); + + // Check if resource picker should be disabled useEffect(() => { - if (scheduledTaskList.isSuccess && router.query.id) { - const task = scheduledTaskList.data.find((task) => task.RowKey === router.query.id); - const postExecution = task?.postExecution?.split(",").map((item) => { - return { label: item, value: item }; - }); + console.log(selectedTenant); + if (!selectedTenant) { + setIsResourcePickerDisabled(false); + return; + } + + // Disable if AllTenants is selected + if (selectedTenant.value === "AllTenants") { + setIsResourcePickerDisabled(true); + return; + } + + // Disable if a tenant group is selected (groups have type: "Group") + if (selectedTenant.type === "Group") { + setIsResourcePickerDisabled(true); + return; + } + + setIsResourcePickerDisabled(false); + }, [selectedTenant]); + + // Helper functions for accordion summaries + const getTriggerSummary = () => { + if (!selectedTriggerType || selectedTaskType?.value !== "triggered") return ""; + + let summary = selectedTriggerType.label; + + if (selectedTriggerType.value === "DeltaQuery") { + if (selectedDeltaResource?.label) { + summary += ` - ${selectedDeltaResource.label}`; + } + if (selectedSimpleEvent?.label) { + summary += ` (${selectedSimpleEvent.label})`; + } + if (selectedUseConditions && selectedDeltaConditions?.length > 0) { + summary += ` with ${selectedDeltaConditions.length} condition${ + selectedDeltaConditions.length > 1 ? "s" : "" + }`; + } + } + + return summary; + }; + + const getScheduleSummary = () => { + if (selectedTaskType?.value !== "scheduled") return ""; + + let summary = ""; + if (selectedScheduledTime) { + // Handle both Unix timestamp and regular date formats + let date; + if ( + typeof selectedScheduledTime === "number" || + (typeof selectedScheduledTime === "string" && /^\d+$/.test(selectedScheduledTime)) + ) { + // Unix timestamp (seconds or milliseconds) + const timestamp = parseInt(selectedScheduledTime); + date = new Date(timestamp > 1000000000000 ? timestamp : timestamp * 1000); + } else { + date = new Date(selectedScheduledTime); + } + // Include both date and time + summary += `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`; + } + if (selectedRecurrence) { + summary += summary ? ` - ${selectedRecurrence.label}` : selectedRecurrence.label; + } + + return summary; + }; + + const getCommandSummary = () => { + if (!selectedCommand) return ""; + + let summary = selectedCommand.label; + + if (selectedTaskType?.value === "triggered" && selectedTriggerType?.value === "DeltaQuery") { + if (selectedExecutePerResource) { + summary += " (per resource)"; + } + if (selectedDeltaExecutionMode) { + summary += ` - ${selectedDeltaExecutionMode.label}`; + } + } + + return summary; + }; + useEffect(() => { + if (scheduledTaskList.isSuccess && (taskId || router.query.id)) { + const task = scheduledTaskList.data.find( + (task) => task.RowKey === (taskId || router.query.id) + ); + + // Early return if task is not found + if (!task) { + console.warn(`Task with RowKey ${taskId || router.query.id} not found`); + return; + } + + const postExecution = task?.PostExecution + ? task.PostExecution.split(",").map((item) => { + return { label: item.trim(), value: item.trim() }; + }) + : []; // Find tenantFilter in tenantList, and create a label/value pair for the autocomplete if (tenantList.isSuccess) { - const tenantFilter = tenantList.data.find( - (tenant) => tenant.defaultDomainName === task?.Tenant - ); + let tenantFilter = null; + let tenantFilterForForm = null; + + // Check if the task has a tenant group + if (task?.TenantGroupInfo) { + // Handle tenant group + tenantFilterForForm = { + value: task.TenantGroupInfo.value, + label: task.TenantGroupInfo.label, + type: "Group", + addedFields: task.TenantGroupInfo, + }; + } else { + // Handle regular tenant + tenantFilter = tenantList.data.find( + (tenant) => + tenant.defaultDomainName === task?.Tenant.value || + tenant.defaultDomainName === task?.Tenant + ); + if (tenantFilter) { + tenantFilterForForm = { + value: tenantFilter.defaultDomainName, + label: `${tenantFilter.displayName} (${tenantFilter.defaultDomainName})`, + type: "Tenant", + addedFields: tenantFilter, + }; + } + } if (commands.isSuccess) { const command = commands.data.find((command) => command.Function === task.Command); + + // If command is not found in the list, create a placeholder command entry + let commandForForm = command; + if (!command && task.Command) { + commandForForm = { + Function: task.Command, + Parameters: [], + // Add minimal required structure for system jobs + }; + } + var recurrence = recurrenceOptions.find( (option) => option.value === task.Recurrence || option.label === task.Recurrence ); + // If recurrence is not found in predefined options, create a custom option + if (!recurrence && task.Recurrence) { + recurrence = { + value: task.Recurrence, + label: `${task.Recurrence}`, + }; + } + // if scheduledtime type is a date, convert to unixtime if (typeof task.ScheduledTime === "date") { task.ScheduledTime = Math.floor(task.ScheduledTime.getTime() / 1000); @@ -97,29 +355,96 @@ const CippSchedulerForm = (props) => { task.ScheduledTime = Math.floor(new Date(task.ScheduledTime).getTime() / 1000); } + // Check if any parameter values are complex objects that can't be represented as simple form fields + const hasComplexObjects = + task.Parameters && typeof task.Parameters === "object" + ? Object.values(task.Parameters).some((value) => { + // Check for arrays + if (Array.isArray(value)) return true; + // Check for objects (but not null) + if (value !== null && typeof value === "object") return true; + // Check for stringified objects that contain [object Object] + if (typeof value === "string" && value.includes("[object Object]")) return true; + // Check for stringified JSON arrays/objects + if ( + typeof value === "string" && + (value.trim().startsWith("[") || value.trim().startsWith("{")) + ) { + try { + const parsed = JSON.parse(value); + return typeof parsed === "object"; + } catch { + return false; + } + } + return false; + }) + : false; + const ResetParams = { - tenantFilter: { - value: tenantFilter?.defaultDomainName, - label: `${tenantFilter?.displayName} (${tenantFilter?.defaultDomainName})`, - }, - RowKey: router.query.Clone ? null : task.RowKey, - Name: router.query.Clone ? `${task.Name} (Clone)` : task?.Name, - command: { label: task.Command, value: task.Command, addedFields: command }, + tenantFilter: tenantFilterForForm, + RowKey: router.query.Clone || cloneMode ? null : task.RowKey, + Name: router.query.Clone || cloneMode ? `${task.Name} (Clone)` : task?.Name, + command: { label: task.Command, value: task.Command, addedFields: commandForForm }, ScheduledTime: task.ScheduledTime, Recurrence: recurrence, parameters: task.Parameters, postExecution: postExecution, - advancedParameters: task.RawJsonParameters ? true : false, + // Set task type based on whether trigger exists + taskType: task.Trigger + ? { value: "triggered", label: "Triggered Task" } + : { value: "scheduled", label: "Scheduled Task" }, + // Trigger configuration - use the trigger data directly since it's already in the correct format + ...(task.Trigger && { + "Trigger.Type": task.Trigger.Type, + "Trigger.DeltaResource": task.Trigger.DeltaResource, + "Trigger.EventType": task.Trigger.EventType, + "Trigger.ResourceFilter": task.Trigger.ResourceFilter || [], + "Trigger.WatchedAttributes": task.Trigger.WatchedAttributes || [], + "Trigger.UseConditions": task.Trigger.UseConditions || false, + "Trigger.DeltaConditions": task.Trigger.DeltaConditions || [], + "Trigger.ExecutePerResource": task.Trigger.ExecutePerResource || false, + "Trigger.ExecutionMode": task.Trigger.ExecutionMode, + }), + // Show advanced parameters if: + // 1. RawJsonParameters exist + // 2. It's a system command with no defined parameters + // 3. Any parameter contains complex objects (arrays, objects, etc.) + advancedParameters: task.RawJsonParameters + ? true + : hasComplexObjects || + !commandForForm?.Parameters || + commandForForm.Parameters.length === 0, + // Set the RawJsonParameters if they exist + RawJsonParameters: task.RawJsonParameters || "", }; formControl.reset(ResetParams); + + // Set up condition builder if task has delta conditions + if ( + task.Trigger?.DeltaConditions && + Array.isArray(task.Trigger.DeltaConditions) && + task.Trigger.DeltaConditions.length > 0 + ) { + const conditionsWithIds = task.Trigger.DeltaConditions.map((condition, index) => ({ + id: index, + ...condition, + })); + setAddedConditions(conditionsWithIds); + } else { + // Reset to default single condition if no conditions exist + setAddedConditions([{ id: 0 }]); + } } } } }, [ + taskId, router.query.id, scheduledTaskList.isSuccess, tenantList.isSuccess, router.query.Clone, + cloneMode, commands.isSuccess, ]); @@ -127,16 +452,52 @@ const CippSchedulerForm = (props) => { useEffect(() => { if (advancedParameters === true) { - var schedulerValues = formControl.getValues("parameters"); - Object.keys(schedulerValues).forEach((key) => { - if (schedulerValues[key] === "" || schedulerValues[key] === null) { - delete schedulerValues[key]; + // Check if we're editing an existing task and it has RawJsonParameters + const currentRawJsonParameters = formControl.getValues("RawJsonParameters"); + + // If we already have raw JSON parameters (from editing existing task), use those + if ( + currentRawJsonParameters && + currentRawJsonParameters.trim() !== "" && + currentRawJsonParameters !== "{}" + ) { + // Already populated from existing task, no need to overwrite + return; + } + + // Get the original task parameters if we're editing (to preserve complex objects) + let parametersToUse = null; + if ((taskId || router.query.id) && scheduledTaskList.isSuccess) { + const task = scheduledTaskList.data.find( + (task) => task.RowKey === (taskId || router.query.id) + ); + if (task?.Parameters) { + parametersToUse = task.Parameters; } - }); - const jsonString = JSON.stringify(schedulerValues, null, 2); - formControl.setValue("RawJsonParameters", jsonString); + } + + // If we don't have original task parameters, use current form parameters + if (!parametersToUse) { + parametersToUse = formControl.getValues("parameters"); + } + + // Add null check to prevent error when no parameters exist + if (parametersToUse && typeof parametersToUse === "object") { + // Create a clean copy for JSON + const cleanParams = { ...parametersToUse }; + Object.keys(cleanParams).forEach((key) => { + if (cleanParams[key] === "" || cleanParams[key] === null) { + delete cleanParams[key]; + } + }); + const jsonString = JSON.stringify(cleanParams, null, 2); + formControl.setValue("RawJsonParameters", jsonString); + } else { + // If no parameters, set empty object + formControl.setValue("RawJsonParameters", "{}"); + } } - }, [advancedParameters]); + }, [advancedParameters, taskId, router.query.id, scheduledTaskList.isSuccess]); const gridSize = fullWidth ? 12 : 4; // Adjust size based on fullWidth prop @@ -146,187 +507,584 @@ const CippSchedulerForm = (props) => { {(scheduledTaskList.isFetching || tenantList.isLoading || commands.isLoading) && ( )} - + {/* Top section: Tenant and Task Name */} + - + - + { - return { - label: command.Function, - value: command.Function, - addedFields: command, - }; - }) || [] - } - validators={{ - validate: (value) => { - if (!value) { - return "Please select a Command"; - } - return true; - }, - }} + multiple + creatable={false} + options={[ + { label: "Webhook", value: "Webhook" }, + { label: "Email", value: "Email" }, + { label: "PSA", value: "PSA" }, + ]} /> - - + + {/* Divider */} + + - - + + {/* Task Type Selection */} + + + {taskTypeOptions.map((option) => ( + + ))} + - {selectedCommand?.addedFields?.Synopsis && ( - - - PowerShell Command: - - {selectedCommand.addedFields.Synopsis} - - + + {/* Trigger Configuration Accordion */} + + + + }> + + Trigger Configuration + {getTriggerSummary() && ( + + - {getTriggerSummary()} + + )} + + + + + + + + + {/* Delta Query Configuration */} + + + + + Delta queries track changes to Microsoft Graph resources. Learn more about{" "} + + delta query concepts and usage + {" "} + in the Microsoft documentation. + + + + + + + + + + + + + + + + + + + + + + + {/* Condition Builder for all event types */} + + + + + + + + + Delta Query Conditions + + + + Create PowerShell-style Where-Object conditions to filter delta query + results. Each condition compares a resource property against a specific + value. Multiple conditions work as AND logic - all must be true to trigger + the task. + + + + {addedConditions.map((condition, index) => ( + + + + + + + + + + + + handleRemoveCondition(index)} color="error"> + + + + + ))} + + + {/* Delta Query Execution Options */} + + + + + + + + + {/* Trigger Recurrence */} + + + + + + + - )} + - {selectedCommand?.addedFields?.Parameters?.map((param, idx) => ( - - - {param.Type === "System.Boolean" || - param.Type === "System.Management.Automation.SwitchParameter" ? ( - - ) : param.Type === "System.Collections.Hashtable" ? ( - - ) : param.Type?.startsWith("System.String") ? ( - - ) : ( - - )} - - - ))} - - - - - - + {/* Schedule Configuration - Only for scheduled tasks */} - - getCippValidator(value, "json"), - }} - formControl={formControl} - multiline - rows={4} - placeholder={`Enter a JSON object`} - /> + + + }> + + Schedule Configuration + {getScheduleSummary() && ( + + - {getScheduleSummary()} + + )} + + + + + + + + + { + let options = [...recurrenceOptions]; + + // If we're editing a task and the recurrence isn't in the base options, add it + if ((taskId || router.query.id) && scheduledTaskList.isSuccess) { + const task = scheduledTaskList.data.find( + (task) => task.RowKey === (taskId || router.query.id) + ); + if ( + task?.Recurrence && + !options.find((opt) => opt.value === task.Recurrence) + ) { + options.push({ + value: task.Recurrence, + label: `Custom: ${task.Recurrence}`, + }); + } + } + + return options; + })()} + multiple={false} + disableClearable={true} + creatable={true} + /> + + + + - - + + {/* Command & Parameters - For both scheduled and triggered tasks */} + + + }> + + Command & Parameters + {getCommandSummary() && ( + + - {getCommandSummary()} + + )} + + + + + {/* Command selection for both scheduled and triggered tasks */} + + { + const baseOptions = + commands.data?.map((command) => { + return { + label: command.Function, + value: command.Function, + addedFields: command, + }; + }) || []; + + // If we're editing a task and the command isn't in the base options, add it + if ((taskId || router.query.id) && scheduledTaskList.isSuccess) { + const task = scheduledTaskList.data.find( + (task) => task.RowKey === (taskId || router.query.id) + ); + if ( + task?.Command && + !baseOptions.find((opt) => opt.value === task.Command) + ) { + baseOptions.unshift({ + label: task.Command, + value: task.Command, + addedFields: { + Function: task.Command, + Parameters: [], + }, + }); + } + } + + return baseOptions; + })()} + validators={{ + validate: (value) => { + if (!value) { + return "Please select a Command"; + } + return true; + }, + }} + /> + + + {selectedCommand?.addedFields?.Synopsis && ( + + + PowerShell Command: + + {selectedCommand.addedFields.Synopsis} + + + + )} + + {selectedCommand?.addedFields?.Parameters?.map((param, idx) => ( + + + {param.Type === "System.Boolean" || + param.Type === "System.Management.Automation.SwitchParameter" ? ( + + ) : param.Type === "System.Collections.Hashtable" ? ( + + ) : param.Type?.startsWith("System.String") ? ( + + ) : ( + + )} + + + ))} + + + + + + + + getCippValidator(value, "json"), + }} + formControl={formControl} + multiline + rows={6} + maxRows={30} + sx={{ + "& .MuiInputBase-root": { + overflow: "auto", + minHeight: "200px", + }, + }} + placeholder={`Enter a JSON object`} + /> + + + + + - + + + + + - ); }; diff --git a/src/components/CippIntegrations/CippApiClientManagement.jsx b/src/components/CippIntegrations/CippApiClientManagement.jsx index 5e7db0b495d5..13cb698b8c56 100644 --- a/src/components/CippIntegrations/CippApiClientManagement.jsx +++ b/src/components/CippIntegrations/CippApiClientManagement.jsx @@ -78,12 +78,12 @@ const CippApiClientManagement = () => { multiple: false, creatable: false, label: "Select Role", - placeholder: "Choose a role from the Custom Role list.", + placeholder: "Choose a role from the CIPP Role list.", api: { url: "/api/ListCustomRole", queryKey: "CustomRoleList", - labelField: "RowKey", - valueField: "RowKey", + labelField: "RoleName", + valueField: "RoleName", showRefresh: true, }, }, @@ -306,6 +306,7 @@ const CippApiClientManagement = () => { name: "AppName", label: "App Name", placeholder: "Enter a name for this Application Registration.", + disableVariables: true, }, { type: "autoComplete", @@ -316,11 +317,11 @@ const CippApiClientManagement = () => { api: { url: "/api/ListCustomRole", queryKey: "CustomRoleList", - labelField: "RowKey", - valueField: "RowKey", + labelField: "RoleName", + valueField: "RoleName", showRefresh: true, }, - placeholder: "Choose a role from the Custom Role list.", + placeholder: "Choose a role from the CIPP Role list.", }, { type: "autoComplete", @@ -380,12 +381,12 @@ const CippApiClientManagement = () => { multiple: false, creatable: false, label: "Select Role", - placeholder: "Choose a role from the Custom Role list.", + placeholder: "Choose a role from the CIPP Role list.", api: { url: "/api/ListCustomRole", queryKey: "CustomRoleList", - labelField: "RowKey", - valueField: "RowKey", + labelField: "RoleName", + valueField: "RoleName", showRefresh: true, }, }, diff --git a/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx b/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx index 0d8c98d4000f..ae0014d0e276 100644 --- a/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx +++ b/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx @@ -115,7 +115,7 @@ const CippIntegrationFieldMapping = () => { {fieldMapping?.data?.CIPPFields?.filter( (field) => field.FieldType === header.FieldType ).map((field, fieldIndex) => ( - + { {fieldMapping.isLoading && ( - + - + @@ -170,7 +170,7 @@ const CippIntegrationFieldMapping = () => { )} {fieldMapping.isSuccess && !extension && ( - + Extension not found diff --git a/src/components/CippIntegrations/CippIntegrationSettings.jsx b/src/components/CippIntegrations/CippIntegrationSettings.jsx index b594ae5c78d1..3ff5bea6f256 100644 --- a/src/components/CippIntegrations/CippIntegrationSettings.jsx +++ b/src/components/CippIntegrations/CippIntegrationSettings.jsx @@ -1,4 +1,4 @@ -import { Box } from "@mui/material"; +import { Box, CardContent } from "@mui/material"; import { Grid } from "@mui/system"; import CippFormSection from "/src/components/CippFormPages/CippFormSection"; import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; @@ -62,7 +62,7 @@ const CippIntegrationSettings = ({ children }) => { {setting?.condition ? ( s.name === `${extension.id}.Enabled`) && !enabled}> - + { ) : ( - + { {integrations.isLoading && Loading...} {integrations.isSuccess && !extension && ( - + Extension not found diff --git a/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx b/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx index d495bc370e6c..fe0039c63799 100644 --- a/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx +++ b/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx @@ -3,13 +3,13 @@ import { Button, CardActions, CardContent, - Grid, Stack, Skeleton, SvgIcon, Tooltip, Typography, } from "@mui/material"; +import { Grid } from "@mui/system"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { ApiGetCall, ApiPostCall } from "/src/api/ApiCall"; @@ -93,6 +93,10 @@ const CippIntegrationSettings = ({ children }) => { }; setTableData([...tableData, newRowData]); + + // Clear the form fields after successfully adding the mapping + formControl.setValue("tenantFilter", null); + formControl.setValue("integrationCompany", null); }; const handleAutoMap = () => { @@ -162,7 +166,7 @@ const CippIntegrationSettings = ({ children }) => { mb: 3, }} > - + { /> - + - + { return { label: company.name, @@ -200,7 +204,7 @@ const CippIntegrationSettings = ({ children }) => { sortOptions={true} /> - + + + + } + > + + + Customize your organization's branding for reports and documents. Changes will be applied + to all generated reports. + + + {/* Logo Upload Section */} + + + Logo + + + + + + {logoPreview && ( + + Logo preview + + )} + + + Recommended: PNG format, max 2MB, optimal size 200x100px + + + + + {/* Color Picker Section */} + + + Brand Color + + + formControl.setValue("colour", e.target.value)} + style={{ + width: "50px", + height: "40px", + border: "1px solid #ddd", + borderRadius: "4px", + cursor: "pointer", + }} + /> + + + + This color will be used for accents and highlights in reports + + + + {/* Preview Section */} + + + Preview + + + {logoPreview && ( + Logo + )} + + + Your Organization + + + Executive Report Preview + + + + + + {/* API Results inside the card */} + + + + ); +}; + +export default CippBrandingSettings; diff --git a/src/components/CippSettings/CippCustomRoles.jsx b/src/components/CippSettings/CippCustomRoles.jsx index 0dbf589a75f5..56a7ddf651ea 100644 --- a/src/components/CippSettings/CippCustomRoles.jsx +++ b/src/components/CippSettings/CippCustomRoles.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { Box, @@ -13,7 +13,7 @@ import { Skeleton, } from "@mui/material"; -import Grid from "@mui/material/Grid2"; +import { Grid } from "@mui/system"; import { ApiGetCall, ApiGetCallWithPagination, ApiPostCall } from "../../api/ApiCall"; import { CippOffCanvas } from "/src/components/CippComponents/CippOffCanvas"; import { CippFormTenantSelector } from "/src/components/CippComponents/CippFormTenantSelector"; @@ -180,13 +180,13 @@ export const CippCustomRoles = () => { const ApiPermissionRow = ({ obj, cat }) => { const [offcanvasVisible, setOffcanvasVisible] = useState(false); + const [descriptionOffcanvasVisible, setDescriptionOffcanvasVisible] = useState(false); + const [selectedDescription, setSelectedDescription] = useState({ name: '', description: '' }); - 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 }]; + const handleDescriptionClick = (name, description) => { + setSelectedDescription({ name, description }); + setDescriptionOffcanvasVisible(true); + }; return ( { > {obj} - + + )} + + ))} - ); - }); - return sections; + + ); })} + + {/* Description offcanvas */} + setDescriptionOffcanvasVisible(false)} + title="Function Description" + > + + + {selectedDescription.name} + + + {selectedDescription.description} + + + ); }; @@ -391,7 +418,7 @@ export const CippCustomRoles = () => { )} - + {selectedRole && selectedTenant?.length > 0 && ( <>
    Allowed Tenants
    diff --git a/src/components/CippSettings/CippDnsSettings.jsx b/src/components/CippSettings/CippDnsSettings.jsx index 525fde070cca..847c0451bf87 100644 --- a/src/components/CippSettings/CippDnsSettings.jsx +++ b/src/components/CippSettings/CippDnsSettings.jsx @@ -24,11 +24,11 @@ const CippDnsSettings = () => { const DnsButtons = () => { const resolvers = ["Google", "Cloudflare", "Quad9"]; - return resolvers.map((resolver) => ( + return resolvers.map((resolver, index) => ( + + + {/* Main offcanvas */} + setOffcanvasVisible(false)} + title={`${cat}.${obj} Endpoints`} + > + + + Listed below are the available API endpoints based on permission level. ReadWrite + level includes endpoints under Read. + + {Object.keys(apiPermissions[cat][obj]).map((type, typeIndex) => { + var items = []; + for (var api in apiPermissions[cat][obj][type]) { + const apiFunction = apiPermissions[cat][obj][type][api]; + items.push({ + name: apiFunction.Name, + description: apiFunction.Description?.[0]?.Text || null, + }); + } + return ( + + {type} + + {items.map((item, idx) => ( + + + {item.name} + + {item.description && ( + + )} + + ))} + + + ); + })} + + + + {/* Description offcanvas */} + setDescriptionOffcanvasVisible(false)} + title="Function Description" + > + + + {selectedDescription.name} + + {selectedDescription.description} + + + + ); + }; + + return ( + <> + + + + + Role Options + + {!selectedRole && ( + + )} + {selectedRole && isBaseRole && ["admin", "superadmin"].includes(selectedRole) && ( + }> + This is a highly privileged role and overrides any custom role restrictions. + + )} + {cippApiRoleSelected && ( + + This is the default role for all API clients in the CIPP-API integration. If you + would like different permissions for specific applications, create a role per + application and select it from the CIPP-API integrations page. + + )} + + + {!isBaseRole && ( + <> + + + {allTenantSelected && blockedTenants?.length == 0 && ( + + All tenants selected, no tenant restrictions will be applied unless blocked + tenants are specified. + + )} + + {allTenantSelected && ( + + + + )} + + + { + const allEndpoints = []; + Object.keys(apiPermissions) + .sort() + .forEach((cat) => { + Object.keys(apiPermissions[cat]) + .sort() + .forEach((obj) => { + Object.keys(apiPermissions[cat][obj]).forEach((type) => { + Object.keys(apiPermissions[cat][obj][type]).forEach( + (apiKey) => { + const apiFunction = apiPermissions[cat][obj][type][apiKey]; + const descriptionText = apiFunction.Description?.[0]?.Text; + allEndpoints.push({ + label: descriptionText + ? `${apiFunction.Name} - ${descriptionText}` + : apiFunction.Name, + value: apiFunction.Name, + category: `${cat}.${obj}.${type}`, + }); + } + ); + }); + }); + }); + // Sort endpoints alphabetically within each category + return allEndpoints.sort((a, b) => { + if (a.category !== b.category) { + return a.category.localeCompare(b.category); + } + return a.label.localeCompare(b.label); + }); + })() + : [] + } + formControl={formControl} + fullWidth={true} + multiple={true} + creatable={false} + groupBy={(option) => option.category} + renderGroup={(params) => ( +
  • + {params.group} + {params.children} +
  • + )} + helperText="Select specific API endpoints to block for this role, this overrides permission settings below." + /> +
    + + )} + {apiPermissionFetching && ( + <> + + + + + + + + + + {[...Array(5)].map((_, index) => ( + + + + + + ))} + + )} + {apiPermissionSuccess && ( + <> + {/* Display include/exclude patterns for base roles */} + {isBaseRole && selectedRole && cippRoles[selectedRole]?.include && ( + <> + + Defined Permissions + + + + Include Patterns: + + + These patterns define which permissions are included for this base role: + + + {cippRoles[selectedRole].include.map((pattern, idx) => ( + + {pattern} + + ))} + + + {cippRoles[selectedRole]?.exclude && + cippRoles[selectedRole].exclude.length > 0 && ( + <> + + Exclude Patterns: + + + These patterns define which permissions are explicitly excluded from + this base role: + + + {cippRoles[selectedRole].exclude.map((pattern, idx) => ( + + {pattern} + + ))} + + + )} + + + )} + + + API Permissions + + {!isBaseRole && ( + + Set All Permissions + + + + + + )} + + <> + {Object.keys(apiPermissions) + .sort() + .map((cat, catIndex) => ( + + }>{cat} + + {Object.keys(apiPermissions[cat]) + .sort() + .map((obj, index) => { + const readOnly = baseRolePermissions?.[cat] ? true : false; + return ( + + + + ); + })} + + + ))} + + + + )} +
    + + + {selectedEntraGroup && ( + + This role will be assigned to the Entra Group:{" "} + {selectedEntraGroup.label} + + )} + {selectedTenant?.length > 0 && ( + <> +
    Allowed Tenants
    +
      + {selectedTenant.map((tenant, idx) => ( +
    • {tenant?.label}
    • + ))} +
    + + )} + {blockedTenants?.length > 0 && ( + <> +
    Blocked Tenants
    +
      + {blockedTenants.map((tenant, idx) => ( +
    • {tenant?.label}
    • + ))} +
    + + )} + {blockedEndpoints?.length > 0 && ( + <> +
    Blocked Endpoints
    +
      + {blockedEndpoints.map((endpoint, idx) => ( +
    • + {endpoint?.label || endpoint?.value || endpoint} +
    • + ))} +
    + + )} + {selectedPermissions && apiPermissionSuccess && ( + <> +
    Selected Permissions
    +
      + {selectedPermissions && + Object.keys(selectedPermissions) + ?.sort() + .map((cat, idx) => ( + + {selectedPermissions?.[cat] && + typeof selectedPermissions[cat] === "string" && + !selectedPermissions[cat]?.includes("None") && ( +
    • {selectedPermissions[cat]}
    • + )} +
      + ))} +
    + + )} +
    +
    + + + + + + + ); +}; + +export default CippRoleAddEdit; diff --git a/src/components/CippSettings/CippRoles.jsx b/src/components/CippSettings/CippRoles.jsx new file mode 100644 index 000000000000..c155064b634a --- /dev/null +++ b/src/components/CippSettings/CippRoles.jsx @@ -0,0 +1,143 @@ +import React from "react"; +import { Box, Button, SvgIcon } from "@mui/material"; +import { CippDataTable } from "../CippTable/CippDataTable"; +import { PencilIcon, TrashIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline"; +import NextLink from "next/link"; +import { CippPropertyListCard } from "../../components/CippCards/CippPropertyListCard"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; +import { getCippFormatting } from "../../utils/get-cipp-formatting"; +import { Stack } from "@mui/system"; +import { CippCopyToClipBoard } from "../CippComponents/CippCopyToClipboard"; + +const CippRoles = () => { + const actions = [ + { + label: "Edit", + icon: ( + + + + ), + link: "/cipp/super-admin/cipp-roles/edit?role=[RoleName]", + }, + { + label: "Clone", + icon: ( + + + + ), + type: "POST", + url: "/api/ExecCustomRole", + data: { + Action: "Clone", + RoleName: "RoleName", + }, + fields: [ + { + label: "New Role Name", + name: "NewRoleName", + type: "textField", + required: true, + helperText: + "Enter a name for the new cloned role. This cannot be the same as an existing role.", + disableVariables: true, + }, + ], + relatedQueryKeys: ["customRoleList"], + confirmText: "Are you sure you want to clone this custom role?", + condition: (row) => row?.Type === "Custom", + }, + { + label: "Delete", + icon: ( + + + + ), + confirmText: "Are you sure you want to delete this custom role?", + url: "/api/ExecCustomRole", + type: "POST", + data: { + Action: "Delete", + RoleName: "RoleName", + }, + condition: (row) => row?.Type === "Custom", + relatedQueryKeys: ["customRoleList"], + }, + ]; + + const offCanvas = { + children: (data) => { + const includeProps = ["RoleName", "Type", "EntraGroup", "AllowedTenants", "BlockedTenants"]; + const keys = includeProps.filter((key) => Object.keys(data).includes(key)); + const properties = []; + keys.forEach((key) => { + if (data[key] && data[key].length > 0) { + properties.push({ + label: getCippTranslation(key), + value: getCippFormatting(data[key], key), + }); + } + }); + + if (data["Permissions"] && Object.keys(data["Permissions"]).length > 0) { + properties.push({ + label: "Permissions", + value: ( + + {Object.keys(data["Permissions"]) + .sort() + .map((permission, idx) => ( + + + + ))} + + ), + }); + } + + return ( + + ); + }, + }; + + return ( + + + + + } + component={NextLink} + href="/cipp/super-admin/cipp-roles/add" + > + Add Role + + } + api={{ + url: "/api/ListCustomRole", + }} + queryKey="customRoleTable" + simpleColumns={["RoleName", "Type", "EntraGroup", "AllowedTenants", "BlockedTenants"]} + offCanvas={offCanvas} + /> + + ); +}; + +export default CippRoles; diff --git a/src/components/CippSettings/CippTenantResults.jsx b/src/components/CippSettings/CippTenantResults.jsx index 9483a07f7a2e..dc79285ccb4e 100644 --- a/src/components/CippSettings/CippTenantResults.jsx +++ b/src/components/CippSettings/CippTenantResults.jsx @@ -1,5 +1,5 @@ import { CippDataTable } from "../CippTable/CippDataTable"; -import { Sync } from "@mui/icons-material"; +import { Plumbing, Sync } from "@mui/icons-material"; export const CippTenantResults = (props) => { const { importReport = false } = props; @@ -28,6 +28,9 @@ export const CippTenantResults = (props) => { "LastRun", "GraphTest", "ExchangeTest", + "OrgManagementRepairNeeeded", + "OrgManagementRoles", + "OrgManagementRolesMissing", ], }} /> @@ -54,6 +57,15 @@ export const CippTenantResults = (props) => { relatedQueryKeys: "ExecAccessChecks-Tenants", multiPost: false, }, + { + label: "Repair Exchange Roles", + type: "POST", + url: "/api/ExecExchangeRoleRepair", + data: { TenantId: "TenantId" }, + icon: , + confirmText: "Repair Exchange roles for [TenantName]?", + condition: (row) => row.OrgManagementRepairNeeded === true, + }, ]} simpleColumns={[ "TenantName", diff --git a/src/components/CippSettings/CippVersionProperties.jsx b/src/components/CippSettings/CippVersionProperties.jsx index dca288ede4dd..6fbf88a39311 100644 --- a/src/components/CippSettings/CippVersionProperties.jsx +++ b/src/components/CippSettings/CippVersionProperties.jsx @@ -1,4 +1,4 @@ -import { Box, Button, Skeleton, SvgIcon } from "@mui/material"; +import { Box, Button, SvgIcon } from "@mui/material"; import { CippPropertyListCard } from "/src/components/CippCards/CippPropertyListCard"; import { CheckCircle, SystemUpdateAlt, Warning } from "@mui/icons-material"; import { ApiGetCall } from "/src/api/ApiCall"; diff --git a/src/components/CippStandards/CippStandardAccordion.jsx b/src/components/CippStandards/CippStandardAccordion.jsx index 8037de3b2e2b..aec6d0fbda94 100644 --- a/src/components/CippStandards/CippStandardAccordion.jsx +++ b/src/components/CippStandards/CippStandardAccordion.jsx @@ -9,7 +9,6 @@ import { SvgIcon, Collapse, Divider, - Grid, Tooltip, Chip, TextField, @@ -25,7 +24,11 @@ import { Search, Close, FilterAlt, + NotificationImportant, + Assignment, + Construction, } from "@mui/icons-material"; +import { Grid } from "@mui/system"; import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; import { useWatch } from "react-hook-form"; import _ from "lodash"; @@ -37,6 +40,9 @@ import Intune from "../../icons/iconly/bulk/intune"; import GDAPRoles from "/src/data/GDAPRoles"; import timezoneList from "/src/data/timezoneList"; import standards from "/src/data/standards.json"; +import { CippFormCondition } from "../CippComponents/CippFormCondition"; +import { CippPolicyImportDrawer } from "../CippComponents/CippPolicyImportDrawer"; +import ReactMarkdown from "react-markdown"; const getAvailableActions = (disabledFeatures) => { const allActions = [ @@ -68,7 +74,7 @@ const CippAddedComponent = React.memo(({ standardName, component, formControl }) } return ( - + { const [configuredState, setConfiguredState] = useState({}); const [filter, setFilter] = useState("all"); const [searchQuery, setSearchQuery] = useState(""); + const [savedValues, setSavedValues] = useState({}); + const [originalValues, setOriginalValues] = useState({}); const watchedValues = useWatch({ control: formControl.control, }); - useEffect(() => { - const newConfiguredState = { ...configuredState }; + // Watch all trackDrift values for all standards at once + const allTrackDriftValues = useWatch({ + control: formControl.control, + name: Object.keys(selectedStandards).map((standardName) => `${standardName}.trackDrift`), + }); - Object.keys(selectedStandards).forEach((standardName) => { - const standard = providedStandards.find((s) => s.name === standardName.split("[")[0]); - if (standard) { - const actionFilled = !!_.get(watchedValues, `${standardName}.action`, false); + // Handle drift mode automatic action setting + useEffect(() => { + if (isDriftMode && selectedStandards) { + Object.keys(selectedStandards).forEach((standardName) => { + const currentValues = formControl.getValues(standardName) || {}; + const autoRemediate = currentValues.autoRemediate; + + // Set default action based on autoRemediate setting + const defaultAction = autoRemediate + ? [ + { label: "Report", value: "Report" }, + { label: "Remediate", value: "Remediate" }, + ] + : [{ label: "Report", value: "Report" }]; + + // Only set if action is not already set + if (!currentValues.action) { + formControl.setValue(`${standardName}.action`, defaultAction); + } - const addedComponentsFilled = - standard.addedComponent?.every((component) => { - const isRequired = component.required !== false && component.type !== "switch"; - if (!isRequired) return true; - return !!_.get(watchedValues, `${standardName}.${component.name}`); - }) ?? true; + // Set default autoRemediate if not set + if (currentValues.autoRemediate === undefined) { + formControl.setValue(`${standardName}.autoRemediate`, false); + formControl.setValue(`${standardName}.action`, [{ label: "Report", value: "Report" }]); + } + }); + } + }, [isDriftMode, selectedStandards, formControl]); + + // Check if a standard is configured based on its values + const isStandardConfigured = (standardName, standard, values) => { + if (!values) return false; + + // ALWAYS require an action for any standard to be considered configured + // The action field should be an array with at least one element + const actionValue = _.get(values, "action"); + if (!actionValue || (Array.isArray(actionValue) && actionValue.length === 0)) return false; + + // Additional checks for required components + const hasRequiredComponents = + standard.addedComponent && + standard.addedComponent.some((comp) => comp.type !== "switch" && comp.required !== false); + const actionRequired = standard.disabledFeatures !== undefined || hasRequiredComponents; + + // Always require an action (should be an array with at least one element) + const actionFilled = actionValue && (!Array.isArray(actionValue) || actionValue.length > 0); + + const addedComponentsFilled = + standard.addedComponent?.every((component) => { + // Always skip switches + if (component.type === "switch") return true; + + // Handle conditional fields + if (component.condition) { + const conditionField = component.condition.field; + const conditionValue = _.get(values, conditionField); + const compareType = component.condition.compareType || "is"; + const compareValue = component.condition.compareValue; + const propertyName = component.condition.propertyName || "value"; + + let conditionMet = false; + if (propertyName === "value") { + switch (compareType) { + case "is": + conditionMet = _.isEqual(conditionValue, compareValue); + break; + case "isNot": + conditionMet = !_.isEqual(conditionValue, compareValue); + break; + default: + conditionMet = false; + } + } else if (Array.isArray(conditionValue)) { + switch (compareType) { + case "valueEq": + conditionMet = conditionValue.some((item) => item?.[propertyName] === compareValue); + break; + default: + conditionMet = false; + } + } - const isConfigured = actionFilled && addedComponentsFilled; + // If condition is not met, skip validation for this field + if (!conditionMet) return true; + } - if (newConfiguredState[standardName] !== isConfigured) { - newConfiguredState[standardName] = isConfigured; + // Check if field is required + const isRequired = component.required !== false; + if (!isRequired) return true; + + // Get field value using lodash's get to properly handle nested properties + const fieldValue = _.get(values, component.name); + + // Check if field has a value based on its type and multiple property + if (component.type === "autoComplete" || component.type === "select") { + if (component.multiple) { + // For multiple selection, check if array exists and has items + return Array.isArray(fieldValue) && fieldValue.length > 0; + } else { + // For single selection, check if value exists + return !!fieldValue; + } } + + // For other field types + return !!fieldValue; + }) ?? true; + + return actionFilled && addedComponentsFilled; + }; + + // Initialize when watchedValues are available + useEffect(() => { + if (editMode) { + // Only run initialization if we have watchedValues and they contain data + if (!watchedValues || Object.keys(watchedValues).length === 0) { + return; } - }); - if (!_.isEqual(newConfiguredState, configuredState)) { - setConfiguredState(newConfiguredState); + // Prevent re-initialization if we already have configuration state + const hasConfigState = Object.keys(configuredState).length > 0; + if (hasConfigState) { + return; + } + + const initial = {}; + const initialConfigured = {}; + + // For each standard, get its current values and determine if it's configured + Object.keys(selectedStandards).forEach((standardName) => { + const currentValues = _.get(watchedValues, standardName); + if (!currentValues) return; + + initial[standardName] = _.cloneDeep(currentValues); + + const baseStandardName = standardName.split("[")[0]; + const standard = providedStandards.find((s) => s.name === baseStandardName); + if (standard) { + initialConfigured[standardName] = isStandardConfigured( + standardName, + standard, + currentValues + ); + } + }); + + // Store both the initial values and set them as current saved values + setOriginalValues(initial); + setSavedValues(initial); + setConfiguredState(initialConfigured); + // Only depend on watchedValues and selectedStandards to avoid infinite loops + // eslint-disable-next-line react-hooks/exhaustive-deps } - }, [watchedValues, providedStandards, selectedStandards]); + }, [watchedValues, selectedStandards, editMode]); + + // Save changes for a standard + const handleSave = (standardName, standard, current) => { + // Clone the current values to avoid reference issues + const newValues = _.cloneDeep(current); + + // Update saved values + setSavedValues((prev) => ({ + ...prev, + [standardName]: newValues, + })); + + // Update configured state right away + const isConfigured = isStandardConfigured(standardName, standard, newValues); + + setConfiguredState((prev) => ({ + ...prev, + [standardName]: isConfigured, + })); + // Collapse the accordion after saving + handleAccordionToggle(null); + }; + + // Handle auto-remediate toggle in drift mode + const handleAutoRemediateChange = (standardName, value) => { + const action = value + ? [ + { label: "Report", value: "Report" }, + { label: "Remediate", value: "Remediate" }, + ] + : [{ label: "Report", value: "Report" }]; + + formControl.setValue(`${standardName}.autoRemediate`, value); + formControl.setValue(`${standardName}.action`, action); + }; + + // Cancel changes for a standard + const handleCancel = (standardName) => { + // Get the last saved values + const savedValue = _.get(savedValues, standardName); + if (!savedValue) return; + + // Set the entire standard's value at once to ensure proper handling of nested objects and arrays + formControl.setValue(standardName, _.cloneDeep(savedValue)); + + // Find the original standard definition to get the base standard + const baseStandardName = standardName.split("[")[0]; + const standard = providedStandards.find((s) => s.name === baseStandardName); + + // Determine if the standard was configured with saved values + if (standard) { + const isConfigured = isStandardConfigured(standardName, standard, savedValue); + + // Restore the previous configuration state + setConfiguredState((prev) => ({ + ...prev, + [standardName]: isConfigured, + })); + } + + // Collapse the accordion after canceling + handleAccordionToggle(null); + }; + + // Group standards by category const groupedStandards = useMemo(() => { const result = {}; @@ -154,6 +362,7 @@ const CippStandardAccordion = ({ return result; }, [selectedStandards, providedStandards]); + // Filter standards based on search and filter selection const filteredGroupedStandards = useMemo(() => { if (!searchQuery && filter === "all") { return groupedStandards; @@ -166,6 +375,11 @@ const CippStandardAccordion = ({ const categoryMatchesSearch = !searchQuery || category.toLowerCase().includes(searchLower); const filteredStandards = groupedStandards[category].filter(({ standardName, standard }) => { + // If this is the currently expanded standard, always include it in the result + if (standardName === expanded) { + return true; + } + const matchesSearch = !searchQuery || categoryMatchesSearch || @@ -176,7 +390,7 @@ const CippStandardAccordion = ({ Array.isArray(standard.tag) && standard.tag.some((tag) => tag.toLowerCase().includes(searchLower))); - const isConfigured = configuredState[standardName]; + const isConfigured = _.get(configuredState, standardName); const matchesFilter = filter === "all" || (filter === "configured" && isConfigured) || @@ -193,6 +407,7 @@ const CippStandardAccordion = ({ return result; }, [groupedStandards, searchQuery, filter, configuredState]); + // Count standards by configuration state const standardCounts = useMemo(() => { let allCount = 0; let configuredCount = 0; @@ -235,7 +450,13 @@ const CippStandardAccordion = ({ sx={{ width: { xs: "100%", sm: 350 } }} placeholder="Search..." value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} + onChange={(e) => { + // Close any expanded accordion when changing search query + if (expanded && e.target.value !== searchQuery) { + handleAccordionToggle(null); + } + setSearchQuery(e.target.value); + }} slotProps={{ input: { startAdornment: ( @@ -248,7 +469,13 @@ const CippStandardAccordion = ({ setSearchQuery("")} + onClick={() => { + // Close any expanded accordion when clearing search + if (expanded) { + handleAccordionToggle(null); + } + setSearchQuery(""); + }} aria-label="Clear search" > @@ -268,19 +495,37 @@ const CippStandardAccordion = ({ @@ -307,7 +552,7 @@ const CippStandardAccordion = ({ const isExpanded = expanded === standardName; const hasAddedComponents = standard.addedComponent && standard.addedComponent.length > 0; - const isConfigured = configuredState[standardName]; + const isConfigured = _.get(configuredState, standardName); const disabledFeatures = standard.disabledFeatures || {}; let selectedActions = _.get(watchedValues, `${standardName}.action`); @@ -315,22 +560,135 @@ const CippStandardAccordion = ({ selectedActions = [selectedActions]; } + // Get template name for Intune Templates + let templateDisplayName = ""; + if (standardName.startsWith("standards.IntuneTemplate")) { + // Check for TemplateList selection + const templateList = _.get(watchedValues, `${standardName}.TemplateList`); + if (templateList && templateList.label) { + templateDisplayName = templateList.label; + } + + // Check for TemplateList-Tags selection (takes priority) + const templateListTags = _.get(watchedValues, `${standardName}.TemplateList-Tags`); + if (templateListTags && templateListTags.label) { + templateDisplayName = templateListTags.label; + } + } + + // For multiple standards, check the first added component const selectedTemplateName = standard.multiple ? _.get(watchedValues, `${standardName}.${standard.addedComponent?.[0]?.name}`) : ""; - const accordionTitle = selectedTemplateName - ? `${standard.label} - ${selectedTemplateName.label}` + + // Build accordion title with template name if available + const accordionTitle = templateDisplayName + ? `${standard.label} - ${templateDisplayName}` + : selectedTemplateName && _.get(selectedTemplateName, "label") + ? `${standard.label} - ${_.get(selectedTemplateName, "label")}` : standard.label; + // Get current values and check if they differ from saved values + const current = _.get(watchedValues, standardName); + const saved = _.get(savedValues, standardName) || {}; + + const hasUnsaved = !_.isEqual(current, saved); + + // Check if all required fields are filled + const requiredFieldsFilled = current + ? standard.addedComponent?.every((component) => { + // Always skip switches regardless of their required property + if (component.type === "switch") return true; + + // Skip optional fields (not required) + const isRequired = component.required !== false; + if (!isRequired) return true; + + // Handle conditional fields + if (component.condition) { + const conditionField = component.condition.field; + const conditionValue = _.get(current, conditionField); + const compareType = component.condition.compareType || "is"; + const compareValue = component.condition.compareValue; + const propertyName = component.condition.propertyName || "value"; + + let conditionMet = false; + if (propertyName === "value") { + switch (compareType) { + case "is": + conditionMet = _.isEqual(conditionValue, compareValue); + break; + case "isNot": + conditionMet = !_.isEqual(conditionValue, compareValue); + break; + default: + conditionMet = false; + } + } else if (Array.isArray(conditionValue)) { + switch (compareType) { + case "valueEq": + conditionMet = conditionValue.some( + (item) => item?.[propertyName] === compareValue + ); + break; + default: + conditionMet = false; + } + } + + // If condition is not met, skip validation + if (!conditionMet) return true; + } + + // Get field value for validation using lodash's get to properly handle nested properties + const fieldValue = _.get(current, component.name); + + // Check if required field has a value based on its type and multiple property + if (component.type === "autoComplete" || component.type === "select") { + if (component.multiple) { + // For multiple selection, check if array exists and has items + return Array.isArray(fieldValue) && fieldValue.length > 0; + } else { + // For single selection, check if value exists + return !!fieldValue; + } + } + + // For other field types + return !!fieldValue; + }) ?? true + : false; + + // ALWAYS require an action for all standards + const actionRequired = true; + + // Check if there are required non-switch components for UI display purposes + const hasRequiredComponents = + standard.addedComponent && + standard.addedComponent.some( + (comp) => comp.type !== "switch" && comp.required !== false + ); + + // Action is always required and must be an array with at least one element + const actionValue = _.get(current, "action"); + const hasAction = + actionValue && (!Array.isArray(actionValue) || actionValue.length > 0); + + // Allow saving if: + // 1. Action is selected if required + // 2. All required fields are filled + // 3. There are unsaved changes + const canSave = hasAction && requiredFieldsFilled && hasUnsaved; + return ( - + {standard.cat === "Global Standards" ? ( @@ -348,31 +706,69 @@ const CippStandardAccordion = ({ {accordionTitle} - {selectedActions && selectedActions?.length > 0 && ( - - {selectedActions?.map((action, index) => ( - - - - ))} - - - )} - - {standard.helpText} - + + {/* Hide action chips in drift mode */} + {!isDriftMode && selectedActions && selectedActions?.length > 0 && ( + <> + {selectedActions?.map((action, index) => ( + + + {action.value === "Report" && } + {action.value === "warn" && } + {action.value === "Remediate" && } + + } + /> + + ))} + + )} + + + theme.palette.primary.main, + textDecoration: "underline", + "&:hover": { + textDecoration: "none", + }, + }, + color: "text.secondary", + fontSize: "0.875rem", + lineHeight: 1.43, + mr: 1, + }} + > + ( + + {children} + + ), + // Convert paragraphs to spans to avoid unwanted spacing + p: ({ children }) => {children}, + }} + > + {standard.helpText} + + @@ -394,9 +790,11 @@ const CippStandardAccordion = ({ {isConfigured ? "Configured" : "Unconfigured"} - handleRemoveStandard(standardName)}> - - + + handleRemoveStandard(standardName)}> + + + handleAccordionToggle(standardName)}> - - - + {isDriftMode ? ( + /* Drift mode layout - full width with slider first */ + + {/* Auto-remediate switch takes full width and is first */} + + + handleAutoRemediateChange(standardName, e.target.checked) + } + fullWidth + /> + + + {/* Additional components take full width */} + {hasAddedComponents && ( + <> + {/* Add catalog button for Intune Template standard - appears first */} + {standardName.startsWith("standards.IntuneTemplate") && ( + + + + + + )} + {standard.addedComponent?.map((component, idx) => + component?.condition ? ( + + + + ) : ( + + ) + )} + + )} + ) : ( + /* Standard mode layout - original grid layout */ + + + + - {hasAddedComponents && ( - - - {standard.addedComponent?.map((component, idx) => ( - - ))} + {hasAddedComponents && ( + + + {/* Add catalog button for Intune Template standard - appears first */} + {standardName.startsWith("standards.IntuneTemplate") && ( + + + + + + )} + {standard.addedComponent?.map((component, idx) => + component?.condition ? ( + + + + ) : ( + + ) + )} + - - )} - + )} + + )} + + + + + + +
    diff --git a/src/components/CippStandards/CippStandardDialog.jsx b/src/components/CippStandards/CippStandardDialog.jsx index 5bc741800718..d74d6f4d3630 100644 --- a/src/components/CippStandards/CippStandardDialog.jsx +++ b/src/components/CippStandards/CippStandardDialog.jsx @@ -1,11 +1,10 @@ -import { differenceInDays } from 'date-fns'; +import { differenceInDays } from "date-fns"; import { Dialog, DialogActions, DialogContent, DialogTitle, TextField, - Grid, Card, CardContent, Typography, @@ -15,10 +14,530 @@ import { Switch, Button, IconButton, + CircularProgress, + Select, + MenuItem, + FormControl, + InputLabel, + Stack, + Divider, + Collapse, + ToggleButton, + ToggleButtonGroup, + List, + ListItem, + ListItemText, + ListItemSecondaryAction, } from "@mui/material"; -import { Add } from "@mui/icons-material"; -import { useState, useCallback } from "react"; +import { Grid } from "@mui/system"; +import { + Add, + Sort, + Clear, + FilterList, + ExpandMore, + ExpandLess, + ViewModule, + ViewList, +} from "@mui/icons-material"; +import { useState, useCallback, useMemo, memo, useEffect } from "react"; import { debounce } from "lodash"; +import { Virtuoso } from "react-virtuoso"; +import ReactMarkdown from "react-markdown"; + +// Memoized Standard Card component to prevent unnecessary re-renders +const StandardCard = memo( + ({ + standard, + category, + selectedStandards, + handleToggleSingleStandard, + handleAddClick, + isButtonDisabled, + }) => { + const isNewStandard = (dateAdded) => { + const currentDate = new Date(); + const addedDate = new Date(dateAdded); + return differenceInDays(currentDate, addedDate) <= 30; + }; + + // Create a memoized handler for this specific standard to avoid recreation on each render + const handleToggle = useCallback(() => { + handleToggleSingleStandard(standard.name); + }, [handleToggleSingleStandard, standard.name]); + + // Check if this standard is selected - memoize for better performance + const isSelected = useMemo(() => { + return !!selectedStandards[standard.name]; + }, [selectedStandards, standard.name]); + + // Lazily render complex parts of the card only when visible + const [expanded, setExpanded] = useState(false); + + // Use intersection observer to detect when card is visible + useEffect(() => { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setExpanded(true); + observer.disconnect(); + } + }, + { threshold: 0.1 } + ); + + const currentRef = document.getElementById(`standard-card-${standard.name}`); + if (currentRef) { + observer.observe(currentRef); + } + + return () => observer.disconnect(); + }, [standard.name]); + + return ( + + + {isNewStandard(standard.addedDate) && ( + + )} + + + + {standard.label} + + {expanded && standard.helpText && ( + <> + + Description: + + theme.palette.primary.main, + textDecoration: "underline", + "&:hover": { + textDecoration: "none", + }, + }, + color: "text.secondary", + fontSize: "0.875rem", + lineHeight: 1.43, + mb: 2, + }} + > + ( + + {children} + + ), + // Convert paragraphs to spans to avoid unwanted spacing + p: ({ children }) => {children}, + }} + > + {standard.helpText} + + + + )} + + Category: + + + {expanded && + standard.tag?.filter((tag) => !tag.toLowerCase().includes("impact")).length > 0 && ( + <> + + Tags: + + + {standard.tag + .filter((tag) => !tag.toLowerCase().includes("impact")) + .map((tag, idx) => ( + + ))} + + + )} + + Impact: + + + {expanded && standard.recommendedBy?.length > 0 && ( + <> + + Recommended By: + + + {standard.recommendedBy.join(", ")} + + + )} + {expanded && standard.addedDate?.length > 0 && ( + <> + + Date Added: + + + + {standard.addedDate} + + + + )} + + + + {standard.multiple ? ( + handleAddClick(standard.name)} + > + + + ) : ( + + } + label="Add this standard to the template" + /> + )} + + + + + ); + }, + // Custom equality function to prevent unnecessary re-renders + (prevProps, nextProps) => { + // Only re-render if one of these props changed + if (prevProps.isButtonDisabled !== nextProps.isButtonDisabled) return false; + if (prevProps.standard.name !== nextProps.standard.name) return false; + + // Only check selected state for this specific standard + const prevSelected = !!prevProps.selectedStandards[prevProps.standard.name]; + const nextSelected = !!nextProps.selectedStandards[nextProps.standard.name]; + if (prevSelected !== nextSelected) return false; + + // If we get here, nothing important changed, skip re-render + return true; + } +); + +StandardCard.displayName = "StandardCard"; + +// Virtualized grid to handle large numbers of standards efficiently +const VirtualizedStandardGrid = memo(({ items, renderItem }) => { + const [itemsPerRow, setItemsPerRow] = useState(() => + window.innerWidth > 960 ? 4 : window.innerWidth > 600 ? 2 : 1 + ); + + // Handle window resize for responsive grid + useEffect(() => { + const handleResize = () => { + const newItemsPerRow = window.innerWidth > 960 ? 4 : window.innerWidth > 600 ? 2 : 1; + setItemsPerRow(newItemsPerRow); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + const rows = useMemo(() => { + const rowCount = Math.ceil(items.length / itemsPerRow); + const rowsData = []; + + for (let i = 0; i < rowCount; i++) { + const startIdx = i * itemsPerRow; + const rowItems = items.slice(startIdx, startIdx + itemsPerRow); + rowsData.push(rowItems); + } + + return rowsData; + }, [items, itemsPerRow]); + + return ( + ( + + + {rows[index].map(renderItem)} + + + )} + /> + ); +}); + +VirtualizedStandardGrid.displayName = "VirtualizedStandardGrid"; + +// Compact List View component for standards +const CompactStandardList = memo( + ({ items, selectedStandards, handleToggleSingleStandard, handleAddClick, isButtonDisabled }) => { + return ( + + {items.map(({ standard, category }) => { + const isSelected = !!selectedStandards[standard.name]; + + const isNewStandard = (dateAdded) => { + if (!dateAdded) return false; + const currentDate = new Date(); + const addedDate = new Date(dateAdded); + return differenceInDays(currentDate, addedDate) <= 30; + }; + + const handleToggle = () => { + handleToggleSingleStandard(standard.name); + }; + + return ( + + + + {standard.label} + + {isNewStandard(standard.addedDate) && ( + + )} + + + + } + secondary={ + + {standard.helpText && ( + theme.palette.primary.main, + textDecoration: "underline", + "&:hover": { + textDecoration: "none", + }, + }, + color: "text.secondary", + fontSize: "0.875rem", + lineHeight: 1.43, + }} + > + ( + + {children} + + ), + p: ({ children }) => ( + + {children} + + ), + }} + > + {standard.helpText} + + + )} + + {standard.tag?.filter((tag) => !tag.toLowerCase().includes("impact")).length > + 0 && ( + + {standard.tag + .filter((tag) => !tag.toLowerCase().includes("impact")) + .slice(0, 3) // Show only first 3 tags to save space + .map((tag, idx) => ( + + ))} + {standard.tag.filter((tag) => !tag.toLowerCase().includes("impact")) + .length > 3 && ( + + + + {standard.tag.filter((tag) => !tag.toLowerCase().includes("impact")) + .length - 3}{" "} + more + + )} + + )} + {standard.recommendedBy?.length > 0 && ( + + β€’ Recommended by: {standard.recommendedBy.join(", ")} + + )} + {standard.addedDate && ( + + β€’ Added: {standard.addedDate} + + )} + + + } + /> + + {standard.multiple ? ( + handleAddClick(standard.name)} + sx={{ mr: 1 }} + > + + + ) : ( + + } + label="" + sx={{ mr: 1 }} + /> + )} + + + ); + })} + + ); + } +); + +CompactStandardList.displayName = "CompactStandardList"; const CippStandardDialog = ({ dialogOpen, @@ -31,192 +550,772 @@ const CippStandardDialog = ({ handleAddMultipleStandard, }) => { const [isButtonDisabled, setButtonDisabled] = useState(false); + const [localSearchQuery, setLocalSearchQuery] = useState(""); + const [isInitialLoading, setIsInitialLoading] = useState(true); + const [viewMode, setViewMode] = useState("card"); // "card" or "list" + + // Enhanced filtering and sorting state + const [sortBy, setSortBy] = useState("addedDate"); // Default sort by date added + const [sortOrder, setSortOrder] = useState("desc"); // desc to show newest first + const [selectedCategories, setSelectedCategories] = useState([]); + const [selectedImpacts, setSelectedImpacts] = useState([]); + const [selectedRecommendedBy, setSelectedRecommendedBy] = useState([]); + const [selectedTagFrameworks, setSelectedTagFrameworks] = useState([]); + const [showOnlyNew, setShowOnlyNew] = useState(false); // Show only standards added in last 30 days + const [filtersExpanded, setFiltersExpanded] = useState(false); // Control filter section collapse/expand + + // Auto-adjust sort order when sort type changes + useEffect(() => { + if (sortBy === "label") { + setSortOrder("asc"); // Names: A-Z + } else if (sortBy === "addedDate") { + setSortOrder("desc"); // Dates: Newest first + } else if (sortBy === "impact") { + setSortOrder("desc"); // Impact: High to Low + } + }, [sortBy]); + + // Get all unique values for filters + const { allCategories, allImpacts, allRecommendedBy, allTagFrameworks } = useMemo(() => { + const categorySet = new Set(); + const impactSet = new Set(); + const recommendedBySet = new Set(); + const tagFrameworkSet = new Set(); + + // Function to extract base framework from tag + const extractTagFramework = (tag) => { + // Compliance Frameworks - extract version dynamically + if (tag.startsWith("CIS M365")) { + const versionMatch = tag.match(/CIS M365 (\d+\.\d+)/); + return versionMatch ? `CIS M365 ${versionMatch[1]}` : "CIS M365"; + } + if (tag.startsWith("CISA ")) return "CISA"; + if (tag.startsWith("EIDSCA.")) return "EIDSCA"; + if (tag.startsWith("Essential 8")) return "Essential 8"; + if (tag.startsWith("NIST CSF")) { + const versionMatch = tag.match(/NIST CSF (\d+\.\d+)/); + return versionMatch ? `NIST CSF ${versionMatch[1]}` : "NIST CSF"; + } + + // Microsoft Secure Score Categories + if (tag.startsWith("exo_")) return "Secure Score - Exchange"; + if (tag.startsWith("mdo_")) return "Secure Score - Defender"; + if (tag.startsWith("spo_")) return "Secure Score - SharePoint"; + if (tag.startsWith("mip_")) return "Secure Score - Purview"; + + // For any other tags, return null to exclude them + return null; + }; + + Object.keys(categories).forEach((category) => { + categorySet.add(category); + categories[category].forEach((standard) => { + if (standard.impact) impactSet.add(standard.impact); + if (standard.recommendedBy && Array.isArray(standard.recommendedBy)) { + standard.recommendedBy.forEach((rec) => recommendedBySet.add(rec)); + } + // Process tags to extract frameworks + if (standard.tag && Array.isArray(standard.tag)) { + standard.tag.forEach((tag) => { + const framework = extractTagFramework(tag); + if (framework) { + // Only add non-null frameworks + tagFrameworkSet.add(framework); + } + }); + } + }); + }); + + // Custom sort order for impacts: Low -> Medium -> High + const impactOrder = ["Low Impact", "Medium Impact", "High Impact"]; + const sortedImpacts = Array.from(impactSet).sort((a, b) => { + const aIndex = impactOrder.indexOf(a); + const bIndex = impactOrder.indexOf(b); + return aIndex - bIndex; + }); + + // Sort tag frameworks with compliance frameworks first, then service categories + const sortedTagFrameworks = Array.from(tagFrameworkSet).sort((a, b) => { + // Define priority groups + const getFrameworkPriority = (framework) => { + if (framework.startsWith("CIS M365")) return 1; + if (framework === "CISA") return 2; + if (framework === "EIDSCA") return 3; + if (framework === "Essential 8") return 4; + if (framework.startsWith("NIST CSF")) return 5; + if (framework.startsWith("Secure Score -")) return 6; + return 999; // Other tags go last + }; + + const aPriority = getFrameworkPriority(a); + const bPriority = getFrameworkPriority(b); + + // If different priorities, sort by priority + if (aPriority !== bPriority) { + return aPriority - bPriority; + } + + // If same priority, sort alphabetically + return a.localeCompare(b); + }); + + return { + allCategories: Array.from(categorySet).sort(), + allImpacts: sortedImpacts, + allRecommendedBy: Array.from(recommendedBySet).sort(), + allTagFrameworks: sortedTagFrameworks, + }; + }, [categories]); + + // Enhanced filter function + const enhancedFilterStandards = useCallback( + (standardsList) => { + // Function to extract base framework from tag (same as in useMemo) + const extractTagFramework = (tag) => { + // Compliance Frameworks - extract version dynamically + if (tag.startsWith("CIS M365")) { + const versionMatch = tag.match(/CIS M365 (\d+\.\d+)/); + return versionMatch ? `CIS M365 ${versionMatch[1]}` : "CIS M365"; + } + if (tag.startsWith("CISA ")) return "CISA"; + if (tag.startsWith("EIDSCA.")) return "EIDSCA"; + if (tag.startsWith("Essential 8")) return "Essential 8"; + if (tag.startsWith("NIST CSF")) { + const versionMatch = tag.match(/NIST CSF (\d+\.\d+)/); + return versionMatch ? `NIST CSF ${versionMatch[1]}` : "NIST CSF"; + } + + // Microsoft Secure Score Categories + if (tag.startsWith("exo_")) return "Secure Score - Exchange"; + if (tag.startsWith("mdo_")) return "Secure Score - Defender"; + if (tag.startsWith("spo_")) return "Secure Score - SharePoint"; + if (tag.startsWith("mip_")) return "Secure Score - Purview"; + + // For any other tags, return null to exclude them + return null; + }; + + return standardsList.filter((standard) => { + // Original text search + const matchesSearch = + !localSearchQuery || + standard.label.toLowerCase().includes(localSearchQuery.toLowerCase()) || + standard.helpText.toLowerCase().includes(localSearchQuery.toLowerCase()) || + (standard.tag && + standard.tag.some((tag) => tag.toLowerCase().includes(localSearchQuery.toLowerCase()))); + + // Category filter + const matchesCategory = + selectedCategories.length === 0 || selectedCategories.includes(standard.cat); - const handleAddClick = (standardName) => { - setButtonDisabled(true); - handleAddMultipleStandard(standardName); + // Impact filter + const matchesImpact = + selectedImpacts.length === 0 || selectedImpacts.includes(standard.impact); - setTimeout(() => { - setButtonDisabled(false); - }, 100); - }; + // Recommended by filter + const matchesRecommendedBy = + selectedRecommendedBy.length === 0 || + (standard.recommendedBy && + Array.isArray(standard.recommendedBy) && + standard.recommendedBy.some((rec) => selectedRecommendedBy.includes(rec))); + // Tag framework filter + const matchesTagFramework = + selectedTagFrameworks.length === 0 || + (standard.tag && + Array.isArray(standard.tag) && + standard.tag.some((tag) => { + const framework = extractTagFramework(tag); + return framework && selectedTagFrameworks.includes(framework); + })); + + // New standards filter (last 30 days) + const isNewStandard = (dateAdded) => { + if (!dateAdded) return false; + const currentDate = new Date(); + const addedDate = new Date(dateAdded); + return differenceInDays(currentDate, addedDate) <= 30; + }; + const matchesNewFilter = !showOnlyNew || isNewStandard(standard.addedDate); + + return ( + matchesSearch && + matchesCategory && + matchesImpact && + matchesRecommendedBy && + matchesTagFramework && + matchesNewFilter + ); + }); + }, + [ + localSearchQuery, + selectedCategories, + selectedImpacts, + selectedRecommendedBy, + selectedTagFrameworks, + showOnlyNew, + ] + ); + + // Enhanced sort function + const sortStandards = useCallback( + (standardsList) => { + return [...standardsList].sort((a, b) => { + let aValue, bValue; + + switch (sortBy) { + case "label": + aValue = a.label.toLowerCase(); + bValue = b.label.toLowerCase(); + break; + case "addedDate": + aValue = new Date(a.addedDate || "1900-01-01"); + bValue = new Date(b.addedDate || "1900-01-01"); + break; + case "category": + aValue = a.cat?.toLowerCase() || ""; + bValue = b.cat?.toLowerCase() || ""; + break; + case "impact": + // Sort by impact priority: High > Medium > Low + const impactOrder = { "High Impact": 3, "Medium Impact": 2, "Low Impact": 1 }; + aValue = impactOrder[a.impact] || 0; + bValue = impactOrder[b.impact] || 0; + break; + case "recommendedBy": + aValue = + a.recommendedBy && a.recommendedBy.length > 0 + ? a.recommendedBy.join(", ").toLowerCase() + : ""; + bValue = + b.recommendedBy && b.recommendedBy.length > 0 + ? b.recommendedBy.join(", ").toLowerCase() + : ""; + break; + default: + aValue = a.label.toLowerCase(); + bValue = b.label.toLowerCase(); + } + + if (aValue < bValue) return sortOrder === "asc" ? -1 : 1; + if (aValue > bValue) return sortOrder === "asc" ? 1 : -1; + return 0; + }); + }, + [sortBy, sortOrder] + ); + + // Optimize handleAddClick to be more performant + const handleAddClick = useCallback( + (standardName) => { + setButtonDisabled(true); + handleAddMultipleStandard(standardName); + // Use requestAnimationFrame for smoother UI updates + requestAnimationFrame(() => { + setTimeout(() => { + setButtonDisabled(false); + }, 100); + }); + }, + [handleAddMultipleStandard] + ); + + // Optimize search debounce with a higher timeout for better performance const handleSearchQueryChange = useCallback( debounce((query) => { setSearchQuery(query.trim()); - }, 50), - [] + }, 350), // Increased debounce time for better performance + [setSearchQuery] ); - const isNewStandard = (dateAdded) => { - const currentDate = new Date(); - const addedDate = new Date(dateAdded); - return differenceInDays(currentDate, addedDate) <= 30; - }; + // Only process visible categories on demand to improve performance + const [processedItems, setProcessedItems] = useState([]); + + // Handle search input change locally + const handleLocalSearchChange = useCallback( + (e) => { + const value = e.target.value; + setLocalSearchQuery(value); + handleSearchQueryChange(value); + }, + [handleSearchQueryChange] + ); + + // Clear all filters + const clearAllFilters = useCallback(() => { + setLocalSearchQuery(""); + setSelectedCategories([]); + setSelectedImpacts([]); + setSelectedRecommendedBy([]); + setSelectedTagFrameworks([]); + setShowOnlyNew(false); + setSortBy("addedDate"); + setSortOrder("desc"); + setViewMode("card"); // Reset to card view + handleSearchQueryChange(""); + }, [handleSearchQueryChange]); + + // Clear dialog state on close + const handleClose = useCallback(() => { + setLocalSearchQuery(""); // Clear local search state + setSelectedCategories([]); + setSelectedImpacts([]); + setSelectedRecommendedBy([]); + setSelectedTagFrameworks([]); + setShowOnlyNew(false); + setViewMode("card"); // Reset to card view + handleSearchQueryChange(""); // Clear parent search state + handleCloseDialog(); + }, [handleCloseDialog, handleSearchQueryChange]); + + // Process standards data only when dialog is opened, to improve performance + useEffect(() => { + if (dialogOpen) { + // Use requestIdleCallback if available, or setTimeout as fallback + const processStandards = () => { + // Create a flattened list of all standards for virtualized rendering + const allItems = []; + + Object.keys(categories).forEach((category) => { + const categoryStandards = categories[category]; + const filteredStandards = enhancedFilterStandards(categoryStandards); + + filteredStandards.forEach((standard) => { + allItems.push({ + standard, + category, + }); + }); + }); + + // Apply sorting to the final combined array instead of per-category + const sortedAllItems = sortStandards(allItems.map((item) => item.standard)).map( + (standard) => { + const item = allItems.find((item) => item.standard.name === standard.name); + return item; + } + ); + + setProcessedItems(sortedAllItems); + setIsInitialLoading(false); + }; + if (window.requestIdleCallback) { + window.requestIdleCallback(processStandards, { timeout: 500 }); + } else { + setTimeout(processStandards, 100); + } + + return () => { + if (window.cancelIdleCallback) { + window.cancelIdleCallback(processStandards); + } + }; + } else { + setIsInitialLoading(true); + } + }, [dialogOpen, categories, enhancedFilterStandards, sortStandards]); + + // Render individual standard card + const renderStandardCard = useCallback( + ({ standard, category }) => ( + + ), + [selectedStandards, handleToggleSingleStandard, handleAddClick, isButtonDisabled] + ); + + // Count active filters + const activeFiltersCount = + selectedCategories.length + + selectedImpacts.length + + selectedRecommendedBy.length + + selectedTagFrameworks.length + + (showOnlyNew ? 1 : 0); + + // Don't render dialog contents until it's actually open (improves performance) return ( { + // Clear processed items on dialog close to free up memory + setProcessedItems([]); + }, + }} PaperProps={{ sx: { minWidth: "720px", + maxHeight: "90vh", + height: "90vh", + display: "flex", + flexDirection: "column", }, }} > Select a Standard to Add - - handleSearchQueryChange(e.target.value.toLowerCase())} - /> - - {Object.keys(categories).every( - (category) => filterStandards(categories[category]).length === 0 - ) ? ( - + {/* Search and Filter Controls */} + + {/* Search Box */} + + + {/* Unified Controls Section */} + + {/* Clickable header bar */} + setFiltersExpanded(!filtersExpanded)} + sx={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + py: 0.75, + px: 1, + borderRadius: filtersExpanded ? "4px 4px 0 0" : 1, + cursor: "pointer", + bgcolor: "action.hover", + border: "1px solid", + borderColor: "divider", + borderBottom: filtersExpanded ? "none" : "none", + "&:hover": { + bgcolor: "action.selected", + }, + }} > - Search returned no results - - ) : ( - Object.keys(categories).map((category) => - filterStandards(categories[category]).map((standard) => ( - - + + + View, Sort & Filter Options + + {!filtersExpanded && ( + + ({viewMode === "card" ? "Card" : "List"} β€’{" "} + {sortBy === "addedDate" ? "Date" : "Name"} {sortOrder === "desc" ? "↓" : "↑"} + {activeFiltersCount > 0 + ? ` β€’ ${activeFiltersCount} filter${activeFiltersCount !== 1 ? "s" : ""}` + : ""} + ) + + )} + + {filtersExpanded ? : } + + + {/* Single line controls when expanded */} + + + {/* View Mode */} + { + if (newViewMode !== null) { + setViewMode(newViewMode); + } + }} + > + + + Cards + + + + List + + + + {/* Sort Controls */} + + Sort By + + + + + Order + + + + {/* Filter Controls */} + + Categories + + + + + Impact + + + + + Recommended By + + + + + Compliance Tags + + + + {/* New Standards Toggle */} + setShowOnlyNew(e.target.checked)} + /> + } + label="New (30 days)" + sx={{ ml: 1 }} + /> + + {/* Clear Button */} + {activeFiltersCount > 0 && ( + + )} + + + + + {/* Active Filter Chips */} + {activeFiltersCount > 0 && ( + + + {selectedCategories.map((category) => ( + + setSelectedCategories((prev) => prev.filter((c) => c !== category)) + } + color="primary" + variant="outlined" + /> + ))} + {selectedImpacts.map((impact) => ( + setSelectedImpacts((prev) => prev.filter((i) => i !== impact))} + color="secondary" + variant="outlined" + /> + ))} + {selectedRecommendedBy.map((rec) => ( + + setSelectedRecommendedBy((prev) => prev.filter((r) => r !== rec)) + } + color="success" + variant="outlined" + /> + ))} + {selectedTagFrameworks.map((framework) => ( + + setSelectedTagFrameworks((prev) => prev.filter((f) => f !== framework)) + } + color="warning" + variant="outlined" + /> + ))} + {showOnlyNew && ( + setShowOnlyNew(false)} + color="info" + variant="outlined" + /> + )} + + )} - + + + + + {/* Results */} + {isInitialLoading ? ( + + + + ) : processedItems.length === 0 ? ( + + + No standards match your search and filter criteria + + + Try adjusting your search terms or clearing some filters + + + ) : ( + + + Showing {processedItems.length} standard{processedItems.length !== 1 ? "s" : ""} + + {viewMode === "card" ? ( + + + + ) : ( + + + + )} + + )} - - diff --git a/src/components/CippStandards/CippStandardsSideBar.jsx b/src/components/CippStandards/CippStandardsSideBar.jsx index d8c6092174d9..09cc642f61bd 100644 --- a/src/components/CippStandards/CippStandardsSideBar.jsx +++ b/src/components/CippStandards/CippStandardsSideBar.jsx @@ -21,6 +21,8 @@ import CippFormComponent from "/src/components/CippComponents/CippFormComponent" import { CippFormTenantSelector } from "../CippComponents/CippFormTenantSelector"; import { CippApiDialog } from "../CippComponents/CippApiDialog"; import ReactTimeAgo from "react-time-ago"; +import { Alert } from "@mui/material"; +import { ApiGetCall } from "../../api/ApiCall"; const StyledTimelineDot = (props) => { const { complete } = props; @@ -64,26 +66,226 @@ const CippStandardsSideBar = ({ formControl, createDialog, edit, + onSaveSuccess, + onDriftConflictChange, + isDriftMode = false, }) => { const [currentStep, setCurrentStep] = useState(0); const [savedItem, setSavedItem] = useState(null); + const [driftError, setDriftError] = useState(""); + const dialogAfterEffect = (id) => { setSavedItem(id); + + // Reset form's dirty state to prevent unsaved changes warning + if (formControl && formControl.reset) { + // Get current values and reset the form with them to clear dirty state + const currentValues = formControl.getValues(); + formControl.reset(currentValues); + } + + // Call the onSaveSuccess callback if provided + if (typeof onSaveSuccess === "function") { + onSaveSuccess(); + } }; const watchForm = useWatch({ control: formControl.control }); + // Use proper CIPP ApiGetCall for drift validation + const driftValidationApi = ApiGetCall({ + url: "/api/ListTenantAlignment", + queryKey: "ListTenantAlignment-drift-validation", + }); + + // Get tenant groups for group membership validation + const tenantGroupsApi = ApiGetCall({ + url: "/api/ListTenantGroups", + queryKey: "ListTenantGroups-drift-validation", + }); + + // Helper function to expand groups to their member tenants + const expandGroupsToTenants = (tenants, groups) => { + const expandedTenants = []; + + tenants.forEach((tenant) => { + const tenantValue = typeof tenant === "object" ? tenant.value : tenant; + const tenantType = typeof tenant === "object" ? tenant.type : null; + + if (tenantType === "Group") { + // Find the group and add all its members + const group = groups?.find((g) => g.Id === tenantValue); + if (group && group.Members) { + group.Members.forEach((member) => { + expandedTenants.push(member.defaultDomainName); + }); + } + } else { + // Regular tenant + expandedTenants.push(tenantValue); + } + }); + + return expandedTenants; + }; + + // Enhanced drift validation using CIPP patterns with group support + const validateDrift = async (tenants) => { + if (!isDriftMode || !tenants || tenants.length === 0) { + setDriftError(""); + onDriftConflictChange?.(false); + return; + } + + try { + // Wait for both APIs to load + if (!driftValidationApi.data || !tenantGroupsApi.data) { + return; + } + + // Filter out current template if editing + console.log("Duplicate detection debug:", { + edit, + currentGUID: watchForm.GUID, + allTemplates: driftValidationApi.data?.map((t) => ({ + GUID: t.GUID, + standardId: t.standardId, + standardName: t.standardName, + })), + }); + + const existingTemplates = driftValidationApi.data.filter((template) => { + const shouldInclude = + edit && watchForm.GUID ? template.standardId !== watchForm.GUID : true; + console.log( + `Template ${template.standardId} (${template.standardName}): shouldInclude=${shouldInclude}, currentGUID=${watchForm.GUID}` + ); + return shouldInclude; + }); + + console.log( + "Filtered templates:", + existingTemplates?.map((t) => ({ + GUID: t.GUID, + standardId: t.standardId, + standardName: t.standardName, + })) + ); + + // Get tenant groups data + const groups = tenantGroupsApi.data?.Results || []; + + // Expand selected tenants (including group members) + const selectedTenantList = expandGroupsToTenants(tenants, groups); + + // Simple conflict check + const conflicts = []; + + // Filter for drift templates only and group by standardId + const driftTemplates = existingTemplates.filter( + (template) => template.standardType === "drift" + ); + const uniqueTemplates = {}; + + driftTemplates.forEach((template) => { + if (!uniqueTemplates[template.standardId]) { + uniqueTemplates[template.standardId] = { + standardName: template.standardName, + tenants: [], + }; + } + uniqueTemplates[template.standardId].tenants.push(template.tenantFilter); + }); + + // Check for conflicts with unique templates + console.log("Checking conflicts with unique templates:", uniqueTemplates); + console.log("Selected tenant list:", selectedTenantList); + + for (const templateId in uniqueTemplates) { + const template = uniqueTemplates[templateId]; + const templateTenants = template.tenants; + + console.log( + `Checking template ${templateId} (${template.standardName}) with tenants:`, + templateTenants + ); + + const hasConflict = selectedTenantList.some((selectedTenant) => { + // Check if any template tenant matches the selected tenant + const conflict = templateTenants.some((templateTenant) => { + if (selectedTenant === "AllTenants" || templateTenant === "AllTenants") { + console.log( + `Conflict found: ${selectedTenant} vs ${templateTenant} (AllTenants match)` + ); + return true; + } + const match = selectedTenant === templateTenant; + if (match) { + console.log(`Conflict found: ${selectedTenant} vs ${templateTenant} (exact match)`); + } + return match; + }); + return conflict; + }); + + console.log(`Template ${templateId} has conflict: ${hasConflict}`); + + if (hasConflict) { + conflicts.push(template.standardName || "Unknown Template"); + } + } + + console.log("Final conflicts:", conflicts); + + if (conflicts.length > 0) { + setDriftError( + `This template has tenants that are assigned to another Drift Template. You can only assign one Drift Template to each tenant. Please check the ${conflicts.join( + ", " + )} template.` + ); + onDriftConflictChange?.(true); + } else { + setDriftError(""); + onDriftConflictChange?.(false); + } + } catch (error) { + setDriftError("Error checking for conflicts" + (error.message ? `: ${error.message}` : "")); + onDriftConflictChange?.(true); + } + }; + + // Watch tenant changes + useEffect(() => { + if (!isDriftMode) return; + + const timeoutId = setTimeout(() => { + validateDrift(watchForm.tenantFilter); + }, 500); + + return () => clearTimeout(timeoutId); + }, [watchForm.tenantFilter, isDriftMode, driftValidationApi.data, tenantGroupsApi.data]); + useEffect(() => { const stepsStatus = { - step1: !!watchForm.templateName, - step2: watchForm.tenantFilter && watchForm.tenantFilter.length > 0, + step1: !!_.get(watchForm, "templateName"), + step2: _.get(watchForm, "tenantFilter", []).length > 0, step3: Object.keys(selectedStandards).length > 0, step4: - watchForm.standards && + _.get(watchForm, "standards") && Object.keys(selectedStandards).length > 0 && Object.keys(selectedStandards).every((standardName) => { - const standardValues = _.get(watchForm, `${standardName}`, {}) ?? {}; - return standardValues?.action; + const standardValues = _.get(watchForm, `${standardName}`, {}); + const standard = selectedStandards[standardName]; + // Check if this standard requires an action + const hasRequiredComponents = + standard?.addedComponent && + standard.addedComponent.some( + (comp) => comp.type !== "switch" && comp.required !== false + ); + const actionRequired = standard?.disabledFeatures !== undefined || hasRequiredComponents; + // Always require an action value which should be an array with at least one element + const actionValue = _.get(standardValues, "action"); + return actionValue && (!Array.isArray(actionValue) || actionValue.length > 0); }), }; @@ -91,24 +293,102 @@ const CippStandardsSideBar = ({ setCurrentStep(completedSteps); }, [selectedStandards, watchForm]); + // Create a local reference to the stepsStatus from the latest effect run const stepsStatus = { - step1: !!watchForm.templateName, - step2: watchForm.tenantFilter && watchForm.tenantFilter.length > 0, + step1: !!_.get(watchForm, "templateName"), + step2: _.get(watchForm, "tenantFilter", []).length > 0, step3: Object.keys(selectedStandards).length > 0, step4: - watchForm.standards && + _.get(watchForm, "standards") && Object.keys(selectedStandards).length > 0 && Object.keys(selectedStandards).every((standardName) => { const standardValues = _.get(watchForm, `${standardName}`, {}); - return standardValues?.action; + const standard = selectedStandards[standardName]; + // Always require an action for all standards (must be an array with at least one element) + const actionValue = _.get(standardValues, "action"); + return actionValue && (!Array.isArray(actionValue) || actionValue.length > 0); }), }; + return ( + + {isDriftMode ? "About Drift Templates" : "About Standard Templates"} + + {isDriftMode ? ( + + + Drift templates provide continuous monitoring of tenant configurations to detect + unauthorized changes. Each tenant can only have one drift template applied at a time. + + + Remediation Options: + + + β€’ Automatic Remediation: Immediately reverts unauthorized changes + back to the template configuration +
    β€’ Manual Remediation: Sends email notifications for review, + allowing you to accept or deny detected changes +
    + + Key Features: + + + β€’ Monitors all security standards, Conditional Access policies, and Intune policies +
    + β€’ Detects changes made outside of CIPP +
    + β€’ Configurable webhook and email notifications +
    β€’ Granular control over deviation acceptance +
    +
    + ) : ( + + + Standard templates can be applied to multiple tenants and allow overlapping + configurations with intelligent merging based on specificity and timing. + + + + Merge Priority (Specificity): + + + 1. Individual Tenant - Highest priority, overrides all others +
    + 2. Tenant Group - Overrides "All Tenants" settings +
    + 3. All Tenants - Lowest priority, default baseline +
    + + + Conflict Resolution: + + + When multiple standards target the same scope (e.g., two tenant-specific templates), + the most recently created template takes precedence. + + + + Example: An "All Tenants" template enables audit log retention for 90 + days, but you need 365 days for one specific tenant. Create a tenant-specific template + with 365-day retention - it will override the global setting for that tenant only. + +
    + )} + + {/* Hidden field to mark drift templates */} + {isDriftMode && ( + + )} - {watchForm.tenantFilter?.some((tenant) => tenant.value === "AllTenants") && ( + + {/* Show drift error */} + {isDriftMode && driftError && {driftError}} + + {(watchForm.tenantFilter?.some( + (tenant) => tenant.value === "AllTenants" || tenant.type === "Group" + ) || (watchForm.excludedTenants && watchForm.excludedTenants.length > 0)) && ( <> )} - {updatedAt.date && ( + {/* Drift-specific fields */} + {isDriftMode && ( + <> + + + + + )} + {/* Hide schedule options in drift mode */} + {!isDriftMode && ( <> + {updatedAt.date && ( + <> + + Last Updated by {updatedAt?.user} + + + )} + - Last Updated by {updatedAt?.user} + This setting allows you to create this template and run it only by using "Run Now". )} - - - This setting allows you to create this template and run it only by using "Run Now". -
    - - - - {steps.map((step, index) => ( - - - - {index < steps.length - 1 && } - - {step} - - ))} - - + {/* Hide timeline/ticker in drift mode */} + {!isDriftMode && ( + <> + + + + {steps.map((step, index) => ( + + + + {index < steps.length - 1 && } + + {step} + + ))} + + + + )} {actions.map((action, index) => ( @@ -207,7 +525,9 @@ const CippStandardsSideBar = ({ label={action.label} onClick={action.handler} disabled={ - !(watchForm.tenantFilter && watchForm.tenantFilter.length > 0) || currentStep < 3 + !(watchForm.tenantFilter && watchForm.tenantFilter.length > 0) || + currentStep < 3 || + (isDriftMode && driftError) } /> ))} @@ -218,7 +538,9 @@ const CippStandardsSideBar = ({ createDialog={createDialog} title="Add Standard" api={{ - confirmText: watchForm.runManually + confirmText: isDriftMode + ? "This template will automatically every hour to detect drift. Are you sure you want to apply this Drift Template?" + : watchForm.runManually ? "Are you sure you want to apply this standard? This template has been set to never run on a schedule. After saving the template you will have to run it manually." : "Are you sure you want to apply this standard? This will apply the template and run every 3 hours.", url: "/api/AddStandardsTemplate", @@ -232,7 +554,15 @@ const CippStandardsSideBar = ({ standards: "standards", ...(edit ? { GUID: "GUID" } : {}), ...(savedItem ? { GUID: savedItem } : {}), - runManually: "runManually", + runManually: isDriftMode ? false : "runManually", + isDriftTemplate: "isDriftTemplate", + ...(isDriftMode + ? { + type: "drift", + driftAlertWebhook: "driftAlertWebhook", + driftAlertEmail: "driftAlertEmail", + } + : {}), }, }} row={formControl.getValues()} @@ -241,6 +571,8 @@ const CippStandardsSideBar = ({ "listStandardTemplates", "listStandards", `listStandardTemplates-${watchForm.GUID}`, + "ListTenantAlignment-drift-validation", + "ListTenantGroups-drift-validation", ]} />
    @@ -260,6 +592,8 @@ CippStandardsSideBar.propTypes = { ).isRequired, updatedAt: PropTypes.string, formControl: PropTypes.object.isRequired, + onSaveSuccess: PropTypes.func, + onDriftConflictChange: PropTypes.func, }; export default CippStandardsSideBar; diff --git a/src/components/CippTable/CIPPTableToptoolbar.js b/src/components/CippTable/CIPPTableToptoolbar.js index 5245c72e5a3a..321e53e1d7b2 100644 --- a/src/components/CippTable/CIPPTableToptoolbar.js +++ b/src/components/CippTable/CIPPTableToptoolbar.js @@ -1,38 +1,146 @@ -import { DeveloperMode, SevereCold, Sync, Tune, ViewColumn, MoreVert } from "@mui/icons-material"; +import React, { useState, useEffect, useRef } from "react"; import { + Box, Button, - Checkbox, - Divider, - IconButton, - ListItemText, Menu, MenuItem, - SvgIcon, + ListItemText, + ListItemIcon, + Divider, + IconButton, Tooltip, Typography, + InputBase, + Paper, + Checkbox, + SvgIcon, + Dialog, + DialogTitle, + DialogContent, + DialogActions, } from "@mui/material"; -import { Box, Stack } from "@mui/system"; import { - MRT_GlobalFilterTextField, - MRT_ToggleFiltersButton, - MRT_ToggleFullScreenButton, -} from "material-react-table"; + Search as SearchIcon, + FilterList as FilterListIcon, + ViewColumn as ViewColumnIcon, + FileDownload as ExportIcon, + KeyboardArrowDown as ArrowDownIcon, + Visibility as VisibilityIcon, + VisibilityOff as VisibilityOffIcon, + Code as CodeIcon, + PictureAsPdf as PdfIcon, + TableChart as CsvIcon, + SevereCold, + Sync, + Check as CheckIcon, + MoreVert as MoreVertIcon, + Fullscreen as FullscreenIcon, +} from "@mui/icons-material"; +import { ExclamationCircleIcon, ChevronDownIcon } from "@heroicons/react/24/outline"; +import { styled, alpha } from "@mui/material/styles"; +import { MRT_ToggleFullScreenButton } from "material-react-table"; import { PDFExportButton } from "../pdfExportButton"; -import { ChevronDownIcon, ExclamationCircleIcon } from "@heroicons/react/24/outline"; -import { usePopover } from "../../hooks/use-popover"; import { CSVExportButton } from "../csvExportButton"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; +import { useMediaQuery } from "@mui/material"; +import { CippQueueTracker } from "./CippQueueTracker"; +import { usePopover } from "../../hooks/use-popover"; import { useDialog } from "../../hooks/use-dialog"; -import { useEffect, useState } from "react"; import { CippApiDialog } from "../CippComponents/CippApiDialog"; -import { getCippTranslation } from "../../utils/get-cipp-translation"; import { useSettings } from "../../hooks/use-settings"; import { useRouter } from "next/router"; import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; import { CippCodeBlock } from "../CippComponents/CippCodeBlock"; import { ApiGetCall } from "../../api/ApiCall"; +import { useQueryClient } from "@tanstack/react-query"; import GraphExplorerPresets from "/src/data/GraphExplorerPresets.json"; import CippGraphExplorerFilter from "./CippGraphExplorerFilter"; -import { useMediaQuery } from "@mui/material"; +import { Stack } from "@mui/system"; + +// Styled components for modern design +const ModernSearchContainer = styled(Paper)(({ theme }) => ({ + display: "flex", + alignItems: "center", + width: "100%", + maxWidth: "300px", + minWidth: "200px", + height: "40px", + backgroundColor: theme.palette.mode === "dark" ? "#2A2D3A" : "#F8F9FA", + border: `1px solid ${theme.palette.mode === "dark" ? "#404040" : "#E0E0E0"}`, + borderRadius: "8px", + padding: "0 12px", + "&:hover": { + borderColor: theme.palette.primary.main, + }, + "&:focus-within": { + borderColor: theme.palette.primary.main, + boxShadow: `0 0 0 2px ${alpha(theme.palette.primary.main, 0.2)}`, + }, + [theme.breakpoints.down("md")]: { + minWidth: "0", + maxWidth: "none", + flex: 1, + }, +})); + +const ModernSearchInput = styled(InputBase)(({ theme }) => ({ + marginLeft: theme.spacing(1), + flex: 1, + fontSize: "14px", + "& .MuiInputBase-input": { + padding: "8px 0", + "&::placeholder": { + color: theme.palette.text.secondary, + opacity: 0.7, + }, + }, +})); + +const ModernButton = styled(Button)(({ theme }) => ({ + height: "40px", + borderRadius: "8px", + textTransform: "none", + fontWeight: 500, + fontSize: "14px", + padding: "8px 16px", + backgroundColor: theme.palette.mode === "dark" ? "#2A2D3A" : "#F8F9FA", + border: `1px solid ${theme.palette.mode === "dark" ? "#404040" : "#E0E0E0"}`, + color: theme.palette.text.primary, + minWidth: "auto", + whiteSpace: "nowrap", + "&:hover": { + backgroundColor: theme.palette.mode === "dark" ? "#363A4A" : "#F0F0F0", + borderColor: theme.palette.primary.main, + }, + "& .MuiButton-startIcon": { + marginRight: "8px", + }, + "& .MuiButton-endIcon": { + marginLeft: "8px", + }, + [theme.breakpoints.down("md")]: { + padding: "8px 12px", + fontSize: "13px", + "& .MuiButton-startIcon": { + marginRight: "6px", + }, + "& .MuiButton-endIcon": { + marginLeft: "6px", + }, + }, + [theme.breakpoints.down("sm")]: { + padding: "8px 10px", + fontSize: "12px", + "& .MuiButton-startIcon": { + marginRight: "4px", + }, + "& .MuiButton-endIcon": { + marginLeft: "4px", + }, + }, +})); + +const RefreshButton = styled(IconButton)(({ theme }) => ({})); export const CIPPTableToptoolbar = ({ api, @@ -53,10 +161,15 @@ export const CIPPTableToptoolbar = ({ data, setGraphFilterData, setConfiguredSimpleColumns, + queueMetadata, + isInDialog = false, }) => { const popover = usePopover(); - const columnPopover = usePopover(); - const filterPopover = usePopover(); + const [filtersAnchor, setFiltersAnchor] = useState(null); + const [columnsAnchor, setColumnsAnchor] = useState(null); + const [exportAnchor, setExportAnchor] = useState(null); + const [actionMenuAnchor, setActionMenuAnchor] = useState(null); + const [searchValue, setSearchValue] = useState(""); const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); const settings = useSettings(); @@ -64,26 +177,54 @@ export const CIPPTableToptoolbar = ({ const createDialog = useDialog(); const [actionData, setActionData] = useState({ data: {}, action: {}, ready: false }); const [offcanvasVisible, setOffcanvasVisible] = useState(false); + const [jsonDialogOpen, setJsonDialogOpen] = useState(false); // For dialog-based JSON view const [filterList, setFilterList] = useState(filters); + const [currentEffectiveQueryKey, setCurrentEffectiveQueryKey] = useState(queryKey || title); const [originalSimpleColumns, setOriginalSimpleColumns] = useState(simpleColumns); const [filterCanvasVisible, setFilterCanvasVisible] = useState(false); + const [activeFilterName, setActiveFilterName] = useState(null); const pageName = router.pathname.split("/").slice(1).join("/"); - const currentTenant = useSettings()?.currentTenant; + const currentTenant = settings?.currentTenant; - const [actionMenuAnchor, setActionMenuAnchor] = useState(null); - const handleActionMenuOpen = (event) => setActionMenuAnchor(event.currentTarget); - const handleActionMenuClose = () => setActionMenuAnchor(null); + // Track if we've restored filters for this page to prevent infinite loops + const restoredFiltersRef = useRef(new Set()); + + const getBulkActions = (actions, selectedRows) => { + return ( + actions + ?.filter((action) => !action.link && !action?.hideBulk) + ?.map((action) => ({ + ...action, + disabled: action.condition + ? !selectedRows.every((row) => action.condition(row.original)) + : false, + })) || [] + ); + }; useEffect(() => { //if usedData changes, deselect all rows table.toggleAllRowsSelected(false); }, [usedData]); + + // Sync currentEffectiveQueryKey with queryKey prop changes (e.g., tenant changes) + useEffect(() => { + setCurrentEffectiveQueryKey(queryKey || title); + // Clear active filter name when query key changes (page load, tenant change, etc.) + setActiveFilterName(null); + }, [queryKey, title]); + //if the currentTenant Switches, remove Graph filters useEffect(() => { if (currentTenant) { setGraphFilterData({}); + // Clear active filter name when tenant changes + setActiveFilterName(null); + // Clear restoration tracking so saved filters can be re-applied + const restorationKey = `${pageName}-graph`; + restoredFiltersRef.current.delete(restorationKey); } - }, [currentTenant]); + }, [currentTenant, pageName]); //useEffect to set the column visibility to the preferred columns if they exist useEffect(() => { @@ -99,15 +240,137 @@ export const CIPPTableToptoolbar = ({ setOriginalSimpleColumns(simpleColumns); }, [simpleColumns]); + // Early restoration of graph filters (before API call) - run only once per page + useEffect(() => { + const restorationKey = `${pageName}-graph`; + + if ( + settings.persistFilters && + settings.lastUsedFilters && + settings.lastUsedFilters[pageName] && + api?.url === "/api/ListGraphRequest" && // Only for graph requests + !restoredFiltersRef.current.has(restorationKey) // Only if not already restored + ) { + const last = settings.lastUsedFilters[pageName]; + if (last.type === "graph") { + // Mark as restored to prevent infinite loops + restoredFiltersRef.current.add(restorationKey); + + // Directly set the graph filter data without calling setTableFilter to avoid loops + const filterProps = [ + "$filter", + "$select", + "$expand", + "$orderby", + "$count", + "$search", + "ReverseTenantLookup", + "ReverseTenantLookupProperty", + "AsApp", + ]; + const graphFilter = filterProps.reduce((acc, prop) => { + if (last.value[prop]) { + acc[prop] = last.value[prop]; + } + return acc; + }, {}); + + const newQueryKey = `${queryKey ? queryKey : title}-${last.name}`; + setGraphFilterData({ + data: { ...mergeCaseInsensitive(api.data, graphFilter) }, + queryKey: newQueryKey, + }); + setCurrentEffectiveQueryKey(newQueryKey); + setActiveFilterName(last.name); + + if (last.value?.$select) { + let selectColumns = []; + if (Array.isArray(last.value.$select)) { + selectColumns = last.value.$select; + } else if (typeof last.value.$select === "string") { + selectColumns = last.value.$select + .split(",") + .map((col) => col.trim()) + .filter((col) => usedColumns.includes(col)); + } + if (selectColumns.length > 0) { + setConfiguredSimpleColumns(selectColumns); + } + } + } + } + }, [settings.persistFilters, settings.lastUsedFilters, pageName, api?.url, queryKey, title]); + + // Clear restoration tracking when page changes + useEffect(() => { + restoredFiltersRef.current.clear(); + }, [pageName]); + + // Restore last used filter on mount if persistFilters is enabled (non-graph filters) + useEffect(() => { + // Wait for table to be initialized and data to be available + if ( + settings.persistFilters && + settings.lastUsedFilters && + settings.lastUsedFilters[pageName] && + table && + usedColumns.length > 0 && + !getRequestData?.isFetching + ) { + // Use setTimeout to ensure the table is fully rendered + const timeoutId = setTimeout(() => { + const last = settings.lastUsedFilters[pageName]; + + if (last.type === "global") { + table.setGlobalFilter(last.value); + setActiveFilterName(last.name); + } else if (last.type === "column") { + // Only apply if all filter columns exist in the current table + const allColumns = table.getAllColumns().map((col) => col.id); + const filterColumns = Array.isArray(last.value) ? last.value.map((f) => f.id) : []; + const allExist = filterColumns.every((colId) => allColumns.includes(colId)); + if (allExist) { + table.setShowColumnFilters(true); + table.setColumnFilters(last.value); + setActiveFilterName(last.name); + } + } + // Note: graph filters are handled in the earlier useEffect + }, 100); + + return () => clearTimeout(timeoutId); + } + }, [ + settings.persistFilters, + settings.lastUsedFilters, + pageName, + table, + usedColumns, + getRequestData?.isFetching, + ]); + const presetList = ApiGetCall({ url: "/api/ListGraphExplorerPresets", queryKey: `ListGraphExplorerPresets${api?.data?.Endpoint ?? ""}`, data: { Endpoint: api?.data?.Endpoint ?? "", }, - waiting: api?.data?.Endpoint ? true : false, + waiting: !!api?.data?.Endpoint, }); + // Handle search input changes + const handleSearchChange = (event) => { + const value = event.target.value; + setSearchValue(value); + table.setGlobalFilter(value); + }; + + // Handle column filters toggle + const handleColumnFiltersToggle = () => { + const currentState = table.getState().showColumnFilters; + table.setShowColumnFilters(!currentState); + }; + const resetToDefaultVisibility = () => { setColumnVisibility((prevVisibility) => { const updatedVisibility = {}; @@ -124,7 +387,7 @@ export const CIPPTableToptoolbar = ({ [pageName]: {}, }, }); - columnPopover.handleClose(); + setColumnsAnchor(null); }; const resetToPreferedVisibility = () => { @@ -144,7 +407,7 @@ export const CIPPTableToptoolbar = ({ return updatedVisibility; }); } - columnPopover.handleClose(); + setColumnsAnchor(null); }; const saveAsPreferedColumns = () => { @@ -154,7 +417,7 @@ export const CIPPTableToptoolbar = ({ [pageName]: columnVisibility, }, }); - columnPopover.handleClose(); + setColumnsAnchor(null); }; const mergeCaseInsensitive = (obj1, obj2) => { @@ -171,13 +434,37 @@ export const CIPPTableToptoolbar = ({ return merged; }; + // Shared function for setting nested column visibility + const setNestedVisibility = (col) => { + if (typeof col === "object" && col !== null) { + Object.keys(col).forEach((key) => { + if (usedColumns.includes(key.trim())) { + setColumnVisibility((prev) => ({ ...prev, [key.trim()]: true })); + setNestedVisibility(col[key]); + } + }); + } else { + if (usedColumns.includes(col.trim())) { + setColumnVisibility((prev) => ({ ...prev, [col.trim()]: true })); + } + } + }; + const setTableFilter = (filter, filterType, filterName) => { if (filterType === "global" || filterType === undefined) { table.setGlobalFilter(filter); + setActiveFilterName(filterName); + if (settings.persistFilters && settings.setLastUsedFilter) { + settings.setLastUsedFilter(pageName, { type: "global", value: filter, name: filterName }); + } } if (filterType === "column") { table.setShowColumnFilters(true); table.setColumnFilters(filter); + setActiveFilterName(filterName); + if (settings.persistFilters && settings.setLastUsedFilter) { + settings.setLastUsedFilter(pageName, { type: "column", value: filter, name: filterName }); + } } if (filterType === "reset") { table.resetGlobalFilter(); @@ -186,6 +473,11 @@ export const CIPPTableToptoolbar = ({ setGraphFilterData({}); resetToDefaultVisibility(); } + setCurrentEffectiveQueryKey(queryKey || title); // Reset to original query key + setActiveFilterName(null); // Clear active filter + if (settings.persistFilters && settings.setLastUsedFilter) { + settings.setLastUsedFilter(pageName, { type: "reset", value: null, name: null }); + } } if (filterType === "graph") { const filterProps = [ @@ -208,31 +500,23 @@ export const CIPPTableToptoolbar = ({ table.resetGlobalFilter(); table.resetColumnFilters(); //get api.data, merge with graphFilter, set api.data + const newQueryKey = `${queryKey ? queryKey : title}-${filterName}`; setGraphFilterData({ data: { ...mergeCaseInsensitive(api.data, graphFilter) }, - queryKey: `${queryKey ? queryKey : title}-${filterName}`, + queryKey: newQueryKey, }); + setCurrentEffectiveQueryKey(newQueryKey); + setActiveFilterName(filterName); // Track active graph filter + if (settings.persistFilters && settings.setLastUsedFilter) { + settings.setLastUsedFilter(pageName, { type: "graph", value: filter, name: filterName }); + } if (filter?.$select) { let selectedColumns = []; if (Array.isArray(filter?.$select)) { selectedColumns = filter?.$select; - } else { - selectedColumns = filter?.$select.split(","); + } else if (typeof filter?.$select === "string") { + selectedColumns = filter.$select.split(","); } - const setNestedVisibility = (col) => { - if (typeof col === "object" && col !== null) { - Object.keys(col).forEach((key) => { - if (usedColumns.includes(key.trim())) { - setColumnVisibility((prev) => ({ ...prev, [key.trim()]: true })); - setNestedVisibility(col[key]); - } - }); - } else { - if (usedColumns.includes(col.trim())) { - setColumnVisibility((prev) => ({ ...prev, [col.trim()]: true })); - } - } - }; if (selectedColumns.length > 0) { setConfiguredSimpleColumns(selectedColumns); selectedColumns.forEach((col) => { @@ -251,6 +535,7 @@ export const CIPPTableToptoolbar = ({ var presetEndpoint = preset?.params?.endpoint?.replace(/^\//, ""); if (presetEndpoint === endpoint) { graphPresetList.push({ + id: preset?.id, filterName: preset?.name, value: preset?.params, type: "graph", @@ -262,6 +547,7 @@ export const CIPPTableToptoolbar = ({ var customPresetEndpoint = preset?.params?.endpoint?.replace(/^\//, ""); if (customPresetEndpoint === endpoint) { graphPresetList.push({ + id: preset?.id, filterName: preset?.name, value: preset?.params, type: "graph", @@ -277,299 +563,643 @@ export const CIPPTableToptoolbar = ({ return ( <> ({ + sx={{ display: "flex", - gap: "0.5rem", - p: "8px", + flexDirection: { xs: "column", md: "row" }, + gap: { xs: 1, md: 2 }, + px: 0.5, + pb: 2, justifyContent: "space-between", - })} + alignItems: { xs: "stretch", md: "center" }, + backgroundColor: "background.paper", + }} > + {/* Left side - Main controls */} - <> - + { + if (typeof refreshFunction === "object") { + refreshFunction.refetch(); + } else if (typeof refreshFunction === "function") { + refreshFunction(); + } else if (data && !getRequestData.isFetched) { + // do nothing because data was sent native. + } else if (getRequestData) { + getRequestData.refetch(); + } + }} + disabled={ + getRequestData?.isLoading || + getRequestData?.isFetching || + refreshFunction?.isFetching } > -
    { - if (typeof refreshFunction === "object") { - refreshFunction.refetch(); - } else if (typeof refreshFunction === "function") { - refreshFunction(); - } else if (data && !getRequestData.isFetched) { - //do nothing because data was sent native. - } else if (getRequestData) { - getRequestData.refetch(); - } + + {getRequestData?.isFetchNextPageError ? ( + + ) : ( + + )} + + + + + {/* Search Input */} + + + + + + {/* Desktop Buttons */} + {!mdDown && ( + <> + {/* Filters Button */} + } + endIcon={} + onClick={(event) => setFiltersAnchor(event.currentTarget)} + sx={{ + color: activeFilterName ? "primary.main" : "text.primary", + borderColor: activeFilterName ? "primary.main" : undefined, + }} + > + Filters + + setFiltersAnchor(null)} + PaperProps={{ + sx: { + mt: 1, + borderRadius: 2, + minWidth: 200, + }, }} > - - - {getRequestData?.isFetchNextPageError ? ( - - ) : ( - - )} - - -
    -
    - - - - - - - - - - setTableFilter("", "reset", "")}> - - - {api?.url === "/api/ListGraphRequest" && ( { - filterPopover.handleClose(); - setFilterCanvasVisible(true); + handleColumnFiltersToggle(); + setFiltersAnchor(null); }} > - + + {table.getState().showColumnFilters + ? "Hide Column Filters" + : "Show Column Filters"} + + + + setTableFilter("", "reset", "")}> + + {api?.url === "/api/ListGraphRequest" && ( + { + setFiltersAnchor(null); + setFilterCanvasVisible(true); + }} + > + + + )} + {filterList?.length > 0 && } + {filterList?.map((filter) => ( + { + setFiltersAnchor(null); + setTableFilter(filter.value, filter.type, filter.filterName); + }} + > + + {activeFilterName === filter.filterName && ( + + )} + {filter.filterName} + + } + /> + + ))} + + + {/* Columns Button */} + } + endIcon={} + onClick={(event) => setColumnsAnchor(event.currentTarget)} + > + Columns + + setColumnsAnchor(null)} + PaperProps={{ + sx: { + mt: 1, + borderRadius: 2, + minWidth: 250, + maxHeight: 400, + }, + }} + > + + + + + + + + + + + {table + .getAllColumns() + .filter((column) => !column.id.startsWith("mrt-")) + .map((column) => ( + + setColumnVisibility({ + ...columnVisibility, + [column.id]: !column.getIsVisible(), + }) + } + > + + + + ))} + + + {/* Export Button */} + {exportEnabled && ( + } + endIcon={} + onClick={(event) => setExportAnchor(event.currentTarget)} + > + Export + )} - - {filterList?.map((filter) => ( + + )} + + {/* Mobile Action Menu */} + setActionMenuAnchor(null)} + PaperProps={{ + sx: { + mt: 1, + borderRadius: 2, + minWidth: 180, + }, + }} + > + { + setFiltersAnchor(event.currentTarget); + setActionMenuAnchor(null); + }} + > + + + + Filters + + { + setColumnsAnchor(event.currentTarget); + setActionMenuAnchor(null); + }} + > + + + + Columns + + {exportEnabled && ( + { + setExportAnchor(event.currentTarget); + setActionMenuAnchor(null); + }} + > + + + + Export + + )} + { + table.setIsFullScreen(!table.getState().isFullScreen); + setActionMenuAnchor(null); + }} + > + + + + + {table.getState().isFullScreen ? "Exit Fullscreen" : "Fullscreen"} + + + + + {/* Filters Menu */} + setFiltersAnchor(null)} + PaperProps={{ + sx: { + mt: 1, + borderRadius: 2, + minWidth: 200, + }, + }} + > + { + handleColumnFiltersToggle(); + setFiltersAnchor(null); + }} + > + + {table.getState().showColumnFilters ? "Hide Column Filters" : "Show Column Filters"} + + + + setTableFilter("", "reset", "")}> + + + {api?.url === "/api/ListGraphRequest" && ( + { + setFiltersAnchor(null); + setFilterCanvasVisible(true); + }} + > + + + )} + {filterList?.length > 0 && } + {filterList?.map((filter) => ( + { + setFiltersAnchor(null); + setTableFilter(filter.value, filter.type, filter.filterName); + }} + > + + {activeFilterName === filter.filterName && ( + + )} + {filter.filterName} + + } + /> + + ))} + + + {/* Columns Menu */} + setColumnsAnchor(null)} + PaperProps={{ + sx: { + mt: 1, + borderRadius: 2, + minWidth: 250, + maxHeight: 400, + }, + }} + > + + + + + + + + + + + {table + .getAllColumns() + .filter((column) => !column.id.startsWith("mrt-")) + .map((column) => ( { - filterPopover.handleClose(); - setTableFilter(filter.value, filter.type, filter.filterName); - }} + key={column.id} + onClick={() => + setColumnVisibility({ + ...columnVisibility, + [column.id]: !column.getIsVisible(), + }) + } > - + + ))} - - - - - - - + + + {/* Export Menu */} + {exportEnabled && ( setExportAnchor(null)} + PaperProps={{ + sx: { + mt: 1, + borderRadius: 2, + minWidth: 180, + }, + }} > - - + { + // Trigger CSV export + const csvButton = document.querySelector("[data-csv-export]"); + if (csvButton) csvButton.click(); + setExportAnchor(null); + }} + > + + + + - - + { + // Trigger PDF export + const pdfButton = document.querySelector("[data-pdf-export]"); + if (pdfButton) pdfButton.click(); + setExportAnchor(null); + }} + > + + + + - - + { + if (isInDialog) { + setJsonDialogOpen(true); + } else { + setOffcanvasVisible(true); + } + setExportAnchor(null); + }} + > + + + + - {table - .getAllColumns() - .filter((column) => !column.id.startsWith("mrt-")) - .map((column) => ( - - setColumnVisibility({ - ...columnVisibility, - [column.id]: !column.getIsVisible(), - }) - } - > - - - - ))} + )} - <> - {exportEnabled && ( - <> - - - - )} - - setOffcanvasVisible(true)}> - - - - {mdDown && ( - - )} - - { - //add a little icon with how many rows are selected - (table.getIsAllRowsSelected() || table.getIsSomeRowsSelected()) && ( - - {table.getSelectedRowModel().rows.length} rows selected - - ) - } - { - setOffcanvasVisible(false); + {/* Mobile Action Menu */} + {mdDown && ( + setActionMenuAnchor(event.currentTarget)} + size="small" + sx={{ + height: "40px", + width: "40px", + border: "1px solid", + borderColor: "divider", + borderRadius: "8px", + ml: "auto", }} > - - API Response - - - - + + + )}
    - - - {getRequestData?.data?.pages?.[0].Metadata?.ColdStart === true && ( - - - - )} - {actions && (table.getIsSomeRowsSelected() || table.getIsAllRowsSelected()) && ( - <> - - - {actions - ?.filter((action) => !action.link && !action?.hideBulk) - .map((action, index) => ( - { - setActionData({ - data: table.getSelectedRowModel().rows.map((row) => row.original), - action: action, - ready: true, - }); - - if (action?.noConfirm && action.customFunction) { - table - .getSelectedRowModel() - .rows.map((row) => - action.customFunction(row.original.original, action, {}) - ); - } else { - createDialog.handleOpen(); - popover.handleClose(); - } - }} - > - - {action.icon} - - {action.label} - - ))} - - + + {/* Right side - Additional controls */} + + {/* Selected rows indicator */} + {(table.getIsAllRowsSelected() || table.getIsSomeRowsSelected()) && ( + + {table.getSelectedRowModel().rows.length} rows selected + + )} + + {/* Bulk Actions - inline with toolbar */} + {actions && + getBulkActions(actions, table.getSelectedRowModel().rows).length > 0 && + (table.getIsSomeRowsSelected() || table.getIsAllRowsSelected()) && ( + )} - + + {/* Cold start indicator */} + {getRequestData?.data?.pages?.[0].Metadata?.ColdStart === true && ( + + + + )} + + {/* Queue tracker */} + - - - {actionData.ready && ( -
    + + {/* Bulk Actions Menu - now inline with toolbar */} + + {actions && + getBulkActions(actions, table.getSelectedRowModel().rows).map((action, index) => ( + { + if (action.disabled) return; + setActionData({ + data: table.getSelectedRowModel().rows.map((row) => row.original), + action: action, + ready: true, + }); + + if (action?.noConfirm && action.customFunction) { + table + .getSelectedRowModel() + .rows.map((row) => action.customFunction(row.original.original, action, {})); + } else { + createDialog.handleOpen(); + popover.handleClose(); + } + }} + > + + {action.icon} + + {action.label} + + ))} + + + {/* API Response Off-Canvas - only show when not in dialog mode */} + {!isInDialog && ( + { + setOffcanvasVisible(false); + }} + > + + API Response + + + + )} + + {/* Action Dialog */} + {actionData.ready && ( + + )} + + {/* Graph Filter Off-Canvas */} { setTableFilter(filter, "graph", "Custom Filter"); if (filter?.$select) { let selectedColumns = []; if (Array.isArray(filter?.$select)) { selectedColumns = filter?.$select; - } else { - selectedColumns = filter?.$select.split(","); + } else if (typeof filter?.$select === "string") { + selectedColumns = filter.$select.split(","); } - const setNestedVisibility = (col) => { - if (typeof col === "object" && col !== null) { - Object.keys(col).forEach((key) => { - if (usedColumns.includes(key.trim())) { - setColumnVisibility((prev) => ({ ...prev, [key.trim()]: true })); - setNestedVisibility(col[key]); - } - }); - } else { - if (usedColumns.includes(col.trim())) { - setColumnVisibility((prev) => ({ ...prev, [col.trim()]: true })); - } - } - }; if (selectedColumns.length > 0) { setConfiguredSimpleColumns(selectedColumns); selectedColumns.forEach((col) => { @@ -615,6 +1232,30 @@ export const CIPPTableToptoolbar = ({ component="card" /> + + {/* JSON Dialog for when in dialog mode */} + {isInDialog && ( + setJsonDialogOpen(false)} + sx={{ zIndex: (theme) => theme.zIndex.modal + 1 }} + > + API Response + + + + + + + + )} ); }; diff --git a/src/components/CippTable/CippDataTable.js b/src/components/CippTable/CippDataTable.js index 6c2269f0f6c1..bdf10dd8e8aa 100644 --- a/src/components/CippTable/CippDataTable.js +++ b/src/components/CippTable/CippDataTable.js @@ -12,10 +12,10 @@ import { import { ResourceUnavailable } from "../resource-unavailable"; import { ResourceError } from "../resource-error"; import { Scrollbar } from "../scrollbar"; -import React, { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ApiGetCallWithPagination } from "../../api/ApiCall"; import { utilTableMode } from "./util-tablemode"; -import { utilColumnsFromAPI } from "./util-columnsFromAPI"; +import { utilColumnsFromAPI, resolveSimpleColumnVariables } from "./util-columnsFromAPI"; import { CIPPTableToptoolbar } from "./CIPPTableToptoolbar"; import { Info, More, MoreHoriz } from "@mui/icons-material"; import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; @@ -26,6 +26,55 @@ import { Box } from "@mui/system"; import { useSettings } from "../../hooks/use-settings"; import { isEqual } from "lodash"; // Import lodash for deep comparison +// Resolve dot-delimited property paths against arbitrary data objects. +const getNestedValue = (source, path) => { + if (!source) { + return undefined; + } + if (!path) { + return source; + } + + return path.split(".").reduce((acc, key) => { + if (acc === undefined || acc === null) { + return undefined; + } + if (typeof acc !== "object") { + return undefined; + } + return acc[key]; + }, source); +}; + +// Resolve dot-delimited column ids against the original row data so nested fields can sort/filter properly. +const getRowValueByColumnId = (row, columnId) => { + if (!row?.original || !columnId) { + return undefined; + } + + if (columnId.includes("@odata")) { + return row.original[columnId]; + } + + return getNestedValue(row.original, columnId); +}; + +const compareNullable = (aVal, bVal) => { + if (aVal === null && bVal === null) { + return 0; + } + if (aVal === null) { + return 1; + } + if (bVal === null) { + return -1; + } + if (aVal === bVal) { + return 0; + } + return aVal > bVal ? 1 : -1; +}; + export const CippDataTable = (props) => { const { queryKey, @@ -56,6 +105,7 @@ export const CippDataTable = (props) => { filters, maxHeightOffset = "380px", defaultSorting = [], + isInDialog = false, } = props; const [columnVisibility, setColumnVisibility] = useState(initialColumnVisibility); const [configuredSimpleColumns, setConfiguredSimpleColumns] = useState(simpleColumns); @@ -63,6 +113,8 @@ export const CippDataTable = (props) => { const [usedColumns, setUsedColumns] = useState([]); const [offcanvasVisible, setOffcanvasVisible] = useState(false); const [offCanvasData, setOffCanvasData] = useState({}); + const [customComponentData, setCustomComponentData] = useState({}); + const [customComponentVisible, setCustomComponentVisible] = useState(false); const [actionData, setActionData] = useState({ data: {}, action: {}, ready: false }); const [graphFilterData, setGraphFilterData] = useState({}); const [sorting, setSorting] = useState([]); @@ -106,22 +158,6 @@ export const CippDataTable = (props) => { useEffect(() => { if (getRequestData.isSuccess) { const allPages = getRequestData.data.pages; - const getNestedValue = (obj, path) => { - if (!path) { - return obj; - } - - const keys = path.split("."); - let result = obj; - for (const key of keys) { - if (result && typeof result === "object" && key in result) { - result = result[key]; - } else { - return undefined; - } - } - return result; - }; const combinedResults = allPages.flatMap((page) => { const nestedData = getNestedValue(page, api.dataKey); @@ -150,15 +186,31 @@ export const CippDataTable = (props) => { let finalColumns = []; let newVisibility = { ...columnVisibility }; + // Check if we're in AllTenants mode and data has Tenant property + const isAllTenants = settings?.currentTenant === "AllTenants"; + const hasTenantProperty = usedData.some( + (row) => row && typeof row === "object" && "Tenant" in row + ); + const shouldShowTenant = isAllTenants && hasTenantProperty; + if (columns.length === 0 && configuredSimpleColumns.length === 0) { finalColumns = apiColumns; apiColumns.forEach((col) => { newVisibility[col.id] = true; }); } else if (configuredSimpleColumns.length > 0) { - finalColumns = apiColumns.map((col) => { - newVisibility[col.id] = configuredSimpleColumns.includes(col.id); - return col; + // Resolve any variables in the simple columns before checking visibility + const resolvedSimpleColumns = resolveSimpleColumnVariables(configuredSimpleColumns, usedData); + + // Add Tenant to resolved columns if in AllTenants mode and not already included + let finalResolvedColumns = [...resolvedSimpleColumns]; + if (shouldShowTenant && !resolvedSimpleColumns.includes("Tenant")) { + finalResolvedColumns = [...resolvedSimpleColumns, "Tenant"]; + } + + finalColumns = apiColumns; + finalColumns.forEach((col) => { + newVisibility[col.id] = finalResolvedColumns.includes(col.id); }); } else { const providedColumnKeys = new Set(columns.map((col) => col.id || col.header)); @@ -166,13 +218,22 @@ export const CippDataTable = (props) => { finalColumns.forEach((col) => { newVisibility[col.accessorKey] = providedColumnKeys.has(col.id); }); + + // Handle Tenant column for custom columns case + if (shouldShowTenant) { + const tenantColumn = finalColumns.find((col) => col.id === "Tenant"); + if (tenantColumn) { + // Make tenant visible + newVisibility["Tenant"] = true; + } + } } if (defaultSorting?.length > 0) { setSorting(defaultSorting); } setUsedColumns(finalColumns); setColumnVisibility(newVisibility); - }, [columns.length, usedData, queryKey]); + }, [columns.length, usedData, queryKey, settings?.currentTenant]); const createDialog = useDialog(); @@ -200,6 +261,20 @@ export const CippDataTable = (props) => { }; const table = useMaterialReactTable({ + muiTableBodyCellProps: { + onCopy: (e) => { + const sel = window.getSelection()?.toString() ?? ""; + if (sel) { + e.preventDefault(); + e.stopPropagation(); + e.nativeEvent?.stopImmediatePropagation?.(); + e.clipboardData.setData("text/plain", sel); + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(sel).catch(() => {}); + } + } + }, + }, mrtTheme: (theme) => ({ baseBackgroundColor: theme.palette.background.paper, }), @@ -210,6 +285,81 @@ export const CippDataTable = (props) => { top: table.getState().isFullScreen ? 64 : undefined, }, }), + // Add global styles to target the specific filter components + enableColumnFilterModes: true, + muiTableHeadCellProps: { + sx: { + // Target the filter row cells + "& .MuiTableCell-root": { + padding: "8px 16px", + }, + // Target the Autocomplete component in filter cells + "& .MuiAutocomplete-root": { + width: "100%", + }, + // Force the tags container to be single line with ellipsis + "& .MuiAutocomplete-root .MuiInputBase-root": { + height: "40px !important", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + display: "flex", + flexWrap: "nowrap", + }, + // Target the tags container specifically + "& .MuiAutocomplete-root .MuiInputBase-root .MuiInputBase-input": { + height: "24px", + minHeight: "24px", + maxHeight: "24px", + }, + // Target regular input fields (not in Autocomplete) + "& .MuiInputBase-root": { + height: "40px !important", + }, + // Ensure all input fields have consistent styling + "& .MuiInputBase-input": { + height: "24px", + minHeight: "24px", + maxHeight: "24px", + }, + // Target the specific chip class mentioned + "& .MuiChip-label.MuiChip-labelMedium": { + maxWidth: "80px", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + padding: "0 4px", + }, + // Make chips smaller overall and add title attribute for tooltip + "& .MuiChip-root": { + height: "24px", + maxHeight: "24px", + // This adds a tooltip effect using the browser's native tooltip + "&::before": { + content: "attr(data-label)", + display: "none", + }, + "&:hover::before": { + display: "block", + position: "absolute", + top: "-25px", + left: "0", + backgroundColor: "rgba(0, 0, 0, 0.8)", + color: "white", + padding: "4px 8px", + borderRadius: "4px", + fontSize: "12px", + whiteSpace: "nowrap", + zIndex: 9999, + }, + }, + }, + }, + // Initialize the filter chips with data attributes for tooltips + initialState: { + columnFilters: columnFilters, + columnVisibility: columnVisibility, + }, columns: memoizedColumns, data: memoizedData ?? [], state: { @@ -248,19 +398,29 @@ export const CippDataTable = (props) => { currentTenant: row.original.Tenant, }); } - setActionData({ - data: row.original, - action: action, - ready: true, - }); + if (action.noConfirm && action.customFunction) { action.customFunction(row.original, action, {}); closeMenu(); return; - } else { - createDialog.handleOpen(); + } + + // Handle custom component differently + if (typeof action.customComponent === 'function') { + setCustomComponentData({ data: row.original, action: action }); + setCustomComponentVisible(true); closeMenu(); + return; } + + // Standard dialog flow + setActionData({ + data: row.original, + action: action, + ready: true, + }); + createDialog.handleOpen(); + closeMenu(); }} disabled={handleActionDisabled(row.original, action)} > @@ -324,6 +484,8 @@ export const CippDataTable = (props) => { graphFilterData={graphFilterData} setGraphFilterData={setGraphFilterData} setConfiguredSimpleColumns={setConfiguredSimpleColumns} + queueMetadata={getRequestData.data?.pages?.[0]?.Metadata} + isInDialog={isInDialog} /> )} @@ -331,18 +493,56 @@ export const CippDataTable = (props) => { }, sortingFns: { dateTimeNullsLast: (a, b, id) => { - const aVal = a?.original?.[id] ?? null; - const bVal = b?.original?.[id] ?? null; - if (aVal === null && bVal === null) { - return 0; - } - if (aVal === null) { - return 1; - } - if (bVal === null) { - return -1; - } - return aVal > bVal ? 1 : -1; + const aRaw = getRowValueByColumnId(a, id); + const bRaw = getRowValueByColumnId(b, id); + const aDate = aRaw ? new Date(aRaw) : null; + const bDate = bRaw ? new Date(bRaw) : null; + const aTime = aDate && !Number.isNaN(aDate.getTime()) ? aDate.getTime() : null; + const bTime = bDate && !Number.isNaN(bDate.getTime()) ? bDate.getTime() : null; + + return compareNullable(aTime, bTime); + }, + number: (a, b, id) => { + const aRaw = getRowValueByColumnId(a, id); + const bRaw = getRowValueByColumnId(b, id); + const aNum = typeof aRaw === "number" ? aRaw : Number(aRaw); + const bNum = typeof bRaw === "number" ? bRaw : Number(bRaw); + const aVal = Number.isNaN(aNum) ? null : aNum; + const bVal = Number.isNaN(bNum) ? null : bNum; + + return compareNullable(aVal, bVal); + }, + boolean: (a, b, id) => { + const aRaw = getRowValueByColumnId(a, id); + const bRaw = getRowValueByColumnId(b, id); + const toBool = (value) => { + if (value === null || value === undefined) { + return null; + } + if (typeof value === "boolean") { + return value; + } + if (typeof value === "string") { + const lower = value.toLowerCase(); + if (lower === "true" || lower === "yes") { + return true; + } + if (lower === "false" || lower === "no") { + return false; + } + } + if (typeof value === "number") { + return value !== 0; + } + return null; + }; + + const aBool = toBool(aRaw); + const bBool = toBool(bRaw); + const aNumeric = aBool === null ? null : aBool ? 1 : 0; + const bNumeric = bBool === null ? null : bBool ? 1 : 0; + + return compareNullable(aNumeric, bNumeric); }, }, filterFns: { @@ -376,6 +576,7 @@ export const CippDataTable = (props) => { } }, }, + globalFilterFn: "contains", enableGlobalFilterModes: true, renderGlobalFilterModeMenuItems: ({ internalFilterOptions, onSelectFilterMode }) => { // add custom filter options @@ -448,14 +649,14 @@ export const CippDataTable = (props) => { if (filters && Array.isArray(filters) && filters.length > 0 && memoizedColumns.length > 0) { // Make sure the table and columns are ready setTimeout(() => { - if (table && typeof table.setColumnFilters === 'function') { - const formattedFilters = filters.map(filter => ({ + if (table && typeof table.setColumnFilters === "function") { + const formattedFilters = filters.map((filter) => ({ id: filter.id || filter.columnId, - value: filter.value + value: filter.value, })); table.setColumnFilters(formattedFilters); } - },); + }); } }, [filters, memoizedColumns, table]); @@ -497,7 +698,11 @@ export const CippDataTable = (props) => { {cardButton || !hideTitle ? ( <> - + ) : null} @@ -539,8 +744,20 @@ export const CippDataTable = (props) => { customComponent={offCanvas?.customComponent} {...offCanvas} /> + {/* Render custom component */} + {customComponentVisible && + customComponentData?.action && + typeof customComponentData.action.customComponent === 'function' && + customComponentData.action.customComponent(customComponentData.data, { + drawerVisible: customComponentVisible, + setDrawerVisible: setCustomComponentVisible, + fromRowAction: true, + }) + } + + {/* Render standard dialog */} {useMemo(() => { - if (!actionData.ready) return null; + if (!actionData.ready || (actionData.action && typeof actionData.action.customComponent === 'function')) return null; return ( { const [openDialogs, setOpenDialogs] = useState([]); @@ -59,6 +58,7 @@ const CippDataTableButton = ({ data, title, tableTitle = "Data" }) => { title={tableTitle} data={dialogData} simple={false} + isInDialog={true} />
    diff --git a/src/components/CippTable/CippGraphExplorerFilter.js b/src/components/CippTable/CippGraphExplorerFilter.js index 1b77a2a55216..ad1315667b35 100644 --- a/src/components/CippTable/CippGraphExplorerFilter.js +++ b/src/components/CippTable/CippGraphExplorerFilter.js @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useCallback } from "react"; -import { Button, Typography } from "@mui/material"; +import { useState, useEffect, useCallback } from "react"; +import { Button, Link, Typography } from "@mui/material"; import { Save as SaveIcon, Delete, @@ -28,6 +28,7 @@ const CippGraphExplorerFilter = ({ onSubmitFilter, onPresetChange, component = "accordion", + relatedQueryKeys = [], }) => { const [offCanvasOpen, setOffCanvasOpen] = useState(false); const [cardExpanded, setCardExpanded] = useState(true); @@ -162,7 +163,7 @@ const CippGraphExplorerFilter = ({ }, [currentEndpoint, debouncedRefetch]); const savePresetApi = ApiPostCall({ - relatedQueryKeys: ["ListGraphExplorerPresets", "ListGraphRequest"], + relatedQueryKeys: ["ListGraphExplorerPresets*", "ListGraphRequest", ...relatedQueryKeys], }); // Save preset function @@ -397,6 +398,9 @@ const CippGraphExplorerFilter = ({ Import / Export Graph Explorer Preset + + Copy the JSON below to export your preset, or paste a preset JSON to import it. + setEditorValues(JSON.parse(value))} @@ -411,6 +415,7 @@ const CippGraphExplorerFilter = ({ }} variant="contained" color="primary" + sx={{ mt: 2 }} > Import Template @@ -541,7 +546,7 @@ const CippGraphExplorerFilter = ({ } > - + )} placeholder="Select a preset" + helperText="Select an existing preset to load its parameters" /> {/* Preset Name Field */} - + - + + The{" "} + + Graph endpoint + {" "} + to query (e.g. https://graph.microsoft.com/beta/$Endpoint) + + } /> - + {/* Filter Field */} - + + Graph $filter query + + } /> {/* Expand Field */} - + {/* Top Field */} - + {/* Search Field */} - + {/* Format Field */} - + {/* Reverse Tenant Lookup Switch */} - + {/* Reverse Tenant Lookup Property Field */} - + {/* No Pagination Switch */} - + {/* $count Switch */} - + {/* AsApp switch */} - + { + const queryClient = useQueryClient(); + const [queueCanvasVisible, setQueueCanvasVisible] = useState(false); + const [persistentQueueData, setPersistentQueueData] = useState(null); + const [lastProcessedQueueId, setLastProcessedQueueId] = useState(null); + const [queueQueryKey, setQueueQueryKey] = useState(null); + const [hasAutoRefreshed, setHasAutoRefreshed] = useState(false); + + const hasQueueData = !!queueId; + const currentQueryKey = queryKey || title; + + // Show queue if we have current queue data OR persistent queue data from the same query key + // If query key changed and we don't have an active queueId, don't show the tracker + const shouldShowQueue = + hasQueueData || (!!persistentQueueData && queueQueryKey === currentQueryKey); + + // Check if queue is in a completed state based on persistent data only (to avoid circular dependency) + const isQueueCompleted = + persistentQueueData?.Status === "Completed" || + persistentQueueData?.Status === "Failed" || + persistentQueueData?.Status === "Completed (with errors)"; + + const effectiveQueueId = queueId || lastProcessedQueueId; + + const queuePolling = ApiGetCall({ + url: `/api/ListCippQueue`, + data: { QueueId: effectiveQueueId }, + queryKey: `CippQueue-${effectiveQueueId || "unknown"}`, + waiting: shouldShowQueue && !!effectiveQueueId && !isQueueCompleted, + refetchInterval: (data) => { + // Check if the current data shows completion + const currentData = data?.[0]; + const isCurrentCompleted = + currentData?.Status === "Completed" || + currentData?.Status === "Failed" || + currentData?.Status === "Completed (with errors)"; + + // Also check persistent data + const isPersistentCompleted = + persistentQueueData?.Status === "Completed" || + persistentQueueData?.Status === "Failed" || + persistentQueueData?.Status === "Completed (with errors)"; + + // Stop polling if either shows completion + if (isCurrentCompleted || isPersistentCompleted || !shouldShowQueue || !effectiveQueueId) { + return false; + } + + return 3000; + }, + refetchOnMount: true, + refetchOnWindowFocus: false, + }); + + const queueData = queuePolling.data?.[0]; + + // Handle queue data persistence - only update persistent queue data when we get a new QueueId + // and ensure it's pinned to the current query key + useEffect(() => { + const currentQueryKey = queryKey || title; + + // If query key changed, clear all queue data + if (queueQueryKey && queueQueryKey !== currentQueryKey) { + setPersistentQueueData(null); + setLastProcessedQueueId(null); + setQueueQueryKey(currentQueryKey); + setHasAutoRefreshed(false); + return; + } + + // Set query key if not set + if (!queueQueryKey) { + setQueueQueryKey(currentQueryKey); + } + + // Only process new QueueId if we actually have one and it's different + if (queueId && queueId !== lastProcessedQueueId) { + // New QueueId detected, clear old persistent data and set new QueueId + setPersistentQueueData(null); + setLastProcessedQueueId(queueId); + setHasAutoRefreshed(false); // Reset auto-refresh flag for new queue + } + + // Don't clear persistent data if queueId is temporarily null (during table refresh) + // Only clear if we explicitly get a different QueueId or change query/page + }, [queueId, lastProcessedQueueId, queryKey, title, queueQueryKey]); + + // Update persistent queue data when new queue data is available + useEffect(() => { + const currentQueryKey = queryKey || title; + + // Only update if we're on the same query key where the queue was initiated + if (queueData && queueId === lastProcessedQueueId && queueQueryKey === currentQueryKey) { + setPersistentQueueData(queueData); + } + }, [queueData, queueId, lastProcessedQueueId, queryKey, title, queueQueryKey]); + + // Auto-refresh table when queue reaches 100% completion + useEffect(() => { + const currentQueryKey = queryKey || title; + + // Only auto-refresh if we're on the same query key where the queue was initiated + // and we haven't already auto-refreshed for this queue completion + if ( + !hasAutoRefreshed && + (persistentQueueData?.Status === "Completed" || + persistentQueueData?.Status === "Failed" || + persistentQueueData?.Status === "Completed (with errors)") && + queueQueryKey === currentQueryKey + ) { + // Queue is complete, invalidate the table query to refresh data + if (currentQueryKey) { + queryClient.invalidateQueries({ queryKey: [currentQueryKey] }); + setHasAutoRefreshed(true); // Mark that we've auto-refreshed + // Call callback if provided + if (onQueueComplete) { + onQueueComplete(); + } + } + } + }, [ + hasAutoRefreshed, + persistentQueueData?.PercentComplete, + persistentQueueData?.Status, + queryKey, + title, + queryClient, + queueQueryKey, + onQueueComplete, + ]); + + // Don't render anything if we don't have queue data to show + // Check for valid queueId or persistent queue data + if (!shouldShowQueue || (!queueId && !lastProcessedQueueId && !persistentQueueData)) { + return null; + } + + return ( + <> + + + ) : (persistentQueueData || queueData)?.Status === "Completed (with errors)" ? ( + + ) : (persistentQueueData || queueData)?.Status === "Failed" ? ( + + ) : (persistentQueueData || queueData)?.RunningTasks > 0 ? ( + + ) : ( + + ) + } + overlap="circular" + anchorOrigin={{ + vertical: "top", + horizontal: "right", + }} + > + setQueueCanvasVisible(true)} + sx={{ + animation: + (persistentQueueData || queueData)?.Status !== "Completed" && + (persistentQueueData || queueData)?.Status !== "Completed (with errors)" && + (persistentQueueData || queueData)?.Status !== "Failed" + ? "pulse 2s infinite" + : "none", + "@keyframes pulse": { + "0%": { + transform: "scale(1)", + opacity: 1, + }, + "50%": { + transform: "scale(1.1)", + opacity: 0.8, + }, + "100%": { + transform: "scale(1)", + opacity: 1, + }, + }, + color: + (persistentQueueData || queueData)?.Status === "Completed" + ? "success.main" + : (persistentQueueData || queueData)?.Status === "Completed (with errors)" + ? "warning.main" + : (persistentQueueData || queueData)?.Status === "Failed" + ? "error.main" + : (persistentQueueData || queueData)?.RunningTasks > 0 + ? "warning.main" + : "primary.main", + }} + > + + + + + + {/* Queue Status OffCanvas */} + setQueueCanvasVisible(false)} + > + + {persistentQueueData || queueData ? ( + <> + {(persistentQueueData || queueData).Name} + + + + Progress: {(persistentQueueData || queueData).PercentComplete?.toFixed(1)}% + complete + + + + + + + Total Tasks: {(persistentQueueData || queueData).TotalTasks || 0} + + + Completed:{" "} + {(persistentQueueData || queueData).CompletedTasks || 0} + + + Running: {(persistentQueueData || queueData).RunningTasks || 0} + + + Failed: {(persistentQueueData || queueData).FailedTasks || 0} + + + + + Status: {(persistentQueueData || queueData).Status} + + + {(persistentQueueData || queueData).Tasks && + (persistentQueueData || queueData).Tasks.length > 0 && ( + <> + + Task Details + + + + theme.palette.mode === "dark" + ? "rgba(255,255,255,0.1)" + : "rgba(0,0,0,0.1)", + borderRadius: 4, + }, + "&::-webkit-scrollbar-thumb": { + backgroundColor: (theme) => + theme.palette.mode === "dark" + ? "rgba(255,255,255,0.3)" + : "rgba(0,0,0,0.3)", + borderRadius: 4, + "&:hover": { + backgroundColor: (theme) => + theme.palette.mode === "dark" + ? "rgba(255,255,255,0.5)" + : "rgba(0,0,0,0.5)", + }, + }, + }} + > + {(persistentQueueData || queueData).Tasks.map((task, index) => ( + ({ + p: 2, + border: 1, + borderColor: + theme.palette.mode === "dark" + ? "rgba(255,255,255,0.12)" + : "divider", + borderRadius: 1, + backgroundColor: + task.Status === "Completed" + ? theme.palette.mode === "dark" + ? "rgba(102, 187, 106, 0.15)" + : "success.light" + : task.Status === "Failed" + ? theme.palette.mode === "dark" + ? "rgba(244, 67, 54, 0.15)" + : "error.light" + : task.Status === "Running" + ? theme.palette.mode === "dark" + ? "rgba(255, 152, 0, 0.15)" + : "warning.light" + : theme.palette.mode === "dark" + ? "rgba(255,255,255,0.05)" + : "grey.100", + transition: "all 0.2s ease-in-out", + "&:hover": { + transform: "translateY(-1px)", + boxShadow: + theme.palette.mode === "dark" + ? "0 4px 8px rgba(0,0,0,0.3)" + : "0 4px 8px rgba(0,0,0,0.1)", + }, + })} + > + + + {task.Name} + + ({ + px: 1.5, + py: 0.5, + borderRadius: 2, + backgroundColor: + theme.palette.mode === "dark" + ? "rgba(255,255,255,0.1)" + : "background.paper", + border: + theme.palette.mode === "dark" + ? "1px solid rgba(255,255,255,0.2)" + : "none", + fontWeight: "medium", + textTransform: "uppercase", + fontSize: "0.7rem", + letterSpacing: "0.5px", + color: + task.Status === "Completed" + ? "success.main" + : task.Status === "Failed" + ? "error.main" + : task.Status === "Running" + ? "warning.main" + : "text.secondary", + })} + > + {task.Status} + + + {task.Timestamp && ( + + {new Date(task.Timestamp).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + })}{" "} + {new Date(task.Timestamp).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + })} + + )} + + ))} + + + + )} + + ) : queuePolling.isLoading ? ( + Loading queue data... + ) : queuePolling.isError ? ( + + Error loading queue data: {queuePolling.error?.message} + + ) : ( + No queue data available + )} + + + + ); +}; diff --git a/src/components/CippTable/util-columnsFromAPI.js b/src/components/CippTable/util-columnsFromAPI.js index 058db11048d3..8d93ccc19c18 100644 --- a/src/components/CippTable/util-columnsFromAPI.js +++ b/src/components/CippTable/util-columnsFromAPI.js @@ -2,11 +2,48 @@ import { getCippFilterVariant } from "../../utils/get-cipp-filter-variant"; import { getCippFormatting } from "../../utils/get-cipp-formatting"; import { getCippTranslation } from "../../utils/get-cipp-translation"; -const skipRecursion = ["location", "ScheduledBackupValues"]; +const skipRecursion = ["location", "ScheduledBackupValues", "Tenant"]; + +// Variable replacement patterns - maps variable names to property patterns +const variableReplacements = { + cippuserschema: (dataSample) => { + // Find the first property that contains "_cippUser" + const cippUserProp = Object.keys(dataSample).find((key) => key.includes("_cippUser")); + return cippUserProp || "cippuserschema"; // fallback to original if not found + }, +}; + +// Function to resolve variable replacements in column names +const resolveVariables = (columnName, dataSample) => { + return columnName.replace(/%(\w+)%/g, (match, variableName) => { + const resolver = variableReplacements[variableName.toLowerCase()]; + if (resolver && typeof resolver === "function") { + const resolved = resolver(dataSample); + console.log("resolving " + match + " to " + resolved); + return resolved; + } + return match; // return original if no resolver found + }); +}; + +const getAtPath = (obj, path) => { + const parts = path.split("."); + return parts.reduce((acc, part) => { + if (acc && typeof acc === "object") { + return acc[part]; + } + return undefined; + }, obj); +}; + // Function to merge keys from all objects in the array const mergeKeys = (dataArray) => { return dataArray.reduce((acc, item) => { const mergeRecursive = (obj, base = {}) => { + // Add null/undefined check before calling Object.keys + if (!obj || typeof obj !== 'object') { + return base; + } Object.keys(obj).forEach((key) => { if ( typeof obj[key] === "object" && @@ -14,37 +51,42 @@ const mergeKeys = (dataArray) => { !Array.isArray(obj[key]) && !skipRecursion.includes(key) ) { - if (typeof base[key] === "boolean") { - // Skip merging if base[key] is a boolean - return; - } - if (typeof base[key] !== "object" || Array.isArray(base[key])) { - // Re-initialize base[key] if it's not an object - base[key] = {}; - } + if (typeof base[key] === "boolean") return; // don't merge into a boolean + if (typeof base[key] !== "object" || Array.isArray(base[key])) base[key] = {}; base[key] = mergeRecursive(obj[key], base[key]); } else if (typeof obj[key] === "boolean") { base[key] = obj[key]; } else if (typeof obj[key] === "string" && obj[key].toUpperCase() === "FAILED") { - base[key] = base[key]; // Keep existing value if it's 'FAILED' + // keep existing value if it's 'FAILED' + base[key] = base[key]; } else if (obj[key] !== undefined && obj[key] !== null) { - base[key] = obj[key]; // Assign valid primitive values + base[key] = obj[key]; } }); return base; }; + // Add null/undefined check before calling mergeRecursive + if (!item || typeof item !== 'object') { + return acc; + } return mergeRecursive(item, acc); }, {}); }; export const utilColumnsFromAPI = (dataArray) => { + // Add safety check for dataArray + if (!dataArray || !Array.isArray(dataArray) || dataArray.length === 0) { + return []; + } + const dataSample = mergeKeys(dataArray); const generateColumns = (obj, parentKey = "") => { return Object.keys(obj) .map((key) => { const accessorKey = parentKey ? `${parentKey}.${key}` : key; + if ( typeof obj[key] === "object" && obj[key] !== null && @@ -54,32 +96,58 @@ export const utilColumnsFromAPI = (dataArray) => { return generateColumns(obj[key], accessorKey); } - return { + // Build a value resolver usable by both accessorFn/Cell and the filter util + const resolveValue = (rowLike) => + accessorKey.includes("@odata") ? rowLike?.[accessorKey] : getAtPath(rowLike, accessorKey); + + // Pre-compute some sample values for filter heuristics (optional) + const valuesForColumn = (Array.isArray(dataArray) ? dataArray : []) + .map((r) => resolveValue(r)) + .filter((v) => v !== undefined && v !== null); + + const sampleValue = valuesForColumn.length ? valuesForColumn[0] : undefined; + + const column = { header: getCippTranslation(accessorKey), id: accessorKey, accessorFn: (row) => { - let value; - if (accessorKey.includes("@odata")) { - value = row[accessorKey]; - } else { - value = accessorKey.split(".").reduce((acc, part) => acc && acc[part], row); - } + const value = resolveValue(row); return getCippFormatting(value, accessorKey, "text"); }, - ...getCippFilterVariant(key), + ...getCippFilterVariant(accessorKey, { + sampleValue, + values: valuesForColumn, + getValue: (row) => resolveValue(row), + dataArray: dataArray, // Pass the full data array for processing if needed + }), Cell: ({ row }) => { - let value; - if (accessorKey.includes("@odata")) { - value = row.original[accessorKey]; - } else { - value = accessorKey.split(".").reduce((acc, part) => acc && acc[part], row.original); - } + const value = resolveValue(row.original); return getCippFormatting(value, accessorKey); }, }; + + return column; }) .flat(); }; return generateColumns(dataSample); }; + +// Helper function to resolve variables in simple column names +export const resolveSimpleColumnVariables = (simpleColumns, dataArray) => { + if (!simpleColumns || !Array.isArray(dataArray) || dataArray.length === 0) { + return simpleColumns; + } + + const dataSample = mergeKeys(dataArray); + + return simpleColumns.map((columnName) => { + if (typeof columnName === "string" && columnName.includes("%")) { + const resolved = resolveVariables(columnName, dataSample); + console.log(`Resolving simple column: ${columnName} -> ${resolved}`); + return resolved; + } + return columnName; + }); +}; diff --git a/src/components/CippTable/util-tablemode.js b/src/components/CippTable/util-tablemode.js index 17cd92006a7e..3914ee4ebd6a 100644 --- a/src/components/CippTable/util-tablemode.js +++ b/src/components/CippTable/util-tablemode.js @@ -53,7 +53,6 @@ export const utilTableMode = ( enableStickyHeader: true, selectAllMode: "all", enableColumnPinning: true, - enableStickyHeader: true, muiPaginationProps: { rowsPerPageOptions: [25, 50, 100, 250, 500], }, diff --git a/src/components/CippWizard/CIPPDeploymentStep.js b/src/components/CippWizard/CIPPDeploymentStep.js deleted file mode 100644 index 070b38811dbf..000000000000 --- a/src/components/CippWizard/CIPPDeploymentStep.js +++ /dev/null @@ -1,379 +0,0 @@ -import { useEffect, useState } from "react"; -import { - Alert, - Button, - Grid, - Link, - Stack, - Typography, - Skeleton, - Box, - CircularProgress, - SvgIcon, -} from "@mui/material"; -import CippFormComponent from "../CippComponents/CippFormComponent"; -import { CippWizardStepButtons } from "./CippWizardStepButtons"; -import { ApiGetCall } from "../../api/ApiCall"; -import CippButtonCard from "../CippCards/CippButtonCard"; -import { CippCopyToClipBoard } from "../CippComponents/CippCopyToClipboard"; -import { CheckCircle, OpenInNew, Sync } from "@mui/icons-material"; -import CippPermissionCheck from "../CippSettings/CippPermissionCheck"; -import { useQueryClient } from "@tanstack/react-query"; -import { CippApiResults } from "../CippComponents/CippApiResults"; - -export const CippDeploymentStep = (props) => { - const queryClient = useQueryClient(); - const { formControl, onPreviousStep, onNextStep, currentStep } = props; - const values = formControl.getValues(); - - const [currentStepState, setCurrentStepState] = useState(1); - const [pollingStep, setPollingStep] = useState(1); - const [approvalUrl, setApprovalUrl] = useState(true); - - const startSetupApi = ApiGetCall({ - url: "/api/ExecSAMSetup?CreateSAM=true&partnersetup=true", - queryKey: "startSAMSetup", - }); - - const checkSetupStatusApi = ApiGetCall({ - url: `/api/ExecSAMSetup?CheckSetupProcess=true&step=${pollingStep}`, - queryKey: `checkSetupStep${pollingStep}`, - waiting: !pollingStep, - }); - const appId = ApiGetCall({ - url: `/api/ExecListAppId`, - queryKey: `ExecListAppId`, - waiting: true, - }); - useEffect(() => { - if ( - startSetupApi.data && - startSetupApi.data.step === 1 && - values.selectedOption === "CreateApp" - ) { - formControl.register("wizardStatus", { - required: true, - }); - formControl.setValue("noSubmitButton", true); - setPollingStep(1); - setCurrentStepState(1); - } - }, [startSetupApi.data]); - - useEffect(() => { - if (pollingStep && values.selectedOption === "CreateApp") { - const intervalId = setInterval(() => { - if (!checkSetupStatusApi.isFetching) { - checkSetupStatusApi.refetch(); - } - }, 5000); - return () => clearInterval(intervalId); - } - }, [pollingStep, checkSetupStatusApi]); - - useEffect(() => { - if (checkSetupStatusApi.data) { - const { step, message, url, code } = checkSetupStatusApi.data; - if (url) { - setApprovalUrl(url); - } - if (step === 2) { - setCurrentStepState(2); - setPollingStep(2); - } else if (step >= 3) { - setCurrentStepState(4); - setPollingStep(null); - formControl.setValue( - "wizardStatus", - "You've executed the Setup Wizard. You may now navigate away from this wizard." - ); - formControl.trigger(); - } - } - }, [checkSetupStatusApi.data, currentStepState]); - - const openPopup = (url) => { - const width = 500; - const height = 500; - const left = window.screen.width / 2 - width / 2; - const top = window.screen.height / 2 - height / 2; - window.open(url, "_blank", `width=${width},height=${height},left=${left},top=${top}`); - }; - return ( - - - {values.selectedOption === "CreateApp" && ( - <> - - To run this setup you will need the following prerequisites: -
  • - A CIPP Service Account. For more information on how to create a service account, - click{" "} - - here - -
  • -
  • (Temporary) Global Administrator permissions for the CIPP Service Account
  • -
  • - Multi-factor authentication enabled for the CIPP Service Account, with no trusted - locations or other exclusions. -
  • -
    - {currentStepState >= 1 && ( - - Step 1: Create Application - - {currentStepState <= 1 ? ( - - ) : ( - - - - )} - -
    - } - variant="outlined" - isFetching={startSetupApi.isLoading} - CardButton={ - - } - > - - Click the button below and enter the provided code. This creates the CIPP - Application Registration in your tenant that allows you to access the Graph API. - Login using your CIPP Service Account. - - {startSetupApi.isLoading ? ( - - ) : ( - - )} - - )} - {currentStepState >= 2 && ( - - Step 2: Approve Permissions - - {currentStepState <= 2 ? ( - - ) : ( - - - - )} - -
    - } - CardButton={ - - } - > - - Step 2: Approvals Required - - - Please open the link below and provide the required approval, this allows the app - specific permissions shown in the next screen. Login using your CIPP Service - Account. - - - )} - - {/* Final Step 4 Card */} - {currentStepState >= 4 && } - - - - - - - - - )} - - {values.selectedOption === "UpdateTokens" && ( - - Update Tokens - - {appId.isLoading ? ( - - ) : ( - - - - )} - - - } - CardButton={ - <> - - - {!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test( - appId?.data?.applicationId - ) && ( - - The Application ID is not valid. Please return to the first page of the SAM - wizard and use the Manual . - - )} - - } - > - - Click the button below to refresh your token. - - {formControl.setValue("noSubmitButton", true)} - - - )} - - {values.selectedOption === "Manual" && ( - <> - {formControl.setValue("setKeys", true)} - - You may enter your secrets below. Leave fields blank to retain existing values. - - { - const guidRegex = - /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; - return value === "" || guidRegex.test(value) || "Invalid Tenant ID"; - }, - }} - /> - { - const guidRegex = - /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; - return value === "" || guidRegex.test(value) || "Invalid Application ID"; - }, - }} - /> - { - const secretRegex = /^(?!^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$)[A-Za-z0-9-_~.]{20,}$/; - return ( - value === "" || - secretRegex.test(value) || - "This should be the secret value, not the secret ID" - ); - }, - }} - /> - { - const jwtRegex = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/; - return value === "" || jwtRegex.test(value) || "Invalid Refresh Token"; - }, - }} - /> - - )} - - - - ); -}; diff --git a/src/components/CippWizard/CIPPDeploymentStep.jsx b/src/components/CippWizard/CIPPDeploymentStep.jsx new file mode 100644 index 000000000000..7a6553b9323c --- /dev/null +++ b/src/components/CippWizard/CIPPDeploymentStep.jsx @@ -0,0 +1,101 @@ +import { useEffect } from "react"; +import { Stack, Typography } from "@mui/material"; +import CippFormComponent from "../CippComponents/CippFormComponent"; +import { CippWizardStepButtons } from "./CippWizardStepButtons"; +import { CIPPDeploymentUpdateTokens } from "./CIPPDeploymentUpdateTokens"; + +export const CippDeploymentStep = (props) => { + const { formControl, onPreviousStep, onNextStep, currentStep } = props; + const values = formControl.getValues(); + + // Use useEffect to set form values instead of doing it during render + useEffect(() => { + if (values.selectedOption === "Manual") { + formControl.setValue("setKeys", true); + } + }, [values.selectedOption, formControl]); + + return ( + + + {values.selectedOption === "UpdateTokens" && ( + + )} + + {values.selectedOption === "Manual" && ( + <> + + You may enter your secrets below. Leave fields blank to retain existing values. + + { + const guidRegex = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + return value === "" || guidRegex.test(value) || "Invalid Tenant ID"; + }, + }} + /> + { + const guidRegex = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + return value === "" || guidRegex.test(value) || "Invalid Application ID"; + }, + }} + /> + { + const secretRegex = + /^(?!^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$)[A-Za-z0-9-_~.]{20,}$/; + return ( + value === "" || + secretRegex.test(value) || + "This should be the secret value, not the secret ID" + ); + }, + }} + /> + { + const jwtRegex = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/; + return value === "" || jwtRegex.test(value) || "Invalid Refresh Token"; + }, + }} + /> + + )} + + + + ); +}; diff --git a/src/components/CippWizard/CIPPDeploymentUpdateTokens.jsx b/src/components/CippWizard/CIPPDeploymentUpdateTokens.jsx new file mode 100644 index 000000000000..50fef63317f2 --- /dev/null +++ b/src/components/CippWizard/CIPPDeploymentUpdateTokens.jsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import { Stack, Typography, CircularProgress, SvgIcon, Box } from "@mui/material"; +import { CheckCircle } from "@mui/icons-material"; +import CippButtonCard from "../CippCards/CippButtonCard"; +import { ApiGetCall } from "../../api/ApiCall"; +import { CippApiResults } from "../CippComponents/CippApiResults"; +import { CIPPM365OAuthButton } from "../CippComponents/CIPPM365OAuthButton"; + +export const CIPPDeploymentUpdateTokens = ({ formControl }) => { + const [tokens, setTokens] = useState(null); + + // Get application ID information for the card header + const appId = ApiGetCall({ + url: `/api/ExecListAppId`, + queryKey: `ExecListAppId`, + waiting: true, + }); + + // Handle successful authentication + const handleAuthSuccess = (tokenData) => { + setTokens(tokenData); + }; + + return ( + + + Update Tokens + + {appId.isLoading ? ( + + ) : ( + + + + )} + + + } + CardButton={ + + } + > + + Click the button to refresh the Graph token for your tenants using popup authentication. + This method opens a popup window where you can sign in to your Microsoft account. + + + + + ); +}; + +export default CIPPDeploymentUpdateTokens; diff --git a/src/components/CippWizard/CippAddTenantForm.jsx b/src/components/CippWizard/CippAddTenantForm.jsx index a23291dd54c7..2e302bb02944 100644 --- a/src/components/CippWizard/CippAddTenantForm.jsx +++ b/src/components/CippWizard/CippAddTenantForm.jsx @@ -182,13 +182,13 @@ export const CippAddTenantForm = (props) => { {field.type === "header" ? ( <> - + {field.label} ) : ( - + )} diff --git a/src/components/CippWizard/CippAlertsStep.jsx b/src/components/CippWizard/CippAlertsStep.jsx new file mode 100644 index 000000000000..ba4e62c7f9f5 --- /dev/null +++ b/src/components/CippWizard/CippAlertsStep.jsx @@ -0,0 +1,89 @@ +import { Alert, Stack, Typography } from "@mui/material"; +import { CippWizardStepButtons } from "./CippWizardStepButtons"; + +export const CippAlertsStep = (props) => { + const { formControl, onPreviousStep, onNextStep, currentStep } = props; + + const postExecutionOptions = [ + { label: "Webhook", value: "Webhook" }, + { label: "Email", value: "Email" }, + { label: "PSA", value: "PSA" }, + ]; + + const recurrenceOptions = [ + { value: "30m", label: "Every 30 minutes" }, + { value: "1h", label: "Every hour" }, + { value: "4h", label: "Every 4 hours" }, + { value: "1d", label: "Every 1 day" }, + { value: "7d", label: "Every 7 days" }, + { value: "30d", label: "Every 30 days" }, + { value: "365d", label: "Every 365 days" }, + ]; + + return ( + + + Almost done + + + There's a couple more things that you can configure outside of the wizard, let's list + some of them; + +
      +
    • + CIPP has the ability to send alerts to your PSA, Webhook or Email. You can configure + these settings under > Tenant Administration > Alert Configuration. +
    • +
    +
      +
    • + If you imported baselines, or want to set tenants to your own baseline, you should + check out our standards under these settings under > Tenant Administration > + Standards. +
    • +
    +
      +
    • + If you want to use our integrations, you should set these up under > CIPP > + Integrations. Some examples are CSP integrations, Password Pusher, PSA, and more. +
    • +
    +
      +
    • + Adding more users to CIPP? you can do this via CIPP > Advanced > Super Admin. +
    • +
    +
      +
    • + You can deploy Windows Applications too, directly using intune. We have Chocolately, + WinGet, and RMM apps under > Intune > Applications. Some examples are CSP + integrations, Password Pusher, PSA, and more. +
    • +
    +
      +
    • + Tenants can be grouped, and you can implement custom variables for your tenants under + WinGet, and RMM apps under Tenant Administrator > Administration > Tenants. +
    • +
    +
      +
    • + Have an enterprise app you want to deploy? Check out our tools{" "} + section. This menu also contains useful things such as our geo-ip lookup, and more. +
    • +
    +
    +
    + + +
    + ); +}; + +export default CippAlertsStep; diff --git a/src/components/CippWizard/CippBaselinesStep.jsx b/src/components/CippWizard/CippBaselinesStep.jsx new file mode 100644 index 000000000000..0343e8a33f1c --- /dev/null +++ b/src/components/CippWizard/CippBaselinesStep.jsx @@ -0,0 +1,105 @@ +import { Alert, Stack, Typography, FormControl, FormLabel, Box } from "@mui/material"; +import CippFormComponent from "../CippComponents/CippFormComponent"; +import { CippWizardStepButtons } from "./CippWizardStepButtons"; +import { CippFormCondition } from "../CippComponents/CippFormCondition"; + +export const CippBaselinesStep = (props) => { + const { formControl, onPreviousStep, onNextStep, currentStep } = props; + + return ( + + + + + Baselines are template configurations that can be used as examples for setting up your + environment. Don't want to configure these yet? No problem! You can find the templates + at Tools - Community Repositories + + + Downloading these baselines will create templates in your CIPP instance. These templates + won't make any changes to your environment, but can be used as examples on how to setup + environments. Each template library contains multiple templates, +
      +
    • + CIPP Templates by CyberDrain contain several example standards, including low, + medium, and high priority standards +
    • +
    • + JoeyV's Conditional Access Baseline contains a Microsoft approved baseline for + Conditional Access, following the Microsoft best practices. +
    • +
    • + OpenIntuneBaseline contains Intune templates, the baseline is a community driven + baseline for Intune, based on CIS, NIST, and more benchmarks. It's considered the + leading baseline for Intune. +
    • +
    +
    +
    + + + Baseline Configuration + + + + + + + + + Select baselines to download: + + `${option.Name} (${option.Owner})`, + valueField: "FullName", + addedFields: { + templateRepoBranch: "main", + }, + }} + multiple={true} + placeholder="Select one or more baselines" + /> + + +
    + + +
    + ); +}; + +export default CippBaselinesStep; diff --git a/src/components/CippWizard/CippCAForm.jsx b/src/components/CippWizard/CippCAForm.jsx deleted file mode 100644 index b99d69c8e55c..000000000000 --- a/src/components/CippWizard/CippCAForm.jsx +++ /dev/null @@ -1,96 +0,0 @@ -import { Grid, Stack } from "@mui/material"; -import { CippWizardStepButtons } from "./CippWizardStepButtons"; -import CippJsonView from "../CippFormPages/CippJSONView"; -import CippFormComponent from "../CippComponents/CippFormComponent"; -import { ApiGetCall } from "../../api/ApiCall"; -import { useEffect, useState } from "react"; -import { useWatch } from "react-hook-form"; - -export const CippCAForm = (props) => { - const { formControl, onPreviousStep, onNextStep, currentStep } = props; - const values = formControl.getValues(); - const CATemplates = ApiGetCall({ url: "/api/ListCATemplates" }); - const [JSONData, setJSONData] = useState(); - const watcher = useWatch({ control: formControl.control, name: "TemplateList" }); - useEffect(() => { - if (CATemplates.isSuccess && watcher?.value) { - const template = CATemplates.data.find((template) => template.GUID === watcher.value); - if (template) { - setJSONData(template); - formControl.setValue("rawjson", JSON.stringify(template, null)); - } - } - }, [CATemplates, watcher]); - - return ( - - - ({ - label: template.displayName, - value: template.GUID, - })) - : [] - } - /> - - - - - - - - - - - - - ); -}; diff --git a/src/components/CippWizard/CippIntunePolicy.jsx b/src/components/CippWizard/CippIntunePolicy.jsx index e21491751546..c94d4ef93864 100644 --- a/src/components/CippWizard/CippIntunePolicy.jsx +++ b/src/components/CippWizard/CippIntunePolicy.jsx @@ -1,9 +1,10 @@ -import { Grid, Stack } from "@mui/material"; +import { Stack } from "@mui/material"; +import { Grid } from "@mui/system"; import { CippWizardStepButtons } from "./CippWizardStepButtons"; import CippJsonView from "../CippFormPages/CippJSONView"; import CippFormComponent from "../CippComponents/CippFormComponent"; import { ApiGetCall } from "../../api/ApiCall"; -import { use, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { useWatch } from "react-hook-form"; import { CippFormCondition } from "../CippComponents/CippFormCondition"; @@ -15,6 +16,28 @@ export const CippIntunePolicy = (props) => { const watcher = useWatch({ control: formControl.control, name: "TemplateList" }); const jsonWatch = useWatch({ control: formControl.control, name: "RAWJson" }); const selectedTenants = useWatch({ control: formControl.control, name: "tenantFilter" }); + + // do not provide inputs for reserved placeholders + const reservedPlaceholders = [ + "%serial%", + "%systemroot%", + "%systemdrive%", + "%temp%", + "%tenantid%", + "%tenantfilter%", + "%initialdomain%", + "%tenantname%", + "%partnertenantid%", + "%samappid%", + "%userprofile%", + "%username%", + "%userdomain%", + "%windir%", + "%programfiles%", + "%programfiles(x86)%", + "%programdata%", + ]; + useEffect(() => { if (CATemplates.isSuccess && watcher?.value) { const template = CATemplates.data.find((template) => template.GUID === watcher.value); @@ -58,7 +81,7 @@ export const CippIntunePolicy = (props) => { /> - + { compareType="is" compareValue="customGroup" > - + { const rawJson = jsonWatch ? jsonWatch : ""; const placeholderMatches = [...rawJson.matchAll(/%(\w+)%/g)].map((m) => m[1]); const uniquePlaceholders = Array.from(new Set(placeholderMatches)); - if (uniquePlaceholders.length === 0 || selectedTenants.length === 0) { + // Filter out reserved placeholders + const filteredPlaceholders = uniquePlaceholders.filter( + (placeholder) => !reservedPlaceholders.includes(`%${placeholder.toLowerCase()}%`) + ); + if (filteredPlaceholders.length === 0 || selectedTenants.length === 0) { return null; } - return uniquePlaceholders.map((placeholder) => ( - + return filteredPlaceholders.map((placeholder) => ( + {selectedTenants.map((tenant, idx) => ( { + const { formControl, onPreviousStep, onNextStep, currentStep } = props; + + return ( + + + Notification Settings + + Configure your notification settings. These settings will determine how you receive alerts + from CIPP. You can test your configuration using the "Send Test Alert" button. Don't want + to setup notifications yet? You can skip this step and configure it later via Application + Settings - Notifications + + {/* Use the reusable notification form component */} + + + + {/* Use the wizard step buttons for navigation */} + + + ); +}; + +export default CippNotificationsStep; diff --git a/src/components/CippWizard/CippPSACredentialsStep.js b/src/components/CippWizard/CippPSACredentialsStep.jsx similarity index 100% rename from src/components/CippWizard/CippPSACredentialsStep.js rename to src/components/CippWizard/CippPSACredentialsStep.jsx diff --git a/src/components/CippWizard/CippPSASyncOptions.js b/src/components/CippWizard/CippPSASyncOptions.jsx similarity index 100% rename from src/components/CippWizard/CippPSASyncOptions.js rename to src/components/CippWizard/CippPSASyncOptions.jsx diff --git a/src/components/CippWizard/CippSAMDeploy.jsx b/src/components/CippWizard/CippSAMDeploy.jsx new file mode 100644 index 000000000000..2cb619fef7aa --- /dev/null +++ b/src/components/CippWizard/CippSAMDeploy.jsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from "react"; +import { Alert, Stack, Box, Link } from "@mui/material"; +import { CIPPM365OAuthButton } from "../CippComponents/CIPPM365OAuthButton"; +import { CippApiResults } from "../CippComponents/CippApiResults"; +import { ApiPostCall } from "../../api/ApiCall"; +import { CippWizardStepButtons } from "./CippWizardStepButtons"; + +export const CippSAMDeploy = (props) => { + const { formControl, currentStep, onPreviousStep, onNextStep } = props; + const [authStatus, setAuthStatus] = useState({ + success: false, + error: null, + loading: false, + }); + + // Block next step until SAM app is created + formControl.register("SAMWizard", { + required: true, + }); + + // Set SAMWizard = true if auth is successful + useEffect(() => { + if (authStatus.success) { + formControl.setValue("SAMWizard", true); + formControl.trigger("SAMWizard"); + } + }, [authStatus, formControl]); + + const createSamApp = ApiPostCall({ urlfromdata: true }); + + const handleAuthSuccess = (tokenData) => { + setAuthStatus({ + success: false, + error: null, + loading: true, + }); + + createSamApp.mutate({ + url: "/api/ExecCreateSamApp", + data: { access_token: tokenData.accessToken }, + }); + }; + + const handleAuthError = (error) => { + setAuthStatus({ + success: false, + error: error.errorMessage || "Authentication failed", + loading: false, + }); + }; + + useEffect(() => { + if (createSamApp.isSuccess && authStatus.loading && createSamApp.data) { + const data = createSamApp.data?.data; + if (data.severity === "error") { + setAuthStatus({ + success: false, + error: data.message || "Failed to create SAM application", + loading: false, + }); + } else if (data.severity === "success") { + setAuthStatus({ + success: true, + error: null, + loading: false, + }); + } + } + }, [createSamApp, authStatus]); + + useEffect(() => { + if (createSamApp.isError && authStatus.loading) { + setAuthStatus({ + success: false, + error: "An error occurred while creating the SAM application", + loading: false, + }); + } + }, [createSamApp, authStatus]); + + return ( + + + To run this setup you will need the following prerequisites: +
  • + A CIPP Service Account. For more information on how to create a service account, click{" "} + + here + +
  • +
  • (Temporary) Global Administrator permissions for the CIPP Service Account
  • +
  • + Multi-factor authentication enabled for the CIPP Service Account, with no trusted + locations or other exclusions. +
  • +
    + + {authStatus.error && ( + + {authStatus.error} + + )} + + + + + + + + +
    + ); +}; + +export default CippSAMDeploy; diff --git a/src/components/CippWizard/CippTenantModeDeploy.jsx b/src/components/CippWizard/CippTenantModeDeploy.jsx new file mode 100644 index 000000000000..8f8683af405e --- /dev/null +++ b/src/components/CippWizard/CippTenantModeDeploy.jsx @@ -0,0 +1,130 @@ +import { useEffect } from "react"; +import { Stack, Box, Typography, Link } from "@mui/material"; +import { CIPPM365OAuthButton } from "../CippComponents/CIPPM365OAuthButton"; +import { CippApiResults } from "../CippComponents/CippApiResults"; +import { ApiPostCall } from "../../api/ApiCall"; +import { CippWizardStepButtons } from "./CippWizardStepButtons"; +import { CippTenantTable } from "./CippTenantTable"; + +export const CippTenantModeDeploy = (props) => { + const { formControl, currentStep, onPreviousStep, onNextStep } = props; + + formControl.register("GDAPAuth", { + required: true, + }); + + const updateRefreshToken = ApiPostCall({ urlfromdata: true }); + const addTenant = ApiPostCall({ urlfromdata: true }); + + useEffect(() => { + if (updateRefreshToken.isSuccess) { + formControl.setValue("GDAPAuth", true); + formControl.trigger("GDAPAuth"); + } + if (addTenant.isSuccess) { + // Reset the form control for the next tenant addition + formControl.setValue("GDAPAuth", true); + formControl.trigger("GDAPAuth"); + } + }, [updateRefreshToken.isSuccess, formControl, addTenant.isSuccess]); + + return ( + + + + {/* Partner Tenant (GDAP) */} + + + Partner Tenant + + + Using GDAP is recommended for CIPP, however you can also authenticate to individual + tenants. It is still highly recommended to connect to your partner tenant first, even if + you are not a Microsoft CSP. This allows CIPP to send notifications, perform permission + checks, and update permissions when required. + + + Please remember to log onto a service account dedicated for CIPP. More info? Check out the{" "} + + service account documentation + + . + + + + + { + const updatedTokenData = { + ...tokenData, + tenantMode: "GDAP", + }; + updateRefreshToken.mutate({ + url: "/api/ExecUpdateRefreshToken", + data: updatedTokenData, + }); + }} + buttonText="Connect to Partner Tenant (Recommended)" + showSuccessAlert={false} + /> + + + + + {/* Per-Tenant */} + + + Per-Tenant Authentication + + + Click the button below to connect to individual tenants. You can authenticate to multiple + tenants by repeating this step for each tenant you want to add. Accidentally added the + wrong tenant? Use the table below to remove it. + + + + + { + const updatedTokenData = { + ...tokenData, + tenantMode: "perTenant", + }; + addTenant.mutate({ + url: "/api/ExecAddTenant", + data: updatedTokenData, + }); + }} + buttonText="Connect to Separate Tenants" + showSuccessAlert={false} + /> + + + + + + + + + ); +}; + +export default CippTenantModeDeploy; diff --git a/src/components/CippWizard/CippTenantStep.jsx b/src/components/CippWizard/CippTenantStep.jsx index 90399921d0cd..5c5d9c1be708 100644 --- a/src/components/CippWizard/CippTenantStep.jsx +++ b/src/components/CippWizard/CippTenantStep.jsx @@ -12,6 +12,7 @@ export const CippTenantStep = (props) => { currentStep, onPreviousStep, preText, + includeOffboardingDefaults = false, } = props; return ( @@ -23,6 +24,7 @@ export const CippTenantStep = (props) => { formControl={formControl} allTenants={allTenants} type={type} + includeOffboardingDefaults={includeOffboardingDefaults} preselectedEnabled={true} /> { + const createDialog = useDialog(); + + // Actions formatted as per your guidelines + const actions = [ + { + label: "Exclude Tenants", + type: "POST", + url: `/api/ExecExcludeTenant?AddExclusion=true`, + icon: , + data: { value: "customerId" }, + confirmText: "Are you sure you want to exclude [displayName]?", + multiPost: false, + condition: (row) => row.displayName !== "*Partner Tenant", + }, + { + label: "Include Tenants", + type: "POST", + url: `/api/ExecExcludeTenant?RemoveExclusion=true`, + icon: , + data: { value: "customerId" }, + confirmText: "Are you sure you want to include [displayName]?", + multiPost: false, + condition: (row) => row.displayName !== "*Partner Tenant", + }, + { + label: "Refresh CPV Permissions", + type: "POST", + url: `/api/ExecCPVPermissions`, + icon: , + data: { tenantFilter: "customerId" }, + confirmText: "Are you sure you want to refresh the CPV permissions for [displayName]?", + multiPost: false, + }, + { + label: "Reset CPV Permissions", + type: "POST", + url: `/api/ExecCPVPermissions?&ResetSP=true`, + icon: , + data: { tenantFilter: "customerId" }, + confirmText: + "Are you sure you want to reset the CPV permissions for [displayName]? (This will delete the Service Principal and re-add it.)", + multiPost: false, + condition: (row) => + row.displayName !== "*Partner Tenant" && row.delegatedPrivilegeStatus !== "directTenant", + }, + { + label: "Remove Tenant", + type: "POST", + url: `/api/ExecRemoveTenant`, + icon: , + data: { TenantID: "customerId" }, + confirmText: + "Are you sure you want to remove [displayName]? If this is a Direct Tenant, this will no longer be accessible until you add it via the Setup Wizard.", + multiPost: false, + condition: (row) => row.displayName !== "*Partner Tenant", + }, + ]; + + // Offcanvas details + const offCanvas = { + extendedInfoFields: [ + "displayName", + "defaultDomainName", + "delegatedPrivilegeStatus", + "Excluded", + "ExcludeDate", + "ExcludeUser", + ], + actions: actions, + }; + + // Columns for the table + const columns = customColumns || [ + "displayName", // Tenant Name + "defaultDomainName", // Default Domain + "delegatedPrivilegeStatus", // Delegated Privilege Status + "Excluded", // Excluded Status + "ExcludeDate", // Exclude Date + "ExcludeUser", // Exclude User + ]; + + // Default filters + const defaultFilters = [ + { + filterName: "Included tenants", + value: [{ id: "Excluded", value: "No" }], + type: "column", + }, + { + filterName: "Excluded tenants", + value: [{ id: "Excluded", value: "Yes" }], + type: "column", + }, + ]; + + const filters = customFilters || defaultFilters; + + return ( + <> + + + + + Force Refresh + + ) : null + } + tenantInTitle={tenantInTitle} + apiUrl="/api/ExecExcludeTenant?ListAll=True" + actions={actions} + offCanvas={offCanvas} + simpleColumns={columns} + filters={filters} + showTenantSelector={showTenantSelector} + showAllTenantsSelector={showAllTenantsSelector} + /> + {showCardButton && !onRefreshButtonClick && ( + + )} + + ); +}; + +export default CippTenantTable; diff --git a/src/components/CippWizard/CippWizard.jsx b/src/components/CippWizard/CippWizard.jsx index f0cc5f3013b3..22f24de234bc 100644 --- a/src/components/CippWizard/CippWizard.jsx +++ b/src/components/CippWizard/CippWizard.jsx @@ -1,11 +1,34 @@ import { useCallback, useMemo, useState } from "react"; import { Card, CardContent, Container, Stack } from "@mui/material"; -import Grid from "@mui/material/Grid2"; +import { Grid } from "@mui/system"; import { WizardSteps } from "./wizard-steps"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; export const CippWizard = (props) => { - const { postUrl, orientation = "horizontal", steps } = props; + const { + postUrl, + orientation = "horizontal", + steps, + contentMaxWidth = "md", + } = props; + + const formControl = useForm({ mode: "onChange", defaultValues: props.initialState }); + const formWatcher = useWatch({ + control: formControl.control, + }); + + const stepsWithVisibility = useMemo(() => { + return steps.filter((step) => { + if (step.hideStepWhen) { + return !step.hideStepWhen(formWatcher); + } + if (step.showStepWhen) { + return step.showStepWhen(formWatcher); + } + return true; + }); + }, [steps, formWatcher]); + const [activeStep, setActiveStep] = useState(0); const handleBack = useCallback(() => { setActiveStep((prevState) => (prevState > 0 ? prevState - 1 : prevState)); @@ -14,40 +37,48 @@ export const CippWizard = (props) => { const handleNext = useCallback(() => { setActiveStep((prevState) => (prevState < steps.length - 1 ? prevState + 1 : prevState)); }, []); - const formControl = useForm({ mode: "onChange", defaultValues: props.initialState }); + const content = useMemo(() => { - const StepComponent = steps[activeStep].component; + const currentStep = stepsWithVisibility[activeStep]; + const StepComponent = currentStep.component; + return ( ); - }, [activeStep, handleNext, handleBack, steps, formControl]); + }, [activeStep, handleNext, handleBack, stepsWithVisibility, formControl]); + + // Get the maxWidth for the current step, fallback to global setting + const currentStepMaxWidth = useMemo(() => { + const currentStep = stepsWithVisibility[activeStep]; + return currentStep.maxWidth ?? contentMaxWidth; + }, [activeStep, stepsWithVisibility, contentMaxWidth]); return ( {orientation === "vertical" ? ( - + - + {content} @@ -59,10 +90,10 @@ export const CippWizard = (props) => { postUrl={postUrl} activeStep={activeStep} orientation={orientation} - steps={steps} + steps={stepsWithVisibility} />
    - {content} + {content}
    diff --git a/src/components/CippWizard/CippWizardAppApproval.jsx b/src/components/CippWizard/CippWizardAppApproval.jsx index a32eef3a32de..ba53800a2cb2 100644 --- a/src/components/CippWizard/CippWizardAppApproval.jsx +++ b/src/components/CippWizard/CippWizardAppApproval.jsx @@ -1,51 +1,205 @@ -import { Stack } from "@mui/material"; +import { Stack, Alert } from "@mui/material"; import CippWizardStepButtons from "./CippWizardStepButtons"; import { Grid } from "@mui/system"; import CippFormComponent from "../CippComponents/CippFormComponent"; import { getCippValidator } from "../../utils/get-cipp-validator"; import { CippFormCondition } from "../CippComponents/CippFormCondition"; +import CippPermissionPreview from "../CippComponents/CippPermissionPreview"; +import { useWatch } from "react-hook-form"; +import { CippPropertyListCard } from "../CippCards/CippPropertyListCard"; export const CippWizardAppApproval = (props) => { const { postUrl, formControl, onPreviousStep, onNextStep, currentStep } = props; + // Watch for the selected template to access permissions and type + const selectedTemplate = useWatch({ + control: formControl.control, + name: "selectedTemplate", + }); + return ( - - - + + {/* Mode Selector */} + + + {/* Template Mode */} + + + + Select an app approval template to deploy. Templates contain predefined permissions that + will be applied to the application. + getCippValidator(value, "guid"), + type="autoComplete" + name="selectedTemplate" + label="Select App Template" + api={{ + url: "/api/ListAppApprovalTemplates", + queryKey: "appApprovalTemplates", + labelField: (item) => `${item.TemplateName}`, + valueField: "TemplateId", + addedField: { + AppId: "AppId", + AppName: "AppName", + AppType: "AppType", + GalleryTemplateId: "GalleryTemplateId", + GalleryInformation: "GalleryInformation", + PermissionSetId: "PermissionSetId", + PermissionSetName: "PermissionSetName", + Permissions: "Permissions", + ApplicationManifest: "ApplicationManifest", + }, + showRefresh: true, }} - name="AppId" + validators={{ required: "A template is required" }} formControl={formControl} + multiple={false} /> - - - + + {selectedTemplate?.addedFields?.AppName && ( + + + {(selectedTemplate.addedFields.AppType || "EnterpriseApp") === "EnterpriseApp" ? ( + + ) : (selectedTemplate.addedFields.AppType || "EnterpriseApp") === + "ApplicationManifest" ? ( + + ) : ( + + )} + + )} + + + + {/* Manual Mode */} + + + getCippValidator(value, "guid"), + }} + name="AppId" + formControl={formControl} + /> + + + + + + + + { + const { postUrl, formControl, onPreviousStep, onNextStep, currentStep } = props; + const templateSelection = useWatch({ control: formControl.control, name: "TemplateList" }); + const assignmentFilterManagementType = + useWatch({ + control: formControl?.control ?? formControl, + name: "assignmentFilterManagementType", + defaultValue: "devices", + }) ?? "devices"; + const platformOptions = + assignmentFilterManagementType === "apps" ? APP_PLATFORM_OPTIONS : DEVICE_PLATFORM_OPTIONS; + + useEffect(() => { + if (templateSelection?.value) { + const { addedFields } = templateSelection; + + formControl.setValue( + "assignmentFilterManagementType", + addedFields.assignmentFilterManagementType || "devices" + ); + formControl.setValue("platform", addedFields.platform || ""); + formControl.setValue("displayName", addedFields.displayName || ""); + formControl.setValue("description", addedFields.description || ""); + formControl.setValue("rule", addedFields.rule || ""); + } + }, [templateSelection, formControl]); + + return ( + + + + + `${option.Displayname || option.displayName} (${option.platform})`, + valueField: "GUID", + addedField: { + platform: "platform", + displayName: "displayName", + description: "description", + rule: "rule", + assignmentFilterManagementType: "assignmentFilterManagementType", + }, + showRefresh: true, + }} + /> + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/components/CippWizard/CippWizardAutoComplete.jsx b/src/components/CippWizard/CippWizardAutoComplete.jsx index a4e8f237f911..7012b80c5aba 100644 --- a/src/components/CippWizard/CippWizardAutoComplete.jsx +++ b/src/components/CippWizard/CippWizardAutoComplete.jsx @@ -29,7 +29,7 @@ export const CippWizardAutoComplete = (props) => { api={{ ...api, tenantFilter: currentTenant ? currentTenant.value : undefined, - queryKey: `${api.url}-${currentTenant ? currentTenant.value : "default"}`, + queryKey: api.queryKey ? api.queryKey.replace('{tenant}', currentTenant ? currentTenant.value : "default") : `${api.url}-${currentTenant ? currentTenant.value : "default"}`, }} multiple={type === "single" ? false : true} disableClearable={true} diff --git a/src/components/CippWizard/CippWizardAutopilotImport.jsx b/src/components/CippWizard/CippWizardAutopilotImport.jsx new file mode 100644 index 000000000000..fc7876e34698 --- /dev/null +++ b/src/components/CippWizard/CippWizardAutopilotImport.jsx @@ -0,0 +1,483 @@ +import { + Button, + Link, + Stack, + Box, + Typography, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Alert, +} from "@mui/material"; +import { Grid } from "@mui/system"; +import { CippWizardStepButtons } from "./CippWizardStepButtons"; +import { CippDataTable } from "../CippTable/CippDataTable"; +import { useWatch } from "react-hook-form"; +import { Delete, FileDownload, Upload, Add } from "@mui/icons-material"; +import { useEffect, useState } from "react"; +import React from "react"; + +export const CippWizardAutopilotImport = (props) => { + const { + onNextStep, + formControl, + currentStep, + onPreviousStep, + fields, + name, + nameToCSVMapping, + fileName = "template", + } = props; + const tableData = useWatch({ control: formControl.control, name: name }); + const [newTableData, setTableData] = useState([]); + const fileInputRef = React.useRef(null); + const [manualDialogOpen, setManualDialogOpen] = useState(false); + const [manualInputs, setManualInputs] = useState([{}]); + const inputRefs = React.useRef([]); + const [validationErrors, setValidationErrors] = useState([]); + + const handleRemoveItem = (row) => { + if (row === undefined) return false; + const index = tableData?.findIndex((item) => item === row); + const newTableData = [...tableData]; + newTableData.splice(index, 1); + setTableData(newTableData); + }; + + const handleFileSelect = (event) => { + const file = event.target.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target.result; + const lines = text.split('\n'); + const firstLine = lines[0].split(',').map(header => header.trim()); + + // Check if this is a headerless CSV (no recognizable headers) + const hasHeaders = firstLine.some(header => { + // Check if any header matches our expected field names + return fields.some(field => + header === field.propertyName || + header === field.friendlyName || + (field.alternativePropertyNames && field.alternativePropertyNames.includes(header)) + ); + }); + + let headers, headerMapping; + + if (hasHeaders) { + // Normal CSV with headers + headers = firstLine; + + // Create mapping for property names and alternative property names + headerMapping = {}; + fields.forEach(field => { + // Map primary property name to itself + headerMapping[field.propertyName] = field.propertyName; + // Map friendly name to property name + headerMapping[field.friendlyName] = field.propertyName; + // Map alternative property names to the primary property name + if (field.alternativePropertyNames) { + field.alternativePropertyNames.forEach(altName => { + headerMapping[altName] = field.propertyName; + }); + } + }); + + // Check if all required columns are present (using any of the supported formats) + const missingColumns = fields.filter(field => { + // Only serial number is required + if (field.propertyName !== 'SerialNumber') { + return false; // Skip non-required fields + } + + const hasPropertyName = headers.includes(field.propertyName); + const hasFriendlyName = headers.includes(field.friendlyName); + const hasAlternativeName = field.alternativePropertyNames ? + field.alternativePropertyNames.some(altName => headers.includes(altName)) : false; + return !hasPropertyName && !hasFriendlyName && !hasAlternativeName; + }); + + if (missingColumns.length > 0) { + const missingFormats = missingColumns.map(f => { + const formats = [f.propertyName, f.friendlyName]; + if (f.alternativePropertyNames) { + formats.push(...f.alternativePropertyNames); + } + return `"${formats.join('" or "')}"`; + }).join(', '); + console.error(`CSV is missing required columns: ${missingFormats}`); + return; + } + } else { + // Headerless CSV - assume order: serial, productid, hash + headers = ['SerialNumber', 'productKey', 'hardwareHash']; + headerMapping = { + 'SerialNumber': 'SerialNumber', + 'productKey': 'productKey', + 'hardwareHash': 'hardwareHash' + }; + + // Check if we have at least 3 columns for the expected order + if (firstLine.length < 3) { + console.error('Headerless CSV must have at least 3 columns in order: Serial Number, Product ID, Hardware Hash'); + return; + } + } + + const data = lines.slice(hasHeaders ? 1 : 0) // Skip first line only if it has headers + .filter(line => line.trim() !== '') // Remove empty lines + .map(line => { + const values = line.split(','); + // Initialize with all fields as empty strings + const row = fields.reduce((obj, field) => { + obj[field.propertyName] = ''; + return obj; + }, {}); + // Fill in the values from the CSV + headers.forEach((header, i) => { + const propertyName = headerMapping[header]; + if (propertyName) { + row[propertyName] = values[i]?.trim() || ''; + } + }); + return row; + }); + + setTableData(data); + formControl.setValue(name, data, { shouldValidate: true }); + }; + reader.readAsText(file); + } + }; + + const handleManualInputChange = (rowIndex, field, value) => { + setManualInputs(prev => { + const newInputs = [...prev]; + if (!newInputs[rowIndex]) { + newInputs[rowIndex] = {}; + } + newInputs[rowIndex][field] = value; + return newInputs; + }); + }; + + const handleAddRow = () => { + setManualInputs(prev => [...prev, {}]); + }; + + const validateRows = (rows) => { + const errors = []; + const seenSerials = new Set(); + const seenProductKeys = new Set(); + + rows.forEach((row, index) => { + const serialField = fields.find(f => f.propertyName === 'SerialNumber'); + const productKeyField = fields.find(f => f.propertyName === 'productKey'); + const manufacturerField = fields.find(f => f.propertyName === 'oemManufacturerName'); + const modelField = fields.find(f => f.propertyName === 'modelName'); + const hardwareHashField = fields.find(f => f.propertyName === 'hardwareHash'); + + if (serialField && row[serialField.propertyName] && seenSerials.has(row[serialField.propertyName])) { + errors.push(`Row ${index + 1}: Duplicate serial number "${row[serialField.propertyName]}"`); + } + if (serialField && row[serialField.propertyName]) { + seenSerials.add(row[serialField.propertyName]); + } + + if (productKeyField && row[productKeyField.propertyName] && seenProductKeys.has(row[productKeyField.propertyName])) { + errors.push(`Row ${index + 1}: Duplicate product key "${row[productKeyField.propertyName]}"`); + } + if (productKeyField && row[productKeyField.propertyName]) { + seenProductKeys.add(row[productKeyField.propertyName]); + } + + // Validate Product ID length (must be exactly 13 characters) + if (productKeyField && row[productKeyField.propertyName] && row[productKeyField.propertyName].length !== 13) { + errors.push(`Row ${index + 1}: Product ID must be exactly 13 characters long`); + } + + // Validate Serial Number requirements: must have either Manufacturer+Model OR Hardware Hash + if (serialField && row[serialField.propertyName] && row[serialField.propertyName].trim() !== '') { + const hasManufacturer = manufacturerField && row[manufacturerField.propertyName] && row[manufacturerField.propertyName].trim() !== ''; + const hasModel = modelField && row[modelField.propertyName] && row[modelField.propertyName].trim() !== ''; + const hasHardwareHash = hardwareHashField && row[hardwareHashField.propertyName] && row[hardwareHashField.propertyName].trim() !== ''; + + const hasManufacturerAndModel = hasManufacturer && hasModel; + const hasHash = hasHardwareHash; + + if (!hasManufacturerAndModel && !hasHash) { + errors.push(`Row ${index + 1}: Serial Number must be accompanied by either both Manufacturer and Model, or Hardware Hash`); + } + } + }); + + setValidationErrors(errors); + return errors.length === 0; + }; + + const handleManualAdd = () => { + const newRows = manualInputs.filter(row => + Object.values(row).some(value => value && value.trim() !== '') + ).map(row => { + // Ensure all fields exist in the row + return fields.reduce((obj, field) => { + obj[field.propertyName] = row[field.propertyName] || ''; + return obj; + }, {}); + }); + + if (newRows.length === 0) { + setManualDialogOpen(false); + setManualInputs([{}]); + return; + } + + if (!validateRows(newRows)) { + return; + } + + const updatedData = [...tableData, ...newRows]; + setTableData(updatedData); + formControl.setValue(name, updatedData, { shouldValidate: true }); + setManualInputs([{}]); + setManualDialogOpen(false); + }; + + const handleDialogClose = () => { + setManualDialogOpen(false); + setManualInputs([{}]); + }; + + const handleKeyPress = (event, rowIndex) => { + const productKeyField = fields.find(f => f.propertyName === 'productKey'); + if (event.key === 'Enter' && productKeyField && manualInputs[rowIndex]?.[productKeyField.propertyName]) { + if (rowIndex === manualInputs.length - 1) { + const newRowIndex = manualInputs.length; + setManualInputs(prev => [...prev, {}]); + // Wait for the next render cycle to set focus + setTimeout(() => { + const newInput = inputRefs.current[newRowIndex]?.[productKeyField.propertyName]; + if (newInput) { + newInput.focus(); + } + }, 0); + } + } + }; + + const handleRemoveRow = (rowIndex) => { + setManualInputs(prev => prev.filter((_, index) => index !== rowIndex)); + }; + + useEffect(() => { + console.log('Table Data:', newTableData); + formControl.setValue(name, newTableData, { + shouldValidate: true, + }); + }, [newTableData]); + + // Add effect to validate rows when manualInputs changes + useEffect(() => { + validateRows(manualInputs); + }, [manualInputs]); + + const actions = [ + { + icon: , + label: "Delete Row", + confirmText: "Are you sure you want to delete this row?", + customFunction: handleRemoveItem, + noConfirm: true, + }, + ]; + + return ( + + f.propertyName)} + cardButton={ + + + + + + + } + /> + + + Manual Import + + + {validationErrors.length > 0 && ( + + + Please fix the following validation errors: + + {validationErrors.map((error, index) => ( + + β€’ {error} + + ))} + + )} + {manualInputs.map((row, rowIndex) => ( + + {/* Row identifier */} + + {rowIndex + 1} + + {fields.map((field) => ( + + { + if (!inputRefs.current[rowIndex]) { + inputRefs.current[rowIndex] = {}; + } + inputRefs.current[rowIndex][field.propertyName] = el; + }} + label={field.friendlyName} + value={row[field.propertyName] || ''} + onChange={(e) => handleManualInputChange(rowIndex, field.propertyName, e.target.value)} + onKeyDown={(e) => field.propertyName === 'productKey' && handleKeyPress(e, rowIndex)} + fullWidth + size="small" + /> + + ))} + + + ))} + + + + + + + + + + + + + + ); +}; diff --git a/src/components/CippWizard/CippWizardAutopilotOptions.jsx b/src/components/CippWizard/CippWizardAutopilotOptions.jsx index 154eebbab74b..a289a0589c89 100644 --- a/src/components/CippWizard/CippWizardAutopilotOptions.jsx +++ b/src/components/CippWizard/CippWizardAutopilotOptions.jsx @@ -1,4 +1,5 @@ -import { Grid, Stack } from "@mui/material"; +import { Stack } from "@mui/material"; +import { Grid } from "@mui/system"; import CippWizardStepButtons from "./CippWizardStepButtons"; import CippFormComponent from "../CippComponents/CippFormComponent"; export const CippWizardAutopilotOptions = (props) => { @@ -8,7 +9,7 @@ export const CippWizardAutopilotOptions = (props) => { <> - + { <> - + { formControl={formControl} /> - + { {fields.map((field) => ( <> - + { ))} - + @@ -116,7 +108,7 @@ export const CippWizardCSVImport = (props) => { {!manualFields && ( <> - + @@ -127,7 +119,7 @@ export const CippWizardCSVImport = (props) => { {fields.map((field) => ( - + { /> ); -}; +}; \ No newline at end of file diff --git a/src/components/CippWizard/CippWizardConfirmation.js b/src/components/CippWizard/CippWizardConfirmation.js deleted file mode 100644 index 3e8ae6c78bff..000000000000 --- a/src/components/CippWizard/CippWizardConfirmation.js +++ /dev/null @@ -1,85 +0,0 @@ -import { Card, Stack, Grid } from "@mui/material"; -import { PropertyList } from "../property-list"; -import CippWizardStepButtons from "./CippWizardStepButtons"; -import { PropertyListItem } from "../property-list-item"; -import { getCippTranslation } from "../../utils/get-cipp-translation"; -import { getCippFormatting } from "../../utils/get-cipp-formatting"; - -export const CippWizardConfirmation = (props) => { - const { postUrl, lastStep, formControl, onPreviousStep, onNextStep, currentStep } = props; - const formValues = formControl.getValues(); - const formEntries = Object.entries(formValues); - //remove all entries in "blacklist" from showing on confirmation page - const blacklist = [ - "selectedOption", - "GUID", - "ID", - "noSubmitButton", - "RAWJson", - "TemplateList", - "addrow", - ]; - - const tenantEntry = formEntries.find(([key]) => key === "tenantFilter" || key === "tenant"); - const userEntry = formEntries.find(([key]) => - ["user", "userPrincipalName", "username"].includes(key) - ); - const filteredEntries = formEntries.filter( - ([key]) => - !blacklist.includes(key) && - key !== "tenantFilter" && - key !== "tenant" && - !["user", "userPrincipalName", "username"].includes(key) - ); - - const halfIndex = Math.ceil(filteredEntries.length / 2); - const firstHalf = filteredEntries.slice(0, halfIndex); - const secondHalf = filteredEntries.slice(halfIndex); - - if (tenantEntry) { - firstHalf.unshift(tenantEntry); - } - - if (userEntry) { - secondHalf.unshift(userEntry); - } - - return ( - - - - - - {firstHalf.map(([key, value]) => { - const formattedValue = getCippFormatting(value, key); - const label = getCippTranslation(key); - return ; - })} - - - - - {secondHalf.map(([key, value]) => { - const formattedValue = getCippFormatting(value, key); - const label = getCippTranslation(key); - return ; - })} - - - - - - - - ); -}; - -export default CippWizardConfirmation; diff --git a/src/components/CippWizard/CippWizardConfirmation.jsx b/src/components/CippWizard/CippWizardConfirmation.jsx new file mode 100644 index 000000000000..3f8ecd288590 --- /dev/null +++ b/src/components/CippWizard/CippWizardConfirmation.jsx @@ -0,0 +1,150 @@ +import { Card, Stack, Typography } from "@mui/material"; +import { Grid } from "@mui/system"; +import { PropertyList } from "../property-list"; +import { PropertyListItem } from "../property-list-item"; +import CippWizardStepButtons from "./CippWizardStepButtons"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; +import { getCippFormatting } from "../../utils/get-cipp-formatting"; + +export const CippWizardConfirmation = (props) => { + const { + postUrl, + lastStep, + formControl, + onPreviousStep, + onNextStep, + currentStep, + columns = 2 // Default to 2 columns for backward compatibility + } = props; + + const formValues = formControl.getValues(); + const formEntries = Object.entries(formValues); + + const blacklist = [ + "selectedOption", + "GDAPAuth", + "SAMWizard", + "GUID", + "ID", + "noSubmitButton", + "RAWJson", + "TemplateList", + "addrow", + ]; + + // Filter out null values and undefined values which could be from hidden conditional fields + const filteredFormEntries = formEntries.filter( + ([_, value]) => value !== null && value !== undefined + ); + + const tenantEntry = filteredFormEntries.find( + ([key]) => key === "tenantFilter" || key === "tenant" + ); + const userEntry = filteredFormEntries.find(([key]) => + ["user", "userPrincipalName", "username"].includes(key) + ); + + const filteredEntries = formEntries.filter( + ([key]) => + !blacklist.includes(key) && + key !== "tenantFilter" && + key !== "tenant" && + !["user", "userPrincipalName", "username"].includes(key) && + !key.startsWith('HIDDEN_') + ); + + // Calculate total entries including special ones for even distribution + const totalEntries = filteredEntries.length + (tenantEntry ? 1 : 0) + (userEntry ? 1 : 0); + + // Dynamically split entries based on columns prop with special entries distributed + const splitEntries = () => { + const result = Array.from({ length: columns }, () => []); + + // Add special entries to different columns first + if (tenantEntry) { + result[0].push(tenantEntry); + } + if (userEntry && result[1]) { + result[1].push(userEntry); + } + + // Distribute remaining entries across columns to balance them + filteredEntries.forEach((entry) => { + // Find the column with the fewest entries + let targetColumn = 0; + let minLength = result[0].length; + + for (let i = 1; i < columns; i++) { + if (result[i].length < minLength) { + minLength = result[i].length; + targetColumn = i; + } + } + + result[targetColumn].push(entry); + }); + + return result; + }; + + const columnEntries = splitEntries(); + + // Calculate Grid sizes based on number of columns + const getGridSize = () => { + const sizes = { + 1: { lg: 12, md: 12, xs: 12 }, + 2: { lg: 6, md: 6, xs: 12 }, + 3: { lg: 4, md: 6, xs: 12 }, + 4: { lg: 3, md: 6, xs: 12 }, + 6: { lg: 2, md: 4, xs: 12 }, + }; + + return sizes[columns] || sizes[2]; // Default to 2 columns + }; + + const gridSize = getGridSize(); + + return ( + + {filteredEntries.length === 0 ? ( + + + + You've completed the steps in this wizard. Hit submit to save your changes. + + + + ) : ( + + + {columnEntries.map((columnData, index) => ( + + + {columnData.map(([key, value]) => ( + + ))} + + + ))} + + + )} + + + + ); +}; + +export default CippWizardConfirmation; diff --git a/src/components/CippWizard/CippWizardGroupTemplates.jsx b/src/components/CippWizard/CippWizardGroupTemplates.jsx index 215d22a0509f..16e1bd61ef0d 100644 --- a/src/components/CippWizard/CippWizardGroupTemplates.jsx +++ b/src/components/CippWizard/CippWizardGroupTemplates.jsx @@ -11,26 +11,35 @@ export const CippWizardGroupTemplates = (props) => { const watcher = useWatch({ control: formControl.control, name: "TemplateList" }); const groupOptions = [ { label: "Dynamic Group", value: "dynamic" }, - { label: "Dynamic Distribution Group", value: "dynamicdistribution" }, + { label: "Dynamic Distribution Group", value: "dynamicDistribution" }, { label: "Security Group", value: "generic" }, { label: "Distribution Group", value: "distribution" }, - { label: "Azure Role Group", value: "azurerole" }, + { label: "Azure Role Group", value: "azureRole" }, { label: "Mail Enabled Security Group", value: "security" }, ]; useEffect(() => { if (watcher?.value) { + console.log("Loading template:", watcher); + + // Set groupType first to ensure conditional fields are visible formControl.setValue("groupType", watcher.addedFields.groupType); - formControl.setValue("Displayname", watcher.addedFields.Displayname); - formControl.setValue("Description", watcher.addedFields.Description); - formControl.setValue("username", watcher.addedFields.username); - formControl.setValue("allowExternal", watcher.addedFields.allowExternal); - formControl.setValue("MembershipRules", watcher.addedFields.MembershipRules); + + // Use setTimeout to ensure the DOM updates with the groupType before setting other fields + setTimeout(() => { + formControl.setValue("displayName", watcher.addedFields.displayName); + formControl.setValue("description", watcher.addedFields.description); + formControl.setValue("username", watcher.addedFields.username); + formControl.setValue("allowExternal", watcher.addedFields.allowExternal); + formControl.setValue("membershipRules", watcher.addedFields.membershipRules); + + console.log("Set membershipRules to:", watcher.addedFields.membershipRules); + }, 100); } }, [watcher]); return ( - + { excludeTenantFilter: true, url: "/api/ListGroupTemplates", queryKey: "ListGroupTemplates", - labelField: (option) => `${option.Displayname} (${option.groupType})`, + labelField: (option) => + `${option.Displayname || option.displayName} (${option.groupType})`, valueField: "GUID", addedField: { groupType: "groupType", - Displayname: "Displayname", - Description: "Description", + displayName: "displayName", + description: "description", username: "username", allowExternal: "allowExternal", - MembershipRules: "MembershipRules", + membershipRules: "membershipRules", }, + showRefresh: true, }} /> - + { validators={{ required: "Please select a group type" }} /> - + - + - + { formControl={formControl} /> - - + + - - - - + + + + - - - - - - - + + { } }, [selectedUsers]); + // Set initial defaults source on component mount if not already set useEffect(() => { - if (userSettingsDefaults?.offboardingDefaults) { - userSettingsDefaults.offboardingDefaults.forEach((setting) => { - formControl.setValue(setting.name, setting.value); - }); + const currentDefaultsSource = formControl.getValues("HIDDEN_defaultsSource"); + if (!currentDefaultsSource) { + // Default to user defaults since form starts with user defaults from initialState within the wizard component + formControl.setValue("HIDDEN_defaultsSource", "user"); } - }, [userSettingsDefaults]); + }, [formControl]); + + // Apply defaults only once per tenant or when tenant changes + useEffect(() => { + const currentTenantId = currentTenant?.value; + const appliedDefaultsForTenant = formControl.getValues("HIDDEN_appliedDefaultsForTenant"); + + // Only apply defaults if we haven't applied them for this tenant yet + if (currentTenantId && appliedDefaultsForTenant !== currentTenantId) { + const tenantDefaults = currentTenant?.addedFields?.offboardingDefaults; + + if (tenantDefaults) { + // Apply tenant defaults + Object.entries(tenantDefaults).forEach(([key, value]) => { + formControl.setValue(key, value); + }); + // Set the source indicator + formControl.setValue("HIDDEN_defaultsSource", "tenant"); + } else if (userSettingsDefaults?.offboardingDefaults) { + // Apply user defaults if no tenant defaults + userSettingsDefaults.offboardingDefaults.forEach((setting) => { + formControl.setValue(setting.name, setting.value); + }); + // Set the source indicator + formControl.setValue("HIDDEN_defaultsSource", "user"); + } + + // Mark that we've applied defaults for this tenant + formControl.setValue("HIDDEN_appliedDefaultsForTenant", currentTenantId); + } + }, [currentTenant?.value, userSettingsDefaults, formControl]); useEffect(() => { if (disableForwarding) { formControl.setValue("forward", null); - formControl.setValue("keepCopy", false); + formControl.setValue("KeepCopy", false); } }, [disableForwarding, formControl]); + const getDefaultsSource = () => { + return formControl.getValues("HIDDEN_defaultsSource") || "user"; + }; + return ( - + + + {getDefaultsSource() === "tenant" ? "Using Tenant Defaults" : "Using User Defaults"} + { /> @@ -123,6 +164,12 @@ export const CippWizardOffboarding = (props) => { type="switch" formControl={formControl} /> + { - + @@ -155,6 +202,7 @@ export const CippWizardOffboarding = (props) => { dataKey: "Results", labelField: (option) => `${option.displayName} (${option.userPrincipalName})`, valueField: "id", + queryKey: "Offboarding-Users", data: { Endpoint: "users", manualPagination: true, @@ -179,6 +227,7 @@ export const CippWizardOffboarding = (props) => { url: "/api/ListGraphRequest", dataKey: "Results", tenantFilter: currentTenant ? currentTenant.value : undefined, + queryKey: "Offboarding-Users", data: { Endpoint: "users", manualPagination: true, @@ -203,6 +252,7 @@ export const CippWizardOffboarding = (props) => { valueField: "id", url: "/api/ListGraphRequest", dataKey: "Results", + queryKey: "Offboarding-Users", data: { Endpoint: "users", manualPagination: true, @@ -244,6 +294,7 @@ export const CippWizardOffboarding = (props) => { valueField: "id", url: "/api/ListGraphRequest", dataKey: "Results", + queryKey: "Offboarding-Users", data: { Endpoint: "users", manualPagination: true, @@ -256,7 +307,7 @@ export const CippWizardOffboarding = (props) => { /> { - + { compareType="is" compareValue={true} > - + Scheduled Offboarding Date { /> - + Send results to: { wizardTitle, backButton = true, wizardOrientation = "horizontal", + maxWidth = "xl", ...other } = props; return ( @@ -26,7 +26,7 @@ const CippWizardPage = (props) => { py: 4, }} > - + {backButton && ( + + + {/* Combined Preview and Configuration Dialog */} + + + + Executive Report - {tenantName} + + + + + + + + {/* Left Panel - Section Configuration */} + + + + + Report Sections + + + Configure which sections to include in your executive report. Changes are reflected + in real-time. + + + + {sectionOptions.map((option) => ( + + + { + event.stopPropagation(); + handleSectionToggle(option.key); + }} + color="primary" + size="small" + disabled={ + // Disable if this is the last enabled section + sectionConfig[option.key] && + Object.values(sectionConfig).filter(Boolean).length === 1 + } + /> + } + label={ + handleSectionToggle(option.key)}> + + {option.label} + + + {option.description} + + + } + sx={{ margin: 0, width: "100%" }} + /> + + + ))} + + + + + πŸ’‘ Pro Tip + + + Enable only the sections relevant to your audience to create focused, impactful + reports. At least one section must be enabled. + + + + + + {/* Right Panel - PDF Preview */} + + {isDataLoading ? ( + + Loading Report Data... + + Fetching additional data for comprehensive report generation + + + ) : reportDocument ? ( + + {reportDocument} + + ) : ( + + + Report preview will appear here + + + )} + + + + + + + Sections enabled: {Object.values(sectionConfig).filter(Boolean).length} of{" "} + {sectionOptions.length} + + + + + + + + + + ); +}; diff --git a/src/components/PrivateRoute.js b/src/components/PrivateRoute.js index 011886bc4499..5b067cf4e7c3 100644 --- a/src/components/PrivateRoute.js +++ b/src/components/PrivateRoute.js @@ -1,39 +1,82 @@ import { ApiGetCall } from "../api/ApiCall.jsx"; import UnauthenticatedPage from "../pages/unauthenticated.js"; +import LoadingPage from "../pages/loading.js"; +import ApiOfflinePage from "../pages/api-offline.js"; export const PrivateRoute = ({ children, routeType }) => { - const { - data: profile, - error, - isLoading, - } = ApiGetCall({ + const session = ApiGetCall({ url: "/.auth/me", - queryKey: "authmecipp", + queryKey: "authmeswa", refetchOnWindowFocus: true, staleTime: 120000, // 2 minutes }); - if (isLoading) { - return "Loading..."; + const apiRoles = ApiGetCall({ + url: "/api/me", + queryKey: "authmecipp", + retry: 2, // Reduced retry count to show offline message sooner + waiting: !session.isSuccess || session.data?.clientPrincipal === null, + }); + + // Check if the session is still loading before determining authentication status + if ( + session.isLoading || + apiRoles.isLoading || + (apiRoles.isFetching && (apiRoles.data === null || apiRoles.data === undefined)) + ) { + return ; + } + + // Check if the API is offline (404 error from /api/me endpoint) + // Or other network errors that would indicate API is unavailable + if ( + apiRoles?.error?.response?.status === 404 || // API endpoint not found + apiRoles?.error?.response?.status === 502 || // Bad Gateway + apiRoles?.error?.response?.status === 503 || // Service Unavailable + (apiRoles?.isSuccess && !apiRoles?.data) // No client principal data, indicating API might be offline + ) { + return ; + } + + // if not logged into swa + if (null === session?.data?.clientPrincipal || session?.data === undefined) { + return ; } let roles = null; - if (null !== profile?.clientPrincipal) { - roles = profile?.clientPrincipal.userRoles; - } else if (null === profile?.clientPrincipal) { + + if ( + session?.isSuccess && + apiRoles?.isSuccess && + undefined !== apiRoles?.data?.clientPrincipal && + session?.data?.clientPrincipal?.userDetails && + apiRoles?.data?.clientPrincipal?.userDetails && + session?.data?.clientPrincipal?.userDetails !== apiRoles?.data?.clientPrincipal?.userDetails + ) { + // refetch the profile if the user details are different + apiRoles.refetch(); + } + + if (null !== apiRoles?.data?.clientPrincipal && undefined !== apiRoles?.data) { + roles = apiRoles?.data?.clientPrincipal?.userRoles ?? []; + } else if (null === apiRoles?.data?.clientPrincipal || undefined === apiRoles?.data) { return ; } if (null === roles) { return ; } else { const blockedRoles = ["anonymous", "authenticated"]; - const userRoles = roles.filter((role) => !blockedRoles.includes(role)); - const isAuthenticated = userRoles.length > 0 && !error; - const isAdmin = roles.includes("admin"); - if (routeType === "admin") { - return !isAdmin ? : children; - } else { - return !isAuthenticated ? : children; + const userRoles = roles?.filter((role) => !blockedRoles.includes(role)) ?? []; + const isAuthenticated = userRoles.length > 0 && !apiRoles?.error; + const isAdmin = roles?.includes("admin") || roles?.includes("superadmin"); + if (routeType === "admin" && !isAdmin) { + return ; } + + if (!isAuthenticated) { + return ; + } + + return children; } }; diff --git a/src/components/ReleaseNotesDialog.js b/src/components/ReleaseNotesDialog.js new file mode 100644 index 000000000000..6fc9274dbca3 --- /dev/null +++ b/src/components/ReleaseNotesDialog.js @@ -0,0 +1,474 @@ +ο»Ώimport { + Component, + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Link, + Stack, + Typography, +} from "@mui/material"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkParse from "remark-parse"; +import rehypeRaw from "rehype-raw"; +import { unified } from "unified"; +import packageInfo from "../../public/version.json"; +import { ApiGetCall } from "../api/ApiCall"; +import { GitHub } from "@mui/icons-material"; +import { CippAutoComplete } from "./CippComponents/CippAutocomplete"; + +const RELEASE_COOKIE_KEY = "cipp_release_notice"; +const RELEASE_OWNER = "KelvinTegelaar"; +const RELEASE_REPO = "CIPP"; + +const secureFlag = () => { + if (typeof window === "undefined") { + return ""; + } + + return window.location.protocol === "https:" ? " Secure" : ""; +}; + +const getCookie = (name) => { + if (typeof document === "undefined") { + return null; + } + + const cookiePrefix = `${name}=`; + const cookies = document.cookie.split("; "); + + for (const cookie of cookies) { + if (cookie.startsWith(cookiePrefix)) { + return decodeURIComponent(cookie.slice(cookiePrefix.length)); + } + } + + return null; +}; + +const setCookie = (name, value, days = 365) => { + if (typeof document === "undefined") { + return; + } + + const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString(); + document.cookie = `${name}=${encodeURIComponent( + value + )}; expires=${expires}; path=/; SameSite=Lax;${secureFlag()}`; +}; + +const buildReleaseMetadata = (version) => { + const [major = "0", minor = "0", patch = "0"] = String(version).split("."); + const currentTag = `v${major}.${minor}.${patch}`; + const baseTag = `v${major}.${minor}.0`; + const tagToUse = patch === "0" ? currentTag : baseTag; + + return { + currentTag, + releaseTag: tagToUse, + releaseUrl: `https://github.com/${RELEASE_OWNER}/${RELEASE_REPO}/releases/tag/${tagToUse}`, + }; +}; + +const formatReleaseBody = (body) => { + if (!body) { + return ""; + } + + return body.replace(/(^|[^\w/])@([a-zA-Z0-9-]+)/g, (match, prefix, username) => { + return `${prefix}[@${username}](https://github.com/${username})`; + }); +}; + +class MarkdownErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error) { + if (process.env.NODE_ENV !== "production") { + // eslint-disable-next-line no-console + console.error("Failed to render release notes", error); + } + } + + render() { + if (this.state.hasError) { + return this.props.fallback(this.state.error); + } + + return this.props.children; + } +} + +export const ReleaseNotesDialog = forwardRef((_props, ref) => { + const releaseMeta = useMemo(() => buildReleaseMetadata(packageInfo.version), []); + const [isEligible, setIsEligible] = useState(false); + const [open, setOpen] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const [manualOpenRequested, setManualOpenRequested] = useState(false); + const [selectedReleaseTag, setSelectedReleaseTag] = useState(releaseMeta.releaseTag); + const hasOpenedRef = useRef(false); + + useEffect(() => { + hasOpenedRef.current = false; + }, [releaseMeta.releaseTag]); + + useEffect(() => { + setSelectedReleaseTag(releaseMeta.releaseTag); + }, [releaseMeta.releaseTag]); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const storedValue = getCookie(RELEASE_COOKIE_KEY); + + if (storedValue !== releaseMeta.releaseTag) { + setIsEligible(true); + } + }, [releaseMeta.releaseTag]); + + const shouldFetchReleaseList = isEligible || manualOpenRequested || open; + + const releaseListQuery = ApiGetCall({ + url: "/api/ListGitHubReleaseNotes", + queryKey: "list-github-release-options", + data: { + Owner: RELEASE_OWNER, + Repository: RELEASE_REPO, + }, + waiting: shouldFetchReleaseList, + staleTime: 300000, + }); + + const isReleaseListLoading = releaseListQuery.isLoading || releaseListQuery.isFetching; + + const releaseCatalog = useMemo(() => { + return Array.isArray(releaseListQuery.data) ? releaseListQuery.data : []; + }, [releaseListQuery.data]); + + useEffect(() => { + if (!releaseCatalog.length) { + return; + } + + if (!selectedReleaseTag) { + setSelectedReleaseTag(releaseCatalog[0].releaseTag); + return; + } + + const hasSelected = releaseCatalog.some((release) => release.releaseTag === selectedReleaseTag); + + if (!hasSelected) { + const fallbackRelease = + releaseCatalog.find((release) => release.releaseTag === releaseMeta.releaseTag) || + releaseCatalog[0]; + if (fallbackRelease) { + setSelectedReleaseTag(fallbackRelease.releaseTag); + } + } + }, [releaseCatalog, selectedReleaseTag, releaseMeta.releaseTag]); + + const releaseOptions = useMemo(() => { + const mapped = releaseCatalog.map((release) => { + const tag = release.releaseTag ?? release.tagName; + const label = release.name ? `${release.name} (${tag})` : tag; + return { + label, + value: tag, + addedFields: { + htmlUrl: release.htmlUrl, + publishedAt: release.publishedAt, + }, + }; + }); + + if (selectedReleaseTag && !mapped.some((option) => option.value === selectedReleaseTag)) { + mapped.push({ + label: selectedReleaseTag, + value: selectedReleaseTag, + addedFields: { + htmlUrl: releaseMeta.releaseUrl, + publishedAt: null, + }, + }); + } + + return mapped; + }, [releaseCatalog, selectedReleaseTag, releaseMeta.releaseUrl]); + + const selectedReleaseValue = useMemo(() => { + if (!selectedReleaseTag) { + return null; + } + + return ( + releaseOptions.find((option) => option.value === selectedReleaseTag) || { + label: selectedReleaseTag, + value: selectedReleaseTag, + } + ); + }, [releaseOptions, selectedReleaseTag]); + + const handleReleaseChange = useCallback( + (newValue) => { + const nextValue = Array.isArray(newValue) ? newValue[0] : newValue; + if (nextValue?.value && nextValue.value !== selectedReleaseTag) { + setSelectedReleaseTag(nextValue.value); + } + }, + [selectedReleaseTag] + ); + + useImperativeHandle(ref, () => ({ + open: () => { + setManualOpenRequested(true); + setOpen(true); + }, + })); + + const selectedReleaseData = useMemo(() => { + if (!selectedReleaseTag) { + return null; + } + + return ( + releaseCatalog.find((release) => release.releaseTag === selectedReleaseTag) || + releaseCatalog.find((release) => release.releaseTag === releaseMeta.releaseTag) || + null + ); + }, [releaseCatalog, selectedReleaseTag, releaseMeta.releaseTag]); + + const handleDismissUntilNextRelease = () => { + const newestRelease = releaseCatalog[0]; + const tagToStore = + newestRelease?.releaseTag ?? newestRelease?.tagName ?? releaseMeta.releaseTag; + setCookie(RELEASE_COOKIE_KEY, tagToStore); + setOpen(false); + setIsExpanded(false); + setManualOpenRequested(false); + setIsEligible(false); + }; + + const handleRemindLater = () => { + setOpen(false); + setIsExpanded(false); + setManualOpenRequested(false); + }; + + const toggleExpanded = () => { + setIsExpanded((prev) => !prev); + }; + + const requestedVersionLabel = + selectedReleaseData?.releaseTag ?? selectedReleaseTag ?? releaseMeta.currentTag; + const releaseName = + selectedReleaseData?.name || selectedReleaseValue?.label || `CIPP ${releaseMeta.currentTag}`; + const releaseHeading = releaseName || requestedVersionLabel; + const releaseBody = typeof selectedReleaseData?.body === "string" ? selectedReleaseData.body : ""; + const releaseUrl = + selectedReleaseData?.htmlUrl ?? + selectedReleaseValue?.addedFields?.htmlUrl ?? + releaseMeta.releaseUrl; + const formattedReleaseBody = useMemo(() => formatReleaseBody(releaseBody), [releaseBody]); + const gfmSupport = useMemo(() => { + if (!formattedReleaseBody) { + return { plugins: [remarkGfm], error: null }; + } + + try { + unified().use(remarkParse).use(remarkGfm).parse(formattedReleaseBody); + return { plugins: [remarkGfm], error: null }; + } catch (err) { + return { plugins: [], error: err }; + } + }, [formattedReleaseBody]); + + useEffect(() => { + if (!isEligible || hasOpenedRef.current) { + return; + } + + if (releaseCatalog.length || releaseListQuery.error) { + setOpen(true); + hasOpenedRef.current = true; + } + }, [isEligible, releaseCatalog.length, releaseListQuery.error]); + + return ( + + + + + {`Release notes for ${releaseHeading}`} + + + + + + + + {releaseListQuery.error ? ( + + We couldn't load additional releases right now. The latest release notes are shown + below. + {releaseListQuery.error?.message ? ` (${releaseListQuery.error.message})` : ""} + + ) : null} + {gfmSupport.error ? ( + + Displaying these release notes without GitHub-flavoured markdown enhancements due to a + parsing issue. Formatting may look different. + + ) : null} + {isReleaseListLoading && !selectedReleaseData ? ( + + + + ) : releaseListQuery.error ? ( + + We couldn't load the release notes right now. You can view them on GitHub instead. + {releaseListQuery.error?.message ? ` (${releaseListQuery.error.message})` : ""} + + ) : ( + + ( + + + We couldn't format these release notes + {error?.message ? ` (${error.message})` : ""}. A plain-text version is shown + below. + + + {releaseBody} + + + )} + > + ( + + ), + img: ({ node, ...props }) => ( + + ), + }} + rehypePlugins={[rehypeRaw]} + remarkPlugins={gfmSupport.plugins} + > + {formattedReleaseBody} + + + + )} + + + + + + + + + + + ); +}); + +ReleaseNotesDialog.displayName = "ReleaseNotesDialog"; diff --git a/src/components/bulk-actions-menu.js b/src/components/bulk-actions-menu.js index fd15898e28a3..dc1a0c167c1a 100644 --- a/src/components/bulk-actions-menu.js +++ b/src/components/bulk-actions-menu.js @@ -2,7 +2,7 @@ import PropTypes from "prop-types"; import ChevronDownIcon from "@heroicons/react/24/outline/ChevronDownIcon"; import { Button, Link, ListItemText, Menu, MenuItem, SvgIcon } from "@mui/material"; import { usePopover } from "../hooks/use-popover"; -import { FilePresent, Laptop, Mail, Share, Shield, ShieldMoon } from "@mui/icons-material"; +import { FilePresent, Laptop, Mail, Share, Shield, ShieldMoon, PrecisionManufacturing, BarChart } from "@mui/icons-material"; import { GlobeAltIcon, UsersIcon, ServerIcon } from "@heroicons/react/24/outline"; function getIconByName(iconName) { @@ -25,6 +25,10 @@ function getIconByName(iconName) { return ; case "ShieldMoon": return ; + case "PrecisionManufacturing": + return ; + case "BarChart": + return ; default: return null; } diff --git a/src/components/linearProgressWithLabel.jsx b/src/components/linearProgressWithLabel.jsx index f01031da45ca..55b4db2967bd 100644 --- a/src/components/linearProgressWithLabel.jsx +++ b/src/components/linearProgressWithLabel.jsx @@ -1,12 +1,65 @@ -import { Box, LinearProgress, Typography } from "@mui/material"; +import { Box, LinearProgress } from "@mui/material"; export const LinearProgressWithLabel = (props) => { + const { value, colourLevels, addedLabel, ...otherProps } = props; + + // Function to determine color based on value and colourLevels + const getProgressColor = (value, colourLevels) => { + if (!colourLevels) { + return undefined; // Use default MUI color + } + + // Check if flipped mode is enabled + const isFlipped = colourLevels === 'flipped' || colourLevels.flipped === true; + + if (isFlipped) { + // Flipped color order: green -> yellow -> orange -> red + if (value >= 0 && value < 25) { + return "#4caf50"; // Green for low values when flipped + } else if (value >= 25 && value < 50) { + return "#ffeb3b"; // Yellow + } else if (value >= 50 && value < 75) { + return "#ff9800"; // Orange + } else if (value >= 75 && value <= 100) { + return "#f44336"; // Red for high values when flipped + } + } else { + // Normal color order: red -> orange -> yellow -> green + if (value >= 0 && value < 25) { + return colourLevels.level0to25 || "#f44336"; // Default red + } else if (value >= 25 && value < 50) { + return colourLevels.level25to50 || "#ff9800"; // Default orange + } else if (value >= 50 && value < 75) { + return colourLevels.level50to75 || "#ffeb3b"; // Default yellow + } else if (value >= 75 && value <= 100) { + return colourLevels.level75to100 || "#4caf50"; // Default green + } + } + + return undefined; // Fallback to default + }; + + const progressColor = getProgressColor(value, colourLevels); + return ( - + - {`${Math.round(props.value)}% ${props?.addedLabel ?? ""}`} + {`${Math.round(value)}% ${addedLabel ?? ""}`} ); }; diff --git a/src/components/pdfExportButton.js b/src/components/pdfExportButton.js index 3d011fd1ebf2..c95c27911bc4 100644 --- a/src/components/pdfExportButton.js +++ b/src/components/pdfExportButton.js @@ -3,10 +3,12 @@ import { PictureAsPdf } from "@mui/icons-material"; import jsPDF from "jspdf"; import autoTable from "jspdf-autotable"; import { getCippFormatting } from "../utils/get-cipp-formatting"; +import { useSettings } from "../hooks/use-settings"; export const PDFExportButton = (props) => { const { rows, columns, reportName, columnVisibility, ...other } = props; - + const brandingSettings = useSettings().customBranding; + //we need to use jspdf here because the react-pdf library gets killed with our amount of data. const handleExportRows = (rows) => { const unit = "pt"; const size = "A3"; // Use A1, A2, A3 or A4 @@ -27,12 +29,98 @@ export const PDFExportButton = (props) => { return formattedRow; }); + // Add custom branding logo if available + let logoHeight = 0; + if (brandingSettings?.logo) { + try { + const logoSize = 60; // Fixed logo height + const logoX = 40; // Left margin + const logoY = 30; // Top margin + + // Add the base64 image to the PDF + doc.addImage(brandingSettings.logo, "PNG", logoX, logoY, logoSize, logoSize); + logoHeight = logoSize + 20; // Logo height plus some spacing + } catch (error) { + console.warn("Failed to add logo to PDF:", error); + } + } + + // Calculate column widths based on content and available space + const pageWidth = doc.internal.pageSize.getWidth(); + const margin = 40; // Consistent margins from edges + const availableWidth = pageWidth - 2 * margin; + const columnCount = exportColumns.length; + + // Calculate dynamic column widths based on content length + const columnWidths = exportColumns.map((col) => { + const headerLength = col.header.length; + const maxContentLength = Math.max( + ...formattedData.map((row) => String(row[col.dataKey] || "").length) + ); + const estimatedWidth = Math.max(headerLength, maxContentLength) * 6; // 6 points per character + return Math.min(estimatedWidth, (availableWidth / columnCount) * 1.5); // Cap at 1.5x average + }); + + // Normalize widths to fit available space + const totalEstimatedWidth = columnWidths.reduce((sum, width) => sum + width, 0); + const normalizedWidths = columnWidths.map( + (width) => (width / totalEstimatedWidth) * availableWidth + ); + + // Convert hex color to RGB if custom branding color is provided + const getHeaderColor = () => { + if (brandingSettings?.colour) { + const hex = brandingSettings.colour.replace("#", ""); + const r = parseInt(hex.substr(0, 2), 16); + const g = parseInt(hex.substr(2, 2), 16); + const b = parseInt(hex.substr(4, 2), 16); + return [r, g, b]; + } + return [247, 127, 0]; // Default orange color + }; + let content = { - startY: 100, - columns: exportColumns, - body: formattedData, + startY: 100 + logoHeight, // Adjust table start position based on logo + head: [exportColumns.map((col) => col.header)], + body: formattedData.map((row) => exportColumns.map((col) => String(row[col.dataKey] || ""))), theme: "striped", - headStyles: { fillColor: [247, 127, 0] }, + headStyles: { + fillColor: getHeaderColor(), + textColor: [255, 255, 255], + fontStyle: "bold", + halign: "center", + valign: "middle", + fontSize: 10, + cellPadding: 8, + }, + bodyStyles: { + fontSize: 9, + cellPadding: 6, + valign: "top", + overflow: "linebreak", + cellWidth: "wrap", + }, + columnStyles: exportColumns.reduce((styles, col, index) => { + styles[index] = { + cellWidth: normalizedWidths[index], + halign: "left", + valign: "top", + }; + return styles; + }, {}), + margin: { + top: margin, + right: margin, + bottom: margin, + left: margin, + }, + tableWidth: "auto", + styles: { + overflow: "linebreak", + cellWidth: "wrap", + fontSize: 9, + cellPadding: 6, + }, }; autoTable(doc, content); diff --git a/src/components/property-list-item.js b/src/components/property-list-item.js index aa61fa0b5d23..4249e975cef0 100644 --- a/src/components/property-list-item.js +++ b/src/components/property-list-item.js @@ -1,16 +1,6 @@ -import { - Box, - Button, - IconButton, - ListItem, - ListItemText, - SvgIcon, - Tooltip, - Typography, -} from "@mui/material"; +import { Box, Button, ListItem, ListItemText, Typography } from "@mui/material"; import { useState } from "react"; -import CopyToClipboard from "react-copy-to-clipboard"; -import { CopyAll } from "@mui/icons-material"; +import { CippCopyToClipBoard } from "./CippComponents/CippCopyToClipboard"; export const PropertyListItem = (props) => { const { @@ -57,17 +47,7 @@ export const PropertyListItem = (props) => { )} )} - {copyItems && ( - - - - - - - - - - )} + {copyItems && } )} diff --git a/src/components/query-field.js b/src/components/query-field.js index f1078830ee60..dfeb6f37e1a4 100644 --- a/src/components/query-field.js +++ b/src/components/query-field.js @@ -29,7 +29,6 @@ export const QueryField = (props) => { inputRef.current.focus(); } }, - // eslint-disable-next-line react-hooks/exhaustive-deps [disabled]); const handleChange = useCallback((event) => { diff --git a/src/components/resource-loading.js b/src/components/resource-loading.js index 743a53baea03..d80a6b09452a 100644 --- a/src/components/resource-loading.js +++ b/src/components/resource-loading.js @@ -1,5 +1,5 @@ import PropTypes from "prop-types"; -import { CircularProgress, SvgIcon, Typography } from "@mui/material"; +import { CircularProgress, Typography } from "@mui/material"; import { styled } from "@mui/material/styles"; const ResourceLoadingRoot = styled("div")(({ theme }) => ({ diff --git a/src/components/toaster.js b/src/components/toaster.js index aa26b5c462c6..933adf625de1 100644 --- a/src/components/toaster.js +++ b/src/components/toaster.js @@ -1,5 +1,5 @@ import { CloseSharp } from "@mui/icons-material"; -import { Alert, Button, IconButton, Snackbar } from "@mui/material"; +import { Alert, IconButton, Snackbar } from "@mui/material"; import { useSelector } from "react-redux"; import { useDispatch } from "react-redux"; import { closeToast } from "../store/toasts"; diff --git a/src/contexts/release-notes-context.js b/src/contexts/release-notes-context.js new file mode 100644 index 000000000000..54f29623522e --- /dev/null +++ b/src/contexts/release-notes-context.js @@ -0,0 +1,30 @@ +ο»Ώimport { createContext, useCallback, useContext, useMemo, useRef } from "react"; +import PropTypes from "prop-types"; +import { ReleaseNotesDialog } from "../components/ReleaseNotesDialog"; + +const ReleaseNotesContext = createContext({ + openReleaseNotes: () => {}, +}); + +export const ReleaseNotesProvider = ({ children }) => { + const dialogRef = useRef(null); + + const openReleaseNotes = useCallback(() => { + dialogRef.current?.open(); + }, []); + + const value = useMemo(() => ({ openReleaseNotes }), [openReleaseNotes]); + + return ( + + {children} + + + ); +}; + +ReleaseNotesProvider.propTypes = { + children: PropTypes.node.isRequired, +}; + +export const useReleaseNotes = () => useContext(ReleaseNotesContext); diff --git a/src/contexts/settings-context.js b/src/contexts/settings-context.js index a265c9fd09aa..35c87c90d658 100644 --- a/src/contexts/settings-context.js +++ b/src/contexts/settings-context.js @@ -74,6 +74,12 @@ const initialSettings = { pinNav: true, currentTenant: null, showDevtools: false, + customBranding: { + colour: "#F77F00", + logo: null, + }, + persistFilters: false, + lastUsedFilters: {}, }; const initialState = { @@ -86,6 +92,7 @@ export const SettingsContext = createContext({ handleReset: () => {}, handleUpdate: () => {}, isCustom: false, + setLastUsedFilter: () => {}, }); export const SettingsProvider = (props) => { @@ -146,6 +153,19 @@ export const SettingsProvider = (props) => { handleReset, handleUpdate, isCustom, + setLastUsedFilter: (page, filter) => { + setState((prevState) => { + const updated = { + ...prevState, + lastUsedFilters: { + ...prevState.lastUsedFilters, + [page]: filter, + }, + }; + storeSettings(updated); + return updated; + }); + }, }} > {children} diff --git a/src/data/AuditLogSchema.json b/src/data/AuditLogSchema.json index 4ed181f46654..93122adbc4cf 100644 --- a/src/data/AuditLogSchema.json +++ b/src/data/AuditLogSchema.json @@ -16,7 +16,10 @@ "CIPPGeoLocation": "List:countryList", "CIPPBadRepIP": "String", "CIPPHostedIP": "String", - "CIPPIPDetected": "String" + "CIPPIPDetected": "String", + "CIPPUserId": "String", + "CIPPUserKey": "String", + "CIPPUsername": "String" }, "Audit.Exchange": { "Id": "Combination GUID", @@ -71,40 +74,126 @@ "LogonError": "String" }, "List:Operation": [ - { "value": "UserLoggedIn", "label": "A user logged in" }, - { "value": "mailitemsaccessed", "label": "accessed mailbox items" }, - { "value": "add delegation entry.", "label": "added delegation entry" }, - { "value": "add domain to company.", "label": "added domain to company" }, - { "value": "add group.", "label": "added group" }, - { "value": "add member to group.", "label": "added member to group" }, - { "value": "add-mailboxpermission", "label": "added delegate mailbox permissions" }, - { "value": "add member to role.", "label": "added member to role" }, - { "value": "add partner to company.", "label": "added a partner to the directory" }, - { "value": "add service principal.", "label": "added service principal" }, + { + "value": "UserLoggedIn", + "label": "A user logged in" + }, + { + "value": "mailitemsaccessed", + "label": "accessed mailbox items" + }, + { + "value": "add delegation entry.", + "label": "added delegation entry" + }, + { + "value": "add domain to company.", + "label": "added domain to company" + }, + { + "value": "add group.", + "label": "added group" + }, + { + "value": "add member to group.", + "label": "added member to group" + }, + { + "value": "add-mailboxpermission", + "label": "added delegate mailbox permissions" + }, + { + "value": "add member to role.", + "label": "added member to role" + }, + { + "value": "add partner to company.", + "label": "added a partner to the directory" + }, + { + "value": "add service principal.", + "label": "added service principal" + }, { "value": "add service principal credentials.", "label": "added credentials to a service principal" }, - { "value": "add user.", "label": "added user" }, - { "value": "addfolderpermissions", "label": "added permissions to folder" }, - { "value": "applyrecordlabel", "label": "labeled message as a record" }, - { "value": "change user license.", "label": "changed user license" }, - { "value": "change user password.", "label": "changed user password" }, - { "value": "copy", "label": "copied messages to another folder" }, - { "value": "create", "label": "created mailbox item" }, - { "value": "delete group.", "label": "deleted group" }, - { "value": "delete user.", "label": "deleted user" }, - { "value": "harddelete", "label": "purged messages from the mailbox" }, - { "value": "mailboxlogin", "label": "user signed in to mailbox" }, - { "value": "move", "label": "moved messages to another folder" }, - { "value": "movetodeleteditems", "label": "moved messages to deleted items folder" }, - { "value": "new-inboxrule", "label": "created new inbox rule in outlook web app" }, - { "value": "remove delegation entry.", "label": "removed delegation entry" }, - { "value": "remove domain from company.", "label": "removed domain from company" }, - { "value": "remove member from group.", "label": "removed member from group" }, - { "value": "remove member from a role.", "label": "remove member from a role" }, - { "value": "Disable Strong Authentication.", "label": "Disable Strong Authentication." }, - + { + "value": "add user.", + "label": "added user" + }, + { + "value": "addfolderpermissions", + "label": "added permissions to folder" + }, + { + "value": "applyrecordlabel", + "label": "labeled message as a record" + }, + { + "value": "change user license.", + "label": "changed user license" + }, + { + "value": "change user password.", + "label": "changed user password" + }, + { + "value": "copy", + "label": "copied messages to another folder" + }, + { + "value": "create", + "label": "created mailbox item" + }, + { + "value": "delete group.", + "label": "deleted group" + }, + { + "value": "delete user.", + "label": "deleted user" + }, + { + "value": "harddelete", + "label": "purged messages from the mailbox" + }, + { + "value": "mailboxlogin", + "label": "user signed in to mailbox" + }, + { + "value": "move", + "label": "moved messages to another folder" + }, + { + "value": "movetodeleteditems", + "label": "moved messages to deleted items folder" + }, + { + "value": "new-inboxrule", + "label": "created new inbox rule in outlook web app" + }, + { + "value": "remove delegation entry.", + "label": "removed delegation entry" + }, + { + "value": "remove domain from company.", + "label": "removed domain from company" + }, + { + "value": "remove member from group.", + "label": "removed member from group" + }, + { + "value": "remove member from a role.", + "label": "remove member from a role" + }, + { + "value": "Disable Strong Authentication.", + "label": "Disable Strong Authentication." + }, { "value": "remove service principal.", "label": "removed a service principal from the directory" @@ -113,19 +202,58 @@ "value": "remove service principal credentials.", "label": "removed credentials from a service principal" }, - { "value": "remove-mailboxpermission", "label": "removed delegate mailbox permissions" }, - { "value": "remove member from role.", "label": "removed a user from a directory role" }, - { "value": "remove partner from company.", "label": "removed a partner from the directory" }, - { "value": "removefolderpermissions", "label": "removed permissions from folder" }, - { "value": "reset user password.", "label": "reset user password" }, - { "value": "send", "label": "sent message" }, - { "value": "sendas", "label": "sent message using send as permissions" }, - { "value": "sendonbehalf", "label": "sent message using send on behalf permissions" }, - { "value": "set company contact information.", "label": "set company contact information" }, - { "value": "set company information.", "label": "set company information" }, - { "value": "set delegation entry.", "label": "set delegation entry" }, - { "value": "set dirsyncenabled flag.", "label": "turned on azure ad sync" }, - { "value": "set domain authentication.", "label": "set domain authentication" }, + { + "value": "remove-mailboxpermission", + "label": "removed delegate mailbox permissions" + }, + { + "value": "remove member from role.", + "label": "removed a user from a directory role" + }, + { + "value": "remove partner from company.", + "label": "removed a partner from the directory" + }, + { + "value": "removefolderpermissions", + "label": "removed permissions from folder" + }, + { + "value": "reset user password.", + "label": "reset user password" + }, + { + "value": "send", + "label": "sent message" + }, + { + "value": "sendas", + "label": "sent message using send as permissions" + }, + { + "value": "sendonbehalf", + "label": "sent message using send on behalf permissions" + }, + { + "value": "set company contact information.", + "label": "set company contact information" + }, + { + "value": "set company information.", + "label": "set company information" + }, + { + "value": "set delegation entry.", + "label": "set delegation entry" + }, + { + "value": "set dirsyncenabled flag.", + "label": "turned on azure ad sync" + }, + { + "value": "set domain authentication.", + "label": "set domain authentication" + }, { "value": "set federation settings on domain.", "label": "updated the federation settings for a domain" @@ -134,29 +262,69 @@ "value": "set force change user password.", "label": "set property that forces user to change password" }, - { "value": "set-inboxrule", "label": "modified inbox rule from outlook web app" }, - { "value": "set license properties.", "label": "set license properties" }, - { "value": "set password policy.", "label": "set password policy" }, - { "value": "softdelete", "label": "deleted messages from deleted items folder" }, - { "value": "update", "label": "updated message" }, - { "value": "update user.", "label": "updated user" }, - { "value": "update group.", "label": "updated group" }, - { "value": "update domain.", "label": "updated domain" }, + { + "value": "set-inboxrule", + "label": "modified inbox rule from outlook web app" + }, + { + "value": "set license properties.", + "label": "set license properties" + }, + { + "value": "set password policy.", + "label": "set password policy" + }, + { + "value": "softdelete", + "label": "deleted messages from deleted items folder" + }, + { + "value": "update", + "label": "updated message" + }, + { + "value": "update user.", + "label": "updated user" + }, + { + "value": "update group.", + "label": "updated group" + }, + { + "value": "update domain.", + "label": "updated domain" + }, { "value": "updatecalendardelegation", "label": "added or removed user with delegate access to calendar folder" }, - { "value": "updatefolderpermissions", "label": "modified folder permission" }, - { "value": "updateinboxrules", "label": "updated inbox rules from outlook client" }, - { "value": "verify domain.", "label": "verified domain" }, - { "value": "verify email verified domain.", "label": "verified email verified domain" }, + { + "value": "updatefolderpermissions", + "label": "modified folder permission" + }, + { + "value": "updateinboxrules", + "label": "updated inbox rules from outlook client" + }, + { + "value": "verify domain.", + "label": "verified domain" + }, + { + "value": "verify email verified domain.", + "label": "verified email verified domain" + }, { "value": "Update StsRefreshTokenValidFrom Timestamp.", "label": "Update StsRefreshTokenValidFrom Timestamp." } ], "List:LogonType": [ - { "value": 0, "Membername": "Owner", "label": "The mailbox owner." }, + { + "value": 0, + "Membername": "Owner", + "label": "The mailbox owner." + }, { "value": 1, "Membername": "Admin", @@ -177,19 +345,63 @@ "Membername": "SystemService", "label": "A service account in the Microsoft datacenter" }, - { "value": 5, "Membername": "BestAccess", "label": "Reserved for internal use." }, - { "value": 6, "Membername": "DelegatedAdmin", "label": "A delegated administrator." } + { + "value": 5, + "Membername": "BestAccess", + "label": "Reserved for internal use." + }, + { + "value": 6, + "Membername": "DelegatedAdmin", + "label": "A delegated administrator." + } ], "List:UserType": [ - { "value": 0, "Membername": "Regular", "label": "A regular user." }, - { "value": 1, "Membername": "Reserved", "label": "A reserved user." }, - { "value": 2, "Membername": "Admin", "label": "An administrator." }, - { "value": 3, "Membername": "DcAdmin", "label": "A Microsoft datacenter operator." }, - { "value": 4, "Membername": "System", "label": "A system account." }, - { "value": 5, "Membername": "Application", "label": "An application." }, - { "value": 6, "Membername": "ServicePrincipal", "label": "A service principal." }, - { "value": 7, "Membername": "CustomPolicy", "label": "A custom policy." }, - { "value": 8, "Membername": "SystemPolicy", "label": "A system policy." } + { + "value": 0, + "Membername": "Regular", + "label": "A regular user." + }, + { + "value": 1, + "Membername": "Reserved", + "label": "A reserved user." + }, + { + "value": 2, + "Membername": "Admin", + "label": "An administrator." + }, + { + "value": 3, + "Membername": "DcAdmin", + "label": "A Microsoft datacenter operator." + }, + { + "value": 4, + "Membername": "System", + "label": "A system account." + }, + { + "value": 5, + "Membername": "Application", + "label": "An application." + }, + { + "value": 6, + "Membername": "ServicePrincipal", + "label": "A service principal." + }, + { + "value": 7, + "Membername": "CustomPolicy", + "label": "A custom policy." + }, + { + "value": 8, + "Membername": "SystemPolicy", + "label": "A system policy." + } ], "List:AuditLogRecordType": [ { @@ -207,13 +419,21 @@ "Membername": "ExchangeItemGroup", "label": "Events from an Exchange mailbox audit log for actions that can be performed on multiple items, such as moving or deleted one or more email messages." }, - { "value": 4, "Membername": "SharePoint", "label": "SharePoint events." }, + { + "value": 4, + "Membername": "SharePoint", + "label": "SharePoint events." + }, { "value": 6, "Membername": "SharePointFileOperation", "label": "SharePoint file operation events." }, - { "value": 7, "Membername": "OneDrive", "label": "OneDrive for Business events." }, + { + "value": 7, + "Membername": "OneDrive", + "label": "OneDrive for Business events." + }, { "value": 8, "Membername": "AzureActiveDirectory", @@ -269,9 +489,21 @@ "Membername": "ExchangeAggregatedOperation", "label": "Aggregated Exchange mailbox auditing events." }, - { "value": 20, "Membername": "PowerBIAudit", "label": "Power BI events." }, - { "value": 21, "Membername": "CRM", "label": "Dynamics 365 events." }, - { "value": 22, "Membername": "Yammer", "label": "Yammer events." }, + { + "value": 20, + "Membername": "PowerBIAudit", + "label": "Power BI events." + }, + { + "value": 21, + "Membername": "CRM", + "label": "Dynamics 365 events." + }, + { + "value": 22, + "Membername": "Yammer", + "label": "Yammer events." + }, { "value": 23, "Membername": "SkypeForBusinessCmdlets", @@ -282,7 +514,11 @@ "Membername": "Discovery", "label": "Events for eDiscovery activities performed by running content searches and managing eDiscovery cases in the Security & Compliance Center." }, - { "value": 25, "Membername": "MicrosoftTeams", "label": "Events from Microsoft Teams." }, + { + "value": 25, + "Membername": "MicrosoftTeams", + "label": "Events from Microsoft Teams." + }, { "value": 28, "Membername": "ThreatIntelligence", @@ -298,8 +534,16 @@ "Membername": "MicrosoftFlow", "label": "Microsoft Power Automate (formerly called Microsoft Flow) events." }, - { "value": 31, "Membername": "AeD", "label": "Advanced eDiscovery events." }, - { "value": 32, "Membername": "MicrosoftStream", "label": "Microsoft Stream events." }, + { + "value": 31, + "Membername": "AeD", + "label": "Advanced eDiscovery events." + }, + { + "value": 32, + "Membername": "MicrosoftStream", + "label": "Microsoft Stream events." + }, { "value": 33, "Membername": "ComplianceDLPSharePointClassification", @@ -310,7 +554,11 @@ "Membername": "ThreatFinder", "label": "Campaign-related events from Microsoft Defender for Office 365." }, - { "value": 35, "Membername": "Project", "label": "Microsoft Project events." }, + { + "value": 35, + "Membername": "Project", + "label": "Microsoft Project events." + }, { "value": 36, "Membername": "SharePointListOperation", @@ -326,7 +574,11 @@ "Membername": "DataGovernance", "label": "Events related to retention policies and retention labels in the Security & Compliance Center" }, - { "value": 39, "Membername": "Kaizala", "label": "Kaizala events." }, + { + "value": 39, + "Membername": "Kaizala", + "label": "Kaizala events." + }, { "value": 40, "Membername": "SecurityComplianceAlerts", @@ -352,7 +604,11 @@ "Membername": "WorkplaceAnalytics", "label": "Workplace Analytics events." }, - { "value": 45, "Membername": "PowerAppsApp", "label": "Power Apps events." }, + { + "value": 45, + "Membername": "PowerAppsApp", + "label": "Power Apps events." + }, { "value": 46, "Membername": "PowerAppsPlan", @@ -408,13 +664,21 @@ "Membername": "SharePointFieldOperation", "label": "SharePoint list field events." }, - { "value": 57, "Membername": "MicrosoftTeamsAdmin", "label": "Teams admin events." }, + { + "value": 57, + "Membername": "MicrosoftTeamsAdmin", + "label": "Teams admin events." + }, { "value": 58, "Membername": "HRSignal", "label": "Events related to HR data signals that support the Insider risk management solution." }, - { "value": 59, "Membername": "MicrosoftTeamsDevice", "label": "Teams device events." }, + { + "value": 59, + "Membername": "MicrosoftTeamsDevice", + "label": "Teams device events." + }, { "value": 60, "Membername": "MicrosoftTeamsAnalytics", @@ -430,15 +694,31 @@ "Membername": "Campaign", "label": "Email campaign events from Microsoft Defender for Office 365." }, - { "value": 63, "Membername": "DLPEndpoint", "label": "Endpoint DLP events." }, + { + "value": 63, + "Membername": "DLPEndpoint", + "label": "Endpoint DLP events." + }, { "value": 64, "Membername": "AirInvestigation", "label": "Automated incident response (AIR) events." }, - { "value": 65, "Membername": "Quarantine", "label": "Quarantine events." }, - { "value": 66, "Membername": "MicrosoftForms", "label": "Microsoft Forms events." }, - { "value": 67, "Membername": "ApplicationAudit", "label": "Application audit events." }, + { + "value": 65, + "Membername": "Quarantine", + "label": "Quarantine events." + }, + { + "value": 66, + "Membername": "MicrosoftForms", + "label": "Microsoft Forms events." + }, + { + "value": 67, + "Membername": "ApplicationAudit", + "label": "Application audit events." + }, { "value": 68, "Membername": "ComplianceSupervisionExchange", @@ -464,13 +744,21 @@ "Membername": "MipAutoLabelSharePointPolicyLocation", "label": "Auto-labeling policy events in SharePoint." }, - { "value": 73, "Membername": "MicrosoftTeamsShifts", "label": "Teams Shifts events." }, + { + "value": 73, + "Membername": "MicrosoftTeamsShifts", + "label": "Teams Shifts events." + }, { "value": 75, "Membername": "MipAutoLabelExchangeItem", "label": "Auto-labeling events in Exchange." }, - { "value": 76, "Membername": "CortanaBriefing", "label": "Briefing email events." }, + { + "value": 76, + "Membername": "CortanaBriefing", + "label": "Briefing email events." + }, { "value": 78, "Membername": "WDATPAlerts", @@ -526,15 +814,31 @@ "Membername": "PhysicalBadgingSignal", "label": "Events related to physical badging signals that support the Insider risk management solution." }, - { "value": 93, "Membername": "AipDiscover", "label": "AIP scanner events" }, + { + "value": 93, + "Membername": "AipDiscover", + "label": "AIP scanner events" + }, { "value": 94, "Membername": "AipSensitivityLabelAction", "label": "AIP sensitivity label events" }, - { "value": 95, "Membername": "AipProtectionAction", "label": "AIP protection events" }, - { "value": 96, "Membername": "AipFileDeleted", "label": "AIP file deletion events" }, - { "value": 97, "Membername": "AipHeartBeat", "label": "AIP heartbeat events" }, + { + "value": 95, + "Membername": "AipProtectionAction", + "label": "AIP protection events" + }, + { + "value": 96, + "Membername": "AipFileDeleted", + "label": "AIP file deletion events" + }, + { + "value": 97, + "Membername": "AipHeartBeat", + "label": "AIP heartbeat events" + }, { "value": 98, "Membername": "MCASAlerts", @@ -560,8 +864,16 @@ "Membername": "SharePointSearch", "label": "Events related to searching an organization's SharePoint home site." }, - { "value": 103, "Membername": "PrivacyInsights", "label": "Privacy insight events." }, - { "value": 105, "Membername": "MyAnalyticsSettings", "label": "MyAnalytics events." }, + { + "value": 103, + "Membername": "PrivacyInsights", + "label": "Privacy insight events." + }, + { + "value": 105, + "Membername": "MyAnalyticsSettings", + "label": "MyAnalytics events." + }, { "value": 106, "Membername": "SecurityComplianceUserChange", @@ -617,13 +929,21 @@ "Membername": "PowerPagesSite", "label": "Activities related to Power Pages site." }, - { "value": 188, "Membername": "PlannerPlan", "label": "Microsoft Planner plan events." }, + { + "value": 188, + "Membername": "PlannerPlan", + "label": "Microsoft Planner plan events." + }, { "value": 189, "Membername": "PlannerCopyPlan", "label": "Microsoft Planner copy plan events." }, - { "value": 190, "Membername": "PlannerTask", "label": "Microsoft Planner task events." }, + { + "value": 190, + "Membername": "PlannerTask", + "label": "Microsoft Planner task events." + }, { "value": 191, "Membername": "PlannerRoster", @@ -674,7 +994,11 @@ "Membername": "ProjectForThewebRoadmapSettings", "label": "Microsoft Project for the web roadmap tenant settings events." }, - { "value": 216, "Membername": "Viva Goals", "label": "Viva Goals events." }, + { + "value": 216, + "Membername": "Viva Goals", + "label": "Viva Goals events." + }, { "value": 217, "Membername": "MicrosoftGraphDataConnectConsent", @@ -685,7 +1009,11 @@ "Membername": "AttackSimAdmin", "label": "Events related to admin activities in Attack Simulation & Training in Microsoft Defender for Office 365." }, - { "value": 230, "Membername": "TeamsUpStrings", "label": "Teams UpStrings App Events." }, + { + "value": 230, + "Membername": "TeamsUpStrings", + "label": "Teams UpStrings App Events." + }, { "value": 231, "Membername": "PlannerRosterSensitivityLabel", @@ -718,257 +1046,1013 @@ } ], "List:countryList": [ - { "value": "AF", "label": "Afghanistan" }, - { "value": "AX", "label": "\u00c5land Islands" }, - { "value": "AL", "label": "Albania" }, - { "value": "DZ", "label": "Algeria" }, - { "value": "AS", "label": "American Samoa" }, - { "value": "AD", "label": "Andorra" }, - { "value": "AO", "label": "Angola" }, - { "value": "AI", "label": "Anguilla" }, - { "value": "AQ", "label": "Antarctica" }, - { "value": "AG", "label": "Antigua and Barbuda" }, - { "value": "AR", "label": "Argentina" }, - { "value": "AM", "label": "Armenia" }, - { "value": "AW", "label": "Aruba" }, - { "value": "AC", "label": "Ascension Island" }, - { "value": "AU", "label": "Australia" }, - { "value": "AT", "label": "Austria" }, - { "value": "AZ", "label": "Azerbaijan" }, - { "value": "BS", "label": "Bahamas" }, - { "value": "BH", "label": "Bahrain" }, - { "value": "BD", "label": "Bangladesh" }, - { "value": "BB", "label": "Barbados" }, - { "value": "BY", "label": "Belarus" }, - { "value": "BE", "label": "Belgium" }, - { "value": "BZ", "label": "Belize" }, - { "value": "BJ", "label": "Benin" }, - { "value": "BM", "label": "Bermuda" }, - { "value": "BT", "label": "Bhutan" }, - { "value": "BO", "label": "Bolivia, Plurinational State of" }, - { "value": "BQ", "label": "Bonaire, Sint Eustatius and Saba" }, - { "value": "BA", "label": "Bosnia and Herzegovina" }, - { "value": "BW", "label": "Botswana" }, - { "value": "BV", "label": "Bouvet Island" }, - { "value": "BR", "label": "Brazil" }, - { "value": "IO", "label": "British Indian Ocean Territory" }, - { "value": "BN", "label": "Brunei Darussalam" }, - { "value": "BG", "label": "Bulgaria" }, - { "value": "BF", "label": "Burkina Faso" }, - { "value": "BI", "label": "Burundi" }, - { "value": "KH", "label": "Cambodia" }, - { "value": "CM", "label": "Cameroon" }, - { "value": "CA", "label": "Canada" }, - { "value": "CV", "label": "Cape Verde" }, - { "value": "KY", "label": "Cayman Islands" }, - { "value": "CF", "label": "Central African Republic" }, - { "value": "TD", "label": "Chad" }, - { "value": "CL", "label": "Chile" }, - { "value": "CN", "label": "China" }, - { "value": "CX", "label": "Christmas Island" }, - { "value": "CC", "label": "Cocos (Keeling) Islands" }, - { "value": "CO", "label": "Colombia" }, - { "value": "KM", "label": "Comoros" }, - { "value": "CG", "label": "Congo" }, - { "value": "CD", "label": "Congo, the Democratic Republic of the" }, - { "value": "CK", "label": "Cook Islands" }, - { "value": "CR", "label": "Costa Rica" }, - { "value": "CI", "label": "C\u00f4te d'Ivoire" }, - { "value": "HR", "label": "Croatia" }, - { "value": "CU", "label": "Cuba" }, - { "value": "CW", "label": "Cura\u00e7ao" }, - { "value": "CY", "label": "Cyprus" }, - { "value": "CZ", "label": "Czech Republic" }, - { "value": "DK", "label": "Denmark" }, - { "value": "DG", "label": "Diego Garcia" }, - { "value": "DJ", "label": "Djibouti" }, - { "value": "DM", "label": "Dominica" }, - { "value": "DO", "label": "Dominican Republic" }, - { "value": "EC", "label": "Ecuador" }, - { "value": "EG", "label": "Egypt" }, - { "value": "SV", "label": "El Salvador" }, - { "value": "GQ", "label": "Equatorial Guinea" }, - { "value": "ER", "label": "Eritrea" }, - { "value": "EE", "label": "Estonia" }, - { "value": "ET", "label": "Ethiopia" }, - { "value": "FK", "label": "Falkland Islands (Malvinas)" }, - { "value": "FO", "label": "Faroe Islands" }, - { "value": "FJ", "label": "Fiji" }, - { "value": "FI", "label": "Finland" }, - { "value": "FR", "label": "France" }, - { "value": "GF", "label": "French Guiana" }, - { "value": "PF", "label": "French Polynesia" }, - { "value": "TF", "label": "French Southern Territories" }, - { "value": "GA", "label": "Gabon" }, - { "value": "GM", "label": "Gambia" }, - { "value": "GE", "label": "Georgia" }, - { "value": "DE", "label": "Germany" }, - { "value": "GH", "label": "Ghana" }, - { "value": "GI", "label": "Gibraltar" }, - { "value": "GR", "label": "Greece" }, - { "value": "GL", "label": "Greenland" }, - { "value": "GD", "label": "Grenada" }, - { "value": "GP", "label": "Guadeloupe" }, - { "value": "GU", "label": "Guam" }, - { "value": "GT", "label": "Guatemala" }, - { "value": "GG", "label": "Guernsey" }, - { "value": "GN", "label": "Guinea" }, - { "value": "GW", "label": "Guinea-Bissau" }, - { "value": "GY", "label": "Guyana" }, - { "value": "HT", "label": "Haiti" }, - { "value": "HM", "label": "Heard Island and McDonald Islands" }, - { "value": "VA", "label": "Holy See (Vatican City State)" }, - { "value": "HN", "label": "Honduras" }, - { "value": "HK", "label": "Hong Kong" }, - { "value": "HU", "label": "Hungary" }, - { "value": "IS", "label": "Iceland" }, - { "value": "IN", "label": "India" }, - { "value": "ID", "label": "Indonesia" }, - { "value": "IR", "label": "Iran, Islamic Republic of" }, - { "value": "IQ", "label": "Iraq" }, - { "value": "IE", "label": "Ireland" }, - { "value": "IM", "label": "Isle of Man" }, - { "value": "IL", "label": "Israel" }, - { "value": "IT", "label": "Italy" }, - { "value": "JM", "label": "Jamaica" }, - { "value": "JP", "label": "Japan" }, - { "value": "JE", "label": "Jersey" }, - { "value": "JO", "label": "Jordan" }, - { "value": "KZ", "label": "Kazakhstan" }, - { "value": "KE", "label": "Kenya" }, - { "value": "KI", "label": "Kiribati" }, - { "value": "KP", "label": "Korea, Democratic People's Republic of" }, - { "value": "KR", "label": "Korea, Republic of" }, - { "value": "XK", "label": "Kosovo" }, - { "value": "KW", "label": "Kuwait" }, - { "value": "KG", "label": "Kyrgyzstan" }, - { "value": "LA", "label": "Lao People's Democratic Republic" }, - { "value": "LV", "label": "Latvia" }, - { "value": "LB", "label": "Lebanon" }, - { "value": "LS", "label": "Lesotho" }, - { "value": "LR", "label": "Liberia" }, - { "value": "LY", "label": "Libya" }, - { "value": "LI", "label": "Liechtenstein" }, - { "value": "LT", "label": "Lithuania" }, - { "value": "LU", "label": "Luxembourg" }, - { "value": "MO", "label": "Macao" }, - { "value": "MK", "label": "Macedonia, the Former Yugoslav Republic of" }, - { "value": "MG", "label": "Madagascar" }, - { "value": "MW", "label": "Malawi" }, - { "value": "MY", "label": "Malaysia" }, - { "value": "MV", "label": "Maldives" }, - { "value": "ML", "label": "Mali" }, - { "value": "MT", "label": "Malta" }, - { "value": "MH", "label": "Marshall Islands" }, - { "value": "MQ", "label": "Martinique" }, - { "value": "MR", "label": "Mauritania" }, - { "value": "MU", "label": "Mauritius" }, - { "value": "YT", "label": "Mayotte" }, - { "value": "MX", "label": "Mexico" }, - { "value": "FM", "label": "Micronesia, Federated States of" }, - { "value": "MD", "label": "Moldova, Republic of" }, - { "value": "MC", "label": "Monaco" }, - { "value": "MN", "label": "Mongolia" }, - { "value": "ME", "label": "Montenegro" }, - { "value": "MS", "label": "Montserrat" }, - { "value": "MA", "label": "Morocco" }, - { "value": "MZ", "label": "Mozambique" }, - { "value": "MM", "label": "Myanmar" }, - { "value": "NA", "label": "Namibia" }, - { "value": "NR", "label": "Nauru" }, - { "value": "NP", "label": "Nepal" }, - { "value": "NL", "label": "Netherlands" }, - { "value": "NC", "label": "New Caledonia" }, - { "value": "NZ", "label": "New Zealand" }, - { "value": "NI", "label": "Nicaragua" }, - { "value": "NE", "label": "Niger" }, - { "value": "NG", "label": "Nigeria" }, - { "value": "NU", "label": "Niue" }, - { "value": "NF", "label": "Norfolk Island" }, - { "value": "MP", "label": "Northern Mariana Islands" }, - { "value": "NO", "label": "Norway" }, - { "value": "OM", "label": "Oman" }, - { "value": "PK", "label": "Pakistan" }, - { "value": "PW", "label": "Palau" }, - { "value": "PS", "label": "Palestine, State of" }, - { "value": "PA", "label": "Panama" }, - { "value": "PG", "label": "Papua New Guinea" }, - { "value": "PY", "label": "Paraguay" }, - { "value": "PE", "label": "Peru" }, - { "value": "PH", "label": "Philippines" }, - { "value": "PN", "label": "Pitcairn" }, - { "value": "PL", "label": "Poland" }, - { "value": "PT", "label": "Portugal" }, - { "value": "PR", "label": "Puerto Rico" }, - { "value": "QA", "label": "Qatar" }, - { "value": "RE", "label": "R\u00e9union" }, - { "value": "RO", "label": "Romania" }, - { "value": "RU", "label": "Russian Federation" }, - { "value": "RW", "label": "Rwanda" }, - { "value": "BL", "label": "Saint Barth\u00e9lemy" }, - { "value": "SH", "label": "Saint Helena, Ascension and Tristan da Cunha" }, - { "value": "KN", "label": "Saint Kitts and Nevis" }, - { "value": "LC", "label": "Saint Lucia" }, - { "value": "MF", "label": "Saint Martin (French part)" }, - { "value": "PM", "label": "Saint Pierre and Miquelon" }, - { "value": "VC", "label": "Saint Vincent and the Grenadines" }, - { "value": "WS", "label": "Samoa" }, - { "value": "SM", "label": "San Marino" }, - { "value": "ST", "label": "Sao Tome and Principe" }, - { "value": "SA", "label": "Saudi Arabia" }, - { "value": "SN", "label": "Senegal" }, - { "value": "RS", "label": "Serbia" }, - { "value": "SC", "label": "Seychelles" }, - { "value": "SL", "label": "Sierra Leone" }, - { "value": "SG", "label": "Singapore" }, - { "value": "SX", "label": "Sint Maarten (Dutch part)" }, - { "value": "SK", "label": "Slovakia" }, - { "value": "SI", "label": "Slovenia" }, - { "value": "SB", "label": "Solomon Islands" }, - { "value": "SO", "label": "Somalia" }, - { "value": "ZA", "label": "South Africa" }, - { "value": "GS", "label": "South Georgia and the South Sandwich Islands" }, - { "value": "SS", "label": "South Sudan" }, - { "value": "ES", "label": "Spain" }, - { "value": "LK", "label": "Sri Lanka" }, - { "value": "SD", "label": "Sudan" }, - { "value": "SR", "label": "Suriname" }, - { "value": "SJ", "label": "Svalbard and Jan Mayen" }, - { "value": "SZ", "label": "Swaziland" }, - { "value": "SE", "label": "Sweden" }, - { "value": "CH", "label": "Switzerland" }, - { "value": "SY", "label": "Syrian Arab Republic" }, - { "value": "TW", "label": "Taiwan, Province of China" }, - { "value": "TJ", "label": "Tajikistan" }, - { "value": "TZ", "label": "Tanzania, United Republic of" }, - { "value": "TH", "label": "Thailand" }, - { "value": "TL", "label": "Timor-Leste" }, - { "value": "TG", "label": "Togo" }, - { "value": "TK", "label": "Tokelau" }, - { "value": "TO", "label": "Tonga" }, - { "value": "TT", "label": "Trinidad and Tobago" }, - { "value": "TN", "label": "Tunisia" }, - { "value": "TR", "label": "Turkey" }, - { "value": "TM", "label": "Turkmenistan" }, - { "value": "TC", "label": "Turks and Caicos Islands" }, - { "value": "TV", "label": "Tuvalu" }, - { "value": "UG", "label": "Uganda" }, - { "value": "UA", "label": "Ukraine" }, - { "value": "AE", "label": "United Arab Emirates" }, - { "value": "GB", "label": "United Kingdom" }, - { "value": "US", "label": "United States" }, - { "value": "UM", "label": "United States Minor Outlying Islands" }, - { "value": "UY", "label": "Uruguay" }, - { "value": "UZ", "label": "Uzbekistan" }, - { "value": "VU", "label": "Vanuatu" }, - { "value": "VE", "label": "Venezuela, Bolivarian Republic of" }, - { "value": "VN", "label": "Viet Nam" }, - { "value": "VG", "label": "Virgin Islands, British" }, - { "value": "VI", "label": "Virgin Islands, U.S." }, - { "value": "WF", "label": "Wallis and Futuna" }, - { "value": "EH", "label": "Western Sahara" }, - { "value": "YE", "label": "Yemen" }, - { "value": "ZM", "label": "Zambia" }, - { "value": "ZW", "label": "Zimbabwe" } + { + "value": "AF", + "label": "Afghanistan" + }, + { + "value": "AX", + "label": "\u00c5land Islands" + }, + { + "value": "AL", + "label": "Albania" + }, + { + "value": "DZ", + "label": "Algeria" + }, + { + "value": "AS", + "label": "American Samoa" + }, + { + "value": "AD", + "label": "Andorra" + }, + { + "value": "AO", + "label": "Angola" + }, + { + "value": "AI", + "label": "Anguilla" + }, + { + "value": "AQ", + "label": "Antarctica" + }, + { + "value": "AG", + "label": "Antigua and Barbuda" + }, + { + "value": "AR", + "label": "Argentina" + }, + { + "value": "AM", + "label": "Armenia" + }, + { + "value": "AW", + "label": "Aruba" + }, + { + "value": "AC", + "label": "Ascension Island" + }, + { + "value": "AU", + "label": "Australia" + }, + { + "value": "AT", + "label": "Austria" + }, + { + "value": "AZ", + "label": "Azerbaijan" + }, + { + "value": "BS", + "label": "Bahamas" + }, + { + "value": "BH", + "label": "Bahrain" + }, + { + "value": "BD", + "label": "Bangladesh" + }, + { + "value": "BB", + "label": "Barbados" + }, + { + "value": "BY", + "label": "Belarus" + }, + { + "value": "BE", + "label": "Belgium" + }, + { + "value": "BZ", + "label": "Belize" + }, + { + "value": "BJ", + "label": "Benin" + }, + { + "value": "BM", + "label": "Bermuda" + }, + { + "value": "BT", + "label": "Bhutan" + }, + { + "value": "BO", + "label": "Bolivia, Plurinational State of" + }, + { + "value": "BQ", + "label": "Bonaire, Sint Eustatius and Saba" + }, + { + "value": "BA", + "label": "Bosnia and Herzegovina" + }, + { + "value": "BW", + "label": "Botswana" + }, + { + "value": "BV", + "label": "Bouvet Island" + }, + { + "value": "BR", + "label": "Brazil" + }, + { + "value": "IO", + "label": "British Indian Ocean Territory" + }, + { + "value": "BN", + "label": "Brunei Darussalam" + }, + { + "value": "BG", + "label": "Bulgaria" + }, + { + "value": "BF", + "label": "Burkina Faso" + }, + { + "value": "BI", + "label": "Burundi" + }, + { + "value": "KH", + "label": "Cambodia" + }, + { + "value": "CM", + "label": "Cameroon" + }, + { + "value": "CA", + "label": "Canada" + }, + { + "value": "CV", + "label": "Cape Verde" + }, + { + "value": "KY", + "label": "Cayman Islands" + }, + { + "value": "CF", + "label": "Central African Republic" + }, + { + "value": "TD", + "label": "Chad" + }, + { + "value": "CL", + "label": "Chile" + }, + { + "value": "CN", + "label": "China" + }, + { + "value": "CX", + "label": "Christmas Island" + }, + { + "value": "CC", + "label": "Cocos (Keeling) Islands" + }, + { + "value": "CO", + "label": "Colombia" + }, + { + "value": "KM", + "label": "Comoros" + }, + { + "value": "CG", + "label": "Congo" + }, + { + "value": "CD", + "label": "Congo, the Democratic Republic of the" + }, + { + "value": "CK", + "label": "Cook Islands" + }, + { + "value": "CR", + "label": "Costa Rica" + }, + { + "value": "CI", + "label": "C\u00f4te d'Ivoire" + }, + { + "value": "HR", + "label": "Croatia" + }, + { + "value": "CU", + "label": "Cuba" + }, + { + "value": "CW", + "label": "Cura\u00e7ao" + }, + { + "value": "CY", + "label": "Cyprus" + }, + { + "value": "CZ", + "label": "Czech Republic" + }, + { + "value": "DK", + "label": "Denmark" + }, + { + "value": "DG", + "label": "Diego Garcia" + }, + { + "value": "DJ", + "label": "Djibouti" + }, + { + "value": "DM", + "label": "Dominica" + }, + { + "value": "DO", + "label": "Dominican Republic" + }, + { + "value": "EC", + "label": "Ecuador" + }, + { + "value": "EG", + "label": "Egypt" + }, + { + "value": "SV", + "label": "El Salvador" + }, + { + "value": "GQ", + "label": "Equatorial Guinea" + }, + { + "value": "ER", + "label": "Eritrea" + }, + { + "value": "EE", + "label": "Estonia" + }, + { + "value": "ET", + "label": "Ethiopia" + }, + { + "value": "FK", + "label": "Falkland Islands (Malvinas)" + }, + { + "value": "FO", + "label": "Faroe Islands" + }, + { + "value": "FJ", + "label": "Fiji" + }, + { + "value": "FI", + "label": "Finland" + }, + { + "value": "FR", + "label": "France" + }, + { + "value": "GF", + "label": "French Guiana" + }, + { + "value": "PF", + "label": "French Polynesia" + }, + { + "value": "TF", + "label": "French Southern Territories" + }, + { + "value": "GA", + "label": "Gabon" + }, + { + "value": "GM", + "label": "Gambia" + }, + { + "value": "GE", + "label": "Georgia" + }, + { + "value": "DE", + "label": "Germany" + }, + { + "value": "GH", + "label": "Ghana" + }, + { + "value": "GI", + "label": "Gibraltar" + }, + { + "value": "GR", + "label": "Greece" + }, + { + "value": "GL", + "label": "Greenland" + }, + { + "value": "GD", + "label": "Grenada" + }, + { + "value": "GP", + "label": "Guadeloupe" + }, + { + "value": "GU", + "label": "Guam" + }, + { + "value": "GT", + "label": "Guatemala" + }, + { + "value": "GG", + "label": "Guernsey" + }, + { + "value": "GN", + "label": "Guinea" + }, + { + "value": "GW", + "label": "Guinea-Bissau" + }, + { + "value": "GY", + "label": "Guyana" + }, + { + "value": "HT", + "label": "Haiti" + }, + { + "value": "HM", + "label": "Heard Island and McDonald Islands" + }, + { + "value": "VA", + "label": "Holy See (Vatican City State)" + }, + { + "value": "HN", + "label": "Honduras" + }, + { + "value": "HK", + "label": "Hong Kong" + }, + { + "value": "HU", + "label": "Hungary" + }, + { + "value": "IS", + "label": "Iceland" + }, + { + "value": "IN", + "label": "India" + }, + { + "value": "ID", + "label": "Indonesia" + }, + { + "value": "IR", + "label": "Iran, Islamic Republic of" + }, + { + "value": "IQ", + "label": "Iraq" + }, + { + "value": "IE", + "label": "Ireland" + }, + { + "value": "IM", + "label": "Isle of Man" + }, + { + "value": "IL", + "label": "Israel" + }, + { + "value": "IT", + "label": "Italy" + }, + { + "value": "JM", + "label": "Jamaica" + }, + { + "value": "JP", + "label": "Japan" + }, + { + "value": "JE", + "label": "Jersey" + }, + { + "value": "JO", + "label": "Jordan" + }, + { + "value": "KZ", + "label": "Kazakhstan" + }, + { + "value": "KE", + "label": "Kenya" + }, + { + "value": "KI", + "label": "Kiribati" + }, + { + "value": "KP", + "label": "Korea, Democratic People's Republic of" + }, + { + "value": "KR", + "label": "Korea, Republic of" + }, + { + "value": "XK", + "label": "Kosovo" + }, + { + "value": "KW", + "label": "Kuwait" + }, + { + "value": "KG", + "label": "Kyrgyzstan" + }, + { + "value": "LA", + "label": "Lao People's Democratic Republic" + }, + { + "value": "LV", + "label": "Latvia" + }, + { + "value": "LB", + "label": "Lebanon" + }, + { + "value": "LS", + "label": "Lesotho" + }, + { + "value": "LR", + "label": "Liberia" + }, + { + "value": "LY", + "label": "Libya" + }, + { + "value": "LI", + "label": "Liechtenstein" + }, + { + "value": "LT", + "label": "Lithuania" + }, + { + "value": "LU", + "label": "Luxembourg" + }, + { + "value": "MO", + "label": "Macao" + }, + { + "value": "MK", + "label": "Macedonia, the Former Yugoslav Republic of" + }, + { + "value": "MG", + "label": "Madagascar" + }, + { + "value": "MW", + "label": "Malawi" + }, + { + "value": "MY", + "label": "Malaysia" + }, + { + "value": "MV", + "label": "Maldives" + }, + { + "value": "ML", + "label": "Mali" + }, + { + "value": "MT", + "label": "Malta" + }, + { + "value": "MH", + "label": "Marshall Islands" + }, + { + "value": "MQ", + "label": "Martinique" + }, + { + "value": "MR", + "label": "Mauritania" + }, + { + "value": "MU", + "label": "Mauritius" + }, + { + "value": "YT", + "label": "Mayotte" + }, + { + "value": "MX", + "label": "Mexico" + }, + { + "value": "FM", + "label": "Micronesia, Federated States of" + }, + { + "value": "MD", + "label": "Moldova, Republic of" + }, + { + "value": "MC", + "label": "Monaco" + }, + { + "value": "MN", + "label": "Mongolia" + }, + { + "value": "ME", + "label": "Montenegro" + }, + { + "value": "MS", + "label": "Montserrat" + }, + { + "value": "MA", + "label": "Morocco" + }, + { + "value": "MZ", + "label": "Mozambique" + }, + { + "value": "MM", + "label": "Myanmar" + }, + { + "value": "NA", + "label": "Namibia" + }, + { + "value": "NR", + "label": "Nauru" + }, + { + "value": "NP", + "label": "Nepal" + }, + { + "value": "NL", + "label": "Netherlands" + }, + { + "value": "NC", + "label": "New Caledonia" + }, + { + "value": "NZ", + "label": "New Zealand" + }, + { + "value": "NI", + "label": "Nicaragua" + }, + { + "value": "NE", + "label": "Niger" + }, + { + "value": "NG", + "label": "Nigeria" + }, + { + "value": "NU", + "label": "Niue" + }, + { + "value": "NF", + "label": "Norfolk Island" + }, + { + "value": "MP", + "label": "Northern Mariana Islands" + }, + { + "value": "NO", + "label": "Norway" + }, + { + "value": "OM", + "label": "Oman" + }, + { + "value": "PK", + "label": "Pakistan" + }, + { + "value": "PW", + "label": "Palau" + }, + { + "value": "PS", + "label": "Palestine, State of" + }, + { + "value": "PA", + "label": "Panama" + }, + { + "value": "PG", + "label": "Papua New Guinea" + }, + { + "value": "PY", + "label": "Paraguay" + }, + { + "value": "PE", + "label": "Peru" + }, + { + "value": "PH", + "label": "Philippines" + }, + { + "value": "PN", + "label": "Pitcairn" + }, + { + "value": "PL", + "label": "Poland" + }, + { + "value": "PT", + "label": "Portugal" + }, + { + "value": "PR", + "label": "Puerto Rico" + }, + { + "value": "QA", + "label": "Qatar" + }, + { + "value": "RE", + "label": "R\u00e9union" + }, + { + "value": "RO", + "label": "Romania" + }, + { + "value": "RU", + "label": "Russian Federation" + }, + { + "value": "RW", + "label": "Rwanda" + }, + { + "value": "BL", + "label": "Saint Barth\u00e9lemy" + }, + { + "value": "SH", + "label": "Saint Helena, Ascension and Tristan da Cunha" + }, + { + "value": "KN", + "label": "Saint Kitts and Nevis" + }, + { + "value": "LC", + "label": "Saint Lucia" + }, + { + "value": "MF", + "label": "Saint Martin (French part)" + }, + { + "value": "PM", + "label": "Saint Pierre and Miquelon" + }, + { + "value": "VC", + "label": "Saint Vincent and the Grenadines" + }, + { + "value": "WS", + "label": "Samoa" + }, + { + "value": "SM", + "label": "San Marino" + }, + { + "value": "ST", + "label": "Sao Tome and Principe" + }, + { + "value": "SA", + "label": "Saudi Arabia" + }, + { + "value": "SN", + "label": "Senegal" + }, + { + "value": "RS", + "label": "Serbia" + }, + { + "value": "SC", + "label": "Seychelles" + }, + { + "value": "SL", + "label": "Sierra Leone" + }, + { + "value": "SG", + "label": "Singapore" + }, + { + "value": "SX", + "label": "Sint Maarten (Dutch part)" + }, + { + "value": "SK", + "label": "Slovakia" + }, + { + "value": "SI", + "label": "Slovenia" + }, + { + "value": "SB", + "label": "Solomon Islands" + }, + { + "value": "SO", + "label": "Somalia" + }, + { + "value": "ZA", + "label": "South Africa" + }, + { + "value": "GS", + "label": "South Georgia and the South Sandwich Islands" + }, + { + "value": "SS", + "label": "South Sudan" + }, + { + "value": "ES", + "label": "Spain" + }, + { + "value": "LK", + "label": "Sri Lanka" + }, + { + "value": "SD", + "label": "Sudan" + }, + { + "value": "SR", + "label": "Suriname" + }, + { + "value": "SJ", + "label": "Svalbard and Jan Mayen" + }, + { + "value": "SZ", + "label": "Swaziland" + }, + { + "value": "SE", + "label": "Sweden" + }, + { + "value": "CH", + "label": "Switzerland" + }, + { + "value": "SY", + "label": "Syrian Arab Republic" + }, + { + "value": "TW", + "label": "Taiwan, Province of China" + }, + { + "value": "TJ", + "label": "Tajikistan" + }, + { + "value": "TZ", + "label": "Tanzania, United Republic of" + }, + { + "value": "TH", + "label": "Thailand" + }, + { + "value": "TL", + "label": "Timor-Leste" + }, + { + "value": "TG", + "label": "Togo" + }, + { + "value": "TK", + "label": "Tokelau" + }, + { + "value": "TO", + "label": "Tonga" + }, + { + "value": "TT", + "label": "Trinidad and Tobago" + }, + { + "value": "TN", + "label": "Tunisia" + }, + { + "value": "TR", + "label": "Turkey" + }, + { + "value": "TM", + "label": "Turkmenistan" + }, + { + "value": "TC", + "label": "Turks and Caicos Islands" + }, + { + "value": "TV", + "label": "Tuvalu" + }, + { + "value": "UG", + "label": "Uganda" + }, + { + "value": "UA", + "label": "Ukraine" + }, + { + "value": "AE", + "label": "United Arab Emirates" + }, + { + "value": "GB", + "label": "United Kingdom" + }, + { + "value": "US", + "label": "United States" + }, + { + "value": "UM", + "label": "United States Minor Outlying Islands" + }, + { + "value": "UY", + "label": "Uruguay" + }, + { + "value": "UZ", + "label": "Uzbekistan" + }, + { + "value": "VU", + "label": "Vanuatu" + }, + { + "value": "VE", + "label": "Venezuela, Bolivarian Republic of" + }, + { + "value": "VN", + "label": "Viet Nam" + }, + { + "value": "VG", + "label": "Virgin Islands, British" + }, + { + "value": "VI", + "label": "Virgin Islands, U.S." + }, + { + "value": "WF", + "label": "Wallis and Futuna" + }, + { + "value": "EH", + "label": "Western Sahara" + }, + { + "value": "YE", + "label": "Yemen" + }, + { + "value": "ZM", + "label": "Zambia" + }, + { + "value": "ZW", + "label": "Zimbabwe" + } ] -} +} \ No newline at end of file diff --git a/src/data/Extensions.json b/src/data/Extensions.json index 3c61cbbba574..3009704f99cd 100644 --- a/src/data/Extensions.json +++ b/src/data/Extensions.json @@ -29,8 +29,8 @@ "mappingRequired": true, "links": [ { - "name": "Sherweb Cloud Services for MSPs", - "url": "https://info.sherweb.com/sherweb-cloud-services-for-msps" + "name": "Sherweb CIPP Integration", + "url": "https://info.sherweb.com/sherweb-cipp-integration" } ], "SettingOptions": [ @@ -81,12 +81,12 @@ { "type": "autoComplete", "name": "Sherweb.AllowedCustomRoles", - "label": "Select custom roles that are allowed to purchase licenses", + "label": "Select CIPP roles that are allowed to purchase licenses", "api": { "url": "/api/ListCustomRole", "queryKey": "CustomRoles", - "labelField": "RowKey", - "valueField": "RowKey" + "labelField": "RoleName", + "valueField": "RoleName" }, "multiple": true, "condition": { @@ -334,10 +334,37 @@ "action": "disable" } }, + { + "type": "textField", + "name": "HaloPSA.ClientID", + "label": "HaloPSA Client ID", + "placeholder": "Enter your HaloPSA Client ID", + "required": true, + "condition": { + "field": "HaloPSA.Enabled", + "compareType": "is", + "compareValue": true, + "action": "disable" + } + }, + { + "type": "password", + "name": "HaloPSA.APIKey", + "label": "HaloPSA Client Secret", + "placeholder": "Enter your client Secret. Leave blank to keep your current key.", + "required": true, + "condition": { + "field": "HaloPSA.Enabled", + "compareType": "is", + "compareValue": true, + "action": "disable" + } + }, { "type": "autoComplete", "name": "HaloPSA.TicketType", - "label": "Select your HaloPSA Ticket Type, leave blank for default.", + "label": "HaloPSA Ticket Type", + "placeholder": "Select your HaloPSA Ticket Type, leave blank for default", "multiple": false, "api": { "url": "/api/ExecExtensionMapping", @@ -358,26 +385,24 @@ } }, { - "type": "textField", - "name": "HaloPSA.ClientID", - "label": "HaloPSA Client ID", - "placeholder": "Enter your HaloPSA Client ID", - "required": true, - "condition": { - "field": "HaloPSA.Enabled", - "compareType": "is", - "compareValue": true, - "action": "disable" - } - }, - { - "type": "password", - "name": "HaloPSA.APIKey", - "label": "HaloPSA Client Secret", - "placeholder": "Enter your client Secret. Leave blank to keep your current key.", - "required": true, + "type": "autoComplete", + "name": "HaloPSA.Outcome", + "label": "HaloPSA Outcome", + "placeholder": "Select your HaloPSA Outcome, leave blank for default", + "multiple": false, + "api": { + "url": "/api/ExecExtensionMapping", + "data": { + "List": "HaloPSAFields" + }, + "queryKey": "HaloOutcomes", + "dataKey": "Outcomes", + "labelField": "buttonname", + "valueField": "id", + "showRefresh": true + }, "condition": { - "field": "HaloPSA.Enabled", + "field": "HaloPSA.ConsolidateTickets", "compareType": "is", "compareValue": true, "action": "disable" @@ -599,6 +624,18 @@ "compareType": "is", "compareValue": true } + }, + { + "type": "datePicker", + "name": "Hudu.NextSync", + "label": "Reschedule next sync date", + "helperText": "Set a future date to delay the next scheduled sync. Leave blank to sync during the next scheduled sync.", + "condition": { + "field": "Hudu.Enabled", + "compareType": "is", + "compareValue": true, + "action": "disable" + } } ], "mappingRequired": true, @@ -841,7 +878,7 @@ "logo": "/assets/integrations/github.png", "logoDark": "/assets/integrations/github_dark.png", "description": "Enable the GitHub integration to manage your repositories from CIPP.", - "helpText": "This integration allows you to manage GitHub repositories from CIPP, including the Community Repositorities functionality. Requires a GitHub Personal Access Token (PAT) with a minimum of repo:public_repo permissions. If you plan on saving your templates to GitHub or accessing private/internal repositories, you will need to grant the whole repo scope. You can create a PAT in your GitHub account settings, see the GitHub Token documentation for more info. If you do not enable the extension, a read-only API will be provided.", + "helpText": "This integration allows you to manage GitHub repositories from CIPP, including the Community Repositories functionality. Requires a GitHub Personal Access Token (PAT) with a minimum of repo:public_repo permissions. If you plan on saving your templates to GitHub or accessing private/internal repositories, you will need to grant the whole repo scope. You can create a PAT in your GitHub account settings, see the GitHub Token documentation for more info. If you do not enable the extension, a read-only API will be provided.", "links": [ { "name": "GitHub Token", diff --git a/src/data/GDAPRoles.json b/src/data/GDAPRoles.json index 22553236b533..1d3ca9b38094 100644 --- a/src/data/GDAPRoles.json +++ b/src/data/GDAPRoles.json @@ -63,6 +63,22 @@ "Name": "Attribute Definition Reader", "ObjectId": "1d336d2c-4ae8-42ef-9711-b3604ce3fc2c" }, + { + "ExtensionData": {}, + "Description": "Read audit logs and configure diagnostic settings for events related to custom security attributes.", + "IsEnabled": true, + "IsSystem": true, + "Name": "Attribute Log Administrator", + "ObjectId": "5b784334-f94b-471a-a387-e7219fc49ca2" + }, + { + "ExtensionData": {}, + "Description": "Read audit logs related to custom security attributes.", + "IsEnabled": true, + "IsSystem": true, + "Name": "Attribute Log Reader", + "ObjectId": "9c99539d-8186-4804-835f-fd51ef9e2dcd" + }, { "ExtensionData": {}, "Description": "Allowed to view, set and reset authentication method information for any non-admin user.", @@ -79,6 +95,14 @@ "Name": "Authentication Policy Administrator", "ObjectId": "0526716b-113d-4c15-b2c8-68e3c22b9f80" }, + { + "ExtensionData": {}, + "Description": "Customize sign in and sign up experiences for users by creating and managing custom authentication extensions.", + "IsEnabled": true, + "IsSystem": true, + "Name": "Authentication Extensibility Administrator", + "ObjectId": "25a516ed-2fa0-40ea-a2d0-12923a21473a" + }, { "ExtensionData": {}, "Description": "Users assigned to this role are added to the local administrators group on Azure AD-joined devices.", @@ -255,6 +279,14 @@ "Name": "Dynamics 365 Administrator", "ObjectId": "44367163-eba1-44c3-98af-f5787879f96a" }, + { + "ExtensionData": {}, + "Description": "Access and perform all administrative tasks on Dynamics 365 Business Central environments.", + "IsEnabled": true, + "IsSystem": true, + "Name": "Dynamics 365 Business Central Administrator", + "ObjectId": "963797fb-eb3b-4cde-8ce3-5878b3f32a3f" + }, { "ExtensionData": {}, "Description": "Manage all aspects of Microsoft Edge.", @@ -311,6 +343,14 @@ "Name": "Global Reader", "ObjectId": "f2ef992c-3afb-46b9-b7cf-a126ee74c451" }, + { + "ExtensionData": {}, + "Description": "Create and manage all aspects of Microsoft Entra Internet Access and Microsoft Entra Private Access, including managing access to public and private endpoints.", + "IsEnabled": true, + "IsSystem": true, + "Name": "Global Secure Access Administrator", + "ObjectId": "ac434307-12b9-4fa1-a708-88bf58caabc1" + }, { "ExtensionData": {}, "Description": "Members of this role can create/manage groups, create/manage groups settings like naming and expiration policies, and view groups activity and audit reports.", @@ -439,6 +479,30 @@ "Name": "Message Center Reader", "ObjectId": "790c1fb9-7f7d-4f88-86a1-ef1f95c05c1b" }, + { + "ExtensionData": {}, + "Description": "Perform all migration functionality to migrate content to Microsoft 365 using Migration Manager.", + "IsEnabled": true, + "IsSystem": true, + "Name": "Microsoft 365 Migration Administrator", + "ObjectId": "8c8b803f-96e1-4129-9349-20738d9f9652" + }, + { + "ExtensionData": {}, + "Description": "Create and manage all aspects warranty claims and entitlements for Microsoft manufactured hardware, like Surface and HoloLens.", + "IsEnabled": true, + "IsSystem": true, + "Name": "Microsoft Hardware Warranty Administrator", + "ObjectId": "1501b917-7653-4ff9-a4b5-203eaf33784f" + }, + { + "ExtensionData": {}, + "Description": "Create and read warranty claims for Microsoft manufactured hardware, like Surface and HoloLens.", + "IsEnabled": true, + "IsSystem": true, + "Name": "Microsoft Hardware Warranty Specialist", + "ObjectId": "281fe777-fb20-4fbb-b7a3-ccebce5b0d96" + }, { "ExtensionData": {}, "Description": "Can manage network locations and review enterprise network design insights for Microsoft 365 Software as a Service applications.", @@ -455,6 +519,14 @@ "Name": "Office Apps Administrator", "ObjectId": "2b745bdf-0803-4d80-aa65-822c4493daac" }, + { + "ExtensionData": {}, + "Description": "Write, publish, manage, and review the organizational messages for end-users through Microsoft product surfaces.", + "IsEnabled": true, + "IsSystem": true, + "Name": "Organizational Messages Writer", + "ObjectId": "507f53e4-4e52-4077-abd3-d2e1558b6ea2" + }, { "ExtensionData": {}, "Description": "Can reset passwords for non-administrators and Password Administrators.", @@ -583,6 +655,14 @@ "Name": "SharePoint Administrator", "ObjectId": "f28a1f50-f6e7-4571-818b-6a12f2af6b6c" }, + { + "ExtensionData": {}, + "Description": "Manage all aspects of SharePoint Embedded containers.", + "IsEnabled": true, + "IsSystem": true, + "Name": "SharePoint Embedded Administrator", + "ObjectId": "1a7d78b6-429f-476b-8eb-35fb715fffd4" + }, { "ExtensionData": {}, "Description": "Can manage all aspects of the Skype for Business product.", @@ -631,6 +711,22 @@ "Name": "Teams Devices Administrator", "ObjectId": "3d762c5a-1b6c-493f-843e-55a3b42923d4" }, + { + "ExtensionData": {}, + "Description": "Manage voice and telephony features and troubleshoot communication issues within the Microsoft Teams service.", + "IsEnabled": true, + "IsSystem": true, + "Name": "Teams Telephony Administrator", + "ObjectId": "aa38014f-0993-46e9-9b45-30501a20909d" + }, + { + "ExtensionData": {}, + "Description": "Create new Microsoft Entra or Azure AD B2C tenants.", + "IsEnabled": true, + "IsSystem": true, + "Name": "Tenant Creator", + "ObjectId": "112ca1a2-15ad-4102-995e-45b0bc479a6a" + }, { "ExtensionData": {}, "Description": "Can see only tenant level aggregates in Microsoft 365 Usage Analytics and Productivity Score.", @@ -647,6 +743,14 @@ "Name": "User Administrator", "ObjectId": "fe930be7-5e62-47db-91af-98c3a49a38b1" }, + { + "ExtensionData": {}, + "Description": "View product feedback, survey results, and reports to find training and communication opportunities.", + "IsEnabled": true, + "IsSystem": true, + "Name": "User Experience Success Manager", + "ObjectId": "27460883-1df1-4691-b032-3b79643e5e63" + }, { "ExtensionData": {}, "Description": "Manage and share Virtual Visits information and metrics from admin centers or the Virtual Visits app.", diff --git a/src/data/GraphExplorerPresets.json b/src/data/GraphExplorerPresets.json index f5c549347713..a6939606d285 100644 --- a/src/data/GraphExplorerPresets.json +++ b/src/data/GraphExplorerPresets.json @@ -34,7 +34,7 @@ "id": "e7fdc49a-72a9-4a70-9dbf-a74152495d80", "params": { "endpoint": "/devices", - "$select": "deviceId,DisplayName,profileType,registrationDateTime,trustType", + "$select": "deviceId,displayName,profileType,registrationDateTime,trustType", "$filter": "" }, "isBuiltin": true @@ -44,7 +44,7 @@ "id": "f1844e3d-cb3e-4611-9bab-f5f42169bcd0", "params": { "endpoint": "/contacts", - "$select": "CompanyName,DisplayName,Mail,ProxyAddresses", + "$select": "companyName,displayName,mail,proxyAddresses", "$filter": "" }, "isBuiltin": true @@ -151,5 +151,23 @@ "NoPagination": true }, "isBuiltin": true + }, + { + "name": "User Report with Sign in Activity (Entra ID P1+)", + "id": "7a44a7cf-bf40-4a8a-8eaa-ffd8203f3216", + "params": { + "endpoint": "users", + "$select": "id,displayName,userPrincipalName,accountEnabled,mail,proxyAddresses,createdDateTime,signInActivity.lastSuccessfulSignInDateTime" + }, + "isBuiltin": true + }, + { + "name": "Users with Mailbox Type (Custom Data)", + "id": "f632d3b8-29f3-470c-8483-54cb88004674", + "params": { + "endpoint": "users", + "$select": "id,displayName,userPrincipalName,accountEnabled,mail,proxyAddresses,createdDateTime,%cippuserschema%.mailboxType" + }, + "isBuiltin": true } -] +] \ No newline at end of file diff --git a/src/data/M365Licenses-additional.json b/src/data/M365Licenses-additional.json new file mode 100644 index 000000000000..09733867f764 --- /dev/null +++ b/src/data/M365Licenses-additional.json @@ -0,0 +1,250 @@ +[ + { + "Product_Display_Name": "Office 365 Education E3 for Faculty", + "String_Id": "ENTERPRISEPACK_FACULTY", + "GUID": "e4fa3838-3d01-42df-aa28-5e0a4c68604b", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Office 365 A1 for Faculty", + "String_Id": "STANDARDWOFFPACK_FACULTY", + "GUID": "94763226-9b3c-4e75-a931-5c89701abe66", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Office 365 A5 for Faculty", + "String_Id": "ENTERPRISEPREMIUM_FACULTY", + "GUID": "a4585165-0533-458a-97e3-c400570268c4", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Office 365 A5 without Audio Conferencing for faculty", + "String_Id": "ENTERPRISEPREMIUM_NOPSTNCONF_FACULTY", + "GUID": "9a320620-ca3d-4705-a79d-27c135c96e05", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Office 365 Education E1 for Faculty", + "String_Id": "STANDARDPACK_FACULTY", + "GUID": "a19037fc-48b4-4d57-b079-ce44b7832473", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Microsoft 365 A3 for Faculty", + "String_Id": "M365EDU_A3_FACULTY", + "GUID": "4b590615-0888-425a-a965-b3bf7789848d", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "String_Id": "M365EDU_A5_FACULTY", + "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Microsoft 365 A5 without Audio Conferencing for Faculty", + "String_Id": "M365EDU_A5_NOPSTNCONF_FACULTY", + "GUID": "65200ac3-f927-4407-a3d5-c63562dff461", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Office 365 Education for Homeschool for Faculty", + "String_Id": "STANDARDWOFFPACK_HOMESCHOOL_FAC", + "GUID": "43e691ad-1491-4e8c-8dc9-da6b8262c03b", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Office 365 A1 for Faculty (for Device)", + "String_Id": "STANDARDWOFFPACK_FACULTY_DEVICE", + "GUID": "af4e28de-6b52-4fd3-a5f4-6bf708a304d3", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Microsoft Teams Rooms Basic for EDU", + "String_Id": "Microsoft_Teams_Rooms_Basic_FAC", + "GUID": "a4e376bd-c61e-4618-9901-3fc0cb1b88bb", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Microsoft Teams Rooms Basic without Audio Conferencing for EDU", + "String_Id": "Microsoft_Teams_Rooms_Basic_without_Audio_Conferencing_FAC", + "GUID": "7da0ac23-26f8-4d04-8731-9016d9883340", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Microsoft Teams Rooms Pro for EDU", + "String_Id": "Microsoft_Teams_Rooms_Pro_FAC", + "GUID": "c25e2b36-e161-4946-bef2-69239729f690", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Microsoft Teams Rooms Pro without Audio Conferencing for EDU", + "String_Id": "Microsoft_Teams_Rooms_Pro_without_Audio_Conferencing_FAC", + "GUID": "271f6b1a-de32-4849-bcf4-b79b8a7c2cfe", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Office 365 Education E3 for Students", + "String_Id": "ENTERPRISEPACK_STUDENT", + "GUID": "8fc2205d-4e51-4401-97f0-5c89ef1aafb", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Office 365 A1 for Students", + "String_Id": "STANDARDWOFFPACK_STUDENT", + "GUID": "314c4481-f395-4525-be8b-2ec4bb1e9d91", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Office 365 A5 for Students", + "String_Id": "ENTERPRISEPREMIUM_STUDENT", + "GUID": "ee656612-49fa-43e5-b67e-cb1fdf7699df", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Office 365 A5 without PSTN Conferencing for Students", + "String_Id": "ENTERPRISEPREMIUM_NOPSTNCONF_STUDENT", + "GUID": "1164451b-e2e5-4c9e-8fa6-e5122d90dbdc", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Office 365 Education E1 for Students", + "String_Id": "STANDARDPACK_STUDENT", + "GUID": "d37ba356-38c5-4c82-90da-3d714f72a382", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Microsoft 365 A3 for Students", + "String_Id": "M365EDU_A3_STUDENT", + "GUID": "7cfd9a2b-e110-4c39-bf20-c6a3f36a3121", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Microsoft 365 A3 for Students use benefits", + "String_Id": "M365EDU_A3_STUUSEBNFT", + "GUID": "18250162-5d87-4436-a834-d795c15c80f3", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Microsoft 365 A5 for Students", + "String_Id": "M365EDU_A5_STUDENT", + "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Microsoft 365 A5 Student use benefits", + "String_Id": "M365EDU_A5_STUUSEBNFT", + "GUID": "31d57bc7-3a05-4867-ab53-97a17835a411", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Microsoft 365 A5 without Audio Conferencing for Students", + "String_Id": "M365EDU_A5_NOPSTNCONF_STUDENT", + "GUID": "a25c01ce-bab1-47e9-a6d0-ebe939b99ff9", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Microsoft 365 A5 without Audio Conferencing for Students use benefit", + "String_Id": "M365EDU_A5_NOPSTNCONF_STUUSEBNFT", + "GUID": "81441ae1-0b31-4185-a6c0-32b6b84d419f", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Office 365 A3 for Students", + "String_Id": "ENTERPRISEPACKPLUS_STUDENT", + "GUID": "98b6e773-24d4-4c0d-a968-6e787a1f8204", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Office 365 A3 Student use benefit", + "String_Id": "ENTERPRISEPACKPLUS_STUUSEBNFT", + "GUID": "476aad1e-7a7f-473c-9d20-35665a5cbd4f", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Office 365 A5 Student use benefit", + "String_Id": "ENTERPRISEPREMIUM_STUUSEBNFT", + "GUID": "f6e603f1-1a6d-4d32-a730-34b809cb9731", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Office 365 A5 without Audio Conferencing for Students use benefit", + "String_Id": "ENTERPRISEPREMIUM_NOPSTNCONF_STUUSEBNFT", + "GUID": "bc86c9cd-3058-43ba-9972-141678675ac1", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Office 365 Education for Homeschool for Students", + "String_Id": "STANDARDWOFFPACK_HOMESCHOOL_STU", + "GUID": "afbb89a7-db5f-45fb-8af0-1bc5c5015709", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + }, + { + "Product_Display_Name": "Office 365 A1 for Students (for Device)", + "String_Id": "STANDARDWOFFPACK_STUDENT_DEVICE", + "GUID": "160d609e-ab08-4fce-bc1c-ea13321942ac", + "Service_Plan_Name": "", + "Service_Plan_Id": "", + "Service_Plans_Included_Friendly_Names": "" + } +] diff --git a/src/data/M365Licenses.json b/src/data/M365Licenses.json index aa7ef62fb439..7f24d98cdf4f 100644 --- a/src/data/M365Licenses.json +++ b/src/data/M365Licenses.json @@ -1,4 +1,20 @@ [ + { + "Product_Display_Name": "10-Year Audit Log Retention Add On", + "String_Id": "10_ALR_ADDON", + "GUID": "c2e41e49-e2a2-4c55-832a-cf13ffba1d6a", + "Service_Plan_Name": "Auditing_10Year_ Retention_ Add_On", + "Service_Plan_Id": "7d16094b-4db8-41ff-a182-372a90a85407", + "Service_Plans_Included_Friendly_Names": "Auditing 10Year Retention Add On" + }, + { + "Product_Display_Name": "Advanced Communications", + "String_Id": "ADV_COMMS", + "GUID": "e4654015-5daf-4a48-9b37-4f309dddd88b", + "Service_Plan_Name": "TEAMS_ADVCOMMS", + "Service_Plan_Id": "604ec28a-ae18-4bc6-91b0-11da94504ba9", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Advanced Communications" + }, { "Product_Display_Name": "AI Builder Capacity add-on", "String_Id": "CDSAICAPACITY", @@ -55,6 +71,30 @@ "Service_Plan_Id": "2e6ffd72-52d1-4541-8f6c-938f9a8d4cdc", "Service_Plans_Included_Friendly_Names": "Microsoft Application Protection and Governance (D)" }, + { + "Product_Display_Name": "Azure Information Protection Premium P1 for Government", + "String_Id": "RIGHTSMANAGEMENT_CE_GOV\t", + "GUID": "78362de1-6942-4bb8-83a1-a32aa67e6e2c", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION_GOV", + "Service_Plan_Id": "922ba911-5694-4e99-a794-73aed9bfeec8", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation for Government" + }, + { + "Product_Display_Name": "Azure Information Protection Premium P1 for Government", + "String_Id": "RIGHTSMANAGEMENT_CE_GOV\t", + "GUID": "78362de1-6942-4bb8-83a1-a32aa67e6e2c", + "Service_Plan_Name": "RMS_S_PREMIUM_GOV", + "Service_Plan_Id": "1b66aedf-8ca1-4f73-af76-ec76c6180f98", + "Service_Plans_Included_Friendly_Names": "Azure Information Protection Premium P1 for GCC" + }, + { + "Product_Display_Name": "Azure Information Protection Premium P1 for Government", + "String_Id": "RIGHTSMANAGEMENT_CE_GOV\t", + "GUID": "78362de1-6942-4bb8-83a1-a32aa67e6e2c", + "Service_Plan_Name": "RMS_S_ENTERPRISE_GOV", + "Service_Plan_Id": "6a76346d-5d6e-4051-9fe3-ed3f312b5597", + "Service_Plans_Included_Friendly_Names": "Azure Rights Management" + }, { "Product_Display_Name": "Career Coach for faculty", "String_Id": "CAREERCOACH_FACULTY", @@ -127,6 +167,14 @@ "Service_Plan_Id": "f7e5b77d-f293-410a-bae8-f941f19fe680", "Service_Plans_Included_Friendly_Names": "OneDrive for Business (Clipchamp)" }, + { + "Product_Display_Name": "Clipchamp Premium Add-on", + "String_Id": "Clipchamp_Premium_Add_on", + "GUID": "4b2c20e4-939d-4bf4-9dd8-6870240cfe19", + "Service_Plan_Name": "CLIPCHAMP_PREMIUM", + "Service_Plan_Id": "430b908f-78e1-4812-b045-cf83320e7d5d", + "Service_Plans_Included_Friendly_Names": "Microsoft Clipchamp Premium" + }, { "Product_Display_Name": "Microsoft 365 Audio Conferencing", "String_Id": "MCOMEETADV", @@ -479,6 +527,14 @@ "Service_Plan_Id": "3a117d30-cfac-4f00-84ac-54f8b6a18d78", "Service_Plans_Included_Friendly_Names": "Compliance Manager Premium Assessment Add-On" }, + { + "Product_Display_Name": "Compliance Program for Microsoft Cloud", + "String_Id": "Compliance_Program_for_Microsoft_Cloud", + "GUID": "10dd46b2-c5ad-4de3-865c-a6fa1363fb51", + "Service_Plan_Name": "CPMC", + "Service_Plan_Id": "1265e154-5544-4197-bba1-03ef69c3b180", + "Service_Plans_Included_Friendly_Names": "Compliance Program for Microsoft Cloud" + }, { "Product_Display_Name": "Defender Threat Intelligence", "String_Id": "Defender_Threat_Intelligence", @@ -1279,6 +1335,30 @@ "Service_Plan_Id": "7e6d7d78-73de-46ba-83b1-6d25117334ba", "Service_Plans_Included_Friendly_Names": "Power Automate for Dynamics 365" }, + { + "Product_Display_Name": "Dynamics 365 Customer Service Voice Channel Add-in", + "String_Id": "DYN365_CS_VOICE", + "GUID": "dadd2312-b5b1-4fa0-8c15-0903de3e2303", + "Service_Plan_Name": "DYN365_CS_VOICE", + "Service_Plan_Id": "f6ec6dfa-2402-468d-a455-89be11116d43", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 for Customer Service Voice Add-in" + }, + { + "Product_Display_Name": "Dynamics 365 Customer Service Voice Channel Add-in", + "String_Id": "DYN365_CS_VOICE", + "GUID": "dadd2312-b5b1-4fa0-8c15-0903de3e2303", + "Service_Plan_Name": "POWER_VIRTUAL_AGENTS_D365_CS_VOICE", + "Service_Plan_Id": "a3dce1be-e9ca-453a-9483-e69a5b46ce98", + "Service_Plans_Included_Friendly_Names": "Power Virtual Agents for Customer Service Voice" + }, + { + "Product_Display_Name": "Dynamics 365 Customer Service Voice Channel Add-in", + "String_Id": "DYN365_CS_VOICE", + "GUID": "dadd2312-b5b1-4fa0-8c15-0903de3e2303", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION", + "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation" + }, { "Product_Display_Name": "Dynamics 365 Customer Insights Standalone", "String_Id": "DYN365_CUSTOMER_INSIGHTS_BASE", @@ -1855,6 +1935,30 @@ "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", "Service_Plans_Included_Friendly_Names": "Exchange Foundation" }, + { + "Product_Display_Name": "Dynamics 365 Field Service Contractor", + "String_Id": "D365_FIELD_SERVICE_CONTRACTOR", + "GUID": "23e6e135-e869-4ce4-9ae4-5710cd69ac13", + "Service_Plan_Name": "CDS_FIELD_SERVICE_CONTRACTOR", + "Service_Plan_Id": "f4614a66-d632-443a-bc77-afe92987b322", + "Service_Plans_Included_Friendly_Names": "Common Data Service Field service Part Time Contractors" + }, + { + "Product_Display_Name": "Dynamics 365 Field Service Contractor", + "String_Id": "D365_FIELD_SERVICE_CONTRACTOR", + "GUID": "23e6e135-e869-4ce4-9ae4-5710cd69ac13", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION", + "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation" + }, + { + "Product_Display_Name": "Dynamics 365 Field Service Contractor", + "String_Id": "D365_FIELD_SERVICE_CONTRACTOR", + "GUID": "23e6e135-e869-4ce4-9ae4-5710cd69ac13", + "Service_Plan_Name": "POWERAPPS_DYN_APPS", + "Service_Plan_Id": "874fc546-6efe-4d22-90b8-5c4e7aa59f4b", + "Service_Plans_Included_Friendly_Names": "Power Apps for Dynamics 365" + }, { "Product_Display_Name": "Dynamics 365 Field Service Contractor for Government", "String_Id": "D365_FIELD_SERVICE_CONTRACTOR_GOV", @@ -1967,6 +2071,118 @@ "Service_Plan_Id": "7e6d7d78-73de-46ba-83b1-6d25117334ba", "Service_Plans_Included_Friendly_Names": "Power Automate for Dynamics 365" }, + { + "Product_Display_Name": "Dynamics 365 for Finance Attach to Qualifying Dynamics 365 Base Offer", + "String_Id": "DYN365_FINANCE_ATTACH", + "GUID": "d721f2e4-099b-4105-b40e-872e46cad402", + "Service_Plan_Name": "Power_Pages_Internal_User", + "Service_Plan_Id": "60bf28f9-2b70-4522-96f7-335f5e06c941", + "Service_Plans_Included_Friendly_Names": "Power Pages Internal User" + }, + { + "Product_Display_Name": "Dynamics 365 for Finance Attach to Qualifying Dynamics 365 Base Offer", + "String_Id": "DYN365_FINANCE_ATTACH", + "GUID": "d721f2e4-099b-4105-b40e-872e46cad402", + "Service_Plan_Name": "CDS_AI_Capacity_FI", + "Service_Plan_Id": "5d85ec34-44e5-43b6-a9aa-d1b4c1d3aa3b", + "Service_Plans_Included_Friendly_Names": "AI Builder Capacity Add-on" + }, + { + "Product_Display_Name": "Dynamics 365 for Finance Attach to Qualifying Dynamics 365 Base Offer", + "String_Id": "DYN365_FINANCE_ATTACH", + "GUID": "d721f2e4-099b-4105-b40e-872e46cad402", + "Service_Plan_Name": "DYN365_CDS_FINANCE", + "Service_Plan_Id": "e95d7060-d4d9-400a-a2bd-a244bf0b609e", + "Service_Plans_Included_Friendly_Names": "Common Data Service for Dynamics 365 Finance" + }, + { + "Product_Display_Name": "Dynamics 365 for Finance Attach to Qualifying Dynamics 365 Base Offer", + "String_Id": "DYN365_FINANCE_ATTACH", + "GUID": "d721f2e4-099b-4105-b40e-872e46cad402", + "Service_Plan_Name": "DYN365_REGULATORY_SERVICE", + "Service_Plan_Id": "c7657ae3-c0b0-4eed-8c1d-6a7967bd9c65", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 for Finance and Operations, Enterprise edition - Regulatory Service" + }, + { + "Product_Display_Name": "Dynamics 365 for Finance Attach to Qualifying Dynamics 365 Base Offer", + "String_Id": "DYN365_FINANCE_ATTACH", + "GUID": "d721f2e4-099b-4105-b40e-872e46cad402", + "Service_Plan_Name": "D365_Finance_Attach", + "Service_Plan_Id": "223e33cb-eee0-462d-b1bd-e9a5febf8e85", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 for Finance Attach" + }, + { + "Product_Display_Name": "Dynamics 365 for Finance Attach to Qualifying Dynamics 365 Base Offer", + "String_Id": "DYN365_FINANCE_ATTACH", + "GUID": "d721f2e4-099b-4105-b40e-872e46cad402", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION", + "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation" + }, + { + "Product_Display_Name": "Dynamics 365 Finance Attach to Qualifying Base Offer Embedded with Project Management & Accounting", + "String_Id": "DYN365_FINANCE_ATTACH_ISVEMB_PROJOPS", + "GUID": "db5bd06c-b99a-4c54-98e9-90fea5164c88", + "Service_Plan_Name": "D365_ProjectOperationsCDSAttach", + "Service_Plan_Id": "e564d403-7eaf-4c91-b92f-bb0dc62026e1", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Project Operations CDS Attach" + }, + { + "Product_Display_Name": "Dynamics 365 Finance Attach to Qualifying Base Offer Embedded with Project Management & Accounting", + "String_Id": "DYN365_FINANCE_ATTACH_ISVEMB_PROJOPS", + "GUID": "db5bd06c-b99a-4c54-98e9-90fea5164c88", + "Service_Plan_Name": "Power_Pages_Internal_User", + "Service_Plan_Id": "60bf28f9-2b70-4522-96f7-335f5e06c941", + "Service_Plans_Included_Friendly_Names": "Power Pages Internal User" + }, + { + "Product_Display_Name": "Dynamics 365 Finance Attach to Qualifying Base Offer Embedded with Project Management & Accounting", + "String_Id": "DYN365_FINANCE_ATTACH_ISVEMB_PROJOPS", + "GUID": "db5bd06c-b99a-4c54-98e9-90fea5164c88", + "Service_Plan_Name": "CDS_AI_Capacity_FI", + "Service_Plan_Id": "5d85ec34-44e5-43b6-a9aa-d1b4c1d3aa3b", + "Service_Plans_Included_Friendly_Names": "AI Builder Capacity Add-on" + }, + { + "Product_Display_Name": "Dynamics 365 Finance Attach to Qualifying Base Offer Embedded with Project Management & Accounting", + "String_Id": "DYN365_FINANCE_ATTACH_ISVEMB_PROJOPS", + "GUID": "db5bd06c-b99a-4c54-98e9-90fea5164c88", + "Service_Plan_Name": "D365_Finance_Attach", + "Service_Plan_Id": "223e33cb-eee0-462d-b1bd-e9a5febf8e85", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 for Finance Attach" + }, + { + "Product_Display_Name": "Dynamics 365 Finance Attach to Qualifying Base Offer Embedded with Project Management & Accounting", + "String_Id": "DYN365_FINANCE_ATTACH_ISVEMB_PROJOPS", + "GUID": "db5bd06c-b99a-4c54-98e9-90fea5164c88", + "Service_Plan_Name": "D365_ProjectOperationsAttach", + "Service_Plan_Id": "fa7675bd-6717-40e7-8172-d0bbcbe1ab12", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Project Operations Attach" + }, + { + "Product_Display_Name": "Dynamics 365 Finance Attach to Qualifying Base Offer Embedded with Project Management & Accounting", + "String_Id": "DYN365_FINANCE_ATTACH_ISVEMB_PROJOPS", + "GUID": "db5bd06c-b99a-4c54-98e9-90fea5164c88", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION", + "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation" + }, + { + "Product_Display_Name": "Dynamics 365 Finance Attach to Qualifying Base Offer Embedded with Project Management & Accounting", + "String_Id": "DYN365_FINANCE_ATTACH_ISVEMB_PROJOPS", + "GUID": "db5bd06c-b99a-4c54-98e9-90fea5164c88", + "Service_Plan_Name": "PROJECT_FOR_PROJECT_OPERATIONS_ATTACH", + "Service_Plan_Id": "6d8e07c6-9613-484f-8cc1-a66c5c3979bb", + "Service_Plans_Included_Friendly_Names": "Project for Project Operations Attach" + }, + { + "Product_Display_Name": "Dynamics 365 Finance Attach to Qualifying Base Offer Embedded with Project Management & Accounting", + "String_Id": "DYN365_FINANCE_ATTACH_ISVEMB_PROJOPS", + "GUID": "db5bd06c-b99a-4c54-98e9-90fea5164c88", + "Service_Plan_Name": "SHAREPOINTSTANDARD", + "Service_Plan_Id": "c7699d2e-19aa-44de-8edf-1736da088ca1", + "Service_Plans_Included_Friendly_Names": "SharePoint (Plan 1)" + }, { "Product_Display_Name": "Dynamics 365 for Case Management Enterprise Edition", "String_Id": "DYN365_ENTERPRISE_CASE_MANAGEMENT", @@ -2263,6 +2479,542 @@ "Service_Plan_Id": "7e6d7d78-73de-46ba-83b1-6d25117334ba", "Service_Plans_Included_Friendly_Names": "Power Automate for Dynamics 365" }, + { + "Product_Display_Name": "Dynamics 365 Contact Center", + "String_Id": "DYNAMICS_365_CONTACT_CENTER", + "GUID": "dfb1700c-013e-4132-8bce-0d319c43a95d", + "Service_Plan_Name": "DYN365_CC", + "Service_Plan_Id": "2a9d72b3-1714-440f-babf-bf92bf9683d8", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Contact Center" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center", + "String_Id": "DYNAMICS_365_CONTACT_CENTER", + "GUID": "dfb1700c-013e-4132-8bce-0d319c43a95d", + "Service_Plan_Name": "DYN365_CS_MESSAGING_TPS", + "Service_Plan_Id": "47c2b191-a5fb-4129-b690-00c474d2f623", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Customer Service Digital Messaging add-on" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center", + "String_Id": "DYNAMICS_365_CONTACT_CENTER", + "GUID": "dfb1700c-013e-4132-8bce-0d319c43a95d", + "Service_Plan_Name": "DYN365_CS_VOICE", + "Service_Plan_Id": "f6ec6dfa-2402-468d-a455-89be11116d43", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 for Customer Service Voice Add-in" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center", + "String_Id": "DYNAMICS_365_CONTACT_CENTER", + "GUID": "dfb1700c-013e-4132-8bce-0d319c43a95d", + "Service_Plan_Name": "Power_Pages_Internal_User", + "Service_Plan_Id": "60bf28f9-2b70-4522-96f7-335f5e06c941", + "Service_Plans_Included_Friendly_Names": "Power Pages Internal User" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center", + "String_Id": "DYNAMICS_365_CONTACT_CENTER", + "GUID": "dfb1700c-013e-4132-8bce-0d319c43a95d", + "Service_Plan_Name": "POWER_VIRTUAL_AGENTS_D365_CS_VOICE", + "Service_Plan_Id": "a3dce1be-e9ca-453a-9483-e69a5b46ce98", + "Service_Plans_Included_Friendly_Names": "Power Virtual Agents for Customer Service Voice" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center", + "String_Id": "DYNAMICS_365_CONTACT_CENTER", + "GUID": "dfb1700c-013e-4132-8bce-0d319c43a95d", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION", + "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center", + "String_Id": "DYNAMICS_365_CONTACT_CENTER", + "GUID": "dfb1700c-013e-4132-8bce-0d319c43a95d", + "Service_Plan_Name": "SHAREPOINTWAC", + "Service_Plan_Id": "e95bec33-7c88-4a70-8e19-b10bd9d0c014", + "Service_Plans_Included_Friendly_Names": "Office for the Web" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center", + "String_Id": "DYNAMICS_365_CONTACT_CENTER", + "GUID": "dfb1700c-013e-4132-8bce-0d319c43a95d", + "Service_Plan_Name": "SHAREPOINTENTERPRISE", + "Service_Plan_Id": "5dbe027f-2339-4123-9542-606e4d348a72", + "Service_Plans_Included_Friendly_Names": "SharePoint (Plan 2)" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center", + "String_Id": "DYNAMICS_365_CONTACT_CENTER", + "GUID": "dfb1700c-013e-4132-8bce-0d319c43a95d", + "Service_Plan_Name": "POWERAPPS_DYN_APPS", + "Service_Plan_Id": "874fc546-6efe-4d22-90b8-5c4e7aa59f4b", + "Service_Plans_Included_Friendly_Names": "Power Apps for Dynamics 365" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center", + "String_Id": "DYNAMICS_365_CONTACT_CENTER", + "GUID": "dfb1700c-013e-4132-8bce-0d319c43a95d", + "Service_Plan_Name": "FLOW_DYN_APPS", + "Service_Plan_Id": "7e6d7d78-73de-46ba-83b1-6d25117334ba", + "Service_Plans_Included_Friendly_Names": "Power Automate for Dynamics 365" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "39a78eb6-3a8a-4e1e-878a-575a5c8984e7", + "Service_Plan_Name": "DYN365_CC", + "Service_Plan_Id": "2a9d72b3-1714-440f-babf-bf92bf9683d8", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Contact Center" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "39a78eb6-3a8a-4e1e-878a-575a5c8984e7", + "Service_Plan_Name": "DYN365_CS_MESSAGING_TPS", + "Service_Plan_Id": "47c2b191-a5fb-4129-b690-00c474d2f623", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Customer Service Digital Messaging add-on" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "39a78eb6-3a8a-4e1e-878a-575a5c8984e7", + "Service_Plan_Name": "DYN365_CS_VOICE", + "Service_Plan_Id": "f6ec6dfa-2402-468d-a455-89be11116d43", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 for Customer Service Voice Add-in" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "39a78eb6-3a8a-4e1e-878a-575a5c8984e7", + "Service_Plan_Name": "Power_Pages_Internal_User", + "Service_Plan_Id": "60bf28f9-2b70-4522-96f7-335f5e06c941", + "Service_Plans_Included_Friendly_Names": "Power Pages Internal User" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "39a78eb6-3a8a-4e1e-878a-575a5c8984e7", + "Service_Plan_Name": "POWER_VIRTUAL_AGENTS_D365_CS_VOICE", + "Service_Plan_Id": "a3dce1be-e9ca-453a-9483-e69a5b46ce98", + "Service_Plans_Included_Friendly_Names": "Power Virtual Agents for Customer Service Voice" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "39a78eb6-3a8a-4e1e-878a-575a5c8984e7", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION", + "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "39a78eb6-3a8a-4e1e-878a-575a5c8984e7", + "Service_Plan_Name": "SHAREPOINTWAC", + "Service_Plan_Id": "e95bec33-7c88-4a70-8e19-b10bd9d0c014", + "Service_Plans_Included_Friendly_Names": "Office for the Web" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "39a78eb6-3a8a-4e1e-878a-575a5c8984e7", + "Service_Plan_Name": "SHAREPOINTENTERPRISE", + "Service_Plan_Id": "5dbe027f-2339-4123-9542-606e4d348a72", + "Service_Plans_Included_Friendly_Names": "SharePoint (Plan 2)" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "39a78eb6-3a8a-4e1e-878a-575a5c8984e7", + "Service_Plan_Name": "POWERAPPS_DYN_APPS", + "Service_Plan_Id": "874fc546-6efe-4d22-90b8-5c4e7aa59f4b", + "Service_Plans_Included_Friendly_Names": "Power Apps for Dynamics 365" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "39a78eb6-3a8a-4e1e-878a-575a5c8984e7", + "Service_Plan_Name": "FLOW_DYN_APPS", + "Service_Plan_Id": "7e6d7d78-73de-46ba-83b1-6d25117334ba", + "Service_Plans_Included_Friendly_Names": "Power Automate for Dynamics 365" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Digital", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_DIGITAL", + "GUID": "59d3d0bf-df39-4b8b-8601-ea6c09a7fd66", + "Service_Plan_Name": "DYN365_CC_DIGITAL", + "Service_Plan_Id": "0ef2b4e3-0a2b-450d-8c5f-a52203c40f50", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Contact Center Digital" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Digital", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_DIGITAL", + "GUID": "59d3d0bf-df39-4b8b-8601-ea6c09a7fd66", + "Service_Plan_Name": "DYN365_CS_MESSAGING_TPS", + "Service_Plan_Id": "47c2b191-a5fb-4129-b690-00c474d2f623", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Customer Service Digital Messaging add-on" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Digital", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_DIGITAL", + "GUID": "59d3d0bf-df39-4b8b-8601-ea6c09a7fd66", + "Service_Plan_Name": "DYN365_CS_MESSAGING", + "Service_Plan_Id": "43b076f2-1123-45ba-a339-2e170ee58c53", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Customer Service Digital Messaging Application Integration" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Digital", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_DIGITAL", + "GUID": "59d3d0bf-df39-4b8b-8601-ea6c09a7fd66", + "Service_Plan_Name": "Power_Pages_Internal_User", + "Service_Plan_Id": "60bf28f9-2b70-4522-96f7-335f5e06c941", + "Service_Plans_Included_Friendly_Names": "Power Pages Internal User" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Digital", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_DIGITAL", + "GUID": "59d3d0bf-df39-4b8b-8601-ea6c09a7fd66", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION", + "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Digital", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_DIGITAL", + "GUID": "59d3d0bf-df39-4b8b-8601-ea6c09a7fd66", + "Service_Plan_Name": "SHAREPOINTWAC", + "Service_Plan_Id": "e95bec33-7c88-4a70-8e19-b10bd9d0c014", + "Service_Plans_Included_Friendly_Names": "Office for the Web" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Digital", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_DIGITAL", + "GUID": "59d3d0bf-df39-4b8b-8601-ea6c09a7fd66", + "Service_Plan_Name": "SHAREPOINTENTERPRISE", + "Service_Plan_Id": "5dbe027f-2339-4123-9542-606e4d348a72", + "Service_Plans_Included_Friendly_Names": "SharePoint (Plan 2)" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Digital", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_DIGITAL", + "GUID": "59d3d0bf-df39-4b8b-8601-ea6c09a7fd66", + "Service_Plan_Name": "POWERAPPS_DYN_APPS", + "Service_Plan_Id": "874fc546-6efe-4d22-90b8-5c4e7aa59f4b", + "Service_Plans_Included_Friendly_Names": "Power Apps for Dynamics 365" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Digital", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_DIGITAL", + "GUID": "59d3d0bf-df39-4b8b-8601-ea6c09a7fd66", + "Service_Plan_Name": "FLOW_DYN_APPS", + "Service_Plan_Id": "7e6d7d78-73de-46ba-83b1-6d25117334ba", + "Service_Plans_Included_Friendly_Names": "Power Automate for Dynamics 365" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Digital Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_DIGITAL_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "7e6e6091-1680-4532-9370-6cd4598483ac", + "Service_Plan_Name": "DYN365_CC_DIGITAL", + "Service_Plan_Id": "0ef2b4e3-0a2b-450d-8c5f-a52203c40f50", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Contact Center Digital" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Digital Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_DIGITAL_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "7e6e6091-1680-4532-9370-6cd4598483ac", + "Service_Plan_Name": "DYN365_CS_MESSAGING_TPS", + "Service_Plan_Id": "47c2b191-a5fb-4129-b690-00c474d2f623", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Customer Service Digital Messaging add-on" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Digital Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_DIGITAL_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "7e6e6091-1680-4532-9370-6cd4598483ac", + "Service_Plan_Name": "DYN365_CS_MESSAGING", + "Service_Plan_Id": "43b076f2-1123-45ba-a339-2e170ee58c53", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Customer Service Digital Messaging Application Integration" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Digital Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_DIGITAL_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "7e6e6091-1680-4532-9370-6cd4598483ac", + "Service_Plan_Name": "Power_Pages_Internal_User", + "Service_Plan_Id": "60bf28f9-2b70-4522-96f7-335f5e06c941", + "Service_Plans_Included_Friendly_Names": "Power Pages Internal User" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Digital Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_DIGITAL_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "7e6e6091-1680-4532-9370-6cd4598483ac", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION", + "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Digital Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_DIGITAL_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "7e6e6091-1680-4532-9370-6cd4598483ac", + "Service_Plan_Name": "SHAREPOINTWAC", + "Service_Plan_Id": "e95bec33-7c88-4a70-8e19-b10bd9d0c014", + "Service_Plans_Included_Friendly_Names": "Office for the Web" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Digital Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_DIGITAL_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "7e6e6091-1680-4532-9370-6cd4598483ac", + "Service_Plan_Name": "SHAREPOINTENTERPRISE", + "Service_Plan_Id": "5dbe027f-2339-4123-9542-606e4d348a72", + "Service_Plans_Included_Friendly_Names": "SharePoint (Plan 2)" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Digital Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_DIGITAL_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "7e6e6091-1680-4532-9370-6cd4598483ac", + "Service_Plan_Name": "POWERAPPS_DYN_APPS", + "Service_Plan_Id": "874fc546-6efe-4d22-90b8-5c4e7aa59f4b", + "Service_Plans_Included_Friendly_Names": "Power Apps for Dynamics 365" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Digital Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_DIGITAL_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "7e6e6091-1680-4532-9370-6cd4598483ac", + "Service_Plan_Name": "FLOW_DYN_APPS", + "Service_Plan_Id": "7e6d7d78-73de-46ba-83b1-6d25117334ba", + "Service_Plans_Included_Friendly_Names": "Power Automate for Dynamics 365" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Voice", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_VOICE", + "GUID": "79e2368c-4568-48d5-a352-b0344afabcf8", + "Service_Plan_Name": "DYN365_CC_VOICE", + "Service_Plan_Id": "57517633-b4ad-4db8-8c1a-65f443424490", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Contact Center Voice" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Voice", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_VOICE", + "GUID": "79e2368c-4568-48d5-a352-b0344afabcf8", + "Service_Plan_Name": "DYN365_CS_VOICE", + "Service_Plan_Id": "f6ec6dfa-2402-468d-a455-89be11116d43", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 for Customer Service Voice Add-in" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Voice", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_VOICE", + "GUID": "79e2368c-4568-48d5-a352-b0344afabcf8", + "Service_Plan_Name": "Power_Pages_Internal_User", + "Service_Plan_Id": "60bf28f9-2b70-4522-96f7-335f5e06c941", + "Service_Plans_Included_Friendly_Names": "Power Pages Internal User" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Voice", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_VOICE", + "GUID": "79e2368c-4568-48d5-a352-b0344afabcf8", + "Service_Plan_Name": "POWER_VIRTUAL_AGENTS_D365_CS_VOICE", + "Service_Plan_Id": "a3dce1be-e9ca-453a-9483-e69a5b46ce98", + "Service_Plans_Included_Friendly_Names": "Power Virtual Agents for Customer Service Voice" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Voice", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_VOICE", + "GUID": "79e2368c-4568-48d5-a352-b0344afabcf8", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION", + "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Voice", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_VOICE", + "GUID": "79e2368c-4568-48d5-a352-b0344afabcf8", + "Service_Plan_Name": "SHAREPOINTWAC", + "Service_Plan_Id": "e95bec33-7c88-4a70-8e19-b10bd9d0c014", + "Service_Plans_Included_Friendly_Names": "Office for the Web" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Voice", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_VOICE", + "GUID": "79e2368c-4568-48d5-a352-b0344afabcf8", + "Service_Plan_Name": "SHAREPOINTENTERPRISE", + "Service_Plan_Id": "5dbe027f-2339-4123-9542-606e4d348a72", + "Service_Plans_Included_Friendly_Names": "SharePoint (Plan 2)" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Voice", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_VOICE", + "GUID": "79e2368c-4568-48d5-a352-b0344afabcf8", + "Service_Plan_Name": "POWERAPPS_DYN_APPS", + "Service_Plan_Id": "874fc546-6efe-4d22-90b8-5c4e7aa59f4b", + "Service_Plans_Included_Friendly_Names": "Power Apps for Dynamics 365" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Voice", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_VOICE", + "GUID": "79e2368c-4568-48d5-a352-b0344afabcf8", + "Service_Plan_Name": "FLOW_DYN_APPS", + "Service_Plan_Id": "7e6d7d78-73de-46ba-83b1-6d25117334ba", + "Service_Plans_Included_Friendly_Names": "Power Automate for Dynamics 365" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Voice Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_VOICE_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "73e8b747-20bf-463d-8ffd-274a7d65d0bc", + "Service_Plan_Name": "DYN365_CC_VOICE", + "Service_Plan_Id": "57517633-b4ad-4db8-8c1a-65f443424490", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Contact Center Voice" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Voice Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_VOICE_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "73e8b747-20bf-463d-8ffd-274a7d65d0bc", + "Service_Plan_Name": "DYN365_CS_VOICE", + "Service_Plan_Id": "f6ec6dfa-2402-468d-a455-89be11116d43", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 for Customer Service Voice Add-in" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Voice Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_VOICE_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "73e8b747-20bf-463d-8ffd-274a7d65d0bc", + "Service_Plan_Name": "Power_Pages_Internal_User", + "Service_Plan_Id": "60bf28f9-2b70-4522-96f7-335f5e06c941", + "Service_Plans_Included_Friendly_Names": "Power Pages Internal User" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Voice Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_VOICE_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "73e8b747-20bf-463d-8ffd-274a7d65d0bc", + "Service_Plan_Name": "POWER_VIRTUAL_AGENTS_D365_CS_VOICE", + "Service_Plan_Id": "a3dce1be-e9ca-453a-9483-e69a5b46ce98", + "Service_Plans_Included_Friendly_Names": "Power Virtual Agents for Customer Service Voice" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Voice Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_VOICE_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "73e8b747-20bf-463d-8ffd-274a7d65d0bc", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION", + "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Voice Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_VOICE_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "73e8b747-20bf-463d-8ffd-274a7d65d0bc", + "Service_Plan_Name": "SHAREPOINTWAC", + "Service_Plan_Id": "e95bec33-7c88-4a70-8e19-b10bd9d0c014", + "Service_Plans_Included_Friendly_Names": "Office for the Web" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Voice Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_VOICE_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "73e8b747-20bf-463d-8ffd-274a7d65d0bc", + "Service_Plan_Name": "SHAREPOINTENTERPRISE", + "Service_Plan_Id": "5dbe027f-2339-4123-9542-606e4d348a72", + "Service_Plans_Included_Friendly_Names": "SharePoint (Plan 2)" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Voice Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_VOICE_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "73e8b747-20bf-463d-8ffd-274a7d65d0bc", + "Service_Plan_Name": "POWERAPPS_DYN_APPS", + "Service_Plan_Id": "874fc546-6efe-4d22-90b8-5c4e7aa59f4b", + "Service_Plans_Included_Friendly_Names": "Power Apps for Dynamics 365" + }, + { + "Product_Display_Name": "Dynamics 365 Contact Center Voice Add-on for Customer Service Enterprise", + "String_Id": "DYNAMICS_365_CONTACT_CENTER_VOICE_ADD_ON_FOR_CUSTOMER_SERVICE_ENTERPRISE", + "GUID": "73e8b747-20bf-463d-8ffd-274a7d65d0bc", + "Service_Plan_Name": "FLOW_DYN_APPS", + "Service_Plan_Id": "7e6d7d78-73de-46ba-83b1-6d25117334ba", + "Service_Plans_Included_Friendly_Names": "Power Automate for Dynamics 365" + }, + { + "Product_Display_Name": "Dynamics 365 Customer Insights Attach", + "String_Id": "Dynamics_365_Customer_Insights_Attach_New", + "GUID": "ff22b8d4-5073-4b24-ba45-84ad5d9b6642", + "Service_Plan_Name": "CDS_CUSTOMER_INSIGHTS_BASE", + "Service_Plan_Id": "d04ca659-b119-4a92-b8fc-3ede584a9d65", + "Service_Plans_Included_Friendly_Names": "Dataverse for Cust InsightsΒ BASE" + }, + { + "Product_Display_Name": "Dynamics 365 Customer Insights Attach", + "String_Id": "Dynamics_365_Customer_Insights_Attach_New", + "GUID": "ff22b8d4-5073-4b24-ba45-84ad5d9b6642", + "Service_Plan_Name": "CDS_CUSTOMER_INSIGHTS_COMBINED_BASE", + "Service_Plan_Id": "d66ee5da-07d5-49d6-a1d8-45662c3f37be", + "Service_Plans_Included_Friendly_Names": "Dataverse for Customer Insights Combined Base" + }, + { + "Product_Display_Name": "Dynamics 365 Customer Insights Attach", + "String_Id": "Dynamics_365_Customer_Insights_Attach_New", + "GUID": "ff22b8d4-5073-4b24-ba45-84ad5d9b6642", + "Service_Plan_Name": "DYN365_CUSTOMER_INSIGHTS_JOURNEYS_BASE", + "Service_Plan_Id": "1720c3f7-7da3-4a11-8324-92aad283eb68", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Customer Insights Journeys" + }, + { + "Product_Display_Name": "Dynamics 365 Customer Insights Attach", + "String_Id": "Dynamics_365_Customer_Insights_Attach_New", + "GUID": "ff22b8d4-5073-4b24-ba45-84ad5d9b6642", + "Service_Plan_Name": "Forms_Pro_Marketing_App", + "Service_Plan_Id": "22b657cf-0a9e-467b-8a91-5e31f21bc570", + "Service_Plans_Included_Friendly_Names": "Microsoft Dynamics 365 Customer Voice for Marketing Application" + }, + { + "Product_Display_Name": "Dynamics 365 Customer Insights Attach", + "String_Id": "Dynamics_365_Customer_Insights_Attach_New", + "GUID": "ff22b8d4-5073-4b24-ba45-84ad5d9b6642", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION", + "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation" + }, + { + "Product_Display_Name": "Dynamics 365 Customer Insights Journeys T3 Interacted People", + "String_Id": "Dynamics_365_Customer_Insights_Journeys_T3_Interacted_People", + "GUID": "05735051-46c0-4c84-9107-bb13d77d0b88", + "Service_Plan_Name": "CDS_CUSTOMER_INSIGHTS_JOURNEYS_ADD-ON", + "Service_Plan_Id": "2f2e81a6-15de-4041-9f33-73c06fed3801", + "Service_Plans_Included_Friendly_Names": "Dataverse for Customer Insights Journeys add-on" + }, + { + "Product_Display_Name": "Dynamics 365 Customer Insights Journeys T3 Interacted People", + "String_Id": "Dynamics_365_Customer_Insights_Journeys_T3_Interacted_People", + "GUID": "05735051-46c0-4c84-9107-bb13d77d0b88", + "Service_Plan_Name": "DYN365_MARKETING_50K_CONTACT_ADDON", + "Service_Plan_Id": "e626a4ec-1ba2-409e-bf75-9bc0bc30cca7", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 for Marketing 50K Addnl Contacts" + }, + { + "Product_Display_Name": "Dynamics 365 Customer Insights Journeys T3 Interacted People", + "String_Id": "Dynamics_365_Customer_Insights_Journeys_T3_Interacted_People", + "GUID": "05735051-46c0-4c84-9107-bb13d77d0b88", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION", + "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation" + }, + { + "Product_Display_Name": "Dynamics 365 Customer Insights User License", + "String_Id": "Dynamics_365_Customer_Insights_User_License", + "GUID": "12b5a442-a6f2-49e4-868b-2d7408c2356f", + "Service_Plan_Name": "DYN365_MARKETING_MSE_USER", + "Service_Plan_Id": "2824c69a-1ac5-4397-8592-eae51cb8b581", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 for Marketing MSE User" + }, + { + "Product_Display_Name": "Dynamics 365 Customer Insights User License", + "String_Id": "Dynamics_365_Customer_Insights_User_License", + "GUID": "12b5a442-a6f2-49e4-868b-2d7408c2356f", + "Service_Plan_Name": "DYN365_MARKETING_USER", + "Service_Plan_Id": "5d7a6abc-eebd-46ab-96e1-e4a2f54a2248", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 for Marketing USL" + }, + { + "Product_Display_Name": "Dynamics 365 Customer Insights User License", + "String_Id": "Dynamics_365_Customer_Insights_User_License", + "GUID": "12b5a442-a6f2-49e4-868b-2d7408c2356f", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION", + "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation" + }, { "Product_Display_Name": "Dynamics 365 for Customer Service Chat", "String_Id": "DYN365_CS_CHAT", @@ -2727,6 +3479,62 @@ "Service_Plan_Id": "26fa8a18-2812-4b3d-96b4-864818ce26be", "Service_Plans_Included_Friendly_Names": "Power Automate for Dynamics 365 Mixed Reality" }, + { + "Product_Display_Name": "Dynamics 365 Human Resources Attach to Qualifying Dynamics 365 Base Offer", + "String_Id": "DYN365_HUMAN_RESOURCES_ATTACH", + "GUID": "83c489a4-94b6-4dcc-9fdc-ff9b107a4621", + "Service_Plan_Name": "Forms_Pro_Talent", + "Service_Plan_Id": "1c4ae475-5608-43fa-b3f7-d20e07cf24b4", + "Service_Plans_Included_Friendly_Names": "Microsoft Dynamics 365 Customer Voice for Talent" + }, + { + "Product_Display_Name": "Dynamics 365 Human Resources Attach to Qualifying Dynamics 365 Base Offer", + "String_Id": "DYN365_HUMAN_RESOURCES_ATTACH", + "GUID": "83c489a4-94b6-4dcc-9fdc-ff9b107a4621", + "Service_Plan_Name": "Power_Pages_Internal_User", + "Service_Plan_Id": "60bf28f9-2b70-4522-96f7-335f5e06c941", + "Service_Plans_Included_Friendly_Names": "Power Pages Internal User" + }, + { + "Product_Display_Name": "Dynamics 365 Human Resources Attach to Qualifying Dynamics 365 Base Offer", + "String_Id": "DYN365_HUMAN_RESOURCES_ATTACH", + "GUID": "83c489a4-94b6-4dcc-9fdc-ff9b107a4621", + "Service_Plan_Name": "D365_HR_SELF_SERVICE_OPS", + "Service_Plan_Id": "835b837b-63c1-410e-bf6b-bdef201ad129", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Human Resource Self Service" + }, + { + "Product_Display_Name": "Dynamics 365 Human Resources Attach to Qualifying Dynamics 365 Base Offer", + "String_Id": "DYN365_HUMAN_RESOURCES_ATTACH", + "GUID": "83c489a4-94b6-4dcc-9fdc-ff9b107a4621", + "Service_Plan_Name": "D365_HR_OPS", + "Service_Plan_Id": "8b21a5dc-5485-49ed-a2d4-0e772c830f6d", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Human Resources" + }, + { + "Product_Display_Name": "Dynamics 365 Human Resources Attach to Qualifying Dynamics 365 Base Offer", + "String_Id": "DYN365_HUMAN_RESOURCES_ATTACH", + "GUID": "83c489a4-94b6-4dcc-9fdc-ff9b107a4621", + "Service_Plan_Name": "D365_HR_Attach", + "Service_Plan_Id": "3219525a-4064-45ec-9c35-a33ea6b39a49", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Human Resources Attach" + }, + { + "Product_Display_Name": "Dynamics 365 Human Resources Attach to Qualifying Dynamics 365 Base Offer", + "String_Id": "DYN365_HUMAN_RESOURCES_ATTACH", + "GUID": "83c489a4-94b6-4dcc-9fdc-ff9b107a4621", + "Service_Plan_Name": "D365_HR_ATTACH_OPS", + "Service_Plan_Id": "90d8cb62-e98a-4639-8342-8c7d2c8215ba", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Human Resources Attach License" + }, + { + "Product_Display_Name": "Dynamics 365 Human Resources Attach to Qualifying Dynamics 365 Base Offer", + "String_Id": "DYN365_HUMAN_RESOURCES_ATTACH", + "GUID": "83c489a4-94b6-4dcc-9fdc-ff9b107a4621", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION", + "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation" + }, { "Product_Display_Name": "Dynamics 365 Hybrid Connector", "String_Id": "CRM_HYBRIDCONNECTOR", @@ -2759,6 +3567,22 @@ "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", "Service_Plans_Included_Friendly_Names": "Exchange Foundation" }, + { + "Product_Display_Name": "Dynamics 365 for Marketing Addnl Contacts Tier 1", + "String_Id": "DYN365_MARKETING_CONTACT_ADDON", + "GUID": "fc4581aa-6b1f-459d-95b6-84bd49d6f843", + "Service_Plan_Name": "DYN365_MARKETING_CONTACT_ADDON", + "Service_Plan_Id": "18db5075-2c70-408d-a82b-929059d782af", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 for Marketing Additional Contacts Tier 1" + }, + { + "Product_Display_Name": "Dynamics 365 for Marketing Addnl Contacts Tier 1", + "String_Id": "DYN365_MARKETING_CONTACT_ADDON", + "GUID": "fc4581aa-6b1f-459d-95b6-84bd49d6f843", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION", + "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation" + }, { "Product_Display_Name": "Dynamics 365 for Marketing Addnl Contacts Tier 3", "String_Id": "DYN365_MARKETING_CONTACT_ADDON_T3", @@ -3215,6 +4039,78 @@ "Service_Plan_Id": "3089c02b-e533-4b73-96a5-01fa648c3c3c", "Service_Plans_Included_Friendly_Names": "PowerApps for Dynamics 365 for Government" }, + { + "Product_Display_Name": "Dynamics 365 for Project Service Automation for Government", + "String_Id": "D365_ENTERPRISE_PROJECT_SERVICE_AUTOMATION_GOV", + "GUID": "6c827f0a-42cb-4cff-b1cd-f4104c16ede3", + "Service_Plan_Name": "DYN365_ENTERPRISE_PROJECT_SERVICE_AUTOMATION_GOV", + "Service_Plan_Id": "1d8c8e0e-4308-4db5-8a41-b129dbdaea20", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 for Project Service Automation for Government" + }, + { + "Product_Display_Name": "Dynamics 365 for Project Service Automation for Government", + "String_Id": "D365_ENTERPRISE_PROJECT_SERVICE_AUTOMATION_GOV", + "GUID": "6c827f0a-42cb-4cff-b1cd-f4104c16ede3", + "Service_Plan_Name": "Forms_Pro_PS_GCC", + "Service_Plan_Id": "e98256c5-17d0-4987-becc-e991c52d55c6", + "Service_Plans_Included_Friendly_Names": "Microsoft Dynamics 365 Customer Voice for Project Service Automation for GCC" + }, + { + "Product_Display_Name": "Dynamics 365 for Project Service Automation for Government", + "String_Id": "D365_ENTERPRISE_PROJECT_SERVICE_AUTOMATION_GOV", + "GUID": "6c827f0a-42cb-4cff-b1cd-f4104c16ede3", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION_GOV", + "Service_Plan_Id": "922ba911-5694-4e99-a794-73aed9bfeec8", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation for Government" + }, + { + "Product_Display_Name": "Dynamics 365 for Project Service Automation for Government", + "String_Id": "D365_ENTERPRISE_PROJECT_SERVICE_AUTOMATION_GOV", + "GUID": "6c827f0a-42cb-4cff-b1cd-f4104c16ede3", + "Service_Plan_Name": "SHAREPOINTWAC_GOV", + "Service_Plan_Id": "8f9f0f3b-ca90-406c-a842-95579171f8ec", + "Service_Plans_Included_Friendly_Names": "Office for the Web for Government" + }, + { + "Product_Display_Name": "Dynamics 365 for Project Service Automation for Government", + "String_Id": "D365_ENTERPRISE_PROJECT_SERVICE_AUTOMATION_GOV", + "GUID": "6c827f0a-42cb-4cff-b1cd-f4104c16ede3", + "Service_Plan_Name": "PROJECT_CLIENT_SUBSCRIPTION_GOV", + "Service_Plan_Id": "45c6831b-ad74-4c7f-bd03-7c2b3fa39067", + "Service_Plans_Included_Friendly_Names": "Project Online Desktop Client" + }, + { + "Product_Display_Name": "Dynamics 365 for Project Service Automation for Government", + "String_Id": "D365_ENTERPRISE_PROJECT_SERVICE_AUTOMATION_GOV", + "GUID": "6c827f0a-42cb-4cff-b1cd-f4104c16ede3", + "Service_Plan_Name": "SHAREPOINT_PROJECT_GOV", + "Service_Plan_Id": "e57afa78-1f19-4542-ba13-b32cd4d8f472", + "Service_Plans_Included_Friendly_Names": "Project Online Service for Government" + }, + { + "Product_Display_Name": "Dynamics 365 for Project Service Automation for Government", + "String_Id": "D365_ENTERPRISE_PROJECT_SERVICE_AUTOMATION_GOV", + "GUID": "6c827f0a-42cb-4cff-b1cd-f4104c16ede3", + "Service_Plan_Name": "SHAREPOINTENTERPRISE_GOV", + "Service_Plan_Id": "153f85dd-d912-4762-af6c-d6e0fb4f6692", + "Service_Plans_Included_Friendly_Names": "SharePoint Plan 2G" + }, + { + "Product_Display_Name": "Dynamics 365 for Project Service Automation for Government", + "String_Id": "D365_ENTERPRISE_PROJECT_SERVICE_AUTOMATION_GOV", + "GUID": "6c827f0a-42cb-4cff-b1cd-f4104c16ede3", + "Service_Plan_Name": "FLOW_DYN_APPS_GOV", + "Service_Plan_Id": "2c6af4f1-e7b6-4d59-bbc8-eaa884f42d69", + "Service_Plans_Included_Friendly_Names": "Power Automate for Dynamics 365 for Government" + }, + { + "Product_Display_Name": "Dynamics 365 for Project Service Automation for Government", + "String_Id": "D365_ENTERPRISE_PROJECT_SERVICE_AUTOMATION_GOV", + "GUID": "6c827f0a-42cb-4cff-b1cd-f4104c16ede3", + "Service_Plan_Name": "POWERAPPS_DYN_APPS_GOV", + "Service_Plan_Id": "3089c02b-e533-4b73-96a5-01fa648c3c3c", + "Service_Plans_Included_Friendly_Names": "PowerApps for Dynamics 365 for Government" + }, { "Product_Display_Name": "Dynamics 365 for Sales and Customer Service Enterprise Edition", "String_Id": "DYN365_ENTERPRISE_SALES_CUSTOMERSERVICE", @@ -3927,6 +4823,110 @@ "Service_Plan_Id": "874fc546-6efe-4d22-90b8-5c4e7aa59f4b", "Service_Plans_Included_Friendly_Names": "POWERAPPS FOR DYNAMICS 365" }, + { + "Product_Display_Name": "Dynamics 365 Supply Chain Management Premium", + "String_Id": "Dynamics_365_Supply_Chain_Management_Premium", + "GUID": "9467fd84-2758-4287-b1fa-6a908c441b8a", + "Service_Plan_Name": "Power_Pages_Internal_User", + "Service_Plan_Id": "60bf28f9-2b70-4522-96f7-335f5e06c941", + "Service_Plans_Included_Friendly_Names": "Power Pages Internal User" + }, + { + "Product_Display_Name": "Dynamics 365 Supply Chain Management Premium", + "String_Id": "Dynamics_365_Supply_Chain_Management_Premium", + "GUID": "9467fd84-2758-4287-b1fa-6a908c441b8a", + "Service_Plan_Name": "DYN365_CDS_SUPPLYCHAINMANAGEMENT", + "Service_Plan_Id": "b6a8b974-2956-4e14-ae81-f0384c363528", + "Service_Plans_Included_Friendly_Names": "Common Data Service for Dynamics 365 Supply Chain Management" + }, + { + "Product_Display_Name": "Dynamics 365 Supply Chain Management Premium", + "String_Id": "Dynamics_365_Supply_Chain_Management_Premium", + "GUID": "9467fd84-2758-4287-b1fa-6a908c441b8a", + "Service_Plan_Name": "FLOW_FOR_IOM_USL", + "Service_Plan_Id": "9e6d1620-dce9-4655-8933-af8fa5bccc9c", + "Service_Plans_Included_Friendly_Names": "Data Integration for IOM with Power Automate USL" + }, + { + "Product_Display_Name": "Dynamics 365 Supply Chain Management Premium", + "String_Id": "Dynamics_365_Supply_Chain_Management_Premium", + "GUID": "9467fd84-2758-4287-b1fa-6a908c441b8a", + "Service_Plan_Name": "CDS_FOR_IOM", + "Service_Plan_Id": "2bb89402-51e9-4c5a-be33-e954a9dd1ba6", + "Service_Plans_Included_Friendly_Names": "Dataverse for IOM" + }, + { + "Product_Display_Name": "Dynamics 365 Supply Chain Management Premium", + "String_Id": "Dynamics_365_Supply_Chain_Management_Premium", + "GUID": "9467fd84-2758-4287-b1fa-6a908c441b8a", + "Service_Plan_Name": "D365_DemandPlanning", + "Service_Plan_Id": "e8b616eb-1a6d-42b4-84c7-b63870791349", + "Service_Plans_Included_Friendly_Names": "DO NOT USE - Dynamics 365 Supply Chain Management Premium" + }, + { + "Product_Display_Name": "Dynamics 365 Supply Chain Management Premium", + "String_Id": "Dynamics_365_Supply_Chain_Management_Premium", + "GUID": "9467fd84-2758-4287-b1fa-6a908c441b8a", + "Service_Plan_Name": "DYN365_REGULATORY_SERVICE", + "Service_Plan_Id": "c7657ae3-c0b0-4eed-8c1d-6a7967bd9c65", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 for Finance and Operations, Enterprise edition - Regulatory Service" + }, + { + "Product_Display_Name": "Dynamics 365 Supply Chain Management Premium", + "String_Id": "Dynamics_365_Supply_Chain_Management_Premium", + "GUID": "9467fd84-2758-4287-b1fa-6a908c441b8a", + "Service_Plan_Name": "D365_SCM", + "Service_Plan_Id": "1224eae4-0d91-474a-8a52-27ec96a63fe7", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 for Supply Chain Management" + }, + { + "Product_Display_Name": "Dynamics 365 Supply Chain Management Premium", + "String_Id": "Dynamics_365_Supply_Chain_Management_Premium", + "GUID": "9467fd84-2758-4287-b1fa-6a908c441b8a", + "Service_Plan_Name": "DYN365_IOM", + "Service_Plan_Id": "616cf6e2-f52f-4738-b463-10003061fcd3", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Intelligent Order Management" + }, + { + "Product_Display_Name": "Dynamics 365 Supply Chain Management Premium", + "String_Id": "Dynamics_365_Supply_Chain_Management_Premium", + "GUID": "9467fd84-2758-4287-b1fa-6a908c441b8a", + "Service_Plan_Name": "DYN365_IOM_USER", + "Service_Plan_Id": "81375e2f-5ef7-4773-96aa-e3279f50bd21", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Intelligent Order Management USL" + }, + { + "Product_Display_Name": "Dynamics 365 Supply Chain Management Premium", + "String_Id": "Dynamics_365_Supply_Chain_Management_Premium", + "GUID": "9467fd84-2758-4287-b1fa-6a908c441b8a", + "Service_Plan_Name": "D365_SCM_Premium", + "Service_Plan_Id": "0363c8e5-c30d-4d7c-a621-7b6cab5e0482", + "Service_Plans_Included_Friendly_Names": "Dynamics 365 Supply Chain Management Premium" + }, + { + "Product_Display_Name": "Dynamics 365 Supply Chain Management Premium", + "String_Id": "Dynamics_365_Supply_Chain_Management_Premium", + "GUID": "9467fd84-2758-4287-b1fa-6a908c441b8a", + "Service_Plan_Name": "POWERAPPS_DYN_APPS", + "Service_Plan_Id": "874fc546-6efe-4d22-90b8-5c4e7aa59f4b", + "Service_Plans_Included_Friendly_Names": "Power Apps for Dynamics 365" + }, + { + "Product_Display_Name": "Dynamics 365 Supply Chain Management Premium", + "String_Id": "Dynamics_365_Supply_Chain_Management_Premium", + "GUID": "9467fd84-2758-4287-b1fa-6a908c441b8a", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION", + "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation" + }, + { + "Product_Display_Name": "Dynamics 365 Supply Chain Management Premium", + "String_Id": "Dynamics_365_Supply_Chain_Management_Premium", + "GUID": "9467fd84-2758-4287-b1fa-6a908c441b8a", + "Service_Plan_Name": "FLOW_DYN_APPS", + "Service_Plan_Id": "7e6d7d78-73de-46ba-83b1-6d25117334ba", + "Service_Plans_Included_Friendly_Names": "Power Automate for Dynamics 365" + }, { "Product_Display_Name": "Dynamics 365 for Talent", "String_Id": "SKU_Dynamics_365_for_HCM_Trial", @@ -5263,6 +6263,22 @@ "Service_Plan_Id": "882e1d05-acd1-4ccb-8708-6ee03664b117", "Service_Plans_Included_Friendly_Names": "Mobile Device Management for Office 365" }, + { + "Product_Display_Name": "Exchange Online (Plan 2) for GCC", + "String_Id": "EXCHANGEENTERPRISE_GOV", + "GUID": "7be8dc28-4da4-4e6d-b9b9-c60f2806df8a", + "Service_Plan_Name": "EXCHANGE_S_ENTERPRISE_GOV", + "Service_Plan_Id": "8c3069c0-ccdb-44be-ab77-986203a67df2", + "Service_Plans_Included_Friendly_Names": "Exchange Online (Plan 2) for Government" + }, + { + "Product_Display_Name": "Exchange Online (Plan 2) for GCC", + "String_Id": "EXCHANGEENTERPRISE_GOV", + "GUID": "7be8dc28-4da4-4e6d-b9b9-c60f2806df8a", + "Service_Plan_Name": "INTUNE_O365", + "Service_Plan_Id": "882e1d05-acd1-4ccb-8708-6ee03664b117", + "Service_Plans_Included_Friendly_Names": "Mobile Device Management for Office 365" + }, { "Product_Display_Name": "Exchange Online (Plan 2)", "String_Id": "EXCHANGEENTERPRISE", @@ -5367,6 +6383,30 @@ "Service_Plan_Id": "326e2b78-9d27-42c9-8509-46c827743a17", "Service_Plans_Included_Friendly_Names": "Exchange Online Protection" }, + { + "Product_Display_Name": "Flow Plan 1 for Government", + "String_Id": "FLOW_P1_GOV", + "GUID": "2b3b0c87-36af-4d15-8124-04a691cc2546", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION_GOV", + "Service_Plan_Id": "922ba911-5694-4e99-a794-73aed9bfeec8", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation for Government" + }, + { + "Product_Display_Name": "Flow Plan 1 for Government", + "String_Id": "FLOW_P1_GOV", + "GUID": "2b3b0c87-36af-4d15-8124-04a691cc2546", + "Service_Plan_Name": "DYN365_CDS_P1_GOV", + "Service_Plan_Id": "ce361df2-f2a5-4713-953f-4050ba09aad8", + "Service_Plans_Included_Friendly_Names": "Common Data Service for Government" + }, + { + "Product_Display_Name": "Flow Plan 1 for Government", + "String_Id": "FLOW_P1_GOV", + "GUID": "2b3b0c87-36af-4d15-8124-04a691cc2546", + "Service_Plan_Name": "FLOW_P1_GOV", + "Service_Plan_Id": "774da41c-a8b3-47c1-8322-b9c1ab68be9f", + "Service_Plans_Included_Friendly_Names": "Power Automate (Plan 1) for Government" + }, { "Product_Display_Name": "Intune", "String_Id": "INTUNE_A", @@ -6904,15 +7944,15 @@ "Service_Plans_Included_Friendly_Names": "Power Automate for Office 365" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "AAD_BASIC_EDU", "Service_Plan_Id": "1d0f309f-fdf9-4b2a-9ae7-9c48b91f1426", - "Service_Plans_Included_Friendly_Names": "Microsoft Entra ID Basic for Education" + "Service_Plans_Included_Friendly_Names": "Azure Active Directory Basic for Education" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "RMS_S_ENTERPRISE", @@ -6920,7 +7960,7 @@ "Service_Plans_Included_Friendly_Names": "Azure Rights Management" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "CDS_O365_P3", @@ -6928,7 +7968,7 @@ "Service_Plans_Included_Friendly_Names": "Common Data Service for Teams" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "LOCKBOX_ENTERPRISE", @@ -6936,7 +7976,15 @@ "Service_Plans_Included_Friendly_Names": "Customer Lockbox" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", + "String_Id": "M365EDU_A5_FACULTY", + "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", + "Service_Plan_Name": "CustomerLockboxA_Enterprise", + "Service_Plan_Id": "3ec18638-bd4c-4d3b-8905-479ed636b83e", + "Service_Plans_Included_Friendly_Names": "Customer Lockbox (A)" + }, + { + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "MIP_S_Exchange", @@ -6944,7 +7992,15 @@ "Service_Plans_Included_Friendly_Names": "Data Classification in Microsoft 365" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", + "String_Id": "M365EDU_A5_FACULTY", + "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", + "Service_Plan_Name": "COMMON_DEFENDER_PLATFORM_FOR_OFFICE", + "Service_Plan_Id": "a312bdeb-1e21-40d0-84b1-0e73f128144f", + "Service_Plans_Included_Friendly_Names": "Defender Platform for Office 365" + }, + { + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "EducationAnalyticsP1", @@ -6952,7 +8008,7 @@ "Service_Plans_Included_Friendly_Names": "Education Analytics" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "EXCHANGE_S_ENTERPRISE", @@ -6960,7 +8016,15 @@ "Service_Plans_Included_Friendly_Names": "Exchange Online (Plan 2)" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", + "String_Id": "M365EDU_A5_FACULTY", + "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", + "Service_Plan_Name": "GRAPH_CONNECTORS_SEARCH_INDEX", + "Service_Plan_Id": "a6520331-d7d4-4276-95f5-15c0933bc757", + "Service_Plans_Included_Friendly_Names": "Graph Connectors Search with Index" + }, + { + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "INFORMATION_BARRIERS", @@ -6968,7 +8032,7 @@ "Service_Plans_Included_Friendly_Names": "Information Barriers" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "Content_Explorer", @@ -6976,7 +8040,7 @@ "Service_Plans_Included_Friendly_Names": "Information Protection and Governance Analytics - Premium" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "ContentExplorer_Standard", @@ -6984,7 +8048,7 @@ "Service_Plans_Included_Friendly_Names": "Information Protection and Governance Analytics – Standard" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "MIP_S_CLP2", @@ -6992,7 +8056,7 @@ "Service_Plans_Included_Friendly_Names": "Information Protection for Office 365 - Premium" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "MIP_S_CLP1", @@ -7000,7 +8064,7 @@ "Service_Plans_Included_Friendly_Names": "Information Protection for Office 365 - Standard" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "M365_ADVANCED_AUDITING", @@ -7008,15 +8072,15 @@ "Service_Plans_Included_Friendly_Names": "Microsoft 365 Advanced Auditing" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "OFFICESUBSCRIPTION", "Service_Plan_Id": "43de0ff5-c92c-492b-9116-175376d08c38", - "Service_Plans_Included_Friendly_Names": "Microsoft 365 Apps for Enterprise" + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Apps for enterprise" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "MCOMEETADV", @@ -7024,7 +8088,15 @@ "Service_Plans_Included_Friendly_Names": "Microsoft 365 Audio Conferencing" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", + "String_Id": "M365EDU_A5_FACULTY", + "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", + "Service_Plan_Name": "M365_AUDIT_PLATFORM", + "Service_Plan_Id": "f6de4823-28fa-440b-b886-4783fa86ddba", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Audit Platform" + }, + { + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "MICROSOFT_COMMUNICATION_COMPLIANCE", @@ -7032,7 +8104,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft 365 Communication Compliance" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "MTP", @@ -7040,7 +8112,15 @@ "Service_Plans_Included_Friendly_Names": "Microsoft 365 Defender" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", + "String_Id": "M365EDU_A5_FACULTY", + "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", + "Service_Plan_Name": "M365_LIGHTHOUSE_CUSTOMER_PLAN1", + "Service_Plan_Id": "6f23d6a9-adbf-481c-8538-b4c095654487", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Lighthouse (Plan 1)" + }, + { + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "MCOEV", @@ -7048,7 +8128,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft 365 Phone System" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "MICROSOFTBOOKINGS", @@ -7056,7 +8136,15 @@ "Service_Plans_Included_Friendly_Names": "Microsoft Bookings" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", + "String_Id": "M365EDU_A5_FACULTY", + "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", + "Service_Plan_Name": "CLIPCHAMP", + "Service_Plan_Id": "a1ace008-72f3-4ea0-8dac-33b3a23a2472", + "Service_Plans_Included_Friendly_Names": "Microsoft Clipchamp" + }, + { + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "COMMUNICATIONS_DLP", @@ -7064,7 +8152,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft Communications DLP" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "CUSTOMER_KEY", @@ -7072,15 +8160,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft Customer Key" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", - "String_Id": "M365EDU_A5_FACULTY", - "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", - "Service_Plan_Name": "DATA_INVESTIGATIONS", - "Service_Plan_Id": "46129a58-a698-46f0-aa5b-17f6586297d9", - "Service_Plans_Included_Friendly_Names": "Microsoft Data Investigations" - }, - { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "ATP_ENTERPRISE", @@ -7088,7 +8168,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 1)" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "THREAT_INTELLIGENCE", @@ -7096,7 +8176,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 2)" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "EXCEL_PREMIUM", @@ -7104,7 +8184,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft Excel Advanced Analytics" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "OFFICE_FORMS_PLAN_3", @@ -7112,7 +8192,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft Forms (Plan 3)" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "INFO_GOVERNANCE", @@ -7120,7 +8200,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft Information Governance" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "INSIDER_RISK", @@ -7128,7 +8208,15 @@ "Service_Plans_Included_Friendly_Names": "Microsoft Insider Risk Management" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", + "String_Id": "M365EDU_A5_FACULTY", + "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", + "Service_Plan_Name": "INSIDER_RISK_MANAGEMENT", + "Service_Plan_Id": "9d0c4ee5-e4a1-4625-ab39-d82b619b1a34", + "Service_Plans_Included_Friendly_Names": "Microsoft Insider Risk Management - Exchange" + }, + { + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "KAIZALA_STANDALONE", @@ -7136,7 +8224,15 @@ "Service_Plans_Included_Friendly_Names": "Microsoft Kaizala Pro" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", + "String_Id": "M365EDU_A5_FACULTY", + "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", + "Service_Plan_Name": "MICROSOFT_LOOP", + "Service_Plan_Id": "c4b8c31a-fb44-4c65-9837-a21f55fcabda", + "Service_Plans_Included_Friendly_Names": "Microsoft Loop" + }, + { + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "ML_CLASSIFICATION", @@ -7144,7 +8240,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft ML-Based Classification" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "EXCHANGE_ANALYTICS", @@ -7152,7 +8248,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft MyAnalytics (Full)" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "PROJECTWORKMANAGEMENT", @@ -7160,7 +8256,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft Planner" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "RECORDS_MANAGEMENT", @@ -7168,7 +8264,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft Records Management" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "MICROSOFT_SEARCH", @@ -7176,7 +8272,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft Search" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "Deskless", @@ -7184,7 +8280,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft StaffHub" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "STREAM_O365_E5", @@ -7192,7 +8288,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft Stream for Office 365 E5" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "TEAMS1", @@ -7200,7 +8296,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft Teams" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "MINECRAFT_EDUCATION_EDITION", @@ -7208,7 +8304,7 @@ "Service_Plans_Included_Friendly_Names": "Minecraft Education Edition" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "INTUNE_O365", @@ -7216,7 +8312,7 @@ "Service_Plans_Included_Friendly_Names": "Mobile Device Management for Office 365" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "Nucleus", @@ -7224,7 +8320,7 @@ "Service_Plans_Included_Friendly_Names": "Nucleus" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "EQUIVIO_ANALYTICS", @@ -7232,7 +8328,7 @@ "Service_Plans_Included_Friendly_Names": "Office 365 Advanced eDiscovery" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "ADALLOM_S_O365", @@ -7240,7 +8336,7 @@ "Service_Plans_Included_Friendly_Names": "Office 365 Cloud App Security" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "PAM_ENTERPRISE", @@ -7248,7 +8344,7 @@ "Service_Plans_Included_Friendly_Names": "Office 365 Privileged Access Management" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "SAFEDOCS", @@ -7256,7 +8352,7 @@ "Service_Plans_Included_Friendly_Names": "Office 365 SafeDocs" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "SHAREPOINTWAC_EDU", @@ -7264,7 +8360,7 @@ "Service_Plans_Included_Friendly_Names": "Office for the Web for Education" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "POWERAPPS_O365_P3", @@ -7272,7 +8368,7 @@ "Service_Plans_Included_Friendly_Names": "Power Apps for Office 365 (Plan 3)" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "BI_AZURE_P2", @@ -7280,7 +8376,7 @@ "Service_Plans_Included_Friendly_Names": "Power BI Pro" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "PREMIUM_ENCRYPTION", @@ -7288,7 +8384,7 @@ "Service_Plans_Included_Friendly_Names": "Premium Encryption in Office 365" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "PROJECT_O365_P3", @@ -7296,23 +8392,39 @@ "Service_Plans_Included_Friendly_Names": "Project for Office (Plan E5)" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", + "String_Id": "M365EDU_A5_FACULTY", + "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", + "Service_Plan_Name": "PURVIEW_DISCOVERY", + "Service_Plan_Id": "c948ea65-2053-4a5a-8a62-9eaaaf11b522", + "Service_Plans_Included_Friendly_Names": "Purview Discovery" + }, + { + "Product_Display_Name": "Microsoft 365 A5 for faculty", + "String_Id": "M365EDU_A5_FACULTY", + "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", + "Service_Plan_Name": "Bing_Chat_Enterprise", + "Service_Plan_Id": "0d0c0d31-fae7-41f2-b909-eaf4d7f26dba", + "Service_Plans_Included_Friendly_Names": "RETIRED - Commercial data protection for Microsoft Copilot" + }, + { + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "COMMUNICATIONS_COMPLIANCE", "Service_Plan_Id": "41fcdd7d-4733-4863-9cf4-c65b83ce2df4", - "Service_Plans_Included_Friendly_Names": "Microsoft Communications Compliance" + "Service_Plans_Included_Friendly_Names": "RETIRED - Microsoft Communications Compliance" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", - "Service_Plan_Name": "INSIDER_RISK_MANAGEMENT", - "Service_Plan_Id": "9d0c4ee5-e4a1-4625-ab39-d82b619b1a34", - "Service_Plans_Included_Friendly_Names": "Microsoft Insider Risk Management" + "Service_Plan_Name": "DATA_INVESTIGATIONS", + "Service_Plan_Id": "46129a58-a698-46f0-aa5b-17f6586297d9", + "Service_Plans_Included_Friendly_Names": "Retired - Microsoft Data Investigations" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "SCHOOL_DATA_SYNC_P2", @@ -7320,7 +8432,7 @@ "Service_Plans_Included_Friendly_Names": "School Data Sync (Plan 2)" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "SHAREPOINTENTERPRISE_EDU", @@ -7328,7 +8440,7 @@ "Service_Plans_Included_Friendly_Names": "SharePoint (Plan 2) for Education" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "MCOSTANDARD", @@ -7336,7 +8448,7 @@ "Service_Plans_Included_Friendly_Names": "Skype for Business Online (Plan 2)" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "SWAY", @@ -7344,7 +8456,7 @@ "Service_Plans_Included_Friendly_Names": "Sway" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "BPOS_S_TODO_3", @@ -7352,7 +8464,7 @@ "Service_Plans_Included_Friendly_Names": "To-Do (Plan 3)" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "VIVA_LEARNING_SEEDED", @@ -7360,7 +8472,7 @@ "Service_Plans_Included_Friendly_Names": "Viva Learning Seeded" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "WHITEBOARD_PLAN3", @@ -7368,7 +8480,7 @@ "Service_Plans_Included_Friendly_Names": "Whiteboard (Plan 3)" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "YAMMER_EDU", @@ -7376,7 +8488,7 @@ "Service_Plans_Included_Friendly_Names": "Yammer for Academic" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "WINDEFATP", @@ -7384,7 +8496,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Endpoint" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "MICROSOFTENDPOINTDLP", @@ -7392,7 +8504,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft Endpoint DLP" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "UNIVERSAL_PRINT_01", @@ -7400,7 +8512,7 @@ "Service_Plans_Included_Friendly_Names": "Universal Print" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "Virtualization Rights for Windows 10 (E3/E5+VDA)", @@ -7408,7 +8520,7 @@ "Service_Plans_Included_Friendly_Names": "Windows 10/11 Enterprise" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "WINDOWSUPDATEFORBUSINESS_DEPLOYMENTSERVICE", @@ -7416,23 +8528,7 @@ "Service_Plans_Included_Friendly_Names": "Windows Update for Business Deployment Service" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", - "String_Id": "M365EDU_A5_FACULTY", - "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", - "Service_Plan_Name": "AAD_PREMIUM", - "Service_Plan_Id": "41781fb2-bc02-4b7c-bd55-b576c07bb09d", - "Service_Plans_Included_Friendly_Names": "Microsoft Entra ID P1" - }, - { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", - "String_Id": "M365EDU_A5_FACULTY", - "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", - "Service_Plan_Name": "AAD_PREMIUM_P2", - "Service_Plan_Id": "eec0eb4f-6444-4f95-aba0-50c24d67f998", - "Service_Plans_Included_Friendly_Names": "Microsoft Entra ID P2" - }, - { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "RMS_S_PREMIUM", @@ -7440,7 +8536,7 @@ "Service_Plans_Included_Friendly_Names": "Azure Information Protection Premium P1" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "RMS_S_PREMIUM2", @@ -7448,7 +8544,7 @@ "Service_Plans_Included_Friendly_Names": "Azure Information Protection Premium P2" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "DYN365_CDS_O365_P3", @@ -7456,7 +8552,15 @@ "Service_Plans_Included_Friendly_Names": "Common Data Service" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", + "String_Id": "M365EDU_A5_FACULTY", + "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", + "Service_Plan_Name": "Intune_ServiceNow", + "Service_Plan_Id": "3eeb8536-fecf-41bf-a3f8-d6f17a9f3efc", + "Service_Plans_Included_Friendly_Names": "Intune ServiceNow Integration" + }, + { + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "MFA_PREMIUM", @@ -7464,7 +8568,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft Azure Multi-Factor Authentication" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "ADALLOM_S_STANDALONE", @@ -7472,7 +8576,7 @@ "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Cloud Apps" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "ATA", @@ -7480,23 +8584,39 @@ "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Identity" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", + "String_Id": "M365EDU_A5_FACULTY", + "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", + "Service_Plan_Name": "AAD_PREMIUM", + "Service_Plan_Id": "41781fb2-bc02-4b7c-bd55-b576c07bb09d", + "Service_Plans_Included_Friendly_Names": "Microsoft Entra ID P1" + }, + { + "Product_Display_Name": "Microsoft 365 A5 for faculty", + "String_Id": "M365EDU_A5_FACULTY", + "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", + "Service_Plan_Name": "AAD_PREMIUM_P2", + "Service_Plan_Id": "eec0eb4f-6444-4f95-aba0-50c24d67f998", + "Service_Plans_Included_Friendly_Names": "Microsoft Entra ID P2" + }, + { + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "INTUNE_A", "Service_Plan_Id": "c1ec4a95-1f05-45b3-a911-aa3fa01094f5", - "Service_Plans_Included_Friendly_Names": "Microsoft Intune" + "Service_Plans_Included_Friendly_Names": "Microsoft Intune Plan 1" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "INTUNE_EDU", "Service_Plan_Id": "da24caf9-af8e-485c-b7c8-e73336da2693", - "Service_Plans_Included_Friendly_Names": "Microsoft Intune for Education" + "Service_Plans_Included_Friendly_Names": "Microsoft Intune Plan 1 for Education" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "FLOW_O365_P3", @@ -7504,20 +8624,28 @@ "Service_Plans_Included_Friendly_Names": "Power Automate for Office 365" }, { - "Product_Display_Name": "Microsoft 365 A5 for Faculty", + "Product_Display_Name": "Microsoft 365 A5 for faculty", "String_Id": "M365EDU_A5_FACULTY", "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", "Service_Plan_Name": "POWER_VIRTUAL_AGENTS_O365_P3", "Service_Plan_Id": "ded3d325-1bdc-453e-8432-5bac26d7a014", "Service_Plans_Included_Friendly_Names": "Power Virtual Agents for Office 365" }, + { + "Product_Display_Name": "Microsoft 365 A5 for faculty", + "String_Id": "M365EDU_A5_FACULTY", + "GUID": "e97c048c-37a4-45fb-ab50-922fbf07a370", + "Service_Plan_Name": "REMOTE_HELP", + "Service_Plan_Id": "a4c6cf29-1168-4076-ba5c-e8fe0e62b17e", + "Service_Plans_Included_Friendly_Names": "Remote help" + }, { "Product_Display_Name": "Microsoft 365 A5 for Students", "String_Id": "M365EDU_A5_STUDENT", "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", "Service_Plan_Name": "AAD_BASIC_EDU", "Service_Plan_Id": "1d0f309f-fdf9-4b2a-9ae7-9c48b91f1426", - "Service_Plans_Included_Friendly_Names": "Microsoft Entra ID Basic for Education" + "Service_Plans_Included_Friendly_Names": "Azure Active Directory Basic for Education" }, { "Product_Display_Name": "Microsoft 365 A5 for Students", @@ -7543,6 +8671,14 @@ "Service_Plan_Id": "9f431833-0334-42de-a7dc-70aa40db46db", "Service_Plans_Included_Friendly_Names": "Customer Lockbox" }, + { + "Product_Display_Name": "Microsoft 365 A5 for Students", + "String_Id": "M365EDU_A5_STUDENT", + "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", + "Service_Plan_Name": "CustomerLockboxA_Enterprise", + "Service_Plan_Id": "3ec18638-bd4c-4d3b-8905-479ed636b83e", + "Service_Plans_Included_Friendly_Names": "Customer Lockbox (A)" + }, { "Product_Display_Name": "Microsoft 365 A5 for Students", "String_Id": "M365EDU_A5_STUDENT", @@ -7551,6 +8687,14 @@ "Service_Plan_Id": "cd31b152-6326-4d1b-ae1b-997b625182e6", "Service_Plans_Included_Friendly_Names": "Data Classification in Microsoft 365" }, + { + "Product_Display_Name": "Microsoft 365 A5 for Students", + "String_Id": "M365EDU_A5_STUDENT", + "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", + "Service_Plan_Name": "COMMON_DEFENDER_PLATFORM_FOR_OFFICE", + "Service_Plan_Id": "a312bdeb-1e21-40d0-84b1-0e73f128144f", + "Service_Plans_Included_Friendly_Names": "Defender Platform for Office 365" + }, { "Product_Display_Name": "Microsoft 365 A5 for Students", "String_Id": "M365EDU_A5_STUDENT", @@ -7567,6 +8711,14 @@ "Service_Plan_Id": "efb87545-963c-4e0d-99df-69c6916d9eb0", "Service_Plans_Included_Friendly_Names": "Exchange Online (Plan 2)" }, + { + "Product_Display_Name": "Microsoft 365 A5 for Students", + "String_Id": "M365EDU_A5_STUDENT", + "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", + "Service_Plan_Name": "GRAPH_CONNECTORS_SEARCH_INDEX", + "Service_Plan_Id": "a6520331-d7d4-4276-95f5-15c0933bc757", + "Service_Plans_Included_Friendly_Names": "Graph Connectors Search with Index" + }, { "Product_Display_Name": "Microsoft 365 A5 for Students", "String_Id": "M365EDU_A5_STUDENT", @@ -7621,7 +8773,7 @@ "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", "Service_Plan_Name": "OFFICESUBSCRIPTION", "Service_Plan_Id": "43de0ff5-c92c-492b-9116-175376d08c38", - "Service_Plans_Included_Friendly_Names": "Microsoft 365 Apps for Enterprise" + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Apps for enterprise" }, { "Product_Display_Name": "Microsoft 365 A5 for Students", @@ -7631,6 +8783,14 @@ "Service_Plan_Id": "3e26ee1f-8a5f-4d52-aee2-b81ce45c8f40", "Service_Plans_Included_Friendly_Names": "Microsoft 365 Audio Conferencing" }, + { + "Product_Display_Name": "Microsoft 365 A5 for Students", + "String_Id": "M365EDU_A5_STUDENT", + "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", + "Service_Plan_Name": "M365_AUDIT_PLATFORM", + "Service_Plan_Id": "f6de4823-28fa-440b-b886-4783fa86ddba", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Audit Platform" + }, { "Product_Display_Name": "Microsoft 365 A5 for Students", "String_Id": "M365EDU_A5_STUDENT", @@ -7647,6 +8807,14 @@ "Service_Plan_Id": "bf28f719-7844-4079-9c78-c1307898e192", "Service_Plans_Included_Friendly_Names": "Microsoft 365 Defender" }, + { + "Product_Display_Name": "Microsoft 365 A5 for Students", + "String_Id": "M365EDU_A5_STUDENT", + "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", + "Service_Plan_Name": "M365_LIGHTHOUSE_CUSTOMER_PLAN1", + "Service_Plan_Id": "6f23d6a9-adbf-481c-8538-b4c095654487", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Lighthouse (Plan 1)" + }, { "Product_Display_Name": "Microsoft 365 A5 for Students", "String_Id": "M365EDU_A5_STUDENT", @@ -7663,6 +8831,14 @@ "Service_Plan_Id": "199a5c09-e0ca-4e37-8f7c-b05d533e1ea2", "Service_Plans_Included_Friendly_Names": "Microsoft Bookings" }, + { + "Product_Display_Name": "Microsoft 365 A5 for Students", + "String_Id": "M365EDU_A5_STUDENT", + "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", + "Service_Plan_Name": "CLIPCHAMP", + "Service_Plan_Id": "a1ace008-72f3-4ea0-8dac-33b3a23a2472", + "Service_Plans_Included_Friendly_Names": "Microsoft Clipchamp" + }, { "Product_Display_Name": "Microsoft 365 A5 for Students", "String_Id": "M365EDU_A5_STUDENT", @@ -7679,14 +8855,6 @@ "Service_Plan_Id": "6db1f1db-2b46-403f-be40-e39395f08dbb", "Service_Plans_Included_Friendly_Names": "Microsoft Customer Key" }, - { - "Product_Display_Name": "Microsoft 365 A5 for Students", - "String_Id": "M365EDU_A5_STUDENT", - "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", - "Service_Plan_Name": "DATA_INVESTIGATIONS", - "Service_Plan_Id": "46129a58-a698-46f0-aa5b-17f6586297d9", - "Service_Plans_Included_Friendly_Names": "Microsoft Data Investigations" - }, { "Product_Display_Name": "Microsoft 365 A5 for Students", "String_Id": "M365EDU_A5_STUDENT", @@ -7735,6 +8903,14 @@ "Service_Plan_Id": "d587c7a3-bda9-4f99-8776-9bcf59c84f75", "Service_Plans_Included_Friendly_Names": "Microsoft Insider Risk Management" }, + { + "Product_Display_Name": "Microsoft 365 A5 for Students", + "String_Id": "M365EDU_A5_STUDENT", + "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", + "Service_Plan_Name": "INSIDER_RISK_MANAGEMENT", + "Service_Plan_Id": "9d0c4ee5-e4a1-4625-ab39-d82b619b1a34", + "Service_Plans_Included_Friendly_Names": "Microsoft Insider Risk Management - Exchange" + }, { "Product_Display_Name": "Microsoft 365 A5 for Students", "String_Id": "M365EDU_A5_STUDENT", @@ -7743,6 +8919,14 @@ "Service_Plan_Id": "0898bdbb-73b0-471a-81e5-20f1fe4dd66e", "Service_Plans_Included_Friendly_Names": "Microsoft Kaizala Pro" }, + { + "Product_Display_Name": "Microsoft 365 A5 for Students", + "String_Id": "M365EDU_A5_STUDENT", + "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", + "Service_Plan_Name": "MICROSOFT_LOOP", + "Service_Plan_Id": "c4b8c31a-fb44-4c65-9837-a21f55fcabda", + "Service_Plans_Included_Friendly_Names": "Microsoft Loop" + }, { "Product_Display_Name": "Microsoft 365 A5 for Students", "String_Id": "M365EDU_A5_STUDENT", @@ -7895,6 +9079,22 @@ "Service_Plan_Id": "b21a6b06-1988-436e-a07b-51ec6d9f52ad", "Service_Plans_Included_Friendly_Names": "Project for Office (Plan E5)" }, + { + "Product_Display_Name": "Microsoft 365 A5 for Students", + "String_Id": "M365EDU_A5_STUDENT", + "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", + "Service_Plan_Name": "PURVIEW_DISCOVERY", + "Service_Plan_Id": "c948ea65-2053-4a5a-8a62-9eaaaf11b522", + "Service_Plans_Included_Friendly_Names": "Purview Discovery" + }, + { + "Product_Display_Name": "Microsoft 365 A5 for Students", + "String_Id": "M365EDU_A5_STUDENT", + "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", + "Service_Plan_Name": "Bing_Chat_Enterprise", + "Service_Plan_Id": "0d0c0d31-fae7-41f2-b909-eaf4d7f26dba", + "Service_Plans_Included_Friendly_Names": "RETIRED - Commercial data protection for Microsoft Copilot" + }, { "Product_Display_Name": "Microsoft 365 A5 for Students", "String_Id": "M365EDU_A5_STUDENT", @@ -7907,9 +9107,9 @@ "Product_Display_Name": "Microsoft 365 A5 for Students", "String_Id": "M365EDU_A5_STUDENT", "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", - "Service_Plan_Name": "INSIDER_RISK_MANAGEMENT", - "Service_Plan_Id": "9d0c4ee5-e4a1-4625-ab39-d82b619b1a34", - "Service_Plans_Included_Friendly_Names": "RETIRED - Microsoft Insider Risk Management" + "Service_Plan_Name": "DATA_INVESTIGATIONS", + "Service_Plan_Id": "46129a58-a698-46f0-aa5b-17f6586297d9", + "Service_Plans_Included_Friendly_Names": "Retired - Microsoft Data Investigations" }, { "Product_Display_Name": "Microsoft 365 A5 for Students", @@ -8007,22 +9207,6 @@ "Service_Plan_Id": "7bf960f6-2cd9-443a-8046-5dbff9558365", "Service_Plans_Included_Friendly_Names": "Windows Update for Business Deployment Service" }, - { - "Product_Display_Name": "Microsoft 365 A5 for Students", - "String_Id": "M365EDU_A5_STUDENT", - "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", - "Service_Plan_Name": "AAD_PREMIUM", - "Service_Plan_Id": "41781fb2-bc02-4b7c-bd55-b576c07bb09d", - "Service_Plans_Included_Friendly_Names": "Microsoft Entra ID P1" - }, - { - "Product_Display_Name": "Microsoft 365 A5 for Students", - "String_Id": "M365EDU_A5_STUDENT", - "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", - "Service_Plan_Name": "AAD_PREMIUM_P2", - "Service_Plan_Id": "eec0eb4f-6444-4f95-aba0-50c24d67f998", - "Service_Plans_Included_Friendly_Names": "Microsoft Entra ID P2" - }, { "Product_Display_Name": "Microsoft 365 A5 for Students", "String_Id": "M365EDU_A5_STUDENT", @@ -8047,6 +9231,14 @@ "Service_Plan_Id": "28b0fa46-c39a-4188-89e2-58e979a6b014", "Service_Plans_Included_Friendly_Names": "Common Data Service" }, + { + "Product_Display_Name": "Microsoft 365 A5 for Students", + "String_Id": "M365EDU_A5_STUDENT", + "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", + "Service_Plan_Name": "Intune_ServiceNow", + "Service_Plan_Id": "3eeb8536-fecf-41bf-a3f8-d6f17a9f3efc", + "Service_Plans_Included_Friendly_Names": "Intune ServiceNow Integration" + }, { "Product_Display_Name": "Microsoft 365 A5 for Students", "String_Id": "M365EDU_A5_STUDENT", @@ -8071,13 +9263,29 @@ "Service_Plan_Id": "14ab5db5-e6c4-4b20-b4bc-13e36fd2227f", "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Identity" }, + { + "Product_Display_Name": "Microsoft 365 A5 for Students", + "String_Id": "M365EDU_A5_STUDENT", + "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", + "Service_Plan_Name": "AAD_PREMIUM", + "Service_Plan_Id": "41781fb2-bc02-4b7c-bd55-b576c07bb09d", + "Service_Plans_Included_Friendly_Names": "Microsoft Entra ID P1" + }, + { + "Product_Display_Name": "Microsoft 365 A5 for Students", + "String_Id": "M365EDU_A5_STUDENT", + "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", + "Service_Plan_Name": "AAD_PREMIUM_P2", + "Service_Plan_Id": "eec0eb4f-6444-4f95-aba0-50c24d67f998", + "Service_Plans_Included_Friendly_Names": "Microsoft Entra ID P2" + }, { "Product_Display_Name": "Microsoft 365 A5 for Students", "String_Id": "M365EDU_A5_STUDENT", "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", "Service_Plan_Name": "INTUNE_A", "Service_Plan_Id": "c1ec4a95-1f05-45b3-a911-aa3fa01094f5", - "Service_Plans_Included_Friendly_Names": "Microsoft Intune" + "Service_Plans_Included_Friendly_Names": "Microsoft Intune Plan 1" }, { "Product_Display_Name": "Microsoft 365 A5 for Students", @@ -8085,7 +9293,7 @@ "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", "Service_Plan_Name": "INTUNE_EDU", "Service_Plan_Id": "da24caf9-af8e-485c-b7c8-e73336da2693", - "Service_Plans_Included_Friendly_Names": "Microsoft Intune for Education" + "Service_Plans_Included_Friendly_Names": "Microsoft Intune Plan 1 for Education" }, { "Product_Display_Name": "Microsoft 365 A5 for Students", @@ -8103,6 +9311,14 @@ "Service_Plan_Id": "ded3d325-1bdc-453e-8432-5bac26d7a014", "Service_Plans_Included_Friendly_Names": "Power Virtual Agents for Office 365" }, + { + "Product_Display_Name": "Microsoft 365 A5 for Students", + "String_Id": "M365EDU_A5_STUDENT", + "GUID": "46c119d4-0379-4a9d-85e4-97c66d3f909e", + "Service_Plan_Name": "REMOTE_HELP", + "Service_Plan_Id": "a4c6cf29-1168-4076-ba5c-e8fe0e62b17e", + "Service_Plans_Included_Friendly_Names": "Remote help" + }, { "Product_Display_Name": "Microsoft 365 A5 for students use benefit", "String_Id": "M365EDU_A5_STUUSEBNFT", @@ -13079,6 +14295,102 @@ "Service_Plan_Id": "89f1c4c8-0878-40f7-804d-869c9128ab5d", "Service_Plans_Included_Friendly_Names": "Power Platform Connectors in Microsoft 365 Copilot" }, + { + "Product_Display_Name": "Microsoft 365 Copilot for Sales", + "String_Id": "Microsoft_Copilot_for_Sales", + "GUID": "15f2e9fc-b782-4f73-bf51-81d8b7fff6f4", + "Service_Plan_Name": "COPILOT_STUDIO_IN_COPILOT_FOR_M365", + "Service_Plan_Id": "fe6c28b3-d468-44ea-bbd0-a10a5167435c", + "Service_Plans_Included_Friendly_Names": "Copilot Studio in Copilot for M365" + }, + { + "Product_Display_Name": "Microsoft 365 Copilot for Sales", + "String_Id": "Microsoft_Copilot_for_Sales", + "GUID": "15f2e9fc-b782-4f73-bf51-81d8b7fff6f4", + "Service_Plan_Name": "GRAPH_CONNECTORS_COPILOT", + "Service_Plan_Id": "82d30987-df9b-4486-b146-198b21d164c7", + "Service_Plans_Included_Friendly_Names": "Graph Connectors in Microsoft 365 Copilot" + }, + { + "Product_Display_Name": "Microsoft 365 Copilot for Sales", + "String_Id": "Microsoft_Copilot_for_Sales", + "GUID": "15f2e9fc-b782-4f73-bf51-81d8b7fff6f4", + "Service_Plan_Name": "M365_COPILOT_INTELLIGENT_SEARCH", + "Service_Plan_Id": "931e4a88-a67f-48b5-814f-16a5f1e6028d", + "Service_Plans_Included_Friendly_Names": "Intelligent Search" + }, + { + "Product_Display_Name": "Microsoft 365 Copilot for Sales", + "String_Id": "Microsoft_Copilot_for_Sales", + "GUID": "15f2e9fc-b782-4f73-bf51-81d8b7fff6f4", + "Service_Plan_Name": "M365_COPILOT_SHAREPOINT", + "Service_Plan_Id": "0aedf20c-091d-420b-aadf-30c042609612", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Copilot for SharePoint" + }, + { + "Product_Display_Name": "Microsoft 365 Copilot for Sales", + "String_Id": "Microsoft_Copilot_for_Sales", + "GUID": "15f2e9fc-b782-4f73-bf51-81d8b7fff6f4", + "Service_Plan_Name": "M365_COPILOT_TEAMS", + "Service_Plan_Id": "b95945de-b3bd-46db-8437-f2beb6ea2347", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Copilot in Microsoft Teams" + }, + { + "Product_Display_Name": "Microsoft 365 Copilot for Sales", + "String_Id": "Microsoft_Copilot_for_Sales", + "GUID": "15f2e9fc-b782-4f73-bf51-81d8b7fff6f4", + "Service_Plan_Name": "M365_COPILOT_APPS", + "Service_Plan_Id": "a62f8878-de10-42f3-b68f-6149a25ceb97", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Copilot in Productivity Apps" + }, + { + "Product_Display_Name": "Microsoft 365 Copilot for Sales", + "String_Id": "Microsoft_Copilot_for_Sales", + "GUID": "15f2e9fc-b782-4f73-bf51-81d8b7fff6f4", + "Service_Plan_Name": "Microsoft_Copilot_for_Sales", + "Service_Plan_Id": "a2194428-ead1-4fc1-bb81-ab8675125f42", + "Service_Plans_Included_Friendly_Names": "Microsoft Copilot for Sales" + }, + { + "Product_Display_Name": "Microsoft 365 Copilot for Sales", + "String_Id": "Microsoft_Copilot_for_Sales", + "GUID": "15f2e9fc-b782-4f73-bf51-81d8b7fff6f4", + "Service_Plan_Name": "Microsoft_Copilot_for_Sales_PowerAutomate", + "Service_Plan_Id": "0c1c2af2-6c51-43c7-9c55-fa487ac147ff", + "Service_Plans_Included_Friendly_Names": "Microsoft Copilot for Sales with Power Automate" + }, + { + "Product_Display_Name": "Microsoft 365 Copilot for Sales", + "String_Id": "Microsoft_Copilot_for_Sales", + "GUID": "15f2e9fc-b782-4f73-bf51-81d8b7fff6f4", + "Service_Plan_Name": "M365_COPILOT_BUSINESS_CHAT", + "Service_Plan_Id": "3f30311c-6b1e-48a4-ab79-725b469da960", + "Service_Plans_Included_Friendly_Names": "Microsoft Copilot with Graph-grounded chat" + }, + { + "Product_Display_Name": "Microsoft 365 Copilot for Sales", + "String_Id": "Microsoft_Copilot_for_Sales", + "GUID": "15f2e9fc-b782-4f73-bf51-81d8b7fff6f4", + "Service_Plan_Name": "WORKPLACE_ANALYTICS_INSIGHTS_USER", + "Service_Plan_Id": "b622badb-1b45-48d5-920f-4b27a2c0996c", + "Service_Plans_Included_Friendly_Names": "Microsoft Viva Insights" + }, + { + "Product_Display_Name": "Microsoft 365 Copilot for Sales", + "String_Id": "Microsoft_Copilot_for_Sales", + "GUID": "15f2e9fc-b782-4f73-bf51-81d8b7fff6f4", + "Service_Plan_Name": "WORKPLACE_ANALYTICS_INSIGHTS_BACKEND", + "Service_Plan_Id": "ff7b261f-d98b-415b-827c-42a3fdf015af", + "Service_Plans_Included_Friendly_Names": "Microsoft Viva Insights Backend" + }, + { + "Product_Display_Name": "Microsoft 365 Copilot for Sales", + "String_Id": "Microsoft_Copilot_for_Sales", + "GUID": "15f2e9fc-b782-4f73-bf51-81d8b7fff6f4", + "Service_Plan_Name": "M365_COPILOT_CONNECTORS", + "Service_Plan_Id": "89f1c4c8-0878-40f7-804d-869c9128ab5d", + "Service_Plans_Included_Friendly_Names": "Power Platform Connectors in Microsoft 365 Copilot" + }, { "Product_Display_Name": "Microsoft Copilot for Microsoft 365", "String_Id": "M365_Copilot", @@ -16695,6 +18007,702 @@ "Service_Plan_Id": "ded3d325-1bdc-453e-8432-5bac26d7a014", "Service_Plans_Included_Friendly_Names": "Power Virtual Agents for Office 365" }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "MESH_AVATARS_FOR_TEAMS", + "Service_Plan_Id": "dcf9d2f4-772e-4434-b757-77a453cfbc02", + "Service_Plans_Included_Friendly_Names": "Avatars for Teams" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "MESH_AVATARS_ADDITIONAL_FOR_TEAMS", + "Service_Plan_Id": "3efbd4ed-8958-4824-8389-1321f8730af8", + "Service_Plans_Included_Friendly_Names": "Avatars for Teams (additional)" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "CDS_O365_P3", + "Service_Plan_Id": "afa73018-811e-46e9-988f-f75d2b1b8430", + "Service_Plans_Included_Friendly_Names": "Common Data Service for Teams" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "LOCKBOX_ENTERPRISE", + "Service_Plan_Id": "9f431833-0334-42de-a7dc-70aa40db46db", + "Service_Plans_Included_Friendly_Names": "Customer Lockbox" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "CustomerLockboxA_Enterprise", + "Service_Plan_Id": "3ec18638-bd4c-4d3b-8905-479ed636b83e", + "Service_Plans_Included_Friendly_Names": "Customer Lockbox (A)" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "MIP_S_Exchange", + "Service_Plan_Id": "cd31b152-6326-4d1b-ae1b-997b625182e6", + "Service_Plans_Included_Friendly_Names": "Data Classification in Microsoft 365" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "EXCHANGE_S_ENTERPRISE", + "Service_Plan_Id": "efb87545-963c-4e0d-99df-69c6916d9eb0", + "Service_Plans_Included_Friendly_Names": "Exchange Online (Plan 2)" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "GRAPH_CONNECTORS_SEARCH_INDEX", + "Service_Plan_Id": "a6520331-d7d4-4276-95f5-15c0933bc757", + "Service_Plans_Included_Friendly_Names": "Graph Connectors Search with Index" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "MESH_IMMERSIVE_FOR_TEAMS", + "Service_Plan_Id": "f0ff6ac6-297d-49cd-be34-6dfef97f0c28", + "Service_Plans_Included_Friendly_Names": "Immersive spaces for Teams" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "INFORMATION_BARRIERS", + "Service_Plan_Id": "c4801e8a-cb58-4c35-aca6-f2dcc106f287", + "Service_Plans_Included_Friendly_Names": "Information Barriers" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "Content_Explorer", + "Service_Plan_Id": "d9fa6af4-e046-4c89-9226-729a0786685d", + "Service_Plans_Included_Friendly_Names": "Information Protection and Governance Analytics - Premium" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "ContentExplorer_Standard", + "Service_Plan_Id": "2b815d45-56e4-4e3a-b65c-66cb9175b560", + "Service_Plans_Included_Friendly_Names": "Information Protection and Governance Analytics – Standard" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "MIP_S_CLP2", + "Service_Plan_Id": "efb0351d-3b08-4503-993d-383af8de41e3", + "Service_Plans_Included_Friendly_Names": "Information Protection for Office 365 - Premium" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "MIP_S_CLP1", + "Service_Plan_Id": "5136a095-5cf0-4aff-bec3-e84448b38ea5", + "Service_Plans_Included_Friendly_Names": "Information Protection for Office 365 - Standard" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "MYANALYTICS_P2", + "Service_Plan_Id": "33c4f319-9bdd-48d6-9c4d-410b750a4a5a", + "Service_Plans_Included_Friendly_Names": "Insights by MyAnalytics" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "M365_ADVANCED_AUDITING", + "Service_Plan_Id": "2f442157-a11c-46b9-ae5b-6e39ff4e5849", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Advanced Auditing" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "OFFICESUBSCRIPTION", + "Service_Plan_Id": "43de0ff5-c92c-492b-9116-175376d08c38", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Apps for enterprise" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "MCOMEETADV", + "Service_Plan_Id": "3e26ee1f-8a5f-4d52-aee2-b81ce45c8f40", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Audio Conferencing" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "M365_AUDIT_PLATFORM", + "Service_Plan_Id": "f6de4823-28fa-440b-b886-4783fa86ddba", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Audit Platform" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "MICROSOFT_COMMUNICATION_COMPLIANCE", + "Service_Plan_Id": "a413a9ff-720c-4822-98ef-2f37c2a21f4c", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Communication Compliance" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "MTP", + "Service_Plan_Id": "bf28f719-7844-4079-9c78-c1307898e192", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Defender" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "M365_LIGHTHOUSE_CUSTOMER_PLAN1", + "Service_Plan_Id": "6f23d6a9-adbf-481c-8538-b4c095654487", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Lighthouse (Plan 1)" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "MCOEV", + "Service_Plan_Id": "4828c8ec-dc2e-4779-b502-87ac9ce28ab7", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Phone System" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "MICROSOFTBOOKINGS", + "Service_Plan_Id": "199a5c09-e0ca-4e37-8f7c-b05d533e1ea2", + "Service_Plans_Included_Friendly_Names": "Microsoft Bookings" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "CLIPCHAMP", + "Service_Plan_Id": "a1ace008-72f3-4ea0-8dac-33b3a23a2472", + "Service_Plans_Included_Friendly_Names": "Microsoft Clipchamp" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "COMMUNICATIONS_DLP", + "Service_Plan_Id": "6dc145d6-95dd-4191-b9c3-185575ee6f6b", + "Service_Plans_Included_Friendly_Names": "Microsoft Communications DLP" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "CUSTOMER_KEY", + "Service_Plan_Id": "6db1f1db-2b46-403f-be40-e39395f08dbb", + "Service_Plans_Included_Friendly_Names": "Microsoft Customer Key" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "ATP_ENTERPRISE", + "Service_Plan_Id": "f20fedf3-f3c3-43c3-8267-2bfdd51c0939", + "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 1)" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "THREAT_INTELLIGENCE", + "Service_Plan_Id": "8e0c0a52-6a6c-4d40-8370-dd62790dcd70", + "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 2)" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "EXCEL_PREMIUM", + "Service_Plan_Id": "531ee2f8-b1cb-453b-9c21-d2180d014ca5", + "Service_Plans_Included_Friendly_Names": "Microsoft Excel Advanced Analytics" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "FORMS_PLAN_E5", + "Service_Plan_Id": "e212cbc7-0961-4c40-9825-01117710dcb1", + "Service_Plans_Included_Friendly_Names": "Microsoft Forms (Plan E5)" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "INFO_GOVERNANCE", + "Service_Plan_Id": "e26c2fcc-ab91-4a61-b35c-03cdc8dddf66", + "Service_Plans_Included_Friendly_Names": "Microsoft Information Governance" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "INSIDER_RISK", + "Service_Plan_Id": "d587c7a3-bda9-4f99-8776-9bcf59c84f75", + "Service_Plans_Included_Friendly_Names": "Microsoft Insider Risk Management" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "INSIDER_RISK_MANAGEMENT", + "Service_Plan_Id": "9d0c4ee5-e4a1-4625-ab39-d82b619b1a34", + "Service_Plans_Included_Friendly_Names": "Microsoft Insider Risk Management - Exchange" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "KAIZALA_STANDALONE", + "Service_Plan_Id": "0898bdbb-73b0-471a-81e5-20f1fe4dd66e", + "Service_Plans_Included_Friendly_Names": "Microsoft Kaizala Pro" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "MICROSOFT_LOOP", + "Service_Plan_Id": "c4b8c31a-fb44-4c65-9837-a21f55fcabda", + "Service_Plans_Included_Friendly_Names": "Microsoft Loop" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "ML_CLASSIFICATION", + "Service_Plan_Id": "d2d51368-76c9-4317-ada2-a12c004c432f", + "Service_Plans_Included_Friendly_Names": "Microsoft ML-Based Classification" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "EXCHANGE_ANALYTICS", + "Service_Plan_Id": "34c0d7a0-a70f-4668-9238-47f9fc208882", + "Service_Plans_Included_Friendly_Names": "Microsoft MyAnalytics (Full)" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "PROJECTWORKMANAGEMENT", + "Service_Plan_Id": "b737dad2-2f6c-4c65-90e3-ca563267e8b9", + "Service_Plans_Included_Friendly_Names": "Microsoft Planner" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "RECORDS_MANAGEMENT", + "Service_Plan_Id": "65cc641f-cccd-4643-97e0-a17e3045e541", + "Service_Plans_Included_Friendly_Names": "Microsoft Records Management" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "MICROSOFT_SEARCH", + "Service_Plan_Id": "94065c59-bc8e-4e8b-89e5-5138d471eaff", + "Service_Plans_Included_Friendly_Names": "Microsoft Search" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "Deskless", + "Service_Plan_Id": "8c7d2df8-86f0-4902-b2ed-a0458298f3b3", + "Service_Plans_Included_Friendly_Names": "Microsoft StaffHub" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "STREAM_O365_E5", + "Service_Plan_Id": "6c6042f5-6f01-4d67-b8c1-eb99d36eed3e", + "Service_Plans_Included_Friendly_Names": "Microsoft Stream for Office 365 E5" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "INTUNE_O365", + "Service_Plan_Id": "882e1d05-acd1-4ccb-8708-6ee03664b117", + "Service_Plans_Included_Friendly_Names": "Mobile Device Management for Office 365" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "Nucleus", + "Service_Plan_Id": "db4d623d-b514-490b-b7ef-8885eee514de", + "Service_Plans_Included_Friendly_Names": "Nucleus" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "EQUIVIO_ANALYTICS", + "Service_Plan_Id": "4de31727-a228-4ec3-a5bf-8e45b5ca48cc", + "Service_Plans_Included_Friendly_Names": "Office 365 Advanced eDiscovery" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "ADALLOM_S_O365", + "Service_Plan_Id": "8c098270-9dd4-4350-9b30-ba4703f3b36b", + "Service_Plans_Included_Friendly_Names": "Office 365 Cloud App Security" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "PAM_ENTERPRISE", + "Service_Plan_Id": "b1188c4c-1b36-4018-b48b-ee07604f6feb", + "Service_Plans_Included_Friendly_Names": "Office 365 Privileged Access Management" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "SAFEDOCS", + "Service_Plan_Id": "bf6f5520-59e3-4f82-974b-7dbbc4fd27c7", + "Service_Plans_Included_Friendly_Names": "Office 365 SafeDocs" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "SHAREPOINTWAC", + "Service_Plan_Id": "e95bec33-7c88-4a70-8e19-b10bd9d0c014", + "Service_Plans_Included_Friendly_Names": "Office for the Web" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "PEOPLE_SKILLS_FOUNDATION", + "Service_Plan_Id": "13b6da2c-0d84-450e-9f69-a33e221387ca", + "Service_Plans_Included_Friendly_Names": "People Skills - Foundation" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "POWERAPPS_O365_P3", + "Service_Plan_Id": "9c0dab89-a30c-4117-86e7-97bda240acd2", + "Service_Plans_Included_Friendly_Names": "Power Apps for Office 365 (Plan 3)" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "BI_AZURE_P2", + "Service_Plan_Id": "70d33638-9c74-4d01-bfd3-562de28bd4ba", + "Service_Plans_Included_Friendly_Names": "Power BI Pro" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "PREMIUM_ENCRYPTION", + "Service_Plan_Id": "617b097b-4b93-4ede-83de-5f075bb5fb2f", + "Service_Plans_Included_Friendly_Names": "Premium Encryption in Office 365" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "PROJECT_O365_P3", + "Service_Plan_Id": "b21a6b06-1988-436e-a07b-51ec6d9f52ad", + "Service_Plans_Included_Friendly_Names": "Project for Office (Plan E5)" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "PURVIEW_DISCOVERY", + "Service_Plan_Id": "c948ea65-2053-4a5a-8a62-9eaaaf11b522", + "Service_Plans_Included_Friendly_Names": "Purview Discovery" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "Bing_Chat_Enterprise", + "Service_Plan_Id": "0d0c0d31-fae7-41f2-b909-eaf4d7f26dba", + "Service_Plans_Included_Friendly_Names": "RETIRED - Commercial data protection for Microsoft Copilot" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "COMMUNICATIONS_COMPLIANCE", + "Service_Plan_Id": "41fcdd7d-4733-4863-9cf4-c65b83ce2df4", + "Service_Plans_Included_Friendly_Names": "RETIRED - Microsoft Communications Compliance" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "DATA_INVESTIGATIONS", + "Service_Plan_Id": "46129a58-a698-46f0-aa5b-17f6586297d9", + "Service_Plans_Included_Friendly_Names": "Retired - Microsoft Data Investigations" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "PLACES_CORE", + "Service_Plan_Id": "1fe6227d-3e01-46d0-9510-0acad4ff6e94", + "Service_Plans_Included_Friendly_Names": "RETIRED - Places Core" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "SHAREPOINTENTERPRISE", + "Service_Plan_Id": "5dbe027f-2339-4123-9542-606e4d348a72", + "Service_Plans_Included_Friendly_Names": "SharePoint (Plan 2)" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "MCOSTANDARD", + "Service_Plan_Id": "0feaeb32-d00e-4d66-bd5a-43b5b83db82c", + "Service_Plans_Included_Friendly_Names": "Skype for Business Online (Plan 2)" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "SWAY", + "Service_Plan_Id": "a23b959c-7ce8-4e57-9140-b90eb88a9e97", + "Service_Plans_Included_Friendly_Names": "Sway" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "BPOS_S_TODO_3", + "Service_Plan_Id": "3fb82609-8c27-4f7b-bd51-30634711ee67", + "Service_Plans_Included_Friendly_Names": "To-Do (Plan 3)" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "VIVAENGAGE_CORE", + "Service_Plan_Id": "a82fbf69-b4d7-49f4-83a6-915b2cf354f4", + "Service_Plans_Included_Friendly_Names": "Viva Engage Core" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "VIVA_LEARNING_SEEDED", + "Service_Plan_Id": "b76fb638-6ba6-402a-b9f9-83d28acb3d86", + "Service_Plans_Included_Friendly_Names": "Viva Learning Seeded" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "WHITEBOARD_PLAN3", + "Service_Plan_Id": "4a51bca5-1eff-43f5-878c-177680f191af", + "Service_Plans_Included_Friendly_Names": "Whiteboard (Plan 3)" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "YAMMER_ENTERPRISE", + "Service_Plan_Id": "7547a3fe-08ee-4ccb-b430-5077c5041653", + "Service_Plans_Included_Friendly_Names": "Yammer Enterprise" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "WINDEFATP", + "Service_Plan_Id": "871d91ec-ec1a-452b-a83f-bd76c7d770ef", + "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Endpoint" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "MICROSOFTENDPOINTDLP", + "Service_Plan_Id": "64bfac92-2b17-4482-b5e5-a0304429de3e", + "Service_Plans_Included_Friendly_Names": "Microsoft Endpoint DLP" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "UNIVERSAL_PRINT_01", + "Service_Plan_Id": "795f6fe0-cc4d-4773-b050-5dde4dc704c9", + "Service_Plans_Included_Friendly_Names": "Universal Print" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "WIN10_PRO_ENT_SUB", + "Service_Plan_Id": "21b439ba-a0ca-424f-a6cc-52f954a5b111", + "Service_Plans_Included_Friendly_Names": "Windows 10/11 Enterprise (Original)" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "Windows_Autopatch", + "Service_Plan_Id": "9a6eeb79-0b4b-4bf0-9808-39d99a2cd5a3", + "Service_Plans_Included_Friendly_Names": "Windows Autopatch" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "WINDOWSUPDATEFORBUSINESS_DEPLOYMENTSERVICE", + "Service_Plan_Id": "7bf960f6-2cd9-443a-8046-5dbff9558365", + "Service_Plans_Included_Friendly_Names": "Windows Update for Business Deployment Service" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "RMS_S_PREMIUM", + "Service_Plan_Id": "6c57d4b6-3b23-47a5-9bc9-69f17b4947b3", + "Service_Plans_Included_Friendly_Names": "Azure Information Protection Premium P1" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "RMS_S_PREMIUM2", + "Service_Plan_Id": "5689bec4-755d-4753-8b61-40975025187c", + "Service_Plans_Included_Friendly_Names": "Azure Information Protection Premium P2" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "RMS_S_ENTERPRISE", + "Service_Plan_Id": "bea4c11e-220a-4e6d-8eb8-8ea15d019f90", + "Service_Plans_Included_Friendly_Names": "Azure Rights Management" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "DYN365_CDS_O365_P3", + "Service_Plan_Id": "28b0fa46-c39a-4188-89e2-58e979a6b014", + "Service_Plans_Included_Friendly_Names": "Common Data Service" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "Defender_for_Iot_Enterprise", + "Service_Plan_Id": "99cd49a9-0e54-4e07-aea1-d8d9f5f704f5", + "Service_Plans_Included_Friendly_Names": "Defender for IoT - Enterprise IoT Security" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "MFA_PREMIUM", + "Service_Plan_Id": "8a256a2b-b617-496d-b51b-e76466e88db0", + "Service_Plans_Included_Friendly_Names": "Microsoft Azure Multi-Factor Authentication" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "ADALLOM_S_STANDALONE", + "Service_Plan_Id": "2e2ddb96-6af9-4b1d-a3f0-d6ecfd22edb2", + "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Cloud Apps" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "ATA", + "Service_Plan_Id": "14ab5db5-e6c4-4b20-b4bc-13e36fd2227f", + "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Identity" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "AAD_PREMIUM", + "Service_Plan_Id": "41781fb2-bc02-4b7c-bd55-b576c07bb09d", + "Service_Plans_Included_Friendly_Names": "Microsoft Entra ID P1" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "AAD_PREMIUM_P2", + "Service_Plan_Id": "eec0eb4f-6444-4f95-aba0-50c24d67f998", + "Service_Plans_Included_Friendly_Names": "Microsoft Entra ID P2" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "INTUNE_A", + "Service_Plan_Id": "c1ec4a95-1f05-45b3-a911-aa3fa01094f5", + "Service_Plans_Included_Friendly_Names": "Microsoft Intune Plan 1" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "FLOW_O365_P3", + "Service_Plan_Id": "07699545-9485-468e-95b6-2fca3738be01", + "Service_Plans_Included_Friendly_Names": "Power Automate for Office 365" + }, + { + "Product_Display_Name": "Microsoft 365 E5 (no Teams)", + "String_Id": "Microsoft_365_E5_(no_Teams)", + "GUID": "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e", + "Service_Plan_Name": "POWER_VIRTUAL_AGENTS_O365_P3", + "Service_Plan_Id": "ded3d325-1bdc-453e-8432-5bac26d7a014", + "Service_Plans_Included_Friendly_Names": "Power Virtual Agents for Office 365" + }, { "Product_Display_Name": "Microsoft 365 E5 EEA (no Teams) with Calling Minutes", "String_Id": "Microsoft_365_E5_EEA_(no_Teams)_with_Calling_Minutes", @@ -28135,6 +30143,46 @@ "Service_Plan_Id": "e866a266-3cff-43a3-acca-0c90a7e00c8b", "Service_Plans_Included_Friendly_Names": "Entra Identity Governance" }, + { + "Product_Display_Name": "Microsoft Entra Suite Add-on for Microsoft Entra ID P2", + "String_Id": "Microsoft_Entra_Suite_Step_Up_for_Microsoft_Entra_ID_P2", + "GUID": "2ef3064c-c95c-426c-96dd-9ffeaa2f2c37", + "Service_Plan_Name": "Entra_Premium_Internet_Access", + "Service_Plan_Id": "8d23cb83-ab07-418f-8517-d7aca77307dc", + "Service_Plans_Included_Friendly_Names": "Microsoft Entra Internet Access" + }, + { + "Product_Display_Name": "Microsoft Entra Suite Add-on for Microsoft Entra ID P2", + "String_Id": "Microsoft_Entra_Suite_Step_Up_for_Microsoft_Entra_ID_P2", + "GUID": "2ef3064c-c95c-426c-96dd-9ffeaa2f2c37", + "Service_Plan_Name": "Entra_Premium_Private_Access", + "Service_Plan_Id": "f057aab1-b184-49b2-85c0-881b02a405c5", + "Service_Plans_Included_Friendly_Names": "Microsoft Entra Private Access" + }, + { + "Product_Display_Name": "Microsoft Entra Suite Add-on for Microsoft Entra ID P2", + "String_Id": "Microsoft_Entra_Suite_Step_Up_for_Microsoft_Entra_ID_P2", + "GUID": "2ef3064c-c95c-426c-96dd-9ffeaa2f2c37", + "Service_Plan_Name": "Verifiable_Credentials_Service_Request", + "Service_Plan_Id": "aae826b7-14cd-4691-8178-2b312f7072ea", + "Service_Plans_Included_Friendly_Names": "Verifiable Credentials Service Request" + }, + { + "Product_Display_Name": "Microsoft Entra Suite Add-on for Microsoft Entra ID P2", + "String_Id": "Microsoft_Entra_Suite_Step_Up_for_Microsoft_Entra_ID_P2", + "GUID": "2ef3064c-c95c-426c-96dd-9ffeaa2f2c37", + "Service_Plan_Name": "Entra_Identity_Governance", + "Service_Plan_Id": "e866a266-3cff-43a3-acca-0c90a7e00c8b", + "Service_Plans_Included_Friendly_Names": "Entra Identity Governance" + }, + { + "Product_Display_Name": "Microsoft Entra Workload ID", + "String_Id": "Workload_Identities_P2", + "GUID": "52cdf00e-8303-4223-a749-ff69a13e2dd0", + "Service_Plan_Name": "AAD_WRKLDID_P2", + "Service_Plan_Id": "7dc0e92d-bf15-401d-907e-0884efe7c760", + "Service_Plans_Included_Friendly_Names": "Microsoft Entra Workload ID" + }, { "Product_Display_Name": "Microsoft Fabric (Free)", "String_Id": "POWER_BI_STANDARD", @@ -28151,6 +30199,14 @@ "Service_Plan_Id": "2049e525-b859-401b-b2a0-e0a31c4b1fe4", "Service_Plans_Included_Friendly_Names": "Power BI (free)" }, + { + "Product_Display_Name": "Microsoft Fabric (Free)", + "String_Id": "POWER_BI_STANDARD", + "GUID": "a403ebcc-fae0-4ca2-8c8c-7a907fd6c235", + "Service_Plan_Name": "PURVIEW_DISCOVERY", + "Service_Plan_Id": "c948ea65-2053-4a5a-8a62-9eaaaf11b522", + "Service_Plans_Included_Friendly_Names": "Purview Discovery" + }, { "Product_Display_Name": "Microsoft Fabric (Free) for faculty", "String_Id": "POWER_BI_STANDARD_FACULTY", @@ -28191,6 +30247,14 @@ "Service_Plan_Id": "d736def0-1fde-43f0-a5be-e3f8b2de6e41", "Service_Plans_Included_Friendly_Names": "MS IMAGINE ACADEMY" }, + { + "Product_Display_Name": "Microsoft Intune Advanced Analytics", + "String_Id": "Microsoft_Intune_Advanced_Analytics", + "GUID": "5e36d0d4-e9e5-4052-aba0-0257465c9b86", + "Service_Plan_Name": "Intune_AdvancedEA", + "Service_Plan_Id": "2a4baa0e-5e99-4c38-b1f2-6864960f1bd1", + "Service_Plans_Included_Friendly_Names": "Microsoft Intune Advanced Analytics" + }, { "Product_Display_Name": "Microsoft Intune Device", "String_Id": "INTUNE_A_D", @@ -28239,6 +30303,22 @@ "Service_Plan_Id": "d216f254-796f-4dab-bbfa-710686e646b9", "Service_Plans_Included_Friendly_Names": "Microsoft Intune G" }, + { + "Product_Display_Name": "Microsoft Intune Plan 1 A VL", + "String_Id": "INTUNE_A_VL", + "GUID": "99fc2803-fa72-42d3-ae78-b055e177d275", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION", + "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation" + }, + { + "Product_Display_Name": "Microsoft Intune Plan 1 A VL", + "String_Id": "INTUNE_A_VL", + "GUID": "99fc2803-fa72-42d3-ae78-b055e177d275", + "Service_Plan_Name": "INTUNE_A_VL", + "Service_Plan_Id": "3e170737-c728-4eae-bbb9-3f3360f7184c", + "Service_Plans_Included_Friendly_Names": "Microsoft Intune Plan 1" + }, { "Product_Display_Name": "Microsoft Intune Plan 1 A VL_USGOV_GCCHIGH", "String_Id": "INTUNE_A_VL_USGOV_GCCHIGH", @@ -28639,6 +30719,38 @@ "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", "Service_Plans_Included_Friendly_Names": "Exchange Foundation" }, + { + "Product_Display_Name": "Microsoft Sustainability Manager Premium USL Plus", + "String_Id": "MICROSOFT_SUSTAINABILITY_MANAGER_PREMIUM_USL_ADDON", + "GUID": "9d576ffb-dd32-4c33-91ee-91625b61424a", + "Service_Plan_Name": "MCS_BIZAPPS_CLOUD_FOR_SUSTAINABILITY_USL_PLUS", + "Service_Plan_Id": "beaf5b5c-d11c-4417-b5cb-cd9f9e6719b0", + "Service_Plans_Included_Friendly_Names": "MCS - BizApps Cloud for Sustainability USL Plus" + }, + { + "Product_Display_Name": "Microsoft Sustainability Manager Premium USL Plus", + "String_Id": "MICROSOFT_SUSTAINABILITY_MANAGER_PREMIUM_USL_ADDON", + "GUID": "9d576ffb-dd32-4c33-91ee-91625b61424a", + "Service_Plan_Name": "POWER_APPS_FOR_MCS_USL_PLUS", + "Service_Plan_Id": "c5502fe7-406d-442a-827f-4948b821ba08", + "Service_Plans_Included_Friendly_Names": "Power Apps for Cloud for Sustainability USL Plus" + }, + { + "Product_Display_Name": "Microsoft Sustainability Manager Premium USL Plus", + "String_Id": "MICROSOFT_SUSTAINABILITY_MANAGER_PREMIUM_USL_ADDON", + "GUID": "9d576ffb-dd32-4c33-91ee-91625b61424a", + "Service_Plan_Name": "POWER_AUTOMATE_FOR_MCS_USL_PLUS", + "Service_Plan_Id": "1c22bb50-96fb-49e5-baa6-195cab19eee2", + "Service_Plans_Included_Friendly_Names": "Power Automate for Cloud for Sustainability USL Plus" + }, + { + "Product_Display_Name": "Microsoft Sustainability Manager Premium USL Plus", + "String_Id": "MICROSOFT_SUSTAINABILITY_MANAGER_PREMIUM_USL_ADDON", + "GUID": "9d576ffb-dd32-4c33-91ee-91625b61424a", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION", + "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation" + }, { "Product_Display_Name": "Microsoft Sustainability Manager USL Essentials", "String_Id": "Microsoft_Cloud_for_Sustainability_USL", @@ -28743,6 +30855,22 @@ "Service_Plan_Id": "346d83bf-6fe6-42ca-b424-b9300d2e21bf", "Service_Plans_Included_Friendly_Names": "Microsoft 365 Domestic Calling Plan (240 min)" }, + { + "Product_Display_Name": "Microsoft Teams Domestic Calling Plan for GCC", + "String_Id": "MCOPSTN_1_GOV", + "GUID": "923f58ab-fca1-46a1-92f9-89fda21238a8", + "Service_Plan_Name": "MCOPSTN1_GOV", + "Service_Plan_Id": "3c8a8792-7866-409b-bb61-1b20ace0368b", + "Service_Plans_Included_Friendly_Names": "Domestic Calling Plan for Government" + }, + { + "Product_Display_Name": "Microsoft Teams Domestic Calling Plan for GCC", + "String_Id": "MCOPSTN_1_GOV", + "GUID": "923f58ab-fca1-46a1-92f9-89fda21238a8", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION_GOV", + "Service_Plan_Id": "922ba911-5694-4e99-a794-73aed9bfeec8", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation for Government" + }, { "Product_Display_Name": "Microsoft Teams Essentials", "String_Id": "Teams_Ess", @@ -29525,7 +31653,7 @@ "GUID": "c25e2b36-e161-4946-bef2-69239729f690", "Service_Plan_Name": "AAD_BASIC_EDU", "Service_Plan_Id": "1d0f309f-fdf9-4b2a-9ae7-9c48b91f1426", - "Service_Plans_Included_Friendly_Names": "Microsoft Entra ID Basic for Education" + "Service_Plans_Included_Friendly_Names": "Azure Active Directory Basic for Education" }, { "Product_Display_Name": "Microsoft Teams Rooms Pro for EDU", @@ -29567,6 +31695,14 @@ "Service_Plan_Id": "0feaeb32-d00e-4d66-bd5a-43b5b83db82c", "Service_Plans_Included_Friendly_Names": "Skype for Business Online (Plan 2)" }, + { + "Product_Display_Name": "Microsoft Teams Rooms Pro for EDU", + "String_Id": "Microsoft_Teams_Rooms_Pro_FAC", + "GUID": "c25e2b36-e161-4946-bef2-69239729f690", + "Service_Plan_Name": "Teams_Rooms_Pro", + "Service_Plan_Id": "0374d34c-6be4-4dbb-b3f0-26105db0b28a", + "Service_Plans_Included_Friendly_Names": "Teams Rooms Pro" + }, { "Product_Display_Name": "Microsoft Teams Rooms Pro for EDU", "String_Id": "Microsoft_Teams_Rooms_Pro_FAC", @@ -29615,6 +31751,14 @@ "Service_Plan_Id": "c1ec4a95-1f05-45b3-a911-aa3fa01094f5", "Service_Plans_Included_Friendly_Names": "Microsoft Intune Plan 1" }, + { + "Product_Display_Name": "Microsoft Teams Rooms Pro for EDU", + "String_Id": "Microsoft_Teams_Rooms_Pro_FAC", + "GUID": "c25e2b36-e161-4946-bef2-69239729f690", + "Service_Plan_Name": "SPECIALTY_DEVICES", + "Service_Plan_Id": "cfce7ae3-4b41-4438-999c-c0e91f3b7fb9", + "Service_Plans_Included_Friendly_Names": "Specialty devices" + }, { "Product_Display_Name": "Microsoft Teams Rooms Pro for GCC", "String_Id": "Microsoft_Teams_Rooms_Pro_GCC", @@ -30373,15 +32517,15 @@ "GUID": "94763226-9b3c-4e75-a931-5c89701abe66", "Service_Plan_Name": "AAD_BASIC_EDU", "Service_Plan_Id": "1d0f309f-fdf9-4b2a-9ae7-9c48b91f1426", - "Service_Plans_Included_Friendly_Names": "Microsoft Entra ID Basic for Education" + "Service_Plans_Included_Friendly_Names": "Azure Active Directory Basic for Education" }, { "Product_Display_Name": "Office 365 A1 for faculty", "String_Id": "STANDARDWOFFPACK_FACULTY", "GUID": "94763226-9b3c-4e75-a931-5c89701abe66", - "Service_Plan_Name": "DYN365_CDS_O365_P1", - "Service_Plan_Id": "40b010bb-0b69-4654-ac5e-ba161433f4b4", - "Service_Plans_Included_Friendly_Names": "Common Data Service - O365 P1" + "Service_Plan_Name": "RMS_S_ENTERPRISE", + "Service_Plan_Id": "bea4c11e-220a-4e6d-8eb8-8ea15d019f90", + "Service_Plans_Included_Friendly_Names": "Azure Rights Management" }, { "Product_Display_Name": "Office 365 A1 for faculty", @@ -30411,9 +32555,9 @@ "Product_Display_Name": "Office 365 A1 for faculty", "String_Id": "STANDARDWOFFPACK_FACULTY", "GUID": "94763226-9b3c-4e75-a931-5c89701abe66", - "Service_Plan_Name": "RMS_S_ENTERPRISE", - "Service_Plan_Id": "bea4c11e-220a-4e6d-8eb8-8ea15d019f90", - "Service_Plans_Included_Friendly_Names": "Microsoft Microsoft Entra Rights" + "Service_Plan_Name": "M365_LIGHTHOUSE_CUSTOMER_PLAN1", + "Service_Plan_Id": "6f23d6a9-adbf-481c-8538-b4c095654487", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Lighthouse (Plan 1)" }, { "Product_Display_Name": "Office 365 A1 for faculty", @@ -30429,7 +32573,7 @@ "GUID": "94763226-9b3c-4e75-a931-5c89701abe66", "Service_Plan_Name": "KAIZALA_O365_P2", "Service_Plan_Id": "54fc630f-5a40-48ee-8965-af0503c1386e", - "Service_Plans_Included_Friendly_Names": "Microsoft Kaizala Pro Plan 2" + "Service_Plans_Included_Friendly_Names": "Microsoft Kaizala Pro" }, { "Product_Display_Name": "Office 365 A1 for faculty", @@ -30507,25 +32651,17 @@ "Product_Display_Name": "Office 365 A1 for faculty", "String_Id": "STANDARDWOFFPACK_FACULTY", "GUID": "94763226-9b3c-4e75-a931-5c89701abe66", - "Service_Plan_Name": "POWERAPPS_O365_P2", - "Service_Plan_Id": "c68f8d98-5534-41c8-bf36-22fa496fa792", - "Service_Plans_Included_Friendly_Names": "Power Apps for Office 365" - }, - { - "Product_Display_Name": "Office 365 A1 for faculty", - "String_Id": "STANDARDWOFFPACK_FACULTY", - "GUID": "94763226-9b3c-4e75-a931-5c89701abe66", - "Service_Plan_Name": "FLOW_O365_P2", - "Service_Plan_Id": "76846ad7-7776-4c40-a281-a386362dd1b9", - "Service_Plans_Included_Friendly_Names": "Power Automate for Office 365" + "Service_Plan_Name": "PROJECT_O365_P1", + "Service_Plan_Id": "a55dfd10-0864-46d9-a3cd-da5991a3e0e2", + "Service_Plans_Included_Friendly_Names": "Project for Office (Plan E1)" }, { "Product_Display_Name": "Office 365 A1 for faculty", "String_Id": "STANDARDWOFFPACK_FACULTY", "GUID": "94763226-9b3c-4e75-a931-5c89701abe66", - "Service_Plan_Name": "PROJECT_O365_P1", - "Service_Plan_Id": "a55dfd10-0864-46d9-a3cd-da5991a3e0e2", - "Service_Plans_Included_Friendly_Names": "Project for Office (Plan E1)" + "Service_Plan_Name": "Bing_Chat_Enterprise", + "Service_Plan_Id": "0d0c0d31-fae7-41f2-b909-eaf4d7f26dba", + "Service_Plans_Included_Friendly_Names": "RETIRED - Commercial data protection for Microsoft Copilot" }, { "Product_Display_Name": "Office 365 A1 for faculty", @@ -30591,6 +32727,30 @@ "Service_Plan_Id": "2078e8df-cff6-4290-98cb-5408261a760a", "Service_Plans_Included_Friendly_Names": "Yammer for Academic" }, + { + "Product_Display_Name": "Office 365 A1 for faculty", + "String_Id": "STANDARDWOFFPACK_FACULTY", + "GUID": "94763226-9b3c-4e75-a931-5c89701abe66", + "Service_Plan_Name": "DYN365_CDS_O365_P1", + "Service_Plan_Id": "40b010bb-0b69-4654-ac5e-ba161433f4b4", + "Service_Plans_Included_Friendly_Names": "Common Data Service" + }, + { + "Product_Display_Name": "Office 365 A1 for faculty", + "String_Id": "STANDARDWOFFPACK_FACULTY", + "GUID": "94763226-9b3c-4e75-a931-5c89701abe66", + "Service_Plan_Name": "POWERAPPS_O365_P2", + "Service_Plan_Id": "c68f8d98-5534-41c8-bf36-22fa496fa792", + "Service_Plans_Included_Friendly_Names": "Power Apps for Office 365" + }, + { + "Product_Display_Name": "Office 365 A1 for faculty", + "String_Id": "STANDARDWOFFPACK_FACULTY", + "GUID": "94763226-9b3c-4e75-a931-5c89701abe66", + "Service_Plan_Name": "FLOW_O365_P2", + "Service_Plan_Id": "76846ad7-7776-4c40-a281-a386362dd1b9", + "Service_Plans_Included_Friendly_Names": "Power Automate for Office 365" + }, { "Product_Display_Name": "Office 365 A1 Plus for faculty", "String_Id": "STANDARDWOFFPACK_IW_FACULTY", @@ -30813,15 +32973,15 @@ "GUID": "314c4481-f395-4525-be8b-2ec4bb1e9d91", "Service_Plan_Name": "AAD_BASIC_EDU", "Service_Plan_Id": "1d0f309f-fdf9-4b2a-9ae7-9c48b91f1426", - "Service_Plans_Included_Friendly_Names": "Microsoft Entra ID Basic for Education" + "Service_Plans_Included_Friendly_Names": "Azure Active Directory Basic for Education" }, { "Product_Display_Name": "Office 365 A1 for students", "String_Id": "STANDARDWOFFPACK_STUDENT", "GUID": "314c4481-f395-4525-be8b-2ec4bb1e9d91", - "Service_Plan_Name": "DYN365_CDS_O365_P1", - "Service_Plan_Id": "40b010bb-0b69-4654-ac5e-ba161433f4b4", - "Service_Plans_Included_Friendly_Names": "Common Data Service - O365 P1" + "Service_Plan_Name": "RMS_S_ENTERPRISE", + "Service_Plan_Id": "bea4c11e-220a-4e6d-8eb8-8ea15d019f90", + "Service_Plans_Included_Friendly_Names": "Azure Rights Management" }, { "Product_Display_Name": "Office 365 A1 for students", @@ -30851,9 +33011,9 @@ "Product_Display_Name": "Office 365 A1 for students", "String_Id": "STANDARDWOFFPACK_STUDENT", "GUID": "314c4481-f395-4525-be8b-2ec4bb1e9d91", - "Service_Plan_Name": "RMS_S_ENTERPRISE", - "Service_Plan_Id": "bea4c11e-220a-4e6d-8eb8-8ea15d019f90", - "Service_Plans_Included_Friendly_Names": "Microsoft Microsoft Entra Rights" + "Service_Plan_Name": "M365_LIGHTHOUSE_CUSTOMER_PLAN1", + "Service_Plan_Id": "6f23d6a9-adbf-481c-8538-b4c095654487", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Lighthouse (Plan 1)" }, { "Product_Display_Name": "Office 365 A1 for students", @@ -30869,7 +33029,7 @@ "GUID": "314c4481-f395-4525-be8b-2ec4bb1e9d91", "Service_Plan_Name": "KAIZALA_O365_P2", "Service_Plan_Id": "54fc630f-5a40-48ee-8965-af0503c1386e", - "Service_Plans_Included_Friendly_Names": "Microsoft Kaizala Pro Plan 2" + "Service_Plans_Included_Friendly_Names": "Microsoft Kaizala Pro" }, { "Product_Display_Name": "Office 365 A1 for students", @@ -30939,25 +33099,17 @@ "Product_Display_Name": "Office 365 A1 for students", "String_Id": "STANDARDWOFFPACK_STUDENT", "GUID": "314c4481-f395-4525-be8b-2ec4bb1e9d91", - "Service_Plan_Name": "POWERAPPS_O365_P2", - "Service_Plan_Id": "c68f8d98-5534-41c8-bf36-22fa496fa792", - "Service_Plans_Included_Friendly_Names": "Power Apps for Office 365" - }, - { - "Product_Display_Name": "Office 365 A1 for students", - "String_Id": "STANDARDWOFFPACK_STUDENT", - "GUID": "314c4481-f395-4525-be8b-2ec4bb1e9d91", - "Service_Plan_Name": "FLOW_O365_P2", - "Service_Plan_Id": "76846ad7-7776-4c40-a281-a386362dd1b9", - "Service_Plans_Included_Friendly_Names": "Power Automate for Office 365" + "Service_Plan_Name": "PROJECT_O365_P1", + "Service_Plan_Id": "a55dfd10-0864-46d9-a3cd-da5991a3e0e2", + "Service_Plans_Included_Friendly_Names": "Project for Office (Plan E1)" }, { "Product_Display_Name": "Office 365 A1 for students", "String_Id": "STANDARDWOFFPACK_STUDENT", "GUID": "314c4481-f395-4525-be8b-2ec4bb1e9d91", - "Service_Plan_Name": "PROJECT_O365_P1", - "Service_Plan_Id": "a55dfd10-0864-46d9-a3cd-da5991a3e0e2", - "Service_Plans_Included_Friendly_Names": "Project for Office (Plan E1)" + "Service_Plan_Name": "Bing_Chat_Enterprise", + "Service_Plan_Id": "0d0c0d31-fae7-41f2-b909-eaf4d7f26dba", + "Service_Plans_Included_Friendly_Names": "RETIRED - Commercial data protection for Microsoft Copilot" }, { "Product_Display_Name": "Office 365 A1 for students", @@ -31015,6 +33167,30 @@ "Service_Plan_Id": "2078e8df-cff6-4290-98cb-5408261a760a", "Service_Plans_Included_Friendly_Names": "Yammer for Academic" }, + { + "Product_Display_Name": "Office 365 A1 for students", + "String_Id": "STANDARDWOFFPACK_STUDENT", + "GUID": "314c4481-f395-4525-be8b-2ec4bb1e9d91", + "Service_Plan_Name": "DYN365_CDS_O365_P1", + "Service_Plan_Id": "40b010bb-0b69-4654-ac5e-ba161433f4b4", + "Service_Plans_Included_Friendly_Names": "Common Data Service" + }, + { + "Product_Display_Name": "Office 365 A1 for students", + "String_Id": "STANDARDWOFFPACK_STUDENT", + "GUID": "314c4481-f395-4525-be8b-2ec4bb1e9d91", + "Service_Plan_Name": "POWERAPPS_O365_P2", + "Service_Plan_Id": "c68f8d98-5534-41c8-bf36-22fa496fa792", + "Service_Plans_Included_Friendly_Names": "Power Apps for Office 365" + }, + { + "Product_Display_Name": "Office 365 A1 for students", + "String_Id": "STANDARDWOFFPACK_STUDENT", + "GUID": "314c4481-f395-4525-be8b-2ec4bb1e9d91", + "Service_Plan_Name": "FLOW_O365_P2", + "Service_Plan_Id": "76846ad7-7776-4c40-a281-a386362dd1b9", + "Service_Plans_Included_Friendly_Names": "Power Automate for Office 365" + }, { "Product_Display_Name": "Office 365 A1 Plus for students", "String_Id": "STANDARDWOFFPACK_IW_STUDENT", @@ -33215,6 +35391,310 @@ "Service_Plan_Id": "0683001c-0492-4d59-9515-d9a6426b5813", "Service_Plans_Included_Friendly_Names": "Power Virtual Agents for Office 365" }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "MESH_AVATARS_FOR_TEAMS", + "Service_Plan_Id": "dcf9d2f4-772e-4434-b757-77a453cfbc02", + "Service_Plans_Included_Friendly_Names": "Avatars for Teams" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "MESH_AVATARS_ADDITIONAL_FOR_TEAMS", + "Service_Plan_Id": "3efbd4ed-8958-4824-8389-1321f8730af8", + "Service_Plans_Included_Friendly_Names": "Avatars for Teams (additional)" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "CDS_O365_P1", + "Service_Plan_Id": "bed136c6-b799-4462-824d-fc045d3a9d25", + "Service_Plans_Included_Friendly_Names": "Common Data Service for Teams" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "MICROSOFT_MYANALYTICS_FULL", + "Service_Plan_Id": "0403bb98-9d17-4f94-b53e-eca56a7698a6", + "Service_Plans_Included_Friendly_Names": "DO NOT USE - Microsoft MyAnalytics (Full)" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "EXCHANGE_S_STANDARD", + "Service_Plan_Id": "9aaf7827-d63c-4b61-89c3-182f06f82e5c", + "Service_Plans_Included_Friendly_Names": "Exchange Online (Plan 1)" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "GRAPH_CONNECTORS_SEARCH_INDEX", + "Service_Plan_Id": "a6520331-d7d4-4276-95f5-15c0933bc757", + "Service_Plans_Included_Friendly_Names": "Graph Connectors Search with Index" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "MESH_IMMERSIVE_FOR_TEAMS", + "Service_Plan_Id": "f0ff6ac6-297d-49cd-be34-6dfef97f0c28", + "Service_Plans_Included_Friendly_Names": "Immersive spaces for Teams" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "MYANALYTICS_P2", + "Service_Plan_Id": "33c4f319-9bdd-48d6-9c4d-410b750a4a5a", + "Service_Plans_Included_Friendly_Names": "Insights by MyAnalytics" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "INSIGHTS_BY_MYANALYTICS", + "Service_Plan_Id": "b088306e-925b-44ab-baa0-63291c629a91", + "Service_Plans_Included_Friendly_Names": "Insights by MyAnalytics Backend" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "M365_LIGHTHOUSE_CUSTOMER_PLAN1", + "Service_Plan_Id": "6f23d6a9-adbf-481c-8538-b4c095654487", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 Lighthouse (Plan 1)" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "MICROSOFTBOOKINGS", + "Service_Plan_Id": "199a5c09-e0ca-4e37-8f7c-b05d533e1ea2", + "Service_Plans_Included_Friendly_Names": "Microsoft Bookings" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "FORMS_PLAN_E1", + "Service_Plan_Id": "159f4cd6-e380-449f-a816-af1a9ef76344", + "Service_Plans_Included_Friendly_Names": "Microsoft Forms (Plan E1)" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "KAIZALA_O365_P2", + "Service_Plan_Id": "54fc630f-5a40-48ee-8965-af0503c1386e", + "Service_Plans_Included_Friendly_Names": "Microsoft Kaizala Pro" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "PROJECTWORKMANAGEMENT", + "Service_Plan_Id": "b737dad2-2f6c-4c65-90e3-ca563267e8b9", + "Service_Plans_Included_Friendly_Names": "Microsoft Planner" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "MICROSOFT_SEARCH", + "Service_Plan_Id": "94065c59-bc8e-4e8b-89e5-5138d471eaff", + "Service_Plans_Included_Friendly_Names": "Microsoft Search" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "Deskless", + "Service_Plan_Id": "8c7d2df8-86f0-4902-b2ed-a0458298f3b3", + "Service_Plans_Included_Friendly_Names": "Microsoft StaffHub" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "INTUNE_O365", + "Service_Plan_Id": "882e1d05-acd1-4ccb-8708-6ee03664b117", + "Service_Plans_Included_Friendly_Names": "Mobile Device Management for Office 365" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "Nucleus", + "Service_Plan_Id": "db4d623d-b514-490b-b7ef-8885eee514de", + "Service_Plans_Included_Friendly_Names": "Nucleus" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "SHAREPOINTWAC", + "Service_Plan_Id": "e95bec33-7c88-4a70-8e19-b10bd9d0c014", + "Service_Plans_Included_Friendly_Names": "Office for the Web" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "OFFICEMOBILE_SUBSCRIPTION", + "Service_Plan_Id": "c63d4d19-e8cb-460e-b37c-4d6c34603745", + "Service_Plans_Included_Friendly_Names": "Office Mobile Apps for Office 365" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "PEOPLE_SKILLS_FOUNDATION", + "Service_Plan_Id": "13b6da2c-0d84-450e-9f69-a33e221387ca", + "Service_Plans_Included_Friendly_Names": "People Skills - Foundation" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "PROJECT_O365_P1", + "Service_Plan_Id": "a55dfd10-0864-46d9-a3cd-da5991a3e0e2", + "Service_Plans_Included_Friendly_Names": "Project for Office (Plan E1)" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "Bing_Chat_Enterprise", + "Service_Plan_Id": "0d0c0d31-fae7-41f2-b909-eaf4d7f26dba", + "Service_Plans_Included_Friendly_Names": "RETIRED - Commercial data protection for Microsoft Copilot" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "PLACES_CORE", + "Service_Plan_Id": "1fe6227d-3e01-46d0-9510-0acad4ff6e94", + "Service_Plans_Included_Friendly_Names": "RETIRED - Places Core" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "SHAREPOINTSTANDARD", + "Service_Plan_Id": "c7699d2e-19aa-44de-8edf-1736da088ca1", + "Service_Plans_Included_Friendly_Names": "SharePoint (Plan 1)" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "MCOSTANDARD", + "Service_Plan_Id": "0feaeb32-d00e-4d66-bd5a-43b5b83db82c", + "Service_Plans_Included_Friendly_Names": "Skype for Business Online (Plan 2)" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "SWAY", + "Service_Plan_Id": "a23b959c-7ce8-4e57-9140-b90eb88a9e97", + "Service_Plans_Included_Friendly_Names": "Sway" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "BPOS_S_TODO_1", + "Service_Plan_Id": "5e62787c-c316-451f-b873-1d05acd4d12c", + "Service_Plans_Included_Friendly_Names": "To-Do (Plan 1)" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "VIVAENGAGE_CORE", + "Service_Plan_Id": "a82fbf69-b4d7-49f4-83a6-915b2cf354f4", + "Service_Plans_Included_Friendly_Names": "Viva Engage Core" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "VIVA_LEARNING_SEEDED", + "Service_Plan_Id": "b76fb638-6ba6-402a-b9f9-83d28acb3d86", + "Service_Plans_Included_Friendly_Names": "Viva Learning Seeded" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "WHITEBOARD_PLAN1", + "Service_Plan_Id": "b8afc642-032e-4de5-8c0a-507a7bba7e5d", + "Service_Plans_Included_Friendly_Names": "Whiteboard (Plan 1)" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "YAMMER_ENTERPRISE", + "Service_Plan_Id": "7547a3fe-08ee-4ccb-b430-5077c5041653", + "Service_Plans_Included_Friendly_Names": "Yammer Enterprise" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "DYN365_CDS_O365_P1", + "Service_Plan_Id": "40b010bb-0b69-4654-ac5e-ba161433f4b4", + "Service_Plans_Included_Friendly_Names": "Common Data Service" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "RMS_S_BASIC", + "Service_Plan_Id": "31cf2cfc-6b0d-4adc-a336-88b724ed8122", + "Service_Plans_Included_Friendly_Names": "Microsoft Azure Rights Management Service" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "STREAM_O365_E1", + "Service_Plan_Id": "743dd19e-1ce3-4c62-a3ad-49ba8f63a2f6", + "Service_Plans_Included_Friendly_Names": "Microsoft Stream for Office 365 E1" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "POWERAPPS_O365_P1", + "Service_Plan_Id": "92f7a6f3-b89b-4bbd-8c30-809e6da5ad1c", + "Service_Plans_Included_Friendly_Names": "Power Apps for Office 365" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "FLOW_O365_P1", + "Service_Plan_Id": "0f9b09cb-62d1-4ff4-9129-43f4996f83f4", + "Service_Plans_Included_Friendly_Names": "Power Automate for Office 365" + }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "POWER_VIRTUAL_AGENTS_O365_P1", + "Service_Plan_Id": "0683001c-0492-4d59-9515-d9a6426b5813", + "Service_Plans_Included_Friendly_Names": "Power Virtual Agents for Office 365" + }, { "Product_Display_Name": "Office 365 E1 EEA (no Teams)", "String_Id": "Office_365_w/o_Teams_Bundle_E1", @@ -39109,7 +41589,7 @@ "GUID": "0f13a262-dc6f-4800-8dc6-a62f72c95fad", "Service_Plan_Name": "CDSAICAPACITY_PERUSER", "Service_Plan_Id": "91f50f7b-2204-4803-acac-5cf5668b8b39", - "Service_Plans_Included_Friendly_Names": "DO NOT USE - AI Builder capacity Per User add-on" + "Service_Plans_Included_Friendly_Names": "AI Builder capacity Per User add-on" }, { "Product_Display_Name": "PowerApps & Flow GCC Test - O365 & Dyn365 Plans", @@ -39531,9 +42011,9 @@ "Product_Display_Name": "Power Apps Premium", "String_Id": "POWERAPPS_PER_USER", "GUID": "b30411f5-fea1-4a59-9ad9-3db7c7ead579", - "Service_Plan_Name": "DYN365_CDS_P2", - "Service_Plan_Id": "6ea4c1ef-c259-46df-bce2-943342cd3cb2", - "Service_Plans_Included_Friendly_Names": "Common Data Service - P2" + "Service_Plan_Name": "Power_Pages_Internal_User", + "Service_Plan_Id": "60bf28f9-2b70-4522-96f7-335f5e06c941", + "Service_Plans_Included_Friendly_Names": "Power Pages Internal User" }, { "Product_Display_Name": "Power Apps Premium", @@ -39543,6 +42023,30 @@ "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", "Service_Plans_Included_Friendly_Names": "Exchange Foundation" }, + { + "Product_Display_Name": "Power Apps Premium", + "String_Id": "POWERAPPS_PER_USER", + "GUID": "b30411f5-fea1-4a59-9ad9-3db7c7ead579", + "Service_Plan_Name": "CDSAICAPACITY_PERUSER_NEW", + "Service_Plan_Id": "74d93933-6f22-436e-9441-66d205435abb", + "Service_Plans_Included_Friendly_Names": "AI Builder capacity Per User add-on" + }, + { + "Product_Display_Name": "Power Apps Premium", + "String_Id": "POWERAPPS_PER_USER", + "GUID": "b30411f5-fea1-4a59-9ad9-3db7c7ead579", + "Service_Plan_Name": "DYN365_CDS_P2", + "Service_Plan_Id": "6ea4c1ef-c259-46df-bce2-943342cd3cb2", + "Service_Plans_Included_Friendly_Names": "Common Data Service" + }, + { + "Product_Display_Name": "Power Apps Premium", + "String_Id": "POWERAPPS_PER_USER", + "GUID": "b30411f5-fea1-4a59-9ad9-3db7c7ead579", + "Service_Plan_Name": "CDSAICAPACITY_PERUSER", + "Service_Plan_Id": "91f50f7b-2204-4803-acac-5cf5668b8b39", + "Service_Plans_Included_Friendly_Names": "DO NOT USE - AI Builder capacity Per User add-on" + }, { "Product_Display_Name": "Power Apps Premium", "String_Id": "POWERAPPS_PER_USER", @@ -39559,6 +42063,46 @@ "Service_Plan_Id": "dc789ed8-0170-4b65-a415-eb77d5bb350a", "Service_Plans_Included_Friendly_Names": "Power Automate for Power Apps per User Plan" }, + { + "Product_Display_Name": "Power Apps Premium embedded", + "String_Id": "POWERAPPS_PER_USER_ISVEMB", + "GUID": "2a6fb3c6-30cc-4558-a69d-032425c1a3ba", + "Service_Plan_Name": "Power_Pages_Internal_User", + "Service_Plan_Id": "60bf28f9-2b70-4522-96f7-335f5e06c941", + "Service_Plans_Included_Friendly_Names": "Power Pages Internal User" + }, + { + "Product_Display_Name": "Power Apps Premium embedded", + "String_Id": "POWERAPPS_PER_USER_ISVEMB", + "GUID": "2a6fb3c6-30cc-4558-a69d-032425c1a3ba", + "Service_Plan_Name": "EXCHANGE_S_FOUNDATION", + "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", + "Service_Plans_Included_Friendly_Names": "Exchange Foundation" + }, + { + "Product_Display_Name": "Power Apps Premium embedded", + "String_Id": "POWERAPPS_PER_USER_ISVEMB", + "GUID": "2a6fb3c6-30cc-4558-a69d-032425c1a3ba", + "Service_Plan_Name": "DYN365_CDS_P2", + "Service_Plan_Id": "6ea4c1ef-c259-46df-bce2-943342cd3cb2", + "Service_Plans_Included_Friendly_Names": "Common Data Service" + }, + { + "Product_Display_Name": "Power Apps Premium embedded", + "String_Id": "POWERAPPS_PER_USER_ISVEMB", + "GUID": "2a6fb3c6-30cc-4558-a69d-032425c1a3ba", + "Service_Plan_Name": "POWERAPPS_PER_USER", + "Service_Plan_Id": "ea2cf03b-ac60-46ae-9c1d-eeaeb63cec86", + "Service_Plans_Included_Friendly_Names": "Power Apps per User Plan" + }, + { + "Product_Display_Name": "Power Apps Premium embedded", + "String_Id": "POWERAPPS_PER_USER_ISVEMB", + "GUID": "2a6fb3c6-30cc-4558-a69d-032425c1a3ba", + "Service_Plan_Name": "Flow_PowerApps_PerUser", + "Service_Plan_Id": "dc789ed8-0170-4b65-a415-eb77d5bb350a", + "Service_Plans_Included_Friendly_Names": "Power Automate for Power Apps per User Plan" + }, { "Product_Display_Name": "Power Apps Premium for Government", "String_Id": "POWERAPPS_PER_USER_GCC", @@ -40095,6 +42639,22 @@ "Service_Plan_Id": "0bf3c642-7bb5-4ccc-884e-59d09df0266c", "Service_Plans_Included_Friendly_Names": "Power BI Premium Per User" }, + { + "Product_Display_Name": "Power BI Premium Per User Add-On for Faculty", + "String_Id": "PBI_PREMIUM_PER_USER_ADDON_FACULTY", + "GUID": "c05b235f-be75-4029-8851-6a4170758eef", + "Service_Plan_Name": "BI_AZURE_P3", + "Service_Plan_Id": "0bf3c642-7bb5-4ccc-884e-59d09df0266c", + "Service_Plans_Included_Friendly_Names": "Power BI Premium Per User" + }, + { + "Product_Display_Name": "Power BI Premium Per User Add-On for Faculty", + "String_Id": "PBI_PREMIUM_PER_USER_ADDON_FACULTY", + "GUID": "c05b235f-be75-4029-8851-6a4170758eef", + "Service_Plan_Name": "PURVIEW_DISCOVERY", + "Service_Plan_Id": "c948ea65-2053-4a5a-8a62-9eaaaf11b522", + "Service_Plans_Included_Friendly_Names": "Purview Discovery" + }, { "Product_Display_Name": "Power BI Premium Per User Add-On for GCC", "String_Id": "PBI_PREMIUM_PER_USER_ADDON_CE_GCC", @@ -40311,6 +42871,22 @@ "Service_Plan_Id": "18e74ca2-b5f0-4802-9a8b-00d2ff1e8322", "Service_Plans_Included_Friendly_Names": "Power Pages Authenticated Users per site monthly capacity GCCH" }, + { + "Product_Display_Name": "Power Pages authenticated users T1 100 users/per site/month capacity pack CN_CN", + "String_Id": "Power Pages authenticated users T1_CN_CN", + "GUID": "9a3c2a19-06c0-41b1-b2ea-13528d7b2e17", + "Service_Plan_Name": "DV_PowerPages_Authenticated_User", + "Service_Plan_Id": "7aae746a-3463-4737-b295-3c1a16c31438", + "Service_Plans_Included_Friendly_Names": "Dataverse for Power Pages Authenticated users per site" + }, + { + "Product_Display_Name": "Power Pages authenticated users T1 100 users/per site/month capacity pack CN_CN", + "String_Id": "Power Pages authenticated users T1_CN_CN", + "GUID": "9a3c2a19-06c0-41b1-b2ea-13528d7b2e17", + "Service_Plan_Name": "PowerPages_Authenticated_User_CN", + "Service_Plan_Id": "967d9574-a076-4bb7-ab89-f41f64bc142e", + "Service_Plans_Included_Friendly_Names": "Power Pages Authenticated Users per site monthly capacity China" + }, { "Product_Display_Name": "Power Pages authenticated users T1 100 users/per site/month capacity pack_GCC", "String_Id": "Power_Pages_authenticated_users_T1_100_users/per_site/month_capacity_pack_GCC", @@ -42447,6 +45023,70 @@ "Service_Plan_Id": "78b58230-ec7e-4309-913c-93a45cc4735b", "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Webinar" }, + { + "Product_Display_Name": "Teams Premium for Faculty", + "String_Id": "Teams_Premium_for_Faculty", + "GUID": "960a972f-d017-4a17-8f64-b42c8035bc7d", + "Service_Plan_Name": "MICROSOFT_ECDN", + "Service_Plan_Id": "85704d55-2e73-47ee-93b4-4b8ea14db92b", + "Service_Plans_Included_Friendly_Names": "Microsoft eCDN" + }, + { + "Product_Display_Name": "Teams Premium for Faculty", + "String_Id": "Teams_Premium_for_Faculty", + "GUID": "960a972f-d017-4a17-8f64-b42c8035bc7d", + "Service_Plan_Name": "TEAMSPRO_MGMT", + "Service_Plan_Id": "0504111f-feb8-4a3c-992a-70280f9a2869", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Intelligent" + }, + { + "Product_Display_Name": "Teams Premium for Faculty", + "String_Id": "Teams_Premium_for_Faculty", + "GUID": "960a972f-d017-4a17-8f64-b42c8035bc7d", + "Service_Plan_Name": "TEAMSPRO_CUST", + "Service_Plan_Id": "cc8c0802-a325-43df-8cba-995d0c6cb373", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Personalized" + }, + { + "Product_Display_Name": "Teams Premium for Faculty", + "String_Id": "Teams_Premium_for_Faculty", + "GUID": "960a972f-d017-4a17-8f64-b42c8035bc7d", + "Service_Plan_Name": "TEAMSPRO_PROTECTION", + "Service_Plan_Id": "f8b44f54-18bb-46a3-9658-44ab58712968", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Secure" + }, + { + "Product_Display_Name": "Teams Premium for Faculty", + "String_Id": "Teams_Premium_for_Faculty", + "GUID": "960a972f-d017-4a17-8f64-b42c8035bc7d", + "Service_Plan_Name": "TEAMSPRO_VIRTUALAPPT", + "Service_Plan_Id": "9104f592-f2a7-4f77-904c-ca5a5715883f", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Virtual Appointment" + }, + { + "Product_Display_Name": "Teams Premium for Faculty", + "String_Id": "Teams_Premium_for_Faculty", + "GUID": "960a972f-d017-4a17-8f64-b42c8035bc7d", + "Service_Plan_Name": "MCO_VIRTUAL_APPT", + "Service_Plan_Id": "711413d0-b36e-4cd4-93db-0a50a4ab7ea3", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Virtual Appointments" + }, + { + "Product_Display_Name": "Teams Premium for Faculty", + "String_Id": "Teams_Premium_for_Faculty", + "GUID": "960a972f-d017-4a17-8f64-b42c8035bc7d", + "Service_Plan_Name": "TEAMSPRO_WEBINAR", + "Service_Plan_Id": "78b58230-ec7e-4309-913c-93a45cc4735b", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Webinar" + }, + { + "Product_Display_Name": "Teams Premium for Faculty", + "String_Id": "Teams_Premium_for_Faculty", + "GUID": "960a972f-d017-4a17-8f64-b42c8035bc7d", + "Service_Plan_Name": "QUEUES_APP", + "Service_Plan_Id": "ab2d4fb5-f80a-4bf1-a11d-7f1da254041b", + "Service_Plans_Included_Friendly_Names": "Queues app for Microsoft Teams" + }, { "Product_Display_Name": "Teams Rooms Premium", "String_Id": "MTR_PREM", diff --git a/src/data/alerts.json b/src/data/alerts.json index 4779d23e58e1..082fe856f297 100644 --- a/src/data/alerts.json +++ b/src/data/alerts.json @@ -9,6 +9,16 @@ "label": "Alert on admins without any form of MFA", "recommendedRunInterval": "1d" }, + { + "name": "LicenseAssignmentErrors", + "label": "Alert on license assignment errors", + "recommendedRunInterval": "1d" + }, + { + "name": "AlertSmtpAuthSuccess", + "label": "Alert on SMTP AUTH usage with success, helps to phase out SMTP AUTH (Entra P1 Required)", + "recommendedRunInterval": "1d" + }, { "name": "NoCAConfig", "label": "Alert on tenants without a Conditional Access policy, while having Conditional Access licensing available.", @@ -28,6 +38,15 @@ "inputName": "InactiveLicensedUsersExcludeDisabled", "recommendedRunInterval": "1d" }, + { + "name": "EntraConnectSyncStatus", + "label": "Alert if Entra Connect sync is enabled and has not run in the last X hours", + "requiresInput": true, + "inputType": "number", + "inputLabel": "Hours(Default:72)", + "inputName": "EntraConnectSyncStatusHours", + "recommendedRunInterval": "1d" + }, { "name": "QuotaUsed", "label": "Alert on % mailbox quota used", @@ -46,6 +65,15 @@ "inputName": "SharePointQuota", "recommendedRunInterval": "4h" }, + { + "name": "OneDriveQuota", + "label": "Alert on % OneDrive quota used", + "requiresInput": true, + "inputType": "textField", + "inputLabel": "Enter quota percentage (default: 90)", + "inputName": "OneDriveQuota", + "recommendedRunInterval": "4h" + }, { "name": "ExpiringLicenses", "label": "Alert on licenses expiring in 30 days", @@ -76,6 +104,16 @@ "label": "Alert on new Defender Incidents found", "recommendedRunInterval": "4h" }, + { + "name": "Vulnerabilities", + "label": "Alert on vulnerabilities older than X hours", + "requiresInput": true, + "inputType": "number", + "inputLabel": "Alert on vulnerabilities first seen more than X hours ago (default: 24)", + "inputName": "AgeInHours", + "recommendedRunInterval": "4h", + "description": "Monitors for software vulnerabilities that were first discovered more than the specified number of hours ago. This helps identify lingering vulnerabilities that may have been missed or not yet remediated. Requires Defender for Endpoint/Business." + }, { "name": "UnusedLicenses", "label": "Alert on unused licenses", @@ -140,10 +178,73 @@ "label": "Alert on (new) potentially breached passwords. Generates an alert if a password is found to be breached.", "recommendedRunInterval": "7d" }, + { + "name": "LicensedUsersWithRoles", + "label": "Alert on licensed users with any administrator roles", + "recommendedRunInterval": "7d" + }, { "name": "HuntressRogueApps", "label": "Alert on Huntress Rogue Apps detected", "recommendedRunInterval": "4h", - "description": "Huntress has provided a repository of known rogue apps that are commonly used in BEC, data exfiltration and other Microsoft 365 attacks. This alert will notify you if any of these apps are detected in the selected tenant(s). For more information, see https://huntresslabs.github.io/rogueapps/." + "description": "Huntress has provided a repository of known rogue apps that are commonly used in BEC, data exfiltration and other Microsoft 365 attacks. This alert will notify you if any of these apps are detected in the selected tenant(s). For more information, see https://huntresslabs.github.io/rogueapps/.", + "requiresInput": true, + "inputType": "switch", + "inputLabel": "Ignore Disabled Apps?", + "inputName": "IgnoreDisabledApps" + }, + { + "name": "TERRL", + "label": "Alert when Tenant External Recipient Rate Limit exceeds X %", + "requiresInput": true, + "inputType": "number", + "inputLabel": "Alert % (default: 80)", + "inputName": "TERRLThreshold", + "recommendedRunInterval": "1h", + "description": "Monitors tenant outbound email volume against Microsoft's TERRL limits. Tenant data is updated every hour." + }, + { + "name": "LowDomainScore", + "label": "Alert on domains with low security score", + "requiresInput": true, + "inputType": "number", + "inputLabel": "Alert when score is below % (default: 70)", + "inputName": "InputValue", + "recommendedRunInterval": "7d", + "description": "Monitors domain security scores from the DomainAnalyser and alerts when scores fall below the specified threshold." + }, + { + "name": "MXRecordChanged", + "label": "Alert on MX record changes", + "recommendedRunInterval": "1d", + "description": "Monitors MX records for all domains and alerts when changes are detected. This helps identify potential mail routing changes that could indicate security issues or unauthorized modifications." + }, + { + "name": "GlobalAdminNoAltEmail", + "label": "Alert on Global Admin accounts without alternate email address", + "recommendedRunInterval": "7d", + "description": "Monitors Global Admin accounts and alerts when they don't have an alternate email address set, which is important for password recovery of key accounts." + }, + { + "name": "NewRiskyUsers", + "label": "Alert on new risky users (P2 License Required)", + "recommendedRunInterval": "30m", + "description": "Monitors for new risky users in the tenant. Risky users are defined as users who have performed actions that are considered risky, such as password resets, MFA failures, or suspicious activity." + }, + { + "name": "LowTenantAlignment", + "label": "Alert on low tenant alignment percentage", + "requiresInput": true, + "inputType": "number", + "inputLabel": "Alert when alignment is below % (default: 99)", + "inputName": "InputValue", + "recommendedRunInterval": "1d", + "description": "Monitors tenant alignment scores against standards templates and alerts when the alignment percentage falls below the specified threshold. This helps ensure compliance across all managed tenants." + }, + { + "name": "RestrictedUsers", + "label": "Alert on users restricted from sending email", + "recommendedRunInterval": "30m", + "description": "Monitors for users who have been restricted from sending email due to exceeding outbound spam limits. These users typically indicate a compromised account that needs immediate attention." } ] diff --git a/src/data/cipp-roles.json b/src/data/cipp-roles.json new file mode 100644 index 000000000000..f95e32fa18c6 --- /dev/null +++ b/src/data/cipp-roles.json @@ -0,0 +1,23 @@ +{ + "readonly": { + "include": ["*.Read"], + "exclude": ["CIPP.SuperAdmin.*"] + }, + "editor": { + "include": ["*.Read", "*.ReadWrite"], + "exclude": [ + "CIPP.SuperAdmin.*", + "CIPP.Admin.*", + "CIPP.AppSettings.*", + "Tenant.Standards.ReadWrite" + ] + }, + "admin": { + "include": ["*"], + "exclude": ["CIPP.SuperAdmin.*"] + }, + "superadmin": { + "include": ["*"], + "exclude": [] + } +} diff --git a/src/data/portals.json b/src/data/portals.json index 2a3d78cdd3ae..5c8011ebff77 100644 --- a/src/data/portals.json +++ b/src/data/portals.json @@ -79,5 +79,23 @@ "target": "_blank", "external": true, "icon": "ShieldMoon" + }, + { + "label": "Power Platform Portal", + "name": "Power_Platform_Portal", + "url": "https://admin.powerplatform.microsoft.com/account/login/customerId", + "variable": "customerId", + "target": "_blank", + "external": true, + "icon": "PrecisionManufacturing" + }, + { + "label": "Power BI Portal", + "name": "Power_BI_Portal", + "url": "https://app.powerbi.com/admin-portal?ctid=customerId", + "variable": "customerId", + "target": "_blank", + "external": true, + "icon": "BarChart" } ] \ No newline at end of file diff --git a/src/data/standards.json b/src/data/standards.json index 6af613f0a7eb..3706598d7d45 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -5,6 +5,7 @@ "tag": [], "helpText": "Defines the email address to receive general updates and information related to M365 subscriptions. Leave a contact field blank if you do not want to update the contact information.", "docsDescription": "", + "executiveText": "Establishes designated contact email addresses for receiving important Microsoft 365 subscription updates and notifications. This ensures proper communication channels are maintained for general, security, marketing, and technical matters, improving organizational responsiveness to critical system updates.", "addedComponent": [ { "type": "textField", @@ -38,11 +39,86 @@ "powershellEquivalent": "Set-MsolCompanyContactInformation", "recommendedBy": [] }, + { + "name": "standards.DeployMailContact", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Creates a new mail contact in Exchange Online across all selected tenants. The contact will be visible in the Global Address List.", + "docsDescription": "This standard creates a new mail contact in Exchange Online. Mail contacts are useful for adding external email addresses to your organization's address book. They can be used for distribution lists, shared mailboxes, and other collaboration scenarios.", + "executiveText": "Automatically creates external email contacts in the organization's address book, enabling seamless communication with external partners and vendors. This standardizes contact management across all company locations and improves collaboration efficiency.", + "addedComponent": [ + { + "type": "textField", + "name": "standards.DeployMailContact.ExternalEmailAddress", + "label": "External Email Address", + "required": true + }, + { + "type": "textField", + "name": "standards.DeployMailContact.DisplayName", + "label": "Display Name", + "required": true + }, + { + "type": "textField", + "name": "standards.DeployMailContact.FirstName", + "label": "First Name", + "required": false + }, + { + "type": "textField", + "name": "standards.DeployMailContact.LastName", + "label": "Last Name", + "required": false + } + ], + "label": "Deploy Mail Contact", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2024-03-19", + "powershellEquivalent": "New-MailContact", + "recommendedBy": ["CIPP"] + }, + { + "name": "standards.DeployContactTemplates", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Creates new mail contacts in Exchange Online across all selected tenants based on the selected templates. The contact will be visible in the Global Address List unless hidden.", + "docsDescription": "This standard creates new mail contacts in Exchange Online based on the selected templates. Mail contacts are useful for adding external email addresses to your organization's address book. They can be used for distribution lists, shared mailboxes, and other collaboration scenarios.", + "executiveText": "Deploys standardized external contact templates across all company locations, ensuring consistent communication channels with key external partners, vendors, and stakeholders. This streamlines contact management and maintains uniform business relationships.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": true, + "creatable": false, + "label": "Select Mail Contact Templates", + "name": "standards.DeployContactTemplates.templateIds", + "api": { + "url": "/api/ListContactTemplates", + "labelField": "name", + "valueField": "GUID", + "queryKey": "Contact Templates" + } + } + ], + "label": "Deploy Mail Contact Template", + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-05-31", + "powershellEquivalent": "New-MailContact", + "recommendedBy": ["CIPP"] + }, { "name": "standards.AuditLog", "cat": "Global Standards", - "tag": ["CIS", "mip_search_auditlog"], + "tag": ["CIS M365 5.0 (3.1.1)", "mip_search_auditlog", "NIST CSF 2.0 (DE.CM-09)"], "helpText": "Enables the Unified Audit Log for tracking and auditing activities. Also runs Enable-OrganizationCustomization if necessary.", + "executiveText": "Activates comprehensive activity logging across Microsoft 365 services to track user actions, system changes, and security events. This provides essential audit trails for compliance requirements, security investigations, and regulatory reporting.", "addedComponent": [], "label": "Enable the Unified Audit Log", "impact": "Low Impact", @@ -51,12 +127,28 @@ "powershellEquivalent": "Enable-OrganizationCustomization", "recommendedBy": ["CIS", "CIPP"] }, + { + "name": "standards.RestrictThirdPartyStorageServices", + "cat": "Global Standards", + "tag": ["CIS M365 5.0 (1.3.7)"], + "helpText": "Restricts third-party storage services in Microsoft 365 on the web by managing the Microsoft 365 on the web service principal. This disables integrations with services like Dropbox, Google Drive, Box, and other third-party storage providers.", + "docsDescription": "Third-party storage can be enabled for users in Microsoft 365, allowing them to store and share documents using services such as Dropbox, alongside OneDrive and team sites. This standard ensures Microsoft 365 on the web third-party storage services are restricted by creating and disabling the Microsoft 365 on the web service principal (appId: c1f33bc0-bdb4-4248-ba9b-096807ddb43e). By using external storage services an organization may increase the risk of data breaches and unauthorized access to confidential information. Additionally, third-party services may not adhere to the same security standards as the organization, making it difficult to maintain data privacy and security. Impact is highly dependent upon current practices - if users do not use other storage providers, then minimal impact is likely. However, if users regularly utilize providers outside of the tenant this will affect their ability to continue to do so.", + "executiveText": "Prevents employees from using external cloud storage services like Dropbox, Google Drive, and Box within Microsoft 365, reducing data security risks and ensuring all company data remains within controlled corporate systems. This helps maintain data governance and prevents potential data leaks to unauthorized platforms.", + "addedComponent": [], + "label": "Restrict third-party storage services in Microsoft 365 on the web", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2025-06-06", + "powershellEquivalent": "New-MgServicePrincipal and Update-MgServicePrincipal", + "recommendedBy": ["CIS"] + }, { "name": "standards.ProfilePhotos", "cat": "Global Standards", "tag": [], "helpText": "Controls whether users can set their own profile photos in Microsoft 365.", "docsDescription": "Controls whether users can set their own profile photos in Microsoft 365. When disabled, only User and Global administrators can update profile photos for users.", + "executiveText": "Manages user profile photo permissions within Microsoft 365, allowing organizations to control whether employees can upload their own photos or require administrative approval. This helps maintain professional appearance standards and prevents inappropriate images in corporate directories.", "addedComponent": [ { "type": "autoComplete", @@ -88,6 +180,7 @@ "cat": "Global Standards", "tag": [], "helpText": "Adds branding to the logon page that only appears if the url is not login.microsoftonline.com. This potentially prevents AITM attacks via EvilNginx. This will also automatically generate alerts if a clone of your login page has been found when set to Remediate.", + "executiveText": "Implements advanced phishing protection by adding visual indicators to login pages that help users identify legitimate Microsoft login pages versus fraudulent copies. This security measure protects against sophisticated phishing attacks that attempt to steal employee credentials.", "addedComponent": [], "label": "Enable Phishing Protection system via branding CSS", "impact": "Low Impact", @@ -106,6 +199,7 @@ "cat": "Global Standards", "tag": [], "helpText": "Sets the branding for the tenant. This includes the login page, and the Office 365 portal.", + "executiveText": "Customizes Microsoft 365 login pages and portals with company branding, including logos, colors, and messaging. This creates a consistent corporate identity experience for employees and reinforces brand recognition while maintaining professional appearance across all Microsoft services.", "addedComponent": [ { "type": "textField", @@ -161,9 +255,10 @@ { "name": "standards.EnableCustomerLockbox", "cat": "Global Standards", - "tag": ["CIS", "CustomerLockBoxEnabled"], + "tag": ["CIS M365 5.0 (1.3.6)", "CustomerLockBoxEnabled"], "helpText": "Enables Customer Lockbox that offers an approval process for Microsoft support to access organization data", "docsDescription": "Customer Lockbox ensures that Microsoft can't access your content to do service operations without your explicit approval. Customer Lockbox ensures only authorized requests allow access to your organizations data.", + "executiveText": "Requires explicit organizational approval before Microsoft support staff can access company data for service operations. This provides an additional layer of data protection and ensures the organization maintains control over who can access sensitive business information, even during technical support scenarios.", "addedComponent": [], "label": "Enable Customer Lockbox", "impact": "Low Impact", @@ -177,6 +272,7 @@ "cat": "Global Standards", "tag": [], "helpText": "Enables the Pronouns feature for the tenant. This allows users to set their pronouns in their profile.", + "executiveText": "Allows employees to display their preferred pronouns in their Microsoft 365 profiles, supporting inclusive workplace practices and helping colleagues communicate respectfully. This feature enhances diversity and inclusion initiatives while fostering a more welcoming work environment.", "addedComponent": [], "label": "Enable Pronouns", "impact": "Low Impact", @@ -185,12 +281,27 @@ "powershellEquivalent": "Update-MgBetaAdminPeoplePronoun -IsEnabledInOrganization:$true", "recommendedBy": [] }, + { + "name": "standards.EnableNamePronunciation", + "cat": "Global Standards", + "tag": [], + "helpText": "Enables the Name Pronunciation feature for the tenant. This allows users to set their name pronunciation in their profile.", + "docsDescription": "Enables the Name Pronunciation feature for the tenant. This allows users to set their name pronunciation in their profile.", + "executiveText": "Enables employees to add pronunciation guides for their names in Microsoft 365 profiles, improving communication and respect in diverse workplaces. This feature helps colleagues pronounce names correctly, enhancing professional relationships and inclusive culture.", + "addedComponent": [], + "label": "Enable Name Pronunciation", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-06-06", + "recommendedBy": ["CIPP"] + }, { "name": "standards.AnonReportDisable", "cat": "Global Standards", "tag": [], "helpText": "Shows usernames instead of pseudo anonymised names in reports. This standard is required for reporting to work correctly.", "docsDescription": "Microsoft announced some APIs and reports no longer return names, to comply with compliance and legal requirements in specific countries. This proves an issue for a lot of MSPs because those reports are often helpful for engineers. This standard applies a setting that shows usernames in those API calls / reports.", + "executiveText": "Configures Microsoft 365 reports to display actual usernames instead of anonymized identifiers, enabling IT administrators to effectively troubleshoot issues and generate meaningful usage reports. This improves operational efficiency and system management capabilities.", "addedComponent": [], "label": "Enable Usernames instead of pseudo anonymised names in reports", "impact": "Low Impact", @@ -202,9 +313,17 @@ { "name": "standards.DisableGuestDirectory", "cat": "Global Standards", - "tag": [], + "tag": [ + "CIS M365 5.0 (5.1.6.2)", + "CISA (MS.AAD.5.1v1)", + "EIDSCA.AP14", + "EIDSCA.ST08", + "EIDSCA.ST09", + "NIST CSF 2.0 (PR.AA-05)" + ], "helpText": "Disables Guest access to enumerate directory objects. This prevents guest users from seeing other users or guests in the directory.", "docsDescription": "Sets it so guests can view only their own user profile. Permission to view other users isn't allowed. Also restricts guest users from seeing the membership of groups they're in. See exactly what get locked down in the [Microsoft documentation.](https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions)", + "executiveText": "Restricts external guest users from viewing the company's employee directory and organizational structure, protecting sensitive information about staff and internal groups. This security measure prevents unauthorized access to corporate contact information while still allowing necessary collaboration.", "addedComponent": [], "label": "Restrict guest user access to directory objects", "impact": "Low Impact", @@ -216,9 +335,10 @@ { "name": "standards.DisableBasicAuthSMTP", "cat": "Global Standards", - "tag": [], - "helpText": "Disables SMTP AUTH for the organization and all users. This is the default for new tenants.", - "docsDescription": "Disables SMTP basic authentication for the tenant and all users with it explicitly enabled.", + "tag": ["CIS M365 5.0 (6.5.4)", "NIST CSF 2.0 (PR.IR-01)"], + "helpText": "Disables SMTP AUTH organization-wide, impacting POP and IMAP clients that rely on SMTP for sending emails. Default for new tenants. For more information, see the [Microsoft documentation](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission)", + "docsDescription": "Disables tenant-wide SMTP basic authentication, including for all explicitly enabled users, impacting POP and IMAP clients that rely on SMTP for sending emails. For more information, see the [Microsoft documentation](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission).", + "executiveText": "Disables outdated email authentication methods that are vulnerable to security attacks, forcing applications and devices to use modern, more secure authentication protocols. This reduces the risk of email-based security breaches and credential theft.", "addedComponent": [], "label": "Disable SMTP Basic Authentication", "impact": "Medium Impact", @@ -230,8 +350,9 @@ { "name": "standards.ActivityBasedTimeout", "cat": "Global Standards", - "tag": ["CIS", "spo_idle_session_timeout"], + "tag": ["CIS M365 5.0 (1.3.2)", "spo_idle_session_timeout", "NIST CSF 2.0 (PR.AA-03)"], "helpText": "Enables and sets Idle session timeout for Microsoft 365 to 1 hour. This policy affects most M365 web apps", + "executiveText": "Automatically logs out inactive users from Microsoft 365 applications after a specified time period to prevent unauthorized access to company data on unattended devices. This security measure protects against data breaches when employees leave workstations unlocked.", "addedComponent": [ { "type": "autoComplete", @@ -273,9 +394,10 @@ { "name": "standards.AuthMethodsSettings", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["EIDSCA.AG01", "EIDSCA.AG02", "EIDSCA.AG03"], "helpText": "Configures the report suspicious activity settings and system credential preferences in the authentication methods policy.", "docsDescription": "Controls the authentication methods policy settings for reporting suspicious activity and system credential preferences. These settings help enhance the security of authentication in your organization.", + "executiveText": "Configures security settings that allow users to report suspicious login attempts and manages how the system handles authentication credentials. This enhances overall security by enabling early detection of potential security threats and optimizing authentication processes.", "addedComponent": [ { "type": "autoComplete", @@ -329,17 +451,76 @@ "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicy", "recommendedBy": [] }, + { + "name": "standards.AuthMethodsPolicyMigration", + "cat": "Entra (AAD) Standards", + "tag": [], + "helpText": "Completes the migration of authentication methods policy to the new format", + "docsDescription": "Sets the authentication methods policy migration state to complete. This is required when migrating from legacy authentication policies to the new unified authentication methods policy.", + "executiveText": "Completes the transition from legacy authentication policies to Microsoft's modern unified authentication methods policy, ensuring the organization benefits from the latest security features and management capabilities. This migration enables enhanced security controls and simplified policy management.", + "addedComponent": [], + "label": "Complete Authentication Methods Policy Migration", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2025-07-07", + "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicy", + "recommendedBy": ["CIPP"] + }, { "name": "standards.AppDeploy", "cat": "Entra (AAD) Standards", "tag": [], "helpText": "Deploys selected applications to the tenant. Use a comma separated list of application IDs to deploy multiple applications. Permissions will be copied from the source application.", "docsDescription": "Uses the CIPP functionality that deploys applications across an entire tenant base as a standard.", + "executiveText": "Automatically deploys approved business applications across all company locations and users, ensuring consistent access to essential tools and maintaining standardized software configurations. This streamlines application management and reduces IT deployment overhead.", "addedComponent": [ + { + "type": "select", + "multiple": false, + "creatable": false, + "label": "App Approval Mode", + "name": "standards.AppDeploy.mode", + "options": [ + { + "label": "Template", + "value": "template" + }, + { + "label": "Copy Permissions", + "value": "copy" + } + ] + }, + { + "type": "autoComplete", + "multiple": true, + "creatable": false, + "label": "Select Applications", + "name": "standards.AppDeploy.templateIds", + "api": { + "url": "/api/ListAppApprovalTemplates", + "labelField": "TemplateName", + "valueField": "TemplateId", + "queryKey": "StdAppApprovalTemplateList", + "addedField": { + "AppId": "AppId" + } + }, + "condition": { + "field": "standards.AppDeploy.mode", + "compareType": "is", + "compareValue": "template" + } + }, { "type": "textField", "name": "standards.AppDeploy.appids", - "label": "Application IDs, comma separated" + "label": "Application IDs, comma separated", + "condition": { + "field": "standards.AppDeploy.mode", + "compareType": "isNot", + "compareValue": "template" + } } ], "label": "Deploy Application", @@ -355,6 +536,7 @@ "tag": [], "helpText": "Enables the tenant to use LAPS. You must still create a policy for LAPS to be active on all devices. Use the template standards to deploy this by default.", "docsDescription": "Enables the LAPS functionality on the tenant. Prerequisite for using Windows LAPS via Azure AD.", + "executiveText": "Enables Local Administrator Password Solution (LAPS) capability, which automatically manages and rotates local administrator passwords on company computers. This significantly improves security by preventing the use of shared or static administrator passwords that could be exploited by attackers.", "addedComponent": [], "label": "Enable LAPS on the tenant", "impact": "Low Impact", @@ -366,9 +548,19 @@ { "name": "standards.PWdisplayAppInformationRequiredState", "cat": "Entra (AAD) Standards", - "tag": ["CIS"], + "tag": [ + "CIS M365 5.0 (2.3.1)", + "EIDSCA.AM03", + "EIDSCA.AM04", + "EIDSCA.AM06", + "EIDSCA.AM07", + "EIDSCA.AM09", + "EIDSCA.AM10", + "NIST CSF 2.0 (PR.AA-03)" + ], "helpText": "Enables the MS authenticator app to display information about the app that is requesting authentication. This displays the application name.", "docsDescription": "Allows users to use Passwordless with Number Matching and adds location information from the last request", + "executiveText": "Enhances authentication security by requiring users to match numbers and showing detailed information about login requests, including application names and location data. This helps employees verify legitimate login attempts and prevents unauthorized access through more secure authentication methods.", "addedComponent": [], "label": "Enable Passwordless with Location information and Number Matching", "impact": "Low Impact", @@ -380,9 +572,10 @@ { "name": "standards.allowOTPTokens", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["EIDSCA.AM02"], "helpText": "Allows you to use MS authenticator OTP token generator", "docsDescription": "Allows you to use Microsoft Authenticator OTP token generator. Useful for using the NPS extension as MFA on VPN clients.", + "executiveText": "Enables one-time password generation through Microsoft Authenticator app, providing an additional secure authentication method for employees. This is particularly useful for secure VPN access and other systems requiring multi-factor authentication.", "addedComponent": [], "label": "Enable OTP via Authenticator", "impact": "Low Impact", @@ -394,9 +587,10 @@ { "name": "standards.PWcompanionAppAllowedState", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["EIDSCA.AM01"], "helpText": "Sets the state of Authenticator Lite, Authenticator lite is a companion app for passwordless authentication.", "docsDescription": "Sets the Authenticator Lite state to enabled. This allows users to use the Authenticator Lite built into the Outlook app instead of the full Authenticator app.", + "executiveText": "Enables a simplified authentication experience by allowing users to authenticate directly through Outlook without requiring a separate authenticator app. This improves user convenience while maintaining security standards for passwordless authentication.", "addedComponent": [ { "type": "autoComplete", @@ -412,6 +606,10 @@ { "label": "Disabled", "value": "disabled" + }, + { + "label": "Microsoft managed", + "value": "default" } ] } @@ -426,9 +624,18 @@ { "name": "standards.EnableFIDO2", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": [ + "EIDSCA.AF01", + "EIDSCA.AF02", + "EIDSCA.AF03", + "EIDSCA.AF04", + "EIDSCA.AF05", + "EIDSCA.AF06", + "NIST CSF 2.0 (PR.AA-03)" + ], "helpText": "Enables the FIDO2 authenticationMethod for the tenant", "docsDescription": "Enables FIDO2 capabilities for the tenant. This allows users to use FIDO2 keys like a Yubikey for authentication.", + "executiveText": "Enables support for hardware security keys (like YubiKey) that provide the highest level of authentication security. These physical devices prevent phishing attacks and credential theft, offering superior protection for high-value accounts and sensitive business operations.", "addedComponent": [], "label": "Enable FIDO2 capabilities", "impact": "Low Impact", @@ -443,6 +650,7 @@ "tag": [], "helpText": "Enables the HardwareOath authenticationMethod for the tenant. This allows you to use hardware tokens for generating 6 digit MFA codes.", "docsDescription": "Enables Hardware OAuth tokens for the tenant. This allows users to use hardware tokens like a Yubikey for authentication.", + "executiveText": "Enables physical hardware tokens that generate secure authentication codes, providing an alternative to smartphone-based authentication. This is particularly valuable for employees who cannot use mobile devices or require the highest security standards for accessing sensitive systems.", "addedComponent": [], "label": "Enable Hardware OAuth tokens", "impact": "Low Impact", @@ -454,9 +662,10 @@ { "name": "standards.allowOAuthTokens", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["EIDSCA.AT01", "EIDSCA.AT02"], "helpText": "Allows you to use any software OAuth token generator", "docsDescription": "Enables OTP Software OAuth tokens for the tenant. This allows users to use OTP codes generated via software, like a password manager to be used as an authentication method.", + "executiveText": "Allows employees to use third-party authentication apps and password managers to generate secure login codes, providing flexibility in authentication methods while maintaining security standards. This accommodates diverse user preferences and existing security tools.", "addedComponent": [], "label": "Enable OTP Software OAuth tokens", "impact": "Low Impact", @@ -465,12 +674,28 @@ "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", "recommendedBy": [] }, + { + "name": "standards.FormsPhishingProtection", + "cat": "Global Standards", + "tag": ["CIS M365 5.0 (1.3.5)", "Security", "PhishingProtection"], + "helpText": "Enables internal phishing protection for Microsoft Forms to help prevent malicious forms from being created and shared within the organization. This feature scans forms created by internal users for potential phishing content and suspicious patterns.", + "docsDescription": "Enables internal phishing protection for Microsoft Forms by setting the isInOrgFormsPhishingScanEnabled property to true. This security feature helps protect organizations from internal phishing attacks through Microsoft Forms by automatically scanning forms created by internal users for potential malicious content, suspicious links, and phishing patterns. When enabled, Forms will analyze form content and block or flag potentially dangerous forms before they can be shared within the organization.", + "executiveText": "Automatically scans Microsoft Forms created by employees for malicious content and phishing attempts, preventing the creation and distribution of harmful forms within the organization. This protects against both internal threats and compromised accounts that might be used to distribute malicious content.", + "addedComponent": [], + "label": "Enable internal phishing protection for Forms", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-06-06", + "powershellEquivalent": "Graph API", + "recommendedBy": ["CIS", "CIPP"] + }, { "name": "standards.TAP", "cat": "Entra (AAD) Standards", "tag": [], "helpText": "Enables TAP and sets the default TAP lifetime to 1 hour. This configuration also allows you to select if a TAP is single use or multi-logon.", "docsDescription": "Enables Temporary Password generation for the tenant.", + "executiveText": "Enables temporary access passwords that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passwords provide a secure way to restore access without compromising long-term security policies.", "addedComponent": [ { "type": "autoComplete", @@ -500,9 +725,10 @@ { "name": "standards.PasswordExpireDisabled", "cat": "Entra (AAD) Standards", - "tag": ["CIS", "PWAgePolicyNew"], + "tag": ["CIS M365 5.0 (1.3.1)", "PWAgePolicyNew"], "helpText": "Disables the expiration of passwords for the tenant by setting the password expiration policy to never expire for any user.", "docsDescription": "Sets passwords to never expire for tenant, recommended to use in conjunction with secure password requirements.", + "executiveText": "Eliminates mandatory password expiration requirements, allowing employees to keep strong passwords indefinitely rather than forcing frequent changes that often lead to weaker passwords. This modern security approach reduces help desk calls and improves overall password security when combined with multi-factor authentication.", "addedComponent": [], "label": "Do not expire passwords", "impact": "Low Impact", @@ -511,11 +737,34 @@ "powershellEquivalent": "Update-MgDomain", "recommendedBy": ["CIS", "CIPP"] }, + { + "name": "standards.CustomBannedPasswordList", + "cat": "Entra (AAD) Standards", + "tag": ["CIS M365 5.0 (5.2.3.2)"], + "helpText": "**Requires Entra ID P1.** Updates and enables the Entra ID custom banned password list with the supplied words. Enter words separated by commas or semicolons. Each word must be 4-16 characters long. Maximum 1,000 words allowed.", + "docsDescription": "Updates and enables the Entra ID custom banned password list with the supplied words. This supplements the global banned password list maintained by Microsoft. The custom list is limited to 1,000 key base terms of 4-16 characters each. Entra ID will [block variations and common substitutions](https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-configure-custom-password-protection#configure-custom-banned-passwords) of these words in user passwords. [How are passwords evaluated?](https://learn.microsoft.com/en-us/entra/identity/authentication/concept-password-ban-bad#score-calculation)", + "addedComponent": [ + { + "type": "textField", + "name": "standards.CustomBannedPasswordList.BannedWords", + "label": "Banned Words", + "placeholder": "Banned words separated by commas or semicolons", + "required": true + } + ], + "label": "Set Entra ID Custom Banned Password List", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2025-06-28", + "powershellEquivalent": "Get-MgBetaDirectorySetting, New-MgBetaDirectorySetting, Update-MgBetaDirectorySetting", + "recommendedBy": ["CIS"] + }, { "name": "standards.ExternalMFATrusted", "cat": "Entra (AAD) Standards", "tag": [], "helpText": "Sets the state of the Cross-tenant access setting to trust external MFA. This allows guest users to use their home tenant MFA to access your tenant.", + "executiveText": "Allows external partners and vendors to use their own organization's multi-factor authentication when accessing company resources, streamlining collaboration while maintaining security standards. This reduces friction for external users while ensuring they still meet authentication requirements.", "addedComponent": [ { "type": "autoComplete", @@ -545,9 +794,10 @@ { "name": "standards.DisableTenantCreation", "cat": "Entra (AAD) Standards", - "tag": ["CIS"], + "tag": ["CIS M365 5.0 (1.2.3)", "CISA (MS.AAD.6.1v1)"], "helpText": "Restricts creation of M365 tenants to the Global Administrator or Tenant Creator roles.", "docsDescription": "Users by default are allowed to create M365 tenants. This disables that so only admins can create new M365 tenants.", + "executiveText": "Prevents regular employees from creating new Microsoft 365 organizations, ensuring all new tenants are properly managed and controlled by IT administrators. This prevents unauthorized shadow IT environments and maintains centralized governance over Microsoft 365 resources.", "addedComponent": [], "label": "Disable M365 Tenant creation by users", "impact": "Low Impact", @@ -559,9 +809,20 @@ { "name": "standards.EnableAppConsentRequests", "cat": "Entra (AAD) Standards", - "tag": ["CIS"], + "tag": [ + "CIS M365 5.0 (1.5.2)", + "CISA (MS.AAD.9.1v1)", + "EIDSCA.CP04", + "EIDSCA.CR01", + "EIDSCA.CR02", + "EIDSCA.CR03", + "EIDSCA.CR04", + "Essential 8 (1507)", + "NIST CSF 2.0 (PR.AA-05)" + ], "helpText": "Enables App consent admin requests for the tenant via the GA role. Does not overwrite existing reviewer settings", "docsDescription": "Enables the ability for users to request admin consent for applications. Should be used in conjunction with the \"Require admin consent for applications\" standards", + "executiveText": "Establishes a formal approval process where employees can request access to business applications that require administrative review. This balances security with productivity by allowing controlled access to necessary tools while preventing unauthorized application installations.", "addedComponent": [ { "type": "AdminRolesMultiSelect", @@ -582,6 +843,7 @@ "tag": [], "helpText": "Sets the state of the registration campaign for the tenant", "docsDescription": "Sets the state of the registration campaign for the tenant. If enabled nudges users to set up the Microsoft Authenticator during sign-in.", + "executiveText": "Prompts employees to set up multi-factor authentication during login, gradually improving the organization's security posture by encouraging adoption of stronger authentication methods. This helps achieve better security compliance without forcing immediate mandatory changes.", "addedComponent": [ { "type": "autoComplete", @@ -617,9 +879,10 @@ { "name": "standards.DisableM365GroupUsers", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["CISA (MS.AAD.21.1v1)"], "helpText": "Restricts M365 group creation to certain admin roles. This disables the ability to create Teams, SharePoint sites, Planner, etc", "docsDescription": "Users by default are allowed to create M365 groups. This restricts M365 group creation to certain admin roles. This disables the ability to create Teams, SharePoint sites, Planner, etc", + "executiveText": "Restricts the creation of Microsoft 365 groups, Teams, and SharePoint sites to authorized administrators, preventing uncontrolled proliferation of collaboration spaces. This ensures proper governance, naming conventions, and resource management while maintaining oversight of all collaborative environments.", "addedComponent": [], "label": "Disable M365 Group creation by users", "impact": "Low Impact", @@ -631,9 +894,16 @@ { "name": "standards.DisableAppCreation", "cat": "Entra (AAD) Standards", - "tag": ["CIS"], + "tag": [ + "CIS M365 5.0 (1.2.2)", + "CISA (MS.AAD.4.1v1)", + "EIDSCA.AP10", + "Essential 8 (1175)", + "NIST CSF 2.0 (PR.AA-05)" + ], "helpText": "Disables the ability for users to create App registrations in the tenant.", "docsDescription": "Disables the ability for users to create applications in Entra. Done to prevent breached accounts from creating an app to maintain access to the tenant, even after the breached account has been secured.", + "executiveText": "Prevents regular employees from creating application registrations that could be used to maintain unauthorized access to company systems. This security measure ensures that only authorized IT personnel can create applications, reducing the risk of persistent security breaches through malicious applications.", "addedComponent": [], "label": "Disable App creation by users", "impact": "Low Impact", @@ -643,10 +913,44 @@ "recommendedBy": ["CIS", "CIPP"] }, { - "name": "standards.DisableSecurityGroupUsers", + "name": "standards.BitLockerKeysForOwnedDevice", "cat": "Entra (AAD) Standards", "tag": [], + "helpText": "Controls whether standard users can recover BitLocker keys for devices they own.", + "docsDescription": "Updates the Microsoft Entra authorization policy that controls whether standard users can read BitLocker recovery keys for devices they own. Choose to restrict access for tighter security or allow self-service recovery when operational needs require it.", + "executiveText": "Gives administrators centralized control over BitLocker recovery secretsβ€”restrict access to ensure IT-assisted recovery flows, or allow self-service when rapid device unlocks are a priority.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Select state", + "name": "standards.BitLockerKeysForOwnedDevice.state", + "options": [ + { + "label": "Restrict", + "value": "restrict" + }, + { + "label": "Allow", + "value": "allow" + } + ] + } + ], + "label": "Control BitLocker key recovery for owned devices", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-10-12", + "powershellEquivalent": "Update-MgBetaPolicyAuthorizationPolicy", + "recommendedBy": [] + }, + { + "name": "standards.DisableSecurityGroupUsers", + "cat": "Entra (AAD) Standards", + "tag": ["CISA (MS.AAD.20.1v1)", "NIST CSF 2.0 (PR.AA-05)"], "helpText": "Completely disables the creation of security groups by users. This also breaks the ability to manage groups themselves, or create Teams", + "executiveText": "Restricts the creation of security groups to IT administrators only, preventing employees from creating unauthorized access groups that could bypass security controls. This ensures proper governance of access permissions and maintains centralized control over who can access what resources.", "addedComponent": [], "label": "Disable Security Group creation by users", "impact": "Medium Impact", @@ -660,6 +964,7 @@ "cat": "Entra (AAD) Standards", "tag": [], "helpText": "This standard currently does not function and can be safely disabled", + "executiveText": "This standard is currently non-functional and should be disabled. It was previously designed to remove outdated multi-factor authentication configurations in favor of modern security policies.", "addedComponent": [], "label": "Remove Legacy MFA if SD or CA is active", "impact": "Medium Impact", @@ -672,7 +977,8 @@ "name": "standards.DisableSelfServiceLicenses", "cat": "Entra (AAD) Standards", "tag": [], - "helpText": "This standard disables all self service licenses and enables all exclusions", + "helpText": "Note: requires 'Billing Administrator' GDAP role. This standard disables all self service licenses and enables all exclusions", + "executiveText": "Prevents employees from purchasing Microsoft 365 licenses independently, ensuring all software acquisitions go through proper procurement channels. This maintains budget control, prevents unauthorized spending, and ensures compliance with corporate licensing agreements.", "addedComponent": [ { "type": "textField", @@ -692,9 +998,18 @@ "name": "standards.DisableGuests", "cat": "Entra (AAD) Standards", "tag": [], - "helpText": "Blocks login for guest users that have not logged in for 90 days", - "addedComponent": [], - "label": "Disable Guest accounts that have not logged on for 90 days", + "helpText": "Blocks login for guest users that have not logged in for a number of days", + "executiveText": "Automatically disables external guest accounts that haven't been used for a number of days, reducing security risks from dormant accounts while maintaining access for active external collaborators. This helps maintain a clean user directory and reduces potential attack vectors.", + "addedComponent": [ + { + "type": "number", + "name": "standards.DisableGuests.days", + "required": true, + "defaultValue": 90, + "label": "Days of inactivity" + } + ], + "label": "Disable Guest accounts that have not logged on for a number of days", "impact": "Medium Impact", "impactColour": "warning", "addedDate": "2022-10-20", @@ -704,9 +1019,17 @@ { "name": "standards.OauthConsent", "cat": "Entra (AAD) Standards", - "tag": ["CIS"], + "tag": [ + "CIS M365 5.0 (1.5.1)", + "CISA (MS.AAD.4.2v1)", + "EIDSCA.AP08", + "EIDSCA.AP09", + "Essential 8 (1175)", + "NIST CSF 2.0 (PR.AA-05)" + ], "helpText": "Disables users from being able to consent to applications, except for those specified in the field below", "docsDescription": "Requires users to get administrator consent before sharing data with applications. You can preapprove specific applications.", + "executiveText": "Requires administrative approval before employees can grant applications access to company data, preventing unauthorized data sharing and potential security breaches. This protects against malicious applications while allowing approved business tools to function normally.", "addedComponent": [ { "type": "textField", @@ -728,6 +1051,7 @@ "tag": ["IntegratedApps"], "helpText": "Sets the default oauth consent level so users can consent to applications that have low risks.", "docsDescription": "Allows users to consent to applications with low assigned risk.", + "executiveText": "Allows employees to approve low-risk applications without administrative intervention, balancing security with productivity. This provides a middle ground between complete restriction and open access, enabling business agility while maintaining protection against high-risk applications.", "label": "Allow users to consent to applications with low security risk (Prevent OAuth phishing. Lower impact, less secure)", "impact": "Medium Impact", "impactColour": "warning", @@ -738,8 +1062,9 @@ { "name": "standards.GuestInvite", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["CISA (MS.AAD.18.1v1)", "EIDSCA.AP04", "EIDSCA.AP07"], "helpText": "This setting controls who can invite guests to your directory to collaborate on resources secured by your company, such as SharePoint sites or Azure resources.", + "executiveText": "Controls who within the organization can invite external partners and vendors to access company resources, ensuring proper oversight of external access while enabling necessary business collaboration. This helps maintain security while supporting partnership and vendor relationships.", "addedComponent": [ { "type": "autoComplete", @@ -778,9 +1103,10 @@ { "name": "standards.StaleEntraDevices", "cat": "Entra (AAD) Standards", - "tag": ["CIS"], + "tag": ["Essential 8 (1501)", "NIST CSF 2.0 (ID.AM-08)", "NIST CSF 2.0 (PR.PS-03)"], "helpText": "Remediate is currently not available. Cleans up Entra devices that have not connected/signed in for the specified number of days.", "docsDescription": "Remediate is currently not available. Cleans up Entra devices that have not connected/signed in for the specified number of days. First disables and later deletes the devices. More info can be found in the [Microsoft documentation](https://learn.microsoft.com/en-us/entra/identity/devices/manage-stale-devices)", + "executiveText": "Automatically identifies and removes inactive devices that haven't connected to company systems for a specified period, reducing security risks from abandoned or lost devices. This maintains a clean device inventory and prevents potential unauthorized access through dormant device registrations.", "addedComponent": [ { "type": "number", @@ -791,7 +1117,7 @@ "disabledFeatures": { "report": false, "warn": false, - "remediate": true + "remediate": false }, "label": "Cleanup stale Entra devices", "impact": "High Impact", @@ -805,6 +1131,7 @@ "cat": "Entra (AAD) Standards", "tag": [], "helpText": "Disables App consent and set to Allow user consent for apps", + "executiveText": "Reverses application consent restrictions, allowing employees to approve applications independently without administrative oversight. This increases productivity and user autonomy but reduces security controls over data access permissions.", "addedComponent": [], "label": "Undo App Consent Standard", "impact": "High Impact", @@ -816,9 +1143,10 @@ { "name": "standards.SecurityDefaults", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["CISA (MS.AAD.11.1v1)"], "helpText": "Enables security defaults for the tenant, for newer tenants this is enabled by default. Do not enable this feature if you use Conditional Access.", "docsDescription": "Enables SD for the tenant, which disables all forms of basic authentication and enforces users to configure MFA. Users are only prompted for MFA when a logon is considered 'suspect' by Microsoft.", + "executiveText": "Activates Microsoft's baseline security configuration that requires multi-factor authentication and blocks legacy authentication methods. This provides essential security protection for organizations without complex conditional access policies, significantly improving security posture with minimal configuration.", "addedComponent": [], "label": "Enable Security Defaults", "impact": "High Impact", @@ -830,9 +1158,10 @@ { "name": "standards.DisableSMS", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["CIS M365 5.0 (2.3.5)", "EIDSCA.AS04", "NIST CSF 2.0 (PR.AA-03)"], "helpText": "This blocks users from using SMS as an MFA method. If a user only has SMS as a MFA method, they will be unable to log in.", "docsDescription": "Disables SMS as an MFA method for the tenant. If a user only has SMS as a MFA method, they will be unable to sign in.", + "executiveText": "Disables SMS text messages as a multi-factor authentication method due to security vulnerabilities like SIM swapping attacks. This forces users to adopt more secure authentication methods like authenticator apps or hardware tokens, significantly improving account security.", "addedComponent": [], "label": "Disables SMS as an MFA method", "impact": "High Impact", @@ -844,9 +1173,10 @@ { "name": "standards.DisableVoice", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["CIS M365 5.0 (2.3.5)", "EIDSCA.AV01", "NIST CSF 2.0 (PR.AA-03)"], "helpText": "This blocks users from using Voice call as an MFA method. If a user only has Voice as a MFA method, they will be unable to log in.", "docsDescription": "Disables Voice call as an MFA method for the tenant. If a user only has Voice call as a MFA method, they will be unable to sign in.", + "executiveText": "Disables voice call authentication due to security vulnerabilities and social engineering risks. This forces users to adopt more secure authentication methods like authenticator apps, improving overall account security by eliminating phone-based attack vectors.", "addedComponent": [], "label": "Disables Voice call as an MFA method", "impact": "High Impact", @@ -858,8 +1188,9 @@ { "name": "standards.DisableEmail", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["CIS M365 5.0 (2.3.5)", "NIST CSF 2.0 (PR.AA-03)"], "helpText": "This blocks users from using email as an MFA method. This disables the email OTP option for guest users, and instead prompts them to create a Microsoft account.", + "executiveText": "Disables email-based authentication codes due to security concerns with email interception and account compromise. This forces users to adopt more secure authentication methods, particularly affecting guest users who must use stronger verification methods.", "addedComponent": [], "label": "Disables Email as an MFA method", "impact": "High Impact", @@ -874,6 +1205,7 @@ "tag": [], "helpText": "This blocks users from using Certificates as an MFA method.", "docsDescription": "", + "executiveText": "Disables certificate-based authentication as a multi-factor authentication method, typically used when organizations want to standardize on other authentication methods or when certificate management becomes too complex for the security benefit provided.", "addedComponent": [], "label": "Disables Certificates as an MFA method", "impact": "High Impact", @@ -888,6 +1220,7 @@ "tag": [], "helpText": "This blocks users from using QR Code Pin as an MFA method. If a user only has QR Code Pin as a MFA method, they will be unable to log in.", "docsDescription": "Disables QR Code Pin as an MFA method for the tenant. If a user only has QR Code Pin as a MFA method, they will be unable to sign in.", + "executiveText": "Disables QR Code Pin authentication method due to security concerns, forcing users to adopt more secure authentication alternatives. This helps standardize authentication methods and reduces potential security vulnerabilities while ensuring employees use more robust multi-factor authentication options.", "addedComponent": [], "label": "Disables QR Code Pin as an MFA method", "impact": "High Impact", @@ -899,8 +1232,19 @@ { "name": "standards.PerUserMFA", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": [ + "CIS M365 5.0 (1.2.1)", + "CIS M365 5.0 (1.1.1)", + "CIS M365 5.0 (1.1.2)", + "CISA (MS.AAD.1.1v1)", + "CISA (MS.AAD.1.2v1)", + "Essential 8 (1504)", + "Essential 8 (1173)", + "Essential 8 (1401)", + "NIST CSF 2.0 (PR.AA-03)" + ], "helpText": "Enables per user MFA for all users.", + "executiveText": "Requires all employees to use multi-factor authentication for enhanced account security, significantly reducing the risk of unauthorized access from compromised passwords. This fundamental security measure protects against the majority of account-based attacks and is essential for maintaining strong cybersecurity posture.", "addedComponent": [], "label": "Enables per user MFA for all users.", "impact": "High Impact", @@ -924,7 +1268,7 @@ "label": "Preferred Language", "api": { "url": "/languageList.json", - "labelField": "language", + "labelField": "tag", "valueField": "tag" } } @@ -939,7 +1283,7 @@ { "name": "standards.OutBoundSpamAlert", "cat": "Exchange Standards", - "tag": ["CIS"], + "tag": ["CIS M365 5.0 (2.1.6)"], "helpText": "Set the Outbound Spam Alert e-mail address", "docsDescription": "Sets the e-mail address to which outbound spam alerts are sent.", "addedComponent": [ @@ -1011,6 +1355,7 @@ "tag": [], "helpText": "Disables Transport Neutral Encapsulation Format (TNEF)/winmail.dat for the tenant. TNEF can cause issues if the recipient is not using a client supporting TNEF.", "docsDescription": "Disables Transport Neutral Encapsulation Format (TNEF)/winmail.dat for the tenant. TNEF can cause issues if the recipient is not using a client supporting TNEF. Cannot be overridden by the user. For more information, see [Microsoft's documentation.](https://learn.microsoft.com/en-us/exchange/mail-flow/content-conversion/tnef-conversion?view=exchserver-2019)", + "executiveText": "Prevents the creation of winmail.dat attachments that can cause compatibility issues when sending emails to external recipients using non-Outlook email clients. This improves email compatibility and reduces support issues with external partners and customers.", "addedComponent": [], "label": "Disable TNEF/winmail.dat", "impact": "Low Impact", @@ -1025,6 +1370,7 @@ "tag": [], "helpText": "Sets the default Focused Inbox state for the tenant. This can be overridden by the user.", "docsDescription": "Sets the default Focused Inbox state for the tenant. This can be overridden by the user in their Outlook settings. For more information, see [Microsoft's documentation.](https://support.microsoft.com/en-us/office/focused-inbox-for-outlook-f445ad7f-02f4-4294-a82e-71d8964e3978)", + "executiveText": "Configures the default setting for Outlook's Focused Inbox feature, which automatically sorts important emails into a focused view while placing less important emails in a separate section. This can improve employee productivity by reducing email clutter, though users can adjust this setting individually.", "addedComponent": [ { "type": "autoComplete", @@ -1056,6 +1402,7 @@ "tag": [], "helpText": "Sets the Cloud Message Recall state for the tenant. This allows users to recall messages from the cloud.", "docsDescription": "Sets the default state for Cloud Message Recall for the tenant. By default this is enabled. You can read more about the feature [here.](https://techcommunity.microsoft.com/t5/exchange-team-blog/cloud-based-message-recall-in-exchange-online/ba-p/3744714)", + "executiveText": "Enables employees to recall or retract emails they've sent, helping prevent embarrassing mistakes or accidental data sharing. This feature can reduce the impact of human errors in email communication and provides a safety net for sensitive information accidentally sent to wrong recipients.", "addedComponent": [ { "type": "autoComplete", @@ -1087,6 +1434,7 @@ "tag": [], "helpText": "Enables auto-expanding archives for the tenant", "docsDescription": "Enables auto-expanding archives for the tenant. Does not enable archives for users.", + "executiveText": "Enables automatic expansion of email archive storage when users approach their archive limits, ensuring continuous email retention without manual intervention. This prevents email storage issues and maintains compliance with data retention policies without requiring ongoing administrative management.", "addedComponent": [], "label": "Enable Auto-expanding archives", "impact": "Low Impact", @@ -1096,10 +1444,44 @@ "recommendedBy": [] }, { - "name": "standards.EnableOnlineArchiving", + "name": "standards.TwoClickEmailProtection", "cat": "Exchange Standards", "tag": [], + "helpText": "Configures the two-click confirmation requirement for viewing encrypted/protected emails in OWA and new Outlook. When enabled, users must click \"View message\" before accessing protected content, providing an additional layer of privacy protection.", + "docsDescription": "Configures the TwoClickMailPreviewEnabled setting in Exchange Online organization configuration. This security feature requires users to click \"View message\" before accessing encrypted or protected emails in Outlook on the web (OWA) and new Outlook for Windows. This provides additional privacy protection by preventing protected content from automatically displaying, giving users time to ensure their screen is not visible to others before viewing sensitive content. The feature helps protect against shoulder surfing and accidental disclosure of confidential information.", + "executiveText": "Requires employees to click twice before viewing encrypted or sensitive emails, preventing accidental exposure of confidential information when screens might be visible to others. This privacy protection helps prevent shoulder surfing and ensures employees are intentional about viewing sensitive content.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Select value", + "name": "standards.TwoClickEmailProtection.state", + "options": [ + { + "label": "Enabled", + "value": "enabled" + }, + { + "label": "Disabled", + "value": "disabled" + } + ] + } + ], + "label": "Set two-click confirmation for encrypted emails in New Outlook", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-06-13", + "powershellEquivalent": "Set-OrganizationConfig -TwoClickMailPreviewEnabled $true | $false", + "recommendedBy": [] + }, + { + "name": "standards.EnableOnlineArchiving", + "cat": "Exchange Standards", + "tag": ["Essential 8 (1511)", "NIST CSF 2.0 (PR.DS-11)"], "helpText": "Enables the In-Place Online Archive for all UserMailboxes with a valid license.", + "executiveText": "Automatically enables online email archiving for all licensed employees, providing additional storage for older emails while maintaining easy access. This helps manage mailbox sizes, improves email performance, and supports compliance with data retention requirements.", "addedComponent": [], "label": "Enable Online Archive for all users", "impact": "Low Impact", @@ -1113,6 +1495,7 @@ "cat": "Exchange Standards", "tag": [], "helpText": "Enables litigation hold for all UserMailboxes with a valid license.", + "executiveText": "Preserves all email content for legal and compliance purposes by preventing permanent deletion of emails, even when users attempt to delete them. This is essential for organizations subject to legal discovery requirements or regulatory compliance mandates.", "addedComponent": [ { "type": "textField", @@ -1131,9 +1514,10 @@ { "name": "standards.SpoofWarn", "cat": "Exchange Standards", - "tag": ["CIS"], + "tag": ["CIS M365 5.0 (6.2.3)"], "helpText": "Adds or removes indicators to e-mail messages received from external senders in Outlook. Works on all Outlook clients/OWA", "docsDescription": "Adds or removes indicators to e-mail messages received from external senders in Outlook. You can read more about this feature on [Microsoft's Exchange Team Blog.](https://techcommunity.microsoft.com/t5/exchange-team-blog/native-external-sender-callouts-on-email-in-outlook/ba-p/2250098)", + "executiveText": "Displays visual warnings in Outlook when emails come from external senders, helping employees identify potentially suspicious messages and reducing the risk of phishing attacks. This security feature makes it easier for staff to distinguish between internal and external communications.", "addedComponent": [ { "type": "autoComplete", @@ -1170,8 +1554,9 @@ { "name": "standards.EnableMailTips", "cat": "Exchange Standards", - "tag": ["CIS", "exo_mailtipsenabled"], + "tag": ["CIS M365 5.0 (6.5.2)", "exo_mailtipsenabled"], "helpText": "Enables all MailTips in Outlook. MailTips are the notifications Outlook and Outlook on the web shows when an email you create, meets some requirements", + "executiveText": "Enables helpful notifications in Outlook that warn users about potential email issues, such as sending to large groups, external recipients, or invalid addresses. This reduces email mistakes and improves communication efficiency by providing real-time guidance to employees.", "addedComponent": [ { "type": "number", @@ -1193,6 +1578,7 @@ "cat": "Exchange Standards", "tag": [], "helpText": "Sets the default state for automatically turning meetings into Teams meetings for the tenant. This can be overridden by the user in Outlook.", + "executiveText": "Automatically adds Microsoft Teams meeting links to calendar invitations by default, streamlining the process of creating virtual meetings. This improves collaboration efficiency and ensures consistent meeting experiences across the organization, though users can override this setting when needed.", "addedComponent": [ { "type": "autoComplete", @@ -1225,6 +1611,7 @@ "tag": [], "helpText": "Disables the daily viva reports for all users. This standard requires the CIPP-SAM application to have the Company Administrator (Global Admin) role in the tenant. Enable this using CIPP > Advanced > Super Admin > SAM App Roles. Activate the roles with a CPV refresh.", "docsDescription": "", + "executiveText": "Disables daily Microsoft Viva Insights reports that are automatically sent to employees, reducing email volume and allowing organizations to control when and how productivity insights are shared. This can help prevent information overload while maintaining the ability to access insights when needed.", "addedComponent": [], "label": "Disable daily Insight/Viva reports", "impact": "Low Impact", @@ -1236,8 +1623,9 @@ { "name": "standards.RotateDKIM", "cat": "Exchange Standards", - "tag": ["CIS"], + "tag": ["CIS M365 5.0 (2.1.9)"], "helpText": "Rotate DKIM keys that are 1024 bit to 2048 bit", + "executiveText": "Upgrades email security by replacing older 1024-bit encryption keys with stronger 2048-bit keys for email authentication. This improves the organization's email security posture and helps prevent email spoofing and tampering, maintaining trust with email recipients.", "addedComponent": [], "label": "Rotate DKIM keys that are 1024 bit to 2048 bit", "impact": "Low Impact", @@ -1249,8 +1637,9 @@ { "name": "standards.AddDKIM", "cat": "Exchange Standards", - "tag": ["CIS"], + "tag": ["CIS M365 5.0 (2.1.9)"], "helpText": "Enables DKIM for all domains that currently support it", + "executiveText": "Enables email authentication technology that digitally signs outgoing emails to verify they actually came from your organization. This prevents email spoofing, improves email deliverability, and protects the company's reputation by ensuring recipients can trust emails from your domains.", "addedComponent": [], "label": "Enables DKIM for all domains that currently support it", "impact": "Low Impact", @@ -1259,12 +1648,52 @@ "powershellEquivalent": "New-DkimSigningConfig and Set-DkimSigningConfig", "recommendedBy": ["CIS", "CIPP"] }, + { + "name": "standards.AddDMARCToMOERA", + "cat": "Global Standards", + "tag": ["CIS M365 5.0 (2.1.10)", "Security", "PhishingProtection"], + "helpText": "Note: requires 'Domain Name Administrator' GDAP role. This should be enabled even if the MOERA (onmicrosoft.com) domains is not used for sending. Enabling this prevents email spoofing. The default value is 'v=DMARC1; p=reject;' recommended because the domain is only used within M365 and reporting is not needed. Omitting pct tag default to 100%", + "docsDescription": "Note: requires 'Domain Name Administrator' GDAP role. Adds a DMARC record to MOERA (onmicrosoft.com) domains. This should be enabled even if the MOERA (onmicrosoft.com) domains is not used for sending. Enabling this prevents email spoofing. The default record is 'v=DMARC1; p=reject;' recommended because the domain is only used within M365 and reporting is not needed. Omitting pct tag default to 100%", + "executiveText": "Implements advanced email security for Microsoft's default domain names (onmicrosoft.com) to prevent criminals from impersonating your organization. This blocks fraudulent emails that could damage your company's reputation and protects partners and customers from phishing attacks using your domain names.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": true, + "required": false, + "placeholder": "v=DMARC1; p=reject; (recommended)", + "label": "Value", + "name": "standards.AddDMARCToMOERA.RecordValue", + "options": [ + { + "label": "v=DMARC1; p=reject; (recommended)", + "value": "v=DMARC1; p=reject;" + } + ] + } + ], + "label": "Enables DMARC on MOERA (onmicrosoft.com) domains", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-06-16", + "powershellEquivalent": "Portal only", + "recommendedBy": ["CIS", "Microsoft"] + }, { "name": "standards.EnableMailboxAuditing", "cat": "Exchange Standards", - "tag": ["CIS", "exo_mailboxaudit"], + "tag": [ + "CIS M365 5.0 (6.1.1)", + "CIS M365 5.0 (6.1.2)", + "CIS M365 5.0 (6.1.3)", + "exo_mailboxaudit", + "Essential 8 (1509)", + "Essential 8 (1683)", + "NIST CSF 2.0 (DE.CM-09)" + ], "helpText": "Enables Mailbox auditing for all mailboxes and on tenant level. Disables audit bypass on all mailboxes. Unified Audit Log needs to be enabled for this standard to function.", "docsDescription": "Enables mailbox auditing on tenant level and for all mailboxes. Disables audit bypass on all mailboxes. By default Microsoft does not enable mailbox auditing for Resource Mailboxes, Public Folder Mailboxes and DiscoverySearch Mailboxes. Unified Audit Log needs to be enabled for this standard to function.", + "executiveText": "Enables comprehensive logging of all email access and modifications across all employee mailboxes, providing detailed audit trails for security investigations and compliance requirements. This helps detect unauthorized access, data breaches, and supports regulatory compliance efforts.", "addedComponent": [], "label": "Enable Mailbox auditing", "impact": "Low Impact", @@ -1278,6 +1707,7 @@ "cat": "Exchange Standards", "tag": [], "helpText": "Sets the Send and Receive limits for new users. Valid values are 1MB to 150MB", + "executiveText": "Establishes standard email attachment size limits for all new employees, balancing functionality with system performance and security. This prevents email system overload from large attachments while ensuring employees can share necessary files through appropriate channels.", "addedComponent": [ { "type": "number", @@ -1305,6 +1735,7 @@ "tag": [], "helpText": "Sets the default sharing level for the default calendar, for all users", "docsDescription": "Sets the default sharing level for the default calendar for all users in the tenant. You can read about the different sharing levels [here.](https://learn.microsoft.com/en-us/powershell/module/exchange/set-mailboxfolderpermission?view=exchange-ps#-accessrights)", + "executiveText": "Configures how much calendar information employees share by default with colleagues, balancing collaboration needs with privacy. This setting determines whether others can see meeting details, free/busy times, or just availability, helping optimize scheduling while protecting sensitive meeting information.", "disabledFeatures": { "report": true, "warn": true, @@ -1374,9 +1805,10 @@ { "name": "standards.EXOOutboundSpamLimits", "cat": "Exchange Standards", - "tag": ["CIS"], + "tag": ["CIS M365 5.0 (2.1.6)"], "helpText": "Configures the outbound spam recipient limits (external per hour, internal per hour, per day) and the action to take when a limit is reached. The 'Set Outbound Spam Alert e-mail' standard is recommended to configure together with this one. ", "docsDescription": "Configures the Exchange Online outbound spam recipient limits for external per hour, internal per hour, and per day, along with the action to take (e.g., BlockUser, Alert) when these limits are exceeded. This helps prevent abuse and manage email flow. Microsoft's recommendations can be found [here.](https://learn.microsoft.com/en-us/defender-office-365/recommended-settings-for-eop-and-office365#eop-outbound-spam-policy-settings) The 'Set Outbound Spam Alert e-mail' standard is recommended to configure together with this one.", + "executiveText": "Sets limits on how many emails employees can send per hour and per day to prevent spam and protect the organization's email reputation. When limits are exceeded, the system can alert administrators or temporarily block the user, helping detect compromised accounts or prevent abuse.", "addedComponent": [ { "type": "number", @@ -1403,8 +1835,14 @@ "name": "standards.EXOOutboundSpamLimits.ActionWhenThresholdReached", "label": "Action When Threshold Reached", "options": [ - { "label": "Alert", "value": "Alert" }, - { "label": "Block User", "value": "BlockUser" }, + { + "label": "Alert", + "value": "Alert" + }, + { + "label": "Block User", + "value": "BlockUser" + }, { "label": "Block user from sending mail for the rest of the day", "value": "BlockUserForToday" @@ -1422,9 +1860,10 @@ { "name": "standards.DisableExternalCalendarSharing", "cat": "Exchange Standards", - "tag": ["CIS", "exo_individualsharing"], + "tag": ["CIS M365 5.0 (1.3.3)", "exo_individualsharing"], "helpText": "Disables the ability for users to share their calendar with external users. Only for the default policy, so exclusions can be made if needed.", "docsDescription": "Disables external calendar sharing for the entire tenant. This is not a widely used feature, and it's therefore unlikely that this will impact users. Only for the default policy, so exclusions can be made if needed by making a new policy and assigning it to users.", + "executiveText": "Prevents employees from sharing their calendars with external parties, protecting sensitive meeting information and internal schedules from unauthorized access. This security measure helps maintain confidentiality of business activities while still allowing internal collaboration.", "addedComponent": [], "label": "Disable external calendar sharing", "impact": "Low Impact", @@ -1436,9 +1875,10 @@ { "name": "standards.AutoAddProxy", "cat": "Exchange Standards", - "tag": ["CIS"], + "tag": [], "helpText": "Automatically adds all available domains as a proxy address.", "docsDescription": "Automatically finds all available domain names in the tenant, and tries to add proxy addresses based on the user's UPN to each of these.", + "executiveText": "Automatically creates email addresses for employees across all company domains, ensuring they can receive emails sent to any of the organization's domain names. This improves email delivery reliability and maintains consistent communication channels across different business units or brands.", "addedComponent": [], "label": "Automatically deploy proxy addresses", "impact": "Medium Impact", @@ -1455,9 +1895,10 @@ { "name": "standards.DisableAdditionalStorageProviders", "cat": "Exchange Standards", - "tag": ["CIS", "exo_storageproviderrestricted"], + "tag": ["CIS M365 5.0 (6.5.3)", "exo_storageproviderrestricted"], "helpText": "Disables the ability for users to open files in Outlook on the Web, from other providers such as Box, Dropbox, Facebook, Google Drive, OneDrive Personal, etc.", "docsDescription": "Disables additional storage providers in OWA. This is to prevent users from using personal storage providers like Dropbox, Google Drive, etc. Usually this has little user impact.", + "executiveText": "Prevents employees from accessing personal cloud storage services like Dropbox or Google Drive through Outlook on the web, reducing data security risks and ensuring company information stays within approved corporate systems. This helps maintain data governance and prevents accidental data leaks.", "addedComponent": [], "label": "Disable additional storage providers in OWA", "impact": "Low Impact", @@ -1469,9 +1910,10 @@ { "name": "standards.AntiSpamSafeList", "cat": "Defender Standards", - "tag": [], + "tag": ["CIS M365 5.0 (2.1.13)"], "helpText": "Sets the anti-spam connection filter policy option 'safe list' in Defender.", "docsDescription": "Sets [Microsoft's built-in 'safe list'](https://learn.microsoft.com/en-us/powershell/module/exchange/set-hostedconnectionfilterpolicy?view=exchange-ps#-enablesafelist) in the anti-spam connection filter policy, rather than setting a custom safe/block list of IPs.", + "executiveText": "Enables Microsoft's pre-approved list of trusted email servers to improve email delivery from legitimate sources while maintaining spam protection. This reduces false positives where legitimate emails might be blocked while still protecting against spam and malicious emails.", "addedComponent": [ { "type": "switch", @@ -1491,6 +1933,7 @@ "cat": "Exchange Standards", "tag": [], "helpText": "Sets the shorten meetings settings on a tenant level. This will shorten meetings by the selected amount of minutes. Valid values are 0 to 29. Short meetings are under 60 minutes, long meetings are over 60 minutes.", + "executiveText": "Automatically shortens calendar meetings by a specified number of minutes to provide buffer time between meetings, reducing back-to-back scheduling stress and allowing employees time to transition between meetings. This improves work-life balance and meeting effectiveness.", "addedComponent": [ { "type": "autoComplete", @@ -1538,6 +1981,7 @@ "tag": [], "helpText": "Sets the state of Bookings on the tenant. Bookings is a scheduling tool that allows users to book appointments with others both internal and external.", "docsDescription": "", + "executiveText": "Controls whether employees can use Microsoft Bookings to create online appointment scheduling pages for internal and external clients. This feature can improve customer service and streamline appointment management, but may need to be controlled for security or business process reasons.", "addedComponent": [ { "type": "autoComplete", @@ -1563,12 +2007,51 @@ "powershellEquivalent": "Set-OrganizationConfig -BookingsEnabled", "recommendedBy": [] }, + { + "name": "standards.EXODirectSend", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Sets the state of Direct Send in Exchange Online. Direct Send allows applications to send emails directly to Exchange Online mailboxes as the tenants domains, without requiring authentication.", + "docsDescription": "Controls whether applications can use Direct Send to send emails directly to Exchange Online mailboxes as the tenants domains, without requiring authentication. A detailed explanation from Microsoft can be found [here.](https://learn.microsoft.com/en-us/exchange/mail-flow-best-practices/how-to-set-up-a-multifunction-device-or-application-to-send-email-using-microsoft-365-or-office-365)", + "executiveText": "Controls whether business applications and devices (like printers or scanners) can send emails through the company's email system without authentication. While this enables convenient features like scan-to-email, it may pose security risks and should be carefully managed.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Select value", + "name": "standards.EXODirectSend.state", + "options": [ + { + "label": "Enabled", + "value": "enabled" + }, + { + "label": "Disabled", + "value": "disabled" + } + ] + } + ], + "label": "Set Direct Send state", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2025-05-28", + "powershellEquivalent": "Set-OrganizationConfig -RejectDirectSend $true/$false", + "recommendedBy": [] + }, { "name": "standards.DisableOutlookAddins", "cat": "Exchange Standards", - "tag": ["CIS", "exo_outlookaddins"], + "tag": [ + "CIS M365 5.0 (6.3.1)", + "exo_outlookaddins", + "NIST CSF 2.0 (PR.AA-05)", + "NIST CSF 2.0 (PR.PS-05)" + ], "helpText": "Disables the ability for users to install add-ins in Outlook. This is to prevent users from installing malicious add-ins.", "docsDescription": "Disables users from being able to install add-ins in Outlook. Only admins are able to approve add-ins for the users. This is done to reduce the threat surface for data exfiltration.", + "executiveText": "Prevents employees from installing third-party add-ins in Outlook without administrative approval, reducing security risks from potentially malicious extensions. This ensures only vetted and approved tools can access company email data while maintaining centralized control over email functionality.", "addedComponent": [], "label": "Disable users from installing add-ins in Outlook", "impact": "Medium Impact", @@ -1582,6 +2065,7 @@ "cat": "Exchange Standards", "tag": [], "helpText": "Loops through all users and removes the Safe Senders list. This is to prevent SPF bypass attacks, as the Safe Senders list is not checked by SPF.", + "executiveText": "Removes user-defined safe sender lists to prevent security bypasses where malicious emails could avoid spam filtering. This ensures all emails go through proper security screening, even if users have previously marked senders as 'safe', improving overall email security.", "addedComponent": [], "disabledFeatures": { "report": true, @@ -1601,6 +2085,7 @@ "tag": [], "helpText": "Sets emails sent as and on behalf of shared mailboxes to also be stored in the shared mailbox sent items folder", "docsDescription": "This makes sure that e-mails sent from shared mailboxes or delegate mailboxes, end up in the mailbox of the shared/delegate mailbox instead of the sender, allowing you to keep replies in the same mailbox as the original e-mail.", + "executiveText": "Ensures emails sent from shared mailboxes (like info@company.com) are stored in the shared mailbox rather than the individual sender's mailbox. This maintains complete email threads in one location, improving collaboration and ensuring all team members can see the full conversation history.", "addedComponent": [ { "type": "switch", @@ -1621,6 +2106,7 @@ "tag": [], "helpText": "Enables the ability for users to send from their alias addresses.", "docsDescription": "Allows users to change the 'from' address to any set in their Azure AD Profile.", + "executiveText": "Allows employees to send emails from their alternative email addresses (aliases) rather than just their primary address. This is useful for employees who manage multiple roles or departments, enabling them to send emails from the most appropriate address for the context.", "addedComponent": [], "label": "Allow users to send from their alias addresses", "impact": "Medium Impact", @@ -1635,6 +2121,7 @@ "tag": [], "helpText": "Set the state of the spam submission button in Outlook", "docsDescription": "Set the state of the built-in Report button in Outlook. This gives the users the ability to report emails as spam or phish.", + "executiveText": "Enables employees to easily report suspicious emails directly from Outlook, helping improve the organization's spam and phishing detection systems. This crowdsourced approach to security allows users to contribute to threat detection while providing valuable feedback to enhance email security filters.", "addedComponent": [ { "type": "autoComplete", @@ -1669,11 +2156,12 @@ { "name": "standards.DisableSharedMailbox", "cat": "Exchange Standards", - "tag": ["CIS"], + "tag": ["CIS M365 5.0 (1.2.2)", "CISA (MS.AAD.10.1v1)", "NIST CSF 2.0 (PR.AA-01)"], "helpText": "Blocks login for all accounts that are marked as a shared mailbox. This is Microsoft best practice to prevent direct logons to shared mailboxes.", "docsDescription": "Shared mailboxes can be directly logged into if the password is reset, this presents a security risk as do all shared login credentials. Microsoft's recommendation is to disable the user account for shared mailboxes. It would be a good idea to review the sign-in reports to establish potential impact.", + "executiveText": "Prevents direct login to shared mailbox accounts (like info@company.com), ensuring they can only be accessed through authorized users' accounts. This security measure eliminates the risk of shared passwords and unauthorized access while maintaining proper access control and audit trails.", "addedComponent": [], - "label": "Disable Shared Mailbox AAD accounts", + "label": "Disable Shared Mailbox Entra accounts", "impact": "Medium Impact", "impactColour": "warning", "addedDate": "2021-11-16", @@ -1681,11 +2169,33 @@ "recommendedBy": ["CIS", "CIPP"] }, { - "name": "standards.EXODisableAutoForwarding", + "name": "standards.DisableResourceMailbox", "cat": "Exchange Standards", - "tag": ["CIS", "mdo_autoforwardingmode", "mdo_blockmailforward"], - "helpText": "Disables the ability for users to automatically forward e-mails to external recipients.", + "tag": ["NIST CSF 2.0 (PR.AA-01)"], + "helpText": "Blocks login for all accounts that are marked as a resource mailbox and does not have a license assigned. Accounts that are synced from on-premises AD are excluded, as account state is managed in the on-premises AD.", + "docsDescription": "Resource mailboxes can be directly logged into if the password is reset, this presents a security risk as do all shared login credentials. Microsoft's recommendation is to disable the user account for resource mailboxes. Accounts that are synced from on-premises AD are excluded, as account state is managed in the on-premises AD.", + "executiveText": "Prevents direct login to resource mailbox accounts (like conference rooms or equipment), ensuring they can only be managed through proper administrative channels. This security measure eliminates potential unauthorized access to resource scheduling systems while maintaining proper booking functionality.", + "addedComponent": [], + "label": "Disable Unlicensed Resource Mailbox Entra accounts", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2025-06-01", + "powershellEquivalent": "Get-Mailbox & Update-MgUser", + "recommendedBy": ["Microsoft", "CIPP"] + }, + { + "name": "standards.EXODisableAutoForwarding", + "cat": "Exchange Standards", + "tag": [ + "CIS M365 5.0 (6.2.1)", + "mdo_autoforwardingmode", + "mdo_blockmailforward", + "CISA (MS.EXO.4.1v1)", + "NIST CSF 2.0 (PR.DS-02)" + ], + "helpText": "Disables the ability for users to automatically forward e-mails to external recipients.", "docsDescription": "Disables the ability for users to automatically forward e-mails to external recipients. This is to prevent data exfiltration. Please check if there are any legitimate use cases for this feature before implementing, like forwarding invoices and such.", + "executiveText": "Prevents employees from automatically forwarding company emails to external addresses, protecting against data leaks and unauthorized information sharing. This security measure helps maintain control over sensitive business communications while preventing both accidental and intentional data exfiltration.", "addedComponent": [], "label": "Disable automatic forwarding to external recipients", "impact": "High Impact", @@ -1697,9 +2207,10 @@ { "name": "standards.RetentionPolicyTag", "cat": "Exchange Standards", - "tag": [], + "tag": ["CIS M365 5.0 (6.4.1)"], "helpText": "Creates a CIPP - Deleted Items retention policy tag that permanently deletes items in the Deleted Items folder after X days.", "docsDescription": "Creates a CIPP - Deleted Items retention policy tag that permanently deletes items in the Deleted Items folder after X days.", + "executiveText": "Automatically and permanently removes deleted emails after a specified number of days, helping manage storage costs and ensuring compliance with data retention policies. This prevents accumulation of unnecessary deleted items while maintaining a reasonable recovery window for accidentally deleted emails.", "addedComponent": [ { "type": "number", @@ -1721,6 +2232,7 @@ "tag": [], "helpText": "Sets a e-mail address to alert when a User requests to release a quarantined message.", "docsDescription": "Sets a e-mail address to alert when a User requests to release a quarantined message. This is useful for monitoring and ensuring that the correct messages are released.", + "executiveText": "Notifies IT administrators when employees request to release emails that were quarantined for security reasons, enabling oversight of potentially dangerous messages. This helps ensure that legitimate emails are released while maintaining security controls over suspicious content.", "addedComponent": [ { "type": "textField", @@ -1741,6 +2253,7 @@ "tag": [], "helpText": "Sets a e-mail address to alert when a User deletes more than 20 SharePoint files within 60 minutes. NB: Requires a Office 365 E5 subscription, Office 365 E3 with Threat Intelligence or Office 365 EquivioAnalytics add-on.", "docsDescription": "Sets a e-mail address to alert when a User deletes more than 20 SharePoint files within 60 minutes. This is useful for monitoring and ensuring that the correct SharePoint files are deleted. NB: Requires a Office 365 E5 subscription, Office 365 E3 with Threat Intelligence or Office 365 EquivioAnalytics add-on.", + "executiveText": "Alerts administrators when employees delete large numbers of SharePoint files in a short time period, helping detect potential data destruction attacks, ransomware, or accidental mass deletions. This early warning system enables rapid response to protect critical business documents and data.", "addedComponent": [ { "type": "number", @@ -1770,12 +2283,54 @@ "powershellEquivalent": "New-ProtectionAlert and Set-ProtectionAlert", "recommendedBy": [] }, + { + "name": "standards.SafeLinksTemplatePolicy", + "label": "SafeLinks Policy Template", + "cat": "Templates", + "multiple": false, + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "impact": "Medium Impact", + "addedDate": "2025-04-29", + "helpText": "Deploy and manage SafeLinks policy templates to protect against malicious URLs in emails and Office documents.", + "executiveText": "Deploys standardized URL protection policies that automatically scan and verify links in emails and documents before users click them. This template-based approach ensures consistent protection against malicious websites and phishing attacks across the organization.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": true, + "creatable": false, + "name": "standards.SafeLinksTemplatePolicy.TemplateIds", + "label": "Select SafeLinks Policy Templates", + "api": { + "url": "/api/ListSafeLinksPolicyTemplates", + "labelField": "TemplateName", + "valueField": "GUID", + "queryKey": "ListSafeLinksPolicyTemplates" + } + } + ] + }, { "name": "standards.SafeLinksPolicy", "cat": "Defender Standards", - "tag": ["CIS", "mdo_safelinksforemail", "mdo_safelinksforOfficeApps"], + "tag": [ + "CIS M365 5.0 (2.1.1)", + "mdo_safelinksforemail", + "mdo_safelinksforOfficeApps", + "NIST CSF 2.0 (DE.CM-09)" + ], "helpText": "This creates a Safe Links policy that automatically scans, tracks, and and enables safe links for Email, Office, and Teams for both external and internal senders", "addedComponent": [ + { + "type": "textField", + "name": "standards.SafeLinksPolicy.name", + "label": "Policy Name", + "required": true, + "defaultValue": "CIPP Default SafeLinks Policy" + }, { "type": "switch", "label": "AllowClickThrough", @@ -1811,17 +2366,25 @@ "name": "standards.AntiPhishPolicy", "cat": "Defender Standards", "tag": [ - "CIS", "mdo_safeattachments", "mdo_highconfidencespamaction", "mdo_highconfidencephishaction", "mdo_phisspamacation", "mdo_spam_notifications_only_for_admins", "mdo_antiphishingpolicies", - "mdo_phishthresholdlevel" + "mdo_phishthresholdlevel", + "CIS M365 5.0 (2.1.7)", + "NIST CSF 2.0 (DE.CM-09)" ], "helpText": "This creates a Anti-Phishing policy that automatically enables Mailbox Intelligence and spoofing, optional switches for Mail tips.", "addedComponent": [ + { + "type": "textField", + "name": "standards.AntiPhishPolicy.name", + "label": "Policy Name", + "required": true, + "defaultValue": "CIPP Default Anti-Phishing Policy" + }, { "type": "number", "label": "Phishing email threshold. (Default 1)", @@ -1871,6 +2434,7 @@ { "type": "select", "multiple": false, + "creatable": true, "label": "Quarantine policy for Spoof", "name": "standards.AntiPhishPolicy.SpoofQuarantineTag", "options": [ @@ -1911,6 +2475,7 @@ { "type": "select", "multiple": false, + "creatable": true, "label": "Quarantine policy for user impersonation", "name": "standards.AntiPhishPolicy.TargetedUserQuarantineTag", "options": [ @@ -1951,6 +2516,7 @@ { "type": "select", "multiple": false, + "creatable": true, "label": "Quarantine policy for domain impersonation", "name": "standards.AntiPhishPolicy.TargetedDomainQuarantineTag", "options": [ @@ -1991,6 +2557,7 @@ { "type": "select", "multiple": false, + "creatable": true, "label": "Apply quarantine policy", "name": "standards.AntiPhishPolicy.MailboxIntelligenceQuarantineTag", "options": [ @@ -2019,9 +2586,22 @@ { "name": "standards.SafeAttachmentPolicy", "cat": "Defender Standards", - "tag": ["CIS", "mdo_safedocuments", "mdo_commonattachmentsfilter", "mdo_safeattachmentpolicy"], + "tag": [ + "CIS M365 5.0 (2.1.4)", + "mdo_safedocuments", + "mdo_commonattachmentsfilter", + "mdo_safeattachmentpolicy", + "NIST CSF 2.0 (DE.CM-09)" + ], "helpText": "This creates a Safe Attachment policy", "addedComponent": [ + { + "type": "textField", + "name": "standards.SafeAttachmentPolicy.name", + "label": "Policy Name", + "required": true, + "defaultValue": "CIPP Default Safe Attachment Policy" + }, { "type": "select", "multiple": false, @@ -2045,6 +2625,7 @@ { "type": "select", "multiple": false, + "creatable": true, "label": "QuarantineTag", "name": "standards.SafeAttachmentPolicy.QuarantineTag", "options": [ @@ -2071,7 +2652,12 @@ "type": "textField", "name": "standards.SafeAttachmentPolicy.RedirectAddress", "label": "Redirect Address", - "required": false + "required": false, + "condition": { + "field": "standards.SafeAttachmentPolicy.Redirect", + "compareType": "is", + "compareValue": true + } } ], "label": "Default Safe Attachment Policy", @@ -2084,7 +2670,7 @@ { "name": "standards.AtpPolicyForO365", "cat": "Defender Standards", - "tag": ["CIS"], + "tag": ["CIS M365 5.0 (2.1.5)", "NIST CSF 2.0 (DE.CM-09)"], "helpText": "This creates a Atp policy that enables Defender for Office 365 for SharePoint, OneDrive and Microsoft Teams.", "addedComponent": [ { @@ -2131,6 +2717,13 @@ "required": false, "label": "Phishing Simulation Urls", "name": "standards.PhishingSimulations.PhishingSimUrls" + }, + { + "type": "switch", + "label": "Remove extra urls", + "name": "standards.PhishingSimulations.RemoveExtraUrls", + "defaultValue": false, + "required": false } ], "label": "Phishing Simulation Configuration", @@ -2143,9 +2736,23 @@ { "name": "standards.MalwareFilterPolicy", "cat": "Defender Standards", - "tag": ["CIS", "mdo_zapspam", "mdo_zapphish", "mdo_zapmalware"], + "tag": [ + "CIS M365 5.0 (2.1.2)", + "CIS M365 5.0 (2.1.3)", + "mdo_zapspam", + "mdo_zapphish", + "mdo_zapmalware", + "NIST CSF 2.0 (DE.CM-09)" + ], "helpText": "This creates a Malware filter policy that enables the default File filter and Zero-hour auto purge for malware.", "addedComponent": [ + { + "type": "textField", + "name": "standards.MalwareFilterPolicy.name", + "label": "Policy Name", + "required": true, + "defaultValue": "CIPP Default Malware Policy" + }, { "type": "select", "multiple": false, @@ -2171,6 +2778,7 @@ { "type": "select", "multiple": false, + "creatable": true, "label": "QuarantineTag", "name": "standards.MalwareFilterPolicy.QuarantineTag", "options": [ @@ -2198,7 +2806,12 @@ "type": "textField", "name": "standards.MalwareFilterPolicy.InternalSenderAdminAddress", "required": false, - "label": "Internal Sender Admin Address" + "label": "Internal Sender Admin Address", + "condition": { + "field": "standards.MalwareFilterPolicy.EnableInternalSenderAdminNotifications", + "compareType": "is", + "compareValue": true + } }, { "type": "switch", @@ -2210,7 +2823,12 @@ "type": "textField", "name": "standards.MalwareFilterPolicy.ExternalSenderAdminAddress", "required": false, - "label": "External Sender Admin Address" + "label": "External Sender Admin Address", + "condition": { + "field": "standards.MalwareFilterPolicy.EnableExternalSenderAdminNotifications", + "compareType": "is", + "compareValue": true + } } ], "label": "Default Malware Filter Policy", @@ -2226,6 +2844,13 @@ "tag": [], "helpText": "This adds allowed domains to the Spoof Intelligence Allow/Block List.", "addedComponent": [ + { + "type": "switch", + "label": "Remove extra domains from the allow list", + "name": "standards.PhishSimSpoofIntelligence.RemoveExtraDomains", + "defaultValue": false, + "required": false + }, { "type": "autoComplete", "multiple": true, @@ -2247,7 +2872,15 @@ "cat": "Defender Standards", "tag": [], "helpText": "This standard creates a Spam filter policy similar to the default strict policy.", + "docsDescription": "This standard creates a Spam filter policy similar to the default strict policy, the following settings are configured to on by default: IncreaseScoreWithNumericIps, IncreaseScoreWithRedirectToOtherPort, MarkAsSpamEmptyMessages, MarkAsSpamJavaScriptInHtml, MarkAsSpamSpfRecordHardFail, MarkAsSpamFromAddressAuthFail, MarkAsSpamNdrBackscatter, MarkAsSpamBulkMail, InlineSafetyTipsEnabled, PhishZapEnabled, SpamZapEnabled", "addedComponent": [ + { + "type": "textField", + "name": "standards.SpamFilterPolicy.name", + "label": "Policy Name", + "required": true, + "defaultValue": "CIPP Default Spam Filter Policy" + }, { "type": "number", "label": "Bulk email threshold (Default 7)", @@ -2276,7 +2909,7 @@ "type": "autoComplete", "required": true, "multiple": false, - "creatable": false, + "creatable": true, "label": "Spam Quarantine Tag", "name": "standards.SpamFilterPolicy.SpamQuarantineTag", "options": [ @@ -2316,7 +2949,7 @@ "type": "autoComplete", "required": true, "multiple": false, - "creatable": false, + "creatable": true, "label": "High Confidence Spam Quarantine Tag", "name": "standards.SpamFilterPolicy.HighConfidenceSpamQuarantineTag", "options": [ @@ -2356,7 +2989,7 @@ "type": "autoComplete", "required": true, "multiple": false, - "creatable": false, + "creatable": true, "label": "Bulk Quarantine Tag", "name": "standards.SpamFilterPolicy.BulkQuarantineTag", "options": [ @@ -2396,7 +3029,7 @@ "type": "autoComplete", "required": true, "multiple": false, - "creatable": false, + "creatable": true, "label": "Phish Quarantine Tag", "name": "standards.SpamFilterPolicy.PhishQuarantineTag", "options": [ @@ -2418,7 +3051,7 @@ "type": "autoComplete", "required": true, "multiple": false, - "creatable": false, + "creatable": true, "label": "High Confidence Phish Quarantine Tag", "name": "standards.SpamFilterPolicy.HighConfidencePhishQuarantineTag", "options": [ @@ -2496,7 +3129,12 @@ "creatable": true, "required": false, "name": "standards.SpamFilterPolicy.LanguageBlockList", - "label": "Languages to block (uppercase ISO 639-1 two-letter)" + "label": "Languages to block (uppercase ISO 639-1 two-letter)", + "condition": { + "field": "standards.SpamFilterPolicy.EnableLanguageBlockList", + "compareType": "is", + "compareValue": true + } }, { "type": "switch", @@ -2510,7 +3148,12 @@ "creatable": true, "required": false, "name": "standards.SpamFilterPolicy.RegionBlockList", - "label": "Regions to block (uppercase ISO 3166-1 two-letter)" + "label": "Regions to block (uppercase ISO 3166-1 two-letter)", + "condition": { + "field": "standards.SpamFilterPolicy.EnableRegionBlockList", + "compareType": "is", + "compareValue": true + } }, { "type": "autoComplete", @@ -2528,16 +3171,104 @@ "powershellEquivalent": "New-HostedContentFilterPolicy or Set-HostedContentFilterPolicy", "recommendedBy": [] }, + { + "name": "standards.QuarantineTemplate", + "cat": "Defender Standards", + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "tag": [], + "helpText": "This standard creates a Custom Quarantine Policies that can be used in Anti-Spam and all MDO365 policies. Quarantine Policies can be used to specify recipients permissions, enable end-user spam notifications, and specify the release action preference", + "executiveText": "Creates standardized quarantine policies that define how employees can interact with quarantined emails, including permissions to release, delete, or preview suspicious messages. This ensures consistent security handling across the organization while providing appropriate user access to manage quarantined content.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": true, + "name": "displayName", + "label": "Quarantine Display Name", + "required": true + }, + { + "type": "switch", + "label": "Enable end-user spam notifications", + "name": "ESNEnabled", + "defaultValue": true, + "required": false + }, + { + "type": "select", + "multiple": false, + "label": "Select release action preference", + "name": "ReleaseAction", + "options": [ + { + "label": "Allow recipients to request a message to be released from quarantine", + "value": "PermissionToRequestRelease" + }, + { + "label": "Allow recipients to release a message from quarantine", + "value": "PermissionToRelease" + } + ] + }, + { + "type": "switch", + "label": "Include Messages From Blocked Sender Address", + "name": "IncludeMessagesFromBlockedSenderAddress", + "defaultValue": false, + "required": false + }, + { + "type": "switch", + "label": "Allow recipients to delete message", + "name": "PermissionToDelete", + "defaultValue": false, + "required": false + }, + { + "type": "switch", + "label": "Allow recipients to preview message", + "name": "PermissionToPreview", + "defaultValue": false, + "required": false + }, + { + "type": "switch", + "label": "Allow recipients to block Sender Address", + "name": "PermissionToBlockSender", + "defaultValue": false, + "required": false + }, + { + "type": "switch", + "label": "Allow recipients to whitelist Sender Address", + "name": "PermissionToAllowSender", + "defaultValue": false, + "required": false + } + ], + "label": "Custom Quarantine Policy", + "multiple": true, + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-05-16", + "powershellEquivalent": "Set-QuarantinePolicy or New-QuarantinePolicy", + "recommendedBy": [] + }, { "name": "standards.intuneDeviceRetirementDays", "cat": "Intune Standards", "tag": [], - "helpText": "A value between 0 and 270 is supported. A value of 0 disables retirement, retired devices are removed from Intune after the specified number of days.", + "helpText": "A value between 31 and 365 is supported. retired devices are removed from Intune after the specified number of days.", + "executiveText": "Automatically removes inactive devices from management after a specified period, helping maintain a clean device inventory and reducing security risks from abandoned or lost devices. This policy ensures that only actively used corporate devices remain in the management system.", "addedComponent": [ { "type": "number", "name": "standards.intuneDeviceRetirementDays.days", - "label": "Maximum days (0 equals disabled)" + "label": "Maximum days" } ], "label": "Set inactive device retirement days", @@ -2552,6 +3283,7 @@ "cat": "Intune Standards", "tag": [], "helpText": "Sets the branding profile for the Intune Company Portal app. This is a tenant wide setting and overrules any settings set on the app level.", + "executiveText": "Customizes the Intune Company Portal app with company branding, contact information, and support details, providing employees with a consistent corporate experience when managing their devices. This improves user experience and ensures employees know how to get IT support when needed.", "addedComponent": [ { "type": "textField", @@ -2625,6 +3357,7 @@ "cat": "Intune Standards", "tag": [], "helpText": "Sets the mark devices with no compliance policy assigned as compliance/non compliant and Compliance status validity period.", + "executiveText": "Configures how the system treats devices that don't have specific compliance policies and sets how often devices must check in to maintain their compliance status. This ensures proper security oversight of all corporate devices and maintains current compliance information.", "addedComponent": [ { "type": "autoComplete", @@ -2663,15 +3396,25 @@ "tag": [], "helpText": "Configures the MDM user scope. This also sets the terms of use, discovery and compliance URL to default URLs.", "docsDescription": "Configures the MDM user scope. This also sets the terms of use URL, discovery URL and compliance URL to default values.", + "executiveText": "Defines which users can enroll their devices in mobile device management, controlling access to corporate resources and applications. This setting determines the scope of device management coverage and ensures appropriate users have access to necessary business tools.", "addedComponent": [ { "name": "appliesTo", "label": "MDM User Scope?", "type": "radio", "options": [ - { "label": "All", "value": "all" }, - { "label": "None", "value": "none" }, - { "label": "Custom Group", "value": "selected" } + { + "label": "All", + "value": "all" + }, + { + "label": "None", + "value": "none" + }, + { + "label": "Custom Group", + "value": "selected" + } ] }, { @@ -2691,8 +3434,9 @@ { "name": "standards.DefaultPlatformRestrictions", "cat": "Intune Standards", - "tag": [], + "tag": ["CISA (MS.AAD.19.1v1)"], "helpText": "Sets the default platform restrictions for enrolling devices into Intune. Note: Do not block personally owned if platform is blocked.", + "executiveText": "Controls which types of devices (iOS, Android, Windows, macOS) and ownership models (corporate vs. personal) can be enrolled in the company's device management system. This helps maintain security standards while supporting necessary business device types and usage scenarios.", "addedComponent": [ { "type": "switch", @@ -2763,96 +3507,256 @@ "recommendedBy": [] }, { - "name": "standards.intuneDeviceReg", - "cat": "Intune Standards", - "tag": [], - "helpText": "Sets the maximum number of devices that can be registered by a user. A value of 0 disables device registration by users", - "addedComponent": [ - { - "type": "number", - "name": "standards.intuneDeviceReg.max", - "label": "Maximum devices (Enter 2147483647 for unlimited.)", - "required": true - } - ], - "label": "Set Maximum Number of Devices per user", - "impact": "Medium Impact", - "impactColour": "warning", - "addedDate": "2023-03-27", - "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", - "recommendedBy": [] - }, - { - "name": "standards.intuneRequireMFA", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration", "cat": "Intune Standards", "tag": [], - "helpText": "Requires MFA for all users to register devices with Intune. This is useful when not using Conditional Access.", - "label": "Require Multi-factor Authentication to register or join devices with Microsoft Entra", - "impact": "Medium Impact", - "impactColour": "warning", - "addedDate": "2023-10-23", - "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", - "recommendedBy": [] - }, - { - "name": "standards.DeletedUserRentention", - "cat": "SharePoint Standards", - "tag": [], - "helpText": "Sets the retention period for deleted users OneDrive to the specified period of time. The default is 30 days.", - "docsDescription": "When a OneDrive user gets deleted, the personal SharePoint site is saved for selected amount of time that data can be retrieved from it.", + "helpText": "Sets the Windows Hello for Business configuration during device enrollment.", + "executiveText": "Enables or disables Windows Hello for Business during device enrollment, enhancing security through biometric or PIN-based authentication methods. This ensures that devices meet corporate security standards while providing a user-friendly sign-in experience.", "addedComponent": [ { "type": "autoComplete", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.state", + "label": "Configure Windows Hello for Business", "multiple": false, - "name": "standards.DeletedUserRentention.Days", - "label": "Retention time (Default 30 days)", "options": [ { - "label": "30 days", - "value": "30" + "label": "Not configured", + "value": "notConfigured" }, { - "label": "90 days", - "value": "90" + "label": "Enabled", + "value": "enabled" }, { - "label": "1 year", - "value": "365" - }, + "label": "Disabled", + "value": "disabled" + } + ] + }, + { + "type": "switch", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.securityDeviceRequired", + "label": "Use a Trusted Platform Module (TPM)", + "default": true + }, + { + "type": "number", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.pinMinimumLength", + "label": "Minimum PIN length (4-127)", + "default": 4 + }, + { + "type": "number", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.pinMaximumLength", + "label": "Maximum PIN length (4-127)", + "default": 127 + }, + { + "type": "autoComplete", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.pinLowercaseCharactersUsage", + "label": "Lowercase letters in PIN", + "multiple": false, + "options": [ { - "label": "2 years", - "value": "730" + "label": "Not allowed", + "value": "disallowed" }, { - "label": "3 years", - "value": "1095" + "label": "Allowed", + "value": "allowed" }, { - "label": "4 years", - "value": "1460" - }, + "label": "Required", + "value": "required" + } + ] + }, + { + "type": "autoComplete", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.pinUppercaseCharactersUsage", + "label": "Uppercase letters in PIN", + "multiple": false, + "options": [ { - "label": "5 years", - "value": "1825" + "label": "Not allowed", + "value": "disallowed" }, { - "label": "6 years", - "value": "2190" + "label": "Allowed", + "value": "allowed" }, { - "label": "7 years", - "value": "2555" - }, + "label": "Required", + "value": "required" + } + ] + }, + { + "type": "autoComplete", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.pinSpecialCharactersUsage", + "label": "Special characters in PIN", + "multiple": false, + "options": [ { - "label": "8 years", - "value": "2920" + "label": "Not allowed", + "value": "disallowed" }, { - "label": "9 years", - "value": "3285" + "label": "Allowed", + "value": "allowed" }, { - "label": "10 years", + "label": "Required", + "value": "required" + } + ] + }, + { + "type": "number", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.pinExpirationInDays", + "label": "PIN expiration (days) - 0 to disable", + "default": 0 + }, + { + "type": "number", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.pinPreviousBlockCount", + "label": "PIN history - 0 to disable", + "default": 0 + }, + { + "type": "switch", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.unlockWithBiometricsEnabled", + "label": "Allow biometric authentication", + "default": true + }, + { + "type": "autoComplete", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.enhancedBiometricsState", + "label": "Use enhanced anti-spoofing when available", + "multiple": false, + "options": [ + { + "label": "Not configured", + "value": "notConfigured" + }, + { + "label": "Enabled", + "value": "enabled" + }, + { + "label": "Disabled", + "value": "disabled" + } + ] + }, + { + "type": "switch", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.remotePassportEnabled", + "label": "Allow phone sign-in", + "default": true + } + ], + "label": "Windows Hello for Business enrollment configuration", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-09-25", + "powershellEquivalent": "Graph API", + "recommendedBy": [] + }, + { + "name": "standards.intuneDeviceReg", + "cat": "Intune Standards", + "tag": ["CISA (MS.AAD.17.1v1)"], + "helpText": "Sets the maximum number of devices that can be registered by a user. A value of 0 disables device registration by users", + "executiveText": "Limits how many devices each employee can register for corporate access, preventing excessive device proliferation while accommodating legitimate business needs. This helps maintain security oversight and prevents potential abuse of device registration privileges.", + "addedComponent": [ + { + "type": "number", + "name": "standards.intuneDeviceReg.max", + "label": "Maximum devices (Enter 2147483647 for unlimited.)", + "required": true + } + ], + "label": "Set Maximum Number of Devices per user", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2023-03-27", + "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", + "recommendedBy": [] + }, + { + "name": "standards.intuneRequireMFA", + "cat": "Intune Standards", + "tag": [], + "helpText": "Requires MFA for all users to register devices with Intune. This is useful when not using Conditional Access.", + "executiveText": "Requires employees to use multi-factor authentication when registering devices for corporate access, adding an extra security layer to prevent unauthorized device enrollment. This helps ensure only legitimate users can connect their devices to company systems.", + "label": "Require Multi-factor Authentication to register or join devices with Microsoft Entra", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2023-10-23", + "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", + "recommendedBy": [] + }, + { + "name": "standards.DeletedUserRentention", + "cat": "SharePoint Standards", + "tag": [], + "helpText": "Sets the retention period for deleted users OneDrive to the specified period of time. The default is 30 days.", + "docsDescription": "When a OneDrive user gets deleted, the personal SharePoint site is saved for selected amount of time that data can be retrieved from it.", + "executiveText": "Preserves departed employees' OneDrive files for a specified period, allowing time to recover important business documents before permanent deletion. This helps prevent data loss while managing storage costs and maintaining compliance with data retention policies.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "name": "standards.DeletedUserRentention.Days", + "label": "Retention time (Default 30 days)", + "options": [ + { + "label": "30 days", + "value": "30" + }, + { + "label": "90 days", + "value": "90" + }, + { + "label": "1 year", + "value": "365" + }, + { + "label": "2 years", + "value": "730" + }, + { + "label": "3 years", + "value": "1095" + }, + { + "label": "4 years", + "value": "1460" + }, + { + "label": "5 years", + "value": "1825" + }, + { + "label": "6 years", + "value": "2190" + }, + { + "label": "7 years", + "value": "2555" + }, + { + "label": "8 years", + "value": "2920" + }, + { + "label": "9 years", + "value": "3285" + }, + { + "label": "10 years", "value": "3650" } ] @@ -2865,11 +3769,39 @@ "powershellEquivalent": "Update-MgBetaAdminSharePointSetting", "recommendedBy": [] }, + { + "name": "standards.SPFileRequests", + "cat": "SharePoint Standards", + "tag": [], + "helpText": "Enables or disables File Requests for SharePoint and OneDrive, allowing users to create secure upload-only links. Optionally sets the maximum number of days for the link to remain active before expiring.", + "docsDescription": "File Requests allow users to create secure upload-only share links where uploads are hidden from other people using the link. This creates a secure and private way for people to upload files to a folder. This feature is not enabled by default on new tenants and requires PowerShell configuration. This standard enables or disables this feature and optionally configures link expiration settings for both SharePoint and OneDrive.", + "executiveText": "Enables secure file upload functionality that allows external users to submit files directly to company folders without seeing other submissions or folder contents. This provides a professional and secure way to collect documents from clients, vendors, and partners while maintaining data privacy and security.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.SPFileRequests.state", + "label": "Enable File Requests" + }, + { + "type": "number", + "name": "standards.SPFileRequests.expirationDays", + "label": "Link Expiration 1-730 Days (Optional)", + "required": false + } + ], + "label": "Set SharePoint and OneDrive File Requests", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2025-07-30", + "powershellEquivalent": "Set-SPOTenant -CoreRequestFilesLinkEnabled $true -OneDriveRequestFilesLinkEnabled $true -CoreRequestFilesLinkExpirationInDays 30 -OneDriveRequestFilesLinkExpirationInDays 30", + "recommendedBy": ["CIPP"] + }, { "name": "standards.TenantDefaultTimezone", "cat": "SharePoint Standards", "tag": [], "helpText": "Sets the default timezone for the tenant. This will be used for all new users and sites.", + "executiveText": "Standardizes the timezone setting across all SharePoint sites and new user accounts, ensuring consistent scheduling and time-based operations throughout the organization. This improves collaboration efficiency and reduces confusion in global or multi-timezone organizations.", "addedComponent": [ { "type": "TimezoneSelect", @@ -2887,8 +3819,9 @@ { "name": "standards.SPAzureB2B", "cat": "SharePoint Standards", - "tag": ["CIS"], + "tag": ["CIS M365 5.0 (7.2.2)"], "helpText": "Ensure SharePoint and OneDrive integration with Azure AD B2B is enabled", + "executiveText": "Enables secure collaboration with external partners through SharePoint and OneDrive by integrating with Azure B2B guest access. This allows controlled sharing with external organizations while maintaining security oversight and proper access management.", "addedComponent": [], "label": "Enable SharePoint and OneDrive integration with Azure AD B2B", "impact": "Low Impact", @@ -2900,8 +3833,9 @@ { "name": "standards.SPDisallowInfectedFiles", "cat": "SharePoint Standards", - "tag": ["CIS"], + "tag": ["CIS M365 5.0 (7.3.1)", "CISA (MS.SPO.3.1v1)", "NIST CSF 2.0 (DE.CM-09)"], "helpText": "Ensure Office 365 SharePoint infected files are disallowed for download", + "executiveText": "Prevents employees from downloading files that have been identified as containing malware or viruses from SharePoint and OneDrive. This security measure protects against malware distribution through file sharing while maintaining access to clean, safe documents.", "addedComponent": [], "label": "Disallow downloading infected files from SharePoint", "impact": "Low Impact", @@ -2915,6 +3849,7 @@ "cat": "SharePoint Standards", "tag": [], "helpText": "Disables the creation of new SharePoint 2010 and 2013 classic workflows and removes the 'Return to classic SharePoint' link on modern SharePoint list and library pages.", + "executiveText": "Disables outdated SharePoint workflow features and classic interface options, encouraging use of modern, more secure and efficient collaboration tools. This helps maintain security standards while guiding users toward current, supported functionality.", "addedComponent": [], "label": "Disable Legacy Workflows", "impact": "Low Impact", @@ -2926,8 +3861,9 @@ { "name": "standards.SPDirectSharing", "cat": "SharePoint Standards", - "tag": ["CIS"], - "helpText": "Ensure default link sharing is set to Direct in SharePoint and OneDrive", + "tag": [], + "helpText": "This standard has been deprecated in favor of the Default Sharing Link standard. ", + "executiveText": "Configures SharePoint and OneDrive to share files directly with specific people rather than creating anonymous links, improving security by ensuring only intended recipients can access shared documents. This reduces the risk of accidental data exposure through link sharing.", "addedComponent": [], "label": "Default sharing to Direct users", "impact": "Medium Impact", @@ -2939,8 +3875,9 @@ { "name": "standards.SPExternalUserExpiration", "cat": "SharePoint Standards", - "tag": ["CIS"], + "tag": ["CIS M365 5.0 (7.2.9)", "CISA (MS.SPO.1.5v1)"], "helpText": "Ensure guest access to a site or OneDrive will expire automatically", + "executiveText": "Automatically expires external user access to SharePoint sites and OneDrive after a specified period, reducing security risks from forgotten or unnecessary guest accounts. This ensures external access is regularly reviewed and maintained only when actively needed.", "addedComponent": [ { "type": "number", @@ -2958,8 +3895,9 @@ { "name": "standards.SPEmailAttestation", "cat": "SharePoint Standards", - "tag": ["CIS"], + "tag": ["CIS M365 5.0 (7.2.10)", "CISA (MS.SPO.1.6v1)"], "helpText": "Ensure re-authentication with verification code is restricted", + "executiveText": "Requires external users to periodically re-verify their identity through email verification codes when accessing SharePoint resources, adding an extra security layer for external collaboration. This helps ensure continued legitimacy of external access over time.", "addedComponent": [ { "type": "number", @@ -2974,11 +3912,46 @@ "powershellEquivalent": "Set-SPOTenant -EmailAttestationRequired $true -EmailAttestationReAuthDays 15", "recommendedBy": ["CIS", "CIPP"] }, + { + "name": "standards.DefaultSharingLink", + "cat": "SharePoint Standards", + "tag": ["CIS M365 5.0 (7.2.7)", "CIS M365 5.0 (7.2.11)", "CISA (MS.SPO.1.4v1)"], + "helpText": "Configure the SharePoint default sharing link type and permission. This setting controls both the type of sharing link created by default and the permission level assigned to those links.", + "docsDescription": "Sets the default sharing link type (Direct or Internal) and permission (View) in SharePoint and OneDrive. Direct sharing means links only work for specific people, while Internal sharing means links work for anyone in the organization. Setting the view permission as the default ensures that users must deliberately select the edit permission when sharing a link, reducing the risk of unintentionally granting edit privileges.", + "executiveText": "Configures SharePoint default sharing links to implement the principle of least privilege for document sharing. This security measure reduces the risk of accidental data modification while maintaining collaboration functionality, requiring users to explicitly select Edit permissions when necessary. The sharing type setting controls whether links are restricted to specific recipients or available to the entire organization. This reduces the risk of accidental data exposure through link sharing.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": true, + "label": "Default Sharing Link Type", + "name": "standards.DefaultSharingLink.SharingLinkType", + "options": [ + { + "label": "Direct - Only the people the user specifies", + "value": "Direct" + }, + { + "label": "Internal - Only people in your organization", + "value": "Internal" + } + ] + } + ], + "label": "Set Default Sharing Link Settings", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-06-13", + "powershellEquivalent": "Set-SPOTenant -DefaultSharingLinkType [Direct|Internal] -DefaultLinkPermission View", + "recommendedBy": ["CIS", "CIPP"] + }, { "name": "standards.DisableAddShortcutsToOneDrive", "cat": "SharePoint Standards", "tag": [], "helpText": "If disabled, the button Add shortcut to OneDrive will be removed and users in the tenant will no longer be able to add new shortcuts to their OneDrive. Existing shortcuts will remain functional", + "executiveText": "Controls whether employees can create shortcuts to SharePoint libraries in their OneDrive, managing how users organize and access shared content. This setting helps maintain organized file structures and can prevent confusion from excessive shortcuts while preserving existing workflows.", "addedComponent": [ { "type": "autoComplete", @@ -3010,6 +3983,7 @@ "cat": "SharePoint Standards", "tag": [], "helpText": "If disabled, users in the tenant will no longer be able to use the Sync button to sync SharePoint content on all sites. However, existing synced content will remain functional on the user's computer.", + "executiveText": "Controls whether employees can synchronize SharePoint files to their local devices, balancing productivity benefits with data security concerns. This setting helps manage data distribution while maintaining access to cloud-based collaboration when sync is disabled.", "addedComponent": [ { "type": "autoComplete", @@ -3039,9 +4013,16 @@ { "name": "standards.DisableSharePointLegacyAuth", "cat": "SharePoint Standards", - "tag": ["CIS", "spo_legacy_auth"], + "tag": [ + "CIS M365 5.0 (6.5.1)", + "CIS M365 5.0 (7.2.1)", + "spo_legacy_auth", + "CISA (MS.AAD.3.1v1)", + "NIST CSF 2.0 (PR.IR-01)" + ], "helpText": "Disables the ability to authenticate with SharePoint using legacy authentication methods. Any applications that use legacy authentication will need to be updated to use modern authentication.", "docsDescription": "Disables the ability for users and applications to access SharePoint via legacy basic authentication. This will likely not have any user impact, but will block systems/applications depending on basic auth or the SharePointOnlineCredentials class.", + "executiveText": "Disables outdated authentication methods for SharePoint access, forcing applications and users to use modern, more secure authentication protocols. This significantly improves security by eliminating vulnerable authentication pathways while requiring updates to older applications.", "addedComponent": [], "label": "Disable legacy basic authentication for SharePoint", "impact": "Medium Impact", @@ -3053,8 +4034,9 @@ { "name": "standards.sharingCapability", "cat": "SharePoint Standards", - "tag": ["CIS"], + "tag": ["CIS M365 5.0 (7.2.3)", "CISA (MS.AAD.14.1v1)", "CISA (MS.SPO.1.1v1)"], "helpText": "Sets the default sharing level for OneDrive and SharePoint. This is a tenant wide setting and overrules any settings set on the site level", + "executiveText": "Defines the organization's default policy for sharing files and folders in SharePoint and OneDrive, balancing collaboration needs with security requirements. This fundamental setting determines whether employees can share with external users, anonymous links, or only internal colleagues.", "addedComponent": [ { "type": "autoComplete", @@ -3091,9 +4073,10 @@ { "name": "standards.DisableReshare", "cat": "SharePoint Standards", - "tag": ["CIS"], + "tag": ["CIS M365 5.0 (7.2.5)", "CISA (MS.AAD.14.2v1)", "CISA (MS.SPO.1.2v1)"], "helpText": "Disables the ability for external users to share files they don't own. Sharing links can only be made for People with existing access", "docsDescription": "Disables the ability for external users to share files they don't own. Sharing links can only be made for People with existing access. This is a tenant wide setting and overrules any settings set on the site level", + "executiveText": "Prevents external users from sharing company documents with additional people, maintaining control over document distribution and preventing unauthorized access expansion. This security measure ensures that external sharing remains within intended boundaries set by internal employees.", "addedComponent": [], "label": "Disable Re-sharing by External Users", "impact": "High Impact", @@ -3108,6 +4091,7 @@ "tag": [], "helpText": "Disables users from creating new SharePoint sites", "docsDescription": "Disables standard users from creating SharePoint sites, also disables the ability to fully create teams", + "executiveText": "Restricts the creation of new SharePoint sites to authorized administrators, preventing uncontrolled proliferation of collaboration spaces and ensuring proper governance. This maintains organized information architecture while requiring approval for new collaborative environments.", "addedComponent": [], "label": "Disable site creation by standard users", "impact": "High Impact", @@ -3121,6 +4105,7 @@ "cat": "SharePoint Standards", "tag": [], "helpText": "Sets the file extensions that are excluded from syncing with OneDrive. These files will be blocked from upload. '*.' is automatically added to the extension and can be omitted.", + "executiveText": "Blocks specific file types from being uploaded or synchronized to OneDrive, helping prevent security risks from potentially dangerous file formats. This security measure protects against malware distribution while allowing legitimate business file types to be shared safely.", "addedComponent": [ { "type": "textField", @@ -3140,6 +4125,7 @@ "cat": "SharePoint Standards", "tag": [], "helpText": "Disables the ability for Mac devices to sync with OneDrive.", + "executiveText": "Prevents Mac computers from synchronizing files with OneDrive, typically implemented for security or compliance reasons in Windows-centric environments. This restriction helps maintain standardized device management while potentially limiting collaboration for Mac users.", "addedComponent": [], "label": "Do not allow Mac devices to sync using OneDrive", "impact": "High Impact", @@ -3151,21 +4137,43 @@ { "name": "standards.unmanagedSync", "cat": "SharePoint Standards", - "tag": [], - "helpText": "The unmanaged Sync standard has been temporarily disabled and does nothing.", - "addedComponent": [], - "label": "Only allow users to sync OneDrive from AAD joined devices", + "tag": ["CIS M365 5.0 (7.2.3)", "CISA (MS.SPO.2.1v1)", "NIST CSF 2.0 (PR.AA-05)"], + "helpText": "Entra P1 required. Block or limit access to SharePoint and OneDrive content from unmanaged devices (those not hybrid AD joined or compliant in Intune). These controls rely on Microsoft Entra Conditional Access policies and can take up to 24 hours to take effect.", + "docsDescription": "Entra P1 required. Block or limit access to SharePoint and OneDrive content from unmanaged devices (those not hybrid AD joined or compliant in Intune). These controls rely on Microsoft Entra Conditional Access policies and can take up to 24 hours to take effect. 0 = Allow Access, 1 = Allow limited, web-only access, 2 = Block access. All information about this can be found in Microsofts documentation [here.](https://learn.microsoft.com/en-us/sharepoint/control-access-from-unmanaged-devices)", + "executiveText": "Restricts access to company files from personal or unmanaged devices, ensuring corporate data can only be accessed from properly secured and monitored devices. This critical security control prevents data leaks while allowing controlled access through web browsers when necessary.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "name": "standards.unmanagedSync.state", + "label": "State", + "options": [ + { + "label": "Allow limited, web-only access", + "value": "1" + }, + { + "label": "Block access", + "value": "2" + } + ], + "required": false + } + ], + "label": "Restrict access to SharePoint and OneDrive from unmanaged devices", "impact": "High Impact", "impactColour": "danger", - "addedDate": "2022-06-15", - "powershellEquivalent": "Update-MgAdminSharePointSetting", - "recommendedBy": [] + "addedDate": "2025-06-13", + "powershellEquivalent": "Set-SPOTenant -ConditionalAccessPolicy AllowFullAccess | AllowLimitedAccess | BlockAccess", + "recommendedBy": ["CIS"] }, { "name": "standards.sharingDomainRestriction", "cat": "SharePoint Standards", - "tag": ["CIS"], + "tag": ["CIS M365 5.0 (7.2.6)", "CISA (MS.AAD.14.3v1)", "CISA (MS.SPO.1.3v1)"], "helpText": "Restricts sharing to only users with the specified domain. This is useful for organizations that only want to share with their own domain.", + "executiveText": "Controls which external domains employees can share files with, enabling secure collaboration with trusted partners while blocking sharing with unauthorized organizations. This targeted approach maintains necessary business relationships while preventing data exposure to unknown entities.", "addedComponent": [ { "type": "autoComplete", @@ -3204,8 +4212,16 @@ { "name": "standards.TeamsGlobalMeetingPolicy", "cat": "Teams Standards", - "tag": [], + "tag": [ + "CIS M365 5.0 (8.5.1)", + "CIS M365 5.0 (8.5.2)", + "CIS M365 5.0 (8.5.3)", + "CIS M365 5.0 (8.5.4)", + "CIS M365 5.0 (8.5.5)", + "CIS M365 5.0 (8.5.6)" + ], "helpText": "Defines the CIS recommended global meeting policy for Teams. This includes AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl", + "executiveText": "Establishes security-focused default settings for Teams meetings, controlling who can join meetings, present content, and participate in chats. These policies balance collaboration needs with security requirements, ensuring meetings remain productive while protecting against unauthorized access and disruption.", "addedComponent": [ { "type": "autoComplete", @@ -3274,11 +4290,71 @@ "recommendedBy": ["CIS"] }, { - "name": "standards.TeamsEmailIntegration", + "name": "standards.TeamsChatProtection", + "cat": "Teams Standards", + "tag": [], + "helpText": "Configures Teams chat protection settings including weaponizable file protection and malicious URL protection.", + "docsDescription": "Configures Teams messaging safety features to protect users from weaponizable files and malicious URLs in chats and channels. Weaponizable File Protection automatically blocks messages containing potentially dangerous file types (like .exe, .dll, .bat, etc.). Malicious URL Protection scans URLs in messages and displays warnings when potentially harmful links are detected. These protections work across internal and external collaboration scenarios.", + "executiveText": "Enables automated security protections in Microsoft Teams to block dangerous files and warn users about malicious links in chat messages. This helps protect employees from file-based attacks and phishing attempts. These safeguards work seamlessly in the background, providing essential protection without disrupting normal business communication.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.TeamsChatProtection.FileTypeCheck", + "label": "Enable Weaponizable File Protection", + "defaultValue": true + }, + { + "type": "switch", + "name": "standards.TeamsChatProtection.UrlReputationCheck", + "label": "Enable Malicious URL Protection", + "defaultValue": true + } + ], + "label": "Set Teams Chat Protection Settings", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-10-02", + "powershellEquivalent": "Set-CsTeamsMessagingConfiguration -FileTypeCheck 'Enabled' -UrlReputationCheck 'Enabled' -ReportIncorrectSecurityDetections 'Enabled'", + "recommendedBy": ["CIPP"] + }, + { + "name": "standards.TeamsExternalChatWithAnyone", "cat": "Teams Standards", "tag": [], + "helpText": "Controls whether users can start Teams chats with any email address, inviting external recipients as guests via email.", + "docsDescription": "Manages the Teams messaging policy setting UseB2BInvitesToAddExternalUsers. When enabled, users can start chats with any email address and recipients receive an invitation to join the chat as guests. Disabling the setting prevents these external email chats from being created, keeping conversations limited to internal users and approved guests.", + "executiveText": "Allows organizations to decide if employees can launch Microsoft Teams chats with anyone on the internet using just an email address. Disabling the feature keeps conversations inside trusted boundaries and helps prevent accidental data exposure through unexpected external invitations.", + "addedComponent": [ + { + "type": "radio", + "name": "standards.TeamsExternalChatWithAnyone.UseB2BInvitesToAddExternalUsers", + "label": "Allow chatting with anyone via email", + "options": [ + { + "label": "Enabled", + "value": "true" + }, + { + "label": "Disabled", + "value": "false" + } + ], + "defaultValue": "Disabled" + } + ], + "label": "Set Teams chat with anyone setting", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-11-03", + "powershellEquivalent": "Set-CsTeamsMessagingPolicy -Identity Global -UseB2BInvitesToAddExternalUsers $false/$true", + "recommendedBy": ["CIPP"] + }, + { + "name": "standards.TeamsEmailIntegration", + "cat": "Teams Standards", "helpText": "Should users be allowed to send emails directly to a channel email addresses?", "docsDescription": "Teams channel email addresses are an optional feature that allows users to email the Teams channel directly.", + "executiveText": "Controls whether Teams channels can receive emails directly, enabling integration between email and team collaboration. This feature can improve workflow efficiency by allowing external communications to flow into team discussions, though it may need management for security or organizational reasons.", "addedComponent": [ { "type": "switch", @@ -3291,13 +4367,69 @@ "impactColour": "info", "addedDate": "2024-07-30", "powershellEquivalent": "Set-CsTeamsClientConfiguration -AllowEmailIntoChannel $false", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "tag": ["CIS M365 5.0 (8.1.2)"] }, { - "name": "standards.TeamsExternalFileSharing", + "name": "standards.TeamsGuestAccess", + "cat": "Teams Standards", + "tag": [], + "helpText": "Allow guest users access to teams.", + "docsDescription": "Allow guest users access to teams. Guest users are users who are not part of your organization but have been invited to collaborate with your organization in Teams. This setting allows you to control whether guest users can access Teams.", + "executiveText": "Determines whether external partners, vendors, and collaborators can be invited to participate in Teams conversations and meetings. This fundamental setting enables external collaboration while requiring careful management to balance openness with security and information protection.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.TeamsGuestAccess.AllowGuestUser", + "label": "Allow guest users" + } + ], + "label": "Allow guest users in Teams", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-06-03", + "powershellEquivalent": "Set-CsTeamsClientConfiguration -AllowGuestUser $true", + "recommendedBy": [] + }, + { + "name": "standards.TeamsMeetingVerification", "cat": "Teams Standards", "tag": [], + "helpText": "Configures CAPTCHA verification for external users joining Teams meetings. This helps prevent unauthorized AI notetakers and bots from joining meetings.", + "docsDescription": "Configures CAPTCHA verification for external users joining Teams meetings. This security feature requires external participants to complete a CAPTCHA challenge before joining, which helps prevent unauthorized AI notetakers, bots, and other automated systems from accessing meetings.", + "executiveText": "Requires external meeting participants to complete verification challenges before joining Teams meetings, preventing automated bots and unauthorized AI systems from accessing confidential discussions. This security measure protects against meeting infiltration while maintaining legitimate external collaboration.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "CAPTCHA Verification Setting", + "name": "standards.TeamsMeetingVerification.CaptchaVerificationForMeetingJoin", + "options": [ + { + "label": "Not Required", + "value": "NotRequired" + }, + { + "label": "Anonymous Users and Untrusted Organizations", + "value": "AnonymousUsersAndUntrustedOrganizations" + } + ] + } + ], + "label": "Teams Meeting Verification (CAPTCHA)", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-06-14", + "powershellEquivalent": "Set-CsTeamsMeetingPolicy -CaptchaVerificationForMeetingJoin", + "recommendedBy": ["CIPP"] + }, + { + "name": "standards.TeamsExternalFileSharing", + "cat": "Teams Standards", + "tag": ["CIS M365 5.0 (8.4.1)"], "helpText": "Ensure external file sharing in Teams is enabled for only approved cloud storage services.", + "executiveText": "Controls which external cloud storage services (like Google Drive, Dropbox, Box) employees can access through Teams, ensuring file sharing occurs only through approved and secure platforms. This helps maintain data governance while supporting necessary business integrations.", "addedComponent": [ { "type": "switch", @@ -3338,6 +4470,7 @@ "tag": [], "helpText": "Controls whether users with this policy can set the voice profile capture and enrollment through the Recognition tab in their Teams client settings.", "docsDescription": "Controls whether users with this policy can set the voice profile capture and enrollment through the Recognition tab in their Teams client settings.", + "executiveText": "Determines whether employees can enroll their voice and face profiles for recognition features in Teams, enabling personalized experiences like voice identification. This setting balances convenience features with privacy considerations and organizational policies regarding biometric data collection.", "addedComponent": [ { "type": "autoComplete", @@ -3371,6 +4504,7 @@ "tag": [], "helpText": "Sets the properties of the Global external access policy.", "docsDescription": "Sets the properties of the Global external access policy. External access policies determine whether or not your users can: 1) communicate with users who have Session Initiation Protocol (SIP) accounts with a federated organization; 2) communicate with users who are using custom applications built with Azure Communication Services; 3) access Skype for Business Server over the Internet, without having to log on to your internal network; 4) communicate with users who have SIP accounts with a public instant messaging (IM) provider such as Skype; and, 5) communicate with people who are using Teams with an account that's not managed by an organization.", + "executiveText": "Defines the organization's policy for communicating with external users through Teams, including other organizations, Skype users, and unmanaged accounts. This fundamental setting determines the scope of external collaboration while maintaining security boundaries for business communications.", "addedComponent": [ { "type": "switch", @@ -3396,6 +4530,7 @@ "tag": [], "helpText": "Sets the properties of the Global federation configuration.", "docsDescription": "Sets the properties of the Global federation configuration. Federation configuration settings determine whether or not your users can communicate with users who have SIP accounts with a federated organization.", + "executiveText": "Configures how the organization federates with external organizations for Teams communication, controlling whether employees can communicate with specific external domains or all external organizations. This setting enables secure inter-organizational collaboration while maintaining control over external communications.", "addedComponent": [ { "type": "switch", @@ -3448,6 +4583,7 @@ "tag": [], "helpText": "Sets the default number of days after which Teams meeting recordings automatically expire. Valid values are -1 (Never Expire) or between 1 and 99999. The default value is 120 days.", "docsDescription": "Allows administrators to configure a default expiration period (in days) for Teams meeting recordings. Recordings older than this period will be automatically moved to the recycle bin. This setting helps manage storage consumption and enforce data retention policies.", + "executiveText": "Automatically removes old Teams meeting recordings after a specified period to manage storage costs and comply with data retention policies. This helps organizations balance the need to preserve important meeting content with storage efficiency and regulatory compliance requirements.", "addedComponent": [ { "type": "number", @@ -3469,6 +4605,7 @@ "tag": [], "helpText": "Sets the properties of the Global messaging policy.", "docsDescription": "Sets the properties of the Global messaging policy. Messaging policies control which chat and channel messaging features are available to users in Teams.", + "executiveText": "Defines what messaging capabilities employees have in Teams, including the ability to edit or delete messages, create custom emojis, and report inappropriate content. These policies help maintain professional communication standards while enabling necessary collaboration features.", "addedComponent": [ { "type": "switch", @@ -3559,6 +4696,7 @@ }, "helpText": "Deploy the Autopilot Status Page, which shows progress during device setup through Autopilot.", "docsDescription": "This standard allows configuration of the Autopilot Status Page, providing users with a visual representation of the progress during device setup. It includes options like timeout, logging, and retry settings.", + "executiveText": "Provides employees with a visual progress indicator during automated device setup, improving the user experience when receiving new computers. This reduces IT support calls and helps ensure successful device deployment by guiding users through the setup process.", "addedComponent": [ { "type": "number", @@ -3592,14 +4730,14 @@ }, { "type": "switch", - "name": "standards.AutopilotStatusPage.BlockDevice", - "label": "Block device usage during setup", + "name": "standards.AutopilotStatusPage.InstallWindowsUpdates", + "label": "Install Windows Updates during setup", "defaultValue": true }, { "type": "switch", - "name": "standards.AutopilotStatusPage.AllowRetry", - "label": "Allow retry", + "name": "standards.AutopilotStatusPage.BlockDevice", + "label": "Block device usage during setup", "defaultValue": true }, { @@ -3646,7 +4784,8 @@ { "type": "textField", "name": "standards.AutopilotProfile.DeviceNameTemplate", - "label": "Unique Device Name Template" + "label": "Unique Device Name Template", + "required": false }, { "type": "autoComplete", @@ -3657,7 +4796,7 @@ "label": "Languages", "api": { "url": "/languageList.json", - "labelField": "language", + "labelField": "languageTag", "valueField": "tag" } }, @@ -3735,18 +4874,37 @@ "impact": "High Impact", "addedDate": "2023-12-30", "helpText": "Deploy and manage Intune templates across devices.", + "executiveText": "Deploys standardized device management configurations across all corporate devices, ensuring consistent security policies, application settings, and compliance requirements. This template-based approach streamlines device management while maintaining uniform security standards across the organization.", "addedComponent": [ { "type": "autoComplete", "multiple": false, "creatable": false, + "required": false, "name": "TemplateList", "label": "Select Intune Template", "api": { + "queryKey": "ListIntuneTemplates-autcomplete", "url": "/api/ListIntuneTemplates", "labelField": "Displayname", - "valueField": "GUID", - "queryKey": "languages" + "valueField": "GUID" + } + }, + { + "type": "autoComplete", + "multiple": false, + "required": false, + "creatable": false, + "name": "TemplateList-Tags", + "label": "Or select a package of Intune Templates", + "api": { + "queryKey": "ListIntuneTemplates-tag-autcomplete", + "url": "/api/ListIntuneTemplates?mode=Tag", + "labelField": "label", + "valueField": "value", + "addedField": { + "templates": "templates" + } } }, { @@ -3754,11 +4912,26 @@ "label": "Who should this template be assigned to?", "type": "radio", "options": [ - { "label": "Do not assign", "value": "On" }, - { "label": "Assign to all users", "value": "allLicensedUsers" }, - { "label": "Assign to all devices", "value": "AllDevices" }, - { "label": "Assign to all users and devices", "value": "AllDevicesAndUsers" }, - { "label": "Assign to Custom Group", "value": "customGroup" } + { + "label": "Do not assign", + "value": "On" + }, + { + "label": "Assign to all users", + "value": "allLicensedUsers" + }, + { + "label": "Assign to all devices", + "value": "AllDevices" + }, + { + "label": "Assign to all users and devices", + "value": "AllDevicesAndUsers" + }, + { + "label": "Assign to Custom Group", + "value": "customGroup" + } ] }, { @@ -3772,7 +4945,31 @@ "label": "Exclude Groups", "type": "textField", "required": false, - "helpText": "Enter the group name to exclude from the assignment. Wildcards are allowed." + "helpText": "Enter the group name(s) to exclude from the assignment. Wildcards are allowed. Multiple group names are comma-seperated." + }, + { + "type": "textField", + "required": false, + "name": "assignmentFilter", + "label": "Assignment Filter Name (Optional)", + "helpText": "Enter the assignment filter name to apply to this policy assignment. Wildcards are allowed." + }, + { + "name": "assignmentFilterType", + "label": "Assignment Filter Mode (Optional)", + "type": "radio", + "required": false, + "helpText": "Choose whether to include or exclude devices matching the filter. Only applies if you specified a filter name above. Defaults to Include if not specified.", + "options": [ + { + "label": "Include - Assign to devices matching the filter", + "value": "include" + }, + { + "label": "Exclude - Assign to devices NOT matching the filter", + "value": "exclude" + } + ] } ] }, @@ -3788,6 +4985,7 @@ "impact": "Medium Impact", "addedDate": "2023-12-30", "helpText": "Deploy transport rules to manage email flow.", + "executiveText": "Deploys standardized email flow rules that automatically manage how emails are processed, filtered, and routed within the organization. These templates ensure consistent email security policies, compliance requirements, and business rules are applied across all email communications.", "addedComponent": [ { "type": "autoComplete", @@ -3815,6 +5013,7 @@ "impact": "High Impact", "addedDate": "2023-12-30", "helpText": "Manage conditional access policies for better security.", + "executiveText": "Deploys standardized conditional access policies that automatically enforce security requirements based on user location, device compliance, and risk factors. These templates ensure consistent security controls across the organization while enabling secure access to business resources.", "addedComponent": [ { "type": "autoComplete", @@ -3833,17 +5032,35 @@ "label": "What state should we deploy this template in?", "type": "radio", "options": [ - { "value": "donotchange", "label": "Do not change state" }, - { "value": "Enabled", "label": "Set to enabled" }, - { "value": "Disabled", "label": "Set to disabled" }, - { "value": "enabledForReportingButNotEnforced", "label": "Set to report only" } + { + "value": "donotchange", + "label": "Do not change state" + }, + { + "value": "Enabled", + "label": "Set to enabled" + }, + { + "value": "Disabled", + "label": "Set to disabled" + }, + { + "value": "enabledForReportingButNotEnforced", + "label": "Set to report only" + } ] + }, + { + "type": "switch", + "name": "DisableSD", + "label": "Disable Security Defaults when deploying policy" } ] }, { "name": "standards.ExchangeConnectorTemplate", "label": "Exchange Connector Template", + "cat": "Templates", "disabledFeatures": { "report": true, "warn": true, @@ -3852,6 +5069,7 @@ "impact": "Medium Impact", "addedDate": "2023-12-30", "helpText": "Deploy and manage Exchange connectors.", + "executiveText": "Configures standardized Exchange connectors that control how email flows between your organization and external systems. These templates ensure secure and reliable email delivery while maintaining proper routing and security policies for business communications.", "addedComponent": [ { "type": "autoComplete", @@ -3879,6 +5097,7 @@ "impact": "Medium Impact", "addedDate": "2023-12-30", "helpText": "Deploy and manage group templates.", + "executiveText": "Creates standardized groups with predefined settings, permissions, and membership rules. These templates ensure consistent group configurations across the organization, streamlining collaboration and access management while maintaining security standards.", "addedComponent": [ { "type": "autoComplete", @@ -3887,10 +5106,250 @@ "api": { "url": "/api/ListGroupTemplates", "labelField": "Displayname", + "altLabelField": "displayName", "valueField": "GUID", "queryKey": "ListGroupTemplates" } } ] + }, + { + "name": "standards.AssignmentFilterTemplate", + "label": "Assignment Filter Template", + "multi": true, + "cat": "Templates", + "disabledFeatures": { + "report": true, + "warn": true, + "remediate": false + }, + "impact": "Medium Impact", + "addedDate": "2025-10-04", + "helpText": "Deploy and manage assignment filter templates.", + "executiveText": "Creates standardized assignment filters with predefined settings. These templates ensure consistent assignment filter configurations across the organization, streamlining assignment management.", + "addedComponent": [ + { + "type": "autoComplete", + "name": "assignmentFilterTemplate", + "label": "Select Assignment Filter Template", + "api": { + "url": "/api/ListAssignmentFilterTemplates", + "labelField": "Displayname", + "altLabelField": "displayName", + "valueField": "GUID", + "queryKey": "ListAssignmentFilterTemplates" + } + } + ] + }, + { + "name": "standards.MailboxRecipientLimits", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Sets the maximum number of recipients that can be specified in the To, Cc, and Bcc fields of a message for all mailboxes in the tenant.", + "docsDescription": "This standard configures the recipient limits for all mailboxes in the tenant. The recipient limit determines the maximum number of recipients that can be specified in the To, Cc, and Bcc fields of a message. This helps prevent spam and manage email flow.", + "executiveText": "Controls how many recipients employees can include in a single email, helping prevent spam distribution and managing email server load. This security measure protects against both accidental mass mailings and potential abuse while ensuring legitimate business communications can still reach necessary recipients.", + "addedComponent": [ + { + "type": "number", + "name": "standards.MailboxRecipientLimits.RecipientLimit", + "label": "Recipient Limit", + "defaultValue": 500 + } + ], + "label": "Set Mailbox Recipient Limits", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-05-28", + "powershellEquivalent": "Set-Mailbox -RecipientLimits", + "recommendedBy": ["CIPP"] + }, + { + "name": "standards.DisableExchangeOnlinePowerShell", + "cat": "Exchange Standards", + "tag": ["CIS M365 5.0 (6.1.1)", "Security", "NIST CSF 2.0 (PR.AA-05)"], + "helpText": "Disables Exchange Online PowerShell access for non-admin users by setting the RemotePowerShellEnabled property to false for each user. This helps prevent attackers from using PowerShell to run malicious commands, access file systems, registry, and distribute ransomware throughout networks. Users with admin roles are automatically excluded.", + "docsDescription": "Disables Exchange Online PowerShell access for non-admin users by setting the RemotePowerShellEnabled property to false for each user. This security measure follows a least privileged access approach, preventing potential attackers from using PowerShell to execute malicious commands, access sensitive systems, or distribute malware. Users with management roles containing 'Admin' are automatically excluded to ensure administrators retain PowerShell access to perform necessary management tasks.", + "executiveText": "Restricts PowerShell access to Exchange Online for regular employees while maintaining access for administrators, significantly reducing security risks from compromised accounts. This prevents attackers from using PowerShell to execute malicious commands or distribute ransomware while preserving necessary administrative capabilities.", + "label": "Disable Exchange Online PowerShell for non-admin users", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2025-06-19", + "powershellEquivalent": "Set-User -Identity $user -RemotePowerShellEnabled $false", + "recommendedBy": ["CIS", "CIPP"] + }, + { + "name": "standards.OWAAttachmentRestrictions", + "cat": "Exchange Standards", + "tag": ["CIS M365 5.0 (6.1.2)", "Security", "NIST CSF 2.0 (PR.AA-05)"], + "helpText": "Restricts how users on unmanaged devices can interact with email attachments in Outlook on the web and new Outlook for Windows. Prevents downloading attachments or blocks viewing them entirely.", + "docsDescription": "This standard configures the OWA mailbox policy to restrict access to email attachments on unmanaged devices. Users can be prevented from downloading attachments (but can view/edit via Office Online) or blocked from seeing attachments entirely. This helps prevent data exfiltration through email attachments on devices not managed by the organization.", + "executiveText": "Restricts access to email attachments on personal or unmanaged devices while allowing full functionality on corporate-managed devices. This security measure prevents data theft through email attachments while maintaining productivity for employees using approved company devices.", + "addedComponent": [ + { + "type": "autoComplete", + "name": "standards.OWAAttachmentRestrictions.ConditionalAccessPolicy", + "label": "Attachment Restriction Policy", + "options": [ + { + "label": "Read Only (View/Edit via Office Online, no download)", + "value": "ReadOnly" + }, + { + "label": "Read Only Plus Attachments Blocked (Cannot see attachments)", + "value": "ReadOnlyPlusAttachmentsBlocked" + } + ], + "defaultValue": "ReadOnlyPlusAttachmentsBlocked" + } + ], + "label": "Restrict Email Attachments on Unmanaged Devices", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2025-08-22", + "powershellEquivalent": "Set-OwaMailboxPolicy -Identity \"OwaMailboxPolicy-Default\" -ConditionalAccessPolicy ReadOnlyPlusAttachmentsBlocked", + "recommendedBy": ["Microsoft Zero Trust", "CIPP"] + }, + { + "name": "standards.LegacyEmailReportAddins", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Removes legacy Report Phishing and Report Message Outlook add-ins.", + "executiveText": "The legacy Report Phishing and Report Message Outlook add-ins are security issues with the add-in which makes them unsafe for the organization.", + "label": "Remove legacy Outlook Report add-ins", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-08-26", + "powershellEquivalent": "None", + "recommendedBy": ["Microsoft"] + }, + { + "name": "standards.DeployCheckChromeExtension", + "cat": "Intune Standards", + "tag": [], + "helpText": "Deploys the Check Chrome extension via Intune OMA-URI custom policies for both Chrome and Edge browsers with configurable settings. Chrome ID: benimdeioplgkhanklclahllklceahbe, Edge ID: knepjpocdagponkonnbggpcnhnaikajg", + "docsDescription": "Creates Intune OMA-URI custom policies that automatically install and configure the Check Chrome extension on managed devices for both Google Chrome and Microsoft Edge browsers. This ensures the extension is deployed consistently across all corporate devices with customizable settings.", + "executiveText": "Automatically deploys the Check browser extension across all company devices with configurable security and branding settings, ensuring consistent security monitoring and compliance capabilities. This extension provides enhanced security features and monitoring tools that help protect against threats while maintaining user productivity.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.DeployCheckChromeExtension.enableValidPageBadge", + "label": "Enable valid page badge", + "defaultValue": true + }, + { + "type": "switch", + "name": "standards.DeployCheckChromeExtension.enablePageBlocking", + "label": "Enable page blocking", + "defaultValue": true + }, + { + "type": "switch", + "name": "standards.DeployCheckChromeExtension.enableCippReporting", + "label": "Enable CIPP reporting", + "defaultValue": true + }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.cippServerUrl", + "label": "CIPP Server URL", + "placeholder": "https://YOUR-CIPP-SERVER-URL", + "required": false + }, + + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.customRulesUrl", + "label": "Custom Rules URL", + "placeholder": "https://YOUR-CIPP-SERVER-URL/rules.json", + "required": false + }, + { + "type": "number", + "name": "standards.DeployCheckChromeExtension.updateInterval", + "label": "Update interval (hours)", + "defaultValue": 12 + }, + { + "type": "switch", + "name": "standards.DeployCheckChromeExtension.enableDebugLogging", + "label": "Enable debug logging", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.companyName", + "label": "Company Name", + "placeholder": "YOUR-COMPANY", + "required": false + }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.productName", + "label": "Product Name", + "placeholder": "YOUR-PRODUCT-NAME", + "required": false + }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.supportEmail", + "label": "Support Email", + "placeholder": "support@yourcompany.com", + "required": false + }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.primaryColor", + "label": "Primary Color", + "placeholder": "#0044CC", + "required": false + }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.logoUrl", + "label": "Logo URL", + "placeholder": "https://yourcompany.com/logo.png", + "required": false + }, + { + "name": "AssignTo", + "label": "Who should this policy be assigned to?", + "type": "radio", + "options": [ + { + "label": "Do not assign", + "value": "On" + }, + { + "label": "Assign to all users", + "value": "allLicensedUsers" + }, + { + "label": "Assign to all devices", + "value": "AllDevices" + }, + { + "label": "Assign to all users and devices", + "value": "AllDevicesAndUsers" + }, + { + "label": "Assign to Custom Group", + "value": "customGroup" + } + ] + }, + { + "type": "textField", + "required": false, + "name": "customGroup", + "label": "Enter the custom group name if you selected 'Assign to Custom Group'. Wildcards are allowed." + } + ], + "label": "Deploy Check Chrome Extension", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-09-18", + "powershellEquivalent": "New-GraphPostRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies'", + "recommendedBy": ["CIPP"] } ] diff --git a/src/data/timezoneList.json b/src/data/timezoneList.json index 57238fe7eedc..d2501fca5bca 100644 --- a/src/data/timezoneList.json +++ b/src/data/timezoneList.json @@ -1,335 +1,446 @@ [ { - "timezone": "(UTC-12:00) International Date Line West" + "timezone": "(UTC-12:00) International Date Line West", + "standardTime": "Dateline Standard Time" }, { - "timezone": "(UTC-11:00) Coordinated Universal Time-11" + "timezone": "(UTC-11:00) Coordinated Universal Time-11", + "standardTime": "UTC-11" }, { - "timezone": "(UTC-10:00) Hawaii" + "timezone": "(UTC-10:00) Hawaii", + "standardTime": "Hawaiian Standard Time" }, { - "timezone": "(UTC-09:00) Alaska" + "timezone": "(UTC-09:00) Alaska", + "standardTime": "Alaskan Standard Time" }, { - "timezone": "(UTC-08:00) Baja California" + "timezone": "(UTC-08:00) Baja California", + "standardTime": "Pacific Standard Time (Mexico)" }, { - "timezone": "(UTC-08:00) Pacific Time (US and Canada)" + "timezone": "(UTC-08:00) Pacific Time (US and Canada)", + "standardTime": "Pacific Standard Time" }, { - "timezone": "(UTC-07:00) Arizona" + "timezone": "(UTC-07:00) Arizona", + "standardTime": "US Mountain Standard Time" }, { - "timezone": "(UTC-07:00) Chihuahua, La Paz, Mazatlan" + "timezone": "(UTC-07:00) Chihuahua, La Paz, Mazatlan", + "standardTime": "Mountain Standard Time (Mexico)" }, { - "timezone": "(UTC-07:00) Mountain Time (US and Canada)" + "timezone": "(UTC-07:00) Mountain Time (US and Canada)", + "standardTime": "Mountain Standard Time" }, { - "timezone": "(UTC-06:00) Central America" + "timezone": "(UTC-06:00) Central America", + "standardTime": "Central America Standard Time" }, { - "timezone": "(UTC-06:00) Central Time (US and Canada)" + "timezone": "(UTC-06:00) Central Time (US and Canada)", + "standardTime": "Central Standard Time" }, { - "timezone": "(UTC-06:00) Guadalajara, Mexico City, Monterrey" + "timezone": "(UTC-06:00) Guadalajara, Mexico City, Monterrey", + "standardTime": "Central Standard Time (Mexico)" }, { - "timezone": "(UTC-06:00) Saskatchewan" + "timezone": "(UTC-06:00) Saskatchewan", + "standardTime": "Canada Central Standard Time" }, { - "timezone": "(UTC-05:00) Bogota, Lima, Quito" + "timezone": "(UTC-05:00) Bogota, Lima, Quito", + "standardTime": "SA Pacific Standard Time" }, { - "timezone": "(UTC-05:00) Eastern Time (US and Canada)" + "timezone": "(UTC-05:00) Eastern Time (US and Canada)", + "standardTime": "Eastern Standard Time" }, { - "timezone": "(UTC-05:00) Indiana (East)" + "timezone": "(UTC-05:00) Indiana (East)", + "standardTime": "US Eastern Standard Time" }, { - "timezone": "(UTC-04:30) Caracas" + "timezone": "(UTC-04:30) Caracas", + "standardTime": "Venezuela Standard Time" }, { - "timezone": "(UTC-04:00) Asuncion" + "timezone": "(UTC-04:00) Asuncion", + "standardTime": "Paraguay Standard Time" }, { - "timezone": "(UTC-04:00) Atlantic Time (Canada)" + "timezone": "(UTC-04:00) Atlantic Time (Canada)", + "standardTime": "Atlantic Standard Time" }, { - "timezone": "(UTC-04:00) Cuiaba" + "timezone": "(UTC-04:00) Cuiaba", + "standardTime": "Central Brazilian Standard Time" }, { - "timezone": "(UTC-04:00) Georgetown, La Paz, Manaus, San Juan" + "timezone": "(UTC-04:00) Georgetown, La Paz, Manaus, San Juan", + "standardTime": "SA Western Standard Time" }, { - "timezone": "(UTC-04:00) Santiago" + "timezone": "(UTC-04:00) Santiago", + "standardTime": "Pacific SA Standard Time" }, { - "timezone": "(UTC-03:30) Newfoundland" + "timezone": "(UTC-03:30) Newfoundland", + "standardTime": "Newfoundland Standard Time" }, { - "timezone": "(UTC-03:00) Brasilia" + "timezone": "(UTC-03:00) Brasilia", + "standardTime": "E. South America Standard Time" }, { - "timezone": "(UTC-03:00) Buenos Aires" + "timezone": "(UTC-03:00) Buenos Aires", + "standardTime": "Argentina Standard Time" }, { - "timezone": "(UTC-03:00) Cayenne, Fortaleza" + "timezone": "(UTC-03:00) Cayenne, Fortaleza", + "standardTime": "SA Eastern Standard Time" }, { - "timezone": "(UTC-03:00) Greenland" + "timezone": "(UTC-03:00) Greenland", + "standardTime": "Greenland Standard Time" }, { - "timezone": "(UTC-03:00) Montevideo" + "timezone": "(UTC-03:00) Montevideo", + "standardTime": "Montevideo Standard Time" }, { - "timezone": "(UTC-03:00) Salvador" + "timezone": "(UTC-03:00) Salvador", + "standardTime": "Bahia Standard Time" }, { - "timezone": "(UTC-02:00) Coordinated Universal Time-02" + "timezone": "(UTC-02:00) Coordinated Universal Time-02", + "standardTime": "UTC-02" }, { - "timezone": "(UTC-02:00) Mid-Atlantic" + "timezone": "(UTC-02:00) Mid-Atlantic", + "standardTime": "Mid-Atlantic Standard Time" }, { - "timezone": "(UTC-01:00) Azores" + "timezone": "(UTC-01:00) Azores", + "standardTime": "Azores Standard Time" }, { - "timezone": "(UTC-01:00) Cabo Verde" + "timezone": "(UTC-01:00) Cabo Verde", + "standardTime": "Cape Verde Standard Time" }, { - "timezone": "(UTC) Casablanca" + "timezone": "(UTC) Casablanca", + "standardTime": "Morocco Standard Time" }, { - "timezone": "(UTC) Coordinated Universal Time" + "timezone": "(UTC) Coordinated Universal Time", + "standardTime": "UTC" }, { - "timezone": "(UTC) Dublin, Edinburgh, Lisbon, London" + "timezone": "(UTC) Dublin, Edinburgh, Lisbon, London", + "standardTime": "GMT Standard Time" }, { - "timezone": "(UTC) Monrovia, Reykjavik" + "timezone": "(UTC) Monrovia, Reykjavik", + "standardTime": "Greenwich Standard Time" }, { - "timezone": "(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna" + "timezone": "(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna", + "standardTime": "W. Europe Standard Time" }, { - "timezone": "(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague" + "timezone": "(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague", + "standardTime": "Central Europe Standard Time" }, { - "timezone": "(UTC+01:00) Brussels, Copenhagen, Madrid, Paris" + "timezone": "(UTC+01:00) Brussels, Copenhagen, Madrid, Paris", + "standardTime": "Romance Standard Time" }, { - "timezone": "(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb" + "timezone": "(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb", + "standardTime": "Central European Standard Time" }, { - "timezone": "(UTC+01:00) West Central Africa" + "timezone": "(UTC+01:00) West Central Africa", + "standardTime": "W. Central Africa Standard Time" }, { - "timezone": "(UTC+01:00) Windhoek" + "timezone": "(UTC+01:00) Windhoek", + "standardTime": "Namibia Standard Time" }, { - "timezone": "(UTC+02:00) Amman" + "timezone": "(UTC+02:00) Amman", + "standardTime": "Jordan Standard Time" }, { - "timezone": "(UTC+02:00) Athens, Bucharest" + "timezone": "(UTC+02:00) Athens, Bucharest", + "standardTime": "GTB Standard Time" }, { - "timezone": "(UTC+02:00) Beirut" + "timezone": "(UTC+02:00) Beirut", + "standardTime": "Middle East Standard Time" }, { - "timezone": "(UTC+02:00) Cairo" + "timezone": "(UTC+02:00) Cairo", + "standardTime": "Egypt Standard Time" }, { - "timezone": "(UTC+02:00) Damascus" + "timezone": "(UTC+02:00) Damascus", + "standardTime": "Syria Standard Time" }, { - "timezone": "(UTC+02:00) Harare, Pretoria" + "timezone": "(UTC+02:00) Harare, Pretoria", + "standardTime": "South Africa Standard Time" }, { - "timezone": "(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius" + "timezone": "(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius", + "standardTime": "FLE Standard Time" }, { - "timezone": "(UTC+02:00) Jerusalem" + "timezone": "(UTC+02:00) Jerusalem", + "standardTime": "Israel Standard Time" }, { - "timezone": "(UTC+02:00) Minsk (old)" + "timezone": "(UTC+02:00) Minsk (old)", + "standardTime": "Belarus Standard Time" }, { - "timezone": "(UTC+02:00) E. Europe" + "timezone": "(UTC+02:00) E. Europe", + "standardTime": "E. Europe Standard Time" }, { - "timezone": "(UTC+02:00) Kaliningrad" + "timezone": "(UTC+02:00) Kaliningrad", + "standardTime": "Kaliningrad Standard Time" }, { - "timezone": "(UTC+03:00) Baghdad" + "timezone": "(UTC+03:00) Baghdad", + "standardTime": "Arabic Standard Time" }, { - "timezone": "(UTC+03:00) Istanbul" + "timezone": "(UTC+03:00) Istanbul", + "standardTime": "Turkey Standard Time" }, { - "timezone": "(UTC+03:00) Kuwait, Riyadh" + "timezone": "(UTC+03:00) Kuwait, Riyadh", + "standardTime": "Arab Standard Time" }, { - "timezone": "(UTC+03:00) Minsk" + "timezone": "(UTC+03:00) Minsk", + "standardTime": "Belarus Standard Time" }, { - "timezone": "(UTC+03:00) Moscow, St. Petersburg, Volgograd" + "timezone": "(UTC+03:00) Moscow, St. Petersburg, Volgograd", + "standardTime": "Russian Standard Time" }, { - "timezone": "(UTC+03:00) Nairobi" + "timezone": "(UTC+03:00) Nairobi", + "standardTime": "E. Africa Standard Time" }, { - "timezone": "(UTC+03:30) Tehran" + "timezone": "(UTC+03:30) Tehran", + "standardTime": "Iran Standard Time" }, { - "timezone": "(UTC+04:00) Abu Dhabi, Muscat" + "timezone": "(UTC+04:00) Abu Dhabi, Muscat", + "standardTime": "Arabian Standard Time" }, { - "timezone": "(UTC+04:00) Astrakhan, Ulyanovsk" + "timezone": "(UTC+04:00) Astrakhan, Ulyanovsk", + "standardTime": "Astrakhan Standard Time" }, { - "timezone": "(UTC+04:00) Baku" + "timezone": "(UTC+04:00) Baku", + "standardTime": "Azerbaijan Standard Time" }, { - "timezone": "(UTC+04:00) Izhevsk, Samara" + "timezone": "(UTC+04:00) Izhevsk, Samara", + "standardTime": "Russia Time Zone 3" }, { - "timezone": "(UTC+04:00) Port Louis" + "timezone": "(UTC+04:00) Port Louis", + "standardTime": "Mauritius Standard Time" }, { - "timezone": "(UTC+04:00) Tbilisi" + "timezone": "(UTC+04:00) Tbilisi", + "standardTime": "Georgian Standard Time" }, { - "timezone": "(UTC+04:00) Yerevan" + "timezone": "(UTC+04:00) Yerevan", + "standardTime": "Caucasus Standard Time" }, { - "timezone": "(UTC+04:30) Kabul" + "timezone": "(UTC+04:30) Kabul", + "standardTime": "Afghanistan Standard Time" }, { - "timezone": "(UTC+05:00) Ekaterinburg" + "timezone": "(UTC+05:00) Ekaterinburg", + "standardTime": "Ekaterinburg Standard Time" }, { - "timezone": "(UTC+05:00) Islamabad, Karachi" + "timezone": "(UTC+05:00) Islamabad, Karachi", + "standardTime": "Pakistan Standard Time" }, { - "timezone": "(UTC+05:00) Tashkent" + "timezone": "(UTC+05:00) Tashkent", + "standardTime": "West Asia Standard Time" }, { - "timezone": "(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi" + "timezone": "(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi", + "standardTime": "India Standard Time" }, { - "timezone": "(UTC+05:30) Sri Jayawardenepura" + "timezone": "(UTC+05:30) Sri Jayawardenepura", + "standardTime": "Sri Lanka Standard Time" }, { - "timezone": "(UTC+05:45) Kathmandu" + "timezone": "(UTC+05:45) Kathmandu", + "standardTime": "Nepal Standard Time" }, { - "timezone": "(UTC+06:00) Astana" + "timezone": "(UTC+06:00) Astana", + "standardTime": "Qyzylorda Standard Time" }, { - "timezone": "(UTC+06:00) Dhaka" + "timezone": "(UTC+06:00) Dhaka", + "standardTime": "Bangladesh Standard Time" }, { - "timezone": "(UTC+06:00) Omsk" + "timezone": "(UTC+06:00) Omsk", + "standardTime": "Omsk Standard Time" }, { - "timezone": "(UTC+06:30) Yangon (Rangoon)" + "timezone": "(UTC+06:30) Yangon (Rangoon)", + "standardTime": "Myanmar Standard Time" }, { - "timezone": "(UTC+07:00) Bangkok, Hanoi, Jakarta" + "timezone": "(UTC+07:00) Bangkok, Hanoi, Jakarta", + "standardTime": "SE Asia Standard Time" }, { - "timezone": "(UTC+07:00) Barnaul, Gorno-Altaysk" + "timezone": "(UTC+07:00) Barnaul, Gorno-Altaysk", + "standardTime": "Altai Standard Time" }, { - "timezone": "(UTC+07:00) Krasnoyarsk" + "timezone": "(UTC+07:00) Krasnoyarsk", + "standardTime": "North Asia Standard Time" }, { - "timezone": "(UTC+07:00) Novosibirsk" + "timezone": "(UTC+07:00) Novosibirsk", + "standardTime": "N. Central Asia Standard Time" }, { - "timezone": "(UTC+07:00) Tomsk" + "timezone": "(UTC+07:00) Tomsk", + "standardTime": "Tomsk Standard Time" }, { - "timezone": "(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi" + "timezone": "(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi", + "standardTime": "China Standard Time" }, { - "timezone": "(UTC+08:00) Irkutsk" + "timezone": "(UTC+08:00) Irkutsk", + "standardTime": "North Asia East Standard Time" }, { - "timezone": "(UTC+08:00) Kuala Lumpur, Singapore" + "timezone": "(UTC+08:00) Kuala Lumpur, Singapore", + "standardTime": "Singapore Standard Time" }, { - "timezone": "(UTC+08:00) Perth" + "timezone": "(UTC+08:00) Perth", + "standardTime": "W. Australia Standard Time" }, { - "timezone": "(UTC+08:00) Taipei" + "timezone": "(UTC+08:00) Taipei", + "standardTime": "Taipei Standard Time" }, { - "timezone": "(UTC+08:00) Ulaanbaatar" + "timezone": "(UTC+08:00) Ulaanbaatar", + "standardTime": "Ulaanbaatar Standard Time" }, { - "timezone": "(UTC+09:00) Osaka, Sapporo, Tokyo" + "timezone": "(UTC+09:00) Osaka, Sapporo, Tokyo", + "standardTime": "Tokyo Standard Time" }, { - "timezone": "(UTC+09:00) Seoul" + "timezone": "(UTC+09:00) Seoul", + "standardTime": "Korea Standard Time" }, { - "timezone": "(UTC+09:00) Yakutsk" + "timezone": "(UTC+09:00) Yakutsk", + "standardTime": "Yakutsk Standard Time" }, { - "timezone": "(UTC+09:30) Adelaide" + "timezone": "(UTC+09:30) Adelaide", + "standardTime": "Cen. Australia Standard Time" }, { - "timezone": "(UTC+09:30) Darwin" + "timezone": "(UTC+09:30) Darwin", + "standardTime": "AUS Central Standard Time" }, { - "timezone": "(UTC+10:00) Brisbane" + "timezone": "(UTC+10:00) Brisbane", + "standardTime": "E. Australia Standard Time" }, { - "timezone": "(UTC+10:00) Canberra, Melbourne, Sydney" + "timezone": "(UTC+10:00) Canberra, Melbourne, Sydney", + "standardTime": "AUS Eastern Standard Time" }, { - "timezone": "(UTC+10:00) Guam, Port Moresby" + "timezone": "(UTC+10:00) Guam, Port Moresby", + "standardTime": "West Pacific Standard Time" }, { - "timezone": "(UTC+10:00) Hobart" + "timezone": "(UTC+10:00) Hobart", + "standardTime": "Tasmania Standard Time" }, { - "timezone": "(UTC+10:00) Magadan" + "timezone": "(UTC+10:00) Magadan", + "standardTime": "Magadan Standard Time" }, { - "timezone": "(UTC+10:00) Vladivostok" + "timezone": "(UTC+10:00) Vladivostok", + "standardTime": "Vladivostok Standard Time" }, { - "timezone": "(UTC+11:00) Chokurdakh" + "timezone": "(UTC+11:00) Chokurdakh", + "standardTime": "Russia Time Zone 10" }, { - "timezone": "(UTC+11:00) Sakhalin" + "timezone": "(UTC+11:00) Sakhalin", + "standardTime": "Sakhalin Standard Time" }, { - "timezone": "(UTC+11:00) Solomon Is., New Caledonia" + "timezone": "(UTC+11:00) Solomon Is., New Caledonia", + "standardTime": "Central Pacific Standard Time" }, { - "timezone": "(UTC+12:00) Anadyr, Petropavlovsk-Kamchatsky" + "timezone": "(UTC+12:00) Anadyr, Petropavlovsk-Kamchatsky", + "standardTime": "Russia Time Zone 11" }, { - "timezone": "(UTC+12:00) Auckland, Wellington" + "timezone": "(UTC+12:00) Auckland, Wellington", + "standardTime": "New Zealand Standard Time" }, { - "timezone": "(UTC+12:00) Coordinated Universal Time+12" + "timezone": "(UTC+12:00) Coordinated Universal Time+12", + "standardTime": "UTC+12" }, { - "timezone": "(UTC+12:00) Fiji" + "timezone": "(UTC+12:00) Fiji", + "standardTime": "Fiji Standard Time" }, { - "timezone": "(UTC+12:00) Petropavlovsk-Kamchatsky - Old" + "timezone": "(UTC+12:00) Petropavlovsk-Kamchatsky - Old", + "standardTime": "Kamchatka Standard Time" }, { - "timezone": "(UTC+13:00) Nuku'alofa" + "timezone": "(UTC+13:00) Nuku'alofa", + "standardTime": "Tonga Standard Time" }, { - "timezone": "(UTC+13:00) Samoa" + "timezone": "(UTC+13:00) Samoa", + "standardTime": "Samoa Standard Time" } ] diff --git a/src/hooks/use-guid-resolver.js b/src/hooks/use-guid-resolver.js new file mode 100644 index 000000000000..1325722fc872 --- /dev/null +++ b/src/hooks/use-guid-resolver.js @@ -0,0 +1,523 @@ +import { useState, useCallback, useRef, useEffect } from "react"; +import { ApiPostCall } from "/src/api/ApiCall"; +import { useSettings } from "/src/hooks/use-settings"; + +// Function to check if a string is a GUID +const isGuid = (str) => { + if (typeof str !== "string") return false; + const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return guidRegex.test(str); +}; + +// Function to extract GUIDs from strings (including embedded GUIDs) +const extractGuidsFromString = (str) => { + if (typeof str !== "string") return []; + const guidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi; + return str.match(guidRegex) || []; +}; + +// Function to extract object IDs from partner tenant UPNs (user_@.onmicrosoft.com) +// Also handles format: TenantName.onmicrosoft.com\tenant: , object: +const extractObjectIdFromPartnerUPN = (str) => { + if (typeof str !== "string") return []; + const matches = []; + + // Format 1: user_@.onmicrosoft.com + const partnerUpnRegex = /user_([0-9a-f]{32})@([^@]+\.onmicrosoft\.com)/gi; + let match; + + while ((match = partnerUpnRegex.exec(str)) !== null) { + // Convert the 32-character hex string to GUID format + const hexId = match[1]; + const tenantDomain = match[2]; + if (hexId.length === 32) { + const guid = [ + hexId.slice(0, 8), + hexId.slice(8, 12), + hexId.slice(12, 16), + hexId.slice(16, 20), + hexId.slice(20, 32), + ].join("-"); + matches.push({ guid, tenantDomain }); + } + } + + // Format 2: TenantName.onmicrosoft.com\tenant: , object: + // For exchange format, use the partner tenant guid for resolution + const partnerTenantObjectRegex = + /([^\\]+\.onmicrosoft\.com)\\tenant:\s*([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}),\s*object:\s*([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/gi; + + while ((match = partnerTenantObjectRegex.exec(str)) !== null) { + const customerTenantDomain = match[1]; // This is the customer tenant domain + const partnerTenantGuid = match[2]; // This is the partner tenant guid - use this for resolution + const objectGuid = match[3]; // This is the object to resolve + + // Use the partner tenant GUID for resolution + matches.push({ guid: objectGuid, tenantDomain: partnerTenantGuid }); + } + + return matches; +}; + +// Function to recursively scan an object for GUIDs +const findGuids = (obj, guidsSet = new Set(), partnerGuidsMap = new Map()) => { + if (!obj) return { guidsSet, partnerGuidsMap }; + + if (typeof obj === "string") { + // First, extract object IDs from partner tenant UPNs to track which GUIDs belong to partners + const partnerObjectIds = extractObjectIdFromPartnerUPN(obj); + const partnerGuids = new Set(); + + partnerObjectIds.forEach(({ guid, tenantDomain }) => { + if (!partnerGuidsMap.has(tenantDomain)) { + partnerGuidsMap.set(tenantDomain, new Set()); + } + partnerGuidsMap.get(tenantDomain).add(guid); + partnerGuids.add(guid); // Track this GUID as belonging to a partner + }); + + // Check if the entire string is a GUID + if (isGuid(obj)) { + // Only add to main guidsSet if it's not a partner GUID + if (!partnerGuids.has(obj)) { + guidsSet.add(obj); + } + } else { + // Extract GUIDs embedded within longer strings + const embeddedGuids = extractGuidsFromString(obj); + embeddedGuids.forEach((guid) => { + // Only add to main guidsSet if it's not a partner GUID + if (!partnerGuids.has(guid)) { + guidsSet.add(guid); + } + }); + } + } else if (Array.isArray(obj)) { + obj.forEach((item) => { + const result = findGuids(item, guidsSet, partnerGuidsMap); + guidsSet = result.guidsSet; + partnerGuidsMap = result.partnerGuidsMap; + }); + } else if (typeof obj === "object") { + Object.values(obj).forEach((value) => { + const result = findGuids(value, guidsSet, partnerGuidsMap); + guidsSet = result.guidsSet; + partnerGuidsMap = result.partnerGuidsMap; + }); + } + + return { guidsSet, partnerGuidsMap }; +}; + +// Helper function to replace GUIDs and special UPNs in a string with resolved names +const replaceGuidsAndUpnsInString = (str, guidMapping, upnMapping, isLoadingGuids) => { + if (typeof str !== "string") return { result: str, hasResolvedNames: false }; + + let result = str; + let hasResolvedNames = false; + + // Replace standard GUIDs + const guidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi; + const guidsInString = str.match(guidRegex) || []; + + guidsInString.forEach((guid) => { + if (guidMapping[guid]) { + result = result.replace(new RegExp(guid, "gi"), guidMapping[guid]); + hasResolvedNames = true; + } + }); + + // Replace partner UPNs (user_@partnertenant.onmicrosoft.com) + const partnerUpnRegex = /user_([0-9a-f]{32})@([^@]+\.onmicrosoft\.com)/gi; + let match; + + // We need to clone the string to reset the regex lastIndex + const strForMatching = String(str); + + while ((match = partnerUpnRegex.exec(strForMatching)) !== null) { + const fullMatch = match[0]; // The complete UPN + const hexId = match[1]; + + if (hexId.length === 32) { + const guid = [ + hexId.slice(0, 8), + hexId.slice(8, 12), + hexId.slice(12, 16), + hexId.slice(16, 20), + hexId.slice(20, 32), + ].join("-"); + + // For partner UPN format, use the actual UPN if available, otherwise fall back to display name + if (upnMapping[guid]) { + result = result.replace( + new RegExp(fullMatch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), + upnMapping[guid] + ); + hasResolvedNames = true; + } else if (guidMapping[guid]) { + result = result.replace( + new RegExp(fullMatch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), + guidMapping[guid] + ); + hasResolvedNames = true; + } + } + } + + return { result, hasResolvedNames }; +}; + +export const useGuidResolver = (manualTenant = null) => { + const tenantFilter = useSettings().currentTenant; + const activeTenant = manualTenant || tenantFilter; + + // GUID resolution state + const [guidMapping, setGuidMapping] = useState({}); + const [upnMapping, setUpnMapping] = useState({}); // New mapping specifically for UPNs + const [isLoadingGuids, setIsLoadingGuids] = useState(false); + + // Use refs for values that shouldn't trigger re-renders but need to persist + const notFoundGuidsRef = useRef(new Set()); + const pendingGuidsRef = useRef([]); + const pendingPartnerGuidsRef = useRef(new Map()); // Map of tenantDomain -> Set of GUIDs + const lastRequestTimeRef = useRef(0); + const lastPartnerRequestTimeRef = useRef(0); // Separate timing for partner tenant calls + const rateLimitBackoffRef = useRef(2000); // Default backoff time in milliseconds + const rateLimitTimeoutRef = useRef(null); // For tracking retry timeouts + + // Helper function to retry API call with the correct backoff + const retryApiCallWithBackoff = useCallback((apiCall, url, data, retryDelay = null) => { + // Clear any existing timeout + if (rateLimitTimeoutRef.current) { + clearTimeout(rateLimitTimeoutRef.current); + } + + // Use specified delay or current backoff time + const delay = retryDelay || rateLimitBackoffRef.current; + + // Set timeout to retry + rateLimitTimeoutRef.current = setTimeout(() => { + apiCall.mutate({ url, data }); + rateLimitTimeoutRef.current = null; + }, delay); + + // Increase backoff for future retries (up to a reasonable limit) + rateLimitBackoffRef.current = Math.min(rateLimitBackoffRef.current * 1.5, 10000); + }, []); + + // Setup API call for directory objects resolution + const directoryObjectsMutation = ApiPostCall({ + relatedQueryKeys: ["directoryObjects"], + onResult: (data) => { + // Handle rate limit error + if (data && data.statusCode === 429) { + console.log("Rate limit hit on directory objects lookup, retrying..."); + + // Extract retry time from message if available + let retryAfterSeconds = 2; + if (data.message && typeof data.message === "string") { + const match = data.message.match(/Try again in (\d+) seconds/i); + if (match && match[1]) { + retryAfterSeconds = parseInt(match[1], 10) || 2; + } + } + + // Retry with the specified delay (convert to milliseconds) + retryApiCallWithBackoff( + directoryObjectsMutation, + "/api/ListDirectoryObjects", + { + tenantFilter: activeTenant, + ids: pendingGuidsRef.current, + $select: "id,displayName,userPrincipalName,mail", + }, + retryAfterSeconds * 1000 + ); + return; + } + + // Reset backoff time on successful request + rateLimitBackoffRef.current = 2000; + + if (data && Array.isArray(data.value)) { + const newDisplayMapping = {}; + const newUpnMapping = {}; + + // Process the returned results + data.value.forEach((item) => { + if (item.id) { + // For display purposes, prefer displayName > userPrincipalName > mail + if (item.displayName || item.userPrincipalName || item.mail) { + newDisplayMapping[item.id] = item.displayName || item.userPrincipalName || item.mail; + } + + // For UPN replacement, specifically store the UPN when available + if (item.userPrincipalName) { + newUpnMapping[item.id] = item.userPrincipalName; + } + } + }); + + // Find GUIDs that were sent but not returned in the response + const processedGuids = new Set(pendingGuidsRef.current); + const returnedGuids = new Set(data.value.map((item) => item.id)); + const notReturned = [...processedGuids].filter((guid) => !returnedGuids.has(guid)); + + // Add unresolved GUIDs to partner tenant fallback lookup + if (notReturned.length > 0) { + console.log( + `${notReturned.length} GUIDs not resolved by primary tenant, trying partner tenant lookup` + ); + + // Add to partner lookup with the current tenant as fallback + if (!pendingPartnerGuidsRef.current.has(activeTenant)) { + pendingPartnerGuidsRef.current.set(activeTenant, new Set()); + } + notReturned.forEach((guid) => { + pendingPartnerGuidsRef.current.get(activeTenant).add(guid); + }); + + // Trigger partner lookup immediately for fallback + const now = Date.now(); + if (!rateLimitTimeoutRef.current && now - lastPartnerRequestTimeRef.current >= 2000) { + lastPartnerRequestTimeRef.current = now; + + // Use partner tenant API for unresolved GUIDs + console.log( + `Sending partner fallback request for ${notReturned.length} GUIDs in tenant ${activeTenant}` + ); + partnerDirectoryObjectsMutation.mutate({ + url: "/api/ListDirectoryObjects", + data: { + tenantFilter: activeTenant, + ids: notReturned, + $select: "id,displayName,userPrincipalName,mail", + partnerLookup: true, // Flag to indicate this is a partner lookup + }, + }); + } + } + + setGuidMapping((prevMapping) => ({ ...prevMapping, ...newDisplayMapping })); + setUpnMapping((prevMapping) => ({ ...prevMapping, ...newUpnMapping })); + pendingGuidsRef.current = []; + + // Only set loading to false if we don't have pending partner lookups + if (notReturned.length === 0) { + setIsLoadingGuids(false); + } + } + }, + }); + + // Setup API call for partner tenant directory objects resolution + const partnerDirectoryObjectsMutation = ApiPostCall({ + relatedQueryKeys: ["partnerDirectoryObjects"], + onResult: (data) => { + // Handle rate limit error + if (data && data.statusCode === 429) { + console.log("Rate limit hit on partner directory objects lookup, retrying..."); + + // Extract retry time from message if available + let retryAfterSeconds = 2; + if (data.message && typeof data.message === "string") { + const match = data.message.match(/Try again in (\d+) seconds/i); + if (match && match[1]) { + retryAfterSeconds = parseInt(match[1], 10) || 2; + } + } + + // We need to preserve the current tenant domain for retry + const currentTenantEntries = [...pendingPartnerGuidsRef.current.entries()]; + + if (currentTenantEntries.length > 0) { + const [tenantDomain, guidsSet] = currentTenantEntries[0]; + const guidsToRetry = Array.from(guidsSet); + + // Retry with the specified delay (convert to milliseconds) + retryApiCallWithBackoff( + partnerDirectoryObjectsMutation, + "/api/ListDirectoryObjects", + { + tenantFilter: tenantDomain, + ids: guidsToRetry, + $select: "id,displayName,userPrincipalName,mail", + }, + retryAfterSeconds * 1000 + ); + } + return; + } + + // Reset backoff time on successful request + rateLimitBackoffRef.current = 2000; + + if (data && Array.isArray(data.value)) { + const newDisplayMapping = {}; + const newUpnMapping = {}; + + // Process the returned results + data.value.forEach((item) => { + if (item.id) { + // For display purposes, prefer userPrincipalName > mail > DisplayName + if (item.userPrincipalName || item.mail || item.displayName) { + newDisplayMapping[item.id] = item.userPrincipalName || item.mail || item.displayName; + } + + // For UPN replacement, specifically store the UPN when available + if (item.userPrincipalName) { + newUpnMapping[item.id] = item.userPrincipalName; + } + } + }); + + // Find GUIDs that were sent but not returned in the partner lookup + const allPendingPartnerGuids = new Set(); + pendingPartnerGuidsRef.current.forEach((guidsSet) => { + guidsSet.forEach((guid) => allPendingPartnerGuids.add(guid)); + }); + + const returnedGuids = new Set(data.value.map((item) => item.id)); + const stillNotFound = [...allPendingPartnerGuids].filter( + (guid) => !returnedGuids.has(guid) + ); + + // Add truly unresolved GUIDs to notFoundGuids + if (stillNotFound.length > 0) { + stillNotFound.forEach((guid) => notFoundGuidsRef.current.add(guid)); + } + + setGuidMapping((prevMapping) => ({ ...prevMapping, ...newDisplayMapping })); + setUpnMapping((prevMapping) => ({ ...prevMapping, ...newUpnMapping })); + + // Clear processed partner GUIDs + pendingPartnerGuidsRef.current = new Map(); + setIsLoadingGuids(false); + } + }, + }); // Function to handle resolving GUIDs + const resolveGuids = useCallback( + (objectToScan) => { + const { guidsSet, partnerGuidsMap } = findGuids(objectToScan); + + // Handle regular GUIDs (current tenant) - these should NOT include partner tenant GUIDs + if (guidsSet.size > 0) { + const guidsArray = Array.from(guidsSet); + const notResolvedGuids = guidsArray.filter( + (guid) => !guidMapping[guid] && !notFoundGuidsRef.current.has(guid) + ); + + if (notResolvedGuids.length > 0) { + // Merge new GUIDs with existing pending GUIDs without duplicates + const allPendingGuids = [...new Set([...pendingGuidsRef.current, ...notResolvedGuids])]; + pendingGuidsRef.current = allPendingGuids; + setIsLoadingGuids(true); + + // Make API call for primary tenant GUIDs + const now = Date.now(); + if (!rateLimitTimeoutRef.current && now - lastRequestTimeRef.current >= 2000) { + lastRequestTimeRef.current = now; + + // Only send a maximum of 1000 GUIDs per request + const batchSize = 1000; + const guidsToSend = allPendingGuids.slice(0, batchSize); + + if (guidsToSend.length > 0) { + console.log( + `Sending primary tenant request for ${guidsToSend.length} GUIDs in tenant ${activeTenant}` + ); + directoryObjectsMutation.mutate({ + url: "/api/ListDirectoryObjects", + data: { + tenantFilter: activeTenant, + ids: guidsToSend, + $select: "id,displayName,userPrincipalName,mail", + }, + }); + } else { + setIsLoadingGuids(false); + } + } + } + } + + // Handle partner tenant GUIDs separately + if (partnerGuidsMap.size > 0) { + partnerGuidsMap.forEach((guids, tenantDomain) => { + const guidsArray = Array.from(guids); + const notResolvedGuids = guidsArray.filter( + (guid) => !guidMapping[guid] && !notFoundGuidsRef.current.has(guid) + ); + + if (notResolvedGuids.length > 0) { + // Store pending partner GUIDs + if (!pendingPartnerGuidsRef.current.has(tenantDomain)) { + pendingPartnerGuidsRef.current.set(tenantDomain, new Set()); + } + notResolvedGuids.forEach((guid) => + pendingPartnerGuidsRef.current.get(tenantDomain).add(guid) + ); + + setIsLoadingGuids(true); + + // Make API call for partner tenant - with separate timing from primary tenant + const now = Date.now(); + if (!rateLimitTimeoutRef.current && now - lastPartnerRequestTimeRef.current >= 2000) { + lastPartnerRequestTimeRef.current = now; + + // Only send a maximum of 1000 GUIDs per request + const batchSize = 1000; + const guidsToSend = notResolvedGuids.slice(0, batchSize); + + if (guidsToSend.length > 0) { + console.log( + `Sending partner tenant request for ${guidsToSend.length} GUIDs in tenant ${tenantDomain}` + ); + partnerDirectoryObjectsMutation.mutate({ + url: "/api/ListDirectoryObjects", + data: { + tenantFilter: tenantDomain, + ids: guidsToSend, + $select: "id,displayName,userPrincipalName,mail", + }, + }); + } + } + } + }); + } + + // If no GUIDs to process, ensure loading state is false + if (guidsSet.size === 0 && partnerGuidsMap.size === 0) { + setIsLoadingGuids(false); + } + }, + [guidMapping, activeTenant, directoryObjectsMutation, partnerDirectoryObjectsMutation] + ); + + // Create a memoized version of the string replacement function + const replaceGuidsAndUpnsInStringMemoized = useCallback( + (str) => replaceGuidsAndUpnsInString(str, guidMapping, upnMapping, isLoadingGuids), + [guidMapping, upnMapping, isLoadingGuids] + ); + + // Cleanup function to clear any pending timeouts when the component unmounts + useEffect(() => { + return () => { + if (rateLimitTimeoutRef.current) { + clearTimeout(rateLimitTimeoutRef.current); + rateLimitTimeoutRef.current = null; + } + }; + }, []); + + return { + guidMapping, + upnMapping, + isLoadingGuids, + resolveGuids, + isGuid, + extractObjectIdFromPartnerUPN, + replaceGuidsAndUpnsInString: replaceGuidsAndUpnsInStringMemoized, + }; +}; diff --git a/src/hooks/use-page-view.js b/src/hooks/use-page-view.js index c4365337c92a..fe5923c47679 100644 --- a/src/hooks/use-page-view.js +++ b/src/hooks/use-page-view.js @@ -1,3 +1 @@ -import { useEffect } from "react"; - export const usePageView = () => {}; diff --git a/src/hooks/use-permissions.js b/src/hooks/use-permissions.js new file mode 100644 index 000000000000..6b7973c164f8 --- /dev/null +++ b/src/hooks/use-permissions.js @@ -0,0 +1,96 @@ +import { useCallback } from "react"; +import { ApiGetCall } from "/src/api/ApiCall"; +import { hasAccess, hasPermission, hasRole } from "/src/utils/permissions"; + +/** + * Hook for checking user permissions and roles + * Integrates with the existing CIPP authentication system + */ +export const usePermissions = () => { + const currentRole = ApiGetCall({ + url: "/api/me", + queryKey: "authmecipp", + }); + + const userRoles = currentRole.data?.clientPrincipal?.userRoles || []; + const userPermissions = currentRole.data?.permissions || []; + const isLoading = currentRole.isLoading; + const isAuthenticated = currentRole.isSuccess && userRoles.length > 0; + + /** + * Check if user has specific permissions + * @param {string[]} requiredPermissions - Array of required permissions (supports wildcards) + * @returns {boolean} - True if user has at least one of the required permissions + */ + const checkPermissions = useCallback( + (requiredPermissions) => { + if (!isAuthenticated) return false; + return hasPermission(userPermissions, requiredPermissions); + }, + [userPermissions, isAuthenticated] + ); + + /** + * Check if user has specific roles + * @param {string[]} requiredRoles - Array of required roles + * @returns {boolean} - True if user has at least one of the required roles + */ + const checkRoles = useCallback( + (requiredRoles) => { + if (!isAuthenticated) return false; + return hasRole(userRoles, requiredRoles); + }, + [userRoles, isAuthenticated] + ); + + /** + * Check if user has access based on both permissions and roles + * @param {Object} config - Configuration object + * @param {string[]} config.requiredPermissions - Array of required permissions + * @param {string[]} config.requiredRoles - Array of required roles + * @returns {boolean} - True if user has access + */ + const checkAccess = useCallback( + (config = {}) => { + if (!isAuthenticated) return false; + + const { requiredPermissions = [], requiredRoles = [] } = config; + + return hasAccess({ + userPermissions, + userRoles, + requiredPermissions, + requiredRoles, + }); + }, + [userPermissions, userRoles, isAuthenticated] + ); + + return { + userPermissions, + userRoles, + isLoading, + isAuthenticated, + checkPermissions, + checkRoles, + checkAccess, + }; +}; + +/** + * Hook specifically for checking permissions with a simpler API + * @param {string[]} requiredPermissions - Array of required permissions + * @param {string[]} requiredRoles - Array of required roles + * @returns {Object} - Object containing hasAccess boolean and loading state + */ +export const useHasPermission = (requiredPermissions = [], requiredRoles = []) => { + const { checkAccess, isLoading, isAuthenticated } = usePermissions(); + + const hasAccess = checkAccess({ requiredPermissions, requiredRoles }); + + return { + hasAccess, + isLoading, + isAuthenticated, + }; +}; diff --git a/src/hooks/use-securescore.js b/src/hooks/use-securescore.js index 2394fa98ed0a..f96c2bd232b7 100644 --- a/src/hooks/use-securescore.js +++ b/src/hooks/use-securescore.js @@ -3,7 +3,7 @@ import { ApiGetCall } from "../api/ApiCall"; import { useSettings } from "./use-settings"; import standards from "/src/data/standards.json"; -export function useSecureScore() { +export function useSecureScore({ waiting = true } = {}) { const currentTenant = useSettings().currentTenant; if (currentTenant === "AllTenants") { return { @@ -27,6 +27,7 @@ export function useSecureScore() { $top: 999, }, queryKey: `controlScore-${currentTenant}`, + waiting: waiting, }); const secureScore = ApiGetCall({ @@ -39,6 +40,7 @@ export function useSecureScore() { $top: 7, }, queryKey: `secureScore-${currentTenant}`, + waiting: waiting, }); useEffect(() => { @@ -66,7 +68,7 @@ export function useSecureScore() { complianceInformation: translation?.complianceInformation, actionUrl: remediation ? //this needs to be updated to be a direct url to apply this standard. - "/tenant/standards/list-applied-standards" + "/tenant/standards/list-standards" : translation?.actionUrl, remediation: remediation ? `1. Enable the CIPP Standard: ${remediation.label}` @@ -89,12 +91,10 @@ export function useSecureScore() { (secureScoreData.currentScore / secureScoreData.maxScore) * 100 ), percentageVsAllTenants: Math.round( - (secureScoreData.averageComparativeScores?.[0]?.averageScore / secureScoreData.maxScore) * - 100 + secureScoreData.averageComparativeScores?.[0]?.averageScore ), percentageVsSimilar: Math.round( - (secureScoreData.averageComparativeScores?.[1]?.averageScore / secureScoreData.maxScore) * - 100 + secureScoreData.averageComparativeScores?.[1]?.averageScore ), controlScores: updatedControlScores, }); diff --git a/src/layouts/TabbedLayout.jsx b/src/layouts/TabbedLayout.jsx index 1f2b77713237..9594443127bc 100644 --- a/src/layouts/TabbedLayout.jsx +++ b/src/layouts/TabbedLayout.jsx @@ -1,5 +1,5 @@ import { usePathname, useRouter } from "next/navigation"; -import { Box, Container, Divider, Stack, Tab, Tabs, Typography } from "@mui/material"; +import { Box, Divider, Stack, Tab, Tabs } from "@mui/material"; export const TabbedLayout = (props) => { const { tabOptions, children } = props; diff --git a/src/layouts/account-popover.js b/src/layouts/account-popover.js index ab6b9a11155b..579780b6adc8 100644 --- a/src/layouts/account-popover.js +++ b/src/layouts/account-popover.js @@ -9,6 +9,7 @@ import SunIcon from "@heroicons/react/24/outline/SunIcon"; import { Avatar, Box, + CircularProgress, List, ListItem, ListItemButton, @@ -23,7 +24,9 @@ import { import { usePopover } from "../hooks/use-popover"; import { paths } from "../paths"; import { ApiGetCall } from "../api/ApiCall"; -import { CogIcon } from "@heroicons/react/24/outline"; +import { CogIcon, DocumentTextIcon } from "@heroicons/react/24/outline"; +import { useReleaseNotes } from "../contexts/release-notes-context"; +import { useQueryClient } from "@tanstack/react-query"; export const AccountPopover = (props) => { const { @@ -36,21 +39,36 @@ export const AccountPopover = (props) => { const router = useRouter(); const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); const popover = usePopover(); - + const queryClient = useQueryClient(); + const { openReleaseNotes } = useReleaseNotes(); const orgData = ApiGetCall({ - url: "/.auth/me", + url: "/api/me", queryKey: "authmecipp", - staleTime: 120000, - refetchOnWindowFocus: true, + }); + + const userDetails = orgData.data?.clientPrincipal?.userDetails; + + // Cache user photo with user-specific key + const userPhoto = ApiGetCall({ + url: "/api/ListUserPhoto", + data: { UserID: userDetails }, + queryKey: `userPhoto-${userDetails}`, + waiting: !!userDetails, + staleTime: Infinity, + responseType: "blob", + convertToDataUrl: true, }); const handleLogout = useCallback(async () => { try { popover.handleClose(); + // delete query cache and persisted data + queryClient.clear(); router.push("/.auth/logout?post_logout_redirect_uri=" + encodeURIComponent(paths.index)); } catch (err) { console.error(err); + console.log(orgData); toast.error("Something went wrong"); } }, [router, popover]); @@ -60,15 +78,12 @@ export const AccountPopover = (props) => { sx={{ height: 40, width: 40, + fontSize: 20, }} variant="rounded" - src={ - orgData.data?.clientPrincipal?.userDetails - ? `/api/ListUserPhoto?UserID=${orgData.data?.clientPrincipal?.userDetails}` - : "" - } + src={userPhoto.data && !userPhoto.isError ? userPhoto.data : undefined} > - {orgData.data?.userDetails?.[0] || ""} + {userDetails?.[0]?.toUpperCase() || ""} ); @@ -89,16 +104,22 @@ export const AccountPopover = (props) => { <> - {orgData.data?.Org?.Domain} + {orgData.data?.clientPrincipal?.userDetails?.split("@")?.[1]} {orgData.data?.clientPrincipal?.userDetails ?? "Not logged in"} {orgData.data?.clientPrincipal?.userDetails && ( - - - + <> + {orgData?.isFetching ? ( + + ) : ( + + + + )} + )} )} @@ -143,6 +164,19 @@ export const AccountPopover = (props) => { + { + popover.handleClose(); + openReleaseNotes(); + }} + > + + + + + + + diff --git a/src/layouts/config.js b/src/layouts/config.js index b1d9e72b9623..115de1795d46 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -1,12 +1,9 @@ import { BuildingOfficeIcon, HomeIcon, UsersIcon, WrenchIcon } from "@heroicons/react/24/outline"; import { - Cloud, CloudOutlined, - DeviceHub, HomeRepairService, Laptop, MailOutline, - Shield, ShieldOutlined, } from "@mui/icons-material"; import { SvgIcon } from "@mui/material"; @@ -20,6 +17,7 @@ export const nativeMenuItems = [ ), + permissions: ["CIPP.Core.*"], }, { title: "Identity Management", @@ -29,40 +27,96 @@ export const nativeMenuItems = [ ), + permissions: ["Identity.*"], items: [ { title: "Administration", path: "/identity/administration", + permissions: ["Identity.User.*"], items: [ - { title: "Users", path: "/identity/administration/users" }, - { title: "Risky Users", path: "/identity/administration/risky-users" }, - { title: "Groups", path: "/identity/administration/groups" }, + { + title: "Users", + path: "/identity/administration/users", + permissions: ["Identity.User.*"], + }, + { + title: "Risky Users", + path: "/identity/administration/risky-users", + permissions: ["Identity.User.*"], + }, + { + title: "Groups", + path: "/identity/administration/groups", + permissions: ["Identity.Group.*"], + }, { title: "Group Templates", path: "/identity/administration/group-templates", + permissions: ["Identity.Group.*"], + }, + { + title: "Devices", + path: "/identity/administration/devices", + permissions: ["Identity.Device.*"], + }, + { + title: "Deleted Items", + path: "/identity/administration/deleted-items", + permissions: ["Identity.User.*"], + }, + { + title: "Roles", + path: "/identity/administration/roles", + permissions: ["Identity.Role.*"], + }, + { + title: "JIT Admin", + path: "/identity/administration/jit-admin", + permissions: ["Identity.Role.*"], }, - { title: "Devices", path: "/identity/administration/devices" }, - { title: "Deleted Items", path: "/identity/administration/deleted-items" }, - { title: "Roles", path: "/identity/administration/roles" }, - { title: "JIT Admin", path: "/identity/administration/jit-admin" }, { title: "Offboarding Wizard", path: "/identity/administration/offboarding-wizard", + permissions: ["Identity.User.*"], }, ], }, { title: "Reports", path: "/identity/reports", + permissions: [ + "Identity.User.*", + "Identity.Group.*", + "Identity.Device.*", + "Identity.Role.*", + "Identity.AuditLog.*", + ], items: [ - { title: "MFA Report", path: "/identity/reports/mfa-report" }, - { title: "Inactive Users", path: "/identity/reports/inactive-users-report" }, - { title: "Sign-in Report", path: "/identity/reports/signin-report" }, + { + title: "MFA Report", + path: "/identity/reports/mfa-report", + permissions: ["Identity.User.*"], + }, + { + title: "Inactive Users", + path: "/identity/reports/inactive-users-report", + permissions: ["Identity.User.*"], + }, + { + title: "Sign-in Report", + path: "/identity/reports/signin-report", + permissions: ["Identity.User.*"], + }, { title: "AAD Connect Report", path: "/identity/reports/azure-ad-connect-report", + permissions: ["Identity.User.*"], + }, + { + title: "Risk Detections", + path: "/identity/reports/risk-detections", + permissions: ["Identity.User.*"], }, - { title: "Risk Detections", path: "/identity/reports/risk-detections" }, ], }, ], @@ -75,94 +129,132 @@ export const nativeMenuItems = [ ), + permissions: ["Tenant.*", "Identity.AuditLog.*", "CIPP.Backup.*", "Scheduler.Billing.*"], items: [ { title: "Administration", path: "/tenant/administration", + permissions: ["Tenant.Administration.*"], items: [ - { title: "Tenants", path: "/tenant/administration/tenants" }, + { + title: "Tenants", + path: "/tenant/administration/tenants", + permissions: ["Tenant.Administration.*"], + }, { title: "Alert Configuration", path: "/tenant/administration/alert-configuration", + permissions: ["Tenant.Alert.*"], + }, + { + title: "Audit Logs", + path: "/tenant/administration/audit-logs", + permissions: ["Identity.AuditLog.*"], + }, + { + title: "Applications", + path: "/tenant/administration/applications/enterprise-apps", + permissions: ["Tenant.Application.*"], }, - { title: "Audit Logs", path: "/tenant/administration/audit-logs" }, { - title: "Enterprise Applications", - path: "/tenant/administration/enterprise-apps", + title: "Secure Score", + path: "/tenant/administration/securescore", + permissions: ["Tenant.Administration.*"], }, - { title: "Secure Score", path: "/tenant/administration/securescore" }, { title: "App Consent Requests", path: "/tenant/administration/app-consent-requests", + permissions: ["Tenant.Application.*"], }, { title: "Authentication Methods", path: "/tenant/administration/authentication-methods", + permissions: ["Tenant.Config.*"], }, { title: "Partner Relationships", path: "/tenant/administration/partner-relationships", + permissions: ["Tenant.Relationship.*"], }, ], }, { title: "GDAP Management", path: "/tenant/gdap-management/", + permissions: ["Tenant.Relationship.*"], }, { - title: "Configuration Backup", - path: "/tenant/backup", - items: [{ title: "Backups", path: "/tenant/backup/backup-wizard" }], - }, - { - title: "Standards", + title: "Standards & Drift", path: "/tenant/standards", + permissions: [ + "Tenant.Standards.*", + "Tenant.BestPracticeAnalyser.*", + "Tenant.DomainAnalyser.*", + ], items: [ - { title: "Standards", path: "/tenant/standards/list-standards" }, + { + title: "Standards Management", + path: "/tenant/standards/list-standards", + permissions: ["Tenant.Standards.*"], + }, { title: "Best Practice Analyser", path: "/tenant/standards/bpa-report", + permissions: ["Tenant.BestPracticeAnalyser.*"], }, { title: "Domains Analyser", path: "/tenant/standards/domains-analyser", + permissions: ["Tenant.DomainAnalyser.*"], }, ], }, { title: "Conditional Access", path: "/tenant/conditional", + permissions: ["Tenant.ConditionalAccess.*"], items: [ - { title: "CA Policies", path: "/tenant/conditional/list-policies" }, + { + title: "CA Policies", + path: "/tenant/conditional/list-policies", + permissions: ["Tenant.ConditionalAccess.*"], + }, { title: "CA Vacation Mode", path: "/tenant/conditional/deploy-vacation", + permissions: ["Tenant.ConditionalAccess.*"], }, { title: "CA Templates", path: "/tenant/conditional/list-template", + permissions: ["Tenant.ConditionalAccess.*"], }, { title: "Named Locations", path: "/tenant/conditional/list-named-locations", + permissions: ["Tenant.ConditionalAccess.*"], }, ], }, { title: "Reports", path: "/tenant/reports", + permissions: ["Tenant.Administration.*", "Scheduler.Billing.*", "Tenant.Application.*"], items: [ { title: "Licence Report", path: "/tenant/reports/list-licenses", + permissions: ["Tenant.Administration.*"], }, { title: "Sherweb Licence Report", path: "/tenant/reports/list-csp-licenses", + permissions: ["Tenant.Directory.*"], }, { title: "Consented Applications", path: "/tenant/reports/application-consent", + permissions: ["Tenant.Application.*"], }, ], }, @@ -176,37 +268,88 @@ export const nativeMenuItems = [ ), + permissions: [ + "Security.Incident.*", + "Security.Alert.*", + "Tenant.DeviceCompliance.*", + "Security.SafeLinksPolicy.*", + ], items: [ { title: "Incidents & Alerts", path: "/security/incidents", + permissions: ["Security.Incident.*"], items: [ - { title: "Incidents", path: "/security/incidents/list-incidents" }, - { title: "Alerts", path: "/security/incidents/list-alerts" }, + { + title: "Incidents", + path: "/security/incidents/list-incidents", + permissions: ["Security.Incident.*"], + }, + { + title: "Alerts", + path: "/security/incidents/list-alerts", + permissions: ["Security.Alert.*"], + }, + { + title: "MDO Alerts", + path: "/security/incidents/list-mdo-alerts", + permissions: ["Security.Alert.*"], + }, + { + title: "Check Alerts", + path: "/security/incidents/list-check-alerts", + permissions: ["Security.Alert.*"], + }, ], }, { title: "Defender", path: "/security/defender", + permissions: ["Security.Alert.*"], items: [ - { title: "Defender Status", path: "/security/defender/list-defender" }, + { + title: "Defender Status", + path: "/security/defender/list-defender", + permissions: ["Security.Alert.*"], + }, { title: "Defender Deployment", path: "/security/defender/deployment", + permissions: ["Security.Alert.*"], }, { title: "Vulnerabilities", path: "/security/defender/list-defender-tvm", + permissions: ["Security.Alert.*"], }, ], }, { title: "Reports", path: "/security/reports", + permissions: ["Tenant.DeviceCompliance.*"], items: [ { title: "Device Compliance", path: "/security/reports/list-device-compliance", + permissions: ["Tenant.DeviceCompliance.*"], + }, + ], + }, + { + title: "Safe Links", + path: "/security/safelinks", + permissions: ["Security.SafeLinksPolicy.*"], + items: [ + { + title: "Safe Links Policies", + path: "/security/safelinks/safelinks", + permissions: ["Security.SafeLinksPolicy.*"], + }, + { + title: "Safe Links Templates", + path: "/security/safelinks/safelinks-template", + permissions: ["Security.SafeLinksPolicy.*"], }, ], }, @@ -220,45 +363,125 @@ export const nativeMenuItems = [ ), + permissions: [ + "Endpoint.Application.*", + "Endpoint.Autopilot.*", + "Endpoint.MEM.*", + "Endpoint.Device.*", + "Endpoint.Device.Read", + ], items: [ { title: "Applications", path: "/endpoint/applications", + permissions: ["Endpoint.Application.*"], items: [ - { title: "Applications", path: "/endpoint/applications/list" }, - { title: "Application Queue", path: "/endpoint/applications/queue" }, + { + title: "Applications", + path: "/endpoint/applications/list", + permissions: ["Endpoint.Application.*"], + }, + { + title: "Application Queue", + path: "/endpoint/applications/queue", + permissions: ["Endpoint.Application.*"], + }, ], }, { title: "Autopilot", path: "/endpoint/autopilot", + permissions: ["Endpoint.Autopilot.*"], items: [ - { title: "Autopilot Devices", path: "/endpoint/autopilot/list-devices" }, - { title: "Add Autopilot Device", path: "/endpoint/autopilot/add-device" }, - { title: "Profiles", path: "/endpoint/autopilot/list-profiles" }, - { title: "Status Pages", path: "/endpoint/autopilot/list-status-pages" }, - { title: "Add Status Page", path: "/endpoint/autopilot/add-status-page" }, + { + title: "Autopilot Devices", + path: "/endpoint/autopilot/list-devices", + permissions: ["Endpoint.Autopilot.*"], + }, + { + title: "Add Autopilot Device", + path: "/endpoint/autopilot/add-device", + permissions: ["Endpoint.Autopilot.*"], + }, + { + title: "Profiles", + path: "/endpoint/autopilot/list-profiles", + permissions: ["Endpoint.Autopilot.*"], + }, + { + title: "Status Pages", + path: "/endpoint/autopilot/list-status-pages", + permissions: ["Endpoint.Autopilot.*"], + }, ], }, { title: "Device Management", path: "/endpoint/MEM", + permissions: ["Endpoint.MEM.*"], items: [ - { title: "Devices", path: "/endpoint/MEM/devices" }, - { title: "Configuration Policies", path: "/endpoint/MEM/list-policies" }, - { title: "Compliance Policies", path: "/endpoint/MEM/list-compliance-policies" }, - { title: "Protection Policies", path: "/endpoint/MEM/list-appprotection-policies" }, - { title: "Apply Policy", path: "/endpoint/MEM/add-policy" }, - { title: "Policy Templates", path: "/endpoint/MEM/list-templates" }, - { title: "Scripts", path: "/endpoint/MEM/list-scripts" }, + { + title: "Devices", + path: "/endpoint/MEM/devices", + permissions: ["Endpoint.Device.*"], + }, + { + title: "Configuration Policies", + path: "/endpoint/MEM/list-policies", + permissions: ["Endpoint.MEM.*"], + }, + { + title: "Compliance Policies", + path: "/endpoint/MEM/list-compliance-policies", + permissions: ["Endpoint.MEM.*"], + }, + { + title: "Protection Policies", + path: "/endpoint/MEM/list-appprotection-policies", + permissions: ["Endpoint.MEM.*"], + }, + { + title: "Policy Templates", + path: "/endpoint/MEM/list-templates", + permissions: ["Endpoint.MEM.*"], + }, + { + title: "Assignment Filters", + path: "/endpoint/MEM/assignment-filters", + permissions: ["Endpoint.MEM.*"], + }, + { + title: "Assignment Filter Templates", + path: "/endpoint/MEM/assignment-filter-templates", + permissions: ["Endpoint.MEM.*"], + }, + { + title: "Scripts", + path: "/endpoint/MEM/list-scripts", + permissions: ["Endpoint.MEM.*"], + }, ], }, { title: "Reports", path: "/endpoint/reports", + permissions: ["Endpoint.Device.*", "Endpoint.Autopilot.*"], items: [ - { title: "Analytics Device Score", path: "/endpoint/reports/analyticsdevicescore" }, - { title: "Work from anywhere", path: "/endpoint/reports/workfromanywhere" }, + { + title: "Analytics Device Score", + path: "/endpoint/reports/analyticsdevicescore", + permissions: ["Endpoint.Device.*"], + }, + { + title: "Work from anywhere", + path: "/endpoint/reports/workfromanywhere", + permissions: ["Endpoint.Device.*"], + }, + { + title: "Autopilot Deployments", + path: "/endpoint/reports/autopilot-deployment", + permissions: ["Endpoint.Autopilot.*"], + }, ], }, ], @@ -271,22 +494,44 @@ export const nativeMenuItems = [ ), + permissions: [ + "Sharepoint.Site.*", + "Sharepoint.Admin.*", + "Teams.Group.*", + "Teams.Activity.*", + "Teams.Voice.*", + ], items: [ { title: "OneDrive", path: "/teams-share/onedrive", + permissions: ["Sharepoint.Site.*"], }, { title: "SharePoint", path: "/teams-share/sharepoint", + permissions: ["Sharepoint.Admin.*"], }, { title: "Teams", path: "/teams-share/teams", + permissions: ["Teams.Group.*"], items: [ - { title: "Teams", path: "/teams-share/teams/list-team" }, - { title: "Teams Activity", path: "/teams-share/teams/teams-activity" }, - { title: "Business Voice", path: "/teams-share/teams/business-voice" }, + { + title: "Teams", + path: "/teams-share/teams/list-team", + permissions: ["Teams.Group.*"], + }, + { + title: "Teams Activity", + path: "/teams-share/teams/teams-activity", + permissions: ["Teams.Activity.*"], + }, + { + title: "Business Voice", + path: "/teams-share/teams/business-voice", + permissions: ["Teams.Voice.*"], + }, ], }, ], @@ -299,91 +544,202 @@ export const nativeMenuItems = [ ), + permissions: [ + "Exchange.Mailbox.*", + "Exchange.Contact.*", + "Exchange.SpamFilter.*", + "Exchange.TransportRule.*", + "Exchange.Connector.*", + "Exchange.ConnectionFilter.*", + "Exchange.Equipment.*", + "Exchange.Room.*", + "Exchange.SafeLinks.*", + "Exchange.Group.*", + "Exchange.RetentionPolicies.*", + ], items: [ { title: "Administration", path: "/email/administration", + permissions: ["Exchange.Mailbox.*"], items: [ - { title: "Mailboxes", path: "/email/administration/mailboxes" }, - { title: "Deleted Mailboxes", path: "/email/administration/deleted-mailboxes" }, - { title: "Mailbox Rules", path: "/email/administration/mailbox-rules" }, - { title: "Contacts", path: "/email/administration/contacts" }, - { title: "Quarantine", path: "/email/administration/quarantine" }, + { + title: "Mailboxes", + path: "/email/administration/mailboxes", + permissions: ["Exchange.Mailbox.*"], + }, + { + title: "Deleted Mailboxes", + path: "/email/administration/deleted-mailboxes", + permissions: ["Exchange.Mailbox.*"], + }, + { + title: "Mailbox Rules", + path: "/email/administration/mailbox-rules", + permissions: ["Exchange.Mailbox.*"], + }, + { + title: "Contacts", + path: "/email/administration/contacts", + permissions: ["Exchange.Contact.*"], + }, + { + title: "Contact Templates", + path: "/email/administration/contacts-template", + permissions: ["Exchange.Contact.*"], + }, + { + title: "Quarantine", + path: "/email/administration/quarantine", + permissions: ["Exchange.SpamFilter.*"], + }, + { + title: "Restricted Users", + path: "/email/administration/restricted-users", + permissions: ["Exchange.Mailbox.*"], + }, { title: "Tenant Allow/Block Lists", path: "/email/administration/tenant-allow-block-lists", + permissions: ["Exchange.SpamFilter.*"], + }, + { + title: "Retention Policies & Tags", + path: "/email/administration/exchange-retention/policies", + permissions: ["Exchange.RetentionPolicies.*"], }, ], }, { title: "Transport", path: "/email/transport", + permissions: ["Exchange.TransportRule.*"], items: [ - { title: "Transport rules", path: "/email/transport/list-rules" }, + { + title: "Transport rules", + path: "/email/transport/list-rules", + permissions: ["Exchange.TransportRule.*"], + }, { title: "Transport Templates", path: "/email/transport/list-templates", + permissions: ["Exchange.TransportRule.*"], + }, + { + title: "Connectors", + path: "/email/transport/list-connectors", + permissions: ["Exchange.Connector.*"], }, - { title: "Connectors", path: "/email/transport/list-connectors" }, { title: "Connector Templates", path: "/email/transport/list-connector-templates", + permissions: ["Exchange.Connector.*"], }, ], }, { title: "Spamfilter", path: "/email/spamfilter", + permissions: ["Exchange.SpamFilter.*"], items: [ - { title: "Spamfilter", path: "/email/spamfilter/list-spamfilter" }, - { title: "Spamfilter templates", path: "/email/spamfilter/list-templates" }, - { title: "Connection filter", path: "/email/spamfilter/list-connectionfilter" }, + { + title: "Spamfilter", + path: "/email/spamfilter/list-spamfilter", + permissions: ["Exchange.SpamFilter.*"], + }, + { + title: "Spamfilter templates", + path: "/email/spamfilter/list-templates", + permissions: ["Exchange.SpamFilter.*"], + }, + { + title: "Connection filter", + path: "/email/spamfilter/list-connectionfilter", + permissions: ["Exchange.ConnectionFilter.*"], + }, { title: "Connection filter templates", path: "/email/spamfilter/list-connectionfilter-templates", + permissions: ["Exchange.ConnectionFilter.*"], + }, + { + title: "Quarantine Policies", + path: "/email/spamfilter/list-quarantine-policies", + permissions: ["Exchange.SpamFilter.*"], }, ], }, { title: "Resource Management", path: "/email/resources/management", + permissions: ["Exchange.Equipment.*"], items: [ - { title: "Rooms", path: "/email/resources/management/list-rooms" }, - { title: "Room Lists", path: "/email/resources/management/room-lists" }, + { + title: "Equipment", + path: "/email/resources/management/equipment", + permissions: ["Exchange.Equipment.*"], + }, + { + title: "Rooms", + path: "/email/resources/management/list-rooms", + permissions: ["Exchange.Room.*"], + }, + { + title: "Room Lists", + path: "/email/resources/management/room-lists", + permissions: ["Exchange.Room.*"], + }, ], }, { title: "Reports", path: "/email/reports", + permissions: [ + "Exchange.Mailbox.*", + "Exchange.SpamFilter.*", + "Exchange.SafeLinks.*", + "Exchange.Group.*", + ], items: [ { title: "Mailbox Statistics", path: "/email/reports/mailbox-statistics", + permissions: ["Exchange.Mailbox.*"], + }, + { + title: "Mailbox Activity", + path: "/email/reports/mailbox-activity", + permissions: ["Exchange.Mailbox.*"], }, { title: "Mailbox Client Access Settings", path: "/email/reports/mailbox-cas-settings", + permissions: ["Exchange.Mailbox.*"], }, { title: "Anti-Phishing Filters", path: "/email/reports/antiphishing-filters", + permissions: ["Exchange.SpamFilter.*"], }, - { title: "Malware Filters", path: "/email/reports/malware-filters" }, { - title: "Safe Links Filters", - path: "/email/reports/safelinks-filters", + title: "Malware Filters", + path: "/email/reports/malware-filters", + permissions: ["Exchange.SpamFilter.*"], }, { title: "Safe Attachments Filters", path: "/email/reports/safeattachments-filters", + permissions: ["Exchange.SafeLinks.*"], }, { title: "Shared Mailbox with Enabled Account", path: "/email/reports/SharedMailboxEnabledAccount", + permissions: ["Exchange.Mailbox.*"], }, { title: "Global Address List", path: "/email/reports/global-address-list", + permissions: ["Exchange.Group.*"], }, ], }, @@ -397,60 +753,104 @@ export const nativeMenuItems = [ ), + permissions: [ + "CIPP.*", + "Tenant.Administration.*", + "Tenant.Application.*", + "Tenant.DomainAnalyser.*", + "Exchange.Mailbox.*", + ], items: [ { title: "Tenant Tools", path: "/tenant/tools", + permissions: ["Tenant.Administration.*"], items: [ { title: "Graph Explorer", path: "/tenant/tools/graph-explorer", + permissions: ["Tenant.Administration.*"], }, { title: "Application Approval", path: "/tenant/tools/appapproval", + permissions: ["Tenant.Application.*"], + }, + { + title: "Tenant Lookup", + path: "/tenant/tools/tenantlookup", + permissions: ["Tenant.Administration.*"], }, - { title: "Tenant Lookup", path: "/tenant/tools/tenantlookup" }, - { title: "IP Database", path: "/tenant/tools/geoiplookup" }, + { + title: "IP Database", + path: "/tenant/tools/geoiplookup", + permissions: ["CIPP.Core.*"], + }, { title: "Individual Domain Check", path: "/tenant/tools/individual-domains", + permissions: ["Tenant.DomainAnalyser.*"], }, ], }, { title: "Email Tools", path: "/email/tools", + permissions: ["Exchange.Mailbox.*"], items: [ - { title: "Message Trace", path: "/email/tools/message-trace" }, - { title: "Mailbox Restores", path: "/email/tools/mailbox-restores" }, - { title: "Message Viewer", path: "/email/tools/message-viewer" }, + { + title: "Message Trace", + path: "/email/tools/message-trace", + permissions: ["Exchange.Mailbox.*"], + }, + { + title: "Mailbox Restores", + path: "/email/tools/mailbox-restores", + permissions: ["Exchange.Mailbox.*"], + }, + { + title: "Message Viewer", + path: "/email/tools/message-viewer", + permissions: ["Exchange.Mailbox.*"], + }, ], }, { title: "Dark Web Tools", path: "/tools/darkweb", + permissions: ["CIPP.Core.*"], items: [ - { title: "Tenant Breach Lookup", path: "/tools/tenantbreachlookup" }, - { title: "Breach Lookup", path: "/tools/breachlookup" }, + { + title: "Tenant Breach Lookup", + path: "/tools/tenantbreachlookup", + permissions: ["CIPP.Core.*"], + }, + { + title: "Breach Lookup", + path: "/tools/breachlookup", + permissions: ["CIPP.Core.*"], + }, ], }, { title: "Template Library", path: "/tools/templatelib", roles: ["editor", "admin", "superadmin"], + permissions: ["CIPP.Core.*"], }, { title: "Community Repositories", path: "/tools/community-repos", roles: ["editor", "admin", "superadmin"], + permissions: ["CIPP.Core.*"], }, { title: "Scheduler", path: "/cipp/scheduler", roles: ["editor", "admin", "superadmin"], + permissions: ["CIPP.Scheduler.*"], }, ], }, @@ -462,35 +862,68 @@ export const nativeMenuItems = [ ), + permissions: [ + "CIPP.*", // Pattern matching - matches any CIPP permission + ], items: [ - { title: "Application Settings", path: "/cipp/settings", roles: ["admin", "superadmin"] }, - { title: "Logbook", path: "/cipp/logs", roles: ["editor", "admin", "superadmin"] }, - { title: "SAM Setup Wizard", path: "/onboarding", roles: ["admin", "superadmin"] }, - { title: "Integrations", path: "/cipp/integrations", roles: ["admin", "superadmin"] }, + { + title: "Application Settings", + path: "/cipp/settings", + roles: ["admin", "superadmin"], + permissions: ["CIPP.AppSettings.*"], + }, + { + title: "Logbook", + path: "/cipp/logs", + roles: ["editor", "admin", "superadmin"], + permissions: ["CIPP.Core.*"], + }, + { + title: "Setup Wizard", + path: "/onboardingv2", + roles: ["admin", "superadmin"], + permissions: ["CIPP.Core.*"], + }, + { + title: "Integrations", + path: "/cipp/integrations", + roles: ["admin", "superadmin"], + permissions: ["CIPP.Extension.*"], + }, { title: "Custom Data", path: "/cipp/custom-data/directory-extensions", - roles: ["admin", "superadmin"] + roles: ["admin", "superadmin"], + permissions: ["CIPP.Core.*"], }, { title: "Advanced", roles: ["superadmin"], + permissions: ["CIPP.SuperAdmin.*"], items: [ - { title: "Super Admin", path: "/cipp/super-admin/tenant-mode", roles: ["superadmin"] }, + { + title: "Super Admin", + path: "/cipp/super-admin/tenant-mode", + roles: ["superadmin"], + permissions: ["CIPP.SuperAdmin.*"], + }, { title: "Exchange Cmdlets", path: "/cipp/advanced/exchange-cmdlets", roles: ["superadmin"], + permissions: ["CIPP.SuperAdmin.*"], }, { title: "Timers", path: "/cipp/advanced/timers", roles: ["superadmin"], + permissions: ["CIPP.SuperAdmin.*"], }, { title: "Table Maintenance", path: "/cipp/advanced/table-maintenance", roles: ["superadmin"], + permissions: ["CIPP.SuperAdmin.*"], }, ], }, diff --git a/src/layouts/footer.js b/src/layouts/footer.js index 07115518e388..d6bd01193b56 100644 --- a/src/layouts/footer.js +++ b/src/layouts/footer.js @@ -1,4 +1,4 @@ -import { Box, Container, Divider, Typography } from "@mui/material"; +import { Container } from "@mui/material"; export const Footer = () => { diff --git a/src/layouts/index.js b/src/layouts/index.js index ddd1b2460645..5813fc0ee70e 100644 --- a/src/layouts/index.js +++ b/src/layouts/index.js @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState, useRef } from "react"; import { usePathname } from "next/navigation"; import { Alert, Button, Dialog, DialogContent, DialogTitle, useMediaQuery } from "@mui/material"; import { styled } from "@mui/material/styles"; @@ -12,10 +12,9 @@ import { useDispatch } from "react-redux"; import { showToast } from "../store/toasts"; import { Box, Container, Grid } from "@mui/system"; import { CippImageCard } from "../components/CippCards/CippImageCard"; -import Page from "../pages/onboarding"; +import Page from "../pages/onboardingv2"; import { useDialog } from "../hooks/use-dialog"; import { nativeMenuItems } from "/src/layouts/config"; -import { keepPreviousData } from "@tanstack/react-query"; const SIDE_NAV_WIDTH = 270; const SIDE_NAV_PINNED_WIDTH = 50; @@ -31,13 +30,9 @@ const useMobileNav = () => { } }, [open]); - useEffect( - () => { - handlePathnameChange(); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [pathname] - ); + useEffect(() => { + handlePathnameChange(); + }, [pathname]); const handleOpen = useCallback(() => { setOpen(true); @@ -80,18 +75,27 @@ export const Layout = (props) => { const [userSettingsComplete, setUserSettingsComplete] = useState(false); const [fetchingVisible, setFetchingVisible] = useState([]); const [menuItems, setMenuItems] = useState(nativeMenuItems); + const lastUserSettingsUpdate = useRef(null); const currentTenant = settings?.currentTenant; - const currentRole = ApiGetCall({ + const [hideSidebar, setHideSidebar] = useState(false); + + const swaStatus = ApiGetCall({ url: "/.auth/me", - queryKey: "authmecipp", + queryKey: "authmeswa", staleTime: 120000, refetchOnWindowFocus: true, }); - const [hideSidebar, setHideSidebar] = useState(false); + + const currentRole = ApiGetCall({ + url: "/api/me", + queryKey: "authmecipp", + waiting: !swaStatus.isSuccess || swaStatus.data?.clientPrincipal === null, + }); useEffect(() => { if (currentRole.isSuccess && !currentRole.isFetching) { const userRoles = currentRole.data?.clientPrincipal?.userRoles; + const userPermissions = currentRole.data?.permissions; if (!userRoles) { setMenuItems([]); setHideSidebar(true); @@ -100,12 +104,43 @@ export const Layout = (props) => { const filterItemsByRole = (items) => { return items .map((item) => { + // role if (item.roles && item.roles.length > 0) { const hasRole = item.roles.some((requiredRole) => userRoles.includes(requiredRole)); if (!hasRole) { return null; } } + + // Check permission with pattern matching support + if (item.permissions && item.permissions.length > 0) { + const hasPermission = userPermissions?.some((userPerm) => { + return item.permissions.some((requiredPerm) => { + // Exact match + if (userPerm === requiredPerm) { + return true; + } + + // Pattern matching - check if required permission contains wildcards + if (requiredPerm.includes("*")) { + // Convert wildcard pattern to regex + const regexPattern = requiredPerm + .replace(/\./g, "\\.") // Escape dots + .replace(/\*/g, ".*"); // Convert * to .* + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(userPerm); + } + + return false; + }); + }); + if (!hasPermission) { + return null; + } + } else { + return null; + } + // check sub-items if (item.items && item.items.length > 0) { const filteredSubItems = filterItemsByRole(item.items).filter(Boolean); return { ...item, items: filteredSubItems }; @@ -115,11 +150,24 @@ export const Layout = (props) => { }) .filter(Boolean); }; - const filteredMenu = filterItemsByRole(nativeMenuItems); setMenuItems(filteredMenu); + } else if ( + swaStatus.isLoading || + swaStatus.data?.clientPrincipal === null || + swaStatus.data === undefined || + currentRole.isLoading + ) { + setHideSidebar(true); } - }, [currentRole.isSuccess]); + }, [ + currentRole.isSuccess, + swaStatus.data, + swaStatus.isLoading, + currentRole.data?.clientPrincipal?.userRoles, + currentRole.data?.permissions, + currentRole.isFetching, + ]); const handleNavPin = useCallback(() => { settings.handleUpdate({ @@ -132,38 +180,44 @@ export const Layout = (props) => { const userSettingsAPI = ApiGetCall({ url: "/api/ListUserSettings", queryKey: "userSettings", - refetchOnMount: false, - refetchOnReconnect: false, - keepPreviousData: true, }); useEffect(() => { - if (userSettingsAPI.isSuccess && !userSettingsAPI.isFetching && !userSettingsComplete) { - //if userSettingsAPI.data contains offboardingDefaults.user, delete that specific key. - if (userSettingsAPI.data.offboardingDefaults?.user) { - delete userSettingsAPI.data.offboardingDefaults.user; - } - if (userSettingsAPI?.data?.currentTheme) { - delete userSettingsAPI.data.currentTheme; - } - // get current devtools settings - var showDevtools = settings.showDevtools; - // get current bookmarks - var bookmarks = settings.bookmarks; + if (userSettingsAPI.isSuccess && !userSettingsAPI.isFetching) { + // Only update if the data has actually changed (using dataUpdatedAt as a proxy) + const dataUpdatedAt = userSettingsAPI.dataUpdatedAt; + if (dataUpdatedAt && dataUpdatedAt !== lastUserSettingsUpdate.current) { + //if userSettingsAPI.data contains offboardingDefaults.user, delete that specific key. + if (userSettingsAPI.data.offboardingDefaults?.user) { + delete userSettingsAPI.data.offboardingDefaults.user; + } + if (userSettingsAPI.data.offboardingDefaults?.keepCopy) { + delete userSettingsAPI.data.offboardingDefaults.keepCopy; + } + if (userSettingsAPI?.data?.currentTheme) { + delete userSettingsAPI.data.currentTheme; + } + // get current devtools settings + var showDevtools = settings.showDevtools; + // get current bookmarks + var bookmarks = settings.bookmarks; + + settings.handleUpdate({ + ...userSettingsAPI.data, + bookmarks, + showDevtools, + }); - settings.handleUpdate({ - ...userSettingsAPI.data, - bookmarks, - showDevtools, - }); - setUserSettingsComplete(true); + // Track this update and set completion status + lastUserSettingsUpdate.current = dataUpdatedAt; + setUserSettingsComplete(true); + } } }, [ userSettingsAPI.isSuccess, userSettingsAPI.data, userSettingsAPI.isFetching, - userSettingsComplete, - settings, + userSettingsAPI.dataUpdatedAt, ]); const version = ApiGetCall({ @@ -181,11 +235,11 @@ export const Layout = (props) => { }); useEffect(() => { - if (version.isFetched && !alertsAPI.isFetched) { + if (!hideSidebar && version.isFetched && !alertsAPI.isFetched) { alertsAPI.waiting = true; alertsAPI.refetch(); } - }, [version, alertsAPI]); + }, [version, alertsAPI, hideSidebar]); useEffect(() => { if (alertsAPI.isSuccess && !alertsAPI.isFetching) { @@ -238,11 +292,32 @@ export const Layout = (props) => { }} > + + Setup Wizard + + + + + {!setupCompleted && ( + + + + Setup has not been completed. + + + + + )} {(currentTenant === "AllTenants" || !currentTenant) && !allTenantsSupport ? ( - + { ) : ( - <> - - Setup Wizard - - - - - {!setupCompleted && ( - - - - Setup has not been completed. - - - - - )} - {children} - + <>{children} )}