Repository: CSCfi/rems
Pull request #3153 (2023-05-08 16:43:24)

Disable commands by workflow
implements #3131

Workflow now has optional field `:disable-commands` which disables application commands dynamically. Workflow can disable commands by:
- application state (when state is any OR matches selected states), and
- user role (when role is any OR matches selected roles).

User role and application state rules are combined with AND, so both must match.

Example use cases include
- prevent applicants from closing a returned application
- prevent applicants from inviting members to application

# Checklist for author

Remove items that aren't applicable, check items that are done.

## Reviewability
- [x] Link to issue
- [x] Consider adding screenshots for ease of review

## Backwards compatibility
- [x] API is completely new (`api/applications/commands`)

## Documentation
- [x] Update changelog if necessary
- [x] API is documented and shows up in Swagger UI
- [ ] Components are added to guide page (at present, guide page does not exist for workflow admin pages)
- [ ] Update manual/ (if applicable) (`owner.md` could use a description of the feature?)

## Testing
- [x] Complex logic is unit tested
- [ ] Valuable features are integration / browser / acceptance tested automatically

## Follow-up
- [x] New tasks are created for pending or remaining tasks #3163 

# Screenshots

## 1. admin create/edit workflow when no disable command rules
<img width="757" alt="Screenshot 2023-05-30 at 18 17 19" src="https://github.com/CSCfi/rems/assets/794087/ad8aa5cc-98de-4807-b354-7fd84ff34583">

## 2. admin create/edit workflow when disable command rules
<img width="813" alt="Screenshot 2023-05-30 at 18 17 55" src="https://github.com/CSCfi/rems/assets/794087/618a2ced-ed47-4adb-a07b-5bfee6f79d7f">
<img width="806" alt="Screenshot 2023-05-30 at 18 18 45" src="https://github.com/CSCfi/rems/assets/794087/7d3d40d5-60a4-4bd7-9e63-04862423a18b">

## 3. admin workflow view when disable command rules
<img width="819" alt="Screenshot 2023-05-30 at 18 18 56" src="https://github.com/CSCfi/rems/assets/794087/7f1095a2-723b-4db2-803c-247f5fc3dd75">



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 878bc3505..70cebe751 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ Changes since v2.32
 
 ### Additions
 - PDF output can be customized a little. This enables changing the font, which should fix missing diacritics. (#3158)
+- Workflows can now disable commands dynamically by application state, user role, or both. Rules can be set in workflow administration page under disable commands field. (#3131)
 
 ### Fixes
 - Label and header fields have URLs made into links. (#3155)
diff --git a/resources/translations/en.edn b/resources/translations/en.edn
index 6875a04ea..b2de56d13 100644
--- a/resources/translations/en.edn
+++ b/resources/translations/en.edn
@@ -76,6 +76,7 @@
                    :add "Add"
                    :added-at "Added at"
                    :added-by "Added by"
+                   :application-state "Application state"
                    :archive "Archive"
                    :back "Back"
                    :blacklist "Blacklist"
@@ -101,6 +102,8 @@
                    :delete-confirmation "Do you really want to delete?"
                    :description "Description"
                    :disable "Disable"
+                   :disabled-command "Disabled action"
+                   :disabled-commands "Disabled actions"
                    :disabled-and-archived-explanation "Disabling hides an item from applicants. Archiving also hides it in the administration view."
                    :display-archived "Display archived"
                    :display-order "Display order"
@@ -156,9 +159,11 @@
                    :unarchive "Unarchive"
                    :user "User"
                    :userid "User id"
+                   :user-role "User role"
                    :view "View"
                    :workflow "Workflow"
-                   :workflows "Workflows"}
+                   :workflows "Workflows"
+                   :workflow-disabled-commands-explanation [:p "This workflow disables actions for users when conditions apply. Actions, application states and user roles can be found on " [:a {:href "https://github.com/CSCfi/rems/blob/master/docs/application-permissions.md" :target :_blank} "REMS Github page."]]}
   :applicant-info {:applicant "Applicant"
                    :applicants "Applicants"
                    :email "Email"
@@ -290,6 +295,35 @@
              :show-more "Show more"}
   :change-form {:change-form-intro "You are about to change the form of the following catalogue items. The changes will be made one at a time, so that each old item is terminated and a new copy will use the chosen form."
                 :form-selection "Form"}
+  :commands {:accept-invitation "Accept invitation"
+             :accept-licenses "Accept the terms of use"
+             :add-licenses "Add terms of use"
+             :add-member "Add member"
+             :approve "Approve"
+             :assign-external-id "Assign external id"
+             :change-applicant "Make applicant"
+             :change-resources "Change resources"
+             :close "Close application"
+             :copy-as-new "Copy as a new application"
+             ;; :create
+             :decide "Decide"
+             :delete "Delete draft"
+             :invite-decider "Invite decider"
+             :invite-member "Invite member"
+             :invite-reviewer "Invite reviewer"
+             :redact-attachments "Redact attachments"
+             :reject "Reject"
+             :remark "Remark"
+             :remove-member "Remove member"
+             :request-decision "Request decision"
+             :request-review "Request review"
+             :return "Return to applicant"
+             :review "Review"
+             :revoke "Revoke entitlement"
+             :save-draft "Save as draft"
+             ;; :send-expiration-notifications
+             :submit "Send application"
+             :uninvite-member "Uninvite member"}
   :create-form {:add-column "Add column"
                 :add-form-field "Add field"
                 :add-option "Add option"
@@ -347,17 +381,21 @@
   :create-resource {:licenses-selection "Licenses"
                     :resid "Resource identifier"
                     :resid-placeholder "urn:test:fi:some-uuid"}
-  :create-workflow {:decider-workflow "Decider workflow"
+  :create-workflow {:create-new-rule "New rule"
+                    :decider-workflow "Decider workflow"
                     :decider-workflow-description "The handler does not have the authority to approve or reject, but only a separate decider has."
                     :default-workflow "Default workflow"
                     :default-workflow-description "The handler is in full control of the application process. The handler may request reviews and decisions from others as needed."
+                    :disable-commands "Disable actions"
                     :handlers "Handlers"
                     :master-workflow "Master workflow"
                     :master-workflow-description "ONLY FOR TESTING. Union of all possible workflows."
+                    :rule "Rule"
                     :title "Title"}
   :dropdown {:no-results "No results"
              :placeholder "Select..."
-             :loading "Loading..."}
+             :loading "Loading..."
+             :placeholder-any-selection "Any"}
   :duo {:fields {:more-info "Additional info for applicant (optional)"}
         :no-duo-codes "No Data Use Ontology codes"
         :restriction {:collaboration "Primary study investigator(s)"
@@ -516,9 +554,10 @@
   :item-lists {:move-down "Move down"
                :move-up "Move up"
                :remove "Remove"}
-  :label {:default "%1: %2"
-          :dash "%1 – %2"
+  :label {:dash "%1 – %2"
+          :default "%1: %2"
           :long "%1: %2 – %3"
+          :optional "%1 (optional)"
           :parens "%1 (%2)"}
   :link {:download-file "Download file"
          :opens-in-new-window "Opens in a new window"}
@@ -579,6 +618,14 @@
             :settings "Settings"
             :warning-about-missing-email [:<> "You will need to " [:a {:href "/profile"} "add an email address to your settings"] ", so that you can receive notifications about your applications."]
             :your-details "Your user details"}
+  :roles {:applicant "Applicant"
+          :decider "Decider"
+          :handler "Handler"
+          :member "Member"
+          :past-decider "Past decider"
+          :past-reviewer "Past reviewer"
+          :reviewer "Reviewer"
+          :technical-role "Technical role"}
   :search {:clear-search "Clear search"
            :example-searches "Example searches"
            :learn-more "Learn more"
@@ -592,6 +639,7 @@
           :show-all-n-rows "Show all %1 rows"}
   :unauthorized-page {:unauthorized "Unauthorized"
                       :you-are-unauthorized "You're unauthorized to access this page."}
+  :unknown "Unknown"
   :forbidden-page {:forbidden "Forbidden"
                    :you-are-forbidden "Access to the page is forbidden."}
   :error-page {:title "Problem"}
diff --git a/resources/translations/fi.edn b/resources/translations/fi.edn
index a211d15a0..0952a7247 100644
--- a/resources/translations/fi.edn
+++ b/resources/translations/fi.edn
@@ -74,6 +74,7 @@
                    :add "Lisää"
                    :added-at "Lisätty"
                    :added-by "Lisääjä"
+                   :application-state "Hakemuksen tila"
                    :archive "Arkistoi"
                    :back "Takaisin"
                    :blacklist "Kieltolista"
@@ -99,6 +100,8 @@
                    :delete-confirmation "Haluatko todella poistaa?"
                    :description "Kuvaus"
                    :disable "Poista käytöstä"
+                   :disabled-command "Poistettu toiminto"
+                   :disabled-commands "Poistetut toiminnot"
                    :disabled-and-archived-explanation "Käytöstä poistaminen piilottaa asian käyttäjiltä. Arkistoiminen piilottaa sen myös ylläpitonäkymästä."
                    :display-archived "Näytä arkistoidut"
                    :display-order "Näyttöjärjestys"
@@ -152,9 +155,11 @@
                    :unarchive "Palauta arkistosta"
                    :user "Käyttäjä"
                    :userid "Käyttäjätunnus"
+                   :user-role "Käyttäjän rooli"
                    :view "Avaa"
                    :workflow "Työvuo"
-                   :workflows "Työvuot"}
+                   :workflows "Työvuot"
+                   :workflow-disabled-commands-explanation [:p "Tämä työvuo poistaa toimintoja käyttäjiltä ehtojen täyttyessä. Toiminnot, hakemusten tilat ja käyttäjien roolit löytyvät " [:a {:href "https://github.com/CSCfi/rems/blob/master/docs/application-permissions.md" :target :_blank} "REMSin Github-sivulta."]]}
   :applicant-info {:applicant "Hakija"
                    :applicants "Hakijat"
                    :email "Sähköposti"
@@ -269,6 +274,35 @@
              :show-more "Näytä lisää"}
   :change-form {:change-form-intro "Olet muuttamassa seuraavien aineistojen lomaketta. Muutokset tehdään yksi kerrallaan siten, että vanha aineisto poistetaan käytöstä ja uusi kopio käyttää valitsemaasi lomaketta."
                 :form-selection "Lomake"}
+  :commands {:accept-invitation "Hyväksy kutsu"
+             :accept-licenses "Hyväksy käyttöehdot"
+             :add-licenses "Lisää käyttöehto"
+             :add-member "Lisää jäsen"
+             :approve "Hyväksy"
+             :assign-external-id "Aseta tunniste"
+             :change-applicant "Tee hakija"
+             :change-resources "Muuta resursseja"
+             :close "Sulje hakemus"
+             :copy-as-new "Kopioi uudeksi hakemukseksi"
+             ;; :create
+             :decide "Päätä"
+             :delete "Poista luonnos"
+             :invite-decider "Kutsu päättäjä"
+             :invite-member "Kutsu jäsen"
+             :invite-reviewer "Kutsu katselmoija"
+             :redact-attachments "Muokkaa liitetiedostoja"
+             :reject "Hylkää"
+             :remark "Kirjaa huomio"
+             :remove-member "Poista jäsen"
+             :request-decision "Pyydä päätöstä"
+             :request-review "Pyydä katselmointia"
+             :return "Palauta hakijalle"
+             :review "Katselmoi"
+             :revoke "Peru käyttöoikeus"
+             :save-draft "Tallenna"
+             ;; :send-expiration-notifications
+             :submit "Lähetä hakemus"
+             :uninvite-member "Poista henkilö"}
   :create-form {:add-column "Lisää sarake"
                 :add-form-field "Lisää kenttä"
                 :add-option "Lisää vaihtoehto"
@@ -322,17 +356,21 @@
   :create-resource {:licenses-selection "Lisenssit"
                     :resid "Resurssin tunniste"
                     :resid-placeholder "urn:test:fi:some-uuid"}
-  :create-workflow {:decider-workflow "Päättäjätyövuo"
+  :create-workflow {:create-new-rule "Uusi sääntö"
+                    :decider-workflow "Päättäjätyövuo"
                     :decider-workflow-description "Käsittelijällä ei ole valtuutta hyväksyä tai hylätä hakemusta, vaan vain erillisellä hyväksyjällä on."
                     :default-workflow "Oletustyövuo"
                     :default-workflow-description "Hakemus on täysin käsittelijän hallinnassa. Käsittelijä voi pyytää toisilta katselmointeja ja päätöksiä tarpeen mukaan."
+                    :disable-commands "Poista toimintoja käytöstä"
                     :handlers "Käsittelijät"
                     :master-workflow "Master-työvuo"
                     :master-workflow-description "VAIN TESTAUKSEEN. Unioni kaikista mahdollista työvoista."
+                    :rule "Sääntö"
                     :title "Työvuon nimi"}
   :dropdown {:no-results "Ei tuloksia"
              :placeholder "Valitse..."
-             :loading "Ladataan..."}
+             :loading "Ladataan..."
+             :placeholder-any-selection "Mikä tahansa"}
   :duo {:fields {:more-info "Lisätietoa hakijalle (valinnainen)"}
         :no-duo-codes "Ei DUO-koodeja"
         :restriction {:collaboration "Tutkimusryhmän johtaja(t)"
@@ -473,9 +511,10 @@
   :item-lists {:move-down "Siirrä alas"
                :move-up "Siirrä ylös"
                :remove "Poista"}
-  :label {:default "%1: %2"
-          :dash "%1 – %2"
+  :label {:dash "%1 – %2"
+          :default "%1: %2"
           :long "%1: %2 – %3"
+          :optional "%1 (valinnainen)"
           :parens "%1 (%2)"}
   :link {:download-file "Lataa tiedosto"
          :opens-in-new-window "Aukeaa uuteen ikkunaan"}
@@ -535,6 +574,14 @@
             :settings "Asetukset"
             :warning-about-missing-email [:<> "Sinun on " [:a {:href "/profile"} "lisättävä sähköpostiosoite asetuksiisi"] ", jotta voisit vastaanottaa ilmoituksia hakemuksistasi."]
             :your-details "Käyttäjätietosi"}
+  :roles {:applicant "Hakija"
+          :decider "Päättäjä"
+          :handler "Käsittelijä"
+          :member "Jäsen"
+          :past-decider "Aikaisempi päättäjä"
+          :past-reviewer "Aikaisempi katselmoija"
+          :reviewer "Katselmoija"
+          :technical-role "Tekninen rooli"}
   :search {:clear-search "Tyhjennä hakuehdot"
            :example-searches "Esimerkkihakuja"
            :learn-more "Opi lisää"
@@ -547,6 +594,7 @@
           :show-all-n-rows "Näytä kaikki %1 riviä"}
   :unauthorized-page {:unauthorized "Ei käyttöoikeutta"
                       :you-are-unauthorized "Sinulla ei ole tämän sivun käyttöoikeutta."}
+  :unknown "Tuntematon"
   :forbidden-page {:forbidden "Kielletty"
                    :you-are-forbidden "Sivun käyttö on kielletty."}
   :error-page {:title "Ongelma"}
diff --git a/resources/translations/sv.edn b/resources/translations/sv.edn
index 20a30bc69..e9564db8d 100644
--- a/resources/translations/sv.edn
+++ b/resources/translations/sv.edn
@@ -15,7 +15,7 @@
             :approve "Godkänn"
             :approve-end-date "Datum då rättigheterna upphör (valfritt)"
             :approve-reject "Godkänn eller avslå"
-            :assign-external-id "Ange externt id" ;; ELLER Ange id för den resurs ansökan (vai arbetsflödet gäller?
+            :assign-external-id "Ange externt id" ; ELLER Ange id för den resurs ansökan (vai arbetsflödet gäller?
             :assign-external-id-info "Externt id"
             :bundling-intro [:div.intro [:p "Notera att endast resurser med gemensam ansökningsblankett och gemensamt arbetsflöde kan kopplas samman."]]
             :cancel "Avbryt"
@@ -74,6 +74,7 @@
                    :add "Lägg till"
                    :added-at "Tillagd"
                    :added-by "Tillagd av"
+                   :application-state "Ansökningsstatus"
                    :archive "Arkivera"
                    :back "Tillbaka"
                    :blacklist "Svartlista"
@@ -100,6 +101,8 @@
                    :description "Rubrik"
                    :disable "Avaktivera"
                    :disabled-and-archived-explanation "Att avaktivera ett föremål gör att det döljs för användarna. Att arkivera det gör att det även döljs från administrationsvyn."
+                   :disabled-command "Avaktiverad funktion"
+                   :disabled-commands "Avaktiverade funktioner"
                    :display-archived "Visa arkiverade"
                    :display-order "Visningsordning"
                    :edit "Ändra"
@@ -152,9 +155,11 @@
                    :unarchive "Avarkivera"
                    :user "Användare"
                    :userid "Användarnamn"
+                   :user-role "Användarensroll"
                    :view "Visa"
                    :workflow "Arbetsflöde"
-                   :workflows "Arbetsflöden"}
+                   :workflows "Arbetsflöden"
+                   :workflow-disabled-commands-explanation [:p "Detta arbetsflöde avaktiverar funktioner för användarna när villkoren uppfylls. Funktioner, ansökningar statusar och användarroller hittas på " [:a {:href "https://github.com/CSCfi/rems/blob/master/docs/application-permissions.md" :target :_blank} "REMS Github-sidan."]]}
   :applicant-info {:applicant "Sökande"
                    :applicants "Sökande"
                    :email "E-postadress"
@@ -269,6 +274,35 @@
              :show-more "Visa mer"}
   :change-form {:change-form-intro "Du ändrar blanketten för resurserna nedan. Förändringarna görs en åt gången genom att den gamla resursen tas ur bruk och ersätts av en kopia som använder blanketten du väljer nedan."
                 :form-selection "Blankett"}
+  :commands {:accept-invitation "Acceptera inbjudan"
+             :accept-licenses "Acceptera licenserna"
+             :add-licenses "Lägg till en licens"
+             :add-member "Lägg till deltagare"
+             :approve "Godkänn"
+             :assign-external-id "Ange externt id"
+             :change-applicant "Gör till sökanden"
+             :change-resources "Ändra resurser"
+             :close "Stäng ansökan"
+             :copy-as-new "Kopiera som nytt"
+             ;; :create
+             :decide "Avgör"
+             :delete "Radera utkast"
+             :invite-decider "Bjud in beslutare"
+             :invite-member "Bjud in deltagare"
+             :invite-reviewer "Bjud in granskare"
+             :redact-attachments "Ändra bilagor"
+             :reject "Avslå"
+             :remark "Gör anteckning"
+             :remove-member "Ta bort deltagare"
+             :request-decision "Begär beslut"
+             :request-review "Begär granskning"
+             :return "Sänd tillbaka till sökande"
+             :review "Granska"
+             :revoke "Återkalla rättigheter"
+             :save-draft "Spara utkast"
+             ;; :send-expiration-notifications
+             :submit "Lämna in ansökningen"
+             :uninvite-member "Ta bort deltagare"}
   :create-form {:add-column "Lägg till kolumn"
                 :add-form-field "Lägg till fält"
                 :add-option "Lägg till alternativ"
@@ -322,17 +356,21 @@
   :create-resource {:licenses-selection "Licenser"
                     :resid "Resurs-id"
                     :resid-placeholder "urn:test:fi:some-uuid"}
-  :create-workflow {:decider-workflow "Arbetsflöde för beslutsfattande"
+  :create-workflow {:create-new-rule "Ny regel"
+                    :decider-workflow "Arbetsflöde för beslutsfattande"
                     :decider-workflow-description "Handläggaren har inte rätt att godkänna eller förkasta ansökan. Endast beslutsfattare kan göra detta."
                     :default-workflow "Standard"
                     :default-workflow-description "Ansökan kontrolleras helt av handläggaren. Handläggaren kan vid behov be andra att granska eller ta beslut om ansökan."
+                    :disable-commands "Avaktivera funktioner"
                     :handlers "Handläggare"
                     :master-workflow "Master-arbetsflöde"
                     :master-workflow-description "ENDAST FÖR TESTNING. Sammanslagning av alla möjliga arbetsflöden."
+                    :rule "Regel"
                     :title "Arbetsflödets namn"}
   :dropdown {:no-results "Inga resultat"
              :placeholder "Välj..."
-             :loading "Läser in..."}
+             :loading "Läser in..."
+             :placeholder-any-selection "Vad som helst"}
   :duo {:fields {:more-info "Tilläggsinformation till sökande (frivillig)"}
         :no-duo-codes "Inga DUO-koder"
         :restriction {:collaboration "Forskningsgruppledare"
@@ -473,9 +511,10 @@
   :item-lists {:move-down "Flytta nedåt"
                :move-up "Flytta uppåt"
                :remove "Ta bort"}
-  :label {:default "%1: %2"
-          :dash "%1 – %2"
+  :label {:dash "%1 – %2"
+          :default "%1: %2"
           :long "%1: %2 – %3"
+          :optional "%1 (valfritt)"
           :parens "%1 (%2)"}
   :link {:download-file "Ladda ner fil"
          :opens-in-new-window "Öppnas i nytt fönster"}
@@ -535,6 +574,14 @@
             :settings "Inställningar"
             :warning-about-missing-email [:<> "Du måste " [:a {:href "/profile"} "lägga till din e-postadress i dina inställningar"] ", för att kunna ta emot meddelanden om din ansökan."]
             :your-details "Dina användardetaljer"} ; TODO check
+  :roles {:applicant "Sökande"
+          :decider "Beslutare"
+          :handler "Handläggare"
+          :member "Deltagare"
+          :past-decider "Tidigare beslutare"
+          :past-reviewer "Tidigare granskare"
+          :reviewer "Granskare"
+          :technical-role "Teknisk roll"}
   :search {:clear-search "Töm söktermer"
            :example-searches "Exempel på sökningar"
            :learn-more "Läs mer"
@@ -547,6 +594,7 @@
           :show-all-n-rows "Visa alla %1 rader"}
   :unauthorized-page {:unauthorized "Ingen tillgång"
                       :you-are-unauthorized "Du har inte tillgång till denna sida."}
+  :unknown "Okänd"
   :forbidden-page {:forbidden "Förbjuden"
                    :you-are-forbidden "Innehåll förbjudet."}
   :error-page {:title "Problem"}
diff --git a/src/clj/rems/api/applications.clj b/src/clj/rems/api/applications.clj
index dff510010..f332ed4b5 100644
--- a/src/clj/rems/api/applications.clj
+++ b/src/clj/rems/api/applications.clj
@@ -12,6 +12,7 @@
             [rems.application.commands :as commands]
             [rems.application.search :as search]
             [rems.auth.auth :as auth]
+            [rems.common.roles :refer [+admin-read-roles+]]
             [rems.config :as config]
             [rems.context :as context]
             [rems.db.applications :as applications]
@@ -235,6 +236,12 @@
       :return schema/SuccessResponse
       (ok (validate-application request)))
 
+    (GET "/commands" []
+      :summary "List of application commands"
+      :roles +admin-read-roles+
+      :return [s/Keyword]
+      (ok (sort commands/command-names)))
+
     (command-endpoint :application.command/accept-invitation commands/AcceptInvitationCommand)
     (command-endpoint :application.command/accept-licenses commands/AcceptLicensesCommand)
     (command-endpoint :application.command/add-licenses commands/AddLicensesCommand)
diff --git a/src/clj/rems/api/workflows.clj b/src/clj/rems/api/workflows.clj
index b2b19d4d8..81ddd98f8 100644
--- a/src/clj/rems/api/workflows.clj
+++ b/src/clj/rems/api/workflows.clj
@@ -15,14 +15,16 @@
    (s/optional-key :forms) [{:form/id s/Int}]
    :type (apply s/enum events/workflow-types) ; TODO: exclude master workflow?
    (s/optional-key :handlers) [schema-base/UserId]
-   (s/optional-key :licenses) [schema-base/LicenseId]})
+   (s/optional-key :licenses) [schema-base/LicenseId]
+   (s/optional-key :disable-commands) [schema-base/DisableCommandRule]})
 
 (s/defschema EditWorkflowCommand
   {:id s/Int
    (s/optional-key :organization) schema-base/OrganizationId
    ;; type can't change
    (s/optional-key :title) s/Str
-   (s/optional-key :handlers) [schema-base/UserId]})
+   (s/optional-key :handlers) [schema-base/UserId]
+   (s/optional-key :disable-commands) [schema-base/DisableCommandRule]})
 
 (s/defschema CreateWorkflowResponse
   {:success s/Bool
diff --git a/src/clj/rems/application/model.clj b/src/clj/rems/application/model.clj
index dbdc4f9bb..cf3425f66 100644
--- a/src/clj/rems/application/model.clj
+++ b/src/clj/rems/application/model.clj
@@ -16,16 +16,6 @@
 
 ;;;; Application
 
-(def states
-  #{:application.state/approved
-    :application.state/closed
-    :application.state/draft
-    :application.state/rejected
-    :application.state/returned
-    :application.state/revoked
-    :application.state/submitted})
-;; TODO deleted state?
-
 (defmulti ^:private application-base-view
   "Updates the data in the application based on the given event.
   Contrast with calculate-permissions which updates permissions based
@@ -682,11 +672,27 @@
              form/enrich-form-field-visible
              application))
 
-(defn- enrich-disable-commands [application get-config]
-  (permissions/blacklist application
-                         (permissions/compile-rules
-                          (for [command (:disable-commands (get-config))]
-                            {:permission command}))))
+(defn- get-permission-roles [disable-command]
+  (if-some [roles (seq (:when/role disable-command))]
+    roles
+    (list nil))) ; nil means for every role
+
+(defn- is-disabled-for-application [application disable-command]
+  (let [states (:when/state disable-command)]
+    (or (empty? states)
+        (contains? (set states) (:application/state application)))))
+
+(defn enrich-disable-commands [application get-config get-workflow]
+  (let [workflow (get-workflow (get-in application [:application/workflow :workflow/id]))]
+    (permissions/blacklist application
+                           (permissions/compile-rules
+                            (concat (for [command (:disable-commands (get-config))]
+                                      {:permission command})
+                                    (for [command (get-in workflow [:workflow :disable-commands])
+                                          :when (is-disabled-for-application application command)
+                                          role (get-permission-roles command)]
+                                      {:permission (:command command)
+                                       :role role}))))))
 
 (defn- get-attachment-redact-roles [attachment application]
   (when (:attachment/event attachment)
@@ -738,7 +744,7 @@
       (enrich-workflow-handlers get-workflow)
       (enrich-deadline get-config)
       (enrich-super-users get-users-with-role)
-      (enrich-disable-commands get-config)
+      (enrich-disable-commands get-config get-workflow)
       (enrich-attachments get-user)))
 
 (defn build-application-view [events injections]
diff --git a/src/clj/rems/css/styles.clj b/src/clj/rems/css/styles.clj
index 0b79a0f21..340ce4ca7 100644
--- a/src/clj/rems/css/styles.clj
+++ b/src/clj/rems/css/styles.clj
@@ -905,6 +905,8 @@
      [(s/> ":first-child") {:grid-column :checkbox}]
      [(s/> ":not(:first-child)") {:grid-column :content}]]]
 
+   [:.color-pre {:color "#212529"}] ; same color as bootstrap's default for [:pre]
+
    ;; Media queries must be almost last so they override
    (media-queries)
 
diff --git a/src/clj/rems/db/workflow.clj b/src/clj/rems/db/workflow.clj
index f760958bb..0101125c7 100644
--- a/src/clj/rems/db/workflow.clj
+++ b/src/clj/rems/db/workflow.clj
@@ -4,6 +4,7 @@
             [rems.db.core :as db]
             [rems.db.users :as users]
             [rems.json :as json]
+            [rems.schema-base :as schema-base]
             [schema.coerce :as coerce]
             [schema.core :as s]
             [medley.core :refer [update-existing-in]]))
@@ -12,7 +13,8 @@
   {:type (apply s/enum events/workflow-types)
    :handlers [s/Str]
    (s/optional-key :forms) [{:form/id s/Num}]
-   (s/optional-key :licenses) [s/Int]})
+   (s/optional-key :licenses) [s/Int]
+   (s/optional-key :disable-commands) [schema-base/DisableCommandRule]})
 
 (def ^:private coerce-workflow-body
   (coerce/coercer! WorkflowBody coerce/string-coercion-matcher))
@@ -20,11 +22,12 @@
 (def ^:private validate-workflow-body
   (s/validator WorkflowBody))
 
-(defn create-workflow! [{:keys [organization type title handlers forms licenses]}]
-  (let [body {:type type
-              :handlers handlers
-              :forms forms
-              :licenses licenses}]
+(defn create-workflow! [{:keys [organization type title handlers forms licenses disable-commands]}]
+  (let [body (cond-> {:type type
+                      :handlers handlers
+                      :forms forms
+                      :licenses licenses}
+               (seq disable-commands) (assoc :disable-commands disable-commands))]
     (:id (db/create-workflow! {:organization (:organization/id organization)
                                :title title
                                :workflow (json/generate-string
@@ -57,10 +60,11 @@
       (update-existing-in [:workflow :handlers] #(map :userid %))
       (update-existing-in [:workflow :licenses] #(map :license/id %))))
 
-(defn edit-workflow! [{:keys [id organization title handlers]}]
+(defn edit-workflow! [{:keys [id organization title handlers disable-commands]}]
   (let [workflow (unrich-workflow (get-workflow id))
         workflow-body (cond-> (:workflow workflow)
-                        handlers (assoc :handlers handlers))]
+                        handlers (assoc :handlers handlers)
+                        disable-commands (assoc :disable-commands disable-commands))]
     (db/edit-workflow! {:id (or id (:id workflow))
                         :title (or title (:title workflow))
                         :organization (or (:organization/id organization)
diff --git a/src/clj/rems/schema_base.clj b/src/clj/rems/schema_base.clj
index ed4130864..87b9bdbe8 100644
--- a/src/clj/rems/schema_base.clj
+++ b/src/clj/rems/schema_base.clj
@@ -119,3 +119,8 @@
 (s/defschema LicenseId
   {:license/id s/Int})
 
+(s/defschema DisableCommandRule
+  {:command s/Keyword
+   (s/optional-key :when/state) [s/Keyword]
+   (s/optional-key :when/role) [s/Keyword]})
+
diff --git a/src/clj/rems/service/workflow.clj b/src/clj/rems/service/workflow.clj
index 92b3f9979..2774e4df0 100644
--- a/src/clj/rems/service/workflow.clj
+++ b/src/clj/rems/service/workflow.clj
@@ -1,6 +1,7 @@
 (ns rems.service.workflow
   (:require [rems.service.dependencies :as dependencies]
             [rems.service.util :as util]
+            [rems.application.commands :as commands]
             [rems.db.applications :as applications]
             [rems.db.core :as db]
             [rems.db.form :as form]
@@ -27,22 +28,30 @@
      :errors [{:type :invalid-license
                :licenses invalid}]}))
 
-(defn create-workflow! [{:keys [organization handlers licenses forms] :as cmd}]
+(defn invalid-disable-commands-error [disable-commands]
+  (when-some [invalid (seq (remove (comp (set commands/command-names) :command) disable-commands))]
+    {:success false
+     :errors [{:type :invalid-disable-commands
+               :commands invalid}]}))
+
+(defn create-workflow! [{:keys [organization handlers licenses forms disable-commands] :as cmd}]
   (util/check-allowed-organization! organization)
   (or (invalid-users-error handlers)
       (invalid-forms-error forms)
       (invalid-licenses-error licenses)
+      (invalid-disable-commands-error disable-commands)
       (let [id (workflow/create-workflow! (update cmd :licenses #(map :license/id %)))]
         (dependencies/reset-cache!)
         {:success (not (nil? id))
          :id id})))
 
-(defn edit-workflow! [{:keys [id organization handlers] :as cmd}]
+(defn edit-workflow! [{:keys [id organization handlers disable-commands] :as cmd}]
   (let [workflow (workflow/get-workflow id)]
     (util/check-allowed-organization! (:organization workflow))
     (when organization
       (util/check-allowed-organization! organization))
     (or (invalid-users-error handlers)
+        (invalid-disable-commands-error disable-commands)
         (do
           (workflow/edit-workflow! (update cmd :licenses #(map :license/id %)))
           (applications/reload-cache!)
diff --git a/src/cljc/rems/common/application_util.cljc b/src/cljc/rems/common/application_util.cljc
index 01b03a2aa..2838eb2b8 100644
--- a/src/cljc/rems/common/application_util.cljc
+++ b/src/cljc/rems/common/application_util.cljc
@@ -2,6 +2,16 @@
   (:require [clojure.test :refer [deftest is]]
             [rems.common.util :as util]))
 
+(def states
+  #{:application.state/approved
+    :application.state/closed
+    :application.state/draft
+    :application.state/rejected
+    :application.state/returned
+    :application.state/revoked
+    :application.state/submitted})
+;; TODO deleted state?
+
 (defn accepted-licenses? [application userid]
   (let [application-licenses (map :license/id (:application/licenses application))
         user-accepted-licenses (get (:application/accepted-licenses application) userid)]
diff --git a/src/cljc/rems/text.cljc b/src/cljc/rems/text.cljc
index ddd48870d..aec7c4b39 100644
--- a/src/cljc/rems/text.cljc
+++ b/src/cljc/rems/text.cljc
@@ -260,3 +260,61 @@
   (if (= :filename/redacted (:attachment/filename attachment))
     (text :t.applications/attachment-filename-redacted)
     (:attachment/filename attachment)))
+
+(def ^:private localized-roles
+  {;; :api-key
+   :applicant :t.roles/applicant
+   :decider :t.roles/decider
+   ;; :everyone-else
+   ;; :expirer
+   :handler :t.roles/handler
+   ;; :logged-in
+   :member :t.roles/member
+   ;; :organization-owner
+   ;; :owner
+   :past-decider :t.roles/past-decider
+   :past-reviewer :t.roles/past-reviewer
+   ;; :reporter
+   :reviewer :t.roles/reviewer
+   ;; :user-owner
+   })
+
+(defn localize-role [role]
+  (text (get localized-roles role) :t/unknown))
+
+(def ^:private localized-commands
+  {:application.command/accept-invitation :t.commands/accept-invitation
+   :application.command/accept-licenses :t.commands/accept-licenses
+   :application.command/add-licenses :t.commands/add-licenses
+   :application.command/add-member :t.commands/add-member
+   :application.command/approve :t.commands/approve
+   :application.command/assign-external-id :t.commands/assign-external-id
+   :application.command/change-applicant :t.commands/change-applicant
+   :application.command/change-resources :t.commands/change-resources
+   :application.command/close :t.commands/close
+   :application.command/copy-as-new :t.commands/copy-as-new
+   ;; :application.command/create
+   :application.command/decide :t.commands/decide
+   :application.command/delete :t.commands/delete
+   :application.command/invite-decider :t.commands/invite-decider
+   :application.command/invite-member :t.commands/invite-member
+   :application.command/invite-reviewer :t.commands/invite-reviewer
+   :application.command/redact-attachments :t.commands/redact-attachments
+   :application.command/reject :t.commands/reject
+   :application.command/remark :t.commands/remark
+   :application.command/remove-member :t.commands/remove-member
+   :application.command/request-decision :t.commands/request-decision
+   :application.command/request-review :t.commands/request-review
+   :application.command/return :t.commands/return
+   :application.command/review :t.commands/review
+   :application.command/revoke :t.commands/revoke
+   :application.command/save-draft :t.commands/save-draft
+   ;; :application.command/send-expiration-notifications
+   :application.command/submit :t.commands/submit
+   :application.command/uninvite-member :t.commands/uninvite-member})
+
+(defn localize-command [command]
+  (let [command-type (if (keyword? command)
+                       command
+                       (:type command))]
+    (text (get localized-commands command-type) :t/unknown)))
diff --git a/src/cljs/rems/administration/create_workflow.cljs b/src/cljs/rems/administration/create_workflow.cljs
index d8540a90d..b86bcb078 100644
--- a/src/cljs/rems/administration/create_workflow.cljs
+++ b/src/cljs/rems/administration/create_workflow.cljs
@@ -1,19 +1,21 @@
 (ns rems.administration.create-workflow
   (:require [clojure.string :as str]
-            [medley.core :refer [find-first]]
+            [medley.core :refer [find-first indexed remove-nth update-existing]]
             [re-frame.core :as rf]
             [rems.administration.administration :as administration]
             [rems.administration.components :refer [organization-field radio-button-group text-field]]
+            [rems.administration.items :as items]
             [rems.atoms :as atoms :refer [enrich-user document-title]]
             [rems.collapsible :as collapsible]
             [rems.config :as config]
-            [rems.common.util :refer [keep-keys replace-key]]
+            [rems.common.application-util :as application-util]
+            [rems.common.util :refer [andstr conj-vec keep-keys replace-key]]
             [rems.dropdown :as dropdown]
             [rems.fetcher :as fetcher]
             [rems.fields :as fields]
             [rems.flash-message :as flash-message]
             [rems.spinner :as spinner]
-            [rems.text :refer [localized text]]
+            [rems.text :refer [localized localize-command localize-role localize-state text text-format]]
             [rems.util :refer [navigate! post! put! trim-when-string]]))
 
 (defn- item-by-id [items id-key id]
@@ -24,11 +26,13 @@
                    {:db (assoc db
                                ::workflow-id workflow-id
                                ::actors nil
+                               ::commands nil
                                ::editing? (some? workflow-id)
                                ::form {:type :workflow/default})
                     :dispatch-n [[::actors]
                                  [::forms {:disabled true :archived true}]
                                  [::licenses]
+                                 [::commands]
                                  (when workflow-id [::workflow])]}))
 
 (rf/reg-sub ::workflow-id (fn [db _] (::workflow-id db)))
@@ -49,7 +53,8 @@
                                             :forms (mapv #(select-keys % [:form/id]) (get workflow :forms))
                                             :handlers (get workflow :handlers)
                                             :licenses (->> (:licenses workflow)
-                                                           (map #(replace-key % :license/id :id)))})))
+                                                           (mapv #(replace-key % :license/id :id)))
+                                            :disable-commands (get workflow :disable-commands)})))
 
 (fetcher/reg-fetcher ::workflow "/api/workflows/:id" {:path-params (fn [db] {:id (::workflow-id db)})
                                                       :on-success #(rf/dispatch [::fetch-workflow-success %])})
@@ -68,7 +73,8 @@
      (seq (:handlers request))
      true)
    (not (str/blank? (get-in request [:organization :organization/id])))
-   (not (str/blank? (:title request)))))
+   (not (str/blank? (:title request)))
+   (every? :command (:disable-commands request))))
 
 (defn build-create-request [form]
   (let [request (merge
@@ -76,7 +82,10 @@
                   :title (trim-when-string (:title form))
                   :type (:type form)
                   :forms (mapv #(select-keys % [:form/id]) (:forms form))
-                  :licenses (vec (keep-keys {:id :license/id} (:licenses form)))}
+                  :licenses (vec (keep-keys {:id :license/id} (:licenses form)))
+                  :disable-commands (->> (:disable-commands form)
+                                         (mapv #(update-existing % :when/state vec))
+                                         (mapv #(update-existing % :when/role vec)))}
                  (when (needs-handlers? (:type form))
                    {:handlers (map :userid (:handlers form))}))]
     (when (valid-create-request? request)
@@ -86,13 +95,17 @@
   (and (number? (:id request))
        (seq (:handlers request))
        (not (str/blank? (get-in request [:organization :organization/id])))
-       (not (str/blank? (:title request)))))
+       (not (str/blank? (:title request)))
+       (every? :command (:disable-commands request))))
 
 (defn build-edit-request [id form]
   (let [request {:organization {:organization/id (get-in form [:organization :organization/id])}
                  :id id
                  :title (:title form)
-                 :handlers (map :userid (:handlers form))}]
+                 :handlers (map :userid (:handlers form))
+                 :disable-commands (->> (:disable-commands form)
+                                        (mapv #(update-existing % :when/state vec))
+                                        (mapv #(update-existing % :when/role vec)))}]
     (when (valid-edit-request? request)
       request)))
 
@@ -130,6 +143,15 @@
                                        (assoc-in db [::form :licenses]))))
 (fetcher/reg-fetcher ::licenses "/api/licenses")
 
+(fetcher/reg-fetcher ::commands "/api/applications/commands")
+(rf/reg-sub ::available-commands
+            :<- [::commands]
+            (fn [commands]
+              (->> (sort commands)
+                   (remove #{:application.command/assign-external-id
+                             :application.command/create
+                             :application.command/send-expiration-notifications}))))
+
 ;;;; UI
 
 (def ^:private context
@@ -207,12 +229,109 @@
 
 (defn- cancel-button []
   [atoms/link {:class "btn btn-secondary"}
-   "/administration/workflows"
+   (str "/administration/workflows" (andstr "/" @(rf/subscribe [::workflow-id])))
    (text :t.administration/cancel)])
 
 (defn workflow-type-description [description]
   [:div.alert.alert-info description])
 
+(defn- create-rule [{:keys [db]} [_]]
+  {:db (update-in db [::form :disable-commands] conj-vec {})
+   :rems.focus/scroll-into-view [".disable-commands" {:block :end}]})
+
+(defn- remove-rule [{:keys [db]} [_ rule-index]]
+  {:db (update-in db [::form :disable-commands] #(vec (remove-nth rule-index %)))
+   :rems.focus/scroll-into-view [".disable-commands" {:block :end}]})
+
+(defn- set-rule [k]
+  (fn [db [_ rule-index v]]
+    (assoc-in db [::form :disable-commands rule-index k] v)))
+
+(rf/reg-sub ::disable-commands (fn [db _] (get-in db [::form :disable-commands])))
+(rf/reg-event-fx ::create-rule create-rule)
+(rf/reg-event-fx ::remove-rule remove-rule)
+(rf/reg-event-db ::select-rule-command (set-rule :command))
+(rf/reg-event-db ::select-rule-application-states (set-rule :when/state))
+(rf/reg-event-db ::select-rule-user-roles (set-rule :when/role))
+
+(defn- select-command [{:keys [id commands value on-change]}]
+  [:div.form-group.select-command
+   [:label.administration-field-label {:for id} (text :t.administration/disabled-command)]
+   [dropdown/dropdown
+    {:id id
+     :items commands
+     :item-label #(str (localize-command %) " (" (name %) ")")
+     :item-selected? #(= value %)
+     :on-change on-change}]])
+
+(defn- select-application-states [{:keys [id value on-change]}]
+  [:div.form-group.select-application-states
+   [:label.administration-field-label {:for id} (text :t.administration/application-state)]
+   [dropdown/dropdown
+    {:id id
+     :items application-util/states
+     :item-label #(str (localize-state %) " (" (name %) ")")
+     :item-selected? #(contains? (set value) %)
+     :placeholder (text :t.dropdown/placeholder-any-selection)
+     :multi? true
+     :on-change on-change}]])
+
+(defn- select-user-roles []
+  (let [applicant-roles [:applicant :member]
+        expert-roles [:handler :reviewer :decider :past-reviewer :past-decider]
+        technical-roles [:expirer :reporter]]
+    (fn [{:keys [id value on-change]}]
+      [:div.form-group.select-application-states
+       [:label.administration-field-label {:for id} (text :t.administration/user-role)]
+       [dropdown/dropdown
+        {:id id
+         :items (concat applicant-roles expert-roles technical-roles)
+         :item-label #(if (some #{%} technical-roles)
+                        (str (text :t.roles/technical-role) " (" (name %) ")")
+                        (str (localize-role %) " (" (name %) ")"))
+         :item-selected? #(contains? (set value) %)
+         :placeholder (text :t.dropdown/placeholder-any-selection)
+         :multi? true
+         :on-change on-change}]])))
+
+(defn- render-disable-command-rule [rule-index rule]
+  (let [id (str "disable-command-" rule-index)]
+    [:div.form-field.disable-command
+     [:div.form-field-header
+      [:h4 (text :t.create-workflow/rule)]
+      [:div.form-field-controls
+       [items/remove-button #(rf/dispatch [::remove-rule rule-index])]]]
+     [select-command {:id (str id "-select-command")
+                      :commands @(rf/subscribe [::available-commands])
+                      :value (:command rule)
+                      :on-change #(rf/dispatch [::select-rule-command rule-index %])}]
+     [:div.row
+      [:div.col-md
+       [select-application-states {:id (str id "-select-application-states")
+                                   :value (:when/state rule)
+                                   :on-change #(rf/dispatch [::select-rule-application-states rule-index (vec %)])}]]
+      [:div.col-md
+       [select-user-roles {:id (str id "-select-user-roles")
+                           :value (:when/role rule)
+                           :on-change #(rf/dispatch [::select-rule-user-roles rule-index (vec %)])}]]]]))
+
+(defn- workflow-disable-commands-field []
+  (let [disable-commands @(rf/subscribe [::disable-commands])]
+    [:div.form-group.disable-commands
+     [:label.administration-field-label
+      (text-format :t.label/optional (text :t.create-workflow/disable-commands))]
+     (when (seq disable-commands)
+       [:div.alert.alert-info (text :t.administration/workflow-disabled-commands-explanation)])
+     (into [:<>]
+           (for [[rule-index rule] (indexed disable-commands)]
+             [render-disable-command-rule rule-index rule]))
+     [:div.dashed-group.text-center
+      [:a#new-rule {:href "#"
+                    :on-click (fn [event]
+                                (.preventDefault event)
+                                (rf/dispatch [::create-rule]))}
+       (text :t.create-workflow/create-new-rule)]]]))
+
 ;; TODO: Eventually filter handlers by the selected organization when
 ;;   we are sure that all the handlers have the organization information?
 (defn- workflow-handlers-field []
@@ -301,6 +420,7 @@
                     :workflow/master [master-workflow-form])
 
                   [workflow-licenses-field]
+                  [workflow-disable-commands-field]
 
                   [:div.col.commands
                    [cancel-button]
diff --git a/src/cljs/rems/administration/workflow.cljs b/src/cljs/rems/administration/workflow.cljs
index f03fbf9a1..1aa5419c6 100644
--- a/src/cljs/rems/administration/workflow.cljs
+++ b/src/cljs/rems/administration/workflow.cljs
@@ -1,5 +1,6 @@
 (ns rems.administration.workflow
   (:require [clojure.string :as str]
+            [medley.core :refer [indexed]]
             [re-frame.core :as rf]
             [rems.administration.administration :as administration]
             [rems.administration.components :refer [inline-info-field]]
@@ -10,7 +11,8 @@
             [rems.flash-message :as flash-message]
             [rems.common.roles :as roles]
             [rems.spinner :as spinner]
-            [rems.text :refer [localized text]]
+            [rems.table :as table]
+            [rems.text :refer [localized localize-command localize-role localize-state text]]
             [rems.util :refer [fetch]]))
 
 (rf/reg-event-fx
@@ -49,6 +51,59 @@
    :workflow/decider :t.create-workflow/decider-workflow
    :workflow/master :t.create-workflow/master-workflow})
 
+(defn- render-command [command]
+  [:div
+   (localize-command command)
+   [:code.color-pre " (" (name command) ")"]])
+
+(defn- render-user-roles [roles]
+  (for [role roles]
+    [:div
+     (if (some #{role} [:expirer :reporter])
+       (text :t.roles/technical-role)
+       (localize-role role))
+     [:code.color-pre " (" (name role) ")"]]))
+
+(defn- render-application-states [states]
+  (for [state states]
+    [:div
+     (localize-state state)
+     [:code.color-pre " (" (name state) ")"]]))
+
+(rf/reg-sub ::disable-commands
+            :<- [::workflow]
+            #(get-in % [:workflow :disable-commands]))
+(rf/reg-sub ::disable-commands-table-rows
+            :<- [::disable-commands]
+            :<- [:language]
+            (fn [[disable-commands _language]]
+              (for [[rule-index rule] (indexed disable-commands)
+                    :let [command (:command rule)
+                          states (sort (:when/state rule))
+                          roles (sort (:when/role rule))]]
+                {:key (str "disable-command-" rule-index)
+                 :command {:display-value (render-command command)}
+                 :application-state {:display-value (if (seq states)
+                                                      (into [:<>] (render-application-states states))
+                                                      (text :t.dropdown/placeholder-any-selection))}
+                 :user-role {:display-value (if (seq roles)
+                                              (into [:<>] (render-user-roles roles))
+                                              (text :t.dropdown/placeholder-any-selection))}})))
+
+(defn- disable-commands-table []
+  [table/table {:id ::disable-commands
+                :columns [{:key :command
+                           :title (text :t.administration/disabled-command)
+                           :sortable? false}
+                          {:key :application-state
+                           :title (text :t.administration/application-state)
+                           :sortable? false}
+                          {:key :user-role
+                           :title (text :t.administration/user-role)
+                           :sortable? false}]
+                :rows [::disable-commands-table-rows]
+                :default-sort-column :command}])
+
 (defn workflow-view [workflow language]
   [:div.spaced-vertically-3
    [collapsible/component
@@ -79,6 +134,13 @@
                       [atoms/link nil uri title])
                     (interpose ", ")
                     (into [:<>]))]]}]
+   (when (seq (get-in workflow [:workflow :disable-commands]))
+     [collapsible/component
+      {:id "workflow-disabled-commands"
+       :title (text :t.administration/disabled-commands)
+       :always [:div
+                [:div.alert.alert-info (text :t.administration/workflow-disabled-commands-explanation)]
+                [:div.mt-4 [disable-commands-table]]]}])
    (let [id (:id workflow)]
      [:div.col.commands
       [administration/back-button "/administration/workflows"]
diff --git a/src/cljs/rems/focus.cljs b/src/cljs/rems/focus.cljs
index 7fa995d6f..7e218d19e 100644
--- a/src/cljs/rems/focus.cljs
+++ b/src/cljs/rems/focus.cljs
@@ -1,5 +1,7 @@
 (ns rems.focus
-  "Focuses an HTML element as soon as it exists.")
+  "Focuses an HTML element as soon as it exists."
+  (:require [reagent.core :as reagent]
+            [re-frame.core :as rf]))
 
 (defn focus [element]
   (.setAttribute element "tabindex" "-1")
@@ -61,3 +63,10 @@
   (let [offset-before (.-top rect-before)
         offset-after (.-top rect-after)]
     (.scrollBy js/window 0 (- offset-after offset-before))))
+
+(defn scroll-into-view [selector & [opts]]
+  (-> selector
+      (on-element-appear #(.scrollIntoView % (clj->js (or opts {}))))))
+
+(rf/reg-fx :rems.focus/scroll-into-view (fn [[selector opts]]
+                                          (reagent/after-render #(scroll-into-view selector opts))))


Test patch:
diff --git a/src/clj/rems/db/test_data_helpers.clj b/src/clj/rems/db/test_data_helpers.clj
index dd9547670..7ed501407 100644
--- a/src/clj/rems/db/test_data_helpers.clj
+++ b/src/clj/rems/db/test_data_helpers.clj
@@ -177,7 +177,7 @@
     (assert (:success result) {:command command :result result})
     (:id result)))
 
-(defn create-workflow! [{:keys [actor organization title type handlers forms licenses]
+(defn create-workflow! [{:keys [actor organization title type handlers forms licenses disable-commands]
                          :as command}]
   (let [actor (or actor (create-owner!))
         result (with-user actor
@@ -189,7 +189,8 @@
                    :handlers (or handlers
                                  (do (create-user! (get +fake-user-data+ "developer"))
                                      ["developer"]))
-                   :licenses (mapv (fn [id] {:license/id id}) licenses)}))]
+                   :licenses (mapv (fn [id] {:license/id id}) licenses)
+                   :disable-commands disable-commands}))]
     (assert (:success result) {:command command :result result})
     (:id result)))
 
diff --git a/src/clj/rems/service/test_data.clj b/src/clj/rems/service/test_data.clj
index ffd5c0c1a..7eaf5d36b 100644
--- a/src/clj/rems/service/test_data.clj
+++ b/src/clj/rems/service/test_data.clj
@@ -321,14 +321,25 @@
                                             :organization {:organization/id "csc"}
                                             :title "EGA workflow, a variant of default"
                                             :type :workflow/default
-                                            :handlers handlers})]
+                                            :handlers handlers})
+        disabled-commands (test-helpers/create-workflow! {:actor owner
+                                                          :organization {:organization/id "nbn"}
+                                                          :title "Restricted workflow"
+                                                          :type :workflow/default
+                                                          :handlers handlers
+                                                          :licenses [link text]
+                                                          :disable-commands [{:command :application.command/invite-member}
+                                                                             {:command :application.command/close
+                                                                              :when/state [:application.state/returned]
+                                                                              :when/role [:applicant]}]})]
     {:default default
      :ega ega
      :decider decider
      :decider2 decider2
      :master master
      :auto-approve auto-approve
-     :organization-owner organization-owner}))
+     :organization-owner organization-owner
+     :disabled-commands disabled-commands}))
 
 (defn- create-bona-fide-catalogue-item! [users]
   (let [owner (:owner users)
@@ -376,7 +387,7 @@
                             :actor approver
                             :comment "Looking good"})))
 
-(defn- create-applications! [catid users]
+(defn- create-applications! [catid catid-restricted users]
   (let [applicant (users :applicant1)
         approver (users :approver1)
         reviewer (users :reviewer)]
@@ -422,7 +433,7 @@
                               :actor approver
                               :comment "Never going to happen"}))
 
-    (let [app-id (test-helpers/create-draft! applicant [catid] "returned")]
+    (let [app-id (test-helpers/create-draft! applicant [catid-restricted] "returned")]
       (test-helpers/command! {:type :application.command/submit
                               :application-id app-id
                               :actor applicant})
@@ -821,19 +832,26 @@
                                           :organization {:organization/id "nbn"}
                                           :workflow-id (:decider workflows)
                                           :categories [special-category]})
-    (let [catid (test-helpers/create-catalogue-item! {:actor owner
-                                                      :title {:en "Default workflow"
-                                                              :fi "Oletustyövuo"
-                                                              :sv "Standard arbetsflöde"}
-                                                      :infourl {:en "http://www.google.com"
-                                                                :fi "http://www.google.fi"
-                                                                :sv "http://www.google.se"}
-                                                      :resource-id res1
-                                                      :form-id form
-                                                      :organization {:organization/id "nbn"}
-                                                      :workflow-id (:default workflows)
-                                                      :categories [ordinary-category]})]
-      (create-applications! catid users))
+    (let [cat {:actor owner
+               :title {:en "Default workflow"
+                       :fi "Oletustyövuo"
+                       :sv "Standard arbetsflöde"}
+               :infourl {:en "http://www.google.com"
+                         :fi "http://www.google.fi"
+                         :sv "http://www.google.se"}
+               :resource-id res1
+               :form-id form
+               :organization {:organization/id "nbn"}
+               :workflow-id (:default workflows)
+               :categories [ordinary-category]}
+          catid (test-helpers/create-catalogue-item! cat)
+          catid-restricted (test-helpers/create-catalogue-item!
+                            (assoc cat
+                                   :workflow-id (:disabled-commands workflows)
+                                   :title {:en "Default workflow (restricted)"
+                                           :fi "Oletustyövuo (rajoitettu)"
+                                           :sv "Standard arbetsflöde (begränsad)"}))]
+      (create-applications! catid catid-restricted users))
     (test-helpers/create-catalogue-item! {:actor owner
                                           :title {:en "Default workflow 2"
                                                   :fi "Oletustyövuo 2"
@@ -1018,7 +1036,7 @@
                               :actor handler
                               :reviewers [reviewer]
                               :comment "please have a look"}))
-                              ; create application to demo attachment redaction feature
+    ;; create application to demo attachment redaction feature
     (let [applicant (:applicant1 users)
           member (:applicant2 users)
           decider (:approver1 users)
@@ -1062,8 +1080,8 @@
                                 :attachment (test-helpers/create-attachment! {:actor applicant
                                                                               :application-id app-id
                                                                               :filename "applicant_attachment.pdf"})})
-      ; (delete-orphan-attachments-on-submit) process manager removes all dangling attachments,
-      ; so we submit application first before creating more attachments
+      ;; (delete-orphan-attachments-on-submit) process manager removes all dangling attachments,
+      ;; so we submit application first before creating more attachments
       (test-helpers/command! {:type :application.command/submit
                               :application-id app-id
                               :actor applicant})
diff --git a/test/clj/rems/application/test_model.clj b/test/clj/rems/application/test_model.clj
index a048bfc23..666787d17 100644
--- a/test/clj/rems/application/test_model.clj
+++ b/test/clj/rems/application/test_model.clj
@@ -7,6 +7,7 @@
             [rems.api.schema :as schema]
             [rems.application.events :as events]
             [rems.application.model :as model]
+            [rems.common.application-util :as application-util]
             [rems.common.util :refer [deep-merge]]
             [rems.permissions :as permissions]
             [schema.core :as s])
@@ -411,7 +412,7 @@
                          (reduce model/application-view nil)
                          ;; permissions are tested separately in rems.application.test-master-workflow
                          permissions/cleanup)]
-    (is (contains? model/states (:application/state application)))
+    (is (contains? application-util/states (:application/state application)))
     application))
 
 (defn recreate
@@ -1513,6 +1514,26 @@
     (is (= ["fld1"] (visible-fields (assoc-in application [:application/forms 0 :form/fields 0 :field/value] "no"))) "other option value should not make field visible")
     (is (= ["fld1" "fld2"] (visible-fields (assoc-in application [:application/forms 0 :form/fields 0 :field/value] "yes"))) "visible when option value is yes")))
 
+(deftest test-enrich-disable-commands
+  (let [app {:application/state :created
+             :application/role-permissions {:role-1 #{:a :b :c :d :e}
+                                            :role-2 #{:a :b :c :d :e}
+                                            :role-3 #{:a :b :c :d :e}}}]
+    (is (= app
+           (model/enrich-disable-commands app (constantly nil) (constantly nil))))
+    (is (= {:application/state :created
+            :application/role-permissions {:role-1 #{:b :c :e}
+                                           :role-2 #{:b :c :e}
+                                           :role-3 #{:c :d :e}}}
+           (model/enrich-disable-commands app
+                                          (constantly {:disable-commands [:a :does-not-exist]})
+                                          (constantly {:workflow
+                                                       {:disable-commands [{:command :does-not-exist}
+                                                                           {:command :b :when/state [:does-not-exist]}
+                                                                           {:command :b :when/role [:role-3]}
+                                                                           {:command :c :when/state [:does-not-exist] :when/role [:role-3]}
+                                                                           {:command :d :when/state [:created] :when/role [:role-1 :role-2 :does-not-exist]}]}}))))))
+
 (deftest test-hide-sensitive-information
   (let [base (reduce model/application-view nil
                      [created-event
diff --git a/test/clj/rems/test_text.clj b/test/clj/rems/test_text.clj
index 76d6f32f0..e2a4089e0 100644
--- a/test/clj/rems/test_text.clj
+++ b/test/clj/rems/test_text.clj
@@ -3,7 +3,7 @@
             [clojure.test :refer :all]
             [mount.core :as mount]
             [rems.application.events :as events]
-            [rems.application.model :as model]
+            [rems.common.application-util :as application-util]
             [rems.config :refer [env]]
             [rems.test-locales :refer [loc-en]]
             [rems.text :refer [with-language localize-event localize-state]]))
@@ -24,14 +24,14 @@
              (dissoc :unknown)
              (keys)
              (sort))
-         (->> model/states
+         (->> application-util/states
               (map name)
               (map keyword)
               (sort))))
   (with-language :en
     (fn []
       (is (not (valid-localization? (localize-state "foobar"))))
-      (doseq [s model/states]
+      (doseq [s application-util/states]
         (testing s
           (is (valid-localization? (localize-state s))))))))
 
diff --git a/test/cljs/rems/administration/test_create_workflow.cljs b/test/cljs/rems/administration/test_create_workflow.cljs
index a92ee5ea3..92d916a01 100644
--- a/test/cljs/rems/administration/test_create_workflow.cljs
+++ b/test/cljs/rems/administration/test_create_workflow.cljs
@@ -9,7 +9,8 @@
                 :type :workflow/default
                 :forms [{:form/id 123}]
                 :handlers ["bob"]
-                :licenses [{:id 1}]}]
+                :licenses [{:id 1}]
+                :disable-commands [{:command :application.command/close}]}]
       (is (not (nil? (build-create-request form))))
       (is (not (nil? (build-create-request (dissoc form :forms :licenses)))))
       (is (= {:licenses [{:license/id 1}]}
@@ -21,7 +22,9 @@
       (testing "missing workflow type"
         (is (nil? (build-create-request (assoc form :type nil)))))
       (testing "invalid workflow type"
-        (is (nil? (build-create-request (assoc form :type :no-such-type)))))))
+        (is (nil? (build-create-request (assoc form :type :no-such-type)))))
+      (testing "missing command in disable commands"
+        (is (nil? (build-create-request (update form :disable-commands conj {})))))))
 
   (testing "default workflow"
     (let [form {:organization {:organization/id "abc"}
@@ -35,7 +38,8 @@
                 :type :workflow/default
                 :forms [{:form/id 13}]
                 :handlers ["bob" "carl"]
-                :licenses []}
+                :licenses []
+                :disable-commands []}
                (build-create-request form))))
       (testing "missing handlers"
         (is (nil? (build-create-request (assoc-in form [:handlers] [])))))))
@@ -52,7 +56,8 @@
                 :type :workflow/decider
                 :forms [{:form/id 13}]
                 :handlers ["bob" "carl"]
-                :licenses []}
+                :licenses []
+                :disable-commands []}
                (build-create-request form))))
       (testing "missing handlers"
         (is (nil? (build-create-request (assoc-in form [:handlers] [])))))))
@@ -68,7 +73,8 @@
                 :type :workflow/master
                 :forms []
                 :handlers ["bob" "carl"]
-                :licenses []}
+                :licenses []
+                :disable-commands []}
                (build-create-request form))))
       (testing "missing handlers"
         (is (nil? (build-create-request (assoc-in form [:handlers] []))))))))
@@ -77,12 +83,15 @@
   (is (= {:id 3
           :organization {:organization/id "o"}
           :title "t"
-          :handlers ["a" "b"]}
+          :handlers ["a" "b"]
+          :disable-commands [{:command :application.command/close}]}
          (build-edit-request 3 {:organization {:organization/id "o"}
                                 :title "t"
                                 :handlers [{:userid "a"} {:userid "b"}]
-                                :licenses [{:id 1}]}))) ; licenses should not be mapped
+                                :licenses [{:id 1}] ; licenses should not be mapped
+                                :disable-commands [{:command :application.command/close}]})))
   (is (nil? (build-edit-request nil {:title "t" :handlers [{:userid "a"} {:userid "b"}]})))
   (is (nil? (build-edit-request 3 {:title "t" :handlers [{:userid "a"} {:userid "b"}]})))
   (is (nil? (build-edit-request 3 {:organization {:organization/id "o"} :title "" :handlers [{:userid "a"} {:userid "b"}]})))
-  (is (nil? (build-edit-request 3 {:organization {:organization/id "o"} :title "t" :handlers []}))))
+  (is (nil? (build-edit-request 3 {:organization {:organization/id "o"} :title "t" :handlers []})))
+  (is (nil? (build-edit-request 3 {:organization {:organization/id "o"} :title "t" :handlers [{:userid "a"} {:userid "b"}] :disable-commands [{}]}))))