diff --git a/code/src/cljs/sixsq/nuvla/ui/app/view.cljs b/code/src/cljs/sixsq/nuvla/ui/app/view.cljs index 5c7e0d259..2acfd7e1a 100644 --- a/code/src/cljs/sixsq/nuvla/ui/app/view.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/app/view.cljs @@ -1,6 +1,7 @@ (ns sixsq.nuvla.ui.app.view (:require [re-frame.core :refer [dispatch subscribe]] [sixsq.nuvla.ui.common-components.i18n.subs :as i18n-subs] + [sixsq.nuvla.ui.common-components.i18n.views :as i18n-views] [sixsq.nuvla.ui.common-components.intercom.views :as intercom] [sixsq.nuvla.ui.main.components :as main-components] [sixsq.nuvla.ui.main.subs :as subs] @@ -36,8 +37,8 @@ [:<> (let [session @(subscribe [::session-subs/session])] (if session - [FollowRedirect] - [WatcherRedirectProtectedPage]))]) + [FollowRedirect] + [WatcherRedirectProtectedPage]))]) (defn RouterView [] (let [CurrentView @(subscribe [::route-subs/current-view]) @@ -77,6 +78,23 @@ [main-views/SubscriptionRequiredModal] [main-views/Footer]]]]]) +(defn LayoutCallback [] + [:div {:class "login-left"} + [:div {:style {:background-color "#C10E12"}} + [:div {:style {:position :absolute + :right 10 + :padding 10}} [i18n-views/LocaleDropdown]] + [ui/Image {:alt "logo" + :src "/ui/images/nuvla-logo.png" + :size "small" + :centered true}]] + [:div {:style {:margin-top "10%" + :padding "1em" + :background-color "rgba(0,0,0,0.5)" + :box-shadow "0px 0px 50px black" + }} + [RouterView]]]) + (defn Loader [] (let [tr (subscribe [::i18n-subs/tr]) error? (subscribe [::api-subs/cloud-entry-point-error?])] 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..a3d845f01 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 @@ -3,6 +3,9 @@ (def dictionary {:en { + :please-click-to-proceed "Please click here to proceed" + :group-name-validation-error "Group name should start with an alphanumeric character. Space, dash and underscore characters are allowed but not at the end of the group name." + :group-descr-validation-error "Description should not be an empty string." :stop-deployment-remove-volumes "Remove volumes" :stop-deployment-remove-images "Remove images" :stop-deployment-remove-opts-require-ne-2.19 "The selected options require NuvlaEdge version 2.19 or higher to be taken into account." @@ -1207,6 +1210,9 @@ } :fr { + :please-click-to-proceed "Veuillez cliquer ici pour continuer" + :group-name-validation-error "Le nom du groupe doit commencer par un caractère alphanumérique. Les espaces, les tirets et les traits de soulignement sont autorisés, mais pas à la fin du nom de groupe." + :group-descr-validation-error "La description ne doit pas être une chaîne de caractères vide." :stop-deployment-remove-volumes "Supprimer les volumes" :stop-deployment-remove-images "Supprimer les images" :stop-deployment-remove-opts-require-ne-2.19 "Les options sélectionnées nécessitent la version 2.19 ou supérieure de NuvlaEdge pour être prises en compte." diff --git a/code/src/cljs/sixsq/nuvla/ui/db/spec.cljs b/code/src/cljs/sixsq/nuvla/ui/db/spec.cljs index 3cbde4c3b..c461b150d 100644 --- a/code/src/cljs/sixsq/nuvla/ui/db/spec.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/db/spec.cljs @@ -27,6 +27,7 @@ [sixsq.nuvla.ui.pages.edges-detail.spec :as edges-detail] [sixsq.nuvla.ui.pages.edges.spec :as edges] [sixsq.nuvla.ui.pages.profile.spec :as profile] + [sixsq.nuvla.ui.pages.groups.spec :as groups] [sixsq.nuvla.ui.routing.router :refer [router]] [sixsq.nuvla.ui.session.spec :as session])) @@ -69,4 +70,5 @@ resource-log/defaults deployment-sets/defaults deployment-sets-detail/defaults + groups/defaults {:router router})) diff --git a/code/src/cljs/sixsq/nuvla/ui/main/spec.cljs b/code/src/cljs/sixsq/nuvla/ui/main/spec.cljs index b4eb4f0b9..5e3ed330f 100644 --- a/code/src/cljs/sixsq/nuvla/ui/main/spec.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/main/spec.cljs @@ -107,9 +107,13 @@ :label-kw :infra-service-short :icon icons/i-cloud :order 80} + "groups" {:key routes/groups + :label-kw :groups + :icon icons/i-users + :order 90} "api" {:key routes/api :label-kw :api :icon icons/i-code - :order 90}} + :order 100}} ::open-modal nil ::stripe nil}) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/callback/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/callback/views.cljs new file mode 100644 index 000000000..2bef03f9c --- /dev/null +++ b/code/src/cljs/sixsq/nuvla/ui/pages/callback/views.cljs @@ -0,0 +1,20 @@ +(ns sixsq.nuvla.ui.pages.callback.views + (:require [clojure.string :as str] + [re-frame.core :refer [dispatch subscribe]] + [sixsq.nuvla.ui.common-components.i18n.subs :as i18n-subs] + [sixsq.nuvla.ui.main.components :as main-components] + [sixsq.nuvla.ui.main.subs :as main-subs] + [sixsq.nuvla.ui.utils.semantic-ui :as ui] + [sixsq.nuvla.ui.routing.subs :as route-subs] + [sixsq.nuvla.ui.utils.semantic-ui-extensions :as uix])) + +(defn CallbackView + [{{:keys [callback-url]} :query-params :as f}] + (let [tr @(subscribe [::i18n-subs/tr])] + [:div {:style {:display :flex + :justify-content :center}} + [ui/Button {:size "large" + :primary true + :href callback-url + :data-reitit-handle-click "false"} + [:b (tr [:please-click-to-proceed])]]])) 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..13c0e30fd --- /dev/null +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs @@ -0,0 +1,98 @@ +(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.pages.groups.spec :as 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 #(do (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}]) + (dispatch [::get-pending-invitations group-id])) + 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-pending-invitations + (fn [{db :db} [_ group-id]] + (let [on-success #(dispatch [::set-pending-invitations %])] + {:db (assoc db ::spec/pending-invitations nil) + ::cimi-api-fx/operation + [group-id "get-pending-invitations" on-success :on-error #()]}))) + +(reg-event-db + ::set-pending-invitations + (fn [db [_ pending-invitations]] + (assoc db ::spec/pending-invitations pending-invitations))) + +(reg-event-fx + ::revoke + (fn [_ [_ {group-id :id :as _group} invited-email]] + (let [on-success #(do (dispatch [::messages-events/add + {:header "Invitation successfully revoked" + :content (str "Invitation successfully revoked for " + invited-email " from " group-id ".") + :type :info}]) + (dispatch [::get-pending-invitations group-id]))] + {::cimi-api-fx/operation [group-id "revoke-invitation" on-success + :data {:email invited-email}]}))) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/spec.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/spec.cljs new file mode 100644 index 000000000..0c95111ad --- /dev/null +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/spec.cljs @@ -0,0 +1,4 @@ +(ns sixsq.nuvla.ui.pages.groups.spec + (:require [clojure.spec.alpha :as s])) + +(def defaults {::pending-invitations nil}) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/subs.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/subs.cljs new file mode 100644 index 000000000..0acd7a162 --- /dev/null +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/subs.cljs @@ -0,0 +1,64 @@ +(ns sixsq.nuvla.ui.pages.groups.subs + (:require [clojure.string :as str] + [re-frame.core :refer [reg-sub]] + [sixsq.nuvla.ui.pages.groups.spec :as spec] + [sixsq.nuvla.ui.session.subs :as session-subs] + [sixsq.nuvla.ui.utils.general :as general-utils])) + +(reg-sub + ::pending-invitations + :-> ::spec/pending-invitations) + +(reg-sub + ::indexed-groups + :<- [::session-subs/groups] + :<- [::session-subs/peers] + (fn [[groups peers]] + (map (fn [{:keys [id name users] :as _group}] + {:id id + :keywords (remove nil? + (concat + [id name] + users + (map #(get peers %) users)))}) + groups))) + + +(defn matching-group + [pattern {:keys [keywords]}] + (some #(re-matches pattern %) keywords)) + +(defn filter-groups + [allowed-ids group] + (let [children (:children group) + filtered-children (when children + (->> children + (map #(filter-groups allowed-ids %)) + (remove nil?)))] + ;; keep this group if its id is allowed OR it has any kept children + (when (or (allowed-ids (:id group)) + (seq filtered-children)) + (cond-> (assoc group :children filtered-children) + (empty? filtered-children) (dissoc :children))))) + +(defn filter-groups-tree + [allowed-ids groups] + (->> groups + (map #(filter-groups allowed-ids %)) + (remove nil?))) + + +(reg-sub + ::filter-groups-hierarchy + :<- [::indexed-groups] + :<- [::session-subs/groups-hierarchies] + (fn [[indexed-groups groups-hierarchies] [_ search]] + (let [groups-hierarchies (filter (fn [root-group] (not (#{"group/nuvla-user" "group/nuvla-nuvlabox" "group/nuvla-anon" "group/nuvla-vpn"} (:id root-group)))) groups-hierarchies)] + (if (str/blank? search) + groups-hierarchies + (let [pattern (re-pattern (str "(?i).*" (general-utils/regex-escape search) ".*")) + allowed-ids (->> indexed-groups + (keep #(when (matching-group pattern %) (:id %))) + set)] + (filter-groups-tree allowed-ids groups-hierarchies)) + )))) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs new file mode 100644 index 000000000..eacb3a009 --- /dev/null +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs @@ -0,0 +1,456 @@ +(ns sixsq.nuvla.ui.pages.groups.views + (: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.i18n.subs :as i18n-subs] + [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.pages.groups.subs :as 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])) + +(defn acceptable-group-name? + [group-name] + (and (string? group-name) + (re-matches #"^([a-zA-Z0-9]([a-zA-Z0-9\s_-]*[a-zA-Z0-9])?)?$" group-name))) + +(s/def ::group-name acceptable-group-name?) +(s/def ::group-description us/nonblank-string) + +(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 RevokeInvitationButton + [group invited-email] + (let [group-name (or (:name group) (:id group))] + [ConfirmActionModal {:on-confirm #(dispatch [::events/revoke group invited-email]) + :header "Revoke invitation" + :Content [:span "Do you want to revoke the invitation of " [:b invited-email] " from " [:b group-name] " group?"] + :Icon [icons/TrashIcon]}])) + +(defn GroupMember + [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 + (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] + (let [peers (subscribe [::session-subs/peers-options]) + peers-opts (r/atom @peers)] + (fn [add-user + {:keys [fluid placeholder] + :or {fluid false + placeholder ""}} + members] + (let [used-principals (set members)] + [ui/Dropdown {:placeholder placeholder + :fluid fluid + :allow-additions true + :on-add-item (ui-callback/value + #(swap! peers-opts conj {:key %, :value %, :text %})) + :search true + :value @add-user + :on-change (ui-callback/value #(reset! add-user %)) + :options (remove + #(used-principals (:key %)) + @peers-opts) + :selection true + :style {:width "250px"} + :upward false}])))) + +(defn InviteInput + [{:keys [id] :as _group}] + (let [tr (subscribe [::i18n-subs/tr]) + reset-key (r/atom (random-uuid)) + invite-user (r/atom "") + invite-fn #(do + (when-not (str/blank? @invite-user) + (dispatch [::events/invite-to-group id @invite-user]) + (reset! reset-key (random-uuid)) + (reset! invite-user "")))] + (fn [group] + (when (utils-general/can-operation? "invite" group) + ^{:key @reset-key} + [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) + :default-value @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 + (str/join " " [(when-not (s/valid? ::group-name @group-name) (@tr [:group-name-validation-error])) + (when-not (s/valid? ::group-description @group-desc) (@tr [:group-descr-validation-error]))])]] + (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 EditGroupButton + [{:keys [name description] :as group}] + (let [tr (subscribe [::i18n-subs/tr]) + show? (r/atom false) + group-name (r/atom name) + group-desc (r/atom description) + validate? (r/atom false) + close-fn #(reset! show? false)] + (fn [_group] + (let [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 + [icons/PencilIcon {:on-click #(reset! show? true) + :style {:cursor :pointer}}])} + [uix/ModalHeader {:header "Edit group"}] + [ui/ModalContent + [ui/Message {:hidden (not (and @validate? (not form-valid?))) + :error true} + [ui/MessageHeader (@tr [:validation-error])] + [ui/MessageContent + (str/join " " [(when-not (s/valid? ::group-name @group-name) (@tr [:group-name-validation-error])) + (when-not (s/valid? ::group-description @group-desc) (@tr [:group-descr-validation-error]))])]] + [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 [:save])) + :primary true + :disabled (and @validate? (not form-valid?)) + :on-click #(if (not form-valid?) + (reset! validate? true) + (do + (reset! show? false) + (dispatch + [::events/edit-group (assoc group + :name @group-name + :description @group-desc)])))}]]])))) + +(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 " " (when editable? + [EditGroupButton group]) + [ui/HeaderSubheader description " (" id ")"]]] + (when (utils-general/can-operation? "add-subgroup" group) + [AddGroupButton {:header "Add Subgroup" + :parent-group group}])] + [ui/Header {:as :h3} "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 GroupPendingInvitations + [group] + (let [pending-invitations (subscribe [::subs/pending-invitations (:id group)])] + (dispatch [::events/get-pending-invitations (:id group) pending-invitations]) + (fn [group] + [:<> + [ui/Header {:as :h3} "Pending invitations"] + (if (empty? @pending-invitations) + [uix/MsgNoItemsToShow "No pending invitations"] + [ui/ListSA {:divided true :vertical-align "middle"} + (for [pi @pending-invitations] + ^{:key (:invited-email pi)} + [ui/ListItem + [ui/ListContent {:floated :right} + (when (utils-general/can-operation? "revoke-invitation" group) + [RevokeInvitationButton group (:invited-email pi)])] + [ui/ListContent {:style {:display :flex :align-items :flex-end}} + [ui/IconGroup + [ui/Icon {:className icons/i-user :size "large"}]] + (:invited-email pi)] + ])])]))) + +(defn Group + [{: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 {: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 + [selected-group] + (let [tr (subscribe [::i18n-subs/tr]) + search (r/atom "")] + (fn [selected-group] + (let [filtered-groups-hierarch @(subscribe [::subs/filter-groups-hierarchy @search])] + [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])}]] + [ui/Input + {:style {:width "100%"} + :placeholder (str (@tr [:search]) "...") + :icon "search" + :default-value @search + :on-change (ui-callback/input-callback #(reset! search %))}] + (if (seq filtered-groups-hierarch) + [ui/ListSA {:selection true} + (for [group-hierarchy (sort-by (juxt :id :name) filtered-groups-hierarch)] + ^{:key (:id group-hierarchy)} + [Group group-hierarchy selected-group])] + [uix/MsgNoItemsToShow [uix/TR "No groups found"]])])))) + +(defn GroupsViewPage + [{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] + (when (utils-general/can-operation? "get-pending-invitations" selected-group) + ^{:key (:id selected-group)} + [GroupPendingInvitations 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 7a6871468..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,73 +63,6 @@ 1)) -(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 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]) @@ -1264,206 +1197,6 @@ [ui/TableCell {:width 5} [:b (str/capitalize (@tr [:balance]))]] [ui/TableCell {:width 11} (format-currency currency balance)]]]]])))) - -(defn DropdownPrincipals - [_add-user _opts _members] - (let [peers (subscribe [::session-subs/peers-options]) - peers-opts (r/atom @peers)] - (fn [add-user - {:keys [fluid placeholder] - :or {fluid false - placeholder ""}} - members] - (let [used-principals (set members)] - [ui/Dropdown {:placeholder placeholder - :fluid fluid - :allow-additions true - :on-add-item (ui-callback/value - #(swap! peers-opts conj {:key %, :value %, :text %})) - :search true - :value @add-user - :on-change (ui-callback/value #(reset! add-user %)) - :options (remove - #(used-principals (:key %)) - @peers-opts) - :selection true - :style {:width "250px"} - :upward false}])))) - - -(defn GroupMember - [id principal members editable?] - (let [principal-name (subscribe [::session-subs/resolve-principal 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))}])]]])) - - -(defn GroupMembers - [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)] - (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]]))) - - - -(defn Group - [] - (let [collapsed (r/atom true)] - (fn [{:keys [id name description children] :as _group}] - [ui/ListItem {:on-click #(do (swap! collapsed not) - (.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])])]]))) - - -(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] - ^{:key (:id group-hierarchy)} - [Group group-hierarchy])]])) - -(defn Groups - [] - (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])]])))) - (defn MsgNoSubscription [] [uix/MsgNoItemsToShow [uix/TR :no-subscription-information]]) @@ -1584,25 +1317,6 @@ [] [Billing]) - -(defn TabMenuGroups - [] - (let [tr (subscribe [::i18n-subs/tr])] - [:span (str/capitalize (@tr [:groups]))])) - - -(defn GroupsPane - [] - (let [device (subscribe [::main-subs/device])] - [ui/Grid {:columns (grid-columns @device) - :stackable true - :padded true - :centered true} - [ui/GridRow {:columns (grid-columns @device)} - [ui/GridColumn - [Groups]]]])) - - (defn TabMenuDetails [] (let [tr (subscribe [::i18n-subs/tr])] @@ -1631,10 +1345,6 @@ :key :billing :icon icons/i-credit-card} :render #(r/as-element [BillingPane])} - {:menuItem {:content (r/as-element [TabMenuGroups]) - :key :groups - :icon icons/i-users} - :render #(r/as-element [GroupsPane])} {:menuItem {:content (r/as-element [TabMenuDetails]) :key :details :icon icons/i-info} @@ -1673,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 1ca492539..752cfdc51 100644 --- a/code/src/cljs/sixsq/nuvla/ui/routing/router.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/routing/router.cljs @@ -6,7 +6,8 @@ [reitit.frontend.easy :as rfe] [reitit.frontend.history :as rfh] [sixsq.nuvla.ui.app.view :refer [LayoutAuthentication - LayoutPage]] + LayoutPage + LayoutCallback]] [sixsq.nuvla.ui.common-components.notifications.views :refer [notifications-view]] [sixsq.nuvla.ui.config :refer [base-path]] [sixsq.nuvla.ui.pages.about.views :refer [About]] @@ -24,6 +25,7 @@ [sixsq.nuvla.ui.pages.edges.views :refer [DetailedViewPage edges-view]] [sixsq.nuvla.ui.pages.edges.views-cluster :as views-cluster] [sixsq.nuvla.ui.pages.profile.views :refer [profile]] + [sixsq.nuvla.ui.pages.groups.views :refer [GroupsViewPage]] [sixsq.nuvla.ui.pages.welcome.views :refer [home-view]] [sixsq.nuvla.ui.routing.events :as events] [sixsq.nuvla.ui.routing.routes :as routes] @@ -32,6 +34,7 @@ [sixsq.nuvla.ui.session.set-password-views :as set-password-views] [sixsq.nuvla.ui.session.sign-in-views :as sign-in-views] [sixsq.nuvla.ui.session.sign-up-views :as sign-up-views] + [sixsq.nuvla.ui.pages.callback.views :refer [CallbackView]] [sixsq.nuvla.ui.unknown-resource :refer [UnknownResource]])) (defn- create-route-name @@ -60,6 +63,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 @@ -121,6 +140,7 @@ cloud-routes deployment-routes deployment-group-routes + groups-routes ["sign-up" {:name ::routes/sign-up :layout #'LayoutAuthentication @@ -151,6 +171,10 @@ :layout #'LayoutPage :view #'About :link-text "About"}] + ["callback" + {:name ::routes/callback + :layout #'LayoutCallback + :view #'CallbackView}] ["welcome" {:name ::routes/home :link-text "home"}] diff --git a/code/src/cljs/sixsq/nuvla/ui/routing/routes.cljs b/code/src/cljs/sixsq/nuvla/ui/routing/routes.cljs index 31470d86b..7c7295d79 100644 --- a/code/src/cljs/sixsq/nuvla/ui/routing/routes.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/routing/routes.cljs @@ -49,5 +49,8 @@ (def api ::api) (def api-slashed ::api-slashed) (def api-sub-page ::api-sub-page) +(def groups ::groups) +(def groups-details ::groups-details) (def profile ::profile) +(def callback ::callback) (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)) diff --git a/code/src/cljs/sixsq/nuvla/ui/utils/semantic_ui_extensions.cljs b/code/src/cljs/sixsq/nuvla/ui/utils/semantic_ui_extensions.cljs index 97922c424..e455b5778 100644 --- a/code/src/cljs/sixsq/nuvla/ui/utils/semantic_ui_extensions.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/utils/semantic_ui_extensions.cljs @@ -371,7 +371,9 @@ editable? validate-form? type input-help-msg style options input-extra-options] :or {editable? true, spec any?, type :input show-pencil? true}}] - (let [validate? (boolean (or @local-validate? validate-form?)) + (let [validate? (if (some? validate-form?) + validate-form? + @local-validate?) error? (and validate? (not (s/valid? spec default-value))) input-cbk (ui-callback/input-callback #(let [text (when-not (str/blank? %) %)] @@ -447,22 +449,17 @@ (defn TableRowField [_name & {:keys [_key _placeholder _default-value _spec _on-change _on-validation _style _required? _editable? _validate-form? _type _help-popup]}] - (let [local-validate? (r/atom false)] - (fn [name & {:keys [key _placeholder default-value spec _on-change on-validation - required? editable? validate-form? _type help-popup _style] - :or {editable? true, spec any?} - :as options}] - (let [validate? (boolean (or @local-validate? validate-form?)) - error? (and validate? (not (s/valid? spec default-value)))] - (when on-validation - (dispatch [on-validation key error?])) - [ui/TableRow - [ui/TableCell {:collapsing true} - [FieldLabel {:name name - :required? (and editable? required?) - :help-popup help-popup}]] - ^{:key (or key name)} - [TableRowCell options]])))) + (fn [name & {:keys [key _placeholder _default-value spec _on-change _on-validation + required? editable? _validate-form? _type help-popup _style] + :or {editable? true, spec any?} + :as options}] + [ui/TableRow + [ui/TableCell {:collapsing true} + [FieldLabel {:name name + :required? (and editable? required?) + :help-popup help-popup}]] + ^{:key (or key name)} + [TableRowCell options]])) (defn LinkIcon [{:keys [name on-click color class aria-label]}]