diff --git a/code/src/cljs/sixsq/nuvla/ui/common_components/i18n/dictionary.cljs b/code/src/cljs/sixsq/nuvla/ui/common_components/i18n/dictionary.cljs index 1cfc2e86e..62cf494d7 100644 --- a/code/src/cljs/sixsq/nuvla/ui/common_components/i18n/dictionary.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/common_components/i18n/dictionary.cljs @@ -73,7 +73,7 @@ :add-dropdown "Add:" :add-edges "Add edges" :add-first-tag "Add your first tag" - :add-group "add group" + :add-group "Add Group" :add-group-members "add group members" :add-license "Add a license" :add-payment-method "Add payment method" diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs new file mode 100644 index 000000000..ef7000eb9 --- /dev/null +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs @@ -0,0 +1,71 @@ +(ns sixsq.nuvla.ui.pages.groups.events + (:require [day8.re-frame.http-fx] + [re-frame.core :refer [dispatch reg-event-db reg-event-fx]] + [sixsq.nuvla.ui.cimi-api.effects :as cimi-api-fx] + [sixsq.nuvla.ui.common-components.messages.events :as messages-events] + [sixsq.nuvla.ui.config :as config] + [sixsq.nuvla.ui.session.events :as session-events] + [sixsq.nuvla.ui.session.spec :as session-spec] + [sixsq.nuvla.ui.utils.response :as response])) + +(reg-event-fx + ::add-group + (fn [{_db :db} [_ {:keys [parent-group group-identifier group-name group-desc loading?]}]] + (let [on-success #(let [{:keys [status message resource-id]} (response/parse %)] + (dispatch [::session-events/search-groups]) + (dispatch [::messages-events/add + {:header (cond-> (str "added " resource-id) + status (str " (" status ")")) + :content message + :type :success}]) + (reset! loading? false))] + (if parent-group + {::cimi-api-fx/operation + [(:id parent-group) "add-subgroup" + on-success + :data {:group-identifier group-identifier + :name group-name + :description group-desc}]} + {::cimi-api-fx/add + ["group" {:template {:href "group-template/generic" + :group-identifier group-identifier} + :name group-name + :description group-desc} on-success]})))) + +(reg-event-fx + ::edit-group + (fn [_ [_ group]] + (let [id (:id group)] + {::cimi-api-fx/edit [id group #(if (instance? js/Error %) + (let [{:keys [status message]} (response/parse-ex-info %)] + (dispatch [::messages-events/add + {:header (cond-> "Group update failed" + status (str " (" status ")")) + :content message + :type :error}])) + (do + (dispatch [::session-events/search-groups]) + (dispatch [::messages-events/add + {:header "Group updated" + :content "Group updated successfully." + :type :info}])))]}))) + +(reg-event-fx + ::invite-to-group + (fn [{db :db} [_ group-id username]] + (let [on-error #(let [{:keys [status message]} (response/parse-ex-info %)] + (dispatch [::messages-events/add + {:header (cond-> (str "Invitation to " group-id " for " username " failed!") + status (str " (" status ")")) + :content message + :type :error}])) + on-success #(dispatch [::messages-events/add + {:header "Invitation successfully sent to user" + :content (str "User will appear in " group-id + " when he accept the invitation sent to his email address.") + :type :info}]) + data {:username username + :redirect-url (str (::session-spec/server-redirect-uri db) + "?message=join-group-accepted") + :set-password-url (str @config/path-prefix "/set-password")}] + {::cimi-api-fx/operation [group-id "invite" on-success :on-error on-error :data data]}))) \ No newline at end of file diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs index a49c1b362..b09741e3e 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs @@ -1,47 +1,134 @@ (ns sixsq.nuvla.ui.pages.groups.views - (:require ["@stripe/react-stripe-js" :as react-stripe] + (:require [cljs.spec.alpha :as s] [clojure.string :as str] [re-frame.core :refer [dispatch subscribe]] [reagent.core :as r] - [sixsq.nuvla.ui.common-components.acl.views :as acl-views] [sixsq.nuvla.ui.common-components.i18n.subs :as i18n-subs] - [sixsq.nuvla.ui.main.events :as main-events] - [sixsq.nuvla.ui.pages.profile.events :as events] - [sixsq.nuvla.ui.pages.profile.subs :as subs] + [sixsq.nuvla.ui.common-components.plugins.full-text-search :as full-text-search-plugin] + [sixsq.nuvla.ui.pages.groups.events :as events] + [sixsq.nuvla.ui.routing.routes :as routes] + [sixsq.nuvla.ui.routing.events :as routing-events] [sixsq.nuvla.ui.session.subs :as session-subs] + [sixsq.nuvla.ui.utils.forms :as forms] [sixsq.nuvla.ui.utils.general :as utils-general] [sixsq.nuvla.ui.utils.icons :as icons] [sixsq.nuvla.ui.utils.semantic-ui :as ui] [sixsq.nuvla.ui.utils.semantic-ui-extensions :as uix] + [sixsq.nuvla.ui.utils.spec :as us] + [sixsq.nuvla.ui.utils.style :as style] [sixsq.nuvla.ui.utils.ui-callback :as ui-callback])) +(s/def ::group-name us/nonblank-string) +(s/def ::group-description us/nonblank-string) -(def group-changed! (r/atom {})) -(defn set-group-changed! [id] (swap! group-changed! assoc id true)) -(defn disable-changes-protection! - [id] - (swap! group-changed! assoc id false) - (when-not (some true? (vals @group-changed!)) - (dispatch [::main-events/reset-changes-protection]))) +(defn ConfirmActionModal + [{:keys [on-confirm header Content Icon]}] + (let [tr (subscribe [::i18n-subs/tr])] + [uix/ModalDanger + {:button-text (@tr [:yes]) + :on-confirm on-confirm + :trigger (r/as-element + [:span [ui/Popup {:content header + :trigger (r/as-element + [ui/Button {:icon true :basic true} + Icon])}]]) + :header header + :content Content}])) + +(defn RemoveManagerButton + [group principal principal-name group-name] + [ConfirmActionModal {:on-confirm (fn [] + (dispatch [::events/edit-group + (-> group + (utils-general/acl-append-resource :owners "group/nuvla-admin") + (utils-general/acl-remove-resource :owners principal) + (utils-general/acl-remove-resource :edit-meta principal) + (utils-general/acl-remove-resource :edit-data principal) + (utils-general/acl-remove-resource :edit-acl principal) + (utils-general/acl-remove-resource :manage principal))])) + :header "Remove manager" + :Content [:span "Do you want to remove " [:b @principal-name] " from manager's of group " [:b group-name] "?"] + :Icon [ui/IconGroup + [icons/Icon {:name "fal fa-crown"}] + [icons/Icon {:name "fal fa-slash"}]]}]) + +(defn MakeManagerButton + [group principal principal-name group-name] + [ConfirmActionModal {:on-confirm #(dispatch [::events/edit-group + (-> group + (utils-general/acl-append-resource :edit-acl principal) + (utils-general/acl-append-resource :manage principal))]) + :header "Make manager" + :Content [:span "Do you want to make " [:b @principal-name] " a manager of group " [:b group-name] "?"] + :Icon [icons/Icon {:name "fal fa-crown"}]}]) + +(defn RemoveMemberButton + [group principal principal-name group-name] + [ConfirmActionModal {:on-confirm (fn [] + (dispatch [::events/edit-group + (-> group + (update :users (partial remove #{principal})) + (utils-general/acl-append-resource :owners "group/nuvla-admin") + (utils-general/acl-remove-resource :edit-acl principal) + (utils-general/acl-remove-resource :edit-data principal) + (utils-general/acl-remove-resource :edit-meta principal) + (utils-general/acl-remove-resource :view-acl principal) + (utils-general/acl-remove-resource :view-data principal) + (utils-general/acl-remove-resource :view-meta principal) + (utils-general/acl-remove-resource :manage principal))])) + :header "Remove member" + :Content [:span "Do you want to remove " [:b @principal-name] " from " [:b group-name] " group?"] + :Icon [icons/TrashIcon]}]) + +(defn LimitMemberViewButton + [group principal] + [ConfirmActionModal {:on-confirm (fn [] + (dispatch [::events/edit-group + (-> group + (utils-general/acl-remove-resource :edit-meta principal) + (utils-general/acl-remove-resource :edit-data principal) + (utils-general/acl-remove-resource :edit-acl principal) + (utils-general/acl-remove-resource :view-acl principal) + (utils-general/acl-remove-resource :view-data principal) + )])) + :header "Limit member’s view" + :Content "Limit member’s view to only the group name and description" + :Icon [icons/Icon {:className "far fa-eye-slash"}]}]) + +(defn ExtendMemberViewButton + [group principal] + [ConfirmActionModal {:on-confirm (fn [] + (dispatch [::events/edit-group + (utils-general/acl-append-resource group :view-acl principal)])) + :header "Extend user view" + :Content "Extend user view to member's list" + :Icon [icons/Icon {:className "far fa-eye"}]}]) (defn GroupMember - [id principal members editable?] - (let [principal-name (subscribe [::session-subs/resolve-principal principal])] + [id group-name principal editable? {{:keys [owners manage view-data view-acl] :as acl} :acl :as group}] + (let [tr (subscribe [::i18n-subs/tr]) + principal-name (subscribe [::session-subs/resolve-principal principal]) + manager? (boolean ((set (concat owners manage)) principal)) + can-view-members? (boolean ((set (concat owners view-data view-acl)) principal))] [ui/ListItem - [ui/ListContent - [ui/ListHeader - [acl-views/PrincipalIcon principal] - utils-general/nbsp - @principal-name - utils-general/nbsp - (when editable? - [icons/CloseIcon {:link true - :size "small" - :color "red" - :on-click (fn [] - (reset! members (-> @members set (disj principal) vec)) - (dispatch [::main-events/changes-protection? true]) - (set-group-changed! id))}])]]])) + (when editable? + [ui/ListContent {:floated :right} + (if manager? + [RemoveManagerButton group principal principal-name group-name] + [:<> + (if can-view-members? + [LimitMemberViewButton group principal] + [ExtendMemberViewButton group principal]) + [MakeManagerButton group principal principal-name group-name]]) + [RemoveMemberButton group principal principal-name group-name]]) + + + [ui/ListContent {:style {:display :flex :align-items :flex-end}} + [ui/IconGroup + [ui/Icon {:className icons/i-user :size "large"}] + (when manager? [ui/Icon {:className "fa-solid fa-crown" :corner true}])] + @principal-name] + ])) (defn DropdownPrincipals [_add-user _opts _members] @@ -68,151 +155,206 @@ :style {:width "250px"} :upward false}])))) -(defn GroupMembers - [group] +(defn InviteInput + [{:keys [id] :as _group}] (let [tr (subscribe [::i18n-subs/tr]) - editable? (utils-general/editable? group false) - users (:users group) - members (r/atom users) - acl (r/atom (:acl group)) - changed? (r/cursor group-changed! [(:id group)]) - show-acl? (r/atom false) invite-user (r/atom nil) - add-user (r/atom nil)] + invite-fn #(do + (when-not (str/blank? @invite-user) + (dispatch [::events/invite-to-group id @invite-user]) + (reset! invite-user nil)))] (fn [group] - (let [{:keys [id name description]} group] - [ui/Table {:columns 4} - [ui/TableHeader {:fullWidth true} - [ui/TableRow - [ui/TableHeaderCell - [ui/HeaderSubheader {:as :h3} - name " (" id ")"] - (when description [:p description])] - (when (and @acl editable?) - [ui/TableHeaderCell - [acl-views/AclButtonOnly {:default-value @acl - :read-only (not editable?) - :active? show-acl?}]])] - (when @show-acl? - [ui/TableRow - [ui/TableCell {:colSpan 4} - [acl-views/AclSection {:default-value @acl - :read-only (not editable?) - :active? show-acl? - :on-change #(do - (reset! acl %) - (set-group-changed! id) - (dispatch [::main-events/changes-protection? true]))}]]])] - [ui/TableBody - [ui/TableRow - [ui/TableCell - (if (empty? @members) - [uix/MsgNoItemsToShow [uix/TR (if editable? :empty-group-message - :empty-group-or-no-access-message)]] - [ui/ListSA - (for [m @members] - ^{:key m} - [GroupMember id m members editable?])])]] - (when editable? - [ui/TableRow - [ui/TableCell - [:div {:style {:display "flex"}} - [DropdownPrincipals - add-user - {:placeholder (@tr [:add-group-members]) - :fluid true} @members] - [:span utils-general/nbsp] - [uix/Button {:text (@tr [:add]) - :icon "add user" - :disabled (str/blank? @add-user) - :on-click #(do - (swap! members conj @add-user) - (reset! add-user nil) - (set-group-changed! id) - (dispatch [::main-events/changes-protection? true]))}] - [:span utils-general/nbsp] - [:span utils-general/nbsp] - [ui/Input {:placeholder (@tr [:invite-by-email]) - :style {:width "250px"} - :value (or @invite-user "") - :on-change (ui-callback/value #(reset! invite-user %))}] - [:span utils-general/nbsp] - [uix/Button {:text (@tr [:send]) - :icon "send" - :disabled (str/blank? @invite-user) - :on-click #(do - (dispatch [::events/invite-to-group id @invite-user]) - (reset! invite-user nil))}]]] - [ui/TableCell {:textAlign "right"} - [uix/Button {:primary true - :text (@tr [:save]) - :icon "save" - :disabled (not @changed?) - :on-click #(do (dispatch [::events/edit-group (assoc group :users @members, :acl @acl)]) - (disable-changes-protection! id))}]]])]])))) - -(defn GroupMembersSegment - [] - (let [tr (subscribe [::i18n-subs/tr]) - loading? (subscribe [::subs/loading? :group]) - group (subscribe [::subs/group])] - (dispatch [::events/get-group]) - (fn [] - [ui/Segment {:padded true - :color "green" - :loading @loading? - :style {:height "100%"}} - [ui/Header {:as :h2 :dividing true} (@tr [:group-members])] - ^{:key (random-uuid)} - [GroupMembers @group]]))) + (when (utils-general/can-operation? "invite" group) + [ui/Input {:placeholder (@tr [:invite-by-email]) + :type :email + :icon (r/as-element + [icons/PaperPlaneIcon {:style {:font-size "unset"} + :link (not (str/blank? @invite-user)) + :color (when (not (str/blank? @invite-user)) "blue") + :circular true + :onClick invite-fn}]) + :style {:width "280px" :cursor :pointer} + :on-key-press (partial forms/on-return-key invite-fn) + :value (or @invite-user "") + :on-change (ui-callback/value #(reset! invite-user %))}])))) + +(defn sanitize-name [name] + (when name + (str/lower-case + (str/replace + (str/trim + (str/join "" (re-seq #"[a-zA-Z0-9-_\ ]" name))) + " " "-")))) + +(defn AddGroupButton + [_opts] + (let [tr (subscribe [::i18n-subs/tr]) + show? (r/atom false) + group-name (r/atom "") + group-desc (r/atom "") + validate? (r/atom false) + loading? (r/atom false) + close-fn #(reset! show? false)] + (fn [{:keys [parent-group header]}] + (let [group-identifier (sanitize-name @group-name) + form-valid? (and (s/valid? ::group-name @group-name) + (s/valid? ::group-description @group-desc))] + [ui/Modal + {:open @show? + :close-icon true + :on-close close-fn + :trigger (r/as-element + [ui/Button {:secondary true + :size "small" + :icon true + :on-click #(reset! show? true)} + [icons/PlusSquareIcon] + header])} + [uix/ModalHeader {:header header}] + [ui/ModalContent + [ui/Message {:hidden (not (and @validate? (not form-valid?))) + :error true} + [ui/MessageHeader (@tr [:validation-error])] + [ui/MessageContent (@tr [:validation-error-message])]] + (when-not (str/blank? group-identifier) + [:i {:style {:padding-left "1ch" + :color :grey}} + [:b "id : "] + (str "group/" group-identifier)]) + [ui/Table style/definition + [ui/TableBody + [uix/TableRowField (@tr [:name]), :required? true, :default-value @group-name, + :validate-form? @validate?, :spec ::group-name, + :on-change #(reset! group-name %)] + [uix/TableRowField (@tr [:description]), :required? true, + :spec ::group-description, :validate-form? @validate?, + :default-value @group-desc, :on-change #(reset! group-desc %)]]]] + [ui/ModalActions + [uix/Button + {:text (str/capitalize (@tr [:add])) + :primary true + :disabled (and @validate? (not form-valid?)) + :loading @loading? + :on-click #(if (not form-valid?) + (reset! validate? true) + (do + (reset! show? false) + (dispatch + [::events/add-group {:parent-group parent-group + :group-identifier group-identifier + :group-name @group-name + :group-desc @group-desc + :loading? loading?}])))}]]])))) + +(defn GroupMembers + [group] + (let [editable? (utils-general/editable? group false)] + (fn [{:keys [id name description users] :as group}] + (let [group-name (or name id)] + [:<> + [:div {:style {:display :flex + :align-items :flex-start + :justify-content :space-between + :flex-wrap :wrap + :padding-bottom "1em"}} + [ui/Header {:as :h3} + [icons/UserGroupIcon] + [ui/HeaderContent + group-name + [ui/HeaderSubheader description " (" id ")"]]] + (when (utils-general/can-operation? "add-subgroup" group) + [AddGroupButton {:header "Add Subgroup" + :parent-group group}])] + [ui/Header {:as :h3 :dividing true} "Members"] + (if (empty? users) + [uix/MsgNoItemsToShow [uix/TR (if editable? :empty-group-message + :empty-group-or-no-access-message)]] + + [ui/ListSA {:divided true :vertical-align "middle"} + (for [m users] + ^{:key m} + [GroupMember id group-name m editable? group])]) + [InviteInput group] + + ])))) (defn Group - [] - (let [collapsed (r/atom true)] - (fn [{:keys [id name description children] :as _group}] - [ui/ListItem {:on-click #(do (swap! collapsed not) + [{:keys [id] :as _group} {:keys [parents] :as _selected-group}] + (let [collapsed (r/atom (not ((set parents) id)))] + (fn [{:keys [id name children] :as _group} selected-group] + (let [selected? (= (:id selected-group) id) + children? (boolean (seq children))] + [ui/ListItem {:on-click #(do + (dispatch [::routing-events/navigate routes/groups-details {:uuid (utils-general/id->uuid id)}]) (.stopPropagation %))} - [ui/ListIcon {:name "group"}] - [ui/ListContent - [ui/ListHeader (or name id)] - (when description [ui/ListDescription description]) - (when (and (not @collapsed) (seq children)) - [ui/ListList - (for [child children] - ^{:key (:id child)} - [Group child])])]]))) + [ui/ListIcon {:style {:padding 5 + :min-width "17px"} + :on-click #(when children? + (swap! collapsed not) + (.stopPropagation %)) + :name (if (seq children) + (if @collapsed "angle right" "angle down") + "")}] + [ui/ListContent + [ui/ListHeader + {:className "nuvla-group-item" + :style (cond-> {:padding 5 + :border-radius 5} + selected? (assoc :background-color "lightgray") + (not selected?) (assoc :font-weight 400))} + (or name id)] + (when (and (not @collapsed) (seq children)) + [ui/ListList + (for [child (sort-by (juxt :id :name) children)] + ^{:key (:id child)} + [Group child selected-group])])]])))) (defn GroupHierarchySegment - [] - (let [groups-hierarchy @(subscribe [::session-subs/groups-hierarchies])] - [ui/Segment {:padded true - :color "purple"} - [ui/Header {:as :h2 :dividing true} "Group Hierarchy"] - [ui/ListSA {:celled true - :style {:cursor :pointer}} - (for [group-hierarchy groups-hierarchy] + [selected-group] + (let [tr @(subscribe [::i18n-subs/tr]) + groups-hierarchy @(subscribe [::session-subs/groups-hierarchies])] + [ui/Segment {:raised true :style {:overflow-x :auto + :min-height "100%"}} + + [:div {:style {:display :flex + :align-items :baseline + :justify-content :space-between + :flex-wrap :wrap + :padding-bottom "1em"}} + [ui/Header {:as :h3} "Groups"] + [AddGroupButton {:header (tr [:add-group])}]] + + [full-text-search-plugin/FullTextSearch + {:db-path [::deployments-search] + :change-event [:a] + :style {:width "100%"}}] + [ui/ListSA {:selection true} + (for [group-hierarchy (sort-by (juxt :id :name) groups-hierarchy)] ^{:key (:id group-hierarchy)} - [Group group-hierarchy])]])) + [Group group-hierarchy selected-group])]])) (defn GroupsViewPage - [] - (let [tr (subscribe [::i18n-subs/tr]) - groups (subscribe [::session-subs/groups]) - is-group? (subscribe [::session-subs/is-group?]) - is-admin? (subscribe [::session-subs/is-admin?])] - (fn [] - (let [remove-groups #{"group/nuvla-nuvlabox" "group/nuvla-anon" "group/nuvla-user" - (when-not @is-admin? "group/nuvla-admin")} - sorted-groups (->> @groups - (remove (comp remove-groups :id)) - (sort-by :id))] - [:<> - (when @is-group? - [ui/GridColumn - [GroupMembersSegment]]) - [GroupHierarchySegment] - [ui/Segment {:padded true, :color "blue"} - [ui/Header {:as :h2} (str/capitalize (@tr [:groups]))] - (for [group sorted-groups] - ^{:key (str "group-" group)} - [GroupMembers group])]])))) + [{path :path}] + (let [[_ uuid] path + selected-group (when uuid + @(subscribe [::session-subs/group (str "group/" uuid)]))] + [ui/Grid {:stackable false} + [ui/GridColumn {:stretched true + :computer 4 + :tablet 6 + :mobile 8 + :style {:background-color "light-gray" + :padding-right 0}} + [GroupHierarchySegment selected-group]] + [ui/GridColumn {:stretched true + :tablet 10 + :computer 12 + :mobile 8 + :style {:background-color "light-gray" + :padding-right 0}} + [ui/Segment {:style {:min-height "100%" + :overflow-x :auto}} + (if selected-group + ^{:key selected-group} + [GroupMembers selected-group] + [uix/MsgNoItemsToShow [uix/TR "Select a Group"]])]]])) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/profile/events.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/profile/events.cljs index 6a788814f..a368b55d8 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/profile/events.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/profile/events.cljs @@ -8,13 +8,11 @@ [sixsq.nuvla.ui.common-components.i18n.spec :as i18n-spec] [sixsq.nuvla.ui.common-components.messages.events :as messages-events] [sixsq.nuvla.ui.common-components.plugins.audit-log :as audit-log-plugin] - [sixsq.nuvla.ui.config :as config] [sixsq.nuvla.ui.main.spec :as main-spec] [sixsq.nuvla.ui.pages.profile.effects :as fx] [sixsq.nuvla.ui.pages.profile.spec :as spec] [sixsq.nuvla.ui.routing.events :as routing-events] [sixsq.nuvla.ui.routing.routes :as routes] - [sixsq.nuvla.ui.session.events :as session-events] [sixsq.nuvla.ui.session.spec :as session-spec] [sixsq.nuvla.ui.session.utils :as session-utils] [sixsq.nuvla.ui.utils.general :as general-utils] @@ -31,16 +29,6 @@ [:dispatch [::audit-log-plugin/load-events [::spec/events] {:event-name event-names} false]]]})) -(reg-event-db - ::add-group-member - (fn [db [_ member]] - (update-in db [::spec/group :users] #(conj % member)))) - -(reg-event-db - ::remove-group-member - (fn [db [_ member]] - (update-in db [::spec/group :users] #(vec (disj (set %) member))))) - (reg-event-db ::set-user (fn [{:keys [::spec/loading] :as db} [_ user]] @@ -52,77 +40,10 @@ (fn [{{:keys [::session-spec/session] :as db} :db} _] (when-let [user (session-utils/get-user-id session)] (let [is-group? (-> session session-utils/get-active-claim session-utils/is-group?)] - (cond-> {:fx [(when is-group? [:dispatch [::get-group]])]} + (cond-> {} (not is-group?) (assoc ::cimi-api-fx/get [user #(do (dispatch [::set-user %]))] :db (update db ::spec/loading conj :user))))))) -(reg-event-fx - ::add-group - (fn [{_db :db} [_ id name description loading?]] - (let [user {:template {:href "group-template/generic" - :group-identifier id} - :name name - :description description}] - {::cimi-api-fx/add - ["group" user - #(let [{:keys [status message resource-id]} (response/parse %)] - (dispatch [::session-events/search-groups]) - (dispatch [::messages-events/add - {:header (cond-> (str "added " resource-id) - status (str " (" status ")")) - :content message - :type :success}]) - (reset! loading? false))]}))) - -(reg-event-db - ::set-group - (fn [{:keys [::spec/loading] :as db} [_ group]] - (assoc db ::spec/group group - ::spec/loading (disj loading :group)))) - -(reg-event-fx - ::get-group - (fn [{{:keys [::session-spec/session] :as db} :db}] - (when-let [group (session-utils/get-active-claim session)] - {:db (update db ::spec/loading conj :group) - ::cimi-api-fx/get [group #(dispatch [::set-group %])]}))) - -(reg-event-fx - ::edit-group - (fn [_ [_ group]] - (let [id (:id group)] - {::cimi-api-fx/edit [id group #(if (instance? js/Error %) - (let [{:keys [status message]} (response/parse-ex-info %)] - (dispatch [::messages-events/add - {:header (cond-> "Group update failed" - status (str " (" status ")")) - :content message - :type :error}])) - (dispatch [::messages-events/add - {:header "Group updated" - :content "Group updated successfully." - :type :info}]))]}))) - -(reg-event-fx - ::invite-to-group - (fn [{db :db} [_ group-id username]] - (let [on-error #(let [{:keys [status message]} (response/parse-ex-info %)] - (dispatch [::messages-events/add - {:header (cond-> (str "Invitation to " group-id " for " username " failed!") - status (str " (" status ")")) - :content message - :type :error}])) - on-success #(dispatch [::messages-events/add - {:header "Invitation successfully sent to user" - :content (str "User will appear in " group-id - " when he accept the invitation sent to his email address.") - :type :info}]) - data {:username username - :redirect-url (str (::session-spec/server-redirect-uri db) - "?message=join-group-accepted") - :set-password-url (str @config/path-prefix "/set-password")}] - {::cimi-api-fx/operation [group-id "invite" on-success :on-error on-error :data data]}))) - (reg-event-fx ::get-customer (fn [{db :db} [_ id]] diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/profile/spec.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/profile/spec.cljs index 820fb3502..b49b5125d 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/profile/spec.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/profile/spec.cljs @@ -1,8 +1,7 @@ (ns sixsq.nuvla.ui.pages.profile.spec (:require [clojure.spec.alpha :as s] [sixsq.nuvla.ui.common-components.plugins.audit-log :as audit-log-plugin] - [sixsq.nuvla.ui.common-components.plugins.nav-tab :as nav-tab] - [sixsq.nuvla.ui.utils.spec :as us])) + [sixsq.nuvla.ui.common-components.plugins.nav-tab :as nav-tab])) (s/def ::user any?) (s/def ::customer any?) @@ -17,11 +16,6 @@ (s/def ::loading set?) (s/def ::setup-intent any?) (s/def ::vendor any?) -(s/def ::group any?) -(s/def ::group-name us/nonblank-string) -(s/def ::group-description us/nonblank-string) -(s/def ::group-form (s/keys :req [::group-name - ::group-description])) (s/def ::tab any?) (s/def ::two-factor-step (s/nilable string?)) (s/def ::two-factor-enable? boolean?) @@ -43,7 +37,6 @@ ::error-message nil ::loading #{} ::vendor nil - ::group nil ::tab (nav-tab/build-spec) ::two-factor-step :install-app ::two-factor-enable? true diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/profile/subs.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/profile/subs.cljs index b5ce01b68..d3e267c87 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/profile/subs.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/profile/subs.cljs @@ -198,11 +198,6 @@ (fn [db] (::spec/vendor db))) -(reg-sub - ::group - (fn [db] - (::spec/group db))) - (reg-sub ::two-factor-step (fn [db] diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/profile/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/profile/views.cljs index ee450044e..18982e5e4 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/profile/views.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/profile/views.cljs @@ -63,65 +63,6 @@ 1)) -(defn AddGroupButton - [] - (let [tr (subscribe [::i18n-subs/tr]) - show? (r/atom false) - group-name (r/atom "") - group-desc (r/atom "") - validate? (r/atom false) - loading? (r/atom false) - close-fn #(reset! show? false)] - (fn [] - (let [group-identifier (utils-general/sanitize-name @group-name) - form-valid? (and (s/valid? ::spec/group-name @group-name) - (s/valid? ::spec/group-description @group-desc))] - [ui/Modal - {:open @show? - :close-icon true - :on-close close-fn - :trigger (r/as-element - [ui/MenuItem {:on-click #(reset! show? true)} - [icons/UsersIcon] - (str/capitalize (@tr [:add-group]))])} - [uix/ModalHeader {:header (str/capitalize (@tr [:add-group]))}] - [ui/ModalContent - [ui/Message {:hidden (not (and @validate? (not form-valid?))) - :error true} - [ui/MessageHeader (@tr [:validation-error])] - [ui/MessageContent (@tr [:validation-error-message])]] - [ui/Input {:name "name" - :placeholder (@tr [:name]) - :type :text - :error (when (and @validate? - (not (s/valid? ::spec/group-name @group-name))) true) - :fluid true - :on-change (ui-callback/input-callback - #(reset! group-name %))}] - [:br] - [ui/Input {:name "description" - :placeholder (@tr [:description]) - :type :text - :error (when (and @validate? - (not (s/valid? ::spec/group-description @group-desc))) true) - :fluid true - :on-change (ui-callback/input-callback - #(reset! group-desc %))}]] - [ui/ModalActions - [uix/Button - {:text (@tr [:create]) - :primary true - :disabled (and @validate? (not form-valid?)) - :icon icons/i-info-full - :loading @loading? - :on-click #(if (not form-valid?) - (reset! validate? true) - (do - (reset! show? false) - (dispatch - [::events/add-group group-identifier @group-name @group-desc loading?])))}]]])))) - - (defn ModalChangePassword [] (let [open? (subscribe [::subs/modal-open? :change-password]) error (subscribe [::subs/error-message]) @@ -1442,7 +1383,6 @@ :icon "user secret" :content (str/capitalize (@tr [:change-password])) :on-click #(dispatch [::events/open-modal :change-password])}] - [AddGroupButton] (when can-enable-2fa? [ui/MenuItem {:icon "shield" diff --git a/code/src/cljs/sixsq/nuvla/ui/routing/router.cljs b/code/src/cljs/sixsq/nuvla/ui/routing/router.cljs index 44c01b13a..d84bb8da2 100644 --- a/code/src/cljs/sixsq/nuvla/ui/routing/router.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/routing/router.cljs @@ -61,6 +61,22 @@ :view #'views-cluster/ClusterViewPage}]]) (utils/canonical->all-page-names "edges"))) +(def groups-routes + (mapv (fn [page-alias] + [page-alias + {:name (create-route-name page-alias) + :layout #'LayoutPage + :view #'GroupsViewPage + :protected? true + :dict-key :groups} + [""] + ["/" (create-route-name page-alias "-slashed")] + ["/:uuid" + {:name (create-route-name page-alias "-details") + :layout #'LayoutPage + :view #'GroupsViewPage}]]) + (utils/canonical->all-page-names "groups"))) + (def cloud-routes (mapv (fn [page-alias] [page-alias @@ -122,6 +138,7 @@ cloud-routes deployment-routes deployment-group-routes + groups-routes ["sign-up" {:name ::routes/sign-up :layout #'LayoutAuthentication @@ -219,11 +236,7 @@ ["profile" {:name ::routes/profile :layout #'LayoutPage - :view #'profile}] - ["groups" - {:name ::routes/groups - :layout #'LayoutPage - :view #'GroupsViewPage}]] + :view #'profile}]] ["/*" {:name ::routes/catch-all :layout #'LayoutPage diff --git a/code/src/cljs/sixsq/nuvla/ui/routing/routes.cljs b/code/src/cljs/sixsq/nuvla/ui/routing/routes.cljs index 9965c5577..d30b9ce8c 100644 --- a/code/src/cljs/sixsq/nuvla/ui/routing/routes.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/routing/routes.cljs @@ -50,5 +50,6 @@ (def api-slashed ::api-slashed) (def api-sub-page ::api-sub-page) (def groups ::groups) +(def groups-details ::groups-details) (def profile ::profile) (def catch-all ::catch-all) diff --git a/code/src/cljs/sixsq/nuvla/ui/routing/utils.cljs b/code/src/cljs/sixsq/nuvla/ui/routing/utils.cljs index 494d71f28..1ba7ac56e 100644 --- a/code/src/cljs/sixsq/nuvla/ui/routing/utils.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/routing/utils.cljs @@ -101,7 +101,8 @@ "infrastructures" "clouds" "deployment" "deployments" "deployment-set" "deployment-groups" - "deployment-set-details" "deployment-groups-details"}) + "deployment-set-details" "deployment-groups-details" + "group" "groups"}) (defn ->canonical-route-name [route-name] diff --git a/code/src/cljs/sixsq/nuvla/ui/session/events.cljs b/code/src/cljs/sixsq/nuvla/ui/session/events.cljs index 8b618e94e..58af01019 100644 --- a/code/src/cljs/sixsq/nuvla/ui/session/events.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/session/events.cljs @@ -236,7 +236,7 @@ (reg-event-fx ::search-groups (fn [] - {::cimi-api-fx/search [:group {:select "id, name, acl, users, description, operations" + {::cimi-api-fx/search [:group {:select "id, name, acl, users, description, operations, parents" :last 10000 :orderby "name:asc,id:asc"} #(dispatch [::set-groups %])] diff --git a/code/src/cljs/sixsq/nuvla/ui/session/subs.cljs b/code/src/cljs/sixsq/nuvla/ui/session/subs.cljs index 18299297f..b17e3cf49 100644 --- a/code/src/cljs/sixsq/nuvla/ui/session/subs.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/session/subs.cljs @@ -219,14 +219,20 @@ :<- [::groups] (fn [groups] (->> groups - (map (juxt :id :name)) + (map (juxt :id identity)) (into {})))) +(reg-sub + ::group + :<- [::groups-mapping] + (fn [groups-mapping [_ id]] + (get groups-mapping id))) + (reg-sub ::groups-options :<- [::groups-mapping] (fn [groups-mapping] - (map (fn [[id name]] {:key id, :value id, :text (or name (utils/remove-group-prefix id))}) groups-mapping))) + (map (fn [[id {:keys [name]}]] {:key id, :value id, :text (or name (utils/remove-group-prefix id))}) groups-mapping))) (reg-sub ::resolve-principal @@ -237,7 +243,7 @@ (fn [[current-user-id identifier peers groups] [_ id]] (if (string? id) (if (str/starts-with? id "group/") - (or (get groups id) (utils/remove-group-prefix id)) + (or (get-in groups [id :name]) (utils/remove-group-prefix id)) (utils/resolve-user current-user-id identifier peers id)) id))) diff --git a/code/src/cljs/sixsq/nuvla/ui/utils/general.cljs b/code/src/cljs/sixsq/nuvla/ui/utils/general.cljs index ecbcf7d10..9d350b109 100644 --- a/code/src/cljs/sixsq/nuvla/ui/utils/general.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/utils/general.cljs @@ -482,3 +482,23 @@ (if (not= c 0) c (recur (rest rest-orders))))))) + +(defn acl-append + [acl right-kw user-id] + (if user-id + (update acl right-kw (comp vec set conj) user-id) + acl)) + +(defn acl-remove + [acl right-kw user-id] + (if user-id + (update acl right-kw (fn [user-ids] (vec (remove #{user-id} user-ids)))) + acl)) + +(defn acl-append-resource + [resource right-kw user-id] + (update resource :acl acl-append right-kw user-id)) + +(defn acl-remove-resource + [resource right-kw user-id] + (update resource :acl acl-remove right-kw user-id)) diff --git a/code/src/cljs/sixsq/nuvla/ui/utils/icons.cljs b/code/src/cljs/sixsq/nuvla/ui/utils/icons.cljs index 6d5573704..b3619758b 100644 --- a/code/src/cljs/sixsq/nuvla/ui/utils/icons.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/utils/icons.cljs @@ -717,3 +717,13 @@ (def i-cloud-download "fal fa-cloud-download") (def i-cloud-upload "fal fa-cloud-upload") + +(def i-ellipsis "fas fa-ellipsis") +(defn EllipsisIcon + [opts] + [I opts i-ellipsis]) + +(def i-paper-plane "fal fa-paper-plane") +(defn PaperPlaneIcon + [opts] + [I opts i-paper-plane]) diff --git a/code/src/cljs/sixsq/nuvla/ui/utils/semantic_ui.cljs b/code/src/cljs/sixsq/nuvla/ui/utils/semantic_ui.cljs index 8180e9f3f..b9603dbe8 100644 --- a/code/src/cljs/sixsq/nuvla/ui/utils/semantic_ui.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/utils/semantic_ui.cljs @@ -96,7 +96,7 @@ (def Input (r/adapt-react-class semantic/Input)) (def Header (r/adapt-react-class semantic/Header)) -;;(def HeaderContent (r/adapt-react-class semantic/HeaderContent)) +(def HeaderContent (r/adapt-react-class semantic/HeaderContent)) (def HeaderSubheader (r/adapt-react-class semantic/HeaderSubheader)) (def Label (r/adapt-react-class semantic/Label))